diff --git a/docs/github-settings/4. teams.md b/docs/github-settings/4. teams.md index 565398a1e..523bc49c8 100644 --- a/docs/github-settings/4. teams.md +++ b/docs/github-settings/4. teams.md @@ -61,5 +61,33 @@ teams: external_group: "Engineering - Expert Services" ``` + + +

excludearray

+

Exclude a list of repos for this team. The team is applied to every repo in scope except those whose names match one of these glob patterns.

+ + +```yaml +teams: + - name: SuperFriends + permission: maintain + exclude: + - secret-repo +``` + + + +

includearray

+

Include a list of repos for this team. The team is applied only to repos whose names match one of these glob patterns.

+ + +```yaml +teams: + - name: SuperFriends + permission: maintain + include: + - public-* +``` + diff --git a/docs/sample-settings/settings.yml b/docs/sample-settings/settings.yml index 85f50df9d..7f57d55a7 100644 --- a/docs/sample-settings/settings.yml +++ b/docs/sample-settings/settings.yml @@ -161,6 +161,16 @@ teams: - name: globalteam permission: push visibility: closed + - name: docs-team + permission: pull + # You can include a list of repos for this team and only those repos would have this team + include: + - actions-demo + - name: ops-team + permission: push + # You can exclude a list of repos for this team and all repos except these repos would have this team + exclude: + - actions-demo # Branch protection rules # See https://docs.github.com/en/rest/branches/branch-protection?apiVersion=2022-11-28#update-branch-protection for available options diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json index d4955ad26..fd500ae60 100644 --- a/schema/dereferenced/settings.json +++ b/schema/dereferenced/settings.json @@ -419,62 +419,54 @@ "description": "Teams", "type": "array", "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The name of the team." - }, - "description": { - "type": "string", - "description": "The description of the team." - }, - "maintainers": { - "type": "array", - "description": "List GitHub usernames for organization members who will become team maintainers.", - "items": { - "type": "string" - } - }, - "repo_names": { - "type": "array", - "description": "The full name (e.g., \"organization-name/repository-name\") of repositories to add the team to.", - "items": { - "type": "string" + "allOf": [ + { + "type": "object", + "properties": { + "permission": { + "type": "string", + "description": "The permission to grant the team on this repository. We accept the following permissions to be set: `pull`, `triage`, `push`, `maintain`, `admin` and you can also specify a custom repository role name, if the owning organization has defined any. If no permission is specified, the team's `permission` attribute will be used to determine what permission to grant the team on this repository." + } } }, - "privacy": { - "type": "string", - "description": "The level of privacy this team should have. The options are: \n**For a non-nested team:** \n * `secret` - only visible to organization owners and members of this team. \n * `closed` - visible to all members of this organization. \nDefault: `secret` \n**For a parent or child team:** \n * `closed` - visible to all members of this organization. \nDefault for child team: `closed`", - "enum": [ - "secret", - "closed" - ] - }, - "notification_setting": { - "type": "string", - "description": "The notification setting the team has chosen. The options are: \n * `notifications_enabled` - team members receive notifications when the team is @mentioned. \n * `notifications_disabled` - no one receives notifications. \nDefault: `notifications_enabled`", - "enum": [ - "notifications_enabled", - "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" + { + "type": "object", + "required": [ + "name" ], - "default": "pull" - }, - "parent_team_id": { - "type": "integer", - "description": "The ID of a team to set as the parent team." + "properties": { + "name": { + "description": "The name (or slug) of the team to grant access to the repo.", + "type": "string" + }, + "privacy": { + "description": "The level of privacy the team should have when it is created by safe-settings. The options are `secret` (visible only to organization owners and members of the team) or `closed` (visible to all members of the organization).", + "type": "string", + "enum": [ + "secret", + "closed" + ] + }, + "external_group": { + "description": "The display name of an external IdP group to link to this team.", + "type": "string" + }, + "exclude": { + "description": "Exclude this team from repos whose names match one of these glob patterns.", + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "description": "Apply this team only to repos whose names match one of these glob patterns.", + "type": "array", + "items": { + "type": "string" + } + } + } } - }, - "required": [ - "name" ] } }, @@ -1223,6 +1215,49 @@ "type": "boolean", "description": "New, reviewable commits pushed will dismiss previous pull request review approvals." }, + "dismissal_restriction": { + "title": "DismissalRestriction", + "description": "Specify people, teams, or apps allowed to dismiss pull request reviews.", + "type": "object", + "properties": { + "allowed_actors": { + "type": "array", + "description": "Specify people, teams, or apps allowed to dismiss pull request reviews.", + "items": { + "title": "Actor", + "description": "An actor allowed to dismiss pull request reviews", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the actor that can dismiss reviews." + }, + "type": { + "type": "string", + "description": "The type of the actor", + "enum": [ + "User", + "Team", + "IntegrationInstallation", + "RepositoryRole" + ] + } + }, + "required": [ + "id", + "type" + ] + } + }, + "enabled": { + "type": "boolean", + "description": "Whether to restrict review dismissal to specific actors." + } + }, + "required": [ + "enabled" + ] + }, "require_code_owner_review": { "type": "boolean", "description": "Require an approving review in pull requests that modify files that have a designated code owner." @@ -1937,8 +1972,25 @@ } } }, + "app_installations": { + "description": "Manage which repositories a GitHub App installation can access. The target is a GitHub App installation rather than a repository. Repo selection follows the config hierarchy: org-level settings.yml selects all repos in the org; suborgs/*.yml selects repos by the suborg's targeting criteria; repos/*.yml adds the specific repo. Requires safe-settings to be installed on the enterprise with 'Enterprise organization installations' permission.", + "type": "array", + "items": { + "type": "object", + "required": [ + "app_slug" + ], + "additionalProperties": false, + "properties": { + "app_slug": { + "type": "string", + "description": "The slug of the GitHub App installation to manage." + } + } + } + }, "additive_plugins": { - "description": "List of Diffable plugins to run in additive mode. In additive mode the plugin will only add and update entries; it will never call remove(), so items that exist on GitHub but are absent from the YAML are preserved. Only Diffable-extending plugins are supported (labels, collaborators, teams, milestones, autolinks, environments, custom_properties, variables, rulesets, custom_repository_roles). Declare only in settings.yml (org level) to keep behavior consistent across all repos.", + "description": "List of plugins to run in additive mode. In additive mode the plugin will only add and update entries; it will never call remove(), so items that exist on GitHub but are absent from the YAML are preserved. Supported plugins: labels, collaborators, teams, milestones, autolinks, environments, custom_properties, variables, rulesets, custom_repository_roles, app_installations. Declare only in settings.yml (org level) to keep behavior consistent across all repos.", "type": "array", "items": { "type": "string", @@ -1952,7 +2004,8 @@ "custom_properties", "variables", "rulesets", - "custom_repository_roles" + "custom_repository_roles", + "app_installations" ] } }, @@ -1977,7 +2030,8 @@ "custom_properties", "custom_repository_roles", "variables", - "archive" + "archive", + "app_installations" ] }, { @@ -2003,7 +2057,8 @@ "custom_properties", "custom_repository_roles", "variables", - "archive" + "archive", + "app_installations" ] }, "target": { diff --git a/schema/settings.json b/schema/settings.json index 02c8b36fd..2580ecff1 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -136,7 +136,49 @@ "description": "Teams", "type": "array", "items": { - "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2022-11-28.json#/paths/~1orgs~1{org}~1teams/post/requestBody/content/application~1json/schema" + "allOf": [ + { + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2022-11-28.json#/paths/~1orgs~1{org}~1teams~1{team_slug}~1repos~1{owner}~1{repo}/put/requestBody/content/application~1json/schema" + }, + { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "The name (or slug) of the team to grant access to the repo.", + "type": "string" + }, + "privacy": { + "description": "The level of privacy the team should have when it is created by safe-settings. The options are `secret` (visible only to organization owners and members of the team) or `closed` (visible to all members of the organization).", + "type": "string", + "enum": [ + "secret", + "closed" + ] + }, + "external_group": { + "description": "The display name of an external IdP group to link to this team.", + "type": "string" + }, + "exclude": { + "description": "Exclude this team from repos whose names match one of these glob patterns.", + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "description": "Apply this team only to repos whose names match one of these glob patterns.", + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] } }, "branches": { diff --git a/smoke-test.js b/smoke-test.js index 2b4d9dba7..30ef1a643 100644 --- a/smoke-test.js +++ b/smoke-test.js @@ -70,8 +70,11 @@ const CONFIG_PATH = process.env.CONFIG_PATH || '.github' const APP_ID = process.env.APP_ID const PRIVATE_KEY = (process.env.PRIVATE_KEY || '').replace(/\\n/g, '\n') -const TEST_REPOS = ['test', 'demo-repo-service1', 'demo-repo-service2', 'combined-settings-repo'] +const TEST_REPOS = ['test', 'demo-repo-service1', 'demo-repo-service2', 'combined-settings-repo', 'smoke-team-filter'] const TEST_TEAMS = ['AD-GRP-PAYMENTS-PLATFORM-OWNERS', 'awesometeam-a-approvers', 'jefeish-edj-test'] +// Teams exercised by the team include/exclude filter phase (Phase 18). +const SMOKE_FILTER_REPO = 'smoke-team-filter' +const SMOKE_FILTER_TEAMS = ['smoke-filter-included', 'smoke-filter-excluded', 'smoke-filter-nomatch'] // Principals created on demand for the ruleset name-resolution phase (Phase 16) const SMOKE_NR_TEAM = 'safe-settings-smoke-nr-team' @@ -1206,6 +1209,34 @@ const REPO_YML_NO_VARS = `repository: variables: [] ` +// Config for Phase 18: a repo-level config whose team entries carry +// include/exclude repo filters. Only the team whose include glob matches the +// repo name (or that is not excluded) should be applied by safe-settings. +// - smoke-filter-included: include matches -> applied +// - smoke-filter-excluded: exclude matches -> NOT applied +// - smoke-filter-nomatch : include does not match -> NOT applied +const REPO_TEAM_FILTER_YML = `repository: + name: ${SMOKE_FILTER_REPO} + description: Repo for team include/exclude smoke test + private: true + auto_init: true + force_create: true + +teams: + - name: ${SMOKE_FILTER_TEAMS[0]} + permission: maintain + include: + - ${SMOKE_FILTER_REPO} + - name: ${SMOKE_FILTER_TEAMS[1]} + permission: pull + exclude: + - ${SMOKE_FILTER_REPO} + - name: ${SMOKE_FILTER_TEAMS[2]} + permission: pull + include: + - no-such-repo-* +` + // ─── Test Phases ───────────────────────────────────────────────────────────── async function setup () { @@ -2471,6 +2502,7 @@ async function teardown () { log('Deleting test teams...') for (const team of TEST_TEAMS) { await deleteTeam(ORG, team.toLowerCase()) } try { await deleteTeam(ORG, SMOKE_NR_TEAM) } catch { /* ok */ } + for (const team of SMOKE_FILTER_TEAMS) { try { await deleteTeam(ORG, team) } catch { /* ok */ } } log('Deleting custom repository role...') try { await deleteCustomRepositoryRole(ORG, 'security-engineer') } catch { /* ok */ } @@ -3095,6 +3127,64 @@ async function phase17AppInstallations () { // ─── Main ──────────────────────────────────────────────────────────────────── +async function phase18TeamIncludeExclude () { + logPhase('Phase 18: Team include/exclude repo filters') + const branch = 'smoke-test-phase18' + const defaultBranch = await getDefaultBranch() + + // Clean any leftover teams from a previous aborted run so the "not applied" + // assertions can't be satisfied by stale repo-team associations. + await deleteRepo(ORG, SMOKE_FILTER_REPO) + for (const t of SMOKE_FILTER_TEAMS) { await deleteTeam(ORG, t) } + + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/${SMOKE_FILTER_REPO}.yml`, REPO_TEAM_FILTER_YML, branch, 'Add team include/exclude filter config') + + const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: team include/exclude filters', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, 'Check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + const repo = await poll(async () => { + try { return (await octokit.rest.repos.get({ owner: ORG, repo: SMOKE_FILTER_REPO })).data } catch { return null } + }, { desc: `${SMOKE_FILTER_REPO} to be created` }) + assert(repo !== null, `Repo "${SMOKE_FILTER_REPO}" was created`) + + // The included team should be applied (poll — safe-settings may still be working). + const includedTeam = await poll(async () => { + try { + const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: SMOKE_FILTER_REPO }) + return teams.find(t => t.slug === SMOKE_FILTER_TEAMS[0]) || null + } catch { return null } + }, { desc: `included team ${SMOKE_FILTER_TEAMS[0]} to be added`, timeout: 60000 }) + assert(includedTeam !== null, `Team "${SMOKE_FILTER_TEAMS[0]}" applied (include glob matches repo)`) + if (includedTeam) { + const hasMaintain = includedTeam.permission === 'maintain' || (includedTeam.permissions && includedTeam.permissions.maintain === true) + assert(hasMaintain, `Included team has maintain permission (got: ${includedTeam.permission})`) + } + + // The excluded and non-matching teams must NOT be applied. + const finalTeams = await (async () => { + try { + const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: SMOKE_FILTER_REPO }) + return teams.map(t => t.slug) + } catch { return [] } + })() + assert(!finalTeams.includes(SMOKE_FILTER_TEAMS[1]), `Team "${SMOKE_FILTER_TEAMS[1]}" NOT applied (exclude glob matches repo)`) + assert(!finalTeams.includes(SMOKE_FILTER_TEAMS[2]), `Team "${SMOKE_FILTER_TEAMS[2]}" NOT applied (include glob does not match repo)`) + + // Cleanup for standalone --phase 18 runs (teardown also cleans these). + await deleteRepo(ORG, SMOKE_FILTER_REPO) + for (const t of SMOKE_FILTER_TEAMS) { await deleteTeam(ORG, t) } + await deleteBranch(ORG, ADMIN_REPO, branch) +} + async function main () { const { App } = await import('octokit') const app = new App({ appId: APP_ID, privateKey: PRIVATE_KEY }) @@ -3162,7 +3252,8 @@ async function main () { ['Phase 14: regressions', phase14RegressionCoverage], ['Phase 15: Ruleset array drift', phase15RulesetArrayDrift], ['Phase 16: Ruleset name/slug resolution', phase16RulesetNameResolution], - ['Phase 17: App installation management', phase17AppInstallations] + ['Phase 17: App installation management', phase17AppInstallations], + ['Phase 18: Team include/exclude filters', phase18TeamIncludeExclude] ] // When --phase is given, only run setup (phase 0) + the requested phase(s). diff --git a/test/unit/lib/plugins/teams.test.js b/test/unit/lib/plugins/teams.test.js index 8879611c3..f7c599d8b 100644 --- a/test/unit/lib/plugins/teams.test.js +++ b/test/unit/lib/plugins/teams.test.js @@ -97,6 +97,53 @@ describe('Teams', () => { } }) + describe('filtering teams by include/exclude', () => { + beforeEach(() => { + github.repos.listTeams.mockResolvedValue({ data: [] }) + }) + + it('does not add a team when the repo matches an exclude glob', async () => { + const plugin = configure([ + { name: addedTeamName, permission: 'pull', exclude: ['test*'] } + ]) + + await plugin.sync() + + expect(github.teams.addOrUpdateRepoPermissionsInOrg).not.toHaveBeenCalled() + }) + + it('does not add a team when the repo is not in an include glob', async () => { + const plugin = configure([ + { name: addedTeamName, permission: 'pull', include: ['other-*'] } + ]) + + await plugin.sync() + + expect(github.teams.addOrUpdateRepoPermissionsInOrg).not.toHaveBeenCalled() + }) + + it('adds a team when the repo matches an include glob', async () => { + when(github.teams.getByName) + .calledWith({ org, team_slug: addedTeamName }) + .mockResolvedValue({ data: { id: addedTeamId } }) + + const plugin = configure([ + { name: addedTeamName, permission: 'pull', include: ['test*'] } + ]) + + await plugin.sync() + + expect(github.teams.addOrUpdateRepoPermissionsInOrg).toHaveBeenCalledWith({ + org, + team_id: addedTeamId, + team_slug: addedTeamName, + owner: org, + repo: 'test', + permission: 'pull' + }) + }) + }) + describe('external_group linking', () => { const externalGroupName = 'Engineering - Expert Services' const externalGroupId = 42