Skip to content

Commit f4704cd

Browse files
feat: allow some settings to be marked 'unsafe', to allow overriding in the repository
1 parent 777a97f commit f4704cd

24 files changed

Lines changed: 705 additions & 622 deletions

index.js

Lines changed: 58 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ let deploymentConfig
1313
module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => {
1414
let appName = 'safe-settings'
1515
let appSlug = 'safe-settings'
16+
17+
// All repos _could_ be affected, so sync everything
1618
async function syncAllSettings (nop, context, repo = context.repo(), ref) {
1719
try {
18-
deploymentConfig = await loadYamlFileSystem()
19-
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
20+
robot.log.info('Synchronizing all settings')
21+
2022
const configManager = new ConfigManager(context, ref)
21-
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
22-
const config = Object.assign({}, deploymentConfig, runtimeConfig)
23+
const config = await configManager.loadGlobalSettingsYaml()
24+
2325
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
2426
if (ref) {
2527
return Settings.syncAll(nop, context, repo, config, ref)
@@ -42,13 +44,14 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
4244
}
4345
}
4446

47+
// Only the suborg has been modified, so only sync that
4548
async function syncSubOrgSettings (nop, context, suborg, repo = context.repo(), ref) {
4649
try {
47-
deploymentConfig = await loadYamlFileSystem()
48-
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
50+
robot.log.info(`Synchronizing settings for suborg: ${suborg}`)
51+
4952
const configManager = new ConfigManager(context, ref)
50-
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
51-
const config = Object.assign({}, deploymentConfig, runtimeConfig)
53+
const config = await configManager.loadGlobalSettingsYaml()
54+
5255
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
5356
return Settings.syncSubOrgs(nop, context, suborg, repo, config, ref)
5457
} catch (e) {
@@ -67,13 +70,14 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
6770
}
6871
}
6972

73+
// Only the repository has been modified, so only sync that
7074
async function syncSettings (nop, context, repo = context.repo(), ref) {
7175
try {
72-
deploymentConfig = await loadYamlFileSystem()
73-
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
76+
robot.log.info(`Synchronizing settings for repo: ${repo}`)
77+
7478
const configManager = new ConfigManager(context, ref)
75-
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
76-
const config = Object.assign({}, deploymentConfig, runtimeConfig)
79+
const config = await configManager.loadGlobalSettingsYaml()
80+
7781
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
7882
return Settings.sync(nop, context, repo, config, ref)
7983
} catch (e) {
@@ -258,47 +262,57 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
258262
return null
259263
}
260264

265+
// On any push
261266
robot.on('push', async context => {
262267
const { payload } = context
263268
const { repository } = payload
264269

265-
const adminRepo = repository.name === env.ADMIN_REPO
266-
if (!adminRepo) {
267-
return
268-
}
269-
270270
const defaultBranch = payload.ref === 'refs/heads/' + repository.default_branch
271271
if (!defaultBranch) {
272272
robot.log.debug('Not working on the default branch, returning...')
273273
return
274274
}
275275

276-
const settingsModified = payload.commits.find(commit => {
277-
return commit.added.includes(Settings.FILE_NAME) ||
278-
commit.modified.includes(Settings.FILE_NAME)
279-
})
280-
if (settingsModified) {
281-
robot.log.debug(`Changes in '${Settings.FILE_NAME}' detected, doing a full synch...`)
282-
return syncAllSettings(false, context)
283-
}
276+
const adminRepo = repository.name === env.ADMIN_REPO
277+
if (adminRepo) {
278+
const settingsModified = payload.commits.find(commit => {
279+
return commit.added.includes(Settings.FILE_NAME) ||
280+
commit.modified.includes(Settings.FILE_NAME)
281+
})
282+
if (settingsModified) {
283+
robot.log.debug(`Changes in '${Settings.FILE_NAME}' detected, doing a full sync...`)
284+
return syncAllSettings(false, context)
285+
}
284286

285-
const repoChanges = getAllChangedRepoConfigs(payload, context.repo().owner)
286-
if (repoChanges.length > 0) {
287-
return Promise.all(repoChanges.map(repo => {
288-
return syncSettings(false, context, repo)
289-
}))
290-
}
287+
const repoChanges = getAllChangedRepoConfigs(payload, context.repo().owner)
288+
if (repoChanges.length > 0) {
289+
return Promise.all(repoChanges.map(repo => {
290+
return syncSettings(false, context, repo)
291+
}))
292+
}
291293

292-
const changes = getAllChangedSubOrgConfigs(payload)
293-
if (changes.length) {
294-
return Promise.all(changes.map(suborg => {
295-
return syncSubOrgSettings(false, context, suborg)
296-
}))
297-
}
294+
const changes = getAllChangedSubOrgConfigs(payload)
295+
if (changes.length) {
296+
return Promise.all(changes.map(suborg => {
297+
return syncSubOrgSettings(false, context, suborg)
298+
}))
299+
}
298300

299-
robot.log.debug(`No changes in '${Settings.FILE_NAME}' detected, returning...`)
301+
robot.log.debug(`No changes in '${Settings.FILE_NAME}' detected, returning...`)
302+
} else {
303+
const settingsModified = payload.commits.find(commit => {
304+
return commit.added.includes('.github/settings.yml') ||
305+
commit.modified.includes('.github/settings.yml')
306+
})
307+
if (settingsModified) {
308+
robot.log.debug(`Changes in '.github/settings.yml' detected, doing a sync for ${repository.name}...`)
309+
return syncSettings(false, context)
310+
}
311+
}
300312
})
301313

314+
// Invoke syncSettings for events that could cause drift
315+
302316
robot.on('create', async context => {
303317
const { payload } = context
304318
const { sender } = payload
@@ -394,6 +408,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
394408
return syncSettings(false, context)
395409
})
396410

411+
// Renames need some extra handling
397412
robot.on('repository.renamed', async context => {
398413
if (env.BLOCK_REPO_RENAME_BY_HUMAN!== 'true') {
399414
robot.log.debug(`"env.BLOCK_REPO_RENAME_BY_HUMAN" is 'false' by default. Repo rename is not managed by Safe-settings. Continue with the default behavior.`)
@@ -474,7 +489,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
474489
}
475490
})
476491

477-
492+
// Validate settings when PR checks are needed
478493
robot.on('check_suite.requested', async context => {
479494
const { payload } = context
480495
const { repository } = payload
@@ -503,6 +518,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
503518
return createCheckRun(context, pull_request, headSha, headBranch)
504519
})
505520

521+
// Validate settings when PR checks are needed
506522
robot.on('pull_request.opened', async context => {
507523
robot.log.debug('Pull_request opened !')
508524
const { payload } = context
@@ -522,6 +538,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
522538
return createCheckRun(context, pull_request, payload.pull_request.head.sha, payload.pull_request.head.ref)
523539
})
524540

541+
// Validate settings when PR checks are needed
525542
robot.on('pull_request.reopened', async context => {
526543
robot.log.debug('Pull_request REopened !')
527544
const { payload } = context
@@ -543,16 +560,13 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
543560
return createCheckRun(context, pull_request, payload.pull_request.head.sha, payload.pull_request.head.ref)
544561
})
545562

563+
// Validate settings when PR checks are needed
546564
robot.on(['check_suite.rerequested'], async context => {
547565
robot.log.debug('Check suite was rerequested!')
548566
return createCheckRun(context)
549567
})
550568

551-
robot.on(['check_suite.rerequested'], async context => {
552-
robot.log.debug('Check suite was rerequested!')
553-
return createCheckRun(context)
554-
})
555-
569+
// Validate settings when PR checks are needed
556570
robot.on(['check_run.created'], async context => {
557571
robot.log.debug('Check run was created!')
558572
const { payload } = context

lib/configManager.js

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,30 @@ module.exports = class ConfigManager {
1010
}
1111

1212
/**
13-
* Loads a file from GitHub
14-
*
15-
* @param params Params to fetch the file with
16-
* @return The parsed YAML file
17-
*/
18-
async loadYaml (filePath) {
13+
* Loads the settings file from GitHub
14+
*
15+
* @return The parsed YAML file
16+
*/
17+
async loadGlobalSettingsYaml () {
18+
const CONFIG_PATH = env.CONFIG_PATH
19+
const filePath = path.posix.join(CONFIG_PATH, env.SETTINGS_FILE_PATH)
20+
return ConfigManager.loadYaml(this.context.octokit, {
21+
owner: this.context.repo().owner,
22+
repo: env.ADMIN_REPO,
23+
path: filePath,
24+
ref: this.ref
25+
})
26+
}
27+
28+
/**
29+
* Loads a file from GitHub
30+
*
31+
* @param params Params to fetch the file with
32+
* @return The parsed YAML file
33+
*/
34+
static async loadYaml (octokit, params) {
1935
try {
20-
const repo = { owner: this.context.repo().owner, repo: env.ADMIN_REPO }
21-
const params = Object.assign(repo, { path: filePath, ref: this.ref })
22-
const response = await this.context.octokit.repos.getContent(params).catch(e => {
23-
this.log.error(`Error getting settings ${e}`)
24-
})
36+
const response = await octokit.repos.getContent(params)
2537

2638
// Ignore in case path is a folder
2739
// - https://developer.github.com/v3/repos/contents/#response-if-content-is-a-directory
@@ -43,16 +55,4 @@ module.exports = class ConfigManager {
4355
throw e
4456
}
4557
}
46-
47-
/**
48-
* Loads a file from GitHub
49-
*
50-
* @param params Params to fetch the file with
51-
* @return The parsed YAML file
52-
*/
53-
async loadGlobalSettingsYaml () {
54-
const CONFIG_PATH = env.CONFIG_PATH
55-
const filePath = path.posix.join(CONFIG_PATH, env.SETTINGS_FILE_PATH)
56-
return this.loadYaml(filePath)
57-
}
5858
}

lib/deploymentConfig.js

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,51 +4,56 @@ const env = require('./env')
44

55
/**
66
* Class representing a deployment config.
7-
* It is a singleton (class object) for the deployment settings.
8-
* The settings are loaded from the deployment-settings.yml file during initialization and stored as static properties.
7+
* The settings are loaded from the deployment-settings.yml file during initialization and stored as fields.
98
*/
109
class DeploymentConfig {
11-
//static config
12-
static configvalidators = {}
13-
static overridevalidators = {}
14-
15-
static {
16-
const deploymentConfigPath = process.env.DEPLOYMENT_CONFIG_FILE ? process.env.DEPLOYMENT_CONFIG_FILE : 'deployment-settings.yml'
17-
if (fs.existsSync(deploymentConfigPath)) {
18-
this.config = yaml.load(fs.readFileSync(deploymentConfigPath))
19-
} else {
20-
this.config = { restrictedRepos: ['admin', '.github', 'safe-settings'] }
21-
}
22-
23-
const overridevalidators = this.config.overridevalidators
24-
if (this.isIterable(overridevalidators)) {
25-
for (const validator of overridevalidators) {
26-
// eslint-disable-next-line no-new-func
27-
const f = new Function('baseconfig', 'overrideconfig', 'githubContext', validator.script)
28-
this.overridevalidators[validator.plugin] = { canOverride: f, error: validator.error }
29-
}
30-
}
31-
const configvalidators = this.config.configvalidators
32-
if (this.isIterable(configvalidators)) {
33-
for (const validator of configvalidators) {
34-
// eslint-disable-next-line no-new-func
35-
const f = new Function('baseconfig', 'githubContext', validator.script)
36-
this.configvalidators[validator.plugin] = { isValid: f, error: validator.error }
37-
}
38-
}
10+
constructor () {
11+
const deploymentConfigPath = process.env.DEPLOYMENT_CONFIG_FILE ? process.env.DEPLOYMENT_CONFIG_FILE : 'deployment-settings.yml'
12+
13+
let config = {}
14+
if (fs.existsSync(deploymentConfigPath)) {
15+
config = yaml.load(fs.readFileSync(deploymentConfigPath))
16+
}
17+
18+
this.overrideValidators = {}
19+
if (config.overridevalidators) {
20+
if (!Array.isArray(config.overridevalidators)) {
21+
throw new Error('overridevalidators must be an array')
22+
}
23+
for (const validator of config.overridevalidators) {
24+
// eslint-disable-next-line no-new-func
25+
const f = new Function('baseconfig', 'overrideconfig', 'githubContext', validator.script)
26+
this.overrideValidators[validator.plugin] = { canOverride: f, error: validator.error }
27+
}
3928
}
4029

41-
static isIterable (obj) {
42-
// checks for null and undefined
43-
if (obj == null) {
44-
return false
45-
}
46-
return typeof obj[Symbol.iterator] === 'function'
30+
this.configValidators = {}
31+
if (config.configvalidators) {
32+
if (!Array.isArray(config.configvalidators)) {
33+
throw new Error('configvalidators must be an array')
4734
}
35+
for (const validator of config.configvalidators) {
36+
// eslint-disable-next-line no-new-func
37+
const f = new Function('baseconfig', 'githubContext', validator.script)
38+
this.configvalidators[validator.plugin] = { isValid: f, error: validator.error }
39+
}
40+
}
41+
42+
this.restrictedRepos = config.restrictedRepos ?? ['admin', '.github', 'safe-settings']
4843

49-
constructor (nop, context, repo, config, ref, suborg) {
44+
this.unsafeFields = []
45+
// eslint-disable-next-line dot-notation
46+
if (config['unsafe_fields']) {
47+
// eslint-disable-next-line dot-notation
48+
if (!Array.isArray(config['unsafe_fields'])) {
49+
throw new Error('unsafe_fields must be an array')
50+
}
51+
// eslint-disable-next-line dot-notation
52+
this.unsafeFields = config['unsafe_fields']
5053
}
54+
}
5155
}
56+
5257
DeploymentConfig.FILE_NAME = `${env.CONFIG_PATH}/settings.yml`
5358

5459
module.exports = DeploymentConfig

lib/mergeDeep.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
const mergeBy = require('./mergeArrayBy')
2-
const DeploymentConfig = require('./deploymentConfig')
32

43
const NAME_FIELDS = ['name', 'username', 'actor_id', 'login', 'type', 'key_prefix']
54
const NAME_USERNAME_PROPERTY = item => NAME_FIELDS.find(prop => Object.prototype.hasOwnProperty.call(item, prop))
65
const GET_NAME_USERNAME_PROPERTY = item => { if (NAME_USERNAME_PROPERTY(item)) return item[NAME_USERNAME_PROPERTY(item)] }
76

87
class MergeDeep {
9-
constructor (log, github, ignorableFields = [], configvalidators = {}, overridevalidators = {}) {
8+
constructor (log, github, ignorableFields = [], deploymentConfig) {
109
this.log = log
1110
this.github = github
1211
this.ignorableFields = ignorableFields
13-
this.configvalidators = DeploymentConfig.configvalidators
14-
this.overridevalidators = DeploymentConfig.overridevalidators
12+
this.configvalidators = deploymentConfig.configValidators
13+
this.overridevalidators = deploymentConfig.overrideValidators
1514
}
1615

1716
isObjectNotArray (item) {
@@ -336,11 +335,20 @@ class MergeDeep {
336335
this.validateConfig(key, target[key])
337336
}
338337
} else {
339-
// Not calling validators when target[key] is primitive or empty
340-
target[key] = source[key]
338+
// Handle nulls differently than undefined
339+
if (source[key] === null) {
340+
// nulls will be respected, and remove the value
341+
delete target[key]
342+
} else if (source[key] === undefined) {
343+
// undefined will be ignored
344+
} else {
345+
// Not calling validators when target[key] is primitive
346+
target[key] = source[key]
347+
}
341348
}
342349
}
343350
}
351+
this.log.debug(`returning ${JSON.stringify(target)}`)
344352
return target
345353
}
346354

0 commit comments

Comments
 (0)