Skip to content

Commit be3b251

Browse files
authored
Merge pull request #1047 from github-community-projects/decyjphr-incorporate-pr-1018-nop-results
fix: full-sync NOP results without check run + idempotent ruleset create
2 parents 8ace3b7 + 00b54a2 commit be3b251

4 files changed

Lines changed: 155 additions & 2 deletions

File tree

lib/plugins/rulesets.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ module.exports = class Rulesets extends Diffable {
291291
this.log.debug(`Ruleset created successfully ${JSON.stringify(res.url)}`)
292292
return res
293293
}).catch(e => {
294-
return this.handleError(e)
294+
return this.handleDuplicateOrError(e, attrs)
295295
})
296296
} else {
297297
if (this.nop) {
@@ -305,11 +305,45 @@ module.exports = class Rulesets extends Diffable {
305305
this.log.debug(`Ruleset created successfully ${JSON.stringify(res.url)}`)
306306
return res
307307
}).catch(e => {
308-
return this.handleError(e)
308+
return this.handleDuplicateOrError(e, attrs)
309309
})
310310
}
311311
}
312312

313+
// A ruleset create (POST) is not idempotent. Octokit's retry / auth-app
314+
// layers can re-send a POST that already succeeded, and a repo can also be
315+
// processed by two overlapping syncs (e.g. a full sync racing with the
316+
// repository.created webhook). In both cases the second create hits
317+
// "Name must be unique" (422) even though the ruleset now exists. Instead of
318+
// failing the whole run, reconcile by looking the ruleset up by name and
319+
// updating it in place so the create effectively becomes idempotent.
320+
isDuplicateNameError (e) {
321+
if (!e || e.status !== 422) return false
322+
const errors = e.response && e.response.data && e.response.data.errors
323+
const list = Array.isArray(errors) ? errors : []
324+
return list.some(err => {
325+
const msg = typeof err === 'string' ? err : (err && err.message)
326+
return typeof msg === 'string' && /name must be unique/i.test(msg)
327+
})
328+
}
329+
330+
handleDuplicateOrError (e, attrs) {
331+
if (!this.isDuplicateNameError(e)) {
332+
return this.handleError(e)
333+
}
334+
this.log.debug(`Ruleset '${attrs && attrs.name}' already exists (concurrent or retried create); reconciling by update`)
335+
return this.find().then(existing => {
336+
const match = Array.isArray(existing)
337+
? existing.find(record => this.comparator(record, attrs))
338+
: undefined
339+
if (!match) {
340+
return this.handleError(e)
341+
}
342+
const { id: _ignoredId, ...attrsWithoutId } = attrs || {}
343+
return this.update(match, attrsWithoutId)
344+
}).catch(err => this.handleError(err))
345+
}
346+
313347
remove (existing) {
314348
const parms = this.wrapAttrs(Object.assign({ id: existing.id }))
315349
if (this.scope === 'org') {

lib/settings.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,6 +1161,18 @@ class Settings {
11611161
})
11621162
}
11631163

1164+
// Full-sync NOP runs do not have the webhook fields needed to report to a
1165+
// check run. Keep potentially sensitive diff values at debug level and log
1166+
// only a value-free summary at info level.
1167+
if (!payload?.check_run || !payload?.repository) {
1168+
this.log.debug({ results: this.results }, 'Dry-run results')
1169+
const summary = this.results
1170+
.map(res => `${res.type} ${res.plugin} ${res.repo}: ${res.action?.msg ?? ''}`)
1171+
.join('\n')
1172+
this.log.info(`Dry-run finished with ${this.results.length} planned change(s); the full diff is logged at debug level.\n${summary}`)
1173+
return
1174+
}
1175+
11641176
let error = false
11651177
const stats = {
11661178
reposProcessed: {},

test/unit/lib/plugins/rulesets.test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,84 @@ describe('Rulesets', () => {
179179
})
180180
})
181181

182+
describe('idempotent create when the ruleset already exists (retried/concurrent POST)', () => {
183+
function duplicateNameError () {
184+
const e = new Error('Validation Failed')
185+
e.status = 422
186+
e.response = { data: { errors: ['Name must be unique'] } }
187+
return e
188+
}
189+
190+
function wireRequest (routeResults) {
191+
const calls = []
192+
const request = jest.fn().mockImplementation((route, body) => {
193+
calls.push({ route, body })
194+
const handler = routeResults[route]
195+
return handler ? handler() : Promise.resolve({ url: route })
196+
})
197+
request.endpoint = jest.fn().mockImplementation((route, body) => ({ url: route, body }))
198+
request.endpoint.merge = jest.fn().mockImplementation((route, body) => ({ method: 'GET', url: route, ...body }))
199+
github.request = request
200+
return calls
201+
}
202+
203+
it('reconciles a repo ruleset by updating the existing one on 422 "Name must be unique"', async () => {
204+
const attrs = generateRequestRuleset(0, 'synk', repo_conditions, [])
205+
delete attrs.id
206+
const existing = generateResponseRuleset(42, 'synk', repo_conditions, [])
207+
const calls = wireRequest({
208+
'POST /repos/{owner}/{repo}/rulesets': () => Promise.reject(duplicateNameError())
209+
})
210+
github.paginate = jest.fn()
211+
.mockResolvedValueOnce([{ id: 42, name: 'synk', source_type: 'Repository' }])
212+
.mockResolvedValueOnce([existing])
213+
214+
const plugin = configure([attrs])
215+
await plugin.add(attrs)
216+
217+
const put = calls.find(c => c.route === 'PUT /repos/{owner}/{repo}/rulesets/{id}')
218+
expect(put).toBeDefined()
219+
expect(put.body.id).toBe(42)
220+
})
221+
222+
it('reconciles an org ruleset by updating the existing one on 422 "Name must be unique"', async () => {
223+
const attrs = generateRequestRuleset(0, 'synk', org_conditions, [], true)
224+
delete attrs.id
225+
const existing = generateResponseRuleset(7, 'synk', org_conditions, [], true)
226+
const calls = wireRequest({
227+
'POST /orgs/{org}/rulesets': () => Promise.reject(duplicateNameError())
228+
})
229+
github.paginate = jest.fn()
230+
.mockResolvedValueOnce([{ id: 7, name: 'synk', source_type: 'Organization' }])
231+
.mockResolvedValueOnce([existing])
232+
233+
const plugin = configure([attrs], 'org')
234+
await plugin.add(attrs)
235+
236+
const put = calls.find(c => c.route === 'PUT /orgs/{org}/rulesets/{id}')
237+
expect(put).toBeDefined()
238+
expect(put.body.id).toBe(7)
239+
})
240+
241+
it('does not reconcile (surfaces the error) for a 422 that is not a name-uniqueness violation', async () => {
242+
const attrs = generateRequestRuleset(0, 'synk', repo_conditions, [])
243+
delete attrs.id
244+
const other = new Error('Validation Failed')
245+
other.status = 422
246+
other.response = { data: { errors: ['Something else is invalid'] } }
247+
const calls = wireRequest({
248+
'POST /repos/{owner}/{repo}/rulesets': () => Promise.reject(other)
249+
})
250+
github.paginate = jest.fn()
251+
252+
const plugin = configure([attrs])
253+
await plugin.add(attrs)
254+
255+
expect(github.paginate).not.toHaveBeenCalled()
256+
expect(calls.some(c => c.route === 'PUT /repos/{owner}/{repo}/rulesets/{id}')).toBe(false)
257+
})
258+
})
259+
182260
describe('when {{EXTERNALLY_DEFINED}} is present in "required_status_checks" and no status checks exist in GitHub', () => {
183261
it('it initialises the status checks with an empty list', () => {
184262
// Mock the GitHub API response

test/unit/lib/settings.test.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,35 @@ repository:
10571057
expect(msgs.some(m => /teams/.test(m))).toBe(true)
10581058
})
10591059

1060+
it.each([
1061+
['without a check run', {}],
1062+
['without a repository', { check_run: { id: 123 } }]
1063+
])('28. full-sync dry run %s logs a value-free summary instead of updating a check run', async (_description, payload) => {
1064+
stubContext.payload = { installation: { id: 123 }, ...payload }
1065+
stubContext.octokit.checks = { update: jest.fn().mockResolvedValue({}) }
1066+
1067+
const settings = new Settings(true, stubContext, mockRepo, {}, mockRef)
1068+
settings.results = [{
1069+
type: 'INFO',
1070+
plugin: 'Variables',
1071+
repo: 'test/test-repo',
1072+
endpoint: '',
1073+
action: {
1074+
msg: 'Changes found',
1075+
additions: {},
1076+
modifications: { MY_VAR: { value: 'plain-value' } },
1077+
deletions: {}
1078+
}
1079+
}]
1080+
1081+
await settings.handleResults()
1082+
1083+
expect(stubContext.log.info).toHaveBeenCalledWith(expect.stringContaining('Changes found'))
1084+
expect(stubContext.log.info).not.toHaveBeenCalledWith(expect.stringContaining('plain-value'))
1085+
expect(stubContext.log.debug).toHaveBeenCalledWith({ results: settings.results }, 'Dry-run results')
1086+
expect(stubContext.octokit.checks.update).not.toHaveBeenCalled()
1087+
})
1088+
10601089
it('28. base-config filtering preserves org-rulesets informational NopCommands', async () => {
10611090
stubContext.payload.repository = { owner: { login: 'test' }, name: 'safe-settings' }
10621091
stubContext.payload.check_run = { id: 123, check_suite: { pull_requests: [{ number: 456 }] } }

0 commit comments

Comments
 (0)