diff --git a/index.js b/index.js
index 12aae7c7..4a4f0fe8 100644
--- a/index.js
+++ b/index.js
@@ -19,7 +19,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)}`)
@@ -27,8 +27,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)
}
@@ -73,7 +86,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)}`)
@@ -81,7 +94,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
+ 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
@@ -585,17 +611,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 b54f81bb..22ee4417 100644
--- a/lib/commentmessage.js
+++ b/lib/commentmessage.js
@@ -1,31 +1,32 @@
-module.exports = `* Run on: \` <%= new Date() %> \`
+module.exports = `* Run on: \`<%= new Date() %>\`
-* Number of repos that were considered: \`<%= Object.keys(it.reposProcessed).length %> \`
+* Number of repos that were 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.changeSections || it.changeSections.length === 0) { %>
-### Breakdown of errors
+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 %>
- <% }) %>
- <% }) %>
+
+Errors by repo
+
+<% Object.keys(it.errors).forEach(function(repo) { %>
+**<%= repo %>**
+
+<% it.errors[repo].forEach(function(err) { %>* <%= err.msg %>
+<% }) %>
+<% }) %>
+
+
<% } %>`
diff --git a/lib/plugins/custom_properties.js b/lib/plugins/custom_properties.js
index 35f0144d..0dc3c340 100644
--- a/lib/plugins/custom_properties.js
+++ b/lib/plugins/custom_properties.js
@@ -12,24 +12,7 @@ module.exports = class CustomProperties extends Diffable {
// Force all names to lowercase to avoid comparison issues.
normalizeEntries () {
- this.entries = this.entries.reduce((normalizedEntries, entry) => {
- if (!entry || typeof entry !== 'object') {
- return normalizedEntries
- }
-
- const entryName = entry.name || entry.property_name
-
- if (typeof entryName !== 'string') {
- return normalizedEntries
- }
-
- normalizedEntries.push({
- name: entryName.toLowerCase(),
- value: entry.value
- })
-
- return normalizedEntries
- }, [])
+ this.entries = this.normalize(this.entries)
}
async find () {
@@ -52,24 +35,16 @@ module.exports = class CustomProperties extends Diffable {
// Force all names to lowercase to avoid comparison issues.
normalize (properties) {
- return properties.reduce((normalizedProperties, property) => {
- if (!property || typeof property !== 'object') {
- return normalizedProperties
- }
-
- const propertyName = property.property_name || property.name
-
- if (typeof propertyName !== 'string') {
- return normalizedProperties
- }
-
- normalizedProperties.push({
- name: propertyName.toLowerCase(),
- value: property.value
- })
-
- return normalizedProperties
- }, [])
+ return properties
+ .map(({ property_name: propertyName, name, value }) => ({
+ name: propertyName ?? name,
+ value
+ }))
+ .filter(({ name }) => name != null)
+ .map(({ name, value }) => ({
+ name: name.toLowerCase(),
+ value
+ }))
}
comparator (existing, attrs) {
diff --git a/lib/settings.js b/lib/settings.js
index 919e0185..920dc4bb 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -10,15 +10,486 @@ const env = require('./env')
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
+const COMMENT_LIMIT = 55536
const yaml = require('js-yaml')
+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
+}
+
+function isEmptyChange (action) {
+ if (!action) return true
+ const { additions, deletions, modifications } = action
+ if (additions === null && deletions === null && modifications === null) return true
+ return isDeepEmpty(additions) && isDeepEmpty(deletions) && isDeepEmpty(modifications)
+}
+
+/**
+ * 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.
+ */
+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.name) changed.add(e.name) })
+ return changed
+ }
+ if (!prEntries || !Array.isArray(prEntries)) {
+ // All base entries are deleted
+ baseEntries.forEach(e => { if (e.name) changed.add(e.name) })
+ return changed
+ }
+ // Check for added or modified entries
+ for (const prEntry of prEntries) {
+ if (!prEntry.name) continue
+ const baseEntry = baseEntries.find(b => b.name === prEntry.name)
+ if (!baseEntry || JSON.stringify(baseEntry) !== JSON.stringify(prEntry)) {
+ changed.add(prEntry.name)
+ }
+ }
+ // Check for deleted entries
+ for (const baseEntry of baseEntries) {
+ if (!baseEntry.name) continue
+ if (!prEntries.find(p => p.name === baseEntry.name)) {
+ changed.add(baseEntry.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
+}
+
+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()
@@ -42,10 +513,14 @@ 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 {
+ settings.subOrgConfigs = await settings.getSubOrgConfigs()
+ settings.trackChangedReposFromSubOrgConfigs()
+
// Apply org-level settings (e.g., rulesets) first, matching syncAll behavior
await settings.updateOrg()
@@ -91,13 +566,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.
@@ -129,6 +605,41 @@ class Settings {
this.mergeDeep = new MergeDeep(this.log, this.github, [], this.configvalidators, this.overridevalidators)
}
+ 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 : []
+ }
+
+ 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()
@@ -203,6 +714,52 @@ class Settings {
})
})
+ // Filter results to only show PR-introduced changes (not pre-existing drift)
+ 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') {
+ // For org-level rulesets: only include rulesets whose config 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) {
+ // Repos whose override files changed are always relevant
+ if (this.changedRepoNames && this.changedRepoNames.has(res.repo)) {
+ return true
+ }
+
+ // Repo-level rulesets come from override files (not global config.rulesets)
+ // so don't compare against org-level rulesets — just filter them as drift
+ // when no override files changed for this repo
+ if (pluginSection === 'rulesets') {
+ return false
+ }
+
+ // For other repo-level plugins: check if the global config section changed
+ 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
// Different logic
const stats = {
@@ -232,68 +789,87 @@ class Settings {
stats.errors[res.repo] = []
}
stats.errors[res.repo].push(res.action)
- } else if (!(res.action?.additions === null && res.action?.deletions === null && res.action?.modifications === null)) {
+ } 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
- }
- 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) {
- return `${x}`
+ const pluginSectionList = renderChangeSections(stats.changeSections)
+
+ const errorRepos = Object.keys(stats.errors)
+ const errorSection = errorRepos.length === 0
+ ? '### Errors\n`None`'
+ : `### Errors\n\n⚠️ Errors — ${errorRepos.length} repo(s) affected
\n\n${
+ errorRepos.map(repo =>
+ `**${repo}**:\n${stats.errors[repo].map(e => `* ${e.msg}`).join('\n')}`
+ ).join('\n\n')
+ }\n\n `
+
+ const allSections = stats.changeSections.length === 0
+ ? ['_No changes to apply._', errorSection]
+ : [...pluginSectionList, errorSection]
+
+ 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`
+
+ const HEADER_OVERHEAD = 80
+ const BODY_LIMIT = COMMENT_LIMIT - HEADER_OVERHEAD
+
+ const pages = []
+ let currentChunks = []
+ let currentLength = 0
+
+ for (const section of allSections) {
+ const sectionLength = section.length + 2
+ if (currentChunks.length > 0 && currentLength + sectionLength > BODY_LIMIT) {
+ pages.push(currentChunks.join('\n\n'))
+ currentChunks = [section]
+ currentLength = sectionLength
} 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)} |
`
+ currentChunks.push(section)
+ currentLength += sectionLength
}
- }, table)}
-`
+ }
+ if (currentChunks.length > 0) {
+ pages.push(currentChunks.join('\n\n'))
+ }
+ const totalPages = pages.length
const pullRequest = payload.check_run.check_suite.pull_requests[0]
- await this.github.rest.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 header = makeHeader(i + 1, totalPages)
+ const body = header + pages[i]
+ const safeBody = truncateWithSuffix(body, COMMENT_LIMIT, '... (too many changes to report)')
+ await this.github.rest.issues.createComment({
+ owner: payload.repository.owner.login,
+ repo: payload.repository.name,
+ issue_number: pullRequest.number,
+ body: safeBody
+ })
+ }
}
const params = {
@@ -305,7 +881,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)')
}
}
@@ -323,6 +899,9 @@ ${this.results.reduce((x, y) => {
if (rulesetsConfig) {
const RulesetsPlugin = Settings.PLUGINS.rulesets
return 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)
})
}
@@ -984,13 +1563,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`)
@@ -1012,3 +1584,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/repos.json b/schema/dereferenced/repos.json
index 9213456a..7eacb4bd 100644
--- a/schema/dereferenced/repos.json
+++ b/schema/dereferenced/repos.json
@@ -1563,7 +1563,7 @@
"actor_id": {
"type": "integer",
"nullable": true,
- "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, and `Team` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories."
+ "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, `Team`, and `User` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories."
},
"actor_type": {
"type": "string",
@@ -1572,7 +1572,8 @@
"OrganizationAdmin",
"RepositoryRole",
"Team",
- "DeployKey"
+ "DeployKey",
+ "User"
],
"description": "The type of actor that can bypass a ruleset."
},
diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json
index 8698b201..5279f02c 100644
--- a/schema/dereferenced/settings.json
+++ b/schema/dereferenced/settings.json
@@ -308,7 +308,7 @@
}
},
"force_create": {
- "description": "Force create the repository even if it already exists",
+ "description": "Force create the repository",
"type": "boolean"
},
"template": {
@@ -440,15 +440,6 @@
"notifications_disabled"
]
},
- "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"
- },
"parent_team_id": {
"type": "integer",
"description": "The ID of a team to set as the parent team."
@@ -774,7 +765,7 @@
"actor_id": {
"type": "integer",
"nullable": true,
- "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, and `Team` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories."
+ "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, `Team`, and `User` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories."
},
"actor_type": {
"type": "string",
@@ -783,7 +774,8 @@
"OrganizationAdmin",
"RepositoryRole",
"Team",
- "DeployKey"
+ "DeployKey",
+ "User"
],
"description": "The type of actor that can bypass a ruleset."
},
@@ -2286,7 +2278,7 @@
}
},
"force_create": {
- "description": "Force create the repository even if it already exists",
+ "description": "Force create the repository",
"type": "boolean"
},
"template": {
@@ -2720,7 +2712,7 @@
"actor_id": {
"type": "integer",
"nullable": true,
- "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, and `Team` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories."
+ "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, `Team`, and `User` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories."
},
"actor_type": {
"type": "string",
@@ -2729,7 +2721,8 @@
"OrganizationAdmin",
"RepositoryRole",
"Team",
- "DeployKey"
+ "DeployKey",
+ "User"
],
"description": "The type of actor that can bypass a ruleset."
},
diff --git a/schema/dereferenced/suborgs.json b/schema/dereferenced/suborgs.json
index 0267bf7a..601b32be 100644
--- a/schema/dereferenced/suborgs.json
+++ b/schema/dereferenced/suborgs.json
@@ -1597,7 +1597,7 @@
"actor_id": {
"type": "integer",
"nullable": true,
- "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, and `Team` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories."
+ "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, `Team`, and `User` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories."
},
"actor_type": {
"type": "string",
@@ -1606,7 +1606,8 @@
"OrganizationAdmin",
"RepositoryRole",
"Team",
- "DeployKey"
+ "DeployKey",
+ "User"
],
"description": "The type of actor that can bypass a ruleset."
},
diff --git a/test/unit/lib/handleResults.test.js b/test/unit/lib/handleResults.test.js
new file mode 100644
index 00000000..b138ec1c
--- /dev/null
+++ b/test/unit/lib/handleResults.test.js
@@ -0,0 +1,1165 @@
+/* eslint-disable no-undef */
+'use strict'
+
+const Settings = require('../../../lib/settings')
+const { isEmptyChange, isDeepEmpty } = require('../../../lib/settings')
+const env = require('../../../lib/env')
+
+// ---------------------------------------------------------------------------
+// Shared test helpers
+// ---------------------------------------------------------------------------
+
+function makeNopResult ({ repo = 'my-repo', plugin = 'labels', additions = ['label-a'], deletions = null, modifications = null } = {}) {
+ return {
+ type: 'NOP',
+ plugin,
+ repo,
+ endpoint: 'https://api.github.com/repos/test-org/my-repo/labels',
+ body: {},
+ action: {
+ additions,
+ deletions,
+ modifications
+ }
+ }
+}
+
+function makeErrorResult ({ repo = 'my-repo', plugin = 'labels', msg = 'Something went wrong' } = {}) {
+ return {
+ type: 'ERROR',
+ plugin,
+ repo,
+ endpoint: 'https://api.github.com/repos/test-org/my-repo/labels',
+ body: {},
+ action: {
+ additions: null,
+ deletions: null,
+ modifications: null,
+ msg
+ }
+ }
+}
+
+function buildContext (overrides = {}) {
+ const createComment = jest.fn().mockResolvedValue({})
+ const checksUpdate = jest.fn().mockResolvedValue({})
+
+ const context = {
+ payload: {
+ installation: { id: 1 },
+ check_run: {
+ id: 42,
+ check_suite: {
+ pull_requests: [{ number: 7 }]
+ }
+ },
+ repository: {
+ owner: { login: 'test-org' },
+ name: 'admin'
+ }
+ },
+ octokit: {
+ rest: {
+ issues: { createComment },
+ checks: { update: checksUpdate }
+ }
+ },
+ log: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ error: jest.fn()
+ },
+ ...overrides
+ }
+
+ return { context, createComment, checksUpdate }
+}
+
+function buildSettings (context, results = [], config = {}, baseConfig = null) {
+ const settings = new Settings(
+ /* nop */ true,
+ context,
+ { owner: 'test-org', repo: 'admin' },
+ /* config */ config,
+ /* ref */ 'main',
+ /* suborg */ null,
+ /* baseConfig */ baseConfig
+ )
+ settings.results = results
+ return settings
+}
+
+function getCommentBodies (createComment) {
+ expect(createComment).toHaveBeenCalled()
+ return createComment.mock.calls.map(([request]) => request.body)
+}
+
+function getCombinedCommentBody (createComment) {
+ return getCommentBodies(createComment).join('\n\n')
+}
+
+// ---------------------------------------------------------------------------
+// Restore env after each test
+// ---------------------------------------------------------------------------
+
+let originalCreatePrComment
+
+beforeEach(() => {
+ originalCreatePrComment = env.CREATE_PR_COMMENT
+ env.CREATE_PR_COMMENT = 'true'
+})
+
+afterEach(() => {
+ env.CREATE_PR_COMMENT = originalCreatePrComment
+})
+
+// ---------------------------------------------------------------------------
+// Test Plan
+// ---------------------------------------------------------------------------
+
+/*
+ * Code Summary
+ * ------------
+ * handleResults() is the final step of every sync flow. When nop=true it
+ * renders a human-readable markdown summary of all NopCommand results,
+ * optionally posts that summary as a PR comment via the GitHub Issues API, and
+ * always updates a check run via the Checks API with the Eta-rendered template.
+ *
+ * Primary concern: the two API calls receive correctly shaped bodies, edge
+ * cases (empty results, all-null actions, errors) are handled, and the
+ * CREATE_PR_COMMENT env flag is respected.
+ */
+
+describe('handleResults()', () => {
+ // -------------------------------------------------------------------------
+ // Test 1 — empty state
+ // -------------------------------------------------------------------------
+ describe('renders empty state without changes', () => {
+ it('PR comment body contains _No changes to apply._', async () => {
+ // Arrange
+ const { context, createComment } = buildContext()
+ const settings = buildSettings(context, [])
+
+ // Act
+ await settings.handleResults()
+
+ // Assert
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('_No changes to apply._')
+ })
+
+ it('check run summary contains "No changes to apply"', async () => {
+ // Arrange
+ const { context, checksUpdate } = buildContext()
+ const settings = buildSettings(context, [])
+
+ // Act
+ await settings.handleResults()
+
+ // Assert
+ expect(checksUpdate).toHaveBeenCalledTimes(1)
+ const summary = checksUpdate.mock.calls[0][0].output.summary
+ expect(summary).toMatch(/No changes to apply/i)
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 2 — PR comment contains / tags with a real result
+ // -------------------------------------------------------------------------
+ describe('PR comment body contains details tags', () => {
+ it('wraps per-plugin changes in a collapsible section', async () => {
+ // Arrange
+ const { context, createComment } = buildContext()
+ const result = makeNopResult({ repo: 'my-repo', plugin: 'labels', additions: ['bug', 'enhancement'] })
+ const settings = buildSettings(context, [result])
+
+ // Act
+ await settings.handleResults()
+
+ // Assert
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('')
+ expect(body).toContain('')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 3 — check run summary contains / tags
+ // -------------------------------------------------------------------------
+ describe('check run summary contains details tags', () => {
+ it('wraps changes section in collapsible HTML when there are results', async () => {
+ // Arrange
+ const { context, checksUpdate } = buildContext()
+ const result = makeNopResult({ repo: 'my-repo', plugin: 'labels', additions: ['bug'] })
+ const settings = buildSettings(context, [result])
+
+ // Act
+ await settings.handleResults()
+
+ // Assert
+ const summary = checksUpdate.mock.calls[0][0].output.summary
+ expect(summary).toContain('')
+ expect(summary).toContain('')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 4 — output stays under 55536 chars with 200 results
+ // -------------------------------------------------------------------------
+ describe('output stays under 55536 chars with many repos', () => {
+ it('each comment page and the summary are under the limit and are not truncated', async () => {
+ // Arrange
+ const { context, createComment, checksUpdate } = buildContext()
+
+ const REPOS = 40
+ const PLUGINS = ['labels', 'teams', 'collaborators', 'branches', 'environments']
+
+ const results = []
+ for (let r = 0; r < REPOS; r++) {
+ for (const plugin of PLUGINS) {
+ results.push(makeNopResult({ repo: `repo-${r}`, plugin, additions: ['item-a', 'item-b'] }))
+ }
+ }
+
+ const settings = buildSettings(context, results)
+
+ // Act
+ await settings.handleResults()
+
+ // Assert — paginated bodies
+ const bodies = getCommentBodies(createComment)
+ expect(bodies.length).toBeGreaterThan(0)
+ bodies.forEach(body => {
+ expect(body.length).toBeLessThanOrEqual(55536)
+ expect(body).not.toContain('too many changes to report')
+ })
+
+ // Assert — summary
+ const summary = checksUpdate.mock.calls[0][0].output.summary
+ expect(summary.length).toBeLessThan(55536)
+ expect(summary).not.toContain('too many changes to report')
+ })
+
+ it('truncates oversized PR comments without exceeding the limit', async () => {
+ const { context, createComment } = buildContext()
+ const result = makeNopResult({
+ repo: 'huge-repo',
+ plugin: 'labels',
+ additions: [{ name: 'huge-label', description: 'x'.repeat(60000) }]
+ })
+ const settings = buildSettings(context, [result])
+
+ await settings.handleResults()
+
+ const bodies = getCommentBodies(createComment)
+ bodies.forEach(body => {
+ expect(body.length).toBeLessThanOrEqual(55536)
+ })
+ expect(bodies.some(body => body.includes('too many changes to report'))).toBe(true)
+ })
+
+ it('truncates oversized check-run summaries without exceeding the limit', async () => {
+ const { context, checksUpdate } = buildContext()
+ const errorResult = makeErrorResult({
+ repo: 'broken-repo',
+ msg: 'x'.repeat(60000)
+ })
+ const settings = buildSettings(context, [errorResult])
+
+ await settings.handleResults()
+
+ const summary = checksUpdate.mock.calls[0][0].output.summary
+ expect(summary.length).toBeLessThanOrEqual(55536)
+ expect(summary).toContain('too many changes to report')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 5 — errors are wrapped in a collapsible section
+ // -------------------------------------------------------------------------
+ describe('errors are wrapped in collapsible section', () => {
+ it('body contains ⚠️ Errors heading and a element when there is an ERROR result', async () => {
+ // Arrange
+ const { context, createComment } = buildContext()
+ const result = makeErrorResult({ repo: 'broken-repo', msg: 'API rate limit exceeded' })
+ const settings = buildSettings(context, [result])
+
+ // Act
+ await settings.handleResults()
+
+ // Assert
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('⚠️ Errors')
+ expect(body).toContain('')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 6 — repos with all-null actions are omitted from output
+ // -------------------------------------------------------------------------
+ describe('repos with all-null actions are omitted from output', () => {
+ it('a result with additions/deletions/modifications all null does not appear in the PR comment body', async () => {
+ // Arrange
+ const { context, createComment } = buildContext()
+ const nullResult = makeNopResult({
+ repo: 'silent-repo',
+ plugin: 'labels',
+ additions: null,
+ deletions: null,
+ modifications: null
+ })
+ const settings = buildSettings(context, [nullResult])
+
+ // Act
+ await settings.handleResults()
+
+ // Assert
+ const body = getCombinedCommentBody(createComment)
+ // The repo name should not appear in the changes table
+ expect(body).not.toMatch(/silent-repo.*Add:/)
+ // There must be no changes section referencing this repo
+ expect(body).toContain('_No changes to apply._')
+ })
+
+ it('a result with empty-object/array action fields is filtered out', async () => {
+ // Arrange
+ const { context, createComment } = buildContext()
+ const emptyResult = makeNopResult({
+ repo: 'empty-repo',
+ plugin: 'labels',
+ additions: {},
+ deletions: [],
+ modifications: null
+ })
+ const settings = buildSettings(context, [emptyResult])
+
+ // Act
+ await settings.handleResults()
+
+ // Assert
+ const body = getCombinedCommentBody(createComment)
+ expect(body).not.toContain('empty-repo')
+ expect(body).toContain('_No changes to apply._')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 7 — org-level result rows display the admin repo
+ // -------------------------------------------------------------------------
+ describe('org-level labeling', () => {
+ it('shows the admin repo name instead of the org target in output', async () => {
+ // Arrange
+ const { context, createComment } = buildContext()
+ const orgResult = makeNopResult({
+ repo: 'test-org (org)',
+ plugin: 'rulesets',
+ additions: [{ name: 'require-pull-request-reviews' }]
+ })
+ const settings = buildSettings(context, [orgResult])
+
+ // Act
+ await settings.handleResults()
+
+ // Assert
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('admin')
+ expect(body).not.toContain('test-org (org)')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 8 — table-free rendering with trimmed changed fields
+ // -------------------------------------------------------------------------
+ describe('table-free rendering with trimmed changed fields', () => {
+ it('shows affected admin target, changed policy, and field-level ruleset diff', async () => {
+ const { context, createComment } = buildContext()
+
+ const baseConfig = {
+ rulesets: [
+ {
+ name: 'Agent Studio - Required Workflows',
+ conditions: { repository_name: { include: ['agent-*'] } }
+ }
+ ]
+ }
+ const prConfig = {
+ rulesets: [
+ {
+ name: 'Agent Studio - Required Workflows',
+ conditions: { repository_name: { include: ['mythapi-*'] } }
+ }
+ ]
+ }
+ const orgResult = {
+ type: 'NOP',
+ plugin: 'Rulesets',
+ repo: 'test-org (org)',
+ endpoint: '',
+ body: {},
+ action: {
+ additions: [],
+ deletions: [
+ {
+ name: 'Agent Studio - Required Workflows',
+ conditions: { repository_name: { include: ['agent-*'] } }
+ }
+ ],
+ modifications: [
+ {
+ name: 'Agent Studio - Required Workflows',
+ conditions: { repository_name: { include: ['mythapi-*'] } },
+ bypass_actors: [{ actor_id: 1 }]
+ }
+ ]
+ }
+ }
+
+ const settings = buildSettings(context, [orgResult], prConfig, baseConfig)
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('**Repos affected:** 1')
+ expect(body).toContain('Rulesets — 1 repo, 1 policy changed
')
+ expect(body).toContain('**admin**')
+ expect(body).not.toContain('| Repo |')
+ expect(body).not.toContain('')
+ expect(body).not.toContain('test-org (org)')
+ expect(body).toContain('- `Agent Studio - Required Workflows`')
+ expect(body).toContain(' - ~ `conditions.repository_name.include`')
+ expect(body).toContain(' - before: `agent-*`')
+ expect(body).toContain(' - after: `mythapi-*`')
+ expect(body).not.toContain('bypass_actors')
+ })
+
+ it('does not render config-changed rulesets absent from NOP actions', async () => {
+ const { context, createComment } = buildContext()
+ const baseConfig = {
+ rulesets: [
+ { name: 'Rule B', conditions: { repository_name: { include: ['agent-*'] } } },
+ { name: 'Rule D', enforcement: 'evaluate' }
+ ]
+ }
+ const prConfig = {
+ rulesets: [
+ { name: 'Rule B', conditions: { repository_name: { include: ['mythapi-*'] } } },
+ { name: 'Rule D', enforcement: 'active' }
+ ]
+ }
+ const orgResult = {
+ type: 'NOP',
+ plugin: 'Rulesets',
+ repo: 'test-org (org)',
+ endpoint: '',
+ body: {},
+ action: {
+ additions: [],
+ deletions: [{ name: 'Rule B', conditions: { repository_name: { include: ['agent-*'] } } }],
+ modifications: [{ name: 'Rule B', conditions: { repository_name: { include: ['mythapi-*'] } } }]
+ }
+ }
+ const settings = buildSettings(context, [orgResult], prConfig, baseConfig)
+
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('Rule B')
+ expect(body).not.toContain('Rule D')
+ expect(body).toContain('Rulesets — 1 repo, 1 policy changed
')
+ })
+
+ it('uses the same table-free trimmed details in the check-run summary', async () => {
+ const { context, checksUpdate } = buildContext()
+ const result = makeNopResult({
+ repo: 'my-repo',
+ plugin: 'labels',
+ additions: null,
+ modifications: [{ name: 'bug', color: 'blue' }]
+ })
+ const settings = buildSettings(context, [result])
+
+ await settings.handleResults()
+
+ const summary = checksUpdate.mock.calls[0][0].output.summary
+ expect(summary).toContain('Number of repos affected')
+ expect(summary).toContain('labels — 1 repo, 1 setting changed
')
+ expect(summary).toContain('**my-repo**')
+ expect(summary).toContain('- `bug`')
+ expect(summary).toContain(' - ~ `color`')
+ expect(summary).toContain(' - after: `blue`')
+ expect(summary).not.toContain('| Repo |')
+ expect(summary).not.toContain('')
+ })
+
+ it('pairs action diff entries by non-name identity fields', async () => {
+ const { context, createComment } = buildContext()
+ const result = makeNopResult({
+ repo: 'my-repo',
+ plugin: 'teams',
+ deletions: [{ login: 'admin-team', permission: 'pull' }],
+ modifications: [{ login: 'admin-team', permission: 'push' }]
+ })
+ const settings = buildSettings(context, [result])
+
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('- `admin-team`')
+ expect(body).toContain(' - ~ `permission`')
+ expect(body).toContain(' - before: `pull`')
+ expect(body).toContain(' - after: `push`')
+ })
+
+ it('prefers structured action fields over generic msg text', async () => {
+ const { context, createComment } = buildContext()
+ const result = {
+ type: 'NOP',
+ plugin: 'labels',
+ repo: 'my-repo',
+ endpoint: '',
+ body: {},
+ action: {
+ msg: 'Changes found',
+ additions: [{ name: 'security', color: 'red' }],
+ deletions: null,
+ modifications: null
+ }
+ }
+ const settings = buildSettings(context, [result])
+
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('security')
+ expect(body).toContain(' - + `color`: `red`')
+ expect(body).not.toContain('Changes found')
+ })
+
+ it('renders large object values as compact inline JSON', async () => {
+ const { context, createComment } = buildContext()
+ const result = makeNopResult({
+ repo: 'my-repo',
+ plugin: 'branches',
+ additions: [{
+ name: 'main',
+ required_workflows: [{ path: '.github/workflows/build.yml', ref: 'main' }]
+ }]
+ })
+ const settings = buildSettings(context, [result])
+
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('- `main`')
+ expect(body).toContain('required_workflows')
+ expect(body).toContain('"path"')
+ expect(body).toContain('.github/workflows/build.yml')
+ })
+
+ it('shows added and deleted fields within a matched modification', async () => {
+ const { context, createComment } = buildContext()
+ const result = {
+ type: 'NOP',
+ plugin: 'labels',
+ repo: 'my-repo',
+ endpoint: '',
+ body: {},
+ action: {
+ additions: null,
+ deletions: [{ name: 'bug', color: 'red', oldOnly: true }],
+ modifications: [{ name: 'bug', color: 'blue', newOnly: true }]
+ }
+ }
+ const settings = buildSettings(context, [result])
+
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain(' - ~ `color`')
+ expect(body).toContain(' - before: `red`')
+ expect(body).toContain(' - after: `blue`')
+ expect(body).toContain(' - + `newOnly`: `true`')
+ expect(body).toContain(' - - `oldOnly`: `true`')
+ })
+
+ it('detects nested value modifications beyond the display preview', async () => {
+ const { context, createComment } = buildContext()
+ const sharedPrefix = 'a'.repeat(240)
+ const result = {
+ type: 'NOP',
+ plugin: 'branches',
+ repo: 'my-repo',
+ endpoint: '',
+ body: {},
+ action: {
+ additions: null,
+ deletions: [{
+ name: 'main',
+ required_workflows: [{ path: `${sharedPrefix}-OLD.yml`, ref: 'main' }],
+ enforce_admins: false
+ }],
+ modifications: [{
+ name: 'main',
+ required_workflows: [{ path: `${sharedPrefix}-NEW.yml`, ref: 'main' }],
+ enforce_admins: true
+ }]
+ }
+ }
+ const settings = buildSettings(context, [result])
+
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain(' - ~ `required_workflows`')
+ expect(body).toContain('-OLD.yml')
+ expect(body).toContain('-NEW.yml')
+ })
+
+ it('does not hide non-identity fields whose value equals the target name', async () => {
+ const { context, createComment } = buildContext()
+ const result = makeNopResult({
+ repo: 'my-repo',
+ plugin: 'labels',
+ additions: [{ name: 'bug', description: 'bug', color: 'red' }]
+ })
+ const settings = buildSettings(context, [result])
+
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain(' - + `description`: `bug`')
+ expect(body).toContain(' - + `color`: `red`')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 9 — does not create PR comment when CREATE_PR_COMMENT is not true
+ // -------------------------------------------------------------------------
+ describe('does not create PR comment when CREATE_PR_COMMENT is not true', () => {
+ it('createComment is never called when CREATE_PR_COMMENT is "false"', async () => {
+ // Arrange
+ env.CREATE_PR_COMMENT = 'false'
+ const { context, createComment, checksUpdate } = buildContext()
+ const result = makeNopResult()
+ const settings = buildSettings(context, [result])
+
+ // Act
+ await settings.handleResults()
+
+ // Assert
+ expect(createComment).not.toHaveBeenCalled()
+ // checks.update must still be called regardless
+ expect(checksUpdate).toHaveBeenCalledTimes(1)
+ })
+
+ it('createComment is never called when CREATE_PR_COMMENT is undefined', async () => {
+ // Arrange
+ env.CREATE_PR_COMMENT = undefined
+ const { context, createComment } = buildContext()
+ const result = makeNopResult()
+ const settings = buildSettings(context, [result])
+
+ // Act
+ await settings.handleResults()
+
+ // Assert
+ expect(createComment).not.toHaveBeenCalled()
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 9 — isDeepEmpty recursive detection
+ // -------------------------------------------------------------------------
+ describe('isDeepEmpty recursive detection', () => {
+ it('null/undefined are deep-empty', () => {
+ expect(isDeepEmpty(null)).toBe(true)
+ expect(isDeepEmpty(undefined)).toBe(true)
+ })
+
+ it('empty arrays and objects are deep-empty', () => {
+ expect(isDeepEmpty([])).toBe(true)
+ expect(isDeepEmpty({})).toBe(true)
+ })
+
+ it('nested empty structures are deep-empty', () => {
+ expect(isDeepEmpty({ entries: [] })).toBe(true)
+ expect(isDeepEmpty({ a: { b: [] } })).toBe(true)
+ expect(isDeepEmpty({ a: null, b: undefined, c: {} })).toBe(true)
+ expect(isDeepEmpty([{}, [], null])).toBe(true)
+ })
+
+ it('non-empty values are NOT deep-empty', () => {
+ expect(isDeepEmpty('text')).toBe(false)
+ expect(isDeepEmpty(42)).toBe(false)
+ expect(isDeepEmpty(false)).toBe(false)
+ expect(isDeepEmpty([1])).toBe(false)
+ expect(isDeepEmpty({ key: 'value' })).toBe(false)
+ })
+
+ it('partially-filled nested structures are NOT deep-empty', () => {
+ expect(isDeepEmpty({ entries: [{ name: 'x' }] })).toBe(false)
+ expect(isDeepEmpty({ a: null, b: 'content' })).toBe(false)
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 10 — isEmptyChange with deeply-nested empty structures
+ // -------------------------------------------------------------------------
+ describe('isEmptyChange with deeply-nested empty structures', () => {
+ it('action with { entries: [] } additions is considered empty', () => {
+ expect(isEmptyChange({ additions: { entries: [] }, deletions: null, modifications: null })).toBe(true)
+ })
+
+ it('action with nested empty objects across all fields is empty', () => {
+ expect(isEmptyChange({ additions: { a: {} }, deletions: { b: [] }, modifications: { c: null } })).toBe(true)
+ })
+
+ it('action with real content in additions is NOT empty', () => {
+ expect(isEmptyChange({ additions: { entries: [{ name: 'label-a' }] }, deletions: null, modifications: null })).toBe(false)
+ })
+
+ it('filters nested-empty results from PR comment output', async () => {
+ const { context, createComment } = buildContext()
+ const nestedEmptyResult = makeNopResult({
+ repo: 'nested-empty-repo',
+ plugin: 'labels',
+ additions: { entries: [] },
+ deletions: { items: [{}] },
+ modifications: null
+ })
+ const settings = buildSettings(context, [nestedEmptyResult])
+
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).not.toContain('nested-empty-repo')
+ expect(body).toContain('_No changes to apply._')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 11 — Base config filtering: org-level rulesets
+ // -------------------------------------------------------------------------
+ describe('base config filtering for org-level rulesets', () => {
+ it('only shows rulesets that changed between base and PR config', async () => {
+ const { context, createComment } = buildContext()
+
+ const baseConfig = {
+ rulesets: [
+ { name: 'Rule A', enforcement: 'active', conditions: { repository_name: { include: ['*'] } } },
+ { name: 'Rule B', enforcement: 'active', conditions: { repository_name: { include: ['agent-*'] } } },
+ { name: 'Rule C', enforcement: 'evaluate', conditions: { repository_name: { include: ['*'] } } }
+ ]
+ }
+ const prConfig = {
+ rulesets: [
+ { name: 'Rule A', enforcement: 'active', conditions: { repository_name: { include: ['*'] } } },
+ { name: 'Rule B', enforcement: 'active', conditions: { repository_name: { include: ['mythapi-*'] } } }, // changed!
+ { name: 'Rule C', enforcement: 'evaluate', conditions: { repository_name: { include: ['*'] } } }
+ ]
+ }
+
+ // NOP comparison found "changes" for all 3 rulesets (due to API drift)
+ const orgResult = {
+ type: 'NOP',
+ plugin: 'Rulesets',
+ repo: 'test-org (org)',
+ endpoint: '',
+ body: {},
+ action: {
+ additions: [],
+ deletions: [{ name: 'Rule B', conditions: { repository_name: { include: ['agent-*'] } } }],
+ modifications: [
+ { name: 'Rule A', bypass_actors: [{ actor_id: 1 }] },
+ { name: 'Rule B', conditions: { repository_name: { include: ['mythapi-*'] } } },
+ { name: 'Rule C', bypass_actors: [{ actor_id: 1 }] }
+ ]
+ }
+ }
+
+ const settings = buildSettings(context, [orgResult], prConfig, baseConfig)
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ // Rule B changed — should appear
+ expect(body).toContain('Rule B')
+ // Rule A and Rule C are unchanged in config — should NOT appear
+ expect(body).not.toContain('Rule A')
+ expect(body).not.toContain('Rule C')
+ })
+
+ it('shows all rulesets when no baseConfig is provided (fallback)', async () => {
+ const { context, createComment } = buildContext()
+
+ const orgResult = {
+ type: 'NOP',
+ plugin: 'Rulesets',
+ repo: 'test-org (org)',
+ endpoint: '',
+ body: {},
+ action: {
+ additions: [],
+ deletions: [],
+ modifications: [
+ { name: 'Rule A', bypass_actors: [{ actor_id: 1 }] },
+ { name: 'Rule B', conditions: { repository_name: { include: ['mythapi-*'] } } }
+ ]
+ }
+ }
+
+ // No baseConfig — should show everything (no filtering)
+ const settings = buildSettings(context, [orgResult], { rulesets: [] })
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('Rule A')
+ expect(body).toContain('Rule B')
+ })
+
+ it('filters out org result entirely when no rulesets changed', async () => {
+ const { context, createComment } = buildContext()
+
+ const sameRulesets = [
+ { name: 'Rule A', enforcement: 'active' },
+ { name: 'Rule B', enforcement: 'evaluate' }
+ ]
+ const baseConfig = { rulesets: sameRulesets }
+ const prConfig = { rulesets: sameRulesets }
+
+ const orgResult = {
+ type: 'NOP',
+ plugin: 'Rulesets',
+ repo: 'test-org (org)',
+ endpoint: '',
+ body: {},
+ action: {
+ additions: [],
+ deletions: [],
+ modifications: [
+ { name: 'Rule A', bypass_actors: [{ actor_id: 1 }] },
+ { name: 'Rule B', bypass_actors: [{ actor_id: 1 }] }
+ ]
+ }
+ }
+
+ const settings = buildSettings(context, [orgResult], prConfig, baseConfig)
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('_No changes to apply._')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 12 — Base config filtering: repo-level results
+ // -------------------------------------------------------------------------
+ describe('base config filtering for repo-level results', () => {
+ it('filters out repo results when their config section did not change', async () => {
+ const { context, createComment } = buildContext()
+
+ const baseConfig = {
+ rulesets: [
+ { name: 'Org Rule', conditions: { repository_name: { include: ['agent-*'] } } }
+ ],
+ labels: [{ name: 'bug', color: 'red' }]
+ }
+ const prConfig = {
+ rulesets: [
+ { name: 'Org Rule', conditions: { repository_name: { include: ['mythapi-*'] } } } // changed
+ ],
+ labels: [{ name: 'bug', color: 'red' }] // unchanged
+ }
+
+ // Repo-level labels result — labels section didn't change
+ const repoLabelsResult = makeNopResult({
+ repo: 'my-repo',
+ plugin: 'labels',
+ additions: ['stale-label']
+ })
+
+ // Org-level rulesets result — rulesets section DID change
+ const orgResult = {
+ type: 'NOP',
+ plugin: 'Rulesets',
+ repo: 'test-org (org)',
+ endpoint: '',
+ body: {},
+ action: {
+ additions: [],
+ deletions: [],
+ modifications: [{ name: 'Org Rule', conditions: { repository_name: { include: ['mythapi-*'] } } }]
+ }
+ }
+
+ const settings = buildSettings(context, [repoLabelsResult, orgResult], prConfig, baseConfig)
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ // Org rulesets should show
+ expect(body).toContain('Org Rule')
+ // Repo labels should NOT show (labels section unchanged)
+ expect(body).not.toContain('my-repo')
+ })
+
+ it('shows repo results when their config section DID change', async () => {
+ const { context, createComment } = buildContext()
+
+ const baseConfig = { labels: [{ name: 'bug', color: 'red' }] }
+ const prConfig = { labels: [{ name: 'bug', color: 'blue' }] } // changed!
+
+ const repoLabelsResult = makeNopResult({
+ repo: 'affected-repo',
+ plugin: 'labels',
+ additions: [],
+ modifications: [{ name: 'bug', color: 'blue' }]
+ })
+
+ const settings = buildSettings(context, [repoLabelsResult], prConfig, baseConfig)
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('affected-repo')
+ })
+
+ it('preserves ERROR results regardless of config filtering', async () => {
+ const { context, createComment } = buildContext()
+
+ const baseConfig = { labels: [{ name: 'bug', color: 'red' }] }
+ const prConfig = { labels: [{ name: 'bug', color: 'red' }] } // unchanged
+
+ const errorResult = makeErrorResult({ repo: 'error-repo', plugin: 'labels', msg: 'API failure' })
+
+ const settings = buildSettings(context, [errorResult], prConfig, baseConfig)
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('error-repo')
+ expect(body).toContain('API failure')
+ })
+
+ it('filters out repo-level rulesets even when org rulesets section changed', async () => {
+ const { context, createComment } = buildContext()
+
+ const baseConfig = {
+ rulesets: [{ name: 'Org Rule', conditions: { repository_name: { include: ['agent-*'] } } }]
+ }
+ const prConfig = {
+ rulesets: [{ name: 'Org Rule', conditions: { repository_name: { include: ['mythapi-*'] } } }]
+ }
+
+ // Repo-level rulesets result (from override file, not global config)
+ const repoRulesetsResult = {
+ type: 'NOP',
+ plugin: 'Rulesets',
+ repo: 'some-repo',
+ endpoint: '',
+ body: {},
+ action: {
+ additions: [],
+ deletions: [],
+ modifications: [{ name: 'repo-lvl-rule', enforcement: 'active' }]
+ }
+ }
+
+ const settings = buildSettings(context, [repoRulesetsResult], prConfig, baseConfig)
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ // Repo-level rulesets should be filtered (override file didn't change)
+ expect(body).not.toContain('some-repo')
+ })
+
+ it('keeps repo-level results when repo is in changedRepoNames', async () => {
+ const { context, createComment } = buildContext()
+
+ const baseConfig = { labels: [{ name: 'bug', color: 'red' }] }
+ const prConfig = { labels: [{ name: 'bug', color: 'red' }] } // unchanged globally
+
+ // Repo-level result for a repo whose override file changed
+ const repoResult = makeNopResult({
+ repo: 'changed-repo',
+ plugin: 'labels',
+ modifications: [{ name: 'bug', color: 'green' }]
+ })
+
+ const settings = buildSettings(context, [repoResult], prConfig, baseConfig)
+ // Simulate syncSelectedSettings — this repo had its override file changed
+ settings.changedRepoNames = new Set(['changed-repo'])
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('changed-repo')
+ })
+
+ it('filters repo-level rulesets but keeps repo in changedRepoNames', async () => {
+ const { context, createComment } = buildContext()
+
+ const baseConfig = {
+ rulesets: [{ name: 'Org Rule', enforcement: 'active' }]
+ }
+ const prConfig = {
+ rulesets: [{ name: 'Org Rule', enforcement: 'active' }]
+ }
+
+ // Two repos: one selected (override changed), one not
+ const selectedRepoResult = {
+ type: 'NOP',
+ plugin: 'Rulesets',
+ repo: 'selected-repo',
+ endpoint: '',
+ body: {},
+ action: {
+ additions: [],
+ deletions: [],
+ modifications: [{ name: 'repo-rule', enforcement: 'active' }]
+ }
+ }
+ const driftRepoResult = {
+ type: 'NOP',
+ plugin: 'Rulesets',
+ repo: 'drift-repo',
+ endpoint: '',
+ body: {},
+ action: {
+ additions: [],
+ deletions: [],
+ modifications: [{ name: 'other-rule', enforcement: 'evaluate' }]
+ }
+ }
+
+ const settings = buildSettings(context, [selectedRepoResult, driftRepoResult], prConfig, baseConfig)
+ settings.changedRepoNames = new Set(['selected-repo'])
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('selected-repo')
+ expect(body).not.toContain('drift-repo')
+ })
+
+ it('keeps repo-level results for repos affected by changed suborg files', async () => {
+ const { context, createComment } = buildContext()
+
+ const baseConfig = { labels: [{ name: 'bug', color: 'red' }] }
+ const prConfig = { labels: [{ name: 'bug', color: 'red' }] }
+
+ const repoResult = makeNopResult({
+ repo: 'suborg-repo',
+ plugin: 'labels',
+ modifications: [{ name: 'bug', color: 'green' }]
+ })
+
+ const settings = buildSettings(context, [repoResult], prConfig, baseConfig)
+ settings.setChangedConfigTargets([], [{ path: '.github/suborgs/engineering.yml' }])
+ settings.subOrgConfigs = {
+ 'suborg-repo': { source: '.github/suborgs/engineering.yml' },
+ 'other-repo': { source: '.github/suborgs/other.yml' }
+ }
+
+ settings.trackChangedReposFromSubOrgConfigs()
+ await settings.handleResults()
+
+ const body = getCombinedCommentBody(createComment)
+ expect(body).toContain('suborg-repo')
+ expect(body).not.toContain('other-repo')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 13 — getChangedEntryNames helper
+ // -------------------------------------------------------------------------
+ describe('getChangedEntryNames', () => {
+ const { getChangedEntryNames } = require('../../../lib/settings')
+
+ it('returns empty set when both arrays are identical', () => {
+ const entries = [{ name: 'A', val: 1 }, { name: 'B', val: 2 }]
+ expect(getChangedEntryNames(entries, entries).size).toBe(0)
+ })
+
+ it('detects added entries', () => {
+ const base = [{ name: 'A', val: 1 }]
+ const pr = [{ name: 'A', val: 1 }, { name: 'B', val: 2 }]
+ const changed = getChangedEntryNames(base, pr)
+ expect(changed.has('B')).toBe(true)
+ expect(changed.has('A')).toBe(false)
+ })
+
+ it('detects deleted entries', () => {
+ const base = [{ name: 'A', val: 1 }, { name: 'B', val: 2 }]
+ const pr = [{ name: 'A', val: 1 }]
+ const changed = getChangedEntryNames(base, pr)
+ expect(changed.has('B')).toBe(true)
+ expect(changed.has('A')).toBe(false)
+ })
+
+ it('detects modified entries', () => {
+ const base = [{ name: 'A', val: 1 }, { name: 'B', val: 2 }]
+ const pr = [{ name: 'A', val: 1 }, { name: 'B', val: 99 }]
+ const changed = getChangedEntryNames(base, pr)
+ expect(changed.has('B')).toBe(true)
+ expect(changed.has('A')).toBe(false)
+ })
+
+ it('handles null/undefined base gracefully', () => {
+ const pr = [{ name: 'A' }, { name: 'B' }]
+ const changed = getChangedEntryNames(null, pr)
+ expect(changed.has('A')).toBe(true)
+ expect(changed.has('B')).toBe(true)
+ })
+
+ it('handles null/undefined PR gracefully', () => {
+ const base = [{ name: 'A' }, { name: 'B' }]
+ const changed = getChangedEntryNames(base, null)
+ expect(changed.has('A')).toBe(true)
+ expect(changed.has('B')).toBe(true)
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Test 14 — filterActionByChangedNames helper
+ // -------------------------------------------------------------------------
+ describe('filterActionByChangedNames', () => {
+ const { filterActionByChangedNames } = require('../../../lib/settings')
+
+ it('keeps entries matching changed names', () => {
+ const action = {
+ additions: [{ name: 'New Rule', enforcement: 'active' }],
+ deletions: [{ name: 'Old Rule', enforcement: 'evaluate' }],
+ modifications: [
+ { name: 'Changed Rule', conditions: { include: ['*'] } },
+ { name: 'Unchanged Rule', bypass_actors: [{ actor_id: 1 }] }
+ ]
+ }
+ const changed = new Set(['New Rule', 'Old Rule', 'Changed Rule'])
+ const result = filterActionByChangedNames(action, changed)
+
+ expect(result.additions).toHaveLength(1)
+ expect(result.deletions).toHaveLength(1)
+ expect(result.modifications).toHaveLength(1)
+ expect(result.modifications[0].name).toBe('Changed Rule')
+ })
+
+ it('returns null when all entries are filtered out', () => {
+ const action = {
+ additions: [],
+ deletions: [],
+ modifications: [
+ { name: 'Noise A', bypass_actors: [{ actor_id: 1 }] },
+ { name: 'Noise B', bypass_actors: [{ actor_id: 2 }] }
+ ]
+ }
+ const changed = new Set(['Something Else'])
+ const result = filterActionByChangedNames(action, changed)
+ expect(result).toBeNull()
+ })
+
+ it('keeps entries without a name field (structural entries)', () => {
+ const action = {
+ additions: [],
+ deletions: [{ conditions: { repository_name: { include: ['old-*'] } } }], // no name field
+ modifications: []
+ }
+ const changed = new Set(['Rule X'])
+ const result = filterActionByChangedNames(action, changed)
+ expect(result.deletions).toHaveLength(1)
+ })
+ })
+})
diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js
index b3610651..b601757e 100644
--- a/test/unit/lib/settings.test.js
+++ b/test/unit/lib/settings.test.js
@@ -462,4 +462,40 @@ repository:
);
});
});
+
+ describe('changed config tracking', () => {
+ it('syncAll tracks repo and suborg override changes during full nop runs', async () => {
+ const config = { restrictedRepos: [] }
+ const changedFiles = {
+ repos: [{ repo: 'repo-from-override' }],
+ subOrgs: [{ path: '.github/suborgs/frontend.yml' }]
+ }
+
+ const loadConfigsSpy = jest.spyOn(Settings.prototype, 'loadConfigs').mockImplementation(async function () {
+ this.subOrgConfigs = {
+ 'repo-from-suborg': { source: '.github/suborgs/frontend.yml' },
+ 'repo-from-other-suborg': { source: '.github/suborgs/backend.yml' }
+ }
+ })
+ const updateOrgSpy = jest.spyOn(Settings.prototype, 'updateOrg').mockResolvedValue(undefined)
+ const updateAllSpy = jest.spyOn(Settings.prototype, 'updateAll').mockResolvedValue(undefined)
+ const handleResultsSpy = jest.spyOn(Settings.prototype, 'handleResults').mockImplementation(async function () {
+ expect(this.changedRepoNames.has('repo-from-override')).toBe(true)
+ expect(this.changedRepoNames.has('repo-from-suborg')).toBe(true)
+ expect(this.changedRepoNames.has('repo-from-other-suborg')).toBe(false)
+ })
+
+ await Settings.syncAll(true, stubContext, mockRepo, config, mockRef, {}, changedFiles)
+
+ expect(loadConfigsSpy).toHaveBeenCalledTimes(1)
+ expect(updateOrgSpy).toHaveBeenCalledTimes(1)
+ expect(updateAllSpy).toHaveBeenCalledTimes(1)
+ expect(handleResultsSpy).toHaveBeenCalledTimes(1)
+
+ loadConfigsSpy.mockRestore()
+ updateOrgSpy.mockRestore()
+ updateAllSpy.mockRestore()
+ handleResultsSpy.mockRestore()
+ })
+ })
}) // Settings Tests