Skip to content

Commit 79cbca7

Browse files
committed
Fix: Patch comparison for Teams plugin to match on slug instead of name on NOP
1 parent 0cc709f commit 79cbca7

2 files changed

Lines changed: 120 additions & 1 deletion

File tree

lib/plugins/teams.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ module.exports = class Teams extends Diffable {
77
this.log.debug(`Finding teams for ${this.repo.owner}/${this.repo.repo}`)
88
return this.github.paginate(this.github.rest.repos.listTeams, this.repo).then(res => {
99
this.log.debug(`Found teams ${JSON.stringify(res)}`)
10-
return this.checkSecurityManager(res)
10+
return this.checkSecurityManager(res).then(teams => {
11+
// GitHub's team `name` is the display name, which can differ from `slug` (what config
12+
// entries and comparator()/changed() actually match on). Normalizing `name` to `slug` here
13+
// keeps the nop-mode diff (MergeDeep.compareDeep, which pairs array items by `name`) in sync
14+
// with the real add/update/remove decisions, which are always slug-based. This must happen
15+
// after checkSecurityManager(), which matches on the real display `name`.
16+
return teams.map(team => ({ ...team, name: team.slug }))
17+
})
1118
})
1219
}
1320

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Regression test for: Teams plugin nop-mode diff reports phantom additions/deletions
2+
// for teams whose GitHub display `name` differs from their `slug` (e.g. "Platform
3+
// Engineering" vs. `platform-engineering`), even when nothing has actually changed.
4+
//
5+
// Fixes: https://github.com/github-community-projects/safe-settings/issues/1033
6+
const any = require('@travi/any')
7+
const Teams = require('../../../../lib/plugins/teams')
8+
9+
describe('Teams - slug vs display name nop diff', () => {
10+
let github
11+
const org = 'bkeepers'
12+
const teamSlug = 'platform-engineering'
13+
const teamDisplayName = 'Platform Engineering'
14+
const teamId = any.integer()
15+
16+
function configure (config, { nop = true } = {}) {
17+
const log = { debug: jest.fn(), error: console.error }
18+
const errors = []
19+
return new Teams(nop, github, { owner: org, repo: 'test' }, config, log, errors)
20+
}
21+
22+
function mockExistingTeam (overrides = {}) {
23+
github = {
24+
paginate: jest.fn()
25+
.mockImplementation(async (fetch, params) => {
26+
if (typeof fetch !== 'function') {
27+
return []
28+
}
29+
const response = await fetch(params)
30+
return response.data
31+
}),
32+
rest: {
33+
teams: {
34+
create: jest.fn().mockResolvedValue(),
35+
getByName: jest.fn(),
36+
addOrUpdateRepoPermissionsInOrg: jest.fn().mockResolvedValue()
37+
},
38+
repos: {
39+
listTeams: jest.fn().mockResolvedValue({
40+
data: [
41+
{
42+
id: teamId,
43+
slug: teamSlug,
44+
name: teamDisplayName,
45+
permission: 'push',
46+
notification_setting: 'notifications_enabled',
47+
...overrides
48+
}
49+
]
50+
})
51+
}
52+
},
53+
request: Object.assign(jest.fn().mockResolvedValue(), {
54+
endpoint: jest.fn().mockReturnValue({})
55+
})
56+
}
57+
}
58+
59+
it('reports no changes when only the display name differs from the slug', async () => {
60+
mockExistingTeam()
61+
62+
const plugin = configure([
63+
{ name: teamSlug, permission: 'push' }
64+
])
65+
66+
const result = await plugin.sync()
67+
68+
// sync() only resolves with an array of NopCommands when compareDeep detects a change.
69+
// Before the fix, the name/slug mismatch made compareDeep report the whole team as an
70+
// addition + deletion even though nothing changed, so this would be a populated array.
71+
if (result !== undefined) {
72+
throw new Error(
73+
'Expected sync() to resolve with no nop output for an unchanged team, but got:\n' +
74+
JSON.stringify(result, null, 2)
75+
)
76+
}
77+
78+
expect(github.request).not.toHaveBeenCalled()
79+
expect(github.rest.teams.addOrUpdateRepoPermissionsInOrg).not.toHaveBeenCalled()
80+
})
81+
82+
it('still reports a genuine permission change without noise from the name/slug mismatch', async () => {
83+
mockExistingTeam({ permission: 'pull' })
84+
85+
const plugin = configure([
86+
{ name: teamSlug, permission: 'push' }
87+
])
88+
89+
const result = await plugin.sync()
90+
91+
// result[0] is the informational summary NopCommand produced by compareDeep;
92+
// result[1] is the actual PUT action produced by update().
93+
const [summary] = result || []
94+
const hasPhantomDiff =
95+
!summary ||
96+
!summary.action ||
97+
JSON.stringify(summary.action.additions) !== JSON.stringify({}) ||
98+
JSON.stringify(summary.action.deletions) !== JSON.stringify({})
99+
100+
if (hasPhantomDiff) {
101+
throw new Error(
102+
'Expected only a `permission` modification (no additions/deletions) for a team whose ' +
103+
'permission genuinely changed, but got:\n' +
104+
JSON.stringify(result, null, 2)
105+
)
106+
}
107+
108+
expect(summary.action.modifications).toEqual([
109+
expect.objectContaining({ permission: 'push' })
110+
])
111+
})
112+
})

0 commit comments

Comments
 (0)