From db9858836cd4620a1a952983a1c0f191cb09f26b Mon Sep 17 00:00:00 2001 From: Iain Powell Date: Tue, 23 Jun 2026 13:33:57 -0300 Subject: [PATCH] feat(teams): document and test exclude/include repo filtering for team entries `exclude` and `include` glob filters already work for teams at runtime via `Diffable.filterEntries()` but were undocumented in all three schemas and untested. This promotes the feature to officially supported: updates TeamSettings in settings.json, suborgs.json, and repos.json to use the same allOf + exclude/ include pattern as CollaboratorSettings, adds 10 unit tests covering exact match, glob wildcards, API property stripping, and grant revocation, and adds usage examples to the sample settings files. Co-Authored-By: Claude Sonnet 4.6 --- docs/sample-settings/settings.yml | 18 ++ docs/sample-settings/suborg.yml | 22 ++ schema/dereferenced/repos.json | 281 ++++++++++++++++-------- schema/dereferenced/settings.json | 320 +++++++++++++++++----------- schema/dereferenced/suborgs.json | 281 ++++++++++++++++-------- schema/repos.json | 39 +++- schema/settings.json | 39 +++- schema/suborgs.json | 39 +++- test/unit/lib/plugins/teams.test.js | 194 +++++++++++++++++ 9 files changed, 917 insertions(+), 316 deletions(-) diff --git a/docs/sample-settings/settings.yml b/docs/sample-settings/settings.yml index 1ede6a079..4d9559a2e 100644 --- a/docs/sample-settings/settings.yml +++ b/docs/sample-settings/settings.yml @@ -161,6 +161,24 @@ teams: - name: globalteam permission: push visibility: closed + # You can exclude specific repositories from a team grant. The team is applied to + # every repository in scope *except* those matching the exclude glob patterns. + # Patterns use minimatch syntax (same engine as .gitignore-style globs). + # If a repo already has this team and later matches an exclude pattern, + # safe-settings will revoke the team membership on the next sync. + - name: engineering + permission: pull + exclude: + - sandbox-* + - test-* + # You can include only specific repositories for a team grant. The team is applied + # *only* to repositories whose names match at least one include glob pattern. + # Repositories not matching will have this team membership revoked if previously granted. + - name: platform + permission: push + include: + - platform-* + - platform-core # Branch protection rules # See https://docs.github.com/en/rest/branches/branch-protection?apiVersion=2026-03-10#update-branch-protection for available options diff --git a/docs/sample-settings/suborg.yml b/docs/sample-settings/suborg.yml index a509847cc..14f699ca2 100644 --- a/docs/sample-settings/suborg.yml +++ b/docs/sample-settings/suborg.yml @@ -14,3 +14,25 @@ suborgproperties: - EDP: true # Every other property is the same as the org level settings and can be overridden here + +# Teams +# See https://docs.github.com/en/rest/teams/teams?apiVersion=2026-03-10#create-a-team for available options +teams: + # Apply this team to every repo in the suborg + - name: suborg-owners + permission: admin + + # Apply this team to all repos in the suborg EXCEPT those matching the exclude globs. + # Patterns use minimatch syntax (same as .gitignore-style globs). + - name: engineers + permission: pull + exclude: + - '*-sandbox' + - test-* + + # Apply this team ONLY to repos in the suborg whose names match the include globs. + - name: platform-team + permission: push + include: + - platform-* + - core diff --git a/schema/dereferenced/repos.json b/schema/dereferenced/repos.json index 9213456a0..71d47d3d8 100644 --- a/schema/dereferenced/repos.json +++ b/schema/dereferenced/repos.json @@ -177,6 +177,19 @@ "description": "Either `true` to enable the wiki for this repository or `false` to disable it.", "default": true }, + "has_pull_requests": { + "type": "boolean", + "description": "Either `true` to allow pull requests for this repository or `false` to prevent pull requests.", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "description": "The policy that controls who can create pull requests for this repository: `all` or `collaborators_only`.", + "enum": [ + "all", + "collaborators_only" + ] + }, "is_template": { "type": "boolean", "description": "Either `true` to make this repo available as a template repository or `false` to prevent it.", @@ -399,54 +412,89 @@ "description": "Teams", "type": "array", "items": { - "description": "A team entry", - "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" - } - }, - "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" + "description": "A team entry. Use `exclude` and `include` to scope which repositories this team grant applies to within a suborg or org-level settings file. Patterns are evaluated using minimatch glob syntax against the repository name. Omit both properties to apply the team to every repository in scope.", + "allOf": [ + { + "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" + } + }, + "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" + ] + }, + "parent_team_id": { + "type": "integer", + "description": "The ID of a team to set as the parent team." + } + }, + "required": [ + "name" ] }, - "parent_team_id": { - "type": "integer", - "description": "The ID of a team to set as the parent team." + { + "type": "object", + "properties": { + "exclude": { + "description": "Glob patterns (minimatch) of repository names to exclude from this team grant. The team is applied to every repository in scope *except* those whose names match at least one of these patterns. If a repository is already a member of the team and later matches an exclude pattern, safe-settings will revoke that team membership.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "*-sandbox", + "test-*" + ] + ] + }, + "include": { + "description": "Glob patterns (minimatch) of repository names to include for this team grant. When set, the team is applied *only* to repositories whose names match at least one of these patterns. Repositories not matching will have the team membership revoked if it was previously granted.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-app-*", + "platform-core" + ] + ] + } + } } - }, - "required": [ - "name" ] } }, @@ -998,6 +1046,19 @@ "description": "Either `true` to enable the wiki for this repository or `false` to disable it.", "default": true }, + "has_pull_requests": { + "type": "boolean", + "description": "Either `true` to allow pull requests for this repository or `false` to prevent pull requests.", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "description": "The policy that controls who can create pull requests for this repository: `all` or `collaborators_only`.", + "enum": [ + "all", + "collaborators_only" + ] + }, "is_template": { "type": "boolean", "description": "Either `true` to make this repo available as a template repository or `false` to prevent it.", @@ -1213,54 +1274,89 @@ ] }, "TeamSettings": { - "description": "A team entry", - "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" - } - }, - "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" + "description": "A team entry. Use `exclude` and `include` to scope which repositories this team grant applies to within a suborg or org-level settings file. Patterns are evaluated using minimatch glob syntax against the repository name. Omit both properties to apply the team to every repository in scope.", + "allOf": [ + { + "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" + } + }, + "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" + ] + }, + "parent_team_id": { + "type": "integer", + "description": "The ID of a team to set as the parent team." + } + }, + "required": [ + "name" ] }, - "parent_team_id": { - "type": "integer", - "description": "The ID of a team to set as the parent team." + { + "type": "object", + "properties": { + "exclude": { + "description": "Glob patterns (minimatch) of repository names to exclude from this team grant. The team is applied to every repository in scope *except* those whose names match at least one of these patterns. If a repository is already a member of the team and later matches an exclude pattern, safe-settings will revoke that team membership.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "*-sandbox", + "test-*" + ] + ] + }, + "include": { + "description": "Glob patterns (minimatch) of repository names to include for this team grant. When set, the team is applied *only* to repositories whose names match at least one of these patterns. Repositories not matching will have the team membership revoked if it was previously granted.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-app-*", + "platform-core" + ] + ] + } + } } - }, - "required": [ - "name" ] }, "MilestoneSettings": { @@ -1563,7 +1659,7 @@ "actor_id": { "type": "integer", "nullable": true, - "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, and `Team` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories." + "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, `Team`, and `User` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories." }, "actor_type": { "type": "string", @@ -1572,7 +1668,8 @@ "OrganizationAdmin", "RepositoryRole", "Team", - "DeployKey" + "DeployKey", + "User" ], "description": "The type of actor that can bypass a ruleset." }, diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json index 4dcdf0eb6..075a9fb08 100644 --- a/schema/dereferenced/settings.json +++ b/schema/dereferenced/settings.json @@ -50,17 +50,6 @@ } } }, - "code_security": { - "type": "object", - "description": "Use the `status` property to enable or disable GitHub Code Security for this repository.", - "description": "Use the `status` property to enable or disable GitHub Advanced Security for this repository.\nFor more information, see \"[About GitHub Advanced\nSecurity](/github/getting-started-with-github/learning-about-github/about-github-advanced-security).\"\n\nFor standalone Code Scanning or Secret Protection products, this parameter cannot be used.", - "properties": { - "status": { - "type": "string", - "description": "Can be `enabled` or `disabled`." - } - } - }, "code_security": { "type": "object", "description": "Use the `status` property to enable or disable GitHub Code Security for this repository.", @@ -101,16 +90,6 @@ } } }, - "secret_scanning_ai_detection": { - "type": "object", - "description": "Use the `status` property to enable or disable secret scanning AI detection for this repository. For more information, see \"[Responsible detection of generic secrets with AI](https://docs.github.com/code-security/secret-scanning/using-advanced-secret-scanning-and-push-protection-features/generic-secret-detection/responsible-ai-generic-secrets).\"", - "properties": { - "status": { - "type": "string", - "description": "Can be `enabled` or `disabled`." - } - } - }, "secret_scanning_non_provider_patterns": { "type": "object", "description": "Use the `status` property to enable or disable secret scanning non-provider patterns for this repository. For more information, see \"[Supported secret scanning patterns](/code-security/secret-scanning/introduction/supported-secret-scanning-patterns#supported-secrets).\"", @@ -198,6 +177,19 @@ "description": "Either `true` to enable the wiki for this repository or `false` to disable it.", "default": true }, + "has_pull_requests": { + "type": "boolean", + "description": "Either `true` to allow pull requests for this repository or `false` to prevent pull requests.", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "description": "The policy that controls who can create pull requests for this repository: `all` or `collaborators_only`.", + "enum": [ + "all", + "collaborators_only" + ] + }, "is_template": { "type": "boolean", "description": "Either `true` to make this repo available as a template repository or `false` to prevent it.", @@ -329,7 +321,7 @@ } }, "force_create": { - "description": "Force create the repository even if it already exists", + "description": "Force create the repository", "type": "boolean" }, "template": { @@ -420,63 +412,89 @@ "description": "Teams", "type": "array", "items": { - "description": "A team entry", - "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" - } - }, - "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" + "description": "A team entry. Use `exclude` and `include` to scope which repositories this team grant applies to within a suborg or org-level settings file. Patterns are evaluated using minimatch glob syntax against the repository name. Omit both properties to apply the team to every repository in scope.", + "allOf": [ + { + "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" + } + }, + "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" + ] + }, + "parent_team_id": { + "type": "integer", + "description": "The ID of a team to set as the parent team." + } + }, + "required": [ + "name" ] }, - "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" - ], - "default": "pull" - }, - "parent_team_id": { - "type": "integer", - "description": "The ID of a team to set as the parent team." + { + "type": "object", + "properties": { + "exclude": { + "description": "Glob patterns (minimatch) of repository names to exclude from this team grant. The team is applied to every repository in scope *except* those whose names match at least one of these patterns. If a repository is already a member of the team and later matches an exclude pattern, safe-settings will revoke that team membership.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "*-sandbox", + "test-*" + ] + ] + }, + "include": { + "description": "Glob patterns (minimatch) of repository names to include for this team grant. When set, the team is applied *only* to repositories whose names match at least one of these patterns. Repositories not matching will have the team membership revoked if it was previously granted.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-app-*", + "platform-core" + ] + ] + } + } } - }, - "required": [ - "name" ] } }, @@ -795,7 +813,7 @@ "actor_id": { "type": "integer", "nullable": true, - "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, and `Team` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories." + "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, `Team`, and `User` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories." }, "actor_type": { "type": "string", @@ -804,7 +822,8 @@ "OrganizationAdmin", "RepositoryRole", "Team", - "DeployKey" + "DeployKey", + "User" ], "description": "The type of actor that can bypass a ruleset." }, @@ -2176,6 +2195,19 @@ "description": "Either `true` to enable the wiki for this repository or `false` to disable it.", "default": true }, + "has_pull_requests": { + "type": "boolean", + "description": "Either `true` to allow pull requests for this repository or `false` to prevent pull requests.", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "description": "The policy that controls who can create pull requests for this repository: `all` or `collaborators_only`.", + "enum": [ + "all", + "collaborators_only" + ] + }, "is_template": { "type": "boolean", "description": "Either `true` to make this repo available as a template repository or `false` to prevent it.", @@ -2307,7 +2339,7 @@ } }, "force_create": { - "description": "Force create the repository even if it already exists", + "description": "Force create the repository", "type": "boolean" }, "template": { @@ -2391,54 +2423,89 @@ ] }, "TeamSettings": { - "description": "A team entry", - "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" - } - }, - "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" + "description": "A team entry. Use `exclude` and `include` to scope which repositories this team grant applies to within a suborg or org-level settings file. Patterns are evaluated using minimatch glob syntax against the repository name. Omit both properties to apply the team to every repository in scope.", + "allOf": [ + { + "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" + } + }, + "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" + ] + }, + "parent_team_id": { + "type": "integer", + "description": "The ID of a team to set as the parent team." + } + }, + "required": [ + "name" ] }, - "parent_team_id": { - "type": "integer", - "description": "The ID of a team to set as the parent team." + { + "type": "object", + "properties": { + "exclude": { + "description": "Glob patterns (minimatch) of repository names to exclude from this team grant. The team is applied to every repository in scope *except* those whose names match at least one of these patterns. If a repository is already a member of the team and later matches an exclude pattern, safe-settings will revoke that team membership.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "*-sandbox", + "test-*" + ] + ] + }, + "include": { + "description": "Glob patterns (minimatch) of repository names to include for this team grant. When set, the team is applied *only* to repositories whose names match at least one of these patterns. Repositories not matching will have the team membership revoked if it was previously granted.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-app-*", + "platform-core" + ] + ] + } + } } - }, - "required": [ - "name" ] }, "MilestoneSettings": { @@ -2741,7 +2808,7 @@ "actor_id": { "type": "integer", "nullable": true, - "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, and `Team` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories." + "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, `Team`, and `User` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories." }, "actor_type": { "type": "string", @@ -2750,7 +2817,8 @@ "OrganizationAdmin", "RepositoryRole", "Team", - "DeployKey" + "DeployKey", + "User" ], "description": "The type of actor that can bypass a ruleset." }, diff --git a/schema/dereferenced/suborgs.json b/schema/dereferenced/suborgs.json index 0267bf7a8..ddcf9feac 100644 --- a/schema/dereferenced/suborgs.json +++ b/schema/dereferenced/suborgs.json @@ -211,6 +211,19 @@ "description": "Either `true` to enable the wiki for this repository or `false` to disable it.", "default": true }, + "has_pull_requests": { + "type": "boolean", + "description": "Either `true` to allow pull requests for this repository or `false` to prevent pull requests.", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "description": "The policy that controls who can create pull requests for this repository: `all` or `collaborators_only`.", + "enum": [ + "all", + "collaborators_only" + ] + }, "is_template": { "type": "boolean", "description": "Either `true` to make this repo available as a template repository or `false` to prevent it.", @@ -433,54 +446,89 @@ "description": "Teams", "type": "array", "items": { - "description": "A team entry", - "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" - } - }, - "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" + "description": "A team entry. Use `exclude` and `include` to scope which repositories this team grant applies to within a suborg or org-level settings file. Patterns are evaluated using minimatch glob syntax against the repository name. Omit both properties to apply the team to every repository in scope.", + "allOf": [ + { + "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" + } + }, + "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" + ] + }, + "parent_team_id": { + "type": "integer", + "description": "The ID of a team to set as the parent team." + } + }, + "required": [ + "name" ] }, - "parent_team_id": { - "type": "integer", - "description": "The ID of a team to set as the parent team." + { + "type": "object", + "properties": { + "exclude": { + "description": "Glob patterns (minimatch) of repository names to exclude from this team grant. The team is applied to every repository in scope *except* those whose names match at least one of these patterns. If a repository is already a member of the team and later matches an exclude pattern, safe-settings will revoke that team membership.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "*-sandbox", + "test-*" + ] + ] + }, + "include": { + "description": "Glob patterns (minimatch) of repository names to include for this team grant. When set, the team is applied *only* to repositories whose names match at least one of these patterns. Repositories not matching will have the team membership revoked if it was previously granted.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-app-*", + "platform-core" + ] + ] + } + } } - }, - "required": [ - "name" ] } }, @@ -1032,6 +1080,19 @@ "description": "Either `true` to enable the wiki for this repository or `false` to disable it.", "default": true }, + "has_pull_requests": { + "type": "boolean", + "description": "Either `true` to allow pull requests for this repository or `false` to prevent pull requests.", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "description": "The policy that controls who can create pull requests for this repository: `all` or `collaborators_only`.", + "enum": [ + "all", + "collaborators_only" + ] + }, "is_template": { "type": "boolean", "description": "Either `true` to make this repo available as a template repository or `false` to prevent it.", @@ -1247,54 +1308,89 @@ ] }, "TeamSettings": { - "description": "A team entry", - "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" - } - }, - "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" + "description": "A team entry. Use `exclude` and `include` to scope which repositories this team grant applies to within a suborg or org-level settings file. Patterns are evaluated using minimatch glob syntax against the repository name. Omit both properties to apply the team to every repository in scope.", + "allOf": [ + { + "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" + } + }, + "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" + ] + }, + "parent_team_id": { + "type": "integer", + "description": "The ID of a team to set as the parent team." + } + }, + "required": [ + "name" ] }, - "parent_team_id": { - "type": "integer", - "description": "The ID of a team to set as the parent team." + { + "type": "object", + "properties": { + "exclude": { + "description": "Glob patterns (minimatch) of repository names to exclude from this team grant. The team is applied to every repository in scope *except* those whose names match at least one of these patterns. If a repository is already a member of the team and later matches an exclude pattern, safe-settings will revoke that team membership.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "*-sandbox", + "test-*" + ] + ] + }, + "include": { + "description": "Glob patterns (minimatch) of repository names to include for this team grant. When set, the team is applied *only* to repositories whose names match at least one of these patterns. Repositories not matching will have the team membership revoked if it was previously granted.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-app-*", + "platform-core" + ] + ] + } + } } - }, - "required": [ - "name" ] }, "MilestoneSettings": { @@ -1597,7 +1693,7 @@ "actor_id": { "type": "integer", "nullable": true, - "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, and `Team` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories." + "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, `Team`, and `User` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories." }, "actor_type": { "type": "string", @@ -1606,7 +1702,8 @@ "OrganizationAdmin", "RepositoryRole", "Team", - "DeployKey" + "DeployKey", + "User" ], "description": "The type of actor that can bypass a ruleset." }, diff --git a/schema/repos.json b/schema/repos.json index 3a7c51301..5177b5ad5 100644 --- a/schema/repos.json +++ b/schema/repos.json @@ -189,8 +189,43 @@ ] }, "TeamSettings": { - "description": "A team entry", - "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1orgs~1{org}~1teams/post/requestBody/content/application~1json/schema" + "description": "A team entry. Use `exclude` and `include` to scope which repositories this team grant applies to within a suborg or org-level settings file. Patterns are evaluated using minimatch glob syntax against the repository name. Omit both properties to apply the team to every repository in scope.", + "allOf": [ + { + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1orgs~1{org}~1teams/post/requestBody/content/application~1json/schema" + }, + { + "type": "object", + "properties": { + "exclude": { + "description": "Glob patterns (minimatch) of repository names to exclude from this team grant. The team is applied to every repository in scope *except* those whose names match at least one of these patterns. If a repository is already a member of the team and later matches an exclude pattern, safe-settings will revoke that team membership.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "*-sandbox", + "test-*" + ] + ] + }, + "include": { + "description": "Glob patterns (minimatch) of repository names to include for this team grant. When set, the team is applied *only* to repositories whose names match at least one of these patterns. Repositories not matching will have the team membership revoked if it was previously granted.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-app-*", + "platform-core" + ] + ] + } + } + } + ] }, "MilestoneSettings": { "description": "A milestone entry", diff --git a/schema/settings.json b/schema/settings.json index 59d662d50..ab6c4c821 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -196,8 +196,43 @@ ] }, "TeamSettings": { - "description": "A team entry", - "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1orgs~1{org}~1teams/post/requestBody/content/application~1json/schema" + "description": "A team entry. Use `exclude` and `include` to scope which repositories this team grant applies to within a suborg or org-level settings file. Patterns are evaluated using minimatch glob syntax against the repository name. Omit both properties to apply the team to every repository in scope.", + "allOf": [ + { + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1orgs~1{org}~1teams/post/requestBody/content/application~1json/schema" + }, + { + "type": "object", + "properties": { + "exclude": { + "description": "Glob patterns (minimatch) of repository names to exclude from this team grant. The team is applied to every repository in scope *except* those whose names match at least one of these patterns. If a repository is already a member of the team and later matches an exclude pattern, safe-settings will revoke that team membership.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "*-sandbox", + "test-*" + ] + ] + }, + "include": { + "description": "Glob patterns (minimatch) of repository names to include for this team grant. When set, the team is applied *only* to repositories whose names match at least one of these patterns. Repositories not matching will have the team membership revoked if it was previously granted.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-app-*", + "platform-core" + ] + ] + } + } + } + ] }, "MilestoneSettings": { "description": "A milestone entry", diff --git a/schema/suborgs.json b/schema/suborgs.json index 3a3c79def..7781b636a 100644 --- a/schema/suborgs.json +++ b/schema/suborgs.json @@ -223,8 +223,43 @@ ] }, "TeamSettings": { - "description": "A team entry", - "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1orgs~1{org}~1teams/post/requestBody/content/application~1json/schema" + "description": "A team entry. Use `exclude` and `include` to scope which repositories this team grant applies to within a suborg or org-level settings file. Patterns are evaluated using minimatch glob syntax against the repository name. Omit both properties to apply the team to every repository in scope.", + "allOf": [ + { + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1orgs~1{org}~1teams/post/requestBody/content/application~1json/schema" + }, + { + "type": "object", + "properties": { + "exclude": { + "description": "Glob patterns (minimatch) of repository names to exclude from this team grant. The team is applied to every repository in scope *except* those whose names match at least one of these patterns. If a repository is already a member of the team and later matches an exclude pattern, safe-settings will revoke that team membership.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "*-sandbox", + "test-*" + ] + ] + }, + "include": { + "description": "Glob patterns (minimatch) of repository names to include for this team grant. When set, the team is applied *only* to repositories whose names match at least one of these patterns. Repositories not matching will have the team membership revoked if it was previously granted.", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-app-*", + "platform-core" + ] + ] + } + } + } + ] }, "MilestoneSettings": { "description": "A milestone entry", diff --git a/test/unit/lib/plugins/teams.test.js b/test/unit/lib/plugins/teams.test.js index de16965a6..3ad61f8ad 100644 --- a/test/unit/lib/plugins/teams.test.js +++ b/test/unit/lib/plugins/teams.test.js @@ -103,4 +103,198 @@ describe('Teams', () => { ) } }) + + // The repo name used by configure() is 'test'. All exclude/include patterns + // below are written relative to that name so the intent of each case is clear. + describe('exclude/include filtering', () => { + // Use an empty existing-teams list so these tests only exercise additions + // (or the absence of them) without interacting with the remove/update paths. + beforeEach(() => { + github.rest.repos.listTeams.mockResolvedValue({ data: [] }) + }) + + describe('exclude', () => { + it('does not apply a team when the repo name exactly matches an exclude entry', async () => { + const plugin = configure([ + { name: addedTeamName, permission: 'pull', exclude: ['test'] } + ]) + + await plugin.sync() + + expect(github.rest.teams.getByName).not.toHaveBeenCalled() + expect(github.rest.teams.addOrUpdateRepoPermissionsInOrg).not.toHaveBeenCalled() + }) + + it('applies a team when the repo name does not match any exclude entry', async () => { + const plugin = configure([ + { name: addedTeamName, permission: 'pull', exclude: ['other-*'] } + ]) + + when(github.rest.teams.getByName) + .defaultResolvedValue({}) + .calledWith({ org, team_slug: addedTeamName }) + .mockResolvedValue({ data: { id: addedTeamId } }) + + await plugin.sync() + + expect(github.rest.teams.addOrUpdateRepoPermissionsInOrg).toHaveBeenCalledWith({ + org, + team_id: addedTeamId, + team_slug: addedTeamName, + owner: org, + repo: 'test', + permission: 'pull' + }) + }) + + it('does not pass the exclude property to the GitHub API', async () => { + const plugin = configure([ + { name: addedTeamName, permission: 'pull', exclude: ['other-*'] } + ]) + + when(github.rest.teams.getByName) + .defaultResolvedValue({}) + .calledWith({ org, team_slug: addedTeamName }) + .mockResolvedValue({ data: { id: addedTeamId } }) + + await plugin.sync() + + const callArgs = github.rest.teams.addOrUpdateRepoPermissionsInOrg.mock.calls[0][0] + expect(callArgs).not.toHaveProperty('exclude') + }) + + it('supports minimatch glob wildcards in exclude patterns', async () => { + // 'test*' matches the current repo 'test' + const plugin = configure([ + { name: addedTeamName, permission: 'pull', exclude: ['test*'] } + ]) + + await plugin.sync() + + expect(github.rest.teams.addOrUpdateRepoPermissionsInOrg).not.toHaveBeenCalled() + }) + + it('removes an existing team grant when the team entry is excluded for this repo', async () => { + // The team is already applied; with exclude matching, safe-settings treats the + // entry as absent for this repo, so the existing grant is revoked. + github.rest.repos.listTeams.mockResolvedValue({ + data: [{ id: unchangedTeamId, slug: unchangedTeamName, permission: 'push' }] + }) + + const plugin = configure([ + { name: unchangedTeamName, permission: 'push', exclude: ['test'] } + ]) + + await plugin.sync() + + expect(github.request).toHaveBeenCalledWith( + 'DELETE /orgs/:owner/teams/:team_slug/repos/:owner/:repo', + { + org, + owner: org, + repo: 'test', + team_slug: unchangedTeamName + } + ) + }) + }) + + describe('include', () => { + it('applies a team when the repo name exactly matches an include entry', async () => { + const plugin = configure([ + { name: addedTeamName, permission: 'pull', include: ['test'] } + ]) + + when(github.rest.teams.getByName) + .defaultResolvedValue({}) + .calledWith({ org, team_slug: addedTeamName }) + .mockResolvedValue({ data: { id: addedTeamId } }) + + await plugin.sync() + + expect(github.rest.teams.addOrUpdateRepoPermissionsInOrg).toHaveBeenCalledWith({ + org, + team_id: addedTeamId, + team_slug: addedTeamName, + owner: org, + repo: 'test', + permission: 'pull' + }) + }) + + it('does not apply a team when the repo name does not match any include entry', async () => { + const plugin = configure([ + { name: addedTeamName, permission: 'pull', include: ['other-repo'] } + ]) + + await plugin.sync() + + expect(github.rest.teams.getByName).not.toHaveBeenCalled() + expect(github.rest.teams.addOrUpdateRepoPermissionsInOrg).not.toHaveBeenCalled() + }) + + it('does not pass the include property to the GitHub API', async () => { + const plugin = configure([ + { name: addedTeamName, permission: 'pull', include: ['test'] } + ]) + + when(github.rest.teams.getByName) + .defaultResolvedValue({}) + .calledWith({ org, team_slug: addedTeamName }) + .mockResolvedValue({ data: { id: addedTeamId } }) + + await plugin.sync() + + const callArgs = github.rest.teams.addOrUpdateRepoPermissionsInOrg.mock.calls[0][0] + expect(callArgs).not.toHaveProperty('include') + }) + + it('supports minimatch glob wildcards in include patterns', async () => { + // 'test*' matches the current repo 'test' + const plugin = configure([ + { name: addedTeamName, permission: 'pull', include: ['test*'] } + ]) + + when(github.rest.teams.getByName) + .defaultResolvedValue({}) + .calledWith({ org, team_slug: addedTeamName }) + .mockResolvedValue({ data: { id: addedTeamId } }) + + await plugin.sync() + + expect(github.rest.teams.addOrUpdateRepoPermissionsInOrg).toHaveBeenCalledWith({ + org, + team_id: addedTeamId, + team_slug: addedTeamName, + owner: org, + repo: 'test', + permission: 'pull' + }) + }) + + it('removes an existing team grant when this repo is not in the include list', async () => { + // The team is already applied; the include list does not contain this repo, + // so safe-settings treats the entry as absent and revokes the grant. + github.rest.repos.listTeams.mockResolvedValue({ + data: [{ id: unchangedTeamId, slug: unchangedTeamName, permission: 'push' }] + }) + + const plugin = configure([ + { name: unchangedTeamName, permission: 'push', include: ['other-repo'] } + ]) + + await plugin.sync() + + expect(github.request).toHaveBeenCalledWith( + 'DELETE /orgs/:owner/teams/:team_slug/repos/:owner/:repo', + { + org, + owner: org, + repo: 'test', + team_slug: unchangedTeamName + } + ) + }) + }) + }) })