Skip to content

Commit b3aba96

Browse files
committed
fix!: replace custom glob with minimatch for improved pattern matching
1 parent a8a27e7 commit b3aba96

File tree

10 files changed

+443
-260
lines changed

10 files changed

+443
-260
lines changed

NOTICE.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -850,7 +850,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
850850
- isexe, 2.0.0, ISC,
851851
- json-stringify-safe, 5.0.1, ISC,
852852
- lru-cache, 6.0.0, ISC,
853-
- minimatch, 3.0.4, ISC,
853+
- minimatch, 10.0.1, ISC,
854854
- octokit-auth-probot, 1.2.3, ISC,
855855
- once, 1.4.0, ISC,
856856
- probot, 11.0.6, ISC,

README.md

+51-23
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,40 @@ The App listens to the following webhook events:
6565
If you rename a `<repo.yml>` that corresponds to a repo, safe-settings will rename the repo to the new name. This behavior will take effect whether the env variable `BLOCK_REPO_RENAME_BY_HUMAN` is set or not.
6666

6767
### Restricting `safe-settings` to specific repos
68-
`safe-settings` can be turned on only to a subset of repos by specifying them in the runtime settings file, `deployment-settings.yml`. If no file is specified, then the following repositories - `'admin', '.github', 'safe-settings'` are exempted by default.
69-
A sample of `deployment-settings` file is found [here](docs/sample-settings/sample-deployment-settings.yml).
70-
71-
To apply `safe-settings` __only__ to a specific list of repos, add them to the `restrictedRepos` section as `include` array.
7268

73-
To ignore `safe-settings` for a specific list of repos, add them to the `restrictedRepos` section as `exclude` array.
69+
To restrict which repositories `safe-settings` can manage, create a `deployment-settings.yml` file. This file controls the app's scope throught the `restrictedRepos` configuration:
70+
71+
```yml
72+
# Using include/exclude
73+
restrictedRepos:
74+
include:
75+
- api
76+
- core-* # Matches `core-api`, `core-service`, etc.
77+
exclude:
78+
- admin
79+
- .github
80+
- safe-settings
81+
- test-* # Matches `test-repo`, etc.
82+
83+
# Or using simple array syntax for includes
84+
restrictedRepos:
85+
- admin
86+
- .github
87+
# ...
88+
```
7489

7590
> [!NOTE]
76-
> The `include` and `exclude` attributes support as well regular expressions.
77-
> By default they look for regex, Example include: ['SQL'] will look apply to repos with SQL and SQL_ and SQL- etc if you want only SQL repo then use include:['^SQL$']
91+
> Pattern matching uses glob expressions, e.g use * for wildcards.
92+
93+
When using `include` and `exclude`:
94+
95+
- If `include` is specified, will **only** run on repositories that match pattern(s)
96+
- If `exlcude` is specified, will run on all repositories **expect** those matching pattern(s)
97+
- If both are specified, will run only on included repositories that are'nt excluded
98+
99+
By default, if no configuration file is provided, `safe-settings` will excludes these repos: `admin`, `.github` and `safe-settings`.
100+
101+
See our [deployment-settings.yml sample](docs/sample-settings/sample-deployment-settings.yml).
78102

79103
### Custom rules
80104

@@ -290,24 +314,28 @@ The following can be configured:
290314
- `Rulesets`
291315
- `Environments` - wait timer, required reviewers, prevent self review, protected branches deployment branch policy, custom deployment branch policy, variables, deployment protection rules
292316

293-
> [!important]
294-
> It is possible to provide an `include` or `exclude` settings to restrict the `collaborators`, `teams`, `labels` to a list of repos or exclude a set of repos for a collaborator.
295-
> The include/exclude pattern can also be for glob. For e.g.:
296-
```
297-
teams:
298-
- name: Myteam-admins
299-
permission: admin
300-
- name: Myteam-developers
301-
permission: push
302-
- name: Other-team
303-
permission: push
304-
include:
305-
- '*-config'
306-
```
307-
> Will only add `Other-team` to only `*-config` repos
308-
309317
See [`docs/sample-settings/settings.yml`](docs/sample-settings/settings.yml) for a sample settings file.
310318

319+
> [!note]
320+
> When using `collaborators`, `teams` or `labels`, you can control which repositories they apply to using `include` and `exclude`:
321+
>
322+
> - If `include` is specified, settings will **only** apply to repositories that match those patterns
323+
> - If `exclude` is specified, settings will apply to all repositories **except** those matching the patterns
324+
> - If both are specified, `exclude` takes precedence over `include` but `include` patterns will still be respected
325+
>
326+
> Pattern matching uses glob expressions, e.g use * for wildcards. For example:
327+
>
328+
> ```yml
329+
> teams:
330+
> - name: Myteam-admins
331+
> permission: admin
332+
> - name: Myteam-developers
333+
> permission: push
334+
> - name: Other-team
335+
> permission: push
336+
> include:
337+
> - '*-config'
338+
> ```
311339

312340
### Additional values
313341

docs/sample-settings/sample-deployment-settings.yml

+16-5
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
1+
# This is a sample deployment settings file
2+
# See the documentation for more details on how to use this file
3+
4+
# If no file is specified, the following repositories are excluded by default
5+
# restrictedRepos: ['admin', '.github', 'safe-settings']
6+
17
restrictedRepos:
2-
# You can exclude certain repos from safe-settings processing
3-
# If no file is specified, then the following repositories - 'admin', '.github', 'safe-settings' are exempted by default
4-
exclude: ['^admin$', '^\.github$', '^safe-settings$', '.*-test']
5-
# Alternatively you can only include certain repos
6-
include: ['^test$']
8+
exclude:
9+
- admin
10+
- .github
11+
- safe-settings
12+
- admin-*
13+
include:
14+
- docs
15+
- core-*
16+
717
configvalidators:
818
- plugin: collaborators
919
error: |
1020
`Admin cannot be assigned to collaborators`
1121
script: |
1222
console.log(`baseConfig ${JSON.stringify(baseconfig)}`)
1323
return baseconfig.permission != 'admin'
24+
1425
overridevalidators:
1526
- plugin: branches
1627
error: |

index.js

+44-60
Original file line numberDiff line numberDiff line change
@@ -134,73 +134,57 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
134134
}
135135

136136
function getAllChangedSubOrgConfigs (payload) {
137-
const settingPattern = new Glob(`${env.CONFIG_PATH}/suborgs/*.yml`)
138-
// Changes will be an array of files that were added
139-
const added = payload.commits.map(c => {
140-
return (c.added.filter(s => {
141-
robot.log.debug(JSON.stringify(s))
142-
return (s.search(settingPattern) >= 0)
143-
}))
144-
}).flat(2)
145-
const modified = payload.commits.map(c => {
146-
return (c.modified.filter(s => {
147-
robot.log.debug(JSON.stringify(s))
148-
return (s.search(settingPattern) >= 0)
149-
}))
150-
}).flat(2)
151-
const changes = added.concat(modified)
152-
const configs = changes.map(file => {
153-
const matches = file.match(settingPattern)
154-
robot.log.debug(`${JSON.stringify(file)} \n ${matches[1]}`)
155-
return { name: matches[1] + '.yml', path: file }
156-
})
157-
return configs
137+
const pattern = new Glob(`${env.CONFIG_PATH}/suborgs/*.yml`)
138+
139+
const getMatchingFiles = (commits, type) => commits.flatMap(c => c[type].filter(file => pattern.test(file)))
140+
141+
const changes = [
142+
...getMatchingFiles(payload.commits, 'added'),
143+
...getMatchingFiles(payload.commits, 'modified')
144+
]
145+
146+
return changes.map(file => ({
147+
name: file.match(/([^/]+)\.yml$/)[1],
148+
path: file
149+
}))
158150
}
159151

160152
function getAllChangedRepoConfigs (payload, owner) {
161-
const settingPattern = new Glob(`${env.CONFIG_PATH}/repos/*.yml`)
162-
// Changes will be an array of files that were added
163-
const added = payload.commits.map(c => {
164-
return (c.added.filter(s => {
165-
robot.log.debug(JSON.stringify(s))
166-
return (s.search(settingPattern) >= 0)
167-
}))
168-
}).flat(2)
169-
const modified = payload.commits.map(c => {
170-
return (c.modified.filter(s => {
171-
robot.log.debug(JSON.stringify(s))
172-
return (s.search(settingPattern) >= 0)
173-
}))
174-
}).flat(2)
175-
const changes = added.concat(modified)
176-
const configs = changes.map(file => {
177-
robot.log.debug(`${JSON.stringify(file)}`)
178-
return { repo: file.match(settingPattern)[1], owner }
179-
})
180-
return configs
153+
const pattern = new Glob(`${env.CONFIG_PATH}/repos/*.yml`)
154+
155+
const getMatchingFiles = (commits, type) => commits.flatMap(c => c[type].filter(file => pattern.test(file)))
156+
157+
const changes = [
158+
...getMatchingFiles(payload.commits, 'added'),
159+
...getMatchingFiles(payload.commits, 'modified')
160+
]
161+
162+
return changes.map(file => ({
163+
repo: file.match(/([^/]+)\.yml$/)[1],
164+
owner
165+
}))
181166
}
182167

183-
function getChangedRepoConfigName (glob, files, owner) {
184-
const modifiedFiles = files.filter(s => {
185-
robot.log.debug(JSON.stringify(s))
186-
return (s.search(glob) >= 0)
187-
})
168+
function getChangedRepoConfigName (files, owner) {
169+
const pattern = new Glob(`${env.CONFIG_PATH}/repos/*.yml`)
188170

189-
return modifiedFiles.map(modifiedFile => {
190-
return { repo: modifiedFile.match(glob)[1], owner }
191-
})
171+
const modifiedFiles = files.filter(s => pattern.test(s))
172+
173+
return modifiedFiles.map(modifiedFile => ({
174+
repo: modifiedFile.match(/([^/]+)\.yml$/)[1],
175+
owner
176+
}))
192177
}
193178

194-
function getChangedSubOrgConfigName (glob, files) {
195-
const modifiedFiles = files.filter(s => {
196-
robot.log.debug(JSON.stringify(s))
197-
return (s.search(glob) >= 0)
198-
})
179+
function getChangedSubOrgConfigName (files) {
180+
const pattern = new Glob(`${env.CONFIG_PATH}/suborgs/*.yml`)
199181

200-
return modifiedFiles.map(modifiedFile => {
201-
robot.log.debug(`${JSON.stringify(modifiedFile)}`)
202-
return { name: modifiedFile.match(glob)[1] + '.yml', path: modifiedFile }
203-
})
182+
const modifiedFiles = files.filter(s => pattern.test(s))
183+
184+
return modifiedFiles.map(modifiedFile => ({
185+
name: modifiedFile.match(/([^/]+)\.yml$/)[1],
186+
path: modifiedFile
187+
}))
204188
}
205189

206190
async function createCheckRun (context, pull_request, head_sha, head_branch) {
@@ -604,14 +588,14 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
604588
return syncAllSettings(true, context, context.repo(), pull_request.head.ref)
605589
}
606590

607-
const repoChanges = getChangedRepoConfigName(new Glob(`${env.CONFIG_PATH}/repos/*.yml`), files, context.repo().owner)
591+
const repoChanges = getChangedRepoConfigName(files, context.repo().owner)
608592
if (repoChanges.length > 0) {
609593
return Promise.all(repoChanges.map(repo => {
610594
return syncSettings(true, context, repo, pull_request.head.ref)
611595
}))
612596
}
613597

614-
const subOrgChanges = getChangedSubOrgConfigName(new Glob(`${env.CONFIG_PATH}/suborgs/*.yml`), files, context.repo().owner)
598+
const subOrgChanges = getChangedSubOrgConfigName(files, context.repo().owner)
615599
if (subOrgChanges.length) {
616600
return Promise.all(subOrgChanges.map(suborg => {
617601
return syncSubOrgSettings(true, context, suborg, context.repo(), pull_request.head.ref)

lib/glob.js

+8-38
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,14 @@
1-
class Glob {
2-
constructor (glob) {
3-
this.glob = glob
4-
5-
// If not a glob pattern then just match the string.
6-
if (!this.glob.includes('*')) {
7-
this.regexp = new RegExp(`.*${this.glob}.*`, 'u')
8-
return
9-
}
10-
this.regexptText = this.globize(this.glob)
11-
this.regexp = new RegExp(`^${this.regexptText}$`, 'u')
12-
}
13-
14-
globize (glob) {
15-
return glob
16-
.replace(/\\/g, '\\\\') // escape backslashes
17-
.replace(/\//g, '\\/') // escape forward slashes
18-
.replace(/\./g, '\\.') // escape periods
19-
.replace(/\?/g, '([^\\/])') // match any single character except /
20-
.replace(/\*\*/g, '.+') // match any character except /, including /
21-
.replace(/\*/g, '([^\\/]*)') // match any character except /
22-
}
23-
24-
toString () {
25-
return this.glob
26-
}
1+
const { minimatch } = require('minimatch')
272

28-
[Symbol.search] (s) {
29-
return s.search(this.regexp)
30-
}
31-
32-
[Symbol.match] (s) {
33-
return s.match(this.regexp)
34-
}
35-
36-
[Symbol.replace] (s, replacement) {
37-
return s.replace(this.regexp, replacement)
3+
class Glob {
4+
constructor (pattern, options = {}) {
5+
this.pattern = pattern
6+
this.options = options
387
}
398

40-
[Symbol.replaceAll] (s, replacement) {
41-
return s.replaceAll(this.regexp, replacement)
9+
test (input) {
10+
return minimatch(input, this.pattern, this.options)
4211
}
4312
}
13+
4414
module.exports = Glob

lib/plugins/diffable.js

+10-29
Original file line numberDiff line numberDiff line change
@@ -37,41 +37,22 @@ module.exports = class Diffable extends ErrorStash {
3737
filterEntries () {
3838
let filteredEntries = Array.from(this.entries)
3939

40-
// this.log.debug(` entries ${JSON.stringify(filteredEntries)}`)
4140
filteredEntries = filteredEntries.filter(attrs => {
42-
if (Array.isArray(attrs.exclude)) {
43-
const excludeGlobs = attrs.exclude.map(exc => new Glob(exc));
44-
const excludeGlobsMatch = excludeGlobs.some(glob => !!this.repo.repo.match(glob));
41+
if (!Array.isArray(attrs.exclude)) return true
4542

46-
if (!attrs.exclude.includes(this.repo.repo) && !excludeGlobsMatch) {
47-
// this.log.debug(`returning not excluded entry = ${JSON.stringify(attrs)} for repo ${this.repo.repo}`)
48-
return true
49-
} else {
50-
// this.log.debug(`skipping excluded entry = ${JSON.stringify(attrs)} for repo ${this.repo.repo}`)
51-
return false
52-
}
53-
} else {
54-
// this.log.debug(`No excludes. Returning unfiltered entries = ${JSON.stringify(attrs)} for repo ${this.repo.repo}`)
55-
return true
56-
}
43+
const excludeGlobs = attrs.exclude.map(exc => new Glob(exc))
44+
const isExcluded = excludeGlobs.some(glob => glob.test(this.repo.repo))
45+
return !isExcluded
5746
})
47+
5848
filteredEntries = filteredEntries.filter(attrs => {
59-
if (Array.isArray(attrs.include)) {
60-
const includeGlobs = attrs.include.map(inc => new Glob(inc));
61-
const includeGlobsMatch = includeGlobs.some(glob => !!this.repo.repo.match(glob));
49+
if (!Array.isArray(attrs.include)) return true
6250

63-
if (attrs.include.includes(this.repo.repo) || includeGlobsMatch) {
64-
// this.log.debug(`returning included entry = ${JSON.stringify(attrs)} for repo ${this.repo.repo}`)
65-
return true
66-
} else {
67-
// this.log.debug(`skipping not included entry = ${JSON.stringify(attrs)} for repo ${this.repo.repo}`)
68-
return false
69-
}
70-
} else {
71-
// this.log.debug(`No includes. Returning unfiltered entries = ${JSON.stringify(attrs)} for repo ${this.repo.repo}`)
72-
return true
73-
}
51+
const includeGlobs = attrs.include.map(exc => new Glob(exc))
52+
const isIncluded = includeGlobs.some(glob => glob.test(this.repo.repo))
53+
return isIncluded
7454
})
55+
7556
filteredEntries = filteredEntries.map(e => {
7657
const { exclude, include, ...o } = e
7758
return o

lib/settings.js

+5-5
Original file line numberDiff line numberDiff line change
@@ -374,12 +374,12 @@ ${this.results.reduce((x, y) => {
374374
})
375375
}
376376

377-
getSubOrgConfig(repoName) {
377+
getSubOrgConfig (repoName) {
378378
if (this.subOrgConfigs) {
379-
for (const k of Object.keys(this.subOrgConfigs)) {
380-
const repoPattern = new Glob(k)
381-
if (repoName.search(repoPattern) >= 0) {
382-
return this.subOrgConfigs[k]
379+
for (const pattern of Object.keys(this.subOrgConfigs)) {
380+
const glob = new Glob(pattern)
381+
if (glob.test(repoName)) {
382+
return this.subOrgConfigs[pattern]
383383
}
384384
}
385385
}

0 commit comments

Comments
 (0)