From 1107316235ea457b566570a3a1ffaa49de4f4ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Thu, 12 Feb 2026 10:37:24 +0200 Subject: [PATCH 01/21] fix: apply org-level settings before loading repository configurations --- lib/plugins/branches.js | 20 ++++++++++++++------ lib/settings.js | 3 +++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/lib/plugins/branches.js b/lib/plugins/branches.js index d28e2f905..28bb09cc9 100644 --- a/lib/plugins/branches.js +++ b/lib/plugins/branches.js @@ -5,10 +5,18 @@ const Overrides = require('./overrides') const ignorableFields = [] const previewHeaders = { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } const overrides = { - 'contexts': { - 'action': 'reset', - 'type': 'array' - }, + contexts: { + action: 'reset', + type: 'array' + } +} + +// GitHub API requires these fields to be present in updateBranchProtection calls +// See: https://docs.github.com/rest/branches/branch-protection#update-branch-protection +const requiredBranchProtectionDefaults = { + required_status_checks: null, + enforce_admins: null, + restrictions: null } module.exports = class Branches extends ErrorStash { @@ -73,7 +81,7 @@ module.exports = class Branches extends ErrorStash { resArray.push(new NopCommand(this.constructor.name, this.repo, null, results)) } - Object.assign(params, branch.protection, { headers: previewHeaders }) + Object.assign(params, requiredBranchProtectionDefaults, branch.protection, { headers: previewHeaders }) if (this.nop) { resArray.push(new NopCommand(this.constructor.name, this.repo, this.github.repos.updateBranchProtection.endpoint(params), 'Add Branch Protection')) @@ -83,7 +91,7 @@ module.exports = class Branches extends ErrorStash { return this.github.repos.updateBranchProtection(params).then(res => this.log.debug(`Branch protection applied successfully ${JSON.stringify(res.url)}`)).catch(e => { this.logError(`Error applying branch protection ${JSON.stringify(e)}`); return [] }) }).catch((e) => { if (e.status === 404) { - Object.assign(params, Overrides.removeOverrides(overrides, branch.protection, {}), { headers: previewHeaders }) + Object.assign(params, requiredBranchProtectionDefaults, Overrides.removeOverrides(overrides, branch.protection, {}), { headers: previewHeaders }) if (this.nop) { resArray.push(new NopCommand(this.constructor.name, this.repo, this.github.repos.updateBranchProtection.endpoint(params), 'Add Branch Protection')) return Promise.resolve(resArray) diff --git a/lib/settings.js b/lib/settings.js index 8d9e07b2b..9a6d37bf1 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -46,6 +46,9 @@ class Settings { const settings = new Settings(nop, context, context.repo(), config, ref) try { + // Apply org-level settings (e.g., rulesets) first, matching syncAll behavior + await settings.updateOrg() + for (const repo of repos) { settings.repo = repo await settings.loadConfigs(repo) From 0051d0e26024567f1d081d060fd2529e1b7ce30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Thu, 12 Feb 2026 10:37:50 +0200 Subject: [PATCH 02/21] fix: enhance descriptions and add new properties for security features in settings.json --- schema/dereferenced/settings.json | 229 +++++++++++++++++------------- 1 file changed, 132 insertions(+), 97 deletions(-) diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json index e94a66e57..fc9a45c3e 100644 --- a/schema/dereferenced/settings.json +++ b/schema/dereferenced/settings.json @@ -39,7 +39,17 @@ "properties": { "advanced_security": { "type": "object", - "description": "Use the `status` property to enable or disable GitHub Advanced Security for this repository. For more information, see \"[About GitHub Advanced Security](/github/getting-started-with-github/learning-about-github/about-github-advanced-security).\"", + "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.", "properties": { "status": { "type": "string", @@ -67,6 +77,16 @@ } } }, + "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).\"", @@ -135,7 +155,7 @@ }, "use_squash_pr_title_as_default": { "type": "boolean", - "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property has been deprecated. Please use `squash_merge_commit_title` instead.", + "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property is closing down. Please use `squash_merge_commit_title` instead.", "default": false, "deprecated": true }, @@ -338,7 +358,7 @@ }, "maintainers": { "type": "array", - "description": "List GitHub IDs for organization members who will become team maintainers.", + "description": "List GitHub usernames for organization members who will become team maintainers.", "items": { "type": "string" } @@ -368,7 +388,7 @@ }, "permission": { "type": "string", - "description": "**Deprecated**. The permission that new repositories will be added to the team with when none is specified.", + "description": "**Closing down notice**. The permission that new repositories will be added to the team with when none is specified.", "enum": [ "pull", "push" @@ -409,7 +429,7 @@ "contexts": { "type": "array", "deprecated": true, - "description": "**Deprecated**: The list of status checks to require in order to merge into this branch. If any of these checks have recently been set by a particular GitHub App, they will be required to come from that app in future for the branch to merge. Use `checks` instead of `contexts` for more fine-grained control.", + "description": "**Closing down notice**: The list of status checks to require in order to merge into this branch. If any of these checks have recently been set by a particular GitHub App, they will be required to come from that app in future for the branch to merge. Use `checks` instead of `contexts` for more fine-grained control.", "items": { "type": "string" } @@ -663,7 +683,8 @@ "enum": [ "branch", "tag", - "push" + "push", + "repository" ], "default": "branch" }, @@ -690,7 +711,7 @@ "actor_id": { "type": "integer", "nullable": true, - "description": "The ID of the actor that can bypass a ruleset. If `actor_type` is `OrganizationAdmin`, this should be `1`. 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`, 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." }, "actor_type": { "type": "string", @@ -705,10 +726,11 @@ }, "bypass_mode": { "type": "string", - "description": "When the specified actor can bypass the ruleset. `pull_request` means that an actor can only bypass rules on pull requests. `pull_request` is not applicable for the `DeployKey` actor type. Also, `pull_request` is only applicable to branch rulesets.", + "description": "When the specified actor can bypass the ruleset. `pull_request` means that an actor can only bypass rules on pull requests. `pull_request` is not applicable for the `DeployKey` actor type. Also, `pull_request` is only applicable to branch rulesets. When `bypass_mode` is `exempt`, rules will not be run for that actor and a bypass audit entry will not be created.", "enum": [ "always", - "pull_request" + "pull_request", + "exempt" ], "default": "always" } @@ -718,7 +740,7 @@ "conditions": { "title": "Organization ruleset conditions", "type": "object", - "description": "Conditions for an organization ruleset.\nThe branch and tag rulesets conditions object should contain both `repository_name` and `ref_name` properties, or both `repository_id` and `ref_name` properties, or both `repository_property` and `ref_name` properties.\nThe push rulesets conditions object does not require the `ref_name` property.", + "description": "Conditions for an organization ruleset.\nThe branch and tag rulesets conditions object should contain both `repository_name` and `ref_name` properties, or both `repository_id` and `ref_name` properties, or both `repository_property` and `ref_name` properties.\nThe push rulesets conditions object does not require the `ref_name` property.\nFor repository policy rulesets, the conditions object should only contain the `repository_name`, the `repository_id`, or the `repository_property`.", "oneOf": [ { "type": "object", @@ -1043,83 +1065,6 @@ } } }, - { - "title": "merge_queue", - "description": "Merges must be performed via a merge queue.", - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "merge_queue" - ] - }, - "parameters": { - "type": "object", - "properties": { - "check_response_timeout_minutes": { - "type": "integer", - "description": "Maximum time for a required status check to report a conclusion. After this much time has elapsed, checks that have not reported a conclusion will be assumed to have failed", - "minimum": 1, - "maximum": 360 - }, - "grouping_strategy": { - "type": "string", - "description": "When set to ALLGREEN, the merge commit created by merge queue for each PR in the group must pass all required checks to merge. When set to HEADGREEN, only the commit at the head of the merge group, i.e. the commit containing changes from all of the PRs in the group, must pass its required checks to merge.", - "enum": [ - "ALLGREEN", - "HEADGREEN" - ] - }, - "max_entries_to_build": { - "type": "integer", - "description": "Limit the number of queued pull requests requesting checks and workflow runs at the same time.", - "minimum": 0, - "maximum": 100 - }, - "max_entries_to_merge": { - "type": "integer", - "description": "The maximum number of PRs that will be merged together in a group.", - "minimum": 0, - "maximum": 100 - }, - "merge_method": { - "type": "string", - "description": "Method to use when merging changes from queued pull requests.", - "enum": [ - "MERGE", - "SQUASH", - "REBASE" - ] - }, - "min_entries_to_merge": { - "type": "integer", - "description": "The minimum number of PRs that will be merged together in a group.", - "minimum": 0, - "maximum": 100 - }, - "min_entries_to_merge_wait_minutes": { - "type": "integer", - "description": "The time merge queue should wait after the first PR is added to the queue for the minimum group size to be met. After this time has elapsed, the minimum group size will be ignored and a smaller group will be merged.", - "minimum": 0, - "maximum": 360 - } - }, - "required": [ - "check_response_timeout_minutes", - "grouping_strategy", - "max_entries_to_build", - "max_entries_to_merge", - "merge_method", - "min_entries_to_merge", - "min_entries_to_merge_wait_minutes" - ] - } - } - }, { "title": "required_deployments", "description": "Choose which environments must be successfully deployed to before refs can be pushed into a ref that matches this rule.", @@ -1184,6 +1129,18 @@ "parameters": { "type": "object", "properties": { + "allowed_merge_methods": { + "type": "array", + "description": "Array of allowed merge methods. Allowed values include `merge`, `squash`, and `rebase`. At least one option must be enabled.", + "items": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + } + }, "dismiss_stale_reviews_on_push": { "type": "boolean", "description": "New, reviewable commits pushed will dismiss previous pull request review approvals." @@ -1205,6 +1162,55 @@ "required_review_thread_resolution": { "type": "boolean", "description": "All conversations on code must be resolved before a pull request can be merged." + }, + "required_reviewers": { + "type": "array", + "description": "> [!NOTE]\n> `required_reviewers` is in beta and subject to change.\n\nA collection of reviewers and associated file patterns. Each reviewer has a list of file patterns which determine the files that reviewer is required to review.", + "items": { + "title": "RequiredReviewerConfiguration", + "description": "A reviewing team, and file patterns describing which files they must approve changes to.", + "type": "object", + "properties": { + "file_patterns": { + "type": "array", + "description": "Array of file patterns. Pull requests which change matching files must be approved by the specified team. File patterns use fnmatch syntax.", + "items": { + "type": "string" + } + }, + "minimum_approvals": { + "type": "integer", + "description": "Minimum number of approvals required from the specified team. If set to zero, the team will be added to the pull request but approval is optional." + }, + "reviewer": { + "title": "Reviewer", + "description": "A required reviewing team", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the reviewer which must review changes to matching files." + }, + "type": { + "type": "string", + "description": "The type of the reviewer", + "enum": [ + "Team" + ] + } + }, + "required": [ + "id", + "type" + ] + } + }, + "required": [ + "file_patterns", + "minimum_approvals", + "reviewer" + ] + } } }, "required": [ @@ -1307,7 +1313,7 @@ "properties": { "name": { "type": "string", - "description": "How this rule will appear to users." + "description": "How this rule appears when configuring it." }, "negate": { "type": "boolean", @@ -1354,7 +1360,7 @@ "properties": { "name": { "type": "string", - "description": "How this rule will appear to users." + "description": "How this rule appears when configuring it." }, "negate": { "type": "boolean", @@ -1401,7 +1407,7 @@ "properties": { "name": { "type": "string", - "description": "How this rule will appear to users." + "description": "How this rule appears when configuring it." }, "negate": { "type": "boolean", @@ -1448,7 +1454,7 @@ "properties": { "name": { "type": "string", - "description": "How this rule will appear to users." + "description": "How this rule appears when configuring it." }, "negate": { "type": "boolean", @@ -1495,7 +1501,7 @@ "properties": { "name": { "type": "string", - "description": "How this rule will appear to users." + "description": "How this rule appears when configuring it." }, "negate": { "type": "boolean", @@ -1525,7 +1531,7 @@ }, { "title": "file_path_restriction", - "description": "Prevent commits that include changes in specified file paths from being pushed to the commit graph.", + "description": "Prevent commits that include changes in specified file and folder paths from being pushed to the commit graph. This includes absolute paths that contain file names.", "type": "object", "required": [ "type" @@ -1556,7 +1562,7 @@ }, { "title": "max_file_path_length", - "description": "Prevent commits that include file paths that exceed a specified character limit from being pushed to the commit graph.", + "description": "Prevent commits that include file paths that exceed the specified character limit from being pushed to the commit graph.", "type": "object", "required": [ "type" @@ -1573,9 +1579,9 @@ "properties": { "max_file_path_length": { "type": "integer", - "description": "The maximum amount of characters allowed in file paths", + "description": "The maximum amount of characters allowed in file paths.", "minimum": 1, - "maximum": 256 + "maximum": 32767 } }, "required": [ @@ -1617,7 +1623,7 @@ }, { "title": "max_file_size", - "description": "Prevent commits that exceed a specified file size limit from being pushed to the commit.", + "description": "Prevent commits with individual files that exceed the specified limit from being pushed to the commit graph.", "type": "object", "required": [ "type" @@ -1768,6 +1774,35 @@ ] } } + }, + { + "title": "copilot_code_review", + "description": "Request Copilot code review for new pull requests automatically if the author has access to Copilot code review and their premium requests quota has not reached the limit.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "copilot_code_review" + ] + }, + "parameters": { + "type": "object", + "properties": { + "review_draft_pull_requests": { + "type": "boolean", + "description": "Copilot automatically reviews draft pull requests before they are marked as ready for review." + }, + "review_on_push": { + "type": "boolean", + "description": "Copilot automatically reviews each new push to the pull request." + } + } + } + } } ] } From bb1b033f9b9a9b4463554ce731fa4b602155bba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Thu, 12 Feb 2026 17:59:10 +0200 Subject: [PATCH 03/21] fix: update description for deprecated squash-merge commit title property in settings.json --- schema/dereferenced/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json index fc9a45c3e..df3d9231a 100644 --- a/schema/dereferenced/settings.json +++ b/schema/dereferenced/settings.json @@ -155,7 +155,7 @@ }, "use_squash_pr_title_as_default": { "type": "boolean", - "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property is closing down. Please use `squash_merge_commit_title` instead.", + "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property is closing down. Please use `squash_merge_commit_title` instead.**", "default": false, "deprecated": true }, From 9d141d85b0522cf3d8f8e3b0266962de7d89c1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Thu, 12 Mar 2026 11:06:02 +0200 Subject: [PATCH 04/21] test: update branch protection tests to handle null restrictions and enforce_admins --- test/unit/lib/plugins/branches.test.js | 61 ++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/test/unit/lib/plugins/branches.test.js b/test/unit/lib/plugins/branches.test.js index 4b3683f34..889ed8ee8 100644 --- a/test/unit/lib/plugins/branches.test.js +++ b/test/unit/lib/plugins/branches.test.js @@ -65,6 +65,7 @@ describe('Branches', () => { required_pull_request_reviews: { require_code_owner_reviews: true }, + restrictions: null, headers: { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } }) }) @@ -188,12 +189,68 @@ describe('Branches', () => { strict: true, contexts: [] }, + // When override processing clears {{EXTERNALLY_DEFINED}} contexts, + // enforce_admins defaults to null since config doesn't specify it + enforce_admins: null, + restrictions: null, headers: { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } }) }) }) }) + describe('when existing protection has restrictions', () => { + it('preserves restrictions from GitHub when config omits them', () => { + github.repos.getBranchProtection = jest.fn().mockResolvedValue({ + data: { + enforce_admins: { enabled: true }, + required_status_checks: { + strict: false, + contexts: ['ci-check'], + checks: [] + }, + restrictions: { + url: 'https://api.github.com/...', + users: [{ login: 'user1' }, { login: 'user2' }], + teams: [{ slug: 'team-a' }], + apps: [{ slug: 'app-bot' }] + } + } + }) + + // Config only specifies enforce_admins, omits restrictions + const plugin = configure([{ + name: 'main', + protection: { + enforce_admins: false + } + }]) + + return plugin.sync().then(() => { + expect(github.repos.updateBranchProtection).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'bkeepers', + repo: 'test', + branch: 'main', + enforce_admins: false, + // Existing restrictions should be preserved from GitHub + restrictions: { + users: ['user1', 'user2'], + teams: ['team-a'], + apps: ['app-bot'] + }, + // Existing required_status_checks should be preserved from GitHub + required_status_checks: { + strict: false, + contexts: ['ci-check'], + checks: [] + } + }) + ) + }) + }) + }) + describe('when {{EXTERNALLY_DEFINED}} is present in "required_status_checks" and status checks exist in GitHub', () => { it('it retains the status checks from GitHub', () => { github.repos.getBranchProtection = jest.fn().mockResolvedValue({ @@ -227,6 +284,8 @@ describe('Branches', () => { strict: true, contexts: ['check-1', 'check-2'] }, + enforce_admins: null, + restrictions: null, headers: { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } }) }) @@ -265,6 +324,8 @@ describe('Branches', () => { repo: 'test', branch: 'other', enforce_admins: false, + required_status_checks: null, + restrictions: null, headers: { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } }) }) From dce5977664cf68d4a8cd1706ee7099ea40a575ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Fri, 13 Mar 2026 16:57:58 +0200 Subject: [PATCH 05/21] fix: normalize branch protection restrictions and preserve existing settings --- lib/plugins/branches.js | 14 +++++++- test/unit/lib/plugins/branches.test.js | 50 +++++++++++++++++++------- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/lib/plugins/branches.js b/lib/plugins/branches.js index 28bb09cc9..832756fbf 100644 --- a/lib/plugins/branches.js +++ b/lib/plugins/branches.js @@ -81,7 +81,7 @@ module.exports = class Branches extends ErrorStash { resArray.push(new NopCommand(this.constructor.name, this.repo, null, results)) } - Object.assign(params, requiredBranchProtectionDefaults, branch.protection, { headers: previewHeaders }) + Object.assign(params, requiredBranchProtectionDefaults, this.reformatAndReturnBranchProtection(structuredClone(result.data)), branch.protection, { headers: previewHeaders }) if (this.nop) { resArray.push(new NopCommand(this.constructor.name, this.repo, this.github.repos.updateBranchProtection.endpoint(params), 'Add Branch Protection')) @@ -131,6 +131,18 @@ module.exports = class Branches extends ErrorStash { protection.required_linear_history = protection.required_linear_history && protection.required_linear_history.enabled protection.enforce_admins = protection.enforce_admins && protection.enforce_admins.enabled protection.required_signatures = protection.required_signatures && protection.required_signatures.enabled + if (protection.restrictions) { + delete protection.restrictions.url + protection.restrictions.users = Array.isArray(protection.restrictions.users) + ? protection.restrictions.users.map(user => user.login || user) + : [] + protection.restrictions.teams = Array.isArray(protection.restrictions.teams) + ? protection.restrictions.teams.map(team => team.slug || team) + : [] + protection.restrictions.apps = Array.isArray(protection.restrictions.apps) + ? protection.restrictions.apps.map(app => app.slug || app) + : [] + } if (protection.required_pull_request_reviews && !protection.required_pull_request_reviews.bypass_pull_request_allowances) { protection.required_pull_request_reviews.bypass_pull_request_allowances = { apps: [], teams: [], users: [] } } diff --git a/test/unit/lib/plugins/branches.test.js b/test/unit/lib/plugins/branches.test.js index 889ed8ee8..44bb04f0e 100644 --- a/test/unit/lib/plugins/branches.test.js +++ b/test/unit/lib/plugins/branches.test.js @@ -181,7 +181,7 @@ describe('Branches', () => { ) return plugin.sync().then(() => { - expect(github.repos.updateBranchProtection).toHaveBeenCalledWith({ + expect(github.repos.updateBranchProtection).toHaveBeenCalledWith(expect.objectContaining({ owner: 'bkeepers', repo: 'test', branch: 'main', @@ -189,12 +189,11 @@ describe('Branches', () => { strict: true, contexts: [] }, - // When override processing clears {{EXTERNALLY_DEFINED}} contexts, - // enforce_admins defaults to null since config doesn't specify it - enforce_admins: null, + // Existing enforce_admins should be preserved from GitHub + enforce_admins: false, restrictions: null, headers: { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } - }) + })) }) }) }) @@ -249,6 +248,35 @@ describe('Branches', () => { ) }) }) + + it('normalizes restrictions and defaults missing arrays when preserving from GitHub', () => { + github.repos.getBranchProtection = jest.fn().mockResolvedValue({ + data: { + enforce_admins: { enabled: true }, + restrictions: { + url: 'https://api.github.com/...', + users: [{ login: 'user1' }] + } + } + }) + + const plugin = configure([{ + name: 'main', + protection: { + enforce_admins: false + } + }]) + + return plugin.sync().then(() => { + const payload = github.repos.updateBranchProtection.mock.calls[0][0] + expect(payload.restrictions).toEqual({ + users: ['user1'], + teams: [], + apps: [] + }) + expect(payload.restrictions.url).toBeUndefined() + }) + }) }) describe('when {{EXTERNALLY_DEFINED}} is present in "required_status_checks" and status checks exist in GitHub', () => { @@ -256,10 +284,8 @@ describe('Branches', () => { github.repos.getBranchProtection = jest.fn().mockResolvedValue({ data: { enforce_admins: { enabled: false }, - protection: { - required_status_checks: { - contexts: ['check-1', 'check-2'] - } + required_status_checks: { + contexts: ['check-1', 'check-2'] } } }) @@ -276,7 +302,7 @@ describe('Branches', () => { ) return plugin.sync().then(() => { - expect(github.repos.updateBranchProtection).toHaveBeenCalledWith({ + expect(github.repos.updateBranchProtection).toHaveBeenCalledWith(expect.objectContaining({ owner: 'bkeepers', repo: 'test', branch: 'main', @@ -284,10 +310,10 @@ describe('Branches', () => { strict: true, contexts: ['check-1', 'check-2'] }, - enforce_admins: null, + enforce_admins: false, restrictions: null, headers: { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } - }) + })) }) }) }) From d57526e2b3cbbce2d10a32f54e3ebe51a26ace84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Fri, 27 Mar 2026 14:30:16 +0200 Subject: [PATCH 06/21] Update api endpoint version --- app.yml | 2 +- docs/github-settings/1. repository-settings.md | 4 ++-- docs/github-settings/2. repository-variables.md | 2 +- docs/github-settings/3. collaborators.md | 4 ++-- docs/github-settings/4. teams.md | 2 +- docs/github-settings/5. branch-protection.md | 2 +- docs/github-settings/6. deployment-environments.md | 6 +++--- docs/github-settings/7. autolinks.md | 2 +- docs/github-settings/8. labels.md | 6 +++--- docs/sample-settings/settings.yml | 12 ++++++------ index.js | 8 ++++---- lib/plugins/autolinks.js | 2 +- lib/plugins/collaborators.js | 2 +- lib/plugins/rulesets.js | 2 +- lib/plugins/variables.js | 8 ++++---- lib/settings.js | 2 +- schema/settings.json | 12 ++++++------ test/unit/lib/plugins/rulesets.test.js | 2 +- 18 files changed, 40 insertions(+), 40 deletions(-) diff --git a/app.yml b/app.yml index 04b1f7015..1a72906d7 100644 --- a/app.yml +++ b/app.yml @@ -115,7 +115,7 @@ default_permissions: organization_administration: write # Manage Actions variables. - # https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28 + # https://docs.github.com/en/rest/actions/variables?apiVersion=2026-03-10 actions_variables: write diff --git a/docs/github-settings/1. repository-settings.md b/docs/github-settings/1. repository-settings.md index 4d7fc0785..46eaa64b9 100644 --- a/docs/github-settings/1. repository-settings.md +++ b/docs/github-settings/1. repository-settings.md @@ -50,8 +50,8 @@ repository: >[!TIP] >GitHub's API documentation defines these inputs and types: ->1. [Update an environment](https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#update-a-repository) ->2. [Replace all repository topics](https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#replace-all-repository-topics) +>1. [Update an environment](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#update-a-repository) +>2. [Replace all repository topics](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#replace-all-repository-topics)
diff --git a/docs/github-settings/2. repository-variables.md b/docs/github-settings/2. repository-variables.md index 5a9871b70..8d6d733f6 100644 --- a/docs/github-settings/2. repository-variables.md +++ b/docs/github-settings/2. repository-variables.md @@ -18,7 +18,7 @@ variables: >[!TIP] >GitHub's API documentation defines these inputs and types: ->1. [Update a repository variable](https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#update-a-repository-variable) +>1. [Update a repository variable](https://docs.github.com/en/rest/actions/variables?apiVersion=2026-03-10#update-a-repository-variable)
diff --git a/docs/github-settings/3. collaborators.md b/docs/github-settings/3. collaborators.md index bbc971c14..56a0ac595 100644 --- a/docs/github-settings/3. collaborators.md +++ b/docs/github-settings/3. collaborators.md @@ -20,8 +20,8 @@ collaborators: >[!TIP] >GitHub's API documentation defines these inputs and types: ->1. [Add a repository collaborator](https://docs.github.com/en/rest/collaborators/collaborators?apiVersion=2022-11-28#add-a-repository-collaborator) ->2. [Remove a repository collaborator](https://docs.github.com/en/rest/collaborators/collaborators?apiVersion=2022-11-28#remove-a-repository-collaborator) +>1. [Add a repository collaborator](https://docs.github.com/en/rest/collaborators/collaborators?apiVersion=2026-03-10#add-a-repository-collaborator) +>2. [Remove a repository collaborator](https://docs.github.com/en/rest/collaborators/collaborators?apiVersion=2026-03-10#remove-a-repository-collaborator)
diff --git a/docs/github-settings/4. teams.md b/docs/github-settings/4. teams.md index 496b30a32..e6085ebcd 100644 --- a/docs/github-settings/4. teams.md +++ b/docs/github-settings/4. teams.md @@ -20,7 +20,7 @@ teams: >[!TIP] >GitHub's API documentation defines these inputs and types: ->1. [Add or update team repository permissions](https://docs.github.com/en/rest/teams/teams?apiVersion=2022-11-28#add-or-update-team-repository-permissions) +>1. [Add or update team repository permissions](https://docs.github.com/en/rest/teams/teams?apiVersion=2026-03-10#add-or-update-team-repository-permissions)
diff --git a/docs/github-settings/5. branch-protection.md b/docs/github-settings/5. branch-protection.md index 4874a1d79..8f054f818 100644 --- a/docs/github-settings/5. branch-protection.md +++ b/docs/github-settings/5. branch-protection.md @@ -55,7 +55,7 @@ branches: >[!TIP] >GitHub's API documentation defines these inputs and types: ->1. [Update a repository variable](https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#update-a-repository-variable) +>1. [Update a repository variable](https://docs.github.com/en/rest/actions/variables?apiVersion=2026-03-10#update-a-repository-variable)
diff --git a/docs/github-settings/6. deployment-environments.md b/docs/github-settings/6. deployment-environments.md index c4705b33b..87c9e6b81 100644 --- a/docs/github-settings/6. deployment-environments.md +++ b/docs/github-settings/6. deployment-environments.md @@ -46,9 +46,9 @@ environments: >[!TIP] >GitHub's API documentation defines these inputs and types: ->1. [Create or update an environment](https://docs.github.com/en/rest/deployments/environments?apiVersion=2022-11-28#create-or-update-an-environment) ->2. [Create a deployment branch policy](https://docs.github.com/en/rest/deployments/branch-policies?apiVersion=2022-11-28#create-a-deployment-branch-policy) ->3. [Create an environment variable](https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#create-an-environment-variable) +>1. [Create or update an environment](https://docs.github.com/en/rest/deployments/environments?apiVersion=2026-03-10#create-or-update-an-environment) +>2. [Create a deployment branch policy](https://docs.github.com/en/rest/deployments/branch-policies?apiVersion=2026-03-10#create-a-deployment-branch-policy) +>3. [Create an environment variable](https://docs.github.com/en/rest/actions/variables?apiVersion=2026-03-10#create-an-environment-variable) ') - expect(body).toContain('') + expect(body).toContain('Rulesets — 1 repo, 1 policy changed') + expect(body).toContain('**admin**') + expect(body).not.toContain('| Repo |') + expect(body).not.toContain('
diff --git a/docs/github-settings/7. autolinks.md b/docs/github-settings/7. autolinks.md index 9ae3c19f7..26b5143ca 100644 --- a/docs/github-settings/7. autolinks.md +++ b/docs/github-settings/7. autolinks.md @@ -20,7 +20,7 @@ variables: >[!TIP] >GitHub's API documentation defines these inputs and types: ->1. [Create an autolink reference for a repository](https://docs.github.com/en/rest/repos/autolinks?apiVersion=2022-11-28#create-an-autolink-reference-for-a-repository) +>1. [Create an autolink reference for a repository](https://docs.github.com/en/rest/repos/autolinks?apiVersion=2026-03-10#create-an-autolink-reference-for-a-repository) ') + expect(body).toContain('') }) - it('uses the same compact row model in the check-run summary', async () => { + it('uses the same expanded display model in the check-run summary', async () => { const { context, checksUpdate } = buildContext() const result = makeNopResult({ repo: 'my-repo', @@ -407,10 +439,10 @@ describe('handleResults()', () => { const summary = checksUpdate.mock.calls[0][0].output.summary expect(summary).toContain('Number of repos affected') - expect(summary).toContain('| Repo | Policy / Setting | Change |') + expect(summary).toContain('') expect(summary).toContain('my-repo') - expect(summary).toContain('bug') - expect(summary).toContain('Changed: color: blue') + expect(summary).toContain('#### bug') + expect(summary).toContain('') }) it('prefers structured action fields over generic msg text', async () => { @@ -434,9 +466,86 @@ describe('handleResults()', () => { const body = getCombinedCommentBody(createComment) expect(body).toContain('security') - expect(body).toContain('Added: color: red') + expect(body).toContain('') expect(body).not.toContain('Changes found') }) + + it('renders nested object values inside details blocks', async () => { + const { context, createComment } = buildContext() + const result = makeNopResult({ + repo: 'my-repo', + plugin: 'branches', + additions: [{ + name: 'main', + required_workflows: [{ path: '.github/workflows/build.yml', ref: 'main' }] + }] + }) + const settings = buildSettings(context, [result]) + + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('#### main') + expect(body).toContain('
') + expect(body).toContain('"path"') + }) + + it('shows added and deleted fields within a matched modification', async () => { + const { context, createComment } = buildContext() + const result = { + type: 'NOP', + plugin: 'labels', + repo: 'my-repo', + endpoint: '', + body: {}, + action: { + additions: null, + deletions: [{ name: 'bug', color: 'red', oldOnly: true }], + modifications: [{ name: 'bug', color: 'blue', newOnly: true }] + } + } + const settings = buildSettings(context, [result]) + + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('
') + expect(body).toContain('') + expect(body).toContain('') + }) + + it('detects nested value modifications beyond the display preview', async () => { + const { context, createComment } = buildContext() + const sharedPrefix = 'a'.repeat(120) + const result = { + type: 'NOP', + plugin: 'branches', + repo: 'my-repo', + endpoint: '', + body: {}, + action: { + additions: null, + deletions: [{ + name: 'main', + required_workflows: [{ path: `${sharedPrefix}-OLD.yml`, ref: 'main' }], + enforce_admins: false + }], + modifications: [{ + name: 'main', + required_workflows: [{ path: `${sharedPrefix}-NEW.yml`, ref: 'main' }], + enforce_admins: true + }] + } + } + const settings = buildSettings(context, [result]) + + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('') + expect(body).toContain('-OLD.yml') + expect(body).toContain('-NEW.yml') + }) }) // ------------------------------------------------------------------------- From ed58fb6f3675e32706c67aaa098c4d4105568602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Tue, 19 May 2026 14:34:10 +0300 Subject: [PATCH 19/21] fix: preserve same-value fields in NOP comments Do not suppress non-identity fields just because their value matches the changed target name. Identity fields are already skipped by path while flattening, so same-value fields like description: bug should remain visible in field-level NOP comment details. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/settings.js | 2 +- test/unit/lib/handleResults.test.js | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/settings.js b/lib/settings.js index 248ff421f..fe02cbd09 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -260,7 +260,7 @@ function getChangeTarget (entry, fallback) { function rowsForAddedOrDeleted (change, entry, target) { const flattened = flattenForSummary(entry, true) - const fields = Object.keys(flattened).filter(path => flattened[path].text !== target) + const fields = Object.keys(flattened) if (fields.length === 0) return [createFieldChangeRow(change, 'value', change === 'Added' ? '' : target, change === 'Added' ? target : '')] return fields.map(path => { diff --git a/test/unit/lib/handleResults.test.js b/test/unit/lib/handleResults.test.js index f733f5245..e7a5e088c 100644 --- a/test/unit/lib/handleResults.test.js +++ b/test/unit/lib/handleResults.test.js @@ -546,6 +546,22 @@ describe('handleResults()', () => { expect(body).toContain('-OLD.yml') expect(body).toContain('-NEW.yml') }) + + it('does not hide non-identity fields whose value equals the target name', async () => { + const { context, createComment } = buildContext() + const result = makeNopResult({ + repo: 'my-repo', + plugin: 'labels', + additions: [{ name: 'bug', description: 'bug', color: 'red' }] + }) + const settings = buildSettings(context, [result]) + + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('') + expect(body).toContain('') + }) }) // ------------------------------------------------------------------------- From 0e31a8c9e2c6b95c945670b807db7a71e3c788bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Thu, 21 May 2026 12:41:17 +0300 Subject: [PATCH 20/21] feat: simplify NOP comment layout Replace the NOP change matrix and HTML field tables with a table-free bullet layout that groups changes by plugin, target, and rule or setting. Preserve admin-repo display for organization-level settings and keep changed-field-only filtering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/commentmessage.js | 12 +- lib/settings.js | 193 ++++++++++++++++++++-------- test/unit/lib/handleResults.test.js | 123 ++++++++++++++---- 3 files changed, 241 insertions(+), 87 deletions(-) diff --git a/lib/commentmessage.js b/lib/commentmessage.js index 25e584d9a..22ee44178 100644 --- a/lib/commentmessage.js +++ b/lib/commentmessage.js @@ -1,11 +1,9 @@ module.exports = `* Run on: \`<%= new Date() %>\` -* Number of repos considered: \`<%= Object.keys(it.reposProcessed).length %>\` +* Number of repos that were considered: \`<%= Object.keys(it.reposProcessed).length %>\` * Number of repos affected: \`<%= it.reposAffected || 0 %>\` ---- - -## Changes +### Breakdown of changes <% if (!it.changeSections || it.changeSections.length === 0) { %> No changes to apply. @@ -14,12 +12,10 @@ No changes to apply. <%~ it.checkRunDetails %> <% } %> ---- - -## Errors +### Breakdown of errors <% if (Object.keys(it.errors).length === 0) { %> -None +\`None\` <% } else { %>
diff --git a/lib/settings.js b/lib/settings.js index fe02cbd09..116709c2f 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -97,17 +97,16 @@ function filterActionByChangedNames (action, changedNames) { return filtered } -function buildChangeSections (changes) { +function buildChangeSections (changes, baseConfig, config) { return Object.keys(changes).map(plugin => { const repoSections = [] Object.keys(changes[plugin]).forEach(repo => { const targetMap = new Map() changes[plugin][repo].forEach(action => { - expandedTargetsForAction(plugin, action).forEach(target => { + targetsForAction(plugin, repo, action, baseConfig, config).forEach(target => { if (!targetMap.has(target.target)) { targetMap.set(target.target, { target: target.target, - targetCell: markdownTableCell(target.target), rows: [] }) } @@ -116,7 +115,6 @@ function buildChangeSections (changes) { }) repoSections.push({ repo, - repoCell: markdownTableCell(repo), targets: Array.from(targetMap.values()).filter(target => target.rows.length > 0) }) }) @@ -142,49 +140,58 @@ function buildChangeSections (changes) { }).filter(section => section.repoSections.length > 0) } -function buildAffectedRepoMatrix (changeSections) { - const repoPlugins = new Map() - changeSections.forEach(section => { - section.repoSections.forEach(repoSection => { - if (!repoPlugins.has(repoSection.repo)) repoPlugins.set(repoSection.repo, new Set()) - repoPlugins.get(repoSection.repo).add(section.plugin) - }) - }) - - const plugins = changeSections.map(section => section.plugin) - const rows = Array.from(repoPlugins.keys()).sort().map(repo => { - return `| ${markdownTableCell(repo)} | ${plugins.map(plugin => repoPlugins.get(repo).has(plugin) ? ':hand:' : '').join(' | ')} |` - }) - - return `
\nOverview — ${rows.length} affected ${pluralize(rows.length, 'repo', 'repos')}\n\nOnly affected repositories are listed.\n\n| Repo | ${plugins.map(plugin => `${markdownTableCell(plugin)} settings`).join(' | ')} |\n| --- | ${plugins.map(() => '---').join(' | ')} |\n${rows.join('\n')}\n\n:hand: -> Changes to be applied.\n\n
` -} - function renderChangeSections (changeSections) { return changeSections.map(section => { const repoBlocks = section.repoSections.map(repoSection => { const targetBlocks = repoSection.targets.map(target => { - return `#### ${markdownTableCell(target.target)}\n\n${renderFieldChangeTable(target.rows)}` + return `- ${markdownInlineCode(target.target)}\n${renderFieldChangeList(target.rows, ' ')}` }) - return `### ${markdownTableCell(repoSection.repo)}\n\n${targetBlocks.join('\n\n')}` + return `**${markdownText(displayRepoName(repoSection.repo))}**\n${targetBlocks.join('\n')}` }) - return `
\n🔌 ${escapeHtml(section.plugin)} — ${escapeHtml(section.impactSummary)}\n\n${repoBlocks.join('\n\n')}\n\n
` + return `
\n${escapeHtml(section.plugin)} — ${escapeHtml(section.impactSummary)}\n\n${repoBlocks.join('\n\n')}\n\n
` }) } -function renderFieldChangeTable (rows) { - const tableRows = rows.map(row => { - return `
` +function affectedRepoCount (changeSections) { + return new Set(changeSections.flatMap(section => { + return section.repoSections.map(repoSection => displayRepoName(repoSection.repo)) + })).size +} + +function displayRepoName (repo) { + return repo && repo.endsWith('(org)') ? env.ADMIN_REPO : repo +} + +function renderFieldChangeList (rows, indent = '') { + return rows.map(row => { + const marker = changeMarker(row.change) + if (row.change === 'Info') { + return `${indent}- ${marker} ${markdownText(row.after || row.before || row.field)}` + } + if (row.change === 'Modified') { + return `${indent}- ${marker} ${markdownInlineCode(row.field)}\n${indent} - before: ${markdownInlineCode(row.before, row.after)}\n${indent} - after: ${markdownInlineCode(row.after, row.before)}` + } + const value = row.change === 'Deleted' ? row.before : row.after + return `${indent}- ${marker} ${markdownInlineCode(row.field)}: ${markdownInlineCode(value)}` }).join('\n') +} - return `
diff --git a/docs/github-settings/8. labels.md b/docs/github-settings/8. labels.md index ddf4c4b43..cee530f47 100644 --- a/docs/github-settings/8. labels.md +++ b/docs/github-settings/8. labels.md @@ -19,9 +19,9 @@ labels: >[!TIP] >GitHub's API documentation defines these inputs and types: ->1. [Create a label](https://docs.github.com/en/rest/issues/labels?apiVersion=2022-11-28#create-a-label) ->2. [Update a label](https://docs.github.com/en/rest/issues/labels?apiVersion=2022-11-28#update-a-label) ->3. [Delete a label](https://docs.github.com/en/rest/issues/labels?apiVersion=2022-11-28#delete-a-label) +>1. [Create a label](https://docs.github.com/en/rest/issues/labels?apiVersion=2026-03-10#create-a-label) +>2. [Update a label](https://docs.github.com/en/rest/issues/labels?apiVersion=2026-03-10#update-a-label) +>3. [Delete a label](https://docs.github.com/en/rest/issues/labels?apiVersion=2026-03-10#delete-a-label) ` + }).join('\n') + + return `
diff --git a/docs/sample-settings/settings.yml b/docs/sample-settings/settings.yml index 7e19d3354..1ede6a079 100644 --- a/docs/sample-settings/settings.yml +++ b/docs/sample-settings/settings.yml @@ -1,7 +1,7 @@ # This settings file can be used to create org-level settings # This is the settings that need to be applied to all repositories in the org -# See https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#create-an-organization-repository for all available settings for a repository +# See https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#create-an-organization-repository for all available settings for a repository repository: # A short description of the repository that will show up on GitHub description: description of the repo @@ -123,7 +123,7 @@ milestones: state: open # Collaborators: give specific users access to any repository. -# See https://docs.github.com/en/rest/collaborators/collaborators?apiVersion=2022-11-28#add-a-repository-collaborator for available options +# See https://docs.github.com/en/rest/collaborators/collaborators?apiVersion=2026-03-10#add-a-repository-collaborator for available options collaborators: - username: regpaco # The permission to grant the collaborator. Can be one of: @@ -144,7 +144,7 @@ collaborators: - another-repo # Teams -# See https://docs.github.com/en/rest/teams/teams?apiVersion=2022-11-28#create-a-team for available options +# See https://docs.github.com/en/rest/teams/teams?apiVersion=2026-03-10#create-a-team for available options teams: - name: core # The permission to grant the team. Can be one of: @@ -163,7 +163,7 @@ teams: visibility: closed # Branch protection rules -# See https://docs.github.com/en/rest/branches/branch-protection?apiVersion=2022-11-28#update-branch-protection for available options +# See https://docs.github.com/en/rest/branches/branch-protection?apiVersion=2026-03-10#update-branch-protection for available options branches: # If the name of the branch value is specified as `default`, then the app will create a branch protection rule to apply against the default branch in the repo - name: default @@ -202,7 +202,7 @@ branches: teams: [] # Custom properties -# See https://docs.github.com/en/rest/repos/custom-properties?apiVersion=2022-11-28 +# See https://docs.github.com/en/rest/repos/custom-properties?apiVersion=2026-03-10 custom_properties: - name: test value: test @@ -221,7 +221,7 @@ validator: pattern: "[a-zA-Z0-9_-]+" # Rulesets -# See https://docs.github.com/en/rest/orgs/rules?apiVersion=2022-11-28#create-an-organization-repository-rulesetfor available options +# See https://docs.github.com/en/rest/orgs/rules?apiVersion=2026-03-10#create-an-organization-repository-rulesetfor available options rulesets: - name: Template # The target of the ruleset. Can be one of: diff --git a/index.js b/index.js index 4707c82d0..ac1777c46 100644 --- a/index.js +++ b/index.js @@ -407,7 +407,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => repo: env.ADMIN_REPO, path: oldPath, headers: { - 'X-GitHub-Api-Version': '2022-11-28' + 'X-GitHub-Api-Version': '2026-03-10' } }) let content = Buffer.from(repofile.data.content, 'base64').toString() @@ -418,10 +418,10 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => // Check if a config file already exists for the renamed repo name await context.octokit.request('GET /repos/{owner}/{repo}/contents/{path}', { owner: payload.repository.owner.login, - repo: env.ADMIN_REPO, + repo: env.ADMIN_REPO, path: newPath, headers: { - 'X-GitHub-Api-Version': '2022-11-28' + 'X-GitHub-Api-Version': '2026-03-10' } }) } catch (error) { @@ -436,7 +436,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => message: `Repo Renamed and safe-settings renamed the file from ${payload.changes.repository.name.from} to ${payload.repository.name}`, sha: repofile.data.sha, headers: { - 'X-GitHub-Api-Version': '2022-11-28' + 'X-GitHub-Api-Version': '2026-03-10' } }) robot.log.debug(`Created a new setting file ${newPath}`) diff --git a/lib/plugins/autolinks.js b/lib/plugins/autolinks.js index db85abff8..5145fe9cd 100644 --- a/lib/plugins/autolinks.js +++ b/lib/plugins/autolinks.js @@ -19,7 +19,7 @@ module.exports = class Autolinks extends Diffable { changed (existing, attr) { // is_alphanumeric was added mid-2023. In order to continue to support settings yamls which dont specify this // attribute, consider an unset is_alphanumeric as `true` (since that is the default value in the API) - // https://docs.github.com/en/rest/repos/autolinks?apiVersion=2022-11-28#create-an-autolink-reference-for-a-repository + // https://docs.github.com/en/rest/repos/autolinks?apiVersion=2026-03-10#create-an-autolink-reference-for-a-repository const isAlphaNumericMatch = attr.is_alphanumeric === undefined ? existing.is_alphanumeric // === true, the default : attr.is_alphanumeric === existing.is_alphanumeric diff --git a/lib/plugins/collaborators.js b/lib/plugins/collaborators.js index e0b351488..5fba30eb2 100644 --- a/lib/plugins/collaborators.js +++ b/lib/plugins/collaborators.js @@ -15,7 +15,7 @@ module.exports = class Collaborators extends Diffable { } find () { - // https://docs.github.com/en/rest/collaborators/collaborators?apiVersion=2022-11-28 + // https://docs.github.com/en/rest/collaborators/collaborators?apiVersion=2026-03-10 // 'outside' means all outside collaborators of an organization-owned repository. // 'direct' means all collaborators with permissions to an organization-owned repository, regardless of organization membership status. (includes outside collaborators) // 'all' means all collaborators the authenticated user can see. diff --git a/lib/plugins/rulesets.js b/lib/plugins/rulesets.js index b77ead1bd..ad9a21f05 100644 --- a/lib/plugins/rulesets.js +++ b/lib/plugins/rulesets.js @@ -12,7 +12,7 @@ const overrides = { } const version = { - 'X-GitHub-Api-Version': '2022-11-28' + 'X-GitHub-Api-Version': '2026-03-10' } module.exports = class Rulesets extends Diffable { constructor (nop, github, repo, entries, log, errors, scope) { diff --git a/lib/plugins/variables.js b/lib/plugins/variables.js index 25795c408..2ca49ce29 100644 --- a/lib/plugins/variables.js +++ b/lib/plugins/variables.js @@ -16,7 +16,7 @@ module.exports = class Variables extends Diffable { /** * Look-up existing variables for a given repository * - * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#list-repository-variables} list repository variables + * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2026-03-10#list-repository-variables} list repository variables * @returns {Array.} Returns a list of variables that exist in a repository */ async find () { @@ -81,7 +81,7 @@ module.exports = class Variables extends Diffable { * @param {Array.} existing Existing variables defined in the repository * @param {Array.} variables Variables that we have defined as code * - * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#update-a-repository-variable} update a repository variable + * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2026-03-10#update-a-repository-variable} update a repository variable * @returns */ async update (existing, variables = []) { @@ -150,7 +150,7 @@ module.exports = class Variables extends Diffable { * * @param {object} variable The variable to add, with name and value * - * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#create-a-repository-variable} create a repository variable + * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2026-03-10#create-a-repository-variable} create a repository variable * @returns */ async add (variable) { @@ -175,7 +175,7 @@ module.exports = class Variables extends Diffable { * * @param {String} existing Name of the existing variable to remove * - * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#delete-a-repository-variable} delete a repository variable + * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2026-03-10#delete-a-repository-variable} delete a repository variable * @returns */ async remove (existing) { diff --git a/lib/settings.js b/lib/settings.js index 8314c9eee..919e01854 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -607,7 +607,7 @@ ${this.results.reduce((x, y) => { this.log.debug(` In getRepoConfigMap ${JSON.stringify(this.repo)}`) // GitHub getContent api has a hard limit of returning 1000 entries without // any pagination. They suggest to use Tree api. - // https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#get-repository-content + // https://docs.github.com/en/rest/repos/contents?apiVersion=2026-03-10#get-repository-content // get /repos directory sha to use in the getTree api const repo = { owner: this.repo.owner, repo: env.ADMIN_REPO } diff --git a/schema/settings.json b/schema/settings.json index 4d390b38f..8c8a53c81 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -5,7 +5,7 @@ "repositories": { "allOf": [ { - "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2022-11-28.json#/paths/~1repos~1{owner}~1{repo}/patch/requestBody/content/application~1json/schema" + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}/patch/requestBody/content/application~1json/schema" }, { "type": "object", @@ -105,7 +105,7 @@ "items": { "allOf": [ { - "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2022-11-28.json#/paths/~1repos~1{owner}~1{repo}~1collaborators~1{username}/put/requestBody/content/application~1json/schema" + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1collaborators~1{username}/put/requestBody/content/application~1json/schema" }, { "type": "object", @@ -136,7 +136,7 @@ "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" + "$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" } }, "branches": { @@ -149,7 +149,7 @@ "type": "string" }, "protection": { - "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2022-11-28.json#/paths/~1repos~1{owner}~1{repo}~1branches~1{branch}~1protection/put/requestBody/content/application~1json/schema" + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1branches~1{branch}~1protection/put/requestBody/content/application~1json/schema" } } } @@ -173,7 +173,7 @@ "description": "Autolinks", "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/~1repos~1{owner}~1{repo}~1autolinks/post/requestBody/content/application~1json/schema" + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1autolinks/post/requestBody/content/application~1json/schema" } }, "validator": { @@ -189,7 +189,7 @@ "description": "Rulesets", "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}~1rulesets/post/requestBody/content/application~1json/schema" + "$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}~1rulesets/post/requestBody/content/application~1json/schema" } } } diff --git a/test/unit/lib/plugins/rulesets.test.js b/test/unit/lib/plugins/rulesets.test.js index f15abd63f..678e97779 100644 --- a/test/unit/lib/plugins/rulesets.test.js +++ b/test/unit/lib/plugins/rulesets.test.js @@ -3,7 +3,7 @@ const { when } = require('jest-when') const Rulesets = require('../../../../lib/plugins/rulesets') const version = { - 'X-GitHub-Api-Version': '2022-11-28' + 'X-GitHub-Api-Version': '2026-03-10' } const repo_conditions = { ref_name: { From 8450339bf5237b2e15e54d20a7f4b0634d293b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Fri, 27 Mar 2026 15:58:19 +0200 Subject: [PATCH 07/21] Add JSON schemas for safe-settings configuration at repo, org, and suborg levels - Introduced `repos.json` schema for repository-level safe-settings overrides. - Updated `settings.json` schema to include additional properties for org-level configurations. - Created `suborgs.json` schema for suborg-level safe-settings configuration. - Enhanced the build script to dereference all schemas and handle errors during the process. --- lib/plugins/custom_properties.js | 20 +- schema/dereferenced/repos.json | 2742 ++++++++++++++++++++++++++++ schema/dereferenced/settings.json | 62 +- schema/dereferenced/suborgs.json | 2776 +++++++++++++++++++++++++++++ schema/repos.json | 324 ++++ schema/settings.json | 313 +++- schema/suborgs.json | 358 ++++ script/build-schema | 28 +- 8 files changed, 6511 insertions(+), 112 deletions(-) create mode 100644 schema/dereferenced/repos.json create mode 100644 schema/dereferenced/suborgs.json create mode 100644 schema/repos.json create mode 100644 schema/suborgs.json diff --git a/lib/plugins/custom_properties.js b/lib/plugins/custom_properties.js index 5d6ab6e5b..95b82501e 100644 --- a/lib/plugins/custom_properties.js +++ b/lib/plugins/custom_properties.js @@ -12,10 +12,12 @@ module.exports = class CustomProperties extends Diffable { // Force all names to lowercase to avoid comparison issues. normalizeEntries () { - this.entries = this.entries.map(({ name, value }) => ({ - name: name.toLowerCase(), - value - })) + this.entries = this.entries + .filter(({ name }) => name != null) + .map(({ name, value }) => ({ + name: name.toLowerCase(), + value + })) } async find () { @@ -38,10 +40,12 @@ module.exports = class CustomProperties extends Diffable { // Force all names to lowercase to avoid comparison issues. normalize (properties) { - return properties.map(({ property_name: propertyName, value }) => ({ - name: propertyName.toLowerCase(), - value - })) + return properties + .filter(({ property_name: propertyName }) => propertyName != null) + .map(({ property_name: propertyName, value }) => ({ + name: propertyName.toLowerCase(), + value + })) } comparator (existing, attrs) { diff --git a/schema/dereferenced/repos.json b/schema/dereferenced/repos.json new file mode 100644 index 000000000..355285e29 --- /dev/null +++ b/schema/dereferenced/repos.json @@ -0,0 +1,2742 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Safe-settings repo-level configuration", + "description": "Schema for .github/repos/{repo-name}.yml — repo-level safe-settings override configuration. Settings here are merged on top of org-level and suborg-level settings for the specific repository.", + "type": "object", + "properties": { + "repositories": { + "description": "Repository settings", + "allOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository." + }, + "description": { + "type": "string", + "description": "A short description of the repository." + }, + "homepage": { + "type": "string", + "description": "A URL with more information about the repository." + }, + "private": { + "type": "boolean", + "description": "Either `true` to make the repository private or `false` to make it public. Default: `false`. \n**Note**: You will get a `422` error if the organization restricts [changing repository visibility](https://docs.github.com/articles/repository-permission-levels-for-an-organization#changing-the-visibility-of-repositories) to organization owners and a non-owner tries to change the value of private.", + "default": false + }, + "visibility": { + "type": "string", + "description": "The visibility of the repository.", + "enum": [ + "public", + "private" + ] + }, + "security_and_analysis": { + "type": "object", + "description": "Specify which security and analysis features to enable or disable for the repository.\n\nTo use this parameter, you must have admin permissions for the repository or be an owner or security manager for the organization that owns the repository. For more information, see \"[Managing security managers in your organization](https://docs.github.com/organizations/managing-peoples-access-to-your-organization-with-roles/managing-security-managers-in-your-organization).\"\n\nFor example, to enable GitHub Advanced Security, use this data in the body of the `PATCH` request:\n`{ \"security_and_analysis\": {\"advanced_security\": { \"status\": \"enabled\" } } }`.\n\nYou can check which security and analysis features are currently enabled by using a `GET /repos/{owner}/{repo}` request.", + "nullable": true, + "properties": { + "advanced_security": { + "type": "object", + "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.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning for this repository. For more information, see \"[About secret scanning](/code-security/secret-security/about-secret-scanning).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_push_protection": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning push protection for this repository. For more information, see \"[Protecting pushes with secret scanning](/code-security/secret-scanning/protecting-pushes-with-secret-scanning).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "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).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_alert_dismissal": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated alert dismissal for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated bypass for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass_options": { + "type": "object", + "description": "Feature options for secret scanning delegated bypass.\nThis object is only honored when `security_and_analysis.secret_scanning_delegated_bypass.status` is set to `enabled`.\nYou can send this object in the same request as `secret_scanning_delegated_bypass`, or update just the options in a separate request.", + "properties": { + "reviewers": { + "type": "array", + "description": "The bypass reviewers for secret scanning delegated bypass.\nIf you omit this field, the existing set of reviewers is unchanged.", + "items": { + "type": "object", + "required": [ + "reviewer_id", + "reviewer_type" + ], + "properties": { + "reviewer_id": { + "type": "integer", + "description": "The ID of the team or role selected as a bypass reviewer" + }, + "reviewer_type": { + "type": "string", + "description": "The type of the bypass reviewer", + "enum": [ + "TEAM", + "ROLE" + ] + } + } + } + } + } + } + } + }, + "has_issues": { + "type": "boolean", + "description": "Either `true` to enable issues for this repository or `false` to disable them.", + "default": true + }, + "has_projects": { + "type": "boolean", + "description": "Either `true` to enable projects for this repository or `false` to disable them. **Note:** If you're creating a repository in an organization that has disabled repository projects, the default is `false`, and if you pass `true`, the API returns an error.", + "default": true + }, + "has_wiki": { + "type": "boolean", + "description": "Either `true` to enable the wiki for this repository or `false` to disable it.", + "default": true + }, + "is_template": { + "type": "boolean", + "description": "Either `true` to make this repo available as a template repository or `false` to prevent it.", + "default": false + }, + "default_branch": { + "type": "string", + "description": "Updates the default branch for this repository." + }, + "allow_squash_merge": { + "type": "boolean", + "description": "Either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging.", + "default": true + }, + "allow_merge_commit": { + "type": "boolean", + "description": "Either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits.", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "description": "Either `true` to allow rebase-merging pull requests, or `false` to prevent rebase-merging.", + "default": true + }, + "allow_auto_merge": { + "type": "boolean", + "description": "Either `true` to allow auto-merge on pull requests, or `false` to disallow auto-merge.", + "default": false + }, + "delete_branch_on_merge": { + "type": "boolean", + "description": "Either `true` to allow automatically deleting head branches when pull requests are merged, or `false` to prevent automatic deletion.", + "default": false + }, + "allow_update_branch": { + "type": "boolean", + "description": "Either `true` to always allow a pull request head branch that is behind its base branch to be updated even if it is not required to be up to date before merging, or false otherwise.", + "default": false + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property is closing down. Please use `squash_merge_commit_title` instead.", + "default": false, + "deprecated": true + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ], + "description": "Required when using `squash_merge_commit_message`.\n\nThe default value for a squash merge commit title:\n\n- `PR_TITLE` - default to the pull request's title.\n- `COMMIT_OR_PR_TITLE` - default to the commit's title (if only one commit) or the pull request's title (when more than one commit)." + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ], + "description": "The default value for a squash merge commit message:\n\n- `PR_BODY` - default to the pull request's body.\n- `COMMIT_MESSAGES` - default to the branch's commit messages.\n- `BLANK` - default to a blank commit message." + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ], + "description": "Required when using `merge_commit_message`.\n\nThe default value for a merge commit title.\n\n- `PR_TITLE` - default to the pull request's title.\n- `MERGE_MESSAGE` - default to the classic title for a merge message (e.g., Merge pull request #123 from branch-name)." + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ], + "description": "The default value for a merge commit message.\n\n- `PR_TITLE` - default to the pull request's title.\n- `PR_BODY` - default to the pull request's body.\n- `BLANK` - default to a blank commit message." + }, + "archived": { + "type": "boolean", + "description": "Whether to archive this repository. `false` will unarchive a previously archived repository.", + "default": false + }, + "allow_forking": { + "type": "boolean", + "description": "Either `true` to allow private forks, or `false` to prevent private forks.", + "default": false + }, + "web_commit_signoff_required": { + "type": "boolean", + "description": "Either `true` to require contributors to sign off on web-based commits, or `false` to not require contributors to sign off on web-based commits.", + "default": false + } + } + }, + { + "type": "object", + "properties": { + "auto_init": { + "description": "Create an initial commit with empty README. Keep this set to true in most cases since many of the policies below cannot be implemented on bare repos", + "type": "boolean" + }, + "gitignore_template": { + "description": "Desired language or platform [.gitignore template](https://github.com/github/gitignore) to apply. Use the name of the template without the extension. For example, 'Haskell'.", + "type": "string" + }, + "license_template": { + "description": "Choose an [open source license template](https://choosealicense.com/) that best suits your needs, and then use the [license keyword](https://help.github.com/articles/licensing-a-repository/#searching-github-by-license-type) as the `license_template` string. For example, 'mit' or 'mpl-2.0'.", + "type": "string" + }, + "topics": { + "description": "A list of topics to set on the repository", + "type": "array", + "items": { + "type": "string" + } + }, + "security": { + "description": "Settings for Code security and analysis", + "type": "object", + "properties": { + "enableVulnerabilityAlerts": { + "type": "boolean" + }, + "enableAutomatedSecurityFixes": { + "type": "boolean" + } + } + }, + "force_create": { + "description": "If true, create the repository if it does not already exist.", + "type": "boolean" + }, + "template": { + "description": "Name of a template repository to use when creating a new repository.", + "type": "string" + } + } + } + ] + }, + "labels": { + "description": "Labels: define labels for Issues and Pull Requests", + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "color": { + "description": "The hexadecimal color code for the label. If including a `#`, make sure to wrap it with quotes!", + "type": "string" + }, + "description": { + "type": "string" + }, + "oldname": { + "description": "Include the old name to rename an existing label", + "type": "string" + } + } + } + }, + "exclude": { + "description": "Ignore any labels matching these regexes (don't delete them)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "collaborators": { + "description": "Collaborators: give specific users access to any repository.", + "type": "array", + "items": { + "description": "A collaborator entry giving a specific user access to a repository.", + "allOf": [ + { + "type": "object", + "properties": { + "permission": { + "type": "string", + "description": "The permission to grant the collaborator. **Only valid on organization-owned repositories.** 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.", + "default": "push" + } + } + }, + { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "exclude": { + "description": "You can exclude a list of repos for this collaborator and all repos except these repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "description": "You can include a list of repos for this collaborator and only those repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "teams": { + "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" + ] + }, + "parent_team_id": { + "type": "integer", + "description": "The ID of a team to set as the parent team." + } + }, + "required": [ + "name" + ] + } + }, + "milestones": { + "description": "Milestones: define milestones for Issues and Pull Requests", + "type": "array", + "items": { + "description": "A milestone entry", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "state": { + "description": "The state of the milestone. Either `open` or `closed`", + "type": "string" + } + } + } + }, + "branches": { + "description": "Branch protection rules", + "type": "array", + "items": { + "description": "A branch protection rule entry", + "type": "object", + "properties": { + "name": { + "description": "If the name of the branch value is specified as `default`, then the app will create a branch protection rule to apply against the default branch in the repo", + "type": "string" + }, + "protection": { + "type": "object", + "properties": { + "required_status_checks": { + "type": "object", + "description": "Require status checks to pass before merging. Set to `null` to disable.", + "nullable": true, + "properties": { + "strict": { + "type": "boolean", + "description": "Require branches to be up to date before merging." + }, + "contexts": { + "type": "array", + "deprecated": true, + "description": "**Closing down notice**: The list of status checks to require in order to merge into this branch. If any of these checks have recently been set by a particular GitHub App, they will be required to come from that app in future for the branch to merge. Use `checks` instead of `contexts` for more fine-grained control.", + "items": { + "type": "string" + } + }, + "checks": { + "type": "array", + "description": "The list of status checks to require in order to merge into this branch.", + "items": { + "type": "object", + "required": [ + "context" + ], + "properties": { + "context": { + "type": "string", + "description": "The name of the required check" + }, + "app_id": { + "type": "integer", + "description": "The ID of the GitHub App that must provide this check. Omit this field to automatically select the GitHub App that has recently provided this check, or any app if it was not set by a GitHub App. Pass -1 to explicitly allow any app to set the status." + } + } + } + } + }, + "required": [ + "strict", + "contexts" + ] + }, + "enforce_admins": { + "type": "boolean", + "description": "Enforce all configured restrictions for administrators. Set to `true` to enforce required status checks for repository administrators. Set to `null` to disable.", + "nullable": true + }, + "required_pull_request_reviews": { + "type": "object", + "description": "Require at least one approving review on a pull request, before merging. Set to `null` to disable.", + "nullable": true, + "properties": { + "dismissal_restrictions": { + "type": "object", + "description": "Specify which users, teams, and apps can dismiss pull request reviews. Pass an empty `dismissal_restrictions` object to disable. User and team `dismissal_restrictions` are only available for organization-owned repositories. Omit this parameter for personal repositories.", + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s with dismissal access", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s with dismissal access", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s with dismissal access", + "items": { + "type": "string" + } + } + } + }, + "dismiss_stale_reviews": { + "type": "boolean", + "description": "Set to `true` if you want to automatically dismiss approving reviews when someone pushes a new commit." + }, + "require_code_owner_reviews": { + "type": "boolean", + "description": "Blocks merging pull requests until [code owners](https://docs.github.com/articles/about-code-owners/) review them." + }, + "required_approving_review_count": { + "type": "integer", + "description": "Specify the number of reviewers required to approve pull requests. Use a number between 1 and 6 or 0 to not require reviewers." + }, + "require_last_push_approval": { + "type": "boolean", + "description": "Whether the most recent push must be approved by someone other than the person who pushed it. Default: `false`.", + "default": false + }, + "bypass_pull_request_allowances": { + "type": "object", + "description": "Allow specific users, teams, or apps to bypass pull request requirements.", + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + } + } + } + } + }, + "restrictions": { + "type": "object", + "description": "Restrict who can push to the protected branch. User, app, and team `restrictions` are only available for organization-owned repositories. Set to `null` to disable.", + "nullable": true, + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s with push access", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s with push access", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s with push access", + "items": { + "type": "string" + } + } + }, + "required": [ + "users", + "teams" + ] + }, + "required_linear_history": { + "type": "boolean", + "description": "Enforces a linear commit Git history, which prevents anyone from pushing merge commits to a branch. Set to `true` to enforce a linear commit history. Set to `false` to disable a linear commit Git history. Your repository must allow squash merging or rebase merging before you can enable a linear commit history. Default: `false`. For more information, see \"[Requiring a linear commit history](https://docs.github.com/github/administering-a-repository/requiring-a-linear-commit-history)\" in the GitHub Help documentation." + }, + "allow_force_pushes": { + "type": "boolean", + "description": "Permits force pushes to the protected branch by anyone with write access to the repository. Set to `true` to allow force pushes. Set to `false` or `null` to block force pushes. Default: `false`. For more information, see \"[Enabling force pushes to a protected branch](https://docs.github.com/github/administering-a-repository/enabling-force-pushes-to-a-protected-branch)\" in the GitHub Help documentation.\"", + "nullable": true + }, + "allow_deletions": { + "type": "boolean", + "description": "Allows deletion of the protected branch by anyone with write access to the repository. Set to `false` to prevent deletion of the protected branch. Default: `false`. For more information, see \"[Enabling force pushes to a protected branch](https://docs.github.com/github/administering-a-repository/enabling-force-pushes-to-a-protected-branch)\" in the GitHub Help documentation." + }, + "block_creations": { + "type": "boolean", + "description": "If set to `true`, the `restrictions` branch protection settings which limits who can push will also block pushes which create new branches, unless the push is initiated by a user, team, or app which has the ability to push. Set to `true` to restrict new branch creation. Default: `false`." + }, + "required_conversation_resolution": { + "type": "boolean", + "description": "Requires all conversations on code to be resolved before a pull request can be merged into a branch that matches this rule. Set to `false` to disable. Default: `false`." + }, + "lock_branch": { + "type": "boolean", + "description": "Whether to set the branch as read-only. If this is true, users will not be able to push to the branch. Default: `false`.", + "default": false + }, + "allow_fork_syncing": { + "type": "boolean", + "description": "Whether users can pull changes from upstream when the branch is locked. Set to `true` to allow fork syncing. Set to `false` to prevent fork syncing. Default: `false`.", + "default": false + } + }, + "required": [ + "required_status_checks", + "enforce_admins", + "required_pull_request_reviews", + "restrictions" + ] + } + } + } + }, + "autolinks": { + "description": "Autolinks", + "type": "array", + "items": { + "description": "An autolink reference entry", + "type": "object", + "properties": { + "key_prefix": { + "type": "string", + "description": "This prefix appended by certain characters will generate a link any time it is found in an issue, pull request, or commit." + }, + "url_template": { + "type": "string", + "description": "The URL must contain `` for the reference number. `` matches different characters depending on the value of `is_alphanumeric`." + }, + "is_alphanumeric": { + "type": "boolean", + "default": true, + "description": "Whether this autolink reference matches alphanumeric characters. If true, the `` parameter of the `url_template` matches alphanumeric characters `A-Z` (case insensitive), `0-9`, and `-`. If false, this autolink reference only matches numeric characters." + } + }, + "required": [ + "key_prefix", + "url_template" + ] + } + }, + "validator": { + "description": "Repository name validation", + "type": "object", + "properties": { + "pattern": { + "type": "string" + } + } + }, + "environments": { + "description": "Deployment environments", + "type": "array", + "items": { + "description": "A deployment environment configuration entry", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "The name of the deployment environment", + "type": "string" + }, + "wait_timer": { + "description": "The amount of time to delay a job after the job is initially triggered (in minutes)", + "type": "integer" + }, + "reviewers": { + "description": "The people or teams that may review jobs that reference the environment", + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "description": "The type of reviewer (`User` or `Team`)", + "type": "string" + }, + "id": { + "description": "The id of the user or team who can review the deployment", + "type": "integer" + } + } + } + }, + "deployment_branch_policy": { + "description": "The type of deployment branch policy for this environment", + "type": "object", + "properties": { + "protected_branches": { + "description": "Whether only protected branches can be deployed to this environment", + "type": "boolean" + }, + "custom_branch_policies": { + "description": "Whether only branches that match the specified name patterns can deploy to this environment", + "type": "boolean" + } + } + }, + "prevent_self_review": { + "description": "Whether or not a user who created the job is prevented from approving their own job", + "type": "boolean" + } + } + } + }, + "custom_properties": { + "description": "Custom properties", + "type": "array", + "items": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "variables": { + "description": "Repository or org-level Actions variables", + "type": "array", + "items": { + "description": "An Actions variable entry", + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "description": "The name of the variable", + "type": "string" + }, + "value": { + "description": "The value of the variable", + "type": "string" + }, + "visibility": { + "description": "The visibility of the variable. Can be `all`, `private`, or `selected`", + "type": "string", + "enum": [ + "all", + "private", + "selected" + ] + } + } + } + } + }, + "$defs": { + "RepositorySettings": { + "description": "Repository settings", + "allOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository." + }, + "description": { + "type": "string", + "description": "A short description of the repository." + }, + "homepage": { + "type": "string", + "description": "A URL with more information about the repository." + }, + "private": { + "type": "boolean", + "description": "Either `true` to make the repository private or `false` to make it public. Default: `false`. \n**Note**: You will get a `422` error if the organization restricts [changing repository visibility](https://docs.github.com/articles/repository-permission-levels-for-an-organization#changing-the-visibility-of-repositories) to organization owners and a non-owner tries to change the value of private.", + "default": false + }, + "visibility": { + "type": "string", + "description": "The visibility of the repository.", + "enum": [ + "public", + "private" + ] + }, + "security_and_analysis": { + "type": "object", + "description": "Specify which security and analysis features to enable or disable for the repository.\n\nTo use this parameter, you must have admin permissions for the repository or be an owner or security manager for the organization that owns the repository. For more information, see \"[Managing security managers in your organization](https://docs.github.com/organizations/managing-peoples-access-to-your-organization-with-roles/managing-security-managers-in-your-organization).\"\n\nFor example, to enable GitHub Advanced Security, use this data in the body of the `PATCH` request:\n`{ \"security_and_analysis\": {\"advanced_security\": { \"status\": \"enabled\" } } }`.\n\nYou can check which security and analysis features are currently enabled by using a `GET /repos/{owner}/{repo}` request.", + "nullable": true, + "properties": { + "advanced_security": { + "type": "object", + "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.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning for this repository. For more information, see \"[About secret scanning](/code-security/secret-security/about-secret-scanning).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_push_protection": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning push protection for this repository. For more information, see \"[Protecting pushes with secret scanning](/code-security/secret-scanning/protecting-pushes-with-secret-scanning).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "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).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_alert_dismissal": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated alert dismissal for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated bypass for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass_options": { + "type": "object", + "description": "Feature options for secret scanning delegated bypass.\nThis object is only honored when `security_and_analysis.secret_scanning_delegated_bypass.status` is set to `enabled`.\nYou can send this object in the same request as `secret_scanning_delegated_bypass`, or update just the options in a separate request.", + "properties": { + "reviewers": { + "type": "array", + "description": "The bypass reviewers for secret scanning delegated bypass.\nIf you omit this field, the existing set of reviewers is unchanged.", + "items": { + "type": "object", + "required": [ + "reviewer_id", + "reviewer_type" + ], + "properties": { + "reviewer_id": { + "type": "integer", + "description": "The ID of the team or role selected as a bypass reviewer" + }, + "reviewer_type": { + "type": "string", + "description": "The type of the bypass reviewer", + "enum": [ + "TEAM", + "ROLE" + ] + } + } + } + } + } + } + } + }, + "has_issues": { + "type": "boolean", + "description": "Either `true` to enable issues for this repository or `false` to disable them.", + "default": true + }, + "has_projects": { + "type": "boolean", + "description": "Either `true` to enable projects for this repository or `false` to disable them. **Note:** If you're creating a repository in an organization that has disabled repository projects, the default is `false`, and if you pass `true`, the API returns an error.", + "default": true + }, + "has_wiki": { + "type": "boolean", + "description": "Either `true` to enable the wiki for this repository or `false` to disable it.", + "default": true + }, + "is_template": { + "type": "boolean", + "description": "Either `true` to make this repo available as a template repository or `false` to prevent it.", + "default": false + }, + "default_branch": { + "type": "string", + "description": "Updates the default branch for this repository." + }, + "allow_squash_merge": { + "type": "boolean", + "description": "Either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging.", + "default": true + }, + "allow_merge_commit": { + "type": "boolean", + "description": "Either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits.", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "description": "Either `true` to allow rebase-merging pull requests, or `false` to prevent rebase-merging.", + "default": true + }, + "allow_auto_merge": { + "type": "boolean", + "description": "Either `true` to allow auto-merge on pull requests, or `false` to disallow auto-merge.", + "default": false + }, + "delete_branch_on_merge": { + "type": "boolean", + "description": "Either `true` to allow automatically deleting head branches when pull requests are merged, or `false` to prevent automatic deletion.", + "default": false + }, + "allow_update_branch": { + "type": "boolean", + "description": "Either `true` to always allow a pull request head branch that is behind its base branch to be updated even if it is not required to be up to date before merging, or false otherwise.", + "default": false + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property is closing down. Please use `squash_merge_commit_title` instead.", + "default": false, + "deprecated": true + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ], + "description": "Required when using `squash_merge_commit_message`.\n\nThe default value for a squash merge commit title:\n\n- `PR_TITLE` - default to the pull request's title.\n- `COMMIT_OR_PR_TITLE` - default to the commit's title (if only one commit) or the pull request's title (when more than one commit)." + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ], + "description": "The default value for a squash merge commit message:\n\n- `PR_BODY` - default to the pull request's body.\n- `COMMIT_MESSAGES` - default to the branch's commit messages.\n- `BLANK` - default to a blank commit message." + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ], + "description": "Required when using `merge_commit_message`.\n\nThe default value for a merge commit title.\n\n- `PR_TITLE` - default to the pull request's title.\n- `MERGE_MESSAGE` - default to the classic title for a merge message (e.g., Merge pull request #123 from branch-name)." + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ], + "description": "The default value for a merge commit message.\n\n- `PR_TITLE` - default to the pull request's title.\n- `PR_BODY` - default to the pull request's body.\n- `BLANK` - default to a blank commit message." + }, + "archived": { + "type": "boolean", + "description": "Whether to archive this repository. `false` will unarchive a previously archived repository.", + "default": false + }, + "allow_forking": { + "type": "boolean", + "description": "Either `true` to allow private forks, or `false` to prevent private forks.", + "default": false + }, + "web_commit_signoff_required": { + "type": "boolean", + "description": "Either `true` to require contributors to sign off on web-based commits, or `false` to not require contributors to sign off on web-based commits.", + "default": false + } + } + }, + { + "type": "object", + "properties": { + "auto_init": { + "description": "Create an initial commit with empty README. Keep this set to true in most cases since many of the policies below cannot be implemented on bare repos", + "type": "boolean" + }, + "gitignore_template": { + "description": "Desired language or platform [.gitignore template](https://github.com/github/gitignore) to apply. Use the name of the template without the extension. For example, 'Haskell'.", + "type": "string" + }, + "license_template": { + "description": "Choose an [open source license template](https://choosealicense.com/) that best suits your needs, and then use the [license keyword](https://help.github.com/articles/licensing-a-repository/#searching-github-by-license-type) as the `license_template` string. For example, 'mit' or 'mpl-2.0'.", + "type": "string" + }, + "topics": { + "description": "A list of topics to set on the repository", + "type": "array", + "items": { + "type": "string" + } + }, + "security": { + "description": "Settings for Code security and analysis", + "type": "object", + "properties": { + "enableVulnerabilityAlerts": { + "type": "boolean" + }, + "enableAutomatedSecurityFixes": { + "type": "boolean" + } + } + }, + "force_create": { + "description": "If true, create the repository if it does not already exist.", + "type": "boolean" + }, + "template": { + "description": "Name of a template repository to use when creating a new repository.", + "type": "string" + } + } + } + ] + }, + "LabelSettings": { + "description": "Labels: define labels for Issues and Pull Requests", + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "color": { + "description": "The hexadecimal color code for the label. If including a `#`, make sure to wrap it with quotes!", + "type": "string" + }, + "description": { + "type": "string" + }, + "oldname": { + "description": "Include the old name to rename an existing label", + "type": "string" + } + } + } + }, + "exclude": { + "description": "Ignore any labels matching these regexes (don't delete them)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CollaboratorSettings": { + "description": "A collaborator entry giving a specific user access to a repository.", + "allOf": [ + { + "type": "object", + "properties": { + "permission": { + "type": "string", + "description": "The permission to grant the collaborator. **Only valid on organization-owned repositories.** 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.", + "default": "push" + } + } + }, + { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "exclude": { + "description": "You can exclude a list of repos for this collaborator and all repos except these repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "description": "You can include a list of repos for this collaborator and only those repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "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" + ] + }, + "parent_team_id": { + "type": "integer", + "description": "The ID of a team to set as the parent team." + } + }, + "required": [ + "name" + ] + }, + "MilestoneSettings": { + "description": "A milestone entry", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "state": { + "description": "The state of the milestone. Either `open` or `closed`", + "type": "string" + } + } + }, + "BranchSettings": { + "description": "A branch protection rule entry", + "type": "object", + "properties": { + "name": { + "description": "If the name of the branch value is specified as `default`, then the app will create a branch protection rule to apply against the default branch in the repo", + "type": "string" + }, + "protection": { + "type": "object", + "properties": { + "required_status_checks": { + "type": "object", + "description": "Require status checks to pass before merging. Set to `null` to disable.", + "nullable": true, + "properties": { + "strict": { + "type": "boolean", + "description": "Require branches to be up to date before merging." + }, + "contexts": { + "type": "array", + "deprecated": true, + "description": "**Closing down notice**: The list of status checks to require in order to merge into this branch. If any of these checks have recently been set by a particular GitHub App, they will be required to come from that app in future for the branch to merge. Use `checks` instead of `contexts` for more fine-grained control.", + "items": { + "type": "string" + } + }, + "checks": { + "type": "array", + "description": "The list of status checks to require in order to merge into this branch.", + "items": { + "type": "object", + "required": [ + "context" + ], + "properties": { + "context": { + "type": "string", + "description": "The name of the required check" + }, + "app_id": { + "type": "integer", + "description": "The ID of the GitHub App that must provide this check. Omit this field to automatically select the GitHub App that has recently provided this check, or any app if it was not set by a GitHub App. Pass -1 to explicitly allow any app to set the status." + } + } + } + } + }, + "required": [ + "strict", + "contexts" + ] + }, + "enforce_admins": { + "type": "boolean", + "description": "Enforce all configured restrictions for administrators. Set to `true` to enforce required status checks for repository administrators. Set to `null` to disable.", + "nullable": true + }, + "required_pull_request_reviews": { + "type": "object", + "description": "Require at least one approving review on a pull request, before merging. Set to `null` to disable.", + "nullable": true, + "properties": { + "dismissal_restrictions": { + "type": "object", + "description": "Specify which users, teams, and apps can dismiss pull request reviews. Pass an empty `dismissal_restrictions` object to disable. User and team `dismissal_restrictions` are only available for organization-owned repositories. Omit this parameter for personal repositories.", + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s with dismissal access", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s with dismissal access", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s with dismissal access", + "items": { + "type": "string" + } + } + } + }, + "dismiss_stale_reviews": { + "type": "boolean", + "description": "Set to `true` if you want to automatically dismiss approving reviews when someone pushes a new commit." + }, + "require_code_owner_reviews": { + "type": "boolean", + "description": "Blocks merging pull requests until [code owners](https://docs.github.com/articles/about-code-owners/) review them." + }, + "required_approving_review_count": { + "type": "integer", + "description": "Specify the number of reviewers required to approve pull requests. Use a number between 1 and 6 or 0 to not require reviewers." + }, + "require_last_push_approval": { + "type": "boolean", + "description": "Whether the most recent push must be approved by someone other than the person who pushed it. Default: `false`.", + "default": false + }, + "bypass_pull_request_allowances": { + "type": "object", + "description": "Allow specific users, teams, or apps to bypass pull request requirements.", + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + } + } + } + } + }, + "restrictions": { + "type": "object", + "description": "Restrict who can push to the protected branch. User, app, and team `restrictions` are only available for organization-owned repositories. Set to `null` to disable.", + "nullable": true, + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s with push access", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s with push access", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s with push access", + "items": { + "type": "string" + } + } + }, + "required": [ + "users", + "teams" + ] + }, + "required_linear_history": { + "type": "boolean", + "description": "Enforces a linear commit Git history, which prevents anyone from pushing merge commits to a branch. Set to `true` to enforce a linear commit history. Set to `false` to disable a linear commit Git history. Your repository must allow squash merging or rebase merging before you can enable a linear commit history. Default: `false`. For more information, see \"[Requiring a linear commit history](https://docs.github.com/github/administering-a-repository/requiring-a-linear-commit-history)\" in the GitHub Help documentation." + }, + "allow_force_pushes": { + "type": "boolean", + "description": "Permits force pushes to the protected branch by anyone with write access to the repository. Set to `true` to allow force pushes. Set to `false` or `null` to block force pushes. Default: `false`. For more information, see \"[Enabling force pushes to a protected branch](https://docs.github.com/github/administering-a-repository/enabling-force-pushes-to-a-protected-branch)\" in the GitHub Help documentation.\"", + "nullable": true + }, + "allow_deletions": { + "type": "boolean", + "description": "Allows deletion of the protected branch by anyone with write access to the repository. Set to `false` to prevent deletion of the protected branch. Default: `false`. For more information, see \"[Enabling force pushes to a protected branch](https://docs.github.com/github/administering-a-repository/enabling-force-pushes-to-a-protected-branch)\" in the GitHub Help documentation." + }, + "block_creations": { + "type": "boolean", + "description": "If set to `true`, the `restrictions` branch protection settings which limits who can push will also block pushes which create new branches, unless the push is initiated by a user, team, or app which has the ability to push. Set to `true` to restrict new branch creation. Default: `false`." + }, + "required_conversation_resolution": { + "type": "boolean", + "description": "Requires all conversations on code to be resolved before a pull request can be merged into a branch that matches this rule. Set to `false` to disable. Default: `false`." + }, + "lock_branch": { + "type": "boolean", + "description": "Whether to set the branch as read-only. If this is true, users will not be able to push to the branch. Default: `false`.", + "default": false + }, + "allow_fork_syncing": { + "type": "boolean", + "description": "Whether users can pull changes from upstream when the branch is locked. Set to `true` to allow fork syncing. Set to `false` to prevent fork syncing. Default: `false`.", + "default": false + } + }, + "required": [ + "required_status_checks", + "enforce_admins", + "required_pull_request_reviews", + "restrictions" + ] + } + } + }, + "AutolinkSettings": { + "description": "An autolink reference entry", + "type": "object", + "properties": { + "key_prefix": { + "type": "string", + "description": "This prefix appended by certain characters will generate a link any time it is found in an issue, pull request, or commit." + }, + "url_template": { + "type": "string", + "description": "The URL must contain `` for the reference number. `` matches different characters depending on the value of `is_alphanumeric`." + }, + "is_alphanumeric": { + "type": "boolean", + "default": true, + "description": "Whether this autolink reference matches alphanumeric characters. If true, the `` parameter of the `url_template` matches alphanumeric characters `A-Z` (case insensitive), `0-9`, and `-`. If false, this autolink reference only matches numeric characters." + } + }, + "required": [ + "key_prefix", + "url_template" + ] + }, + "ValidatorSettings": { + "description": "Repository name validation", + "type": "object", + "properties": { + "pattern": { + "type": "string" + } + } + }, + "RulesetSettings": { + "description": "A ruleset entry", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the ruleset." + }, + "target": { + "type": "string", + "description": "The target of the ruleset", + "enum": [ + "branch", + "tag", + "push", + "repository" + ], + "default": "branch" + }, + "enforcement": { + "type": "string", + "description": "The enforcement level of the ruleset. `evaluate` allows admins to test rules before enforcing them. Admins can view insights on the Rule Insights page (`evaluate` is only available with GitHub Enterprise).", + "enum": [ + "disabled", + "active", + "evaluate" + ] + }, + "bypass_actors": { + "type": "array", + "description": "The actors that can bypass the rules in this ruleset", + "items": { + "title": "Repository Ruleset Bypass Actor", + "type": "object", + "description": "An actor that can bypass rules in a ruleset", + "required": [ + "actor_type" + ], + "properties": { + "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." + }, + "actor_type": { + "type": "string", + "enum": [ + "Integration", + "OrganizationAdmin", + "RepositoryRole", + "Team", + "DeployKey" + ], + "description": "The type of actor that can bypass a ruleset." + }, + "bypass_mode": { + "type": "string", + "description": "When the specified actor can bypass the ruleset. `pull_request` means that an actor can only bypass rules on pull requests. `pull_request` is not applicable for the `DeployKey` actor type. Also, `pull_request` is only applicable to branch rulesets. When `bypass_mode` is `exempt`, rules will not be run for that actor and a bypass audit entry will not be created.", + "enum": [ + "always", + "pull_request", + "exempt" + ], + "default": "always" + } + } + } + }, + "conditions": { + "title": "Organization ruleset conditions", + "type": "object", + "description": "Conditions for an organization ruleset.\nThe branch and tag rulesets conditions object should contain both `repository_name` and `ref_name` properties, or both `repository_id` and `ref_name` properties, or both `repository_property` and `ref_name` properties.\nThe push rulesets conditions object does not require the `ref_name` property.\nFor repository policy rulesets, the conditions object should only contain the `repository_name`, the `repository_id`, or the `repository_property`.", + "oneOf": [ + { + "type": "object", + "title": "repository_name_and_ref_name", + "description": "Conditions to target repositories by name and refs by name", + "allOf": [ + { + "title": "Repository ruleset conditions for ref names", + "type": "object", + "description": "Parameters for a repository ruleset ref name condition", + "properties": { + "ref_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Repository ruleset conditions for repository names", + "type": "object", + "description": "Parameters for a repository name condition", + "properties": { + "repository_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of repository names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~ALL` to include all repositories.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of repository names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + }, + "protected": { + "type": "boolean", + "description": "Whether renaming of target repositories is prevented." + } + } + } + }, + "required": [ + "repository_name" + ] + } + ] + }, + { + "type": "object", + "title": "repository_id_and_ref_name", + "description": "Conditions to target repositories by id and refs by name", + "allOf": [ + { + "title": "Repository ruleset conditions for ref names", + "type": "object", + "description": "Parameters for a repository ruleset ref name condition", + "properties": { + "ref_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Repository ruleset conditions for repository IDs", + "type": "object", + "description": "Parameters for a repository ID condition", + "properties": { + "repository_id": { + "type": "object", + "properties": { + "repository_ids": { + "type": "array", + "description": "The repository IDs that the ruleset applies to. One of these IDs must match for the condition to pass.", + "items": { + "type": "integer" + } + } + } + } + }, + "required": [ + "repository_id" + ] + } + ] + }, + { + "type": "object", + "title": "repository_property_and_ref_name", + "description": "Conditions to target repositories by property and refs by name", + "allOf": [ + { + "title": "Repository ruleset conditions for ref names", + "type": "object", + "description": "Parameters for a repository ruleset ref name condition", + "properties": { + "ref_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Repository ruleset conditions for repository properties", + "type": "object", + "description": "Parameters for a repository property condition", + "properties": { + "repository_property": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "The repository properties and values to include. All of these properties must match for the condition to pass.", + "items": { + "title": "Repository ruleset property targeting definition", + "type": "object", + "description": "Parameters for a targeting a repository property", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository property to target" + }, + "property_values": { + "type": "array", + "description": "The values to match for the repository property", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "description": "The source of the repository property. Defaults to 'custom' if not specified.", + "enum": [ + "custom", + "system" + ] + } + }, + "required": [ + "name", + "property_values" + ] + } + }, + "exclude": { + "type": "array", + "description": "The repository properties and values to exclude. The condition will not pass if any of these properties match.", + "items": { + "title": "Repository ruleset property targeting definition", + "type": "object", + "description": "Parameters for a targeting a repository property", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository property to target" + }, + "property_values": { + "type": "array", + "description": "The values to match for the repository property", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "description": "The source of the repository property. Defaults to 'custom' if not specified.", + "enum": [ + "custom", + "system" + ] + } + }, + "required": [ + "name", + "property_values" + ] + } + } + } + } + }, + "required": [ + "repository_property" + ] + } + ] + } + ] + }, + "rules": { + "type": "array", + "description": "An array of rules within the ruleset.", + "items": { + "title": "Repository Rule", + "type": "object", + "description": "A repository rule.", + "oneOf": [ + { + "title": "creation", + "description": "Only allow users with bypass permission to create matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "creation" + ] + } + } + }, + { + "title": "update", + "description": "Only allow users with bypass permission to update matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "update" + ] + }, + "parameters": { + "type": "object", + "properties": { + "update_allows_fetch_and_merge": { + "type": "boolean", + "description": "Branch can pull changes from its upstream repository" + } + }, + "required": [ + "update_allows_fetch_and_merge" + ] + } + } + }, + { + "title": "deletion", + "description": "Only allow users with bypass permissions to delete matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "deletion" + ] + } + } + }, + { + "title": "required_linear_history", + "description": "Prevent merge commits from being pushed to matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_linear_history" + ] + } + } + }, + { + "title": "required_deployments", + "description": "Choose which environments must be successfully deployed to before refs can be pushed into a ref that matches this rule.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_deployments" + ] + }, + "parameters": { + "type": "object", + "properties": { + "required_deployment_environments": { + "type": "array", + "description": "The environments that must be successfully deployed to before branches can be merged.", + "items": { + "type": "string" + } + } + }, + "required": [ + "required_deployment_environments" + ] + } + } + }, + { + "title": "required_signatures", + "description": "Commits pushed to matching refs must have verified signatures.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_signatures" + ] + } + } + }, + { + "title": "pull_request", + "description": "Require all commits be made to a non-target branch and submitted via a pull request before they can be merged.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "pull_request" + ] + }, + "parameters": { + "type": "object", + "properties": { + "allowed_merge_methods": { + "type": "array", + "description": "Array of allowed merge methods. Allowed values include `merge`, `squash`, and `rebase`. At least one option must be enabled.", + "items": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + } + }, + "dismiss_stale_reviews_on_push": { + "type": "boolean", + "description": "New, reviewable commits pushed will dismiss previous pull request review approvals." + }, + "require_code_owner_review": { + "type": "boolean", + "description": "Require an approving review in pull requests that modify files that have a designated code owner." + }, + "require_last_push_approval": { + "type": "boolean", + "description": "Whether the most recent reviewable push must be approved by someone other than the person who pushed it." + }, + "required_approving_review_count": { + "type": "integer", + "description": "The number of approving reviews that are required before a pull request can be merged.", + "minimum": 0, + "maximum": 10 + }, + "required_review_thread_resolution": { + "type": "boolean", + "description": "All conversations on code must be resolved before a pull request can be merged." + }, + "required_reviewers": { + "type": "array", + "description": "> [!NOTE]\n> `required_reviewers` is in beta and subject to change.\n\nA collection of reviewers and associated file patterns. Each reviewer has a list of file patterns which determine the files that reviewer is required to review.", + "items": { + "title": "RequiredReviewerConfiguration", + "description": "A reviewing team, and file patterns describing which files they must approve changes to.", + "type": "object", + "properties": { + "file_patterns": { + "type": "array", + "description": "Array of file patterns. Pull requests which change matching files must be approved by the specified team. File patterns use fnmatch syntax.", + "items": { + "type": "string" + } + }, + "minimum_approvals": { + "type": "integer", + "description": "Minimum number of approvals required from the specified team. If set to zero, the team will be added to the pull request but approval is optional." + }, + "reviewer": { + "title": "Reviewer", + "description": "A required reviewing team", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the reviewer which must review changes to matching files." + }, + "type": { + "type": "string", + "description": "The type of the reviewer", + "enum": [ + "Team" + ] + } + }, + "required": [ + "id", + "type" + ] + } + }, + "required": [ + "file_patterns", + "minimum_approvals", + "reviewer" + ] + } + } + }, + "required": [ + "dismiss_stale_reviews_on_push", + "require_code_owner_review", + "require_last_push_approval", + "required_approving_review_count", + "required_review_thread_resolution" + ] + } + } + }, + { + "title": "required_status_checks", + "description": "Choose which status checks must pass before the ref is updated. When enabled, commits must first be pushed to another ref where the checks pass.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_status_checks" + ] + }, + "parameters": { + "type": "object", + "properties": { + "do_not_enforce_on_create": { + "type": "boolean", + "description": "Allow repositories and branches to be created if a check would otherwise prohibit it." + }, + "required_status_checks": { + "type": "array", + "description": "Status checks that are required.", + "items": { + "title": "StatusCheckConfiguration", + "description": "Required status check", + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "The status check context name that must be present on the commit." + }, + "integration_id": { + "type": "integer", + "description": "The optional integration ID that this status check must originate from." + } + }, + "required": [ + "context" + ] + } + }, + "strict_required_status_checks_policy": { + "type": "boolean", + "description": "Whether pull requests targeting a matching branch must be tested with the latest code. This setting will not take effect unless at least one status check is enabled." + } + }, + "required": [ + "required_status_checks", + "strict_required_status_checks_policy" + ] + } + } + }, + { + "title": "non_fast_forward", + "description": "Prevent users with push access from force pushing to refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "non_fast_forward" + ] + } + } + }, + { + "title": "commit_message_pattern", + "description": "Parameters to be used for the commit_message_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "commit_message_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "commit_author_email_pattern", + "description": "Parameters to be used for the commit_author_email_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "commit_author_email_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "committer_email_pattern", + "description": "Parameters to be used for the committer_email_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "committer_email_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "branch_name_pattern", + "description": "Parameters to be used for the branch_name_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "branch_name_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "tag_name_pattern", + "description": "Parameters to be used for the tag_name_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "tag_name_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "file_path_restriction", + "description": "Prevent commits that include changes in specified file and folder paths from being pushed to the commit graph. This includes absolute paths that contain file names.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "file_path_restriction" + ] + }, + "parameters": { + "type": "object", + "properties": { + "restricted_file_paths": { + "type": "array", + "description": "The file paths that are restricted from being pushed to the commit graph.", + "items": { + "type": "string" + } + } + }, + "required": [ + "restricted_file_paths" + ] + } + } + }, + { + "title": "max_file_path_length", + "description": "Prevent commits that include file paths that exceed the specified character limit from being pushed to the commit graph.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "max_file_path_length" + ] + }, + "parameters": { + "type": "object", + "properties": { + "max_file_path_length": { + "type": "integer", + "description": "The maximum amount of characters allowed in file paths.", + "minimum": 1, + "maximum": 32767 + } + }, + "required": [ + "max_file_path_length" + ] + } + } + }, + { + "title": "file_extension_restriction", + "description": "Prevent commits that include files with specified file extensions from being pushed to the commit graph.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "file_extension_restriction" + ] + }, + "parameters": { + "type": "object", + "properties": { + "restricted_file_extensions": { + "type": "array", + "description": "The file extensions that are restricted from being pushed to the commit graph.", + "items": { + "type": "string" + } + } + }, + "required": [ + "restricted_file_extensions" + ] + } + } + }, + { + "title": "max_file_size", + "description": "Prevent commits with individual files that exceed the specified limit from being pushed to the commit graph.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "max_file_size" + ] + }, + "parameters": { + "type": "object", + "properties": { + "max_file_size": { + "type": "integer", + "description": "The maximum file size allowed in megabytes. This limit does not apply to Git Large File Storage (Git LFS).", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "max_file_size" + ] + } + } + }, + { + "title": "workflows", + "description": "Require all changes made to a targeted branch to pass the specified workflows before they can be merged.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "workflows" + ] + }, + "parameters": { + "type": "object", + "properties": { + "do_not_enforce_on_create": { + "type": "boolean", + "description": "Allow repositories and branches to be created if a check would otherwise prohibit it." + }, + "workflows": { + "type": "array", + "description": "Workflows that must pass for this rule to pass.", + "items": { + "title": "WorkflowFileReference", + "description": "A workflow that must run for this rule to pass", + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The path to the workflow file" + }, + "ref": { + "type": "string", + "description": "The ref (branch or tag) of the workflow file to use" + }, + "repository_id": { + "type": "integer", + "description": "The ID of the repository where the workflow is defined" + }, + "sha": { + "type": "string", + "description": "The commit SHA of the workflow file to use" + } + }, + "required": [ + "path", + "repository_id" + ] + } + } + }, + "required": [ + "workflows" + ] + } + } + }, + { + "title": "code_scanning", + "description": "Choose which tools must provide code scanning results before the reference is updated. When configured, code scanning must be enabled and have results for both the commit and the reference being updated.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "code_scanning" + ] + }, + "parameters": { + "type": "object", + "properties": { + "code_scanning_tools": { + "type": "array", + "description": "Tools that must provide code scanning results for this rule to pass.", + "items": { + "title": "CodeScanningTool", + "description": "A tool that must provide code scanning results for this rule to pass.", + "type": "object", + "properties": { + "alerts_threshold": { + "type": "string", + "description": "The severity level at which code scanning results that raise alerts block a reference update. For more information on alert severity levels, see \"[About code scanning alerts](https://docs.github.com/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts#about-alert-severity-and-security-severity-levels).\"", + "enum": [ + "none", + "errors", + "errors_and_warnings", + "all" + ] + }, + "security_alerts_threshold": { + "type": "string", + "description": "The severity level at which code scanning results that raise security alerts block a reference update. For more information on security severity levels, see \"[About code scanning alerts](https://docs.github.com/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts#about-alert-severity-and-security-severity-levels).\"", + "enum": [ + "none", + "critical", + "high_or_higher", + "medium_or_higher", + "all" + ] + }, + "tool": { + "type": "string", + "description": "The name of a code scanning tool" + } + }, + "required": [ + "alerts_threshold", + "security_alerts_threshold", + "tool" + ] + } + } + }, + "required": [ + "code_scanning_tools" + ] + } + } + }, + { + "title": "copilot_code_review", + "description": "Request Copilot code review for new pull requests automatically if the author has access to Copilot code review and their premium requests quota has not reached the limit.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "copilot_code_review" + ] + }, + "parameters": { + "type": "object", + "properties": { + "review_draft_pull_requests": { + "type": "boolean", + "description": "Copilot automatically reviews draft pull requests before they are marked as ready for review." + }, + "review_on_push": { + "type": "boolean", + "description": "Copilot automatically reviews each new push to the pull request." + } + } + } + } + } + ] + } + } + }, + "required": [ + "name", + "enforcement" + ] + }, + "EnvironmentsSettings": { + "description": "A deployment environment configuration entry", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "The name of the deployment environment", + "type": "string" + }, + "wait_timer": { + "description": "The amount of time to delay a job after the job is initially triggered (in minutes)", + "type": "integer" + }, + "reviewers": { + "description": "The people or teams that may review jobs that reference the environment", + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "description": "The type of reviewer (`User` or `Team`)", + "type": "string" + }, + "id": { + "description": "The id of the user or team who can review the deployment", + "type": "integer" + } + } + } + }, + "deployment_branch_policy": { + "description": "The type of deployment branch policy for this environment", + "type": "object", + "properties": { + "protected_branches": { + "description": "Whether only protected branches can be deployed to this environment", + "type": "boolean" + }, + "custom_branch_policies": { + "description": "Whether only branches that match the specified name patterns can deploy to this environment", + "type": "boolean" + } + } + }, + "prevent_self_review": { + "description": "Whether or not a user who created the job is prevented from approving their own job", + "type": "boolean" + } + } + }, + "CustomPropertiesSettings": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "VariablesSettings": { + "description": "An Actions variable entry", + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "description": "The name of the variable", + "type": "string" + }, + "value": { + "description": "The value of the variable", + "type": "string" + }, + "visibility": { + "description": "The visibility of the variable. Can be `all`, `private`, or `selected`", + "type": "string", + "enum": [ + "all", + "private", + "selected" + ] + } + } + } + } +} \ No newline at end of file diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json index df3d9231a..94f353549 100644 --- a/schema/dereferenced/settings.json +++ b/schema/dereferenced/settings.json @@ -96,6 +96,57 @@ "description": "Can be `enabled` or `disabled`." } } + }, + "secret_scanning_delegated_alert_dismissal": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated alert dismissal for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated bypass for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass_options": { + "type": "object", + "description": "Feature options for secret scanning delegated bypass.\nThis object is only honored when `security_and_analysis.secret_scanning_delegated_bypass.status` is set to `enabled`.\nYou can send this object in the same request as `secret_scanning_delegated_bypass`, or update just the options in a separate request.", + "properties": { + "reviewers": { + "type": "array", + "description": "The bypass reviewers for secret scanning delegated bypass.\nIf you omit this field, the existing set of reviewers is unchanged.", + "items": { + "type": "object", + "required": [ + "reviewer_id", + "reviewer_type" + ], + "properties": { + "reviewer_id": { + "type": "integer", + "description": "The ID of the team or role selected as a bypass reviewer" + }, + "reviewer_type": { + "type": "string", + "description": "The type of the bypass reviewer", + "enum": [ + "TEAM", + "ROLE" + ] + } + } + } + } + } } } }, @@ -155,7 +206,7 @@ }, "use_squash_pr_title_as_default": { "type": "boolean", - "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property is closing down. Please use `squash_merge_commit_title` instead.**", + "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property is closing down. Please use `squash_merge_commit_title` instead.", "default": false, "deprecated": true }, @@ -386,15 +437,6 @@ "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" - ], - "default": "pull" - }, "parent_team_id": { "type": "integer", "description": "The ID of a team to set as the parent team." diff --git a/schema/dereferenced/suborgs.json b/schema/dereferenced/suborgs.json new file mode 100644 index 000000000..11ba9ed2d --- /dev/null +++ b/schema/dereferenced/suborgs.json @@ -0,0 +1,2776 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Safe-settings suborg-level configuration", + "description": "Schema for .github/suborgs/*.yml — suborg-level safe-settings configuration. Defines which repos belong to the suborg and what settings to apply.", + "type": "object", + "properties": { + "suborgrepos": { + "type": "array", + "description": "Glob patterns matching repository names. Repos whose names match any pattern are included in this suborg.", + "items": { + "type": "string" + } + }, + "suborgteams": { + "type": "array", + "description": "Team slugs. Repos that belong to any of these teams are included in this suborg.", + "items": { + "type": "string" + } + }, + "suborgproperties": { + "type": "array", + "description": "Custom property filters. Repos with matching custom property values are included in this suborg.", + "items": { + "type": "object", + "properties": { + "property_name": { + "type": "string", + "description": "The name of the custom property" + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Accepted values for this property" + } + } + } + }, + "repositories": { + "description": "Repository settings. Use force_create to create the repository if it does not exist, and template to specify a template repository to use when creating it.", + "allOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository." + }, + "description": { + "type": "string", + "description": "A short description of the repository." + }, + "homepage": { + "type": "string", + "description": "A URL with more information about the repository." + }, + "private": { + "type": "boolean", + "description": "Either `true` to make the repository private or `false` to make it public. Default: `false`. \n**Note**: You will get a `422` error if the organization restricts [changing repository visibility](https://docs.github.com/articles/repository-permission-levels-for-an-organization#changing-the-visibility-of-repositories) to organization owners and a non-owner tries to change the value of private.", + "default": false + }, + "visibility": { + "type": "string", + "description": "The visibility of the repository.", + "enum": [ + "public", + "private" + ] + }, + "security_and_analysis": { + "type": "object", + "description": "Specify which security and analysis features to enable or disable for the repository.\n\nTo use this parameter, you must have admin permissions for the repository or be an owner or security manager for the organization that owns the repository. For more information, see \"[Managing security managers in your organization](https://docs.github.com/organizations/managing-peoples-access-to-your-organization-with-roles/managing-security-managers-in-your-organization).\"\n\nFor example, to enable GitHub Advanced Security, use this data in the body of the `PATCH` request:\n`{ \"security_and_analysis\": {\"advanced_security\": { \"status\": \"enabled\" } } }`.\n\nYou can check which security and analysis features are currently enabled by using a `GET /repos/{owner}/{repo}` request.", + "nullable": true, + "properties": { + "advanced_security": { + "type": "object", + "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.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning for this repository. For more information, see \"[About secret scanning](/code-security/secret-security/about-secret-scanning).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_push_protection": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning push protection for this repository. For more information, see \"[Protecting pushes with secret scanning](/code-security/secret-scanning/protecting-pushes-with-secret-scanning).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "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).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_alert_dismissal": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated alert dismissal for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated bypass for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass_options": { + "type": "object", + "description": "Feature options for secret scanning delegated bypass.\nThis object is only honored when `security_and_analysis.secret_scanning_delegated_bypass.status` is set to `enabled`.\nYou can send this object in the same request as `secret_scanning_delegated_bypass`, or update just the options in a separate request.", + "properties": { + "reviewers": { + "type": "array", + "description": "The bypass reviewers for secret scanning delegated bypass.\nIf you omit this field, the existing set of reviewers is unchanged.", + "items": { + "type": "object", + "required": [ + "reviewer_id", + "reviewer_type" + ], + "properties": { + "reviewer_id": { + "type": "integer", + "description": "The ID of the team or role selected as a bypass reviewer" + }, + "reviewer_type": { + "type": "string", + "description": "The type of the bypass reviewer", + "enum": [ + "TEAM", + "ROLE" + ] + } + } + } + } + } + } + } + }, + "has_issues": { + "type": "boolean", + "description": "Either `true` to enable issues for this repository or `false` to disable them.", + "default": true + }, + "has_projects": { + "type": "boolean", + "description": "Either `true` to enable projects for this repository or `false` to disable them. **Note:** If you're creating a repository in an organization that has disabled repository projects, the default is `false`, and if you pass `true`, the API returns an error.", + "default": true + }, + "has_wiki": { + "type": "boolean", + "description": "Either `true` to enable the wiki for this repository or `false` to disable it.", + "default": true + }, + "is_template": { + "type": "boolean", + "description": "Either `true` to make this repo available as a template repository or `false` to prevent it.", + "default": false + }, + "default_branch": { + "type": "string", + "description": "Updates the default branch for this repository." + }, + "allow_squash_merge": { + "type": "boolean", + "description": "Either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging.", + "default": true + }, + "allow_merge_commit": { + "type": "boolean", + "description": "Either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits.", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "description": "Either `true` to allow rebase-merging pull requests, or `false` to prevent rebase-merging.", + "default": true + }, + "allow_auto_merge": { + "type": "boolean", + "description": "Either `true` to allow auto-merge on pull requests, or `false` to disallow auto-merge.", + "default": false + }, + "delete_branch_on_merge": { + "type": "boolean", + "description": "Either `true` to allow automatically deleting head branches when pull requests are merged, or `false` to prevent automatic deletion.", + "default": false + }, + "allow_update_branch": { + "type": "boolean", + "description": "Either `true` to always allow a pull request head branch that is behind its base branch to be updated even if it is not required to be up to date before merging, or false otherwise.", + "default": false + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property is closing down. Please use `squash_merge_commit_title` instead.", + "default": false, + "deprecated": true + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ], + "description": "Required when using `squash_merge_commit_message`.\n\nThe default value for a squash merge commit title:\n\n- `PR_TITLE` - default to the pull request's title.\n- `COMMIT_OR_PR_TITLE` - default to the commit's title (if only one commit) or the pull request's title (when more than one commit)." + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ], + "description": "The default value for a squash merge commit message:\n\n- `PR_BODY` - default to the pull request's body.\n- `COMMIT_MESSAGES` - default to the branch's commit messages.\n- `BLANK` - default to a blank commit message." + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ], + "description": "Required when using `merge_commit_message`.\n\nThe default value for a merge commit title.\n\n- `PR_TITLE` - default to the pull request's title.\n- `MERGE_MESSAGE` - default to the classic title for a merge message (e.g., Merge pull request #123 from branch-name)." + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ], + "description": "The default value for a merge commit message.\n\n- `PR_TITLE` - default to the pull request's title.\n- `PR_BODY` - default to the pull request's body.\n- `BLANK` - default to a blank commit message." + }, + "archived": { + "type": "boolean", + "description": "Whether to archive this repository. `false` will unarchive a previously archived repository.", + "default": false + }, + "allow_forking": { + "type": "boolean", + "description": "Either `true` to allow private forks, or `false` to prevent private forks.", + "default": false + }, + "web_commit_signoff_required": { + "type": "boolean", + "description": "Either `true` to require contributors to sign off on web-based commits, or `false` to not require contributors to sign off on web-based commits.", + "default": false + } + } + }, + { + "type": "object", + "properties": { + "auto_init": { + "description": "Create an initial commit with empty README. Keep this set to true in most cases since many of the policies below cannot be implemented on bare repos", + "type": "boolean" + }, + "gitignore_template": { + "description": "Desired language or platform [.gitignore template](https://github.com/github/gitignore) to apply. Use the name of the template without the extension. For example, 'Haskell'.", + "type": "string" + }, + "license_template": { + "description": "Choose an [open source license template](https://choosealicense.com/) that best suits your needs, and then use the [license keyword](https://help.github.com/articles/licensing-a-repository/#searching-github-by-license-type) as the `license_template` string. For example, 'mit' or 'mpl-2.0'.", + "type": "string" + }, + "topics": { + "description": "A list of topics to set on the repository", + "type": "array", + "items": { + "type": "string" + } + }, + "security": { + "description": "Settings for Code security and analysis", + "type": "object", + "properties": { + "enableVulnerabilityAlerts": { + "type": "boolean" + }, + "enableAutomatedSecurityFixes": { + "type": "boolean" + } + } + }, + "force_create": { + "description": "If true, create the repository if it does not already exist.", + "type": "boolean" + }, + "template": { + "description": "Name of a template repository to use when creating a new repository.", + "type": "string" + } + } + } + ] + }, + "labels": { + "description": "Labels: define labels for Issues and Pull Requests", + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "color": { + "description": "The hexadecimal color code for the label. If including a `#`, make sure to wrap it with quotes!", + "type": "string" + }, + "description": { + "type": "string" + }, + "oldname": { + "description": "Include the old name to rename an existing label", + "type": "string" + } + } + } + }, + "exclude": { + "description": "Ignore any labels matching these regexes (don't delete them)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "collaborators": { + "description": "Collaborators: give specific users access to any repository.", + "type": "array", + "items": { + "description": "A collaborator entry giving a specific user access to a repository.", + "allOf": [ + { + "type": "object", + "properties": { + "permission": { + "type": "string", + "description": "The permission to grant the collaborator. **Only valid on organization-owned repositories.** 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.", + "default": "push" + } + } + }, + { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "exclude": { + "description": "You can exclude a list of repos for this collaborator and all repos except these repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "description": "You can include a list of repos for this collaborator and only those repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "teams": { + "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" + ] + }, + "parent_team_id": { + "type": "integer", + "description": "The ID of a team to set as the parent team." + } + }, + "required": [ + "name" + ] + } + }, + "milestones": { + "description": "Milestones: define milestones for Issues and Pull Requests", + "type": "array", + "items": { + "description": "A milestone entry", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "state": { + "description": "The state of the milestone. Either `open` or `closed`", + "type": "string" + } + } + } + }, + "branches": { + "description": "Branch protection rules", + "type": "array", + "items": { + "description": "A branch protection rule entry", + "type": "object", + "properties": { + "name": { + "description": "If the name of the branch value is specified as `default`, then the app will create a branch protection rule to apply against the default branch in the repo", + "type": "string" + }, + "protection": { + "type": "object", + "properties": { + "required_status_checks": { + "type": "object", + "description": "Require status checks to pass before merging. Set to `null` to disable.", + "nullable": true, + "properties": { + "strict": { + "type": "boolean", + "description": "Require branches to be up to date before merging." + }, + "contexts": { + "type": "array", + "deprecated": true, + "description": "**Closing down notice**: The list of status checks to require in order to merge into this branch. If any of these checks have recently been set by a particular GitHub App, they will be required to come from that app in future for the branch to merge. Use `checks` instead of `contexts` for more fine-grained control.", + "items": { + "type": "string" + } + }, + "checks": { + "type": "array", + "description": "The list of status checks to require in order to merge into this branch.", + "items": { + "type": "object", + "required": [ + "context" + ], + "properties": { + "context": { + "type": "string", + "description": "The name of the required check" + }, + "app_id": { + "type": "integer", + "description": "The ID of the GitHub App that must provide this check. Omit this field to automatically select the GitHub App that has recently provided this check, or any app if it was not set by a GitHub App. Pass -1 to explicitly allow any app to set the status." + } + } + } + } + }, + "required": [ + "strict", + "contexts" + ] + }, + "enforce_admins": { + "type": "boolean", + "description": "Enforce all configured restrictions for administrators. Set to `true` to enforce required status checks for repository administrators. Set to `null` to disable.", + "nullable": true + }, + "required_pull_request_reviews": { + "type": "object", + "description": "Require at least one approving review on a pull request, before merging. Set to `null` to disable.", + "nullable": true, + "properties": { + "dismissal_restrictions": { + "type": "object", + "description": "Specify which users, teams, and apps can dismiss pull request reviews. Pass an empty `dismissal_restrictions` object to disable. User and team `dismissal_restrictions` are only available for organization-owned repositories. Omit this parameter for personal repositories.", + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s with dismissal access", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s with dismissal access", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s with dismissal access", + "items": { + "type": "string" + } + } + } + }, + "dismiss_stale_reviews": { + "type": "boolean", + "description": "Set to `true` if you want to automatically dismiss approving reviews when someone pushes a new commit." + }, + "require_code_owner_reviews": { + "type": "boolean", + "description": "Blocks merging pull requests until [code owners](https://docs.github.com/articles/about-code-owners/) review them." + }, + "required_approving_review_count": { + "type": "integer", + "description": "Specify the number of reviewers required to approve pull requests. Use a number between 1 and 6 or 0 to not require reviewers." + }, + "require_last_push_approval": { + "type": "boolean", + "description": "Whether the most recent push must be approved by someone other than the person who pushed it. Default: `false`.", + "default": false + }, + "bypass_pull_request_allowances": { + "type": "object", + "description": "Allow specific users, teams, or apps to bypass pull request requirements.", + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + } + } + } + } + }, + "restrictions": { + "type": "object", + "description": "Restrict who can push to the protected branch. User, app, and team `restrictions` are only available for organization-owned repositories. Set to `null` to disable.", + "nullable": true, + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s with push access", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s with push access", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s with push access", + "items": { + "type": "string" + } + } + }, + "required": [ + "users", + "teams" + ] + }, + "required_linear_history": { + "type": "boolean", + "description": "Enforces a linear commit Git history, which prevents anyone from pushing merge commits to a branch. Set to `true` to enforce a linear commit history. Set to `false` to disable a linear commit Git history. Your repository must allow squash merging or rebase merging before you can enable a linear commit history. Default: `false`. For more information, see \"[Requiring a linear commit history](https://docs.github.com/github/administering-a-repository/requiring-a-linear-commit-history)\" in the GitHub Help documentation." + }, + "allow_force_pushes": { + "type": "boolean", + "description": "Permits force pushes to the protected branch by anyone with write access to the repository. Set to `true` to allow force pushes. Set to `false` or `null` to block force pushes. Default: `false`. For more information, see \"[Enabling force pushes to a protected branch](https://docs.github.com/github/administering-a-repository/enabling-force-pushes-to-a-protected-branch)\" in the GitHub Help documentation.\"", + "nullable": true + }, + "allow_deletions": { + "type": "boolean", + "description": "Allows deletion of the protected branch by anyone with write access to the repository. Set to `false` to prevent deletion of the protected branch. Default: `false`. For more information, see \"[Enabling force pushes to a protected branch](https://docs.github.com/github/administering-a-repository/enabling-force-pushes-to-a-protected-branch)\" in the GitHub Help documentation." + }, + "block_creations": { + "type": "boolean", + "description": "If set to `true`, the `restrictions` branch protection settings which limits who can push will also block pushes which create new branches, unless the push is initiated by a user, team, or app which has the ability to push. Set to `true` to restrict new branch creation. Default: `false`." + }, + "required_conversation_resolution": { + "type": "boolean", + "description": "Requires all conversations on code to be resolved before a pull request can be merged into a branch that matches this rule. Set to `false` to disable. Default: `false`." + }, + "lock_branch": { + "type": "boolean", + "description": "Whether to set the branch as read-only. If this is true, users will not be able to push to the branch. Default: `false`.", + "default": false + }, + "allow_fork_syncing": { + "type": "boolean", + "description": "Whether users can pull changes from upstream when the branch is locked. Set to `true` to allow fork syncing. Set to `false` to prevent fork syncing. Default: `false`.", + "default": false + } + }, + "required": [ + "required_status_checks", + "enforce_admins", + "required_pull_request_reviews", + "restrictions" + ] + } + } + } + }, + "autolinks": { + "description": "Autolinks", + "type": "array", + "items": { + "description": "An autolink reference entry", + "type": "object", + "properties": { + "key_prefix": { + "type": "string", + "description": "This prefix appended by certain characters will generate a link any time it is found in an issue, pull request, or commit." + }, + "url_template": { + "type": "string", + "description": "The URL must contain `` for the reference number. `` matches different characters depending on the value of `is_alphanumeric`." + }, + "is_alphanumeric": { + "type": "boolean", + "default": true, + "description": "Whether this autolink reference matches alphanumeric characters. If true, the `` parameter of the `url_template` matches alphanumeric characters `A-Z` (case insensitive), `0-9`, and `-`. If false, this autolink reference only matches numeric characters." + } + }, + "required": [ + "key_prefix", + "url_template" + ] + } + }, + "validator": { + "description": "Repository name validation", + "type": "object", + "properties": { + "pattern": { + "type": "string" + } + } + }, + "environments": { + "description": "Deployment environments", + "type": "array", + "items": { + "description": "A deployment environment configuration entry", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "The name of the deployment environment", + "type": "string" + }, + "wait_timer": { + "description": "The amount of time to delay a job after the job is initially triggered (in minutes)", + "type": "integer" + }, + "reviewers": { + "description": "The people or teams that may review jobs that reference the environment", + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "description": "The type of reviewer (`User` or `Team`)", + "type": "string" + }, + "id": { + "description": "The id of the user or team who can review the deployment", + "type": "integer" + } + } + } + }, + "deployment_branch_policy": { + "description": "The type of deployment branch policy for this environment", + "type": "object", + "properties": { + "protected_branches": { + "description": "Whether only protected branches can be deployed to this environment", + "type": "boolean" + }, + "custom_branch_policies": { + "description": "Whether only branches that match the specified name patterns can deploy to this environment", + "type": "boolean" + } + } + }, + "prevent_self_review": { + "description": "Whether or not a user who created the job is prevented from approving their own job", + "type": "boolean" + } + } + } + }, + "custom_properties": { + "description": "Custom properties", + "type": "array", + "items": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "variables": { + "description": "Repository or org-level Actions variables", + "type": "array", + "items": { + "description": "An Actions variable entry", + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "description": "The name of the variable", + "type": "string" + }, + "value": { + "description": "The value of the variable", + "type": "string" + }, + "visibility": { + "description": "The visibility of the variable. Can be `all`, `private`, or `selected`", + "type": "string", + "enum": [ + "all", + "private", + "selected" + ] + } + } + } + } + }, + "$defs": { + "RepositorySettings": { + "description": "Repository settings. Use force_create to create the repository if it does not exist, and template to specify a template repository to use when creating it.", + "allOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository." + }, + "description": { + "type": "string", + "description": "A short description of the repository." + }, + "homepage": { + "type": "string", + "description": "A URL with more information about the repository." + }, + "private": { + "type": "boolean", + "description": "Either `true` to make the repository private or `false` to make it public. Default: `false`. \n**Note**: You will get a `422` error if the organization restricts [changing repository visibility](https://docs.github.com/articles/repository-permission-levels-for-an-organization#changing-the-visibility-of-repositories) to organization owners and a non-owner tries to change the value of private.", + "default": false + }, + "visibility": { + "type": "string", + "description": "The visibility of the repository.", + "enum": [ + "public", + "private" + ] + }, + "security_and_analysis": { + "type": "object", + "description": "Specify which security and analysis features to enable or disable for the repository.\n\nTo use this parameter, you must have admin permissions for the repository or be an owner or security manager for the organization that owns the repository. For more information, see \"[Managing security managers in your organization](https://docs.github.com/organizations/managing-peoples-access-to-your-organization-with-roles/managing-security-managers-in-your-organization).\"\n\nFor example, to enable GitHub Advanced Security, use this data in the body of the `PATCH` request:\n`{ \"security_and_analysis\": {\"advanced_security\": { \"status\": \"enabled\" } } }`.\n\nYou can check which security and analysis features are currently enabled by using a `GET /repos/{owner}/{repo}` request.", + "nullable": true, + "properties": { + "advanced_security": { + "type": "object", + "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.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning for this repository. For more information, see \"[About secret scanning](/code-security/secret-security/about-secret-scanning).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_push_protection": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning push protection for this repository. For more information, see \"[Protecting pushes with secret scanning](/code-security/secret-scanning/protecting-pushes-with-secret-scanning).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "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).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_alert_dismissal": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated alert dismissal for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated bypass for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass_options": { + "type": "object", + "description": "Feature options for secret scanning delegated bypass.\nThis object is only honored when `security_and_analysis.secret_scanning_delegated_bypass.status` is set to `enabled`.\nYou can send this object in the same request as `secret_scanning_delegated_bypass`, or update just the options in a separate request.", + "properties": { + "reviewers": { + "type": "array", + "description": "The bypass reviewers for secret scanning delegated bypass.\nIf you omit this field, the existing set of reviewers is unchanged.", + "items": { + "type": "object", + "required": [ + "reviewer_id", + "reviewer_type" + ], + "properties": { + "reviewer_id": { + "type": "integer", + "description": "The ID of the team or role selected as a bypass reviewer" + }, + "reviewer_type": { + "type": "string", + "description": "The type of the bypass reviewer", + "enum": [ + "TEAM", + "ROLE" + ] + } + } + } + } + } + } + } + }, + "has_issues": { + "type": "boolean", + "description": "Either `true` to enable issues for this repository or `false` to disable them.", + "default": true + }, + "has_projects": { + "type": "boolean", + "description": "Either `true` to enable projects for this repository or `false` to disable them. **Note:** If you're creating a repository in an organization that has disabled repository projects, the default is `false`, and if you pass `true`, the API returns an error.", + "default": true + }, + "has_wiki": { + "type": "boolean", + "description": "Either `true` to enable the wiki for this repository or `false` to disable it.", + "default": true + }, + "is_template": { + "type": "boolean", + "description": "Either `true` to make this repo available as a template repository or `false` to prevent it.", + "default": false + }, + "default_branch": { + "type": "string", + "description": "Updates the default branch for this repository." + }, + "allow_squash_merge": { + "type": "boolean", + "description": "Either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging.", + "default": true + }, + "allow_merge_commit": { + "type": "boolean", + "description": "Either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits.", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "description": "Either `true` to allow rebase-merging pull requests, or `false` to prevent rebase-merging.", + "default": true + }, + "allow_auto_merge": { + "type": "boolean", + "description": "Either `true` to allow auto-merge on pull requests, or `false` to disallow auto-merge.", + "default": false + }, + "delete_branch_on_merge": { + "type": "boolean", + "description": "Either `true` to allow automatically deleting head branches when pull requests are merged, or `false` to prevent automatic deletion.", + "default": false + }, + "allow_update_branch": { + "type": "boolean", + "description": "Either `true` to always allow a pull request head branch that is behind its base branch to be updated even if it is not required to be up to date before merging, or false otherwise.", + "default": false + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property is closing down. Please use `squash_merge_commit_title` instead.", + "default": false, + "deprecated": true + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ], + "description": "Required when using `squash_merge_commit_message`.\n\nThe default value for a squash merge commit title:\n\n- `PR_TITLE` - default to the pull request's title.\n- `COMMIT_OR_PR_TITLE` - default to the commit's title (if only one commit) or the pull request's title (when more than one commit)." + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ], + "description": "The default value for a squash merge commit message:\n\n- `PR_BODY` - default to the pull request's body.\n- `COMMIT_MESSAGES` - default to the branch's commit messages.\n- `BLANK` - default to a blank commit message." + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ], + "description": "Required when using `merge_commit_message`.\n\nThe default value for a merge commit title.\n\n- `PR_TITLE` - default to the pull request's title.\n- `MERGE_MESSAGE` - default to the classic title for a merge message (e.g., Merge pull request #123 from branch-name)." + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ], + "description": "The default value for a merge commit message.\n\n- `PR_TITLE` - default to the pull request's title.\n- `PR_BODY` - default to the pull request's body.\n- `BLANK` - default to a blank commit message." + }, + "archived": { + "type": "boolean", + "description": "Whether to archive this repository. `false` will unarchive a previously archived repository.", + "default": false + }, + "allow_forking": { + "type": "boolean", + "description": "Either `true` to allow private forks, or `false` to prevent private forks.", + "default": false + }, + "web_commit_signoff_required": { + "type": "boolean", + "description": "Either `true` to require contributors to sign off on web-based commits, or `false` to not require contributors to sign off on web-based commits.", + "default": false + } + } + }, + { + "type": "object", + "properties": { + "auto_init": { + "description": "Create an initial commit with empty README. Keep this set to true in most cases since many of the policies below cannot be implemented on bare repos", + "type": "boolean" + }, + "gitignore_template": { + "description": "Desired language or platform [.gitignore template](https://github.com/github/gitignore) to apply. Use the name of the template without the extension. For example, 'Haskell'.", + "type": "string" + }, + "license_template": { + "description": "Choose an [open source license template](https://choosealicense.com/) that best suits your needs, and then use the [license keyword](https://help.github.com/articles/licensing-a-repository/#searching-github-by-license-type) as the `license_template` string. For example, 'mit' or 'mpl-2.0'.", + "type": "string" + }, + "topics": { + "description": "A list of topics to set on the repository", + "type": "array", + "items": { + "type": "string" + } + }, + "security": { + "description": "Settings for Code security and analysis", + "type": "object", + "properties": { + "enableVulnerabilityAlerts": { + "type": "boolean" + }, + "enableAutomatedSecurityFixes": { + "type": "boolean" + } + } + }, + "force_create": { + "description": "If true, create the repository if it does not already exist.", + "type": "boolean" + }, + "template": { + "description": "Name of a template repository to use when creating a new repository.", + "type": "string" + } + } + } + ] + }, + "LabelSettings": { + "description": "Labels: define labels for Issues and Pull Requests", + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "color": { + "description": "The hexadecimal color code for the label. If including a `#`, make sure to wrap it with quotes!", + "type": "string" + }, + "description": { + "type": "string" + }, + "oldname": { + "description": "Include the old name to rename an existing label", + "type": "string" + } + } + } + }, + "exclude": { + "description": "Ignore any labels matching these regexes (don't delete them)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CollaboratorSettings": { + "description": "A collaborator entry giving a specific user access to a repository.", + "allOf": [ + { + "type": "object", + "properties": { + "permission": { + "type": "string", + "description": "The permission to grant the collaborator. **Only valid on organization-owned repositories.** 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.", + "default": "push" + } + } + }, + { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "exclude": { + "description": "You can exclude a list of repos for this collaborator and all repos except these repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "description": "You can include a list of repos for this collaborator and only those repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "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" + ] + }, + "parent_team_id": { + "type": "integer", + "description": "The ID of a team to set as the parent team." + } + }, + "required": [ + "name" + ] + }, + "MilestoneSettings": { + "description": "A milestone entry", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "state": { + "description": "The state of the milestone. Either `open` or `closed`", + "type": "string" + } + } + }, + "BranchSettings": { + "description": "A branch protection rule entry", + "type": "object", + "properties": { + "name": { + "description": "If the name of the branch value is specified as `default`, then the app will create a branch protection rule to apply against the default branch in the repo", + "type": "string" + }, + "protection": { + "type": "object", + "properties": { + "required_status_checks": { + "type": "object", + "description": "Require status checks to pass before merging. Set to `null` to disable.", + "nullable": true, + "properties": { + "strict": { + "type": "boolean", + "description": "Require branches to be up to date before merging." + }, + "contexts": { + "type": "array", + "deprecated": true, + "description": "**Closing down notice**: The list of status checks to require in order to merge into this branch. If any of these checks have recently been set by a particular GitHub App, they will be required to come from that app in future for the branch to merge. Use `checks` instead of `contexts` for more fine-grained control.", + "items": { + "type": "string" + } + }, + "checks": { + "type": "array", + "description": "The list of status checks to require in order to merge into this branch.", + "items": { + "type": "object", + "required": [ + "context" + ], + "properties": { + "context": { + "type": "string", + "description": "The name of the required check" + }, + "app_id": { + "type": "integer", + "description": "The ID of the GitHub App that must provide this check. Omit this field to automatically select the GitHub App that has recently provided this check, or any app if it was not set by a GitHub App. Pass -1 to explicitly allow any app to set the status." + } + } + } + } + }, + "required": [ + "strict", + "contexts" + ] + }, + "enforce_admins": { + "type": "boolean", + "description": "Enforce all configured restrictions for administrators. Set to `true` to enforce required status checks for repository administrators. Set to `null` to disable.", + "nullable": true + }, + "required_pull_request_reviews": { + "type": "object", + "description": "Require at least one approving review on a pull request, before merging. Set to `null` to disable.", + "nullable": true, + "properties": { + "dismissal_restrictions": { + "type": "object", + "description": "Specify which users, teams, and apps can dismiss pull request reviews. Pass an empty `dismissal_restrictions` object to disable. User and team `dismissal_restrictions` are only available for organization-owned repositories. Omit this parameter for personal repositories.", + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s with dismissal access", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s with dismissal access", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s with dismissal access", + "items": { + "type": "string" + } + } + } + }, + "dismiss_stale_reviews": { + "type": "boolean", + "description": "Set to `true` if you want to automatically dismiss approving reviews when someone pushes a new commit." + }, + "require_code_owner_reviews": { + "type": "boolean", + "description": "Blocks merging pull requests until [code owners](https://docs.github.com/articles/about-code-owners/) review them." + }, + "required_approving_review_count": { + "type": "integer", + "description": "Specify the number of reviewers required to approve pull requests. Use a number between 1 and 6 or 0 to not require reviewers." + }, + "require_last_push_approval": { + "type": "boolean", + "description": "Whether the most recent push must be approved by someone other than the person who pushed it. Default: `false`.", + "default": false + }, + "bypass_pull_request_allowances": { + "type": "object", + "description": "Allow specific users, teams, or apps to bypass pull request requirements.", + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + } + } + } + } + }, + "restrictions": { + "type": "object", + "description": "Restrict who can push to the protected branch. User, app, and team `restrictions` are only available for organization-owned repositories. Set to `null` to disable.", + "nullable": true, + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s with push access", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s with push access", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s with push access", + "items": { + "type": "string" + } + } + }, + "required": [ + "users", + "teams" + ] + }, + "required_linear_history": { + "type": "boolean", + "description": "Enforces a linear commit Git history, which prevents anyone from pushing merge commits to a branch. Set to `true` to enforce a linear commit history. Set to `false` to disable a linear commit Git history. Your repository must allow squash merging or rebase merging before you can enable a linear commit history. Default: `false`. For more information, see \"[Requiring a linear commit history](https://docs.github.com/github/administering-a-repository/requiring-a-linear-commit-history)\" in the GitHub Help documentation." + }, + "allow_force_pushes": { + "type": "boolean", + "description": "Permits force pushes to the protected branch by anyone with write access to the repository. Set to `true` to allow force pushes. Set to `false` or `null` to block force pushes. Default: `false`. For more information, see \"[Enabling force pushes to a protected branch](https://docs.github.com/github/administering-a-repository/enabling-force-pushes-to-a-protected-branch)\" in the GitHub Help documentation.\"", + "nullable": true + }, + "allow_deletions": { + "type": "boolean", + "description": "Allows deletion of the protected branch by anyone with write access to the repository. Set to `false` to prevent deletion of the protected branch. Default: `false`. For more information, see \"[Enabling force pushes to a protected branch](https://docs.github.com/github/administering-a-repository/enabling-force-pushes-to-a-protected-branch)\" in the GitHub Help documentation." + }, + "block_creations": { + "type": "boolean", + "description": "If set to `true`, the `restrictions` branch protection settings which limits who can push will also block pushes which create new branches, unless the push is initiated by a user, team, or app which has the ability to push. Set to `true` to restrict new branch creation. Default: `false`." + }, + "required_conversation_resolution": { + "type": "boolean", + "description": "Requires all conversations on code to be resolved before a pull request can be merged into a branch that matches this rule. Set to `false` to disable. Default: `false`." + }, + "lock_branch": { + "type": "boolean", + "description": "Whether to set the branch as read-only. If this is true, users will not be able to push to the branch. Default: `false`.", + "default": false + }, + "allow_fork_syncing": { + "type": "boolean", + "description": "Whether users can pull changes from upstream when the branch is locked. Set to `true` to allow fork syncing. Set to `false` to prevent fork syncing. Default: `false`.", + "default": false + } + }, + "required": [ + "required_status_checks", + "enforce_admins", + "required_pull_request_reviews", + "restrictions" + ] + } + } + }, + "AutolinkSettings": { + "description": "An autolink reference entry", + "type": "object", + "properties": { + "key_prefix": { + "type": "string", + "description": "This prefix appended by certain characters will generate a link any time it is found in an issue, pull request, or commit." + }, + "url_template": { + "type": "string", + "description": "The URL must contain `` for the reference number. `` matches different characters depending on the value of `is_alphanumeric`." + }, + "is_alphanumeric": { + "type": "boolean", + "default": true, + "description": "Whether this autolink reference matches alphanumeric characters. If true, the `` parameter of the `url_template` matches alphanumeric characters `A-Z` (case insensitive), `0-9`, and `-`. If false, this autolink reference only matches numeric characters." + } + }, + "required": [ + "key_prefix", + "url_template" + ] + }, + "ValidatorSettings": { + "description": "Repository name validation", + "type": "object", + "properties": { + "pattern": { + "type": "string" + } + } + }, + "RulesetSettings": { + "description": "A ruleset entry", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the ruleset." + }, + "target": { + "type": "string", + "description": "The target of the ruleset", + "enum": [ + "branch", + "tag", + "push", + "repository" + ], + "default": "branch" + }, + "enforcement": { + "type": "string", + "description": "The enforcement level of the ruleset. `evaluate` allows admins to test rules before enforcing them. Admins can view insights on the Rule Insights page (`evaluate` is only available with GitHub Enterprise).", + "enum": [ + "disabled", + "active", + "evaluate" + ] + }, + "bypass_actors": { + "type": "array", + "description": "The actors that can bypass the rules in this ruleset", + "items": { + "title": "Repository Ruleset Bypass Actor", + "type": "object", + "description": "An actor that can bypass rules in a ruleset", + "required": [ + "actor_type" + ], + "properties": { + "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." + }, + "actor_type": { + "type": "string", + "enum": [ + "Integration", + "OrganizationAdmin", + "RepositoryRole", + "Team", + "DeployKey" + ], + "description": "The type of actor that can bypass a ruleset." + }, + "bypass_mode": { + "type": "string", + "description": "When the specified actor can bypass the ruleset. `pull_request` means that an actor can only bypass rules on pull requests. `pull_request` is not applicable for the `DeployKey` actor type. Also, `pull_request` is only applicable to branch rulesets. When `bypass_mode` is `exempt`, rules will not be run for that actor and a bypass audit entry will not be created.", + "enum": [ + "always", + "pull_request", + "exempt" + ], + "default": "always" + } + } + } + }, + "conditions": { + "title": "Organization ruleset conditions", + "type": "object", + "description": "Conditions for an organization ruleset.\nThe branch and tag rulesets conditions object should contain both `repository_name` and `ref_name` properties, or both `repository_id` and `ref_name` properties, or both `repository_property` and `ref_name` properties.\nThe push rulesets conditions object does not require the `ref_name` property.\nFor repository policy rulesets, the conditions object should only contain the `repository_name`, the `repository_id`, or the `repository_property`.", + "oneOf": [ + { + "type": "object", + "title": "repository_name_and_ref_name", + "description": "Conditions to target repositories by name and refs by name", + "allOf": [ + { + "title": "Repository ruleset conditions for ref names", + "type": "object", + "description": "Parameters for a repository ruleset ref name condition", + "properties": { + "ref_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Repository ruleset conditions for repository names", + "type": "object", + "description": "Parameters for a repository name condition", + "properties": { + "repository_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of repository names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~ALL` to include all repositories.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of repository names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + }, + "protected": { + "type": "boolean", + "description": "Whether renaming of target repositories is prevented." + } + } + } + }, + "required": [ + "repository_name" + ] + } + ] + }, + { + "type": "object", + "title": "repository_id_and_ref_name", + "description": "Conditions to target repositories by id and refs by name", + "allOf": [ + { + "title": "Repository ruleset conditions for ref names", + "type": "object", + "description": "Parameters for a repository ruleset ref name condition", + "properties": { + "ref_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Repository ruleset conditions for repository IDs", + "type": "object", + "description": "Parameters for a repository ID condition", + "properties": { + "repository_id": { + "type": "object", + "properties": { + "repository_ids": { + "type": "array", + "description": "The repository IDs that the ruleset applies to. One of these IDs must match for the condition to pass.", + "items": { + "type": "integer" + } + } + } + } + }, + "required": [ + "repository_id" + ] + } + ] + }, + { + "type": "object", + "title": "repository_property_and_ref_name", + "description": "Conditions to target repositories by property and refs by name", + "allOf": [ + { + "title": "Repository ruleset conditions for ref names", + "type": "object", + "description": "Parameters for a repository ruleset ref name condition", + "properties": { + "ref_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Repository ruleset conditions for repository properties", + "type": "object", + "description": "Parameters for a repository property condition", + "properties": { + "repository_property": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "The repository properties and values to include. All of these properties must match for the condition to pass.", + "items": { + "title": "Repository ruleset property targeting definition", + "type": "object", + "description": "Parameters for a targeting a repository property", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository property to target" + }, + "property_values": { + "type": "array", + "description": "The values to match for the repository property", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "description": "The source of the repository property. Defaults to 'custom' if not specified.", + "enum": [ + "custom", + "system" + ] + } + }, + "required": [ + "name", + "property_values" + ] + } + }, + "exclude": { + "type": "array", + "description": "The repository properties and values to exclude. The condition will not pass if any of these properties match.", + "items": { + "title": "Repository ruleset property targeting definition", + "type": "object", + "description": "Parameters for a targeting a repository property", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository property to target" + }, + "property_values": { + "type": "array", + "description": "The values to match for the repository property", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "description": "The source of the repository property. Defaults to 'custom' if not specified.", + "enum": [ + "custom", + "system" + ] + } + }, + "required": [ + "name", + "property_values" + ] + } + } + } + } + }, + "required": [ + "repository_property" + ] + } + ] + } + ] + }, + "rules": { + "type": "array", + "description": "An array of rules within the ruleset.", + "items": { + "title": "Repository Rule", + "type": "object", + "description": "A repository rule.", + "oneOf": [ + { + "title": "creation", + "description": "Only allow users with bypass permission to create matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "creation" + ] + } + } + }, + { + "title": "update", + "description": "Only allow users with bypass permission to update matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "update" + ] + }, + "parameters": { + "type": "object", + "properties": { + "update_allows_fetch_and_merge": { + "type": "boolean", + "description": "Branch can pull changes from its upstream repository" + } + }, + "required": [ + "update_allows_fetch_and_merge" + ] + } + } + }, + { + "title": "deletion", + "description": "Only allow users with bypass permissions to delete matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "deletion" + ] + } + } + }, + { + "title": "required_linear_history", + "description": "Prevent merge commits from being pushed to matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_linear_history" + ] + } + } + }, + { + "title": "required_deployments", + "description": "Choose which environments must be successfully deployed to before refs can be pushed into a ref that matches this rule.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_deployments" + ] + }, + "parameters": { + "type": "object", + "properties": { + "required_deployment_environments": { + "type": "array", + "description": "The environments that must be successfully deployed to before branches can be merged.", + "items": { + "type": "string" + } + } + }, + "required": [ + "required_deployment_environments" + ] + } + } + }, + { + "title": "required_signatures", + "description": "Commits pushed to matching refs must have verified signatures.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_signatures" + ] + } + } + }, + { + "title": "pull_request", + "description": "Require all commits be made to a non-target branch and submitted via a pull request before they can be merged.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "pull_request" + ] + }, + "parameters": { + "type": "object", + "properties": { + "allowed_merge_methods": { + "type": "array", + "description": "Array of allowed merge methods. Allowed values include `merge`, `squash`, and `rebase`. At least one option must be enabled.", + "items": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + } + }, + "dismiss_stale_reviews_on_push": { + "type": "boolean", + "description": "New, reviewable commits pushed will dismiss previous pull request review approvals." + }, + "require_code_owner_review": { + "type": "boolean", + "description": "Require an approving review in pull requests that modify files that have a designated code owner." + }, + "require_last_push_approval": { + "type": "boolean", + "description": "Whether the most recent reviewable push must be approved by someone other than the person who pushed it." + }, + "required_approving_review_count": { + "type": "integer", + "description": "The number of approving reviews that are required before a pull request can be merged.", + "minimum": 0, + "maximum": 10 + }, + "required_review_thread_resolution": { + "type": "boolean", + "description": "All conversations on code must be resolved before a pull request can be merged." + }, + "required_reviewers": { + "type": "array", + "description": "> [!NOTE]\n> `required_reviewers` is in beta and subject to change.\n\nA collection of reviewers and associated file patterns. Each reviewer has a list of file patterns which determine the files that reviewer is required to review.", + "items": { + "title": "RequiredReviewerConfiguration", + "description": "A reviewing team, and file patterns describing which files they must approve changes to.", + "type": "object", + "properties": { + "file_patterns": { + "type": "array", + "description": "Array of file patterns. Pull requests which change matching files must be approved by the specified team. File patterns use fnmatch syntax.", + "items": { + "type": "string" + } + }, + "minimum_approvals": { + "type": "integer", + "description": "Minimum number of approvals required from the specified team. If set to zero, the team will be added to the pull request but approval is optional." + }, + "reviewer": { + "title": "Reviewer", + "description": "A required reviewing team", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the reviewer which must review changes to matching files." + }, + "type": { + "type": "string", + "description": "The type of the reviewer", + "enum": [ + "Team" + ] + } + }, + "required": [ + "id", + "type" + ] + } + }, + "required": [ + "file_patterns", + "minimum_approvals", + "reviewer" + ] + } + } + }, + "required": [ + "dismiss_stale_reviews_on_push", + "require_code_owner_review", + "require_last_push_approval", + "required_approving_review_count", + "required_review_thread_resolution" + ] + } + } + }, + { + "title": "required_status_checks", + "description": "Choose which status checks must pass before the ref is updated. When enabled, commits must first be pushed to another ref where the checks pass.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_status_checks" + ] + }, + "parameters": { + "type": "object", + "properties": { + "do_not_enforce_on_create": { + "type": "boolean", + "description": "Allow repositories and branches to be created if a check would otherwise prohibit it." + }, + "required_status_checks": { + "type": "array", + "description": "Status checks that are required.", + "items": { + "title": "StatusCheckConfiguration", + "description": "Required status check", + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "The status check context name that must be present on the commit." + }, + "integration_id": { + "type": "integer", + "description": "The optional integration ID that this status check must originate from." + } + }, + "required": [ + "context" + ] + } + }, + "strict_required_status_checks_policy": { + "type": "boolean", + "description": "Whether pull requests targeting a matching branch must be tested with the latest code. This setting will not take effect unless at least one status check is enabled." + } + }, + "required": [ + "required_status_checks", + "strict_required_status_checks_policy" + ] + } + } + }, + { + "title": "non_fast_forward", + "description": "Prevent users with push access from force pushing to refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "non_fast_forward" + ] + } + } + }, + { + "title": "commit_message_pattern", + "description": "Parameters to be used for the commit_message_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "commit_message_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "commit_author_email_pattern", + "description": "Parameters to be used for the commit_author_email_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "commit_author_email_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "committer_email_pattern", + "description": "Parameters to be used for the committer_email_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "committer_email_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "branch_name_pattern", + "description": "Parameters to be used for the branch_name_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "branch_name_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "tag_name_pattern", + "description": "Parameters to be used for the tag_name_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "tag_name_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "file_path_restriction", + "description": "Prevent commits that include changes in specified file and folder paths from being pushed to the commit graph. This includes absolute paths that contain file names.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "file_path_restriction" + ] + }, + "parameters": { + "type": "object", + "properties": { + "restricted_file_paths": { + "type": "array", + "description": "The file paths that are restricted from being pushed to the commit graph.", + "items": { + "type": "string" + } + } + }, + "required": [ + "restricted_file_paths" + ] + } + } + }, + { + "title": "max_file_path_length", + "description": "Prevent commits that include file paths that exceed the specified character limit from being pushed to the commit graph.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "max_file_path_length" + ] + }, + "parameters": { + "type": "object", + "properties": { + "max_file_path_length": { + "type": "integer", + "description": "The maximum amount of characters allowed in file paths.", + "minimum": 1, + "maximum": 32767 + } + }, + "required": [ + "max_file_path_length" + ] + } + } + }, + { + "title": "file_extension_restriction", + "description": "Prevent commits that include files with specified file extensions from being pushed to the commit graph.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "file_extension_restriction" + ] + }, + "parameters": { + "type": "object", + "properties": { + "restricted_file_extensions": { + "type": "array", + "description": "The file extensions that are restricted from being pushed to the commit graph.", + "items": { + "type": "string" + } + } + }, + "required": [ + "restricted_file_extensions" + ] + } + } + }, + { + "title": "max_file_size", + "description": "Prevent commits with individual files that exceed the specified limit from being pushed to the commit graph.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "max_file_size" + ] + }, + "parameters": { + "type": "object", + "properties": { + "max_file_size": { + "type": "integer", + "description": "The maximum file size allowed in megabytes. This limit does not apply to Git Large File Storage (Git LFS).", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "max_file_size" + ] + } + } + }, + { + "title": "workflows", + "description": "Require all changes made to a targeted branch to pass the specified workflows before they can be merged.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "workflows" + ] + }, + "parameters": { + "type": "object", + "properties": { + "do_not_enforce_on_create": { + "type": "boolean", + "description": "Allow repositories and branches to be created if a check would otherwise prohibit it." + }, + "workflows": { + "type": "array", + "description": "Workflows that must pass for this rule to pass.", + "items": { + "title": "WorkflowFileReference", + "description": "A workflow that must run for this rule to pass", + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The path to the workflow file" + }, + "ref": { + "type": "string", + "description": "The ref (branch or tag) of the workflow file to use" + }, + "repository_id": { + "type": "integer", + "description": "The ID of the repository where the workflow is defined" + }, + "sha": { + "type": "string", + "description": "The commit SHA of the workflow file to use" + } + }, + "required": [ + "path", + "repository_id" + ] + } + } + }, + "required": [ + "workflows" + ] + } + } + }, + { + "title": "code_scanning", + "description": "Choose which tools must provide code scanning results before the reference is updated. When configured, code scanning must be enabled and have results for both the commit and the reference being updated.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "code_scanning" + ] + }, + "parameters": { + "type": "object", + "properties": { + "code_scanning_tools": { + "type": "array", + "description": "Tools that must provide code scanning results for this rule to pass.", + "items": { + "title": "CodeScanningTool", + "description": "A tool that must provide code scanning results for this rule to pass.", + "type": "object", + "properties": { + "alerts_threshold": { + "type": "string", + "description": "The severity level at which code scanning results that raise alerts block a reference update. For more information on alert severity levels, see \"[About code scanning alerts](https://docs.github.com/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts#about-alert-severity-and-security-severity-levels).\"", + "enum": [ + "none", + "errors", + "errors_and_warnings", + "all" + ] + }, + "security_alerts_threshold": { + "type": "string", + "description": "The severity level at which code scanning results that raise security alerts block a reference update. For more information on security severity levels, see \"[About code scanning alerts](https://docs.github.com/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts#about-alert-severity-and-security-severity-levels).\"", + "enum": [ + "none", + "critical", + "high_or_higher", + "medium_or_higher", + "all" + ] + }, + "tool": { + "type": "string", + "description": "The name of a code scanning tool" + } + }, + "required": [ + "alerts_threshold", + "security_alerts_threshold", + "tool" + ] + } + } + }, + "required": [ + "code_scanning_tools" + ] + } + } + }, + { + "title": "copilot_code_review", + "description": "Request Copilot code review for new pull requests automatically if the author has access to Copilot code review and their premium requests quota has not reached the limit.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "copilot_code_review" + ] + }, + "parameters": { + "type": "object", + "properties": { + "review_draft_pull_requests": { + "type": "boolean", + "description": "Copilot automatically reviews draft pull requests before they are marked as ready for review." + }, + "review_on_push": { + "type": "boolean", + "description": "Copilot automatically reviews each new push to the pull request." + } + } + } + } + } + ] + } + } + }, + "required": [ + "name", + "enforcement" + ] + }, + "EnvironmentsSettings": { + "description": "A deployment environment configuration entry", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "The name of the deployment environment", + "type": "string" + }, + "wait_timer": { + "description": "The amount of time to delay a job after the job is initially triggered (in minutes)", + "type": "integer" + }, + "reviewers": { + "description": "The people or teams that may review jobs that reference the environment", + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "description": "The type of reviewer (`User` or `Team`)", + "type": "string" + }, + "id": { + "description": "The id of the user or team who can review the deployment", + "type": "integer" + } + } + } + }, + "deployment_branch_policy": { + "description": "The type of deployment branch policy for this environment", + "type": "object", + "properties": { + "protected_branches": { + "description": "Whether only protected branches can be deployed to this environment", + "type": "boolean" + }, + "custom_branch_policies": { + "description": "Whether only branches that match the specified name patterns can deploy to this environment", + "type": "boolean" + } + } + }, + "prevent_self_review": { + "description": "Whether or not a user who created the job is prevented from approving their own job", + "type": "boolean" + } + } + }, + "CustomPropertiesSettings": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "VariablesSettings": { + "description": "An Actions variable entry", + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "description": "The name of the variable", + "type": "string" + }, + "value": { + "description": "The value of the variable", + "type": "string" + }, + "visibility": { + "description": "The visibility of the variable. Can be `all`, `private`, or `selected`", + "type": "string", + "enum": [ + "all", + "private", + "selected" + ] + } + } + } + } +} \ No newline at end of file diff --git a/schema/repos.json b/schema/repos.json new file mode 100644 index 000000000..3a7c51301 --- /dev/null +++ b/schema/repos.json @@ -0,0 +1,324 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Safe-settings repo-level configuration", + "description": "Schema for .github/repos/{repo-name}.yml — repo-level safe-settings override configuration. Settings here are merged on top of org-level and suborg-level settings for the specific repository.", + "type": "object", + "properties": { + "repositories": { + "$ref": "#/$defs/RepositorySettings" + }, + "labels": { + "$ref": "#/$defs/LabelSettings" + }, + "collaborators": { + "description": "Collaborators: give specific users access to any repository.", + "type": "array", + "items": { + "$ref": "#/$defs/CollaboratorSettings" + } + }, + "teams": { + "description": "Teams", + "type": "array", + "items": { + "$ref": "#/$defs/TeamSettings" + } + }, + "milestones": { + "description": "Milestones: define milestones for Issues and Pull Requests", + "type": "array", + "items": { + "$ref": "#/$defs/MilestoneSettings" + } + }, + "branches": { + "description": "Branch protection rules", + "type": "array", + "items": { + "$ref": "#/$defs/BranchSettings" + } + }, + "autolinks": { + "description": "Autolinks", + "type": "array", + "items": { + "$ref": "#/$defs/AutolinkSettings" + } + }, + "validator": { + "$ref": "#/$defs/ValidatorSettings" + }, + "environments": { + "description": "Deployment environments", + "type": "array", + "items": { + "$ref": "#/$defs/EnvironmentsSettings" + } + }, + "custom_properties": { + "description": "Custom properties", + "type": "array", + "items": { + "$ref": "#/$defs/CustomPropertiesSettings" + } + }, + "variables": { + "description": "Repository or org-level Actions variables", + "type": "array", + "items": { + "$ref": "#/$defs/VariablesSettings" + } + } + }, + "$defs": { + "RepositorySettings": { + "description": "Repository settings", + "allOf": [ + { + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}/patch/requestBody/content/application~1json/schema" + }, + { + "type": "object", + "properties": { + "auto_init": { + "description": "Create an initial commit with empty README. Keep this set to true in most cases since many of the policies below cannot be implemented on bare repos", + "type": "boolean" + }, + "gitignore_template": { + "description": "Desired language or platform [.gitignore template](https://github.com/github/gitignore) to apply. Use the name of the template without the extension. For example, 'Haskell'.", + "type": "string" + }, + "license_template": { + "description": "Choose an [open source license template](https://choosealicense.com/) that best suits your needs, and then use the [license keyword](https://help.github.com/articles/licensing-a-repository/#searching-github-by-license-type) as the `license_template` string. For example, 'mit' or 'mpl-2.0'.", + "type": "string" + }, + "topics": { + "description": "A list of topics to set on the repository", + "type": "array", + "items": { + "type": "string" + } + }, + "security": { + "description": "Settings for Code security and analysis", + "type": "object", + "properties": { + "enableVulnerabilityAlerts": { + "type": "boolean" + }, + "enableAutomatedSecurityFixes": { + "type": "boolean" + } + } + }, + "force_create": { + "description": "If true, create the repository if it does not already exist.", + "type": "boolean" + }, + "template": { + "description": "Name of a template repository to use when creating a new repository.", + "type": "string" + } + } + } + ] + }, + "LabelSettings": { + "description": "Labels: define labels for Issues and Pull Requests", + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "color": { + "description": "The hexadecimal color code for the label. If including a `#`, make sure to wrap it with quotes!", + "type": "string" + }, + "description": { + "type": "string" + }, + "oldname": { + "description": "Include the old name to rename an existing label", + "type": "string" + } + } + } + }, + "exclude": { + "description": "Ignore any labels matching these regexes (don't delete them)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CollaboratorSettings": { + "description": "A collaborator entry giving a specific user access to a repository.", + "allOf": [ + { + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1collaborators~1{username}/put/requestBody/content/application~1json/schema" + }, + { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "exclude": { + "description": "You can exclude a list of repos for this collaborator and all repos except these repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "description": "You can include a list of repos for this collaborator and only those repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "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" + }, + "MilestoneSettings": { + "description": "A milestone entry", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "state": { + "description": "The state of the milestone. Either `open` or `closed`", + "type": "string" + } + } + }, + "BranchSettings": { + "description": "A branch protection rule entry", + "type": "object", + "properties": { + "name": { + "description": "If the name of the branch value is specified as `default`, then the app will create a branch protection rule to apply against the default branch in the repo", + "type": "string" + }, + "protection": { + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1branches~1{branch}~1protection/put/requestBody/content/application~1json/schema" + } + } + }, + "AutolinkSettings": { + "description": "An autolink reference entry", + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1autolinks/post/requestBody/content/application~1json/schema" + }, + "ValidatorSettings": { + "description": "Repository name validation", + "type": "object", + "properties": { + "pattern": { + "type": "string" + } + } + }, + "RulesetSettings": { + "description": "A ruleset 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}~1rulesets/post/requestBody/content/application~1json/schema" + }, + "EnvironmentsSettings": { + "description": "A deployment environment configuration entry", + "type": "object", + "required": ["name"], + "properties": { + "name": { + "description": "The name of the deployment environment", + "type": "string" + }, + "wait_timer": { + "description": "The amount of time to delay a job after the job is initially triggered (in minutes)", + "type": "integer" + }, + "reviewers": { + "description": "The people or teams that may review jobs that reference the environment", + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "description": "The type of reviewer (`User` or `Team`)", + "type": "string" + }, + "id": { + "description": "The id of the user or team who can review the deployment", + "type": "integer" + } + } + } + }, + "deployment_branch_policy": { + "description": "The type of deployment branch policy for this environment", + "type": "object", + "properties": { + "protected_branches": { + "description": "Whether only protected branches can be deployed to this environment", + "type": "boolean" + }, + "custom_branch_policies": { + "description": "Whether only branches that match the specified name patterns can deploy to this environment", + "type": "boolean" + } + } + }, + "prevent_self_review": { + "description": "Whether or not a user who created the job is prevented from approving their own job", + "type": "boolean" + } + } + }, + "CustomPropertiesSettings": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "VariablesSettings": { + "description": "An Actions variable entry", + "type": "object", + "required": ["name", "value"], + "properties": { + "name": { + "description": "The name of the variable", + "type": "string" + }, + "value": { + "description": "The value of the variable", + "type": "string" + }, + "visibility": { + "description": "The visibility of the variable. Can be `all`, `private`, or `selected`", + "type": "string", + "enum": ["all", "private", "selected"] + } + } + } + } +} diff --git a/schema/settings.json b/schema/settings.json index 8c8a53c81..a1d1c0672 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -1,8 +1,85 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Safe-settings org-level configuration", + "description": "Schema for .github/settings.yml — org-level safe-settings configuration", "type": "object", "properties": { "repositories": { + "$ref": "#/$defs/RepositorySettings" + }, + "labels": { + "$ref": "#/$defs/LabelSettings" + }, + "collaborators": { + "description": "Collaborators: give specific users access to any repository.", + "type": "array", + "items": { + "$ref": "#/$defs/CollaboratorSettings" + } + }, + "teams": { + "description": "Teams", + "type": "array", + "items": { + "$ref": "#/$defs/TeamSettings" + } + }, + "milestones": { + "description": "Milestones: define milestones for Issues and Pull Requests", + "type": "array", + "items": { + "$ref": "#/$defs/MilestoneSettings" + } + }, + "branches": { + "description": "Branch protection rules", + "type": "array", + "items": { + "$ref": "#/$defs/BranchSettings" + } + }, + "autolinks": { + "description": "Autolinks", + "type": "array", + "items": { + "$ref": "#/$defs/AutolinkSettings" + } + }, + "validator": { + "$ref": "#/$defs/ValidatorSettings" + }, + "rulesets": { + "description": "Rulesets. Org-level only — rulesets defined here apply to the organization and are NOT inherited by suborg or repo override files.", + "type": "array", + "items": { + "$ref": "#/$defs/RulesetSettings" + } + }, + "environments": { + "description": "Deployment environments", + "type": "array", + "items": { + "$ref": "#/$defs/EnvironmentsSettings" + } + }, + "custom_properties": { + "description": "Custom properties", + "type": "array", + "items": { + "$ref": "#/$defs/CustomPropertiesSettings" + } + }, + "variables": { + "description": "Repository or org-level Actions variables", + "type": "array", + "items": { + "$ref": "#/$defs/VariablesSettings" + } + } + }, + "$defs": { + "RepositorySettings": { + "description": "Repository settings", "allOf": [ { "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}/patch/requestBody/content/application~1json/schema" @@ -40,12 +117,20 @@ "type": "boolean" } } + }, + "force_create": { + "description": "Force create the repository even if it already exists", + "type": "boolean" + }, + "template": { + "description": "Name of a template repository to use when creating the repository", + "type": "string" } } } ] }, - "labels": { + "LabelSettings": { "description": "Labels: define labels for Issues and Pull Requests", "type": "object", "properties": { @@ -80,117 +165,167 @@ } } }, - "milestones": { - "description": "Milestones: define milestones for Issues and Pull Requests", - "type": "array", - "items": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "state": { - "description": "The state of the milestone. Either `open` or `closed`", - "type": "string" + "CollaboratorSettings": { + "description": "A collaborator entry giving a specific user access to a repository.", + "allOf": [ + { + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1collaborators~1{username}/put/requestBody/content/application~1json/schema" + }, + { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "exclude": { + "description": "You can exclude a list of repos for this collaborator and all repos except these repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "description": "You can include a list of repos for this collaborator and only those repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + } } } + ] + }, + "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" + }, + "MilestoneSettings": { + "description": "A milestone entry", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "state": { + "description": "The state of the milestone. Either `open` or `closed`", + "type": "string" + } } }, - "collaborators": { - "description": "Collaborators: give specific users access to any repository.", - "type": "array", - "items": { - "allOf": [ - { - "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1collaborators~1{username}/put/requestBody/content/application~1json/schema" - }, - { + "BranchSettings": { + "description": "A branch protection rule entry", + "type": "object", + "properties": { + "name": { + "description": "If the name of the branch value is specified as `default`, then the app will create a branch protection rule to apply against the default branch in the repo", + "type": "string" + }, + "protection": { + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1branches~1{branch}~1protection/put/requestBody/content/application~1json/schema" + } + } + }, + "AutolinkSettings": { + "description": "An autolink reference entry", + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1autolinks/post/requestBody/content/application~1json/schema" + }, + "ValidatorSettings": { + "description": "Repository name validation", + "type": "object", + "properties": { + "pattern": { + "type": "string" + } + } + }, + "RulesetSettings": { + "description": "A ruleset 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}~1rulesets/post/requestBody/content/application~1json/schema" + }, + "EnvironmentsSettings": { + "description": "A deployment environment configuration entry", + "type": "object", + "required": ["name"], + "properties": { + "name": { + "description": "The name of the deployment environment", + "type": "string" + }, + "wait_timer": { + "description": "The amount of time to delay a job after the job is initially triggered (in minutes)", + "type": "integer" + }, + "reviewers": { + "description": "The people or teams that may review jobs that reference the environment", + "type": "array", + "items": { "type": "object", "properties": { - "username": { + "type": { + "description": "The type of reviewer (`User` or `Team`)", "type": "string" }, - "exclude": { - "description": "You can exclude a list of repos for this collaborator and all repos except these repos would have this collaborator", - "type": "array", - "items": { - "type": "string" - } - }, - "include": { - "description": "You can include a list of repos for this collaborator and only those repos would have this collaborator", - "type": "array", - "items": { - "type": "string" - } + "id": { + "description": "The id of the user or team who can review the deployment", + "type": "integer" } } } - ] - } - }, - "teams": { - "description": "Teams", - "type": "array", - "items": { - "$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" - } - }, - "branches": { - "description": "Branch protection rules", - "type": "array", - "items": { - "properties": { - "name": { - "description": "If the name of the branch value is specified as `default`, then the app will create a branch protection rule to apply against the default branch in the repo", - "type": "string" - }, - "protection": { - "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1branches~1{branch}~1protection/put/requestBody/content/application~1json/schema" + }, + "deployment_branch_policy": { + "description": "The type of deployment branch policy for this environment", + "type": "object", + "properties": { + "protected_branches": { + "description": "Whether only protected branches can be deployed to this environment", + "type": "boolean" + }, + "custom_branch_policies": { + "description": "Whether only branches that match the specified name patterns can deploy to this environment", + "type": "boolean" + } } + }, + "prevent_self_review": { + "description": "Whether or not a user who created the job is prevented from approving their own job", + "type": "boolean" } } }, - "custom_properties": { - "description": "Custom properties", - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - } + "CustomPropertiesSettings": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" } } }, - "autolinks": { - "description": "Autolinks", - "type": "array", - "items": { - "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1autolinks/post/requestBody/content/application~1json/schema" - } - }, - "validator": { - "description": "Repository name validation", + "VariablesSettings": { + "description": "An Actions variable entry", "type": "object", + "required": ["name", "value"], "properties": { - "pattern": { + "name": { + "description": "The name of the variable", "type": "string" + }, + "value": { + "description": "The value of the variable", + "type": "string" + }, + "visibility": { + "description": "The visibility of the variable. Can be `all`, `private`, or `selected`", + "type": "string", + "enum": ["all", "private", "selected"] } } - }, - "rulesets": { - "description": "Rulesets", - "type": "array", - "items": { - "$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}~1rulesets/post/requestBody/content/application~1json/schema" - } } } } diff --git a/schema/suborgs.json b/schema/suborgs.json new file mode 100644 index 000000000..3a3c79def --- /dev/null +++ b/schema/suborgs.json @@ -0,0 +1,358 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Safe-settings suborg-level configuration", + "description": "Schema for .github/suborgs/*.yml — suborg-level safe-settings configuration. Defines which repos belong to the suborg and what settings to apply.", + "type": "object", + "properties": { + "suborgrepos": { + "type": "array", + "description": "Glob patterns matching repository names. Repos whose names match any pattern are included in this suborg.", + "items": { + "type": "string" + } + }, + "suborgteams": { + "type": "array", + "description": "Team slugs. Repos that belong to any of these teams are included in this suborg.", + "items": { + "type": "string" + } + }, + "suborgproperties": { + "type": "array", + "description": "Custom property filters. Repos with matching custom property values are included in this suborg.", + "items": { + "type": "object", + "properties": { + "property_name": { + "type": "string", + "description": "The name of the custom property" + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Accepted values for this property" + } + } + } + }, + "repositories": { + "$ref": "#/$defs/RepositorySettings" + }, + "labels": { + "$ref": "#/$defs/LabelSettings" + }, + "collaborators": { + "description": "Collaborators: give specific users access to any repository.", + "type": "array", + "items": { + "$ref": "#/$defs/CollaboratorSettings" + } + }, + "teams": { + "description": "Teams", + "type": "array", + "items": { + "$ref": "#/$defs/TeamSettings" + } + }, + "milestones": { + "description": "Milestones: define milestones for Issues and Pull Requests", + "type": "array", + "items": { + "$ref": "#/$defs/MilestoneSettings" + } + }, + "branches": { + "description": "Branch protection rules", + "type": "array", + "items": { + "$ref": "#/$defs/BranchSettings" + } + }, + "autolinks": { + "description": "Autolinks", + "type": "array", + "items": { + "$ref": "#/$defs/AutolinkSettings" + } + }, + "validator": { + "$ref": "#/$defs/ValidatorSettings" + }, + "environments": { + "description": "Deployment environments", + "type": "array", + "items": { + "$ref": "#/$defs/EnvironmentsSettings" + } + }, + "custom_properties": { + "description": "Custom properties", + "type": "array", + "items": { + "$ref": "#/$defs/CustomPropertiesSettings" + } + }, + "variables": { + "description": "Repository or org-level Actions variables", + "type": "array", + "items": { + "$ref": "#/$defs/VariablesSettings" + } + } + }, + "$defs": { + "RepositorySettings": { + "description": "Repository settings. Use force_create to create the repository if it does not exist, and template to specify a template repository to use when creating it.", + "allOf": [ + { + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}/patch/requestBody/content/application~1json/schema" + }, + { + "type": "object", + "properties": { + "auto_init": { + "description": "Create an initial commit with empty README. Keep this set to true in most cases since many of the policies below cannot be implemented on bare repos", + "type": "boolean" + }, + "gitignore_template": { + "description": "Desired language or platform [.gitignore template](https://github.com/github/gitignore) to apply. Use the name of the template without the extension. For example, 'Haskell'.", + "type": "string" + }, + "license_template": { + "description": "Choose an [open source license template](https://choosealicense.com/) that best suits your needs, and then use the [license keyword](https://help.github.com/articles/licensing-a-repository/#searching-github-by-license-type) as the `license_template` string. For example, 'mit' or 'mpl-2.0'.", + "type": "string" + }, + "topics": { + "description": "A list of topics to set on the repository", + "type": "array", + "items": { + "type": "string" + } + }, + "security": { + "description": "Settings for Code security and analysis", + "type": "object", + "properties": { + "enableVulnerabilityAlerts": { + "type": "boolean" + }, + "enableAutomatedSecurityFixes": { + "type": "boolean" + } + } + }, + "force_create": { + "description": "If true, create the repository if it does not already exist.", + "type": "boolean" + }, + "template": { + "description": "Name of a template repository to use when creating a new repository.", + "type": "string" + } + } + } + ] + }, + "LabelSettings": { + "description": "Labels: define labels for Issues and Pull Requests", + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "color": { + "description": "The hexadecimal color code for the label. If including a `#`, make sure to wrap it with quotes!", + "type": "string" + }, + "description": { + "type": "string" + }, + "oldname": { + "description": "Include the old name to rename an existing label", + "type": "string" + } + } + } + }, + "exclude": { + "description": "Ignore any labels matching these regexes (don't delete them)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CollaboratorSettings": { + "description": "A collaborator entry giving a specific user access to a repository.", + "allOf": [ + { + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1collaborators~1{username}/put/requestBody/content/application~1json/schema" + }, + { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "exclude": { + "description": "You can exclude a list of repos for this collaborator and all repos except these repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "description": "You can include a list of repos for this collaborator and only those repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "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" + }, + "MilestoneSettings": { + "description": "A milestone entry", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "state": { + "description": "The state of the milestone. Either `open` or `closed`", + "type": "string" + } + } + }, + "BranchSettings": { + "description": "A branch protection rule entry", + "type": "object", + "properties": { + "name": { + "description": "If the name of the branch value is specified as `default`, then the app will create a branch protection rule to apply against the default branch in the repo", + "type": "string" + }, + "protection": { + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1branches~1{branch}~1protection/put/requestBody/content/application~1json/schema" + } + } + }, + "AutolinkSettings": { + "description": "An autolink reference entry", + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1autolinks/post/requestBody/content/application~1json/schema" + }, + "ValidatorSettings": { + "description": "Repository name validation", + "type": "object", + "properties": { + "pattern": { + "type": "string" + } + } + }, + "RulesetSettings": { + "description": "A ruleset 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}~1rulesets/post/requestBody/content/application~1json/schema" + }, + "EnvironmentsSettings": { + "description": "A deployment environment configuration entry", + "type": "object", + "required": ["name"], + "properties": { + "name": { + "description": "The name of the deployment environment", + "type": "string" + }, + "wait_timer": { + "description": "The amount of time to delay a job after the job is initially triggered (in minutes)", + "type": "integer" + }, + "reviewers": { + "description": "The people or teams that may review jobs that reference the environment", + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "description": "The type of reviewer (`User` or `Team`)", + "type": "string" + }, + "id": { + "description": "The id of the user or team who can review the deployment", + "type": "integer" + } + } + } + }, + "deployment_branch_policy": { + "description": "The type of deployment branch policy for this environment", + "type": "object", + "properties": { + "protected_branches": { + "description": "Whether only protected branches can be deployed to this environment", + "type": "boolean" + }, + "custom_branch_policies": { + "description": "Whether only branches that match the specified name patterns can deploy to this environment", + "type": "boolean" + } + } + }, + "prevent_self_review": { + "description": "Whether or not a user who created the job is prevented from approving their own job", + "type": "boolean" + } + } + }, + "CustomPropertiesSettings": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "VariablesSettings": { + "description": "An Actions variable entry", + "type": "object", + "required": ["name", "value"], + "properties": { + "name": { + "description": "The name of the variable", + "type": "string" + }, + "value": { + "description": "The value of the variable", + "type": "string" + }, + "visibility": { + "description": "The visibility of the variable. Can be `all`, `private`, or `selected`", + "type": "string", + "enum": ["all", "private", "selected"] + } + } + } + } +} diff --git a/script/build-schema b/script/build-schema index 7611d089e..31bb38b82 100755 --- a/script/build-schema +++ b/script/build-schema @@ -1,13 +1,31 @@ #!/usr/bin/env node const $RefParser = require('@apidevtools/json-schema-ref-parser') -const fs = require('node:fs/promises'); +const fs = require('node:fs/promises') +const path = require('node:path') + +const schemas = [ + { src: 'schema/settings.json', dest: 'schema/dereferenced/settings.json' }, + { src: 'schema/suborgs.json', dest: 'schema/dereferenced/suborgs.json' }, + { src: 'schema/repos.json', dest: 'schema/dereferenced/repos.json' } +]; (async () => { - const schema = await fs.readFile('schema/settings.json', 'utf-8').then(JSON.parse) + await fs.mkdir('schema/dereferenced', { recursive: true }) - await $RefParser.dereference(schema) + let hasErrors = false + for (const { src, dest } of schemas) { + try { + const dereferenced = await $RefParser.dereference(path.resolve(src)) + await fs.writeFile(dest, JSON.stringify(dereferenced, null, 2)) + console.log(`Dereferenced ${src} → ${dest}`) + } catch (err) { + console.error(`Error dereferencing ${src}: ${err.message}`) + hasErrors = true + } + } - await fs.mkdir('schema/dereferenced', { recursive: true }) - await fs.writeFile('schema/dereferenced/settings.json', JSON.stringify(schema, null, 2)) + if (hasErrors) { + process.exit(1) + } })().catch(console.error) From 8a5f2ca8d34aa992522a9e43b3b35518c14d71a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Mon, 30 Mar 2026 11:07:44 +0300 Subject: [PATCH 08/21] chores: fix branches tests --- docs/sample-settings/sample-deployment-settings.yml | 8 ++++---- test/unit/lib/plugins/branches.test.js | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/sample-settings/sample-deployment-settings.yml b/docs/sample-settings/sample-deployment-settings.yml index 6164d4389..deccd8b44 100644 --- a/docs/sample-settings/sample-deployment-settings.yml +++ b/docs/sample-settings/sample-deployment-settings.yml @@ -27,10 +27,10 @@ overridevalidators: error: | `Branch protection required_approving_review_count cannot be overidden to a lower value` script: | - console.log(`baseConfig ${JSON.stringify(baseconfig)}`) - console.log(`overrideConfig ${JSON.stringify(overrideconfig)}`) - if (baseconfig.protection.required_pull_request_reviews.required_approving_review_count && overrideconfig.protection.required_pull_request_reviews.required_approving_review_count ) { - return overrideconfig.protection.required_pull_request_reviews.required_approving_review_count >= baseconfig.protection.required_pull_request_reviews.required_approving_review_count + const baseCount = baseconfig?.protection?.required_pull_request_reviews?.required_approving_review_count + const overrideCount = overrideconfig?.protection?.required_pull_request_reviews?.required_approving_review_count + if (baseCount && overrideCount) { + return overrideCount >= baseCount } return true - plugin: labels diff --git a/test/unit/lib/plugins/branches.test.js b/test/unit/lib/plugins/branches.test.js index 450ec9939..8b9c61786 100644 --- a/test/unit/lib/plugins/branches.test.js +++ b/test/unit/lib/plugins/branches.test.js @@ -67,6 +67,7 @@ describe('Branches', () => { required_pull_request_reviews: { require_code_owner_reviews: true }, + restrictions: null, headers: { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } }) }) @@ -190,6 +191,8 @@ describe('Branches', () => { strict: true, contexts: [] }, + enforce_admins: null, + restrictions: null, headers: { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } }) }) @@ -229,6 +232,8 @@ describe('Branches', () => { strict: true, contexts: ['check-1', 'check-2'] }, + enforce_admins: null, + restrictions: null, headers: { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } }) }) @@ -267,6 +272,8 @@ describe('Branches', () => { repo: 'test', branch: 'other', enforce_admins: false, + required_status_checks: null, + restrictions: null, headers: { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } }) }) From 70cad391e27ed9998303b6430fcedf30138e5123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Mon, 30 Mar 2026 15:02:35 +0300 Subject: [PATCH 09/21] fix: update GitHub API calls to use the correct namespace for branch protection methods --- test/unit/lib/plugins/branches.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/unit/lib/plugins/branches.test.js b/test/unit/lib/plugins/branches.test.js index d88e7d6b8..49549cc87 100644 --- a/test/unit/lib/plugins/branches.test.js +++ b/test/unit/lib/plugins/branches.test.js @@ -203,7 +203,7 @@ describe('Branches', () => { describe('when existing protection has restrictions', () => { it('preserves restrictions from GitHub when config omits them', () => { - github.repos.getBranchProtection = jest.fn().mockResolvedValue({ + github.rest.repos.getBranchProtection = jest.fn().mockResolvedValue({ data: { enforce_admins: { enabled: true }, required_status_checks: { @@ -229,7 +229,7 @@ describe('Branches', () => { }]) return plugin.sync().then(() => { - expect(github.repos.updateBranchProtection).toHaveBeenCalledWith( + expect(github.rest.repos.updateBranchProtection).toHaveBeenCalledWith( expect.objectContaining({ owner: 'bkeepers', repo: 'test', @@ -253,7 +253,7 @@ describe('Branches', () => { }) it('normalizes restrictions and defaults missing arrays when preserving from GitHub', () => { - github.repos.getBranchProtection = jest.fn().mockResolvedValue({ + github.rest.repos.getBranchProtection = jest.fn().mockResolvedValue({ data: { enforce_admins: { enabled: true }, restrictions: { @@ -271,7 +271,7 @@ describe('Branches', () => { }]) return plugin.sync().then(() => { - const payload = github.repos.updateBranchProtection.mock.calls[0][0] + const payload = github.rest.repos.updateBranchProtection.mock.calls[0][0] expect(payload.restrictions).toEqual({ users: ['user1'], teams: [], From 1c081a1fa202c09c24a48c6c3754e88690bd8501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Tue, 7 Apr 2026 14:10:19 +0300 Subject: [PATCH 10/21] fix(build-schema): enhance schema dereferencing with GitHub API spec --- lib/plugins/branches.js | 27 +- lib/plugins/rulesets.js | 2 +- schema/dereferenced/repos.json | 18 + schema/dereferenced/settings.json | 2118 +++++++++++++++++++++++- schema/dereferenced/suborgs.json | 18 + script/build-schema | 41 +- test/unit/lib/plugins/branches.test.js | 100 +- 7 files changed, 2269 insertions(+), 55 deletions(-) diff --git a/lib/plugins/branches.js b/lib/plugins/branches.js index 710b5578e..d60a20cc3 100644 --- a/lib/plugins/branches.js +++ b/lib/plugins/branches.js @@ -64,7 +64,7 @@ module.exports = class Branches extends ErrorStash { const params = Object.assign({}, p) return this.github.rest.repos.getBranchProtection(params).then((result) => { const mergeDeep = new MergeDeep(this.log, this.github, ignorableFields) - const changes = mergeDeep.compareDeep({ branch: { protection: this.reformatAndReturnBranchProtection(result.data) } }, { branch: { protection: Overrides.removeOverrides(overrides, branch.protection, result.data) } }) + const changes = mergeDeep.compareDeep({ branch: { protection: this.reformatAndReturnBranchProtection(structuredClone(result.data)) } }, { branch: { protection: Overrides.removeOverrides(overrides, branch.protection, result.data) } }) const results = { msg: `Followings changes will be applied to the branch protection for ${params.branch.name} branch`, additions: changes.additions, modifications: changes.modifications, deletions: changes.deletions } this.log.debug(`Result of compareDeep = ${results}`) @@ -81,7 +81,7 @@ module.exports = class Branches extends ErrorStash { resArray.push(new NopCommand(this.constructor.name, this.repo, null, results)) } - Object.assign(params, requiredBranchProtectionDefaults, branch.protection, { headers: previewHeaders }) + Object.assign(params, requiredBranchProtectionDefaults, this.reformatAndReturnBranchProtection(structuredClone(result.data)), Overrides.removeOverrides(overrides, branch.protection, result.data), { headers: previewHeaders }) if (this.nop) { resArray.push(new NopCommand(this.constructor.name, this.repo, this.github.rest.repos.updateBranchProtection.endpoint(params), 'Add Branch Protection')) @@ -131,6 +131,29 @@ module.exports = class Branches extends ErrorStash { protection.required_linear_history = protection.required_linear_history && protection.required_linear_history.enabled protection.enforce_admins = protection.enforce_admins && protection.enforce_admins.enabled protection.required_signatures = protection.required_signatures && protection.required_signatures.enabled + protection.allow_force_pushes = protection.allow_force_pushes && protection.allow_force_pushes.enabled + protection.block_creations = protection.block_creations && protection.block_creations.enabled + protection.lock_branch = protection.lock_branch && protection.lock_branch.enabled + protection.allow_fork_syncing = protection.allow_fork_syncing && protection.allow_fork_syncing.enabled + if (protection.restrictions) { + delete protection.restrictions.url + protection.restrictions.users = Array.isArray(protection.restrictions.users) + ? protection.restrictions.users.map(user => user.login || user) + : [] + protection.restrictions.teams = Array.isArray(protection.restrictions.teams) + ? protection.restrictions.teams.map(team => team.slug || team) + : [] + protection.restrictions.apps = Array.isArray(protection.restrictions.apps) + ? protection.restrictions.apps.map(app => app.slug || app) + : [] + } + if (protection.required_status_checks) { + delete protection.required_status_checks.url + delete protection.required_status_checks.contexts_url + if (Array.isArray(protection.required_status_checks.contexts) && protection.required_status_checks.contexts.length === 0) { + delete protection.required_status_checks.contexts + } + } if (protection.required_pull_request_reviews && !protection.required_pull_request_reviews.bypass_pull_request_allowances) { protection.required_pull_request_reviews.bypass_pull_request_allowances = { apps: [], teams: [], users: [] } } diff --git a/lib/plugins/rulesets.js b/lib/plugins/rulesets.js index ad9a21f05..db9056221 100644 --- a/lib/plugins/rulesets.js +++ b/lib/plugins/rulesets.js @@ -7,7 +7,7 @@ const overrides = { 'required_status_checks': { 'action': 'delete', 'parents': 3, - 'type': 'dict' + 'type': 'array' }, } diff --git a/schema/dereferenced/repos.json b/schema/dereferenced/repos.json index 355285e29..9213456a0 100644 --- a/schema/dereferenced/repos.json +++ b/schema/dereferenced/repos.json @@ -145,6 +145,15 @@ "TEAM", "ROLE" ] + }, + "mode": { + "type": "string", + "description": "The bypass mode for the reviewer", + "enum": [ + "ALWAYS", + "EXEMPT" + ], + "default": "ALWAYS" } } } @@ -957,6 +966,15 @@ "TEAM", "ROLE" ] + }, + "mode": { + "type": "string", + "description": "The bypass mode for the reviewer", + "enum": [ + "ALWAYS", + "EXEMPT" + ], + "default": "ALWAYS" } } } diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json index 94f353549..5f743c4fe 100644 --- a/schema/dereferenced/settings.json +++ b/schema/dereferenced/settings.json @@ -1,8 +1,11 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Safe-settings org-level configuration", + "description": "Schema for .github/settings.yml — org-level safe-settings configuration", "type": "object", "properties": { "repositories": { + "description": "Repository settings", "allOf": [ { "type": "object", @@ -142,6 +145,15 @@ "TEAM", "ROLE" ] + }, + "mode": { + "type": "string", + "description": "The bypass mode for the reviewer", + "enum": [ + "ALWAYS", + "EXEMPT" + ], + "default": "ALWAYS" } } } @@ -294,6 +306,14 @@ "type": "boolean" } } + }, + "force_create": { + "description": "Force create the repository even if it already exists", + "type": "boolean" + }, + "template": { + "description": "Name of a template repository to use when creating the repository", + "type": "string" } } } @@ -334,29 +354,11 @@ } } }, - "milestones": { - "description": "Milestones: define milestones for Issues and Pull Requests", - "type": "array", - "items": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "state": { - "description": "The state of the milestone. Either `open` or `closed`", - "type": "string" - } - } - } - }, "collaborators": { "description": "Collaborators: give specific users access to any repository.", "type": "array", "items": { + "description": "A collaborator entry giving a specific user access to a repository.", "allOf": [ { "type": "object", @@ -397,6 +399,7 @@ "description": "Teams", "type": "array", "items": { + "description": "A team entry", "type": "object", "properties": { "name": { @@ -447,10 +450,32 @@ ] } }, + "milestones": { + "description": "Milestones: define milestones for Issues and Pull Requests", + "type": "array", + "items": { + "description": "A milestone entry", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "state": { + "description": "The state of the milestone. Either `open` or `closed`", + "type": "string" + } + } + } + }, "branches": { "description": "Branch protection rules", "type": "array", "items": { + "description": "A branch protection rule entry", + "type": "object", "properties": { "name": { "description": "If the name of the branch value is specified as `default`, then the app will create a branch protection rule to apply against the default branch in the repo", @@ -659,25 +684,11 @@ } } }, - "custom_properties": { - "description": "Custom properties", - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - } - } - }, "autolinks": { "description": "Autolinks", "type": "array", "items": { + "description": "An autolink reference entry", "type": "object", "properties": { "key_prefix": { @@ -710,9 +721,10 @@ } }, "rulesets": { - "description": "Rulesets", + "description": "Rulesets. Org-level only — rulesets defined here apply to the organization and are NOT inherited by suborg or repo override files.", "type": "array", "items": { + "description": "A ruleset entry", "type": "object", "properties": { "name": { @@ -1855,6 +1867,2042 @@ "enforcement" ] } + }, + "environments": { + "description": "Deployment environments", + "type": "array", + "items": { + "description": "A deployment environment configuration entry", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "The name of the deployment environment", + "type": "string" + }, + "wait_timer": { + "description": "The amount of time to delay a job after the job is initially triggered (in minutes)", + "type": "integer" + }, + "reviewers": { + "description": "The people or teams that may review jobs that reference the environment", + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "description": "The type of reviewer (`User` or `Team`)", + "type": "string" + }, + "id": { + "description": "The id of the user or team who can review the deployment", + "type": "integer" + } + } + } + }, + "deployment_branch_policy": { + "description": "The type of deployment branch policy for this environment", + "type": "object", + "properties": { + "protected_branches": { + "description": "Whether only protected branches can be deployed to this environment", + "type": "boolean" + }, + "custom_branch_policies": { + "description": "Whether only branches that match the specified name patterns can deploy to this environment", + "type": "boolean" + } + } + }, + "prevent_self_review": { + "description": "Whether or not a user who created the job is prevented from approving their own job", + "type": "boolean" + } + } + } + }, + "custom_properties": { + "description": "Custom properties", + "type": "array", + "items": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "variables": { + "description": "Repository or org-level Actions variables", + "type": "array", + "items": { + "description": "An Actions variable entry", + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "description": "The name of the variable", + "type": "string" + }, + "value": { + "description": "The value of the variable", + "type": "string" + }, + "visibility": { + "description": "The visibility of the variable. Can be `all`, `private`, or `selected`", + "type": "string", + "enum": [ + "all", + "private", + "selected" + ] + } + } + } + } + }, + "$defs": { + "RepositorySettings": { + "description": "Repository settings", + "allOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository." + }, + "description": { + "type": "string", + "description": "A short description of the repository." + }, + "homepage": { + "type": "string", + "description": "A URL with more information about the repository." + }, + "private": { + "type": "boolean", + "description": "Either `true` to make the repository private or `false` to make it public. Default: `false`. \n**Note**: You will get a `422` error if the organization restricts [changing repository visibility](https://docs.github.com/articles/repository-permission-levels-for-an-organization#changing-the-visibility-of-repositories) to organization owners and a non-owner tries to change the value of private.", + "default": false + }, + "visibility": { + "type": "string", + "description": "The visibility of the repository.", + "enum": [ + "public", + "private" + ] + }, + "security_and_analysis": { + "type": "object", + "description": "Specify which security and analysis features to enable or disable for the repository.\n\nTo use this parameter, you must have admin permissions for the repository or be an owner or security manager for the organization that owns the repository. For more information, see \"[Managing security managers in your organization](https://docs.github.com/organizations/managing-peoples-access-to-your-organization-with-roles/managing-security-managers-in-your-organization).\"\n\nFor example, to enable GitHub Advanced Security, use this data in the body of the `PATCH` request:\n`{ \"security_and_analysis\": {\"advanced_security\": { \"status\": \"enabled\" } } }`.\n\nYou can check which security and analysis features are currently enabled by using a `GET /repos/{owner}/{repo}` request.", + "nullable": true, + "properties": { + "advanced_security": { + "type": "object", + "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.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning for this repository. For more information, see \"[About secret scanning](/code-security/secret-security/about-secret-scanning).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_push_protection": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning push protection for this repository. For more information, see \"[Protecting pushes with secret scanning](/code-security/secret-scanning/protecting-pushes-with-secret-scanning).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "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).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_alert_dismissal": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated alert dismissal for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated bypass for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass_options": { + "type": "object", + "description": "Feature options for secret scanning delegated bypass.\nThis object is only honored when `security_and_analysis.secret_scanning_delegated_bypass.status` is set to `enabled`.\nYou can send this object in the same request as `secret_scanning_delegated_bypass`, or update just the options in a separate request.", + "properties": { + "reviewers": { + "type": "array", + "description": "The bypass reviewers for secret scanning delegated bypass.\nIf you omit this field, the existing set of reviewers is unchanged.", + "items": { + "type": "object", + "required": [ + "reviewer_id", + "reviewer_type" + ], + "properties": { + "reviewer_id": { + "type": "integer", + "description": "The ID of the team or role selected as a bypass reviewer" + }, + "reviewer_type": { + "type": "string", + "description": "The type of the bypass reviewer", + "enum": [ + "TEAM", + "ROLE" + ] + }, + "mode": { + "type": "string", + "description": "The bypass mode for the reviewer", + "enum": [ + "ALWAYS", + "EXEMPT" + ], + "default": "ALWAYS" + } + } + } + } + } + } + } + }, + "has_issues": { + "type": "boolean", + "description": "Either `true` to enable issues for this repository or `false` to disable them.", + "default": true + }, + "has_projects": { + "type": "boolean", + "description": "Either `true` to enable projects for this repository or `false` to disable them. **Note:** If you're creating a repository in an organization that has disabled repository projects, the default is `false`, and if you pass `true`, the API returns an error.", + "default": true + }, + "has_wiki": { + "type": "boolean", + "description": "Either `true` to enable the wiki for this repository or `false` to disable it.", + "default": true + }, + "is_template": { + "type": "boolean", + "description": "Either `true` to make this repo available as a template repository or `false` to prevent it.", + "default": false + }, + "default_branch": { + "type": "string", + "description": "Updates the default branch for this repository." + }, + "allow_squash_merge": { + "type": "boolean", + "description": "Either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging.", + "default": true + }, + "allow_merge_commit": { + "type": "boolean", + "description": "Either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits.", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "description": "Either `true` to allow rebase-merging pull requests, or `false` to prevent rebase-merging.", + "default": true + }, + "allow_auto_merge": { + "type": "boolean", + "description": "Either `true` to allow auto-merge on pull requests, or `false` to disallow auto-merge.", + "default": false + }, + "delete_branch_on_merge": { + "type": "boolean", + "description": "Either `true` to allow automatically deleting head branches when pull requests are merged, or `false` to prevent automatic deletion.", + "default": false + }, + "allow_update_branch": { + "type": "boolean", + "description": "Either `true` to always allow a pull request head branch that is behind its base branch to be updated even if it is not required to be up to date before merging, or false otherwise.", + "default": false + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property is closing down. Please use `squash_merge_commit_title` instead.", + "default": false, + "deprecated": true + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ], + "description": "Required when using `squash_merge_commit_message`.\n\nThe default value for a squash merge commit title:\n\n- `PR_TITLE` - default to the pull request's title.\n- `COMMIT_OR_PR_TITLE` - default to the commit's title (if only one commit) or the pull request's title (when more than one commit)." + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ], + "description": "The default value for a squash merge commit message:\n\n- `PR_BODY` - default to the pull request's body.\n- `COMMIT_MESSAGES` - default to the branch's commit messages.\n- `BLANK` - default to a blank commit message." + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ], + "description": "Required when using `merge_commit_message`.\n\nThe default value for a merge commit title.\n\n- `PR_TITLE` - default to the pull request's title.\n- `MERGE_MESSAGE` - default to the classic title for a merge message (e.g., Merge pull request #123 from branch-name)." + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ], + "description": "The default value for a merge commit message.\n\n- `PR_TITLE` - default to the pull request's title.\n- `PR_BODY` - default to the pull request's body.\n- `BLANK` - default to a blank commit message." + }, + "archived": { + "type": "boolean", + "description": "Whether to archive this repository. `false` will unarchive a previously archived repository.", + "default": false + }, + "allow_forking": { + "type": "boolean", + "description": "Either `true` to allow private forks, or `false` to prevent private forks.", + "default": false + }, + "web_commit_signoff_required": { + "type": "boolean", + "description": "Either `true` to require contributors to sign off on web-based commits, or `false` to not require contributors to sign off on web-based commits.", + "default": false + } + } + }, + { + "type": "object", + "properties": { + "auto_init": { + "description": "Create an initial commit with empty README. Keep this set to true in most cases since many of the policies below cannot be implemented on bare repos", + "type": "boolean" + }, + "gitignore_template": { + "description": "Desired language or platform [.gitignore template](https://github.com/github/gitignore) to apply. Use the name of the template without the extension. For example, 'Haskell'.", + "type": "string" + }, + "license_template": { + "description": "Choose an [open source license template](https://choosealicense.com/) that best suits your needs, and then use the [license keyword](https://help.github.com/articles/licensing-a-repository/#searching-github-by-license-type) as the `license_template` string. For example, 'mit' or 'mpl-2.0'.", + "type": "string" + }, + "topics": { + "description": "A list of topics to set on the repository", + "type": "array", + "items": { + "type": "string" + } + }, + "security": { + "description": "Settings for Code security and analysis", + "type": "object", + "properties": { + "enableVulnerabilityAlerts": { + "type": "boolean" + }, + "enableAutomatedSecurityFixes": { + "type": "boolean" + } + } + }, + "force_create": { + "description": "Force create the repository even if it already exists", + "type": "boolean" + }, + "template": { + "description": "Name of a template repository to use when creating the repository", + "type": "string" + } + } + } + ] + }, + "LabelSettings": { + "description": "Labels: define labels for Issues and Pull Requests", + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "color": { + "description": "The hexadecimal color code for the label. If including a `#`, make sure to wrap it with quotes!", + "type": "string" + }, + "description": { + "type": "string" + }, + "oldname": { + "description": "Include the old name to rename an existing label", + "type": "string" + } + } + } + }, + "exclude": { + "description": "Ignore any labels matching these regexes (don't delete them)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CollaboratorSettings": { + "description": "A collaborator entry giving a specific user access to a repository.", + "allOf": [ + { + "type": "object", + "properties": { + "permission": { + "type": "string", + "description": "The permission to grant the collaborator. **Only valid on organization-owned repositories.** 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.", + "default": "push" + } + } + }, + { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "exclude": { + "description": "You can exclude a list of repos for this collaborator and all repos except these repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "description": "You can include a list of repos for this collaborator and only those repos would have this collaborator", + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "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" + ] + }, + "parent_team_id": { + "type": "integer", + "description": "The ID of a team to set as the parent team." + } + }, + "required": [ + "name" + ] + }, + "MilestoneSettings": { + "description": "A milestone entry", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "state": { + "description": "The state of the milestone. Either `open` or `closed`", + "type": "string" + } + } + }, + "BranchSettings": { + "description": "A branch protection rule entry", + "type": "object", + "properties": { + "name": { + "description": "If the name of the branch value is specified as `default`, then the app will create a branch protection rule to apply against the default branch in the repo", + "type": "string" + }, + "protection": { + "type": "object", + "properties": { + "required_status_checks": { + "type": "object", + "description": "Require status checks to pass before merging. Set to `null` to disable.", + "nullable": true, + "properties": { + "strict": { + "type": "boolean", + "description": "Require branches to be up to date before merging." + }, + "contexts": { + "type": "array", + "deprecated": true, + "description": "**Closing down notice**: The list of status checks to require in order to merge into this branch. If any of these checks have recently been set by a particular GitHub App, they will be required to come from that app in future for the branch to merge. Use `checks` instead of `contexts` for more fine-grained control.", + "items": { + "type": "string" + } + }, + "checks": { + "type": "array", + "description": "The list of status checks to require in order to merge into this branch.", + "items": { + "type": "object", + "required": [ + "context" + ], + "properties": { + "context": { + "type": "string", + "description": "The name of the required check" + }, + "app_id": { + "type": "integer", + "description": "The ID of the GitHub App that must provide this check. Omit this field to automatically select the GitHub App that has recently provided this check, or any app if it was not set by a GitHub App. Pass -1 to explicitly allow any app to set the status." + } + } + } + } + }, + "required": [ + "strict", + "contexts" + ] + }, + "enforce_admins": { + "type": "boolean", + "description": "Enforce all configured restrictions for administrators. Set to `true` to enforce required status checks for repository administrators. Set to `null` to disable.", + "nullable": true + }, + "required_pull_request_reviews": { + "type": "object", + "description": "Require at least one approving review on a pull request, before merging. Set to `null` to disable.", + "nullable": true, + "properties": { + "dismissal_restrictions": { + "type": "object", + "description": "Specify which users, teams, and apps can dismiss pull request reviews. Pass an empty `dismissal_restrictions` object to disable. User and team `dismissal_restrictions` are only available for organization-owned repositories. Omit this parameter for personal repositories.", + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s with dismissal access", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s with dismissal access", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s with dismissal access", + "items": { + "type": "string" + } + } + } + }, + "dismiss_stale_reviews": { + "type": "boolean", + "description": "Set to `true` if you want to automatically dismiss approving reviews when someone pushes a new commit." + }, + "require_code_owner_reviews": { + "type": "boolean", + "description": "Blocks merging pull requests until [code owners](https://docs.github.com/articles/about-code-owners/) review them." + }, + "required_approving_review_count": { + "type": "integer", + "description": "Specify the number of reviewers required to approve pull requests. Use a number between 1 and 6 or 0 to not require reviewers." + }, + "require_last_push_approval": { + "type": "boolean", + "description": "Whether the most recent push must be approved by someone other than the person who pushed it. Default: `false`.", + "default": false + }, + "bypass_pull_request_allowances": { + "type": "object", + "description": "Allow specific users, teams, or apps to bypass pull request requirements.", + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s allowed to bypass pull request requirements.", + "items": { + "type": "string" + } + } + } + } + } + }, + "restrictions": { + "type": "object", + "description": "Restrict who can push to the protected branch. User, app, and team `restrictions` are only available for organization-owned repositories. Set to `null` to disable.", + "nullable": true, + "properties": { + "users": { + "type": "array", + "description": "The list of user `login`s with push access", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "description": "The list of team `slug`s with push access", + "items": { + "type": "string" + } + }, + "apps": { + "type": "array", + "description": "The list of app `slug`s with push access", + "items": { + "type": "string" + } + } + }, + "required": [ + "users", + "teams" + ] + }, + "required_linear_history": { + "type": "boolean", + "description": "Enforces a linear commit Git history, which prevents anyone from pushing merge commits to a branch. Set to `true` to enforce a linear commit history. Set to `false` to disable a linear commit Git history. Your repository must allow squash merging or rebase merging before you can enable a linear commit history. Default: `false`. For more information, see \"[Requiring a linear commit history](https://docs.github.com/github/administering-a-repository/requiring-a-linear-commit-history)\" in the GitHub Help documentation." + }, + "allow_force_pushes": { + "type": "boolean", + "description": "Permits force pushes to the protected branch by anyone with write access to the repository. Set to `true` to allow force pushes. Set to `false` or `null` to block force pushes. Default: `false`. For more information, see \"[Enabling force pushes to a protected branch](https://docs.github.com/github/administering-a-repository/enabling-force-pushes-to-a-protected-branch)\" in the GitHub Help documentation.\"", + "nullable": true + }, + "allow_deletions": { + "type": "boolean", + "description": "Allows deletion of the protected branch by anyone with write access to the repository. Set to `false` to prevent deletion of the protected branch. Default: `false`. For more information, see \"[Enabling force pushes to a protected branch](https://docs.github.com/github/administering-a-repository/enabling-force-pushes-to-a-protected-branch)\" in the GitHub Help documentation." + }, + "block_creations": { + "type": "boolean", + "description": "If set to `true`, the `restrictions` branch protection settings which limits who can push will also block pushes which create new branches, unless the push is initiated by a user, team, or app which has the ability to push. Set to `true` to restrict new branch creation. Default: `false`." + }, + "required_conversation_resolution": { + "type": "boolean", + "description": "Requires all conversations on code to be resolved before a pull request can be merged into a branch that matches this rule. Set to `false` to disable. Default: `false`." + }, + "lock_branch": { + "type": "boolean", + "description": "Whether to set the branch as read-only. If this is true, users will not be able to push to the branch. Default: `false`.", + "default": false + }, + "allow_fork_syncing": { + "type": "boolean", + "description": "Whether users can pull changes from upstream when the branch is locked. Set to `true` to allow fork syncing. Set to `false` to prevent fork syncing. Default: `false`.", + "default": false + } + }, + "required": [ + "required_status_checks", + "enforce_admins", + "required_pull_request_reviews", + "restrictions" + ] + } + } + }, + "AutolinkSettings": { + "description": "An autolink reference entry", + "type": "object", + "properties": { + "key_prefix": { + "type": "string", + "description": "This prefix appended by certain characters will generate a link any time it is found in an issue, pull request, or commit." + }, + "url_template": { + "type": "string", + "description": "The URL must contain `` for the reference number. `` matches different characters depending on the value of `is_alphanumeric`." + }, + "is_alphanumeric": { + "type": "boolean", + "default": true, + "description": "Whether this autolink reference matches alphanumeric characters. If true, the `` parameter of the `url_template` matches alphanumeric characters `A-Z` (case insensitive), `0-9`, and `-`. If false, this autolink reference only matches numeric characters." + } + }, + "required": [ + "key_prefix", + "url_template" + ] + }, + "ValidatorSettings": { + "description": "Repository name validation", + "type": "object", + "properties": { + "pattern": { + "type": "string" + } + } + }, + "RulesetSettings": { + "description": "A ruleset entry", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the ruleset." + }, + "target": { + "type": "string", + "description": "The target of the ruleset", + "enum": [ + "branch", + "tag", + "push", + "repository" + ], + "default": "branch" + }, + "enforcement": { + "type": "string", + "description": "The enforcement level of the ruleset. `evaluate` allows admins to test rules before enforcing them. Admins can view insights on the Rule Insights page (`evaluate` is only available with GitHub Enterprise).", + "enum": [ + "disabled", + "active", + "evaluate" + ] + }, + "bypass_actors": { + "type": "array", + "description": "The actors that can bypass the rules in this ruleset", + "items": { + "title": "Repository Ruleset Bypass Actor", + "type": "object", + "description": "An actor that can bypass rules in a ruleset", + "required": [ + "actor_type" + ], + "properties": { + "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." + }, + "actor_type": { + "type": "string", + "enum": [ + "Integration", + "OrganizationAdmin", + "RepositoryRole", + "Team", + "DeployKey" + ], + "description": "The type of actor that can bypass a ruleset." + }, + "bypass_mode": { + "type": "string", + "description": "When the specified actor can bypass the ruleset. `pull_request` means that an actor can only bypass rules on pull requests. `pull_request` is not applicable for the `DeployKey` actor type. Also, `pull_request` is only applicable to branch rulesets. When `bypass_mode` is `exempt`, rules will not be run for that actor and a bypass audit entry will not be created.", + "enum": [ + "always", + "pull_request", + "exempt" + ], + "default": "always" + } + } + } + }, + "conditions": { + "title": "Organization ruleset conditions", + "type": "object", + "description": "Conditions for an organization ruleset.\nThe branch and tag rulesets conditions object should contain both `repository_name` and `ref_name` properties, or both `repository_id` and `ref_name` properties, or both `repository_property` and `ref_name` properties.\nThe push rulesets conditions object does not require the `ref_name` property.\nFor repository policy rulesets, the conditions object should only contain the `repository_name`, the `repository_id`, or the `repository_property`.", + "oneOf": [ + { + "type": "object", + "title": "repository_name_and_ref_name", + "description": "Conditions to target repositories by name and refs by name", + "allOf": [ + { + "title": "Repository ruleset conditions for ref names", + "type": "object", + "description": "Parameters for a repository ruleset ref name condition", + "properties": { + "ref_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Repository ruleset conditions for repository names", + "type": "object", + "description": "Parameters for a repository name condition", + "properties": { + "repository_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of repository names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~ALL` to include all repositories.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of repository names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + }, + "protected": { + "type": "boolean", + "description": "Whether renaming of target repositories is prevented." + } + } + } + }, + "required": [ + "repository_name" + ] + } + ] + }, + { + "type": "object", + "title": "repository_id_and_ref_name", + "description": "Conditions to target repositories by id and refs by name", + "allOf": [ + { + "title": "Repository ruleset conditions for ref names", + "type": "object", + "description": "Parameters for a repository ruleset ref name condition", + "properties": { + "ref_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Repository ruleset conditions for repository IDs", + "type": "object", + "description": "Parameters for a repository ID condition", + "properties": { + "repository_id": { + "type": "object", + "properties": { + "repository_ids": { + "type": "array", + "description": "The repository IDs that the ruleset applies to. One of these IDs must match for the condition to pass.", + "items": { + "type": "integer" + } + } + } + } + }, + "required": [ + "repository_id" + ] + } + ] + }, + { + "type": "object", + "title": "repository_property_and_ref_name", + "description": "Conditions to target repositories by property and refs by name", + "allOf": [ + { + "title": "Repository ruleset conditions for ref names", + "type": "object", + "description": "Parameters for a repository ruleset ref name condition", + "properties": { + "ref_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Repository ruleset conditions for repository properties", + "type": "object", + "description": "Parameters for a repository property condition", + "properties": { + "repository_property": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "The repository properties and values to include. All of these properties must match for the condition to pass.", + "items": { + "title": "Repository ruleset property targeting definition", + "type": "object", + "description": "Parameters for a targeting a repository property", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository property to target" + }, + "property_values": { + "type": "array", + "description": "The values to match for the repository property", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "description": "The source of the repository property. Defaults to 'custom' if not specified.", + "enum": [ + "custom", + "system" + ] + } + }, + "required": [ + "name", + "property_values" + ] + } + }, + "exclude": { + "type": "array", + "description": "The repository properties and values to exclude. The condition will not pass if any of these properties match.", + "items": { + "title": "Repository ruleset property targeting definition", + "type": "object", + "description": "Parameters for a targeting a repository property", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository property to target" + }, + "property_values": { + "type": "array", + "description": "The values to match for the repository property", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "description": "The source of the repository property. Defaults to 'custom' if not specified.", + "enum": [ + "custom", + "system" + ] + } + }, + "required": [ + "name", + "property_values" + ] + } + } + } + } + }, + "required": [ + "repository_property" + ] + } + ] + } + ] + }, + "rules": { + "type": "array", + "description": "An array of rules within the ruleset.", + "items": { + "title": "Repository Rule", + "type": "object", + "description": "A repository rule.", + "oneOf": [ + { + "title": "creation", + "description": "Only allow users with bypass permission to create matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "creation" + ] + } + } + }, + { + "title": "update", + "description": "Only allow users with bypass permission to update matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "update" + ] + }, + "parameters": { + "type": "object", + "properties": { + "update_allows_fetch_and_merge": { + "type": "boolean", + "description": "Branch can pull changes from its upstream repository" + } + }, + "required": [ + "update_allows_fetch_and_merge" + ] + } + } + }, + { + "title": "deletion", + "description": "Only allow users with bypass permissions to delete matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "deletion" + ] + } + } + }, + { + "title": "required_linear_history", + "description": "Prevent merge commits from being pushed to matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_linear_history" + ] + } + } + }, + { + "title": "required_deployments", + "description": "Choose which environments must be successfully deployed to before refs can be pushed into a ref that matches this rule.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_deployments" + ] + }, + "parameters": { + "type": "object", + "properties": { + "required_deployment_environments": { + "type": "array", + "description": "The environments that must be successfully deployed to before branches can be merged.", + "items": { + "type": "string" + } + } + }, + "required": [ + "required_deployment_environments" + ] + } + } + }, + { + "title": "required_signatures", + "description": "Commits pushed to matching refs must have verified signatures.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_signatures" + ] + } + } + }, + { + "title": "pull_request", + "description": "Require all commits be made to a non-target branch and submitted via a pull request before they can be merged.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "pull_request" + ] + }, + "parameters": { + "type": "object", + "properties": { + "allowed_merge_methods": { + "type": "array", + "description": "Array of allowed merge methods. Allowed values include `merge`, `squash`, and `rebase`. At least one option must be enabled.", + "items": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + } + }, + "dismiss_stale_reviews_on_push": { + "type": "boolean", + "description": "New, reviewable commits pushed will dismiss previous pull request review approvals." + }, + "require_code_owner_review": { + "type": "boolean", + "description": "Require an approving review in pull requests that modify files that have a designated code owner." + }, + "require_last_push_approval": { + "type": "boolean", + "description": "Whether the most recent reviewable push must be approved by someone other than the person who pushed it." + }, + "required_approving_review_count": { + "type": "integer", + "description": "The number of approving reviews that are required before a pull request can be merged.", + "minimum": 0, + "maximum": 10 + }, + "required_review_thread_resolution": { + "type": "boolean", + "description": "All conversations on code must be resolved before a pull request can be merged." + }, + "required_reviewers": { + "type": "array", + "description": "> [!NOTE]\n> `required_reviewers` is in beta and subject to change.\n\nA collection of reviewers and associated file patterns. Each reviewer has a list of file patterns which determine the files that reviewer is required to review.", + "items": { + "title": "RequiredReviewerConfiguration", + "description": "A reviewing team, and file patterns describing which files they must approve changes to.", + "type": "object", + "properties": { + "file_patterns": { + "type": "array", + "description": "Array of file patterns. Pull requests which change matching files must be approved by the specified team. File patterns use fnmatch syntax.", + "items": { + "type": "string" + } + }, + "minimum_approvals": { + "type": "integer", + "description": "Minimum number of approvals required from the specified team. If set to zero, the team will be added to the pull request but approval is optional." + }, + "reviewer": { + "title": "Reviewer", + "description": "A required reviewing team", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the reviewer which must review changes to matching files." + }, + "type": { + "type": "string", + "description": "The type of the reviewer", + "enum": [ + "Team" + ] + } + }, + "required": [ + "id", + "type" + ] + } + }, + "required": [ + "file_patterns", + "minimum_approvals", + "reviewer" + ] + } + } + }, + "required": [ + "dismiss_stale_reviews_on_push", + "require_code_owner_review", + "require_last_push_approval", + "required_approving_review_count", + "required_review_thread_resolution" + ] + } + } + }, + { + "title": "required_status_checks", + "description": "Choose which status checks must pass before the ref is updated. When enabled, commits must first be pushed to another ref where the checks pass.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_status_checks" + ] + }, + "parameters": { + "type": "object", + "properties": { + "do_not_enforce_on_create": { + "type": "boolean", + "description": "Allow repositories and branches to be created if a check would otherwise prohibit it." + }, + "required_status_checks": { + "type": "array", + "description": "Status checks that are required.", + "items": { + "title": "StatusCheckConfiguration", + "description": "Required status check", + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "The status check context name that must be present on the commit." + }, + "integration_id": { + "type": "integer", + "description": "The optional integration ID that this status check must originate from." + } + }, + "required": [ + "context" + ] + } + }, + "strict_required_status_checks_policy": { + "type": "boolean", + "description": "Whether pull requests targeting a matching branch must be tested with the latest code. This setting will not take effect unless at least one status check is enabled." + } + }, + "required": [ + "required_status_checks", + "strict_required_status_checks_policy" + ] + } + } + }, + { + "title": "non_fast_forward", + "description": "Prevent users with push access from force pushing to refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "non_fast_forward" + ] + } + } + }, + { + "title": "commit_message_pattern", + "description": "Parameters to be used for the commit_message_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "commit_message_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "commit_author_email_pattern", + "description": "Parameters to be used for the commit_author_email_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "commit_author_email_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "committer_email_pattern", + "description": "Parameters to be used for the committer_email_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "committer_email_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "branch_name_pattern", + "description": "Parameters to be used for the branch_name_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "branch_name_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "tag_name_pattern", + "description": "Parameters to be used for the tag_name_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "tag_name_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "file_path_restriction", + "description": "Prevent commits that include changes in specified file and folder paths from being pushed to the commit graph. This includes absolute paths that contain file names.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "file_path_restriction" + ] + }, + "parameters": { + "type": "object", + "properties": { + "restricted_file_paths": { + "type": "array", + "description": "The file paths that are restricted from being pushed to the commit graph.", + "items": { + "type": "string" + } + } + }, + "required": [ + "restricted_file_paths" + ] + } + } + }, + { + "title": "max_file_path_length", + "description": "Prevent commits that include file paths that exceed the specified character limit from being pushed to the commit graph.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "max_file_path_length" + ] + }, + "parameters": { + "type": "object", + "properties": { + "max_file_path_length": { + "type": "integer", + "description": "The maximum amount of characters allowed in file paths.", + "minimum": 1, + "maximum": 32767 + } + }, + "required": [ + "max_file_path_length" + ] + } + } + }, + { + "title": "file_extension_restriction", + "description": "Prevent commits that include files with specified file extensions from being pushed to the commit graph.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "file_extension_restriction" + ] + }, + "parameters": { + "type": "object", + "properties": { + "restricted_file_extensions": { + "type": "array", + "description": "The file extensions that are restricted from being pushed to the commit graph.", + "items": { + "type": "string" + } + } + }, + "required": [ + "restricted_file_extensions" + ] + } + } + }, + { + "title": "max_file_size", + "description": "Prevent commits with individual files that exceed the specified limit from being pushed to the commit graph.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "max_file_size" + ] + }, + "parameters": { + "type": "object", + "properties": { + "max_file_size": { + "type": "integer", + "description": "The maximum file size allowed in megabytes. This limit does not apply to Git Large File Storage (Git LFS).", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "max_file_size" + ] + } + } + }, + { + "title": "workflows", + "description": "Require all changes made to a targeted branch to pass the specified workflows before they can be merged.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "workflows" + ] + }, + "parameters": { + "type": "object", + "properties": { + "do_not_enforce_on_create": { + "type": "boolean", + "description": "Allow repositories and branches to be created if a check would otherwise prohibit it." + }, + "workflows": { + "type": "array", + "description": "Workflows that must pass for this rule to pass.", + "items": { + "title": "WorkflowFileReference", + "description": "A workflow that must run for this rule to pass", + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The path to the workflow file" + }, + "ref": { + "type": "string", + "description": "The ref (branch or tag) of the workflow file to use" + }, + "repository_id": { + "type": "integer", + "description": "The ID of the repository where the workflow is defined" + }, + "sha": { + "type": "string", + "description": "The commit SHA of the workflow file to use" + } + }, + "required": [ + "path", + "repository_id" + ] + } + } + }, + "required": [ + "workflows" + ] + } + } + }, + { + "title": "code_scanning", + "description": "Choose which tools must provide code scanning results before the reference is updated. When configured, code scanning must be enabled and have results for both the commit and the reference being updated.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "code_scanning" + ] + }, + "parameters": { + "type": "object", + "properties": { + "code_scanning_tools": { + "type": "array", + "description": "Tools that must provide code scanning results for this rule to pass.", + "items": { + "title": "CodeScanningTool", + "description": "A tool that must provide code scanning results for this rule to pass.", + "type": "object", + "properties": { + "alerts_threshold": { + "type": "string", + "description": "The severity level at which code scanning results that raise alerts block a reference update. For more information on alert severity levels, see \"[About code scanning alerts](https://docs.github.com/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts#about-alert-severity-and-security-severity-levels).\"", + "enum": [ + "none", + "errors", + "errors_and_warnings", + "all" + ] + }, + "security_alerts_threshold": { + "type": "string", + "description": "The severity level at which code scanning results that raise security alerts block a reference update. For more information on security severity levels, see \"[About code scanning alerts](https://docs.github.com/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts#about-alert-severity-and-security-severity-levels).\"", + "enum": [ + "none", + "critical", + "high_or_higher", + "medium_or_higher", + "all" + ] + }, + "tool": { + "type": "string", + "description": "The name of a code scanning tool" + } + }, + "required": [ + "alerts_threshold", + "security_alerts_threshold", + "tool" + ] + } + } + }, + "required": [ + "code_scanning_tools" + ] + } + } + }, + { + "title": "copilot_code_review", + "description": "Request Copilot code review for new pull requests automatically if the author has access to Copilot code review and their premium requests quota has not reached the limit.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "copilot_code_review" + ] + }, + "parameters": { + "type": "object", + "properties": { + "review_draft_pull_requests": { + "type": "boolean", + "description": "Copilot automatically reviews draft pull requests before they are marked as ready for review." + }, + "review_on_push": { + "type": "boolean", + "description": "Copilot automatically reviews each new push to the pull request." + } + } + } + } + } + ] + } + } + }, + "required": [ + "name", + "enforcement" + ] + }, + "EnvironmentsSettings": { + "description": "A deployment environment configuration entry", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "The name of the deployment environment", + "type": "string" + }, + "wait_timer": { + "description": "The amount of time to delay a job after the job is initially triggered (in minutes)", + "type": "integer" + }, + "reviewers": { + "description": "The people or teams that may review jobs that reference the environment", + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "description": "The type of reviewer (`User` or `Team`)", + "type": "string" + }, + "id": { + "description": "The id of the user or team who can review the deployment", + "type": "integer" + } + } + } + }, + "deployment_branch_policy": { + "description": "The type of deployment branch policy for this environment", + "type": "object", + "properties": { + "protected_branches": { + "description": "Whether only protected branches can be deployed to this environment", + "type": "boolean" + }, + "custom_branch_policies": { + "description": "Whether only branches that match the specified name patterns can deploy to this environment", + "type": "boolean" + } + } + }, + "prevent_self_review": { + "description": "Whether or not a user who created the job is prevented from approving their own job", + "type": "boolean" + } + } + }, + "CustomPropertiesSettings": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "VariablesSettings": { + "description": "An Actions variable entry", + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "description": "The name of the variable", + "type": "string" + }, + "value": { + "description": "The value of the variable", + "type": "string" + }, + "visibility": { + "description": "The visibility of the variable. Can be `all`, `private`, or `selected`", + "type": "string", + "enum": [ + "all", + "private", + "selected" + ] + } + } } } } \ No newline at end of file diff --git a/schema/dereferenced/suborgs.json b/schema/dereferenced/suborgs.json index 11ba9ed2d..0267bf7a8 100644 --- a/schema/dereferenced/suborgs.json +++ b/schema/dereferenced/suborgs.json @@ -179,6 +179,15 @@ "TEAM", "ROLE" ] + }, + "mode": { + "type": "string", + "description": "The bypass mode for the reviewer", + "enum": [ + "ALWAYS", + "EXEMPT" + ], + "default": "ALWAYS" } } } @@ -991,6 +1000,15 @@ "TEAM", "ROLE" ] + }, + "mode": { + "type": "string", + "description": "The bypass mode for the reviewer", + "enum": [ + "ALWAYS", + "EXEMPT" + ], + "default": "ALWAYS" } } } diff --git a/script/build-schema b/script/build-schema index 31bb38b82..62cd32cef 100755 --- a/script/build-schema +++ b/script/build-schema @@ -6,17 +6,44 @@ const path = require('node:path') const schemas = [ { src: 'schema/settings.json', dest: 'schema/dereferenced/settings.json' }, - { src: 'schema/suborgs.json', dest: 'schema/dereferenced/suborgs.json' }, - { src: 'schema/repos.json', dest: 'schema/dereferenced/repos.json' } -]; + { src: 'schema/suborgs.json', dest: 'schema/dereferenced/suborgs.json' }, + { src: 'schema/repos.json', dest: 'schema/dereferenced/repos.json' } +] -(async () => { +;(async () => { await fs.mkdir('schema/dereferenced', { recursive: true }) + // Find the GitHub API spec URL from the schema files. + // The URL in the schema files is the single source of truth for which version is used. + const specUrlPattern = /(https:\/\/raw\.githubusercontent\.com\/github\/rest-api-description[^#"]+api\.github\.com[^#"]*\.json)/ + let specUrl + for (const { src } of schemas) { + const match = (await fs.readFile(src, 'utf8')).match(specUrlPattern) + if (match) { specUrl = match[1]; break } + } + + if (!specUrl) { + console.error('Could not find GitHub API spec URL in schema files.') + process.exit(1) + } + + // Pre-dereference the spec so all internal $refs (e.g. #/components/schemas/...) + // are resolved before it is used as an external reference target. + console.log(`Fetching ${specUrl} ...`) + const resolvedSpec = await $RefParser.dereference(specUrl) + + const githubApiResolver = { + order: 1, + canRead: /\/descriptions\/api\.github\.com\/api\.github\.com/, + read: () => resolvedSpec + } + let hasErrors = false for (const { src, dest } of schemas) { try { - const dereferenced = await $RefParser.dereference(path.resolve(src)) + const dereferenced = await $RefParser.dereference(path.resolve(src), { + resolve: { githubApi: githubApiResolver } + }) await fs.writeFile(dest, JSON.stringify(dereferenced, null, 2)) console.log(`Dereferenced ${src} → ${dest}`) } catch (err) { @@ -25,7 +52,5 @@ const schemas = [ } } - if (hasErrors) { - process.exit(1) - } + if (hasErrors) process.exit(1) })().catch(console.error) diff --git a/test/unit/lib/plugins/branches.test.js b/test/unit/lib/plugins/branches.test.js index 8b9c61786..49549cc87 100644 --- a/test/unit/lib/plugins/branches.test.js +++ b/test/unit/lib/plugins/branches.test.js @@ -183,7 +183,8 @@ describe('Branches', () => { ) return plugin.sync().then(() => { - expect(github.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + + expect(github.rest.repos.updateBranchProtection).toHaveBeenCalledWith(expect.objectContaining({ owner: 'bkeepers', repo: 'test', branch: 'main', @@ -191,10 +192,92 @@ describe('Branches', () => { strict: true, contexts: [] }, - enforce_admins: null, + // Existing enforce_admins should be preserved from GitHub + enforce_admins: false, restrictions: null, headers: { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } + })) + }) + }) + }) + + describe('when existing protection has restrictions', () => { + it('preserves restrictions from GitHub when config omits them', () => { + github.rest.repos.getBranchProtection = jest.fn().mockResolvedValue({ + data: { + enforce_admins: { enabled: true }, + required_status_checks: { + strict: false, + contexts: ['ci-check'], + checks: [] + }, + restrictions: { + url: 'https://api.github.com/...', + users: [{ login: 'user1' }, { login: 'user2' }], + teams: [{ slug: 'team-a' }], + apps: [{ slug: 'app-bot' }] + } + } + }) + + // Config only specifies enforce_admins, omits restrictions + const plugin = configure([{ + name: 'main', + protection: { + enforce_admins: false + } + }]) + + return plugin.sync().then(() => { + expect(github.rest.repos.updateBranchProtection).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'bkeepers', + repo: 'test', + branch: 'main', + enforce_admins: false, + // Existing restrictions should be preserved from GitHub + restrictions: { + users: ['user1', 'user2'], + teams: ['team-a'], + apps: ['app-bot'] + }, + // Existing required_status_checks should be preserved from GitHub + required_status_checks: { + strict: false, + contexts: ['ci-check'], + checks: [] + } + }) + ) + }) + }) + + it('normalizes restrictions and defaults missing arrays when preserving from GitHub', () => { + github.rest.repos.getBranchProtection = jest.fn().mockResolvedValue({ + data: { + enforce_admins: { enabled: true }, + restrictions: { + url: 'https://api.github.com/...', + users: [{ login: 'user1' }] + } + } + }) + + const plugin = configure([{ + name: 'main', + protection: { + enforce_admins: false + } + }]) + + return plugin.sync().then(() => { + const payload = github.rest.repos.updateBranchProtection.mock.calls[0][0] + expect(payload.restrictions).toEqual({ + users: ['user1'], + teams: [], + apps: [] }) + expect(payload.restrictions.url).toBeUndefined() }) }) }) @@ -204,10 +287,8 @@ describe('Branches', () => { github.rest.repos.getBranchProtection = jest.fn().mockResolvedValue({ data: { enforce_admins: { enabled: false }, - protection: { - required_status_checks: { - contexts: ['check-1', 'check-2'] - } + required_status_checks: { + contexts: ['check-1', 'check-2'] } } }) @@ -224,7 +305,8 @@ describe('Branches', () => { ) return plugin.sync().then(() => { - expect(github.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + + expect(github.rest.repos.updateBranchProtection).toHaveBeenCalledWith(expect.objectContaining({ owner: 'bkeepers', repo: 'test', branch: 'main', @@ -232,10 +314,10 @@ describe('Branches', () => { strict: true, contexts: ['check-1', 'check-2'] }, - enforce_admins: null, + enforce_admins: false, restrictions: null, headers: { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } - }) + })) }) }) }) From f255a63209cc12efc44cab49b14d9b6551a35519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Thu, 9 Apr 2026 11:05:19 +0300 Subject: [PATCH 11/21] Update docs/github-settings/5. branch-protection.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/github-settings/5. branch-protection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/github-settings/5. branch-protection.md b/docs/github-settings/5. branch-protection.md index 8f054f818..7a235dffe 100644 --- a/docs/github-settings/5. branch-protection.md +++ b/docs/github-settings/5. branch-protection.md @@ -55,7 +55,7 @@ branches: >[!TIP] >GitHub's API documentation defines these inputs and types: ->1. [Update a repository variable](https://docs.github.com/en/rest/actions/variables?apiVersion=2026-03-10#update-a-repository-variable) +>1. [Update branch protection](https://docs.github.com/en/rest/branches/branch-protection?apiVersion=2026-03-10#update-branch-protection) ` - } else if (y.action.additions === null && y.action.deletions === null && y.action.modifications === null) { - return `${x}` + const errorRepos = Object.keys(stats.errors) + const errorSection = errorRepos.length === 0 + ? '### Errors\n`None`' + : `### Errors\n
\n⚠️ Errors — ${errorRepos.length} repo(s) affected\n\n${ + errorRepos.map(repo => + `**${repo}**:\n${stats.errors[repo].map(e => `* ${e.msg}`).join('\n')}` + ).join('\n\n') + }\n\n
` + + const allSections = Object.keys(stats.changes).length === 0 + ? ['_No changes to apply._', errorSection] + : [...pluginSectionList, errorSection] + + const repoCount = Object.keys(stats.reposProcessed).length + const makeHeader = (page, total) => + total > 1 + ? `#### :robot: Safe-Settings config changes detected (${page}/${total}):\n\n**Repos considered:** ${repoCount}\n\n` + : `#### :robot: Safe-Settings config changes detected:\n\n**Repos considered:** ${repoCount}\n\n` + + const HEADER_OVERHEAD = 80 + const BODY_LIMIT = COMMENT_LIMIT - HEADER_OVERHEAD + + const pages = [] + let currentChunks = [] + let currentLength = 0 + + for (const section of allSections) { + const sectionLength = section.length + 2 + if (currentChunks.length > 0 && currentLength + sectionLength > BODY_LIMIT) { + pages.push(currentChunks.join('\n\n')) + currentChunks = [section] + currentLength = sectionLength } else { - if (y.action === undefined) { - return `${x}` - } - return `${x} -` + currentChunks.push(section) + currentLength += sectionLength } - }, table)} -` + } + if (currentChunks.length > 0) { + pages.push(currentChunks.join('\n\n')) + } + const totalPages = pages.length const pullRequest = payload.check_run.check_suite.pull_requests[0] - await this.github.rest.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: pullRequest.number, - body: summary.length > 55536 ? `${summary.substring(0, 55536)}... (too many changes to report)` : summary - }) + for (let i = 0; i < pages.length; i++) { + const header = makeHeader(i + 1, totalPages) + const body = header + pages[i] + const safeBody = body.length > COMMENT_LIMIT + ? `${body.substring(0, COMMENT_LIMIT)}... (too many changes to report)` + : body + await this.github.rest.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: pullRequest.number, + body: safeBody + }) + } } const params = { @@ -323,6 +358,9 @@ ${this.results.reduce((x, y) => { if (rulesetsConfig) { const RulesetsPlugin = Settings.PLUGINS.rulesets return new RulesetsPlugin(this.nop, this.github, this.repo, rulesetsConfig, this.log, this.errors, SCOPE.ORG).sync().then(res => { + if (this.nop && Array.isArray(res)) { + res.forEach(r => { if (r) r.repo = `${this.repo.owner} (org)` }) + } this.appendToResults(res) }) } diff --git a/test/unit/lib/handleResults.test.js b/test/unit/lib/handleResults.test.js new file mode 100644 index 000000000..13f216a25 --- /dev/null +++ b/test/unit/lib/handleResults.test.js @@ -0,0 +1,367 @@ +/* eslint-disable no-undef */ +'use strict' + +const Settings = require('../../../lib/settings') +const env = require('../../../lib/env') + +// --------------------------------------------------------------------------- +// Shared test helpers +// --------------------------------------------------------------------------- + +function makeNopResult ({ repo = 'my-repo', plugin = 'labels', additions = ['label-a'], deletions = null, modifications = null } = {}) { + return { + type: 'NOP', + plugin, + repo, + endpoint: 'https://api.github.com/repos/test-org/my-repo/labels', + body: {}, + action: { + additions, + deletions, + modifications + } + } +} + +function makeErrorResult ({ repo = 'my-repo', plugin = 'labels', msg = 'Something went wrong' } = {}) { + return { + type: 'ERROR', + plugin, + repo, + endpoint: 'https://api.github.com/repos/test-org/my-repo/labels', + body: {}, + action: { + additions: null, + deletions: null, + modifications: null, + msg + } + } +} + +function buildContext (overrides = {}) { + const createComment = jest.fn().mockResolvedValue({}) + const checksUpdate = jest.fn().mockResolvedValue({}) + + const context = { + payload: { + installation: { id: 1 }, + check_run: { + id: 42, + check_suite: { + pull_requests: [{ number: 7 }] + } + }, + repository: { + owner: { login: 'test-org' }, + name: 'admin' + } + }, + octokit: { + rest: { + issues: { createComment }, + checks: { update: checksUpdate } + } + }, + log: { + debug: jest.fn(), + info: jest.fn(), + error: jest.fn() + }, + ...overrides + } + + return { context, createComment, checksUpdate } +} + +function buildSettings (context, results = []) { + const settings = new Settings( + /* nop */ true, + context, + { owner: 'test-org', repo: 'admin' }, + /* config */ {}, + /* ref */ 'main' + ) + settings.results = results + return settings +} + +function getCommentBodies (createComment) { + expect(createComment).toHaveBeenCalled() + return createComment.mock.calls.map(([request]) => request.body) +} + +function getCombinedCommentBody (createComment) { + return getCommentBodies(createComment).join('\n\n') +} + +// --------------------------------------------------------------------------- +// Restore env after each test +// --------------------------------------------------------------------------- + +let originalCreatePrComment + +beforeEach(() => { + originalCreatePrComment = env.CREATE_PR_COMMENT + env.CREATE_PR_COMMENT = 'true' +}) + +afterEach(() => { + env.CREATE_PR_COMMENT = originalCreatePrComment +}) + +// --------------------------------------------------------------------------- +// Test Plan +// --------------------------------------------------------------------------- + +/* + * Code Summary + * ------------ + * handleResults() is the final step of every sync flow. When nop=true it + * renders a human-readable markdown summary of all NopCommand results, + * optionally posts that summary as a PR comment via the GitHub Issues API, and + * always updates a check run via the Checks API with the Eta-rendered template. + * + * Primary concern: the two API calls receive correctly shaped bodies, edge + * cases (empty results, all-null actions, errors) are handled, and the + * CREATE_PR_COMMENT env flag is respected. + */ + +describe('handleResults()', () => { + // ------------------------------------------------------------------------- + // Test 1 — empty state + // ------------------------------------------------------------------------- + describe('renders empty state without changes', () => { + it('PR comment body contains _No changes to apply._', async () => { + // Arrange + const { context, createComment } = buildContext() + const settings = buildSettings(context, []) + + // Act + await settings.handleResults() + + // Assert + const body = getCombinedCommentBody(createComment) + expect(body).toContain('_No changes to apply._') + }) + + it('check run summary contains "No changes to apply"', async () => { + // Arrange + const { context, checksUpdate } = buildContext() + const settings = buildSettings(context, []) + + // Act + await settings.handleResults() + + // Assert + expect(checksUpdate).toHaveBeenCalledTimes(1) + const summary = checksUpdate.mock.calls[0][0].output.summary + expect(summary).toMatch(/No changes to apply/i) + }) + }) + + // ------------------------------------------------------------------------- + // Test 2 — PR comment contains
/ tags with a real result + // ------------------------------------------------------------------------- + describe('PR comment body contains details tags', () => { + it('wraps per-plugin changes in a collapsible
section', async () => { + // Arrange + const { context, createComment } = buildContext() + const result = makeNopResult({ repo: 'my-repo', plugin: 'labels', additions: ['bug', 'enhancement'] }) + const settings = buildSettings(context, [result]) + + // Act + await settings.handleResults() + + // Assert + const body = getCombinedCommentBody(createComment) + expect(body).toContain('
') + expect(body).toContain('') + }) + }) + + // ------------------------------------------------------------------------- + // Test 3 — check run summary contains
/ tags + // ------------------------------------------------------------------------- + describe('check run summary contains details tags', () => { + it('wraps changes section in collapsible HTML when there are results', async () => { + // Arrange + const { context, checksUpdate } = buildContext() + const result = makeNopResult({ repo: 'my-repo', plugin: 'labels', additions: ['bug'] }) + const settings = buildSettings(context, [result]) + + // Act + await settings.handleResults() + + // Assert + const summary = checksUpdate.mock.calls[0][0].output.summary + expect(summary).toContain('
') + expect(summary).toContain('') + }) + }) + + // ------------------------------------------------------------------------- + // Test 4 — output stays under 55536 chars with 200 results + // ------------------------------------------------------------------------- + describe('output stays under 55536 chars with many repos', () => { + it('each comment page and the summary are under the limit and are not truncated', async () => { + // Arrange + const { context, createComment, checksUpdate } = buildContext() + + const REPOS = 40 + const PLUGINS = ['labels', 'teams', 'collaborators', 'branches', 'environments'] + + const results = [] + for (let r = 0; r < REPOS; r++) { + for (const plugin of PLUGINS) { + results.push(makeNopResult({ repo: `repo-${r}`, plugin, additions: ['item-a', 'item-b'] })) + } + } + + const settings = buildSettings(context, results) + + // Act + await settings.handleResults() + + // Assert — paginated bodies + const bodies = getCommentBodies(createComment) + expect(bodies.length).toBeGreaterThan(0) + bodies.forEach(body => { + expect(body.length).toBeLessThanOrEqual(55536) + expect(body).not.toContain('too many changes to report') + }) + + // Assert — summary + const summary = checksUpdate.mock.calls[0][0].output.summary + expect(summary.length).toBeLessThan(55536) + expect(summary).not.toContain('too many changes to report') + }) + }) + + // ------------------------------------------------------------------------- + // Test 5 — errors are wrapped in a collapsible section + // ------------------------------------------------------------------------- + describe('errors are wrapped in collapsible section', () => { + it('body contains ⚠️ Errors heading and a
element when there is an ERROR result', async () => { + // Arrange + const { context, createComment } = buildContext() + const result = makeErrorResult({ repo: 'broken-repo', msg: 'API rate limit exceeded' }) + const settings = buildSettings(context, [result]) + + // Act + await settings.handleResults() + + // Assert + const body = getCombinedCommentBody(createComment) + expect(body).toContain('⚠️ Errors') + expect(body).toContain('
') + }) + }) + + // ------------------------------------------------------------------------- + // Test 6 — repos with all-null actions are omitted from output + // ------------------------------------------------------------------------- + describe('repos with all-null actions are omitted from output', () => { + it('a result with additions/deletions/modifications all null does not appear in the PR comment body', async () => { + // Arrange + const { context, createComment } = buildContext() + const nullResult = makeNopResult({ + repo: 'silent-repo', + plugin: 'labels', + additions: null, + deletions: null, + modifications: null + }) + const settings = buildSettings(context, [nullResult]) + + // Act + await settings.handleResults() + + // Assert + const body = getCombinedCommentBody(createComment) + // The repo name should not appear in the changes table + expect(body).not.toMatch(/silent-repo.*Add:/) + // There must be no changes section referencing this repo + expect(body).toContain('_No changes to apply._') + }) + + it('a result with empty-object/array action fields is filtered out', async () => { + // Arrange + const { context, createComment } = buildContext() + const emptyResult = makeNopResult({ + repo: 'empty-repo', + plugin: 'labels', + additions: {}, + deletions: [], + modifications: null + }) + const settings = buildSettings(context, [emptyResult]) + + // Act + await settings.handleResults() + + // Assert + const body = getCombinedCommentBody(createComment) + expect(body).not.toContain('empty-repo') + expect(body).toContain('_No changes to apply._') + }) + }) + + // ------------------------------------------------------------------------- + // Test 7 — org-level result rows retain (org) labeling + // ------------------------------------------------------------------------- + describe('org-level labeling', () => { + it('includes repos tagged with (org) in output', async () => { + // Arrange + const { context, createComment } = buildContext() + const orgResult = makeNopResult({ + repo: 'test-org (org)', + plugin: 'rulesets', + additions: [{ name: 'require-pull-request-reviews' }] + }) + const settings = buildSettings(context, [orgResult]) + + // Act + await settings.handleResults() + + // Assert + const body = getCombinedCommentBody(createComment) + expect(body).toContain('test-org (org)') + }) + }) + + // ------------------------------------------------------------------------- + // Test 8 — does not create PR comment when CREATE_PR_COMMENT is not true + // ------------------------------------------------------------------------- + describe('does not create PR comment when CREATE_PR_COMMENT is not true', () => { + it('createComment is never called when CREATE_PR_COMMENT is "false"', async () => { + // Arrange + env.CREATE_PR_COMMENT = 'false' + const { context, createComment, checksUpdate } = buildContext() + const result = makeNopResult() + const settings = buildSettings(context, [result]) + + // Act + await settings.handleResults() + + // Assert + expect(createComment).not.toHaveBeenCalled() + // checks.update must still be called regardless + expect(checksUpdate).toHaveBeenCalledTimes(1) + }) + + it('createComment is never called when CREATE_PR_COMMENT is undefined', async () => { + // Arrange + env.CREATE_PR_COMMENT = undefined + const { context, createComment } = buildContext() + const result = makeNopResult() + const settings = buildSettings(context, [result]) + + // Act + await settings.handleResults() + + // Assert + expect(createComment).not.toHaveBeenCalled() + }) + }) +}) From f37336fa42a4c911d2e614199ab94c65d10fd4f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Thu, 7 May 2026 10:56:44 +0300 Subject: [PATCH 15/21] feat: add isDeepEmpty function for recursive empty detection and enhance isEmptyChange logic --- lib/settings.js | 15 +++++- test/unit/lib/handleResults.test.js | 71 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/lib/settings.js b/lib/settings.js index 7e0bd6cc3..f826561c1 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -12,12 +12,21 @@ const eta = new Eta({ views: path.join(__dirname) }) const SCOPE = { ORG: 'org', REPO: 'repo' } // Determine if the setting is a org setting or repo setting const yaml = require('js-yaml') +function isDeepEmpty (value) { + if (value === null || value === undefined) return true + if (Array.isArray(value)) return value.length === 0 || value.every(isDeepEmpty) + if (typeof value === 'object') { + const keys = Object.keys(value) + return keys.length === 0 || keys.every(k => isDeepEmpty(value[k])) + } + return false +} + function isEmptyChange (action) { if (!action) return true const { additions, deletions, modifications } = action if (additions === null && deletions === null && modifications === null) return true - const isEmpty = (v) => v === null || v === undefined || (typeof v === 'object' && Object.keys(v).length === 0) - return isEmpty(additions) && isEmpty(deletions) && isEmpty(modifications) + return isDeepEmpty(additions) && isDeepEmpty(deletions) && isDeepEmpty(modifications) } class Settings { @@ -1050,3 +1059,5 @@ Settings.PLUGINS = { } module.exports = Settings +module.exports.isEmptyChange = isEmptyChange +module.exports.isDeepEmpty = isDeepEmpty diff --git a/test/unit/lib/handleResults.test.js b/test/unit/lib/handleResults.test.js index 13f216a25..d7ed3540e 100644 --- a/test/unit/lib/handleResults.test.js +++ b/test/unit/lib/handleResults.test.js @@ -2,6 +2,7 @@ 'use strict' const Settings = require('../../../lib/settings') +const { isEmptyChange, isDeepEmpty } = require('../../../lib/settings') const env = require('../../../lib/env') // --------------------------------------------------------------------------- @@ -364,4 +365,74 @@ describe('handleResults()', () => { expect(createComment).not.toHaveBeenCalled() }) }) + + // ------------------------------------------------------------------------- + // Test 9 — isDeepEmpty recursive detection + // ------------------------------------------------------------------------- + describe('isDeepEmpty recursive detection', () => { + it('null/undefined are deep-empty', () => { + expect(isDeepEmpty(null)).toBe(true) + expect(isDeepEmpty(undefined)).toBe(true) + }) + + it('empty arrays and objects are deep-empty', () => { + expect(isDeepEmpty([])).toBe(true) + expect(isDeepEmpty({})).toBe(true) + }) + + it('nested empty structures are deep-empty', () => { + expect(isDeepEmpty({ entries: [] })).toBe(true) + expect(isDeepEmpty({ a: { b: [] } })).toBe(true) + expect(isDeepEmpty({ a: null, b: undefined, c: {} })).toBe(true) + expect(isDeepEmpty([{}, [], null])).toBe(true) + }) + + it('non-empty values are NOT deep-empty', () => { + expect(isDeepEmpty('text')).toBe(false) + expect(isDeepEmpty(42)).toBe(false) + expect(isDeepEmpty(false)).toBe(false) + expect(isDeepEmpty([1])).toBe(false) + expect(isDeepEmpty({ key: 'value' })).toBe(false) + }) + + it('partially-filled nested structures are NOT deep-empty', () => { + expect(isDeepEmpty({ entries: [{ name: 'x' }] })).toBe(false) + expect(isDeepEmpty({ a: null, b: 'content' })).toBe(false) + }) + }) + + // ------------------------------------------------------------------------- + // Test 10 — isEmptyChange with deeply-nested empty structures + // ------------------------------------------------------------------------- + describe('isEmptyChange with deeply-nested empty structures', () => { + it('action with { entries: [] } additions is considered empty', () => { + expect(isEmptyChange({ additions: { entries: [] }, deletions: null, modifications: null })).toBe(true) + }) + + it('action with nested empty objects across all fields is empty', () => { + expect(isEmptyChange({ additions: { a: {} }, deletions: { b: [] }, modifications: { c: null } })).toBe(true) + }) + + it('action with real content in additions is NOT empty', () => { + expect(isEmptyChange({ additions: { entries: [{ name: 'label-a' }] }, deletions: null, modifications: null })).toBe(false) + }) + + it('filters nested-empty results from PR comment output', async () => { + const { context, createComment } = buildContext() + const nestedEmptyResult = makeNopResult({ + repo: 'nested-empty-repo', + plugin: 'labels', + additions: { entries: [] }, + deletions: { items: [{}] }, + modifications: null + }) + const settings = buildSettings(context, [nestedEmptyResult]) + + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).not.toContain('nested-empty-repo') + expect(body).toContain('_No changes to apply._') + }) + }) }) From d595cc284308c505253c51ae55d71012911fab72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Mon, 11 May 2026 18:10:06 +0300 Subject: [PATCH 16/21] feat: filter NOP results to PR-introduced changes --- index.js | 40 ++- lib/settings.js | 128 ++++++++- schema/dereferenced/settings.json | 4 +- test/unit/lib/handleResults.test.js | 408 +++++++++++++++++++++++++++- 4 files changed, 564 insertions(+), 16 deletions(-) diff --git a/index.js b/index.js index 12aae7c79..59101d09e 100644 --- a/index.js +++ b/index.js @@ -19,7 +19,7 @@ let deploymentConfig module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => { let appSlug = 'safe-settings' - async function syncAllSettings (nop, context, repo = context.repo(), ref) { + async function syncAllSettings (nop, context, repo = context.repo(), ref, baseRef) { try { deploymentConfig = await loadYamlFileSystem() robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`) @@ -27,8 +27,21 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => const runtimeConfig = await configManager.loadGlobalSettingsYaml() const config = Object.assign({}, deploymentConfig, runtimeConfig) robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`) + + // Load base branch config for NOP filtering (only show PR-introduced changes) + let baseConfig = null + if (nop && baseRef) { + try { + const baseConfigManager = new ConfigManager(context, baseRef) + const baseRuntimeConfig = await baseConfigManager.loadGlobalSettingsYaml() + baseConfig = Object.assign({}, deploymentConfig, baseRuntimeConfig) + } catch (e) { + robot.log.debug(`Could not load base config for NOP filtering: ${e.message}`) + } + } + if (ref) { - return Settings.syncAll(nop, context, repo, config, ref) + return Settings.syncAll(nop, context, repo, config, ref, baseConfig) } else { return Settings.syncAll(nop, context, repo, config) } @@ -73,7 +86,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => } } - async function syncSelectedSettings (nop, context, repos, subOrgs, ref) { + async function syncSelectedSettings (nop, context, repos, subOrgs, ref, baseRef) { try { deploymentConfig = await loadYamlFileSystem() robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`) @@ -81,7 +94,20 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => const runtimeConfig = await configManager.loadGlobalSettingsYaml() const config = Object.assign({}, deploymentConfig, runtimeConfig) robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`) - return Settings.syncSelectedRepos(nop, context, repos, subOrgs, config, ref) + + // Load base branch config for NOP filtering + let baseConfig = null + if (nop && baseRef) { + try { + const baseConfigManager = new ConfigManager(context, baseRef) + const baseRuntimeConfig = await baseConfigManager.loadGlobalSettingsYaml() + baseConfig = Object.assign({}, deploymentConfig, baseRuntimeConfig) + } catch (e) { + robot.log.debug(`Could not load base config for NOP filtering: ${e.message}`) + } + } + + return Settings.syncSelectedRepos(nop, context, repos, subOrgs, config, ref, baseConfig) } catch (e) { if (nop) { let filename = env.SETTINGS_FILE_PATH @@ -588,14 +614,16 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => if (settingsModified) { robot.log.debug(`Changes in '${Settings.FILE_PATH}' detected, doing a full synch...`) - return syncAllSettings(true, context, context.repo(), pull_request.head.ref) + const baseRef = pull_request.base.ref || repository.default_branch + return syncAllSettings(true, context, context.repo(), pull_request.head.ref, baseRef) } const repoChanges = getChangedRepoConfigName(files, context.repo().owner) const subOrgChanges = getChangedSubOrgConfigName(files) if (repoChanges.length > 0 || subOrgChanges.length > 0) { - return syncSelectedSettings(true, context, repoChanges, subOrgChanges, pull_request.head.ref) + const baseRef = pull_request.base.ref || repository.default_branch + return syncSelectedSettings(true, context, repoChanges, subOrgChanges, pull_request.head.ref, baseRef) } // if no safe-settings changes detected, send a success to the check run diff --git a/lib/settings.js b/lib/settings.js index a5c563b8b..29aac1080 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -29,11 +29,78 @@ function isEmptyChange (action) { return isDeepEmpty(additions) && isDeepEmpty(deletions) && isDeepEmpty(modifications) } +/** + * Determines which named entries in an array-based config section actually changed + * between the base branch and the PR branch. Returns a Set of entry names that differ. + */ +function getChangedEntryNames (baseEntries, prEntries) { + const changed = new Set() + if (!baseEntries && !prEntries) return changed + if (!baseEntries || !Array.isArray(baseEntries)) { + // All PR entries are new + if (Array.isArray(prEntries)) prEntries.forEach(e => { if (e.name) changed.add(e.name) }) + return changed + } + if (!prEntries || !Array.isArray(prEntries)) { + // All base entries are deleted + baseEntries.forEach(e => { if (e.name) changed.add(e.name) }) + return changed + } + // Check for added or modified entries + for (const prEntry of prEntries) { + if (!prEntry.name) continue + const baseEntry = baseEntries.find(b => b.name === prEntry.name) + if (!baseEntry || JSON.stringify(baseEntry) !== JSON.stringify(prEntry)) { + changed.add(prEntry.name) + } + } + // Check for deleted entries + for (const baseEntry of baseEntries) { + if (!baseEntry.name) continue + if (!prEntries.find(p => p.name === baseEntry.name)) { + changed.add(baseEntry.name) + } + } + return changed +} + +/** + * Filters a NOP action's arrays to only include entries whose 'name' is in the changedNames set. + * Returns a new action with filtered arrays, or null if nothing meaningful remains. + */ +function filterActionByChangedNames (action, changedNames) { + if (!action || typeof action === 'string') return action + const { additions, deletions, modifications, ...rest } = action + + const filterArray = (arr) => { + if (!arr || !Array.isArray(arr)) return arr + return arr.filter(entry => { + if (!entry || typeof entry !== 'object') return true + // Keep entries whose name is in the changed set + if (entry.name && changedNames.has(entry.name)) return true + // Keep entries without a name field (structural entries like conditions) + if (!entry.name) return true + return false + }) + } + + const filtered = { + ...rest, + additions: filterArray(additions), + deletions: filterArray(deletions), + modifications: filterArray(modifications) + } + + // Return null if everything was filtered out + if (isEmptyChange(filtered)) return null + return filtered +} + class Settings { static fileCache = {} - static async syncAll (nop, context, repo, config, ref) { - const settings = new Settings(nop, context, repo, config, ref) + static async syncAll (nop, context, repo, config, ref, baseConfig) { + const settings = new Settings(nop, context, repo, config, ref, null, baseConfig) try { await settings.loadConfigs() // settings.repoConfigs = await settings.getRepoConfigs() @@ -59,8 +126,10 @@ class Settings { } } - static async syncSelectedRepos (nop, context, repos, subOrgs, config, ref) { - const settings = new Settings(nop, context, context.repo(), config, ref) + static async syncSelectedRepos (nop, context, repos, subOrgs, config, ref, baseConfig) { + const settings = new Settings(nop, context, context.repo(), config, ref, null, baseConfig) + // Track which repos had their override files changed (their NOP results are always relevant) + settings.changedRepoNames = new Set(repos.map(r => r.repo)) try { // Apply org-level settings (e.g., rulesets) first, matching syncAll behavior @@ -108,13 +177,14 @@ class Settings { await settings.handleResults() } - constructor (nop, context, repo, config, ref, suborg) { + constructor (nop, context, repo, config, ref, suborg, baseConfig) { this.ref = ref this.context = context this.installation_id = context.payload.installation.id this.github = context.octokit this.repo = repo this.config = config + this.baseConfig = baseConfig || null this.nop = nop this.suborgChange = !!suborg // If suborg config has been updated, do not load the entire suborg config, and only process repos restricted to it. @@ -220,6 +290,52 @@ class Settings { }) }) + // Filter results to only show PR-introduced changes (not pre-existing drift) + if (this.baseConfig) { + this.log.debug('Filtering NOP results using base config comparison') + this.results = this.results.filter(res => { + if (!res || res.type === 'ERROR') return true + + const isOrgLevel = res.repo && res.repo.endsWith('(org)') + const pluginSection = res.plugin ? res.plugin.toLowerCase() : null + + if (isOrgLevel && pluginSection === 'rulesets') { + // For org-level rulesets: only include rulesets whose config definition changed + const changedNames = getChangedEntryNames(this.baseConfig.rulesets, this.config.rulesets) + if (changedNames.size === 0) return false + const filtered = filterActionByChangedNames(res.action, changedNames) + if (!filtered) return false + res.action = filtered + return true + } + + if (!isOrgLevel && pluginSection) { + // Repos whose override files changed are always relevant + if (this.changedRepoNames && this.changedRepoNames.has(res.repo)) { + return true + } + + // Repo-level rulesets come from override files (not global config.rulesets) + // so don't compare against org-level rulesets — just filter them as drift + // when no override files changed for this repo + if (pluginSection === 'rulesets') { + return false + } + + // For other repo-level plugins: check if the global config section changed + const baseSection = this.baseConfig[pluginSection] + const prSection = this.config[pluginSection] + if (baseSection !== undefined && prSection !== undefined) { + if (JSON.stringify(baseSection) === JSON.stringify(prSection)) { + return false + } + } + } + + return true + }) + } + let error = false // Different logic const stats = { @@ -1061,3 +1177,5 @@ Settings.PLUGINS = { module.exports = Settings module.exports.isEmptyChange = isEmptyChange module.exports.isDeepEmpty = isDeepEmpty +module.exports.getChangedEntryNames = getChangedEntryNames +module.exports.filterActionByChangedNames = filterActionByChangedNames diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json index 5f743c4fe..e3a316a25 100644 --- a/schema/dereferenced/settings.json +++ b/schema/dereferenced/settings.json @@ -308,7 +308,7 @@ } }, "force_create": { - "description": "Force create the repository even if it already exists", + "description": "Force create the repository", "type": "boolean" }, "template": { @@ -2277,7 +2277,7 @@ } }, "force_create": { - "description": "Force create the repository even if it already exists", + "description": "Force create the repository", "type": "boolean" }, "template": { diff --git a/test/unit/lib/handleResults.test.js b/test/unit/lib/handleResults.test.js index d7ed3540e..2519f858a 100644 --- a/test/unit/lib/handleResults.test.js +++ b/test/unit/lib/handleResults.test.js @@ -75,13 +75,15 @@ function buildContext (overrides = {}) { return { context, createComment, checksUpdate } } -function buildSettings (context, results = []) { +function buildSettings (context, results = [], config = {}, baseConfig = null) { const settings = new Settings( /* nop */ true, context, { owner: 'test-org', repo: 'admin' }, - /* config */ {}, - /* ref */ 'main' + /* config */ config, + /* ref */ 'main', + /* suborg */ null, + /* baseConfig */ baseConfig ) settings.results = results return settings @@ -435,4 +437,404 @@ describe('handleResults()', () => { expect(body).toContain('_No changes to apply._') }) }) + + // ------------------------------------------------------------------------- + // Test 11 — Base config filtering: org-level rulesets + // ------------------------------------------------------------------------- + describe('base config filtering for org-level rulesets', () => { + it('only shows rulesets that changed between base and PR config', async () => { + const { context, createComment } = buildContext() + + const baseConfig = { + rulesets: [ + { name: 'Rule A', enforcement: 'active', conditions: { repository_name: { include: ['*'] } } }, + { name: 'Rule B', enforcement: 'active', conditions: { repository_name: { include: ['agent-*'] } } }, + { name: 'Rule C', enforcement: 'evaluate', conditions: { repository_name: { include: ['*'] } } } + ] + } + const prConfig = { + rulesets: [ + { name: 'Rule A', enforcement: 'active', conditions: { repository_name: { include: ['*'] } } }, + { name: 'Rule B', enforcement: 'active', conditions: { repository_name: { include: ['mythapi-*'] } } }, // changed! + { name: 'Rule C', enforcement: 'evaluate', conditions: { repository_name: { include: ['*'] } } } + ] + } + + // NOP comparison found "changes" for all 3 rulesets (due to API drift) + const orgResult = { + type: 'NOP', + plugin: 'Rulesets', + repo: 'test-org (org)', + endpoint: '', + body: {}, + action: { + additions: [], + deletions: [{ name: 'Rule B', conditions: { repository_name: { include: ['agent-*'] } } }], + modifications: [ + { name: 'Rule A', bypass_actors: [{ actor_id: 1 }] }, + { name: 'Rule B', conditions: { repository_name: { include: ['mythapi-*'] } } }, + { name: 'Rule C', bypass_actors: [{ actor_id: 1 }] } + ] + } + } + + const settings = buildSettings(context, [orgResult], prConfig, baseConfig) + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + // Rule B changed — should appear (note: prettify converts spaces to  ) + expect(body).toContain('Rule B') + // Rule A and Rule C are unchanged in config — should NOT appear + expect(body).not.toContain('Rule A') + expect(body).not.toContain('Rule C') + }) + + it('shows all rulesets when no baseConfig is provided (fallback)', async () => { + const { context, createComment } = buildContext() + + const orgResult = { + type: 'NOP', + plugin: 'Rulesets', + repo: 'test-org (org)', + endpoint: '', + body: {}, + action: { + additions: [], + deletions: [], + modifications: [ + { name: 'Rule A', bypass_actors: [{ actor_id: 1 }] }, + { name: 'Rule B', conditions: { repository_name: { include: ['mythapi-*'] } } } + ] + } + } + + // No baseConfig — should show everything (no filtering) + const settings = buildSettings(context, [orgResult], { rulesets: [] }) + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('Rule A') + expect(body).toContain('Rule B') + }) + + it('filters out org result entirely when no rulesets changed', async () => { + const { context, createComment } = buildContext() + + const sameRulesets = [ + { name: 'Rule A', enforcement: 'active' }, + { name: 'Rule B', enforcement: 'evaluate' } + ] + const baseConfig = { rulesets: sameRulesets } + const prConfig = { rulesets: sameRulesets } + + const orgResult = { + type: 'NOP', + plugin: 'Rulesets', + repo: 'test-org (org)', + endpoint: '', + body: {}, + action: { + additions: [], + deletions: [], + modifications: [ + { name: 'Rule A', bypass_actors: [{ actor_id: 1 }] }, + { name: 'Rule B', bypass_actors: [{ actor_id: 1 }] } + ] + } + } + + const settings = buildSettings(context, [orgResult], prConfig, baseConfig) + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('_No changes to apply._') + }) + }) + + // ------------------------------------------------------------------------- + // Test 12 — Base config filtering: repo-level results + // ------------------------------------------------------------------------- + describe('base config filtering for repo-level results', () => { + it('filters out repo results when their config section did not change', async () => { + const { context, createComment } = buildContext() + + const baseConfig = { + rulesets: [ + { name: 'Org Rule', conditions: { repository_name: { include: ['agent-*'] } } } + ], + labels: [{ name: 'bug', color: 'red' }] + } + const prConfig = { + rulesets: [ + { name: 'Org Rule', conditions: { repository_name: { include: ['mythapi-*'] } } } // changed + ], + labels: [{ name: 'bug', color: 'red' }] // unchanged + } + + // Repo-level labels result — labels section didn't change + const repoLabelsResult = makeNopResult({ + repo: 'my-repo', + plugin: 'labels', + additions: ['stale-label'] + }) + + // Org-level rulesets result — rulesets section DID change + const orgResult = { + type: 'NOP', + plugin: 'Rulesets', + repo: 'test-org (org)', + endpoint: '', + body: {}, + action: { + additions: [], + deletions: [], + modifications: [{ name: 'Org Rule', conditions: { repository_name: { include: ['mythapi-*'] } } }] + } + } + + const settings = buildSettings(context, [repoLabelsResult, orgResult], prConfig, baseConfig) + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + // Org rulesets should show (prettify converts spaces to  ) + expect(body).toContain('Org Rule') + // Repo labels should NOT show (labels section unchanged) + expect(body).not.toContain('my-repo') + }) + + it('shows repo results when their config section DID change', async () => { + const { context, createComment } = buildContext() + + const baseConfig = { labels: [{ name: 'bug', color: 'red' }] } + const prConfig = { labels: [{ name: 'bug', color: 'blue' }] } // changed! + + const repoLabelsResult = makeNopResult({ + repo: 'affected-repo', + plugin: 'labels', + additions: [], + modifications: [{ name: 'bug', color: 'blue' }] + }) + + const settings = buildSettings(context, [repoLabelsResult], prConfig, baseConfig) + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('affected-repo') + }) + + it('preserves ERROR results regardless of config filtering', async () => { + const { context, createComment } = buildContext() + + const baseConfig = { labels: [{ name: 'bug', color: 'red' }] } + const prConfig = { labels: [{ name: 'bug', color: 'red' }] } // unchanged + + const errorResult = makeErrorResult({ repo: 'error-repo', plugin: 'labels', msg: 'API failure' }) + + const settings = buildSettings(context, [errorResult], prConfig, baseConfig) + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('error-repo') + expect(body).toContain('API failure') + }) + + it('filters out repo-level rulesets even when org rulesets section changed', async () => { + const { context, createComment } = buildContext() + + const baseConfig = { + rulesets: [{ name: 'Org Rule', conditions: { repository_name: { include: ['agent-*'] } } }] + } + const prConfig = { + rulesets: [{ name: 'Org Rule', conditions: { repository_name: { include: ['mythapi-*'] } } }] + } + + // Repo-level rulesets result (from override file, not global config) + const repoRulesetsResult = { + type: 'NOP', + plugin: 'Rulesets', + repo: 'some-repo', + endpoint: '', + body: {}, + action: { + additions: [], + deletions: [], + modifications: [{ name: 'repo-lvl-rule', enforcement: 'active' }] + } + } + + const settings = buildSettings(context, [repoRulesetsResult], prConfig, baseConfig) + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + // Repo-level rulesets should be filtered (override file didn't change) + expect(body).not.toContain('some-repo') + }) + + it('keeps repo-level results when repo is in changedRepoNames', async () => { + const { context, createComment } = buildContext() + + const baseConfig = { labels: [{ name: 'bug', color: 'red' }] } + const prConfig = { labels: [{ name: 'bug', color: 'red' }] } // unchanged globally + + // Repo-level result for a repo whose override file changed + const repoResult = makeNopResult({ + repo: 'changed-repo', + plugin: 'labels', + modifications: [{ name: 'bug', color: 'green' }] + }) + + const settings = buildSettings(context, [repoResult], prConfig, baseConfig) + // Simulate syncSelectedSettings — this repo had its override file changed + settings.changedRepoNames = new Set(['changed-repo']) + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('changed-repo') + }) + + it('filters repo-level rulesets but keeps repo in changedRepoNames', async () => { + const { context, createComment } = buildContext() + + const baseConfig = { + rulesets: [{ name: 'Org Rule', enforcement: 'active' }] + } + const prConfig = { + rulesets: [{ name: 'Org Rule', enforcement: 'active' }] + } + + // Two repos: one selected (override changed), one not + const selectedRepoResult = { + type: 'NOP', + plugin: 'Rulesets', + repo: 'selected-repo', + endpoint: '', + body: {}, + action: { + additions: [], + deletions: [], + modifications: [{ name: 'repo-rule', enforcement: 'active' }] + } + } + const driftRepoResult = { + type: 'NOP', + plugin: 'Rulesets', + repo: 'drift-repo', + endpoint: '', + body: {}, + action: { + additions: [], + deletions: [], + modifications: [{ name: 'other-rule', enforcement: 'evaluate' }] + } + } + + const settings = buildSettings(context, [selectedRepoResult, driftRepoResult], prConfig, baseConfig) + settings.changedRepoNames = new Set(['selected-repo']) + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('selected-repo') + expect(body).not.toContain('drift-repo') + }) + }) + + // ------------------------------------------------------------------------- + // Test 13 — getChangedEntryNames helper + // ------------------------------------------------------------------------- + describe('getChangedEntryNames', () => { + const { getChangedEntryNames } = require('../../../lib/settings') + + it('returns empty set when both arrays are identical', () => { + const entries = [{ name: 'A', val: 1 }, { name: 'B', val: 2 }] + expect(getChangedEntryNames(entries, entries).size).toBe(0) + }) + + it('detects added entries', () => { + const base = [{ name: 'A', val: 1 }] + const pr = [{ name: 'A', val: 1 }, { name: 'B', val: 2 }] + const changed = getChangedEntryNames(base, pr) + expect(changed.has('B')).toBe(true) + expect(changed.has('A')).toBe(false) + }) + + it('detects deleted entries', () => { + const base = [{ name: 'A', val: 1 }, { name: 'B', val: 2 }] + const pr = [{ name: 'A', val: 1 }] + const changed = getChangedEntryNames(base, pr) + expect(changed.has('B')).toBe(true) + expect(changed.has('A')).toBe(false) + }) + + it('detects modified entries', () => { + const base = [{ name: 'A', val: 1 }, { name: 'B', val: 2 }] + const pr = [{ name: 'A', val: 1 }, { name: 'B', val: 99 }] + const changed = getChangedEntryNames(base, pr) + expect(changed.has('B')).toBe(true) + expect(changed.has('A')).toBe(false) + }) + + it('handles null/undefined base gracefully', () => { + const pr = [{ name: 'A' }, { name: 'B' }] + const changed = getChangedEntryNames(null, pr) + expect(changed.has('A')).toBe(true) + expect(changed.has('B')).toBe(true) + }) + + it('handles null/undefined PR gracefully', () => { + const base = [{ name: 'A' }, { name: 'B' }] + const changed = getChangedEntryNames(base, null) + expect(changed.has('A')).toBe(true) + expect(changed.has('B')).toBe(true) + }) + }) + + // ------------------------------------------------------------------------- + // Test 14 — filterActionByChangedNames helper + // ------------------------------------------------------------------------- + describe('filterActionByChangedNames', () => { + const { filterActionByChangedNames } = require('../../../lib/settings') + + it('keeps entries matching changed names', () => { + const action = { + additions: [{ name: 'New Rule', enforcement: 'active' }], + deletions: [{ name: 'Old Rule', enforcement: 'evaluate' }], + modifications: [ + { name: 'Changed Rule', conditions: { include: ['*'] } }, + { name: 'Unchanged Rule', bypass_actors: [{ actor_id: 1 }] } + ] + } + const changed = new Set(['New Rule', 'Old Rule', 'Changed Rule']) + const result = filterActionByChangedNames(action, changed) + + expect(result.additions).toHaveLength(1) + expect(result.deletions).toHaveLength(1) + expect(result.modifications).toHaveLength(1) + expect(result.modifications[0].name).toBe('Changed Rule') + }) + + it('returns null when all entries are filtered out', () => { + const action = { + additions: [], + deletions: [], + modifications: [ + { name: 'Noise A', bypass_actors: [{ actor_id: 1 }] }, + { name: 'Noise B', bypass_actors: [{ actor_id: 2 }] } + ] + } + const changed = new Set(['Something Else']) + const result = filterActionByChangedNames(action, changed) + expect(result).toBeNull() + }) + + it('keeps entries without a name field (structural entries)', () => { + const action = { + additions: [], + deletions: [{ conditions: { repository_name: { include: ['old-*'] } } }], // no name field + modifications: [] + } + const changed = new Set(['Rule X']) + const result = filterActionByChangedNames(action, changed) + expect(result.deletions).toHaveLength(1) + }) + }) }) From 5d4b4481af95451f8d083406c859a37b00854cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Tue, 12 May 2026 09:31:53 +0300 Subject: [PATCH 17/21] feat: compact NOP comment output Render NOP validation results as compact per-plugin tables that show the affected repo, policy or setting target, and concise change summary. Keep the existing relevance filtering and pagination behavior while using the same compact row model for PR comments and check-run summaries. Prefer structured additions, deletions, and modifications over generic NOP action messages, using msg only as a fallback when no structured rows exist. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/commentmessage.js | 16 +- lib/settings.js | 237 +++++++++++++++++++++++++--- test/unit/lib/handleResults.test.js | 124 +++++++++++++-- 3 files changed, 340 insertions(+), 37 deletions(-) diff --git a/lib/commentmessage.js b/lib/commentmessage.js index 1f19f3eb3..2e9688d52 100644 --- a/lib/commentmessage.js +++ b/lib/commentmessage.js @@ -1,24 +1,24 @@ module.exports = `* Run on: \`<%= new Date() %>\` * Number of repos considered: \`<%= Object.keys(it.reposProcessed).length %>\` +* Number of repos affected: \`<%= it.reposAffected || 0 %>\` --- ## Changes -<% if (Object.keys(it.changes).length === 0) { %> +<% if (!it.changeSections || it.changeSections.length === 0) { %> No changes to apply. <% } else { %> -<% Object.keys(it.changes).forEach(function(plugin) { %> +<% it.changeSections.forEach(function(section) { %>
-<%= plugin %> settings +<%= section.summary %> -| Repo | Additions | Deletions | Modifications | -| --- | --- | --- | --- | -<% Object.keys(it.changes[plugin]).forEach(function(repo) { %><% it.changes[plugin][repo].forEach(function(action) { %><% if (typeof action === 'string') { %>| <%= repo %> | <%= action %> | | | -<% } else { %>| <%= repo %> | <%- action.additions != null ? JSON.stringify(action.additions, null, 2).split('|').join('|') : '' %> | <%- action.deletions != null ? JSON.stringify(action.deletions, null, 2).split('|').join('|') : '' %> | <%- action.modifications != null ? JSON.stringify(action.modifications, null, 2).split('|').join('|') : '' %> | -<% } %><% }) %><% }) %> +| Repo | Policy / Setting | Change | +| --- | --- | --- | +<% section.rows.forEach(function(row) { %>| <%~ row.repoCell %> | <%~ row.targetCell %> | <%~ row.summaryCell %> | +<% }) %>
<% }) %> diff --git a/lib/settings.js b/lib/settings.js index 29aac1080..0d3aa5e3e 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -96,6 +96,213 @@ function filterActionByChangedNames (action, changedNames) { return filtered } +function buildChangeSections (changes) { + return Object.keys(changes).map(plugin => { + const rows = [] + Object.keys(changes[plugin]).forEach(repo => { + changes[plugin][repo].forEach(action => { + rows.push(...compactRowsForAction(plugin, repo, action)) + }) + }) + + const repoCount = new Set(rows.map(row => row.repo)).size + const targetSingular = plugin.toLowerCase() === 'rulesets' ? 'policy' : 'setting' + const targetPlural = plugin.toLowerCase() === 'rulesets' ? 'policies' : 'settings' + const impactSummary = `${repoCount} ${pluralize(repoCount, 'repo', 'repos')}, ${rows.length} ${pluralize(rows.length, targetSingular, targetPlural)} changed` + return { + plugin, + rows, + repoCount, + changeCount: rows.length, + impactSummary, + summary: `${plugin} - ${impactSummary}` + } + }).filter(section => section.rows.length > 0) +} + +function compactRowsForAction (plugin, repo, action) { + if (typeof action === 'string') { + return [createChangeRow(plugin, repo, plugin, action)] + } + + const additions = normalizeChangeEntries(action && action.additions) + const deletions = normalizeChangeEntries(action && action.deletions) + const modifications = normalizeChangeEntries(action && action.modifications) + const usedDeletions = new Set() + const rows = [] + + additions.forEach(entry => { + const target = getChangeTarget(entry, plugin) + rows.push(createChangeRow(plugin, repo, target, summarizeAddedOrDeleted('Added', entry, target))) + }) + + modifications.forEach((entry, index) => { + const target = getChangeTarget(entry, plugin) + const match = findMatchingDeletion(entry, index, modifications, deletions, usedDeletions) + if (match.index !== -1) usedDeletions.add(match.index) + rows.push(createChangeRow(plugin, repo, target, summarizeModification(match.entry, entry, target))) + }) + + deletions.forEach((entry, index) => { + if (usedDeletions.has(index)) return + const target = getChangeTarget(entry, plugin) + rows.push(createChangeRow(plugin, repo, target, summarizeAddedOrDeleted('Deleted', entry, target))) + }) + + if (rows.length === 0 && action && action.msg) { + return [createChangeRow(plugin, repo, plugin, action.msg)] + } + + return rows +} + +function createChangeRow (plugin, repo, target, summary) { + return { + plugin, + repo, + target, + summary, + repoCell: markdownTableCell(repo), + targetCell: markdownTableCell(target), + summaryCell: markdownTableCell(summary) + } +} + +function normalizeChangeEntries (value) { + if (isDeepEmpty(value)) return [] + return Array.isArray(value) ? value.filter(entry => !isDeepEmpty(entry)) : [value] +} + +function findMatchingDeletion (entry, index, modifications, deletions, usedDeletions) { + const identity = getChangeIdentity(entry) + if (identity) { + const matchIndex = deletions.findIndex((deletion, deletionIndex) => { + if (usedDeletions.has(deletionIndex)) return false + return getChangeIdentity(deletion) === identity + }) + if (matchIndex !== -1) return { entry: deletions[matchIndex], index: matchIndex } + } + + if (modifications.length === 1 && deletions.length === 1 && !usedDeletions.has(0)) { + return { entry: deletions[0], index: 0 } + } + + return { entry: null, index: -1 } +} + +function getChangeIdentity (entry) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null + const field = MergeDeep.NAME_FIELDS.find(field => Object.prototype.hasOwnProperty.call(entry, field)) + if (!field) return null + return `${field}:${formatValue(entry[field])}` +} + +function getChangeTarget (entry, fallback) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return formatValue(entry) || fallback + const field = MergeDeep.NAME_FIELDS.find(field => Object.prototype.hasOwnProperty.call(entry, field)) + return field ? formatValue(entry[field]) : fallback +} + +function summarizeAddedOrDeleted (verb, entry, target) { + const summary = summarizeEntry(entry, target) + return summary ? `${verb}: ${summary}` : verb +} + +function summarizeModification (oldEntry, newEntry, target) { + const diff = summarizeDiff(oldEntry, newEntry) + if (diff) return diff + + const summary = summarizeEntry(newEntry, target) + return summary ? `Changed: ${summary}` : 'Changed' +} + +function summarizeDiff (oldEntry, newEntry) { + if (!oldEntry || !newEntry || typeof oldEntry !== 'object' || typeof newEntry !== 'object') return null + + const oldPaths = flattenForSummary(oldEntry, true) + const newPaths = flattenForSummary(newEntry, true) + const changed = Object.keys(oldPaths) + .filter(path => Object.prototype.hasOwnProperty.call(newPaths, path)) + .filter(path => oldPaths[path] !== newPaths[path]) + .slice(0, 3) + .map(path => `${path}: ${oldPaths[path]} -> ${newPaths[path]}`) + + return changed.length > 0 ? changed.join('; ') : null +} + +function summarizeEntry (entry, target) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + const value = formatValue(entry) + return value === target ? '' : value + } + + const flattened = flattenForSummary(entry, true) + const parts = Object.keys(flattened) + .filter(path => flattened[path] !== target) + .slice(0, 3) + .map(path => `${path}: ${flattened[path]}`) + + return parts.join('; ') +} + +function flattenForSummary (value, skipRootIdentity = false, prefix = '') { + if (value === null || value === undefined || typeof value !== 'object') { + return { [prefix || 'value']: formatValue(value) } + } + + if (Array.isArray(value)) { + return { [prefix || 'value']: formatValue(value) } + } + + const result = {} + Object.keys(value).forEach(key => { + if (!prefix && skipRootIdentity && MergeDeep.NAME_FIELDS.includes(key)) return + const path = prefix ? `${prefix}.${key}` : key + const child = value[key] + + if (child && typeof child === 'object' && !Array.isArray(child)) { + Object.assign(result, flattenForSummary(child, false, path)) + } else { + result[path] = formatValue(child) + } + }) + + return result +} + +function formatValue (value) { + if (value === null) return 'null' + if (value === undefined) return '' + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return `${value}` + if (Array.isArray(value) && value.every(item => item === null || ['string', 'number', 'boolean'].includes(typeof item))) { + return value.map(formatValue).join(', ') + } + return truncate(JSON.stringify(value)) +} + +function truncate (value, limit = 180) { + if (!value || value.length <= limit) return value + return `${value.substring(0, limit - 3)}...` +} + +function pluralize (count, singular, plural) { + return count === 1 ? singular : plural +} + +function markdownTableCell (value) { + return escapeHtml(value) + .replaceAll('\n', ' ') + .replaceAll('|', '|') +} + +function escapeHtml (value) { + return `${value}` + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') +} + class Settings { static fileCache = {} @@ -379,22 +586,19 @@ class Settings { this.log.debug(`Stats ${JSON.stringify(this.results, null, 2)}`) + stats.changeSections = buildChangeSections(stats.changes) + stats.reposAffected = new Set(stats.changeSections.flatMap(section => section.rows.map(row => row.repo))).size + const renderedCommentMessage = await eta.renderString(commetMessageTemplate, stats) if (env.CREATE_PR_COMMENT === 'true') { const COMMENT_LIMIT = 55536 - const pluginSectionList = Object.keys(stats.changes).map(plugin => { - const repos = Object.keys(stats.changes[plugin]) - const rows = repos.map(repo => - stats.changes[plugin][repo].map(action => { - if (typeof action === 'string') { - return `
` - } - return `` - }).join('\n') + const pluginSectionList = stats.changeSections.map(section => { + const rows = section.rows.map(row => + `| ${row.repoCell} | ${row.targetCell} | ${row.summaryCell} |` ).join('\n') - return `
\n🔌 ${plugin} — ${repos.length} repo(s) with pending changes\n\n
From cdea52e7fd56823f90c571fd02c446f4752f7743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Thu, 9 Apr 2026 11:07:04 +0300 Subject: [PATCH 12/21] Update index.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index ac1777c46..12aae7c79 100644 --- a/index.js +++ b/index.js @@ -418,7 +418,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => // Check if a config file already exists for the renamed repo name await context.octokit.request('GET /repos/{owner}/{repo}/contents/{path}', { owner: payload.repository.owner.login, - repo: env.ADMIN_REPO, + repo: env.ADMIN_REPO, path: newPath, headers: { 'X-GitHub-Api-Version': '2026-03-10' From 33531b18d0243dace47fb76a2556b756e4eebe4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Thu, 9 Apr 2026 11:11:07 +0300 Subject: [PATCH 13/21] fix(schema): simplify description for force_create property --- schema/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/settings.json b/schema/settings.json index a1d1c0672..59d662d50 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -119,7 +119,7 @@ } }, "force_create": { - "description": "Force create the repository even if it already exists", + "description": "Force create the repository", "type": "boolean" }, "template": { From efc5df9bbe04be5075060cd2e3939aa93c335a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Tue, 5 May 2026 12:18:36 +0300 Subject: [PATCH 14/21] feat: improve PR comment output with empty-change filtering and pagination - Extract isEmptyChange() to filter null/empty top-level actions - Refactor handleResults() with structured per-plugin HTML sections - Add pagination support for comments exceeding GitHub API limits - Add org-level result labeling in updateOrg() - Update ETA template in commentmessage.js - Add comprehensive test suite for handleResults() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 + full-sync.js | 1 + lib/commentmessage.js | 59 +++-- lib/settings.js | 120 +++++---- test/unit/lib/handleResults.test.js | 367 ++++++++++++++++++++++++++++ 5 files changed, 487 insertions(+), 63 deletions(-) create mode 100644 test/unit/lib/handleResults.test.js diff --git a/.gitignore b/.gitignore index 9cd65700b..00bea122e 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,6 @@ samconfig.toml # test file to be ignored test.log reports +safe-settings-gh-app.pem.bak +.configs +schema/.cache/ \ No newline at end of file diff --git a/full-sync.js b/full-sync.js index 8ba1c7353..88857b5a0 100644 --- a/full-sync.js +++ b/full-sync.js @@ -1,3 +1,4 @@ +require('dotenv').config() const appFn = require('./') const { FULL_SYNC_NOP } = require('./lib/env') const { createProbot } = require('probot') diff --git a/lib/commentmessage.js b/lib/commentmessage.js index b54f81bb4..1f19f3eb3 100644 --- a/lib/commentmessage.js +++ b/lib/commentmessage.js @@ -1,31 +1,46 @@ -module.exports = `* Run on: \` <%= new Date() %> \` +module.exports = `* Run on: \`<%= new Date() %>\` -* Number of repos that were considered: \`<%= Object.keys(it.reposProcessed).length %> \` +* Number of repos considered: \`<%= Object.keys(it.reposProcessed).length %>\` -### Breakdown of changes -| Repo <% Object.keys(it.changes).forEach(plugin => { %> | <%= plugin %> settings <% }) %> | -| -- <% Object.keys(it.changes).forEach(plugin => { -%> | -- <% }) %> -| -<% Object.keys(it.reposProcessed).forEach( repo => { -%> -| <%= repo -%> - <%- Object.keys(it.changes).forEach(plugin => { -%> - <%_ if (it.changes[plugin][repo]) { -%> | :hand: <% } else { %> | :grey_exclamation: <% } -%> - <%_ }) -%> | -<% }) -%> +--- -:hand: -> Changes to be applied to the GitHub repository. -:grey_exclamation: -> nothing to be changed in that particular GitHub repository. +## Changes +<% if (Object.keys(it.changes).length === 0) { %> -### Breakdown of errors +No changes to apply. +<% } else { %> +<% Object.keys(it.changes).forEach(function(plugin) { %> + +
+<%= plugin %> settings + +| Repo | Additions | Deletions | Modifications | +| --- | --- | --- | --- | +<% Object.keys(it.changes[plugin]).forEach(function(repo) { %><% it.changes[plugin][repo].forEach(function(action) { %><% if (typeof action === 'string') { %>| <%= repo %> | <%= action %> | | | +<% } else { %>| <%= repo %> | <%- action.additions != null ? JSON.stringify(action.additions, null, 2).split('|').join('|') : '' %> | <%- action.deletions != null ? JSON.stringify(action.deletions, null, 2).split('|').join('|') : '' %> | <%- action.modifications != null ? JSON.stringify(action.modifications, null, 2).split('|').join('|') : '' %> | +<% } %><% }) %><% }) %> + +
+<% }) %> +<% } %> + +--- +## Errors <% if (Object.keys(it.errors).length === 0) { %> -\`None\` + +None <% } else { %> - <% Object.keys(it.errors).forEach(repo => { %> - <%_= repo %>: - <% it.errors[repo].forEach(plugin => { %> - * <%= plugin.msg %> - <% }) %> - <% }) %> +
+Errors by repo + +<% Object.keys(it.errors).forEach(function(repo) { %> +**<%= repo %>** + +<% it.errors[repo].forEach(function(err) { %>* <%= err.msg %> +<% }) %> +<% }) %> + +
<% } %>` diff --git a/lib/settings.js b/lib/settings.js index 8314c9eee..7e0bd6cc3 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -12,6 +12,14 @@ const eta = new Eta({ views: path.join(__dirname) }) const SCOPE = { ORG: 'org', REPO: 'repo' } // Determine if the setting is a org setting or repo setting const yaml = require('js-yaml') +function isEmptyChange (action) { + if (!action) return true + const { additions, deletions, modifications } = action + if (additions === null && deletions === null && modifications === null) return true + const isEmpty = (v) => v === null || v === undefined || (typeof v === 'object' && Object.keys(v).length === 0) + return isEmpty(additions) && isEmpty(deletions) && isEmpty(modifications) +} + class Settings { static fileCache = {} @@ -232,68 +240,95 @@ class Settings { stats.errors[res.repo] = [] } stats.errors[res.repo].push(res.action) - } else if (!(res.action?.additions === null && res.action?.deletions === null && res.action?.modifications === null)) { + } else if (!isEmptyChange(res.action)) { if (!stats.changes[res.plugin]) { stats.changes[res.plugin] = {} } if (!stats.changes[res.plugin][res.repo]) { stats.changes[res.plugin][res.repo] = [] } - stats.changes[res.plugin][res.repo].push(`${res.action}`) + stats.changes[res.plugin][res.repo].push(res.action) } } }) this.log.debug(`Stats ${JSON.stringify(this.results, null, 2)}`) - const table = ` - - - - - - - - - - - - ` - const renderedCommentMessage = await eta.renderString(commetMessageTemplate, stats) if (env.CREATE_PR_COMMENT === 'true') { - const summary = ` -#### :robot: Safe-Settings config changes detected: + const COMMENT_LIMIT = 55536 + + const pluginSectionList = Object.keys(stats.changes).map(plugin => { + const repos = Object.keys(stats.changes[plugin]) + const rows = repos.map(repo => + stats.changes[plugin][repo].map(action => { + if (typeof action === 'string') { + return `` + } + return `` + }).join('\n') + ).join('\n') + return `
\n🔌 ${plugin} — ${repos.length} repo(s) with pending changes\n\n
MsgPluginRepoAdditionsDeletionsModifications
${repo}${action}
${repo}${prettify(action.additions)}${prettify(action.deletions)}${prettify(action.modifications)}
\n${rows}\n
RepoAdditionsDeletionsModifications
\n\n` + }) -${this.results.reduce((x, y) => { - if (!y) { - return x - } - if (y.type === 'ERROR') { - error = true - return `${x} -
❗ ${y.action.msg} ${y.plugin} ${prettify(y.repo)} ${prettify(y.action.additions)} ${prettify(y.action.deletions)} ${prettify(y.action.modifications)}
${y.plugin} ${prettify(y.repo)} ${prettify(y.action.additions)} ${prettify(y.action.deletions)} ${prettify(y.action.modifications)}
${repo}${action}
${repo}${prettify(action.additions)}${prettify(action.deletions)}${prettify(action.modifications)}
\n${rows}\n
RepoAdditionsDeletionsModifications
\n\n` + return `
\n🔌 ${escapeHtml(section.plugin)} — ${escapeHtml(section.impactSummary)}\n\n| Repo | Policy / Setting | Change |\n| --- | --- | --- |\n${rows}\n\n
` }) const errorRepos = Object.keys(stats.errors) @@ -406,15 +610,15 @@ class Settings { ).join('\n\n') }\n\n` - const allSections = Object.keys(stats.changes).length === 0 + const allSections = stats.changeSections.length === 0 ? ['_No changes to apply._', errorSection] : [...pluginSectionList, errorSection] const repoCount = Object.keys(stats.reposProcessed).length const makeHeader = (page, total) => total > 1 - ? `#### :robot: Safe-Settings config changes detected (${page}/${total}):\n\n**Repos considered:** ${repoCount}\n\n` - : `#### :robot: Safe-Settings config changes detected:\n\n**Repos considered:** ${repoCount}\n\n` + ? `#### :robot: Safe-Settings config changes detected (${page}/${total}):\n\n**Repos considered:** ${repoCount}\n**Repos affected:** ${stats.reposAffected}\n\n` + : `#### :robot: Safe-Settings config changes detected:\n\n**Repos considered:** ${repoCount}\n**Repos affected:** ${stats.reposAffected}\n\n` const HEADER_OVERHEAD = 80 const BODY_LIMIT = COMMENT_LIMIT - HEADER_OVERHEAD @@ -1147,13 +1351,6 @@ class Settings { } } -function prettify (obj) { - if (obj === null || obj === undefined) { - return '' - } - return JSON.stringify(obj, null, 2).replaceAll('\n', '
').replaceAll(' ', ' ') -} - Settings.FILE_NAME = path.posix.join(CONFIG_PATH, env.SETTINGS_FILE_PATH) Settings.FILE_PATH = path.posix.join(CONFIG_PATH, env.SETTINGS_FILE_PATH) Settings.SUB_ORG_PATTERN = new Glob(`${CONFIG_PATH}/suborgs/*.yml`) diff --git a/test/unit/lib/handleResults.test.js b/test/unit/lib/handleResults.test.js index 2519f858a..2ed806e41 100644 --- a/test/unit/lib/handleResults.test.js +++ b/test/unit/lib/handleResults.test.js @@ -334,7 +334,113 @@ describe('handleResults()', () => { }) // ------------------------------------------------------------------------- - // Test 8 — does not create PR comment when CREATE_PR_COMMENT is not true + // Test 8 — compact hybrid rendering + // ------------------------------------------------------------------------- + describe('compact hybrid rendering', () => { + it('shows affected repo, changed policy, and concise ruleset diff', async () => { + const { context, createComment } = buildContext() + + const baseConfig = { + rulesets: [ + { + name: 'Agent Studio - Required Workflows', + conditions: { repository_name: { include: ['agent-*'] } } + } + ] + } + const prConfig = { + rulesets: [ + { + name: 'Agent Studio - Required Workflows', + conditions: { repository_name: { include: ['mythapi-*'] } } + } + ] + } + const orgResult = { + type: 'NOP', + plugin: 'Rulesets', + repo: 'test-org (org)', + endpoint: '', + body: {}, + action: { + additions: [], + deletions: [ + { + name: 'Agent Studio - Required Workflows', + conditions: { repository_name: { include: ['agent-*'] } } + } + ], + modifications: [ + { + name: 'Agent Studio - Required Workflows', + conditions: { repository_name: { include: ['mythapi-*'] } }, + bypass_actors: [{ actor_id: 1 }] + } + ] + } + } + + const settings = buildSettings(context, [orgResult], prConfig, baseConfig) + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('**Repos affected:** 1') + expect(body).toContain('| Repo | Policy / Setting | Change |') + expect(body).toContain('test-org (org)') + expect(body).toContain('Agent Studio - Required Workflows') + expect(body).toContain('conditions.repository_name.include: agent-* -> mythapi-*') + expect(body).not.toContain('Additions') + expect(body).not.toContain('Deletions') + expect(body).not.toContain('Modifications') + }) + + it('uses the same compact row model in the check-run summary', async () => { + const { context, checksUpdate } = buildContext() + const result = makeNopResult({ + repo: 'my-repo', + plugin: 'labels', + modifications: [{ name: 'bug', color: 'blue' }] + }) + const settings = buildSettings(context, [result]) + + await settings.handleResults() + + const summary = checksUpdate.mock.calls[0][0].output.summary + expect(summary).toContain('Number of repos affected') + expect(summary).toContain('| Repo | Policy / Setting | Change |') + expect(summary).toContain('my-repo') + expect(summary).toContain('bug') + expect(summary).toContain('Changed: color: blue') + }) + + it('prefers structured action fields over generic msg text', async () => { + const { context, createComment } = buildContext() + const result = { + type: 'NOP', + plugin: 'labels', + repo: 'my-repo', + endpoint: '', + body: {}, + action: { + msg: 'Changes found', + additions: [{ name: 'security', color: 'red' }], + deletions: null, + modifications: null + } + } + const settings = buildSettings(context, [result]) + + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('security') + expect(body).toContain('Added: color: red') + expect(body).not.toContain('Changes found') + }) + }) + + // ------------------------------------------------------------------------- + // Test 9 — does not create PR comment when CREATE_PR_COMMENT is not true // ------------------------------------------------------------------------- describe('does not create PR comment when CREATE_PR_COMMENT is not true', () => { it('createComment is never called when CREATE_PR_COMMENT is "false"', async () => { @@ -482,11 +588,11 @@ describe('handleResults()', () => { await settings.handleResults() const body = getCombinedCommentBody(createComment) - // Rule B changed — should appear (note: prettify converts spaces to  ) - expect(body).toContain('Rule B') + // Rule B changed — should appear + expect(body).toContain('Rule B') // Rule A and Rule C are unchanged in config — should NOT appear - expect(body).not.toContain('Rule A') - expect(body).not.toContain('Rule C') + expect(body).not.toContain('Rule A') + expect(body).not.toContain('Rule C') }) it('shows all rulesets when no baseConfig is provided (fallback)', async () => { @@ -513,8 +619,8 @@ describe('handleResults()', () => { await settings.handleResults() const body = getCombinedCommentBody(createComment) - expect(body).toContain('Rule A') - expect(body).toContain('Rule B') + expect(body).toContain('Rule A') + expect(body).toContain('Rule B') }) it('filters out org result entirely when no rulesets changed', async () => { @@ -596,8 +702,8 @@ describe('handleResults()', () => { await settings.handleResults() const body = getCombinedCommentBody(createComment) - // Org rulesets should show (prettify converts spaces to  ) - expect(body).toContain('Org Rule') + // Org rulesets should show + expect(body).toContain('Org Rule') // Repo labels should NOT show (labels section unchanged) expect(body).not.toContain('my-repo') }) From 7c6123dddd4e2815122a70bd738a32a8b542baef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Tue, 19 May 2026 14:30:19 +0300 Subject: [PATCH 18/21] feat: expand NOP comment change details Revise NOP validation comments to keep an affected-repos overview while rendering detailed per-plugin, per-repo, and per-rule field changes. Show before/after rows for modified fields, added/deleted rows for fields that only exist on one side, and nested details for complex values. Preserve existing PR-introduced-change filtering and keep comment/check-run length safeguards by reserving truncation suffix space. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/commentmessage.js | 12 +- lib/settings.js | 236 +++++++++++++++++++--------- test/unit/lib/handleResults.test.js | 137 ++++++++++++++-- 3 files changed, 283 insertions(+), 102 deletions(-) diff --git a/lib/commentmessage.js b/lib/commentmessage.js index 2e9688d52..25e584d9a 100644 --- a/lib/commentmessage.js +++ b/lib/commentmessage.js @@ -10,18 +10,8 @@ module.exports = `* Run on: \`<%= new Date() %>\` No changes to apply. <% } else { %> -<% it.changeSections.forEach(function(section) { %> -
-<%= section.summary %> - -| Repo | Policy / Setting | Change | -| --- | --- | --- | -<% section.rows.forEach(function(row) { %>| <%~ row.repoCell %> | <%~ row.targetCell %> | <%~ row.summaryCell %> | -<% }) %> - -
-<% }) %> +<%~ it.checkRunDetails %> <% } %> --- diff --git a/lib/settings.js b/lib/settings.js index 0d3aa5e3e..248ff421f 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -10,6 +10,7 @@ const env = require('./env') const CONFIG_PATH = env.CONFIG_PATH const eta = new Eta({ views: path.join(__dirname) }) const SCOPE = { ORG: 'org', REPO: 'repo' } // Determine if the setting is a org setting or repo setting +const COMMENT_LIMIT = 55536 const yaml = require('js-yaml') function isDeepEmpty (value) { @@ -98,73 +99,127 @@ function filterActionByChangedNames (action, changedNames) { function buildChangeSections (changes) { return Object.keys(changes).map(plugin => { - const rows = [] + const repoSections = [] Object.keys(changes[plugin]).forEach(repo => { + const targetMap = new Map() changes[plugin][repo].forEach(action => { - rows.push(...compactRowsForAction(plugin, repo, action)) + expandedTargetsForAction(plugin, action).forEach(target => { + if (!targetMap.has(target.target)) { + targetMap.set(target.target, { + target: target.target, + targetCell: markdownTableCell(target.target), + rows: [] + }) + } + targetMap.get(target.target).rows.push(...target.rows) + }) + }) + repoSections.push({ + repo, + repoCell: markdownTableCell(repo), + targets: Array.from(targetMap.values()).filter(target => target.rows.length > 0) }) }) - const repoCount = new Set(rows.map(row => row.repo)).size + const filteredRepoSections = repoSections.filter(repoSection => repoSection.targets.length > 0) + const changeCount = filteredRepoSections.reduce((count, repoSection) => { + return count + repoSection.targets.reduce((targetCount, target) => targetCount + target.rows.length, 0) + }, 0) + const targetCount = filteredRepoSections.reduce((count, repoSection) => count + repoSection.targets.length, 0) + const repoCount = filteredRepoSections.length const targetSingular = plugin.toLowerCase() === 'rulesets' ? 'policy' : 'setting' const targetPlural = plugin.toLowerCase() === 'rulesets' ? 'policies' : 'settings' - const impactSummary = `${repoCount} ${pluralize(repoCount, 'repo', 'repos')}, ${rows.length} ${pluralize(rows.length, targetSingular, targetPlural)} changed` + const impactSummary = `${repoCount} ${pluralize(repoCount, 'repo', 'repos')}, ${targetCount} ${pluralize(targetCount, targetSingular, targetPlural)} changed` return { plugin, - rows, + repoSections: filteredRepoSections, repoCount, - changeCount: rows.length, + targetCount, + changeCount, impactSummary, summary: `${plugin} - ${impactSummary}` } - }).filter(section => section.rows.length > 0) + }).filter(section => section.repoSections.length > 0) +} + +function buildAffectedRepoMatrix (changeSections) { + const repoPlugins = new Map() + changeSections.forEach(section => { + section.repoSections.forEach(repoSection => { + if (!repoPlugins.has(repoSection.repo)) repoPlugins.set(repoSection.repo, new Set()) + repoPlugins.get(repoSection.repo).add(section.plugin) + }) + }) + + const plugins = changeSections.map(section => section.plugin) + const rows = Array.from(repoPlugins.keys()).sort().map(repo => { + return `| ${markdownTableCell(repo)} | ${plugins.map(plugin => repoPlugins.get(repo).has(plugin) ? ':hand:' : '').join(' | ')} |` + }) + + return `
\nOverview — ${rows.length} affected ${pluralize(rows.length, 'repo', 'repos')}\n\nOnly affected repositories are listed.\n\n| Repo | ${plugins.map(plugin => `${markdownTableCell(plugin)} settings`).join(' | ')} |\n| --- | ${plugins.map(() => '---').join(' | ')} |\n${rows.join('\n')}\n\n:hand: -> Changes to be applied.\n\n
` } -function compactRowsForAction (plugin, repo, action) { +function renderChangeSections (changeSections) { + return changeSections.map(section => { + const repoBlocks = section.repoSections.map(repoSection => { + const targetBlocks = repoSection.targets.map(target => { + return `#### ${markdownTableCell(target.target)}\n\n${renderFieldChangeTable(target.rows)}` + }) + return `### ${markdownTableCell(repoSection.repo)}\n\n${targetBlocks.join('\n\n')}` + }) + + return `
\n🔌 ${escapeHtml(section.plugin)} — ${escapeHtml(section.impactSummary)}\n\n${repoBlocks.join('\n\n')}\n\n
` + }) +} + +function renderFieldChangeTable (rows) { + const tableRows = rows.map(row => { + return `
${row.changeCell}${row.fieldCell}${row.beforeCell}${row.afterCell}
\n${tableRows}\n
ChangeFieldBeforeAfter
` +} + +function expandedTargetsForAction (plugin, action) { if (typeof action === 'string') { - return [createChangeRow(plugin, repo, plugin, action)] + return [createTarget(plugin, [createFieldChangeRow('Info', 'message', '', action)])] } const additions = normalizeChangeEntries(action && action.additions) const deletions = normalizeChangeEntries(action && action.deletions) const modifications = normalizeChangeEntries(action && action.modifications) const usedDeletions = new Set() - const rows = [] + const targets = [] additions.forEach(entry => { const target = getChangeTarget(entry, plugin) - rows.push(createChangeRow(plugin, repo, target, summarizeAddedOrDeleted('Added', entry, target))) + targets.push(createTarget(target, rowsForAddedOrDeleted('Added', entry, target))) }) modifications.forEach((entry, index) => { const target = getChangeTarget(entry, plugin) const match = findMatchingDeletion(entry, index, modifications, deletions, usedDeletions) if (match.index !== -1) usedDeletions.add(match.index) - rows.push(createChangeRow(plugin, repo, target, summarizeModification(match.entry, entry, target))) + targets.push(createTarget(target, rowsForModification(match.entry, entry, target))) }) deletions.forEach((entry, index) => { if (usedDeletions.has(index)) return const target = getChangeTarget(entry, plugin) - rows.push(createChangeRow(plugin, repo, target, summarizeAddedOrDeleted('Deleted', entry, target))) + targets.push(createTarget(target, rowsForAddedOrDeleted('Deleted', entry, target))) }) - if (rows.length === 0 && action && action.msg) { - return [createChangeRow(plugin, repo, plugin, action.msg)] + if (targets.length === 0 && action && action.msg) { + return [createTarget(plugin, [createFieldChangeRow('Info', 'message', '', action.msg)])] } - return rows + return targets } -function createChangeRow (plugin, repo, target, summary) { +function createTarget (target, rows) { return { - plugin, - repo, target, - summary, - repoCell: markdownTableCell(repo), - targetCell: markdownTableCell(target), - summaryCell: markdownTableCell(summary) + rows: rows.filter(row => row) } } @@ -194,55 +249,62 @@ function getChangeIdentity (entry) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null const field = MergeDeep.NAME_FIELDS.find(field => Object.prototype.hasOwnProperty.call(entry, field)) if (!field) return null - return `${field}:${formatValue(entry[field])}` + return `${field}:${formatValue(entry[field]).text}` } function getChangeTarget (entry, fallback) { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return formatValue(entry) || fallback + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return formatValue(entry).text || fallback const field = MergeDeep.NAME_FIELDS.find(field => Object.prototype.hasOwnProperty.call(entry, field)) - return field ? formatValue(entry[field]) : fallback -} - -function summarizeAddedOrDeleted (verb, entry, target) { - const summary = summarizeEntry(entry, target) - return summary ? `${verb}: ${summary}` : verb + return field ? formatValue(entry[field]).text : fallback } -function summarizeModification (oldEntry, newEntry, target) { - const diff = summarizeDiff(oldEntry, newEntry) - if (diff) return diff +function rowsForAddedOrDeleted (change, entry, target) { + const flattened = flattenForSummary(entry, true) + const fields = Object.keys(flattened).filter(path => flattened[path].text !== target) + if (fields.length === 0) return [createFieldChangeRow(change, 'value', change === 'Added' ? '' : target, change === 'Added' ? target : '')] - const summary = summarizeEntry(newEntry, target) - return summary ? `Changed: ${summary}` : 'Changed' + return fields.map(path => { + const value = flattened[path] + return createFieldChangeRow(change, path, change === 'Deleted' ? value : '', change === 'Deleted' ? '' : value) + }) } -function summarizeDiff (oldEntry, newEntry) { - if (!oldEntry || !newEntry || typeof oldEntry !== 'object' || typeof newEntry !== 'object') return null +function rowsForModification (oldEntry, newEntry, target) { + if (!oldEntry || typeof oldEntry !== 'object' || !newEntry || typeof newEntry !== 'object') { + return rowsForAddedOrDeleted('Modified', newEntry, target) + } const oldPaths = flattenForSummary(oldEntry, true) const newPaths = flattenForSummary(newEntry, true) - const changed = Object.keys(oldPaths) - .filter(path => Object.prototype.hasOwnProperty.call(newPaths, path)) - .filter(path => oldPaths[path] !== newPaths[path]) - .slice(0, 3) - .map(path => `${path}: ${oldPaths[path]} -> ${newPaths[path]}`) + const paths = Array.from(new Set([...Object.keys(oldPaths), ...Object.keys(newPaths)])).sort() + const rows = paths.map(path => { + const hasOld = Object.prototype.hasOwnProperty.call(oldPaths, path) + const hasNew = Object.prototype.hasOwnProperty.call(newPaths, path) + if (hasOld && hasNew && comparableValue(oldPaths[path]) !== comparableValue(newPaths[path])) { + return createFieldChangeRow('Modified', path, oldPaths[path], newPaths[path]) + } + if (!hasOld && hasNew) { + return createFieldChangeRow('Added', path, '', newPaths[path]) + } + if (hasOld && !hasNew) { + return createFieldChangeRow('Deleted', path, oldPaths[path], '') + } + return null + }).filter(row => row) - return changed.length > 0 ? changed.join('; ') : null + if (rows.length > 0) return rows + return rowsForAddedOrDeleted('Modified', newEntry, target) } -function summarizeEntry (entry, target) { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { - const value = formatValue(entry) - return value === target ? '' : value +function createFieldChangeRow (change, field, before, after) { + return { + change, + field, + changeCell: markdownTableCell(change), + fieldCell: markdownTableCell(field), + beforeCell: renderValueCell(before), + afterCell: renderValueCell(after) } - - const flattened = flattenForSummary(entry, true) - const parts = Object.keys(flattened) - .filter(path => flattened[path] !== target) - .slice(0, 3) - .map(path => `${path}: ${flattened[path]}`) - - return parts.join('; ') } function flattenForSummary (value, skipRootIdentity = false, prefix = '') { @@ -271,14 +333,26 @@ function flattenForSummary (value, skipRootIdentity = false, prefix = '') { } function formatValue (value) { - if (value === null) return 'null' - if (value === undefined) return '' - if (typeof value === 'string') return value - if (typeof value === 'number' || typeof value === 'boolean') return `${value}` + if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'text')) return value + if (value === null) return { text: 'null', compare: 'null' } + if (value === undefined) return { text: '', compare: '' } + if (typeof value === 'string') return { text: value, compare: value } + if (typeof value === 'number' || typeof value === 'boolean') return { text: `${value}`, compare: `${value}` } if (Array.isArray(value) && value.every(item => item === null || ['string', 'number', 'boolean'].includes(typeof item))) { - return value.map(formatValue).join(', ') + const text = value.map(item => formatValue(item).text).join(', ') + return { text, compare: text } } - return truncate(JSON.stringify(value)) + const json = JSON.stringify(value, null, 2) + return { + text: truncate(json.replaceAll('\n', ' '), 80), + compare: json, + details: json + } +} + +function comparableValue (value) { + const displayValue = formatValue(value) + return Object.prototype.hasOwnProperty.call(displayValue, 'compare') ? displayValue.compare : displayValue.text } function truncate (value, limit = 180) { @@ -286,16 +360,28 @@ function truncate (value, limit = 180) { return `${value.substring(0, limit - 3)}...` } +function truncateWithSuffix (value, limit, suffix) { + if (!value || value.length <= limit) return value + return `${value.substring(0, limit - suffix.length)}${suffix}` +} + function pluralize (count, singular, plural) { return count === 1 ? singular : plural } function markdownTableCell (value) { - return escapeHtml(value) + const displayValue = formatValue(value).text + return escapeHtml(displayValue) .replaceAll('\n', ' ') .replaceAll('|', '|') } +function renderValueCell (value) { + const displayValue = formatValue(value) + if (!displayValue.details) return markdownTableCell(displayValue) + return `
${markdownTableCell(displayValue.text)}
${escapeHtml(displayValue.details)}
` +} + function escapeHtml (value) { return `${value}` .replaceAll('&', '&') @@ -587,19 +673,17 @@ class Settings { this.log.debug(`Stats ${JSON.stringify(this.results, null, 2)}`) stats.changeSections = buildChangeSections(stats.changes) - stats.reposAffected = new Set(stats.changeSections.flatMap(section => section.rows.map(row => row.repo))).size + stats.reposAffected = new Set(stats.changeSections.flatMap(section => section.repoSections.map(repoSection => repoSection.repo))).size + stats.changeOverview = stats.changeSections.length > 0 ? buildAffectedRepoMatrix(stats.changeSections) : '' + stats.changeDetails = stats.changeSections.length > 0 ? renderChangeSections(stats.changeSections).join('\n\n') : '' + stats.checkRunDetails = stats.changeDetails.length > 50000 + ? `${stats.changeOverview}\n\nDetailed field-level changes are available in the pull request comment.` + : stats.changeDetails const renderedCommentMessage = await eta.renderString(commetMessageTemplate, stats) if (env.CREATE_PR_COMMENT === 'true') { - const COMMENT_LIMIT = 55536 - - const pluginSectionList = stats.changeSections.map(section => { - const rows = section.rows.map(row => - `| ${row.repoCell} | ${row.targetCell} | ${row.summaryCell} |` - ).join('\n') - return `
\n🔌 ${escapeHtml(section.plugin)} — ${escapeHtml(section.impactSummary)}\n\n| Repo | Policy / Setting | Change |\n| --- | --- | --- |\n${rows}\n\n
` - }) + const pluginSectionList = renderChangeSections(stats.changeSections) const errorRepos = Object.keys(stats.errors) const errorSection = errorRepos.length === 0 @@ -612,7 +696,7 @@ class Settings { const allSections = stats.changeSections.length === 0 ? ['_No changes to apply._', errorSection] - : [...pluginSectionList, errorSection] + : [stats.changeOverview, ...pluginSectionList, errorSection] const repoCount = Object.keys(stats.reposProcessed).length const makeHeader = (page, total) => @@ -648,9 +732,7 @@ class Settings { for (let i = 0; i < pages.length; i++) { const header = makeHeader(i + 1, totalPages) const body = header + pages[i] - const safeBody = body.length > COMMENT_LIMIT - ? `${body.substring(0, COMMENT_LIMIT)}... (too many changes to report)` - : body + const safeBody = truncateWithSuffix(body, COMMENT_LIMIT, '... (too many changes to report)') await this.github.rest.issues.createComment({ owner: payload.repository.owner.login, repo: payload.repository.name, @@ -669,7 +751,7 @@ class Settings { completed_at: new Date().toISOString(), output: { title: error ? 'Safe-Settings Dry-Run Finished with Error' : 'Safe-Settings Dry-Run Finished with success', - summary: renderedCommentMessage.length > 55536 ? `${renderedCommentMessage.substring(0, 55536)}... (too many changes to report)` : renderedCommentMessage + summary: truncateWithSuffix(renderedCommentMessage, COMMENT_LIMIT, '... (too many changes to report)') } } diff --git a/test/unit/lib/handleResults.test.js b/test/unit/lib/handleResults.test.js index 2ed806e41..f733f5245 100644 --- a/test/unit/lib/handleResults.test.js +++ b/test/unit/lib/handleResults.test.js @@ -239,6 +239,39 @@ describe('handleResults()', () => { expect(summary.length).toBeLessThan(55536) expect(summary).not.toContain('too many changes to report') }) + + it('truncates oversized PR comments without exceeding the limit', async () => { + const { context, createComment } = buildContext() + const result = makeNopResult({ + repo: 'huge-repo', + plugin: 'labels', + additions: [{ name: 'huge-label', description: 'x'.repeat(60000) }] + }) + const settings = buildSettings(context, [result]) + + await settings.handleResults() + + const bodies = getCommentBodies(createComment) + bodies.forEach(body => { + expect(body.length).toBeLessThanOrEqual(55536) + }) + expect(bodies.some(body => body.includes('too many changes to report'))).toBe(true) + }) + + it('truncates oversized check-run summaries without exceeding the limit', async () => { + const { context, checksUpdate } = buildContext() + const errorResult = makeErrorResult({ + repo: 'broken-repo', + msg: 'x'.repeat(60000) + }) + const settings = buildSettings(context, [errorResult]) + + await settings.handleResults() + + const summary = checksUpdate.mock.calls[0][0].output.summary + expect(summary.length).toBeLessThanOrEqual(55536) + expect(summary).toContain('too many changes to report') + }) }) // ------------------------------------------------------------------------- @@ -334,10 +367,10 @@ describe('handleResults()', () => { }) // ------------------------------------------------------------------------- - // Test 8 — compact hybrid rendering + // Test 8 — expanded per-rule rendering // ------------------------------------------------------------------------- - describe('compact hybrid rendering', () => { - it('shows affected repo, changed policy, and concise ruleset diff', async () => { + describe('expanded per-rule rendering', () => { + it('shows affected repo overview, changed policy, and field-level ruleset diff', async () => { const { context, createComment } = buildContext() const baseConfig = { @@ -385,16 +418,15 @@ describe('handleResults()', () => { const body = getCombinedCommentBody(createComment) expect(body).toContain('**Repos affected:** 1') - expect(body).toContain('| Repo | Policy / Setting | Change |') + expect(body).toContain('Only affected repositories are listed.') + expect(body).toContain('| Repo | Rulesets settings |') expect(body).toContain('test-org (org)') - expect(body).toContain('Agent Studio - Required Workflows') - expect(body).toContain('conditions.repository_name.include: agent-* -> mythapi-*') - expect(body).not.toContain('Additions') - expect(body).not.toContain('Deletions') - expect(body).not.toContain('Modifications') + expect(body).toContain('#### Agent Studio - Required Workflows') + expect(body).toContain('
ChangeFieldBeforeAfterModifiedconditions.repository_name.includeagent-*mythapi-*ChangeFieldBeforeAfterModifiedcolorblueAddedcolorredModifiedcolorredblueAddednewOnlytrueDeletedoldOnlytrueModifiedrequired_workflowsAddeddescriptionbugAddedcolorred
${row.changeCell}${row.fieldCell}${row.beforeCell}${row.afterCell}
\n${tableRows}\n
ChangeFieldBeforeAfter
` +function changeMarker (change) { + if (change === 'Added') return '+' + if (change === 'Deleted') return '-' + if (change === 'Modified') return '~' + return 'i' } -function expandedTargetsForAction (plugin, action) { +function targetsForAction (plugin, repo, action, baseConfig, config) { if (typeof action === 'string') { return [createTarget(plugin, [createFieldChangeRow('Info', 'message', '', action)])] } + const configTargets = targetsFromConfigDiff(plugin, repo, action, baseConfig, config) + if (configTargets) return configTargets + const additions = normalizeChangeEntries(action && action.additions) const deletions = normalizeChangeEntries(action && action.deletions) const modifications = normalizeChangeEntries(action && action.modifications) @@ -216,6 +223,58 @@ function expandedTargetsForAction (plugin, action) { return targets } +function targetsFromConfigDiff (plugin, repo, action, baseConfig, config) { + if (!baseConfig || !config || !action || typeof action === 'string') return null + + const pluginSection = plugin.toLowerCase() + const isOrgRulesets = repo && repo.endsWith('(org)') && pluginSection === 'rulesets' + const baseEntries = baseConfig[pluginSection] + const prEntries = config[pluginSection] + + if (!isOrgRulesets) return null + if (!Array.isArray(baseEntries) || !Array.isArray(prEntries)) return null + + const actionNames = getActionEntryNames(action) + if (actionNames.size === 0) return null + + const changedNames = new Set(Array.from(getChangedEntryNames(baseEntries, prEntries)).filter(name => actionNames.has(name))) + if (changedNames.size === 0) return null + + const targets = [] + Array.from(changedNames).sort().forEach(name => { + const oldEntry = findEntryByIdentity(baseEntries, name) + const newEntry = findEntryByIdentity(prEntries, name) + let rows = [] + + if (oldEntry && newEntry) { + rows = rowsForModification(oldEntry, newEntry, name) + } else if (newEntry) { + rows = rowsForAddedOrDeleted('Added', newEntry, name) + } else if (oldEntry) { + rows = rowsForAddedOrDeleted('Deleted', oldEntry, name) + } + + if (rows.length > 0) targets.push(createTarget(name, rows)) + }) + + return targets.length > 0 ? targets : null +} + +function getActionEntryNames (action) { + const names = new Set() + ;['additions', 'deletions', 'modifications'].forEach(actionField => { + normalizeChangeEntries(action[actionField]).forEach(entry => { + const identity = getEntryIdentityValue(entry) + if (identity) names.add(identity) + }) + }) + return names +} + +function findEntryByIdentity (entries, identity) { + return entries.find(entry => getEntryIdentityValue(entry) === identity) +} + function createTarget (target, rows) { return { target, @@ -254,8 +313,13 @@ function getChangeIdentity (entry) { function getChangeTarget (entry, fallback) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return formatValue(entry).text || fallback + return getEntryIdentityValue(entry) || fallback +} + +function getEntryIdentityValue (entry) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null const field = MergeDeep.NAME_FIELDS.find(field => Object.prototype.hasOwnProperty.call(entry, field)) - return field ? formatValue(entry[field]).text : fallback + return field ? formatValue(entry[field]).text : null } function rowsForAddedOrDeleted (change, entry, target) { @@ -300,10 +364,8 @@ function createFieldChangeRow (change, field, before, after) { return { change, field, - changeCell: markdownTableCell(change), - fieldCell: markdownTableCell(field), - beforeCell: renderValueCell(before), - afterCell: renderValueCell(after) + before, + after } } @@ -342,11 +404,10 @@ function formatValue (value) { const text = value.map(item => formatValue(item).text).join(', ') return { text, compare: text } } - const json = JSON.stringify(value, null, 2) + const json = JSON.stringify(value) return { - text: truncate(json.replaceAll('\n', ' '), 80), - compare: json, - details: json + text: truncate(json, 180), + compare: json } } @@ -360,6 +421,36 @@ function truncate (value, limit = 180) { return `${value.substring(0, limit - 3)}...` } +function truncateAroundDifference (value, otherValue, limit = 180) { + if (!value || value.length <= limit) return value + if (!otherValue || value === otherValue) return truncate(value, limit) + + let prefixLength = 0 + while ( + prefixLength < value.length && + prefixLength < otherValue.length && + value[prefixLength] === otherValue[prefixLength] + ) { + prefixLength++ + } + + let suffixLength = 0 + while ( + suffixLength < value.length - prefixLength && + suffixLength < otherValue.length - prefixLength && + value[value.length - 1 - suffixLength] === otherValue[otherValue.length - 1 - suffixLength] + ) { + suffixLength++ + } + + const contextLength = Math.floor((limit - 6) / 2) + const start = Math.max(0, prefixLength - contextLength) + const end = Math.min(value.length, value.length - suffixLength + contextLength) + const prefix = start > 0 ? '...' : '' + const suffix = end < value.length ? '...' : '' + return truncate(`${prefix}${value.substring(start, end)}${suffix}`, limit) +} + function truncateWithSuffix (value, limit, suffix) { if (!value || value.length <= limit) return value return `${value.substring(0, limit - suffix.length)}${suffix}` @@ -369,17 +460,18 @@ function pluralize (count, singular, plural) { return count === 1 ? singular : plural } -function markdownTableCell (value) { - const displayValue = formatValue(value).text - return escapeHtml(displayValue) - .replaceAll('\n', ' ') - .replaceAll('|', '|') +function markdownInlineCode (value, comparedWith) { + return `\`${markdownText(value, comparedWith).replaceAll('`', '\\`')}\`` } -function renderValueCell (value) { +function markdownText (value, comparedWith) { const displayValue = formatValue(value) - if (!displayValue.details) return markdownTableCell(displayValue) - return `
${markdownTableCell(displayValue.text)}
${escapeHtml(displayValue.details)}
` + const otherDisplayValue = comparedWith === undefined ? null : formatValue(comparedWith) + const text = otherDisplayValue + ? truncateAroundDifference(displayValue.compare || displayValue.text, otherDisplayValue.compare || otherDisplayValue.text) + : displayValue.text + return escapeHtml(text) + .replaceAll('\n', ' ') } function escapeHtml (value) { @@ -672,12 +764,11 @@ class Settings { this.log.debug(`Stats ${JSON.stringify(this.results, null, 2)}`) - stats.changeSections = buildChangeSections(stats.changes) - stats.reposAffected = new Set(stats.changeSections.flatMap(section => section.repoSections.map(repoSection => repoSection.repo))).size - stats.changeOverview = stats.changeSections.length > 0 ? buildAffectedRepoMatrix(stats.changeSections) : '' + stats.changeSections = buildChangeSections(stats.changes, this.baseConfig, this.config) + stats.reposAffected = affectedRepoCount(stats.changeSections) stats.changeDetails = stats.changeSections.length > 0 ? renderChangeSections(stats.changeSections).join('\n\n') : '' stats.checkRunDetails = stats.changeDetails.length > 50000 - ? `${stats.changeOverview}\n\nDetailed field-level changes are available in the pull request comment.` + ? 'Detailed changed-field output is available in the pull request comment.' : stats.changeDetails const renderedCommentMessage = await eta.renderString(commetMessageTemplate, stats) @@ -696,7 +787,7 @@ class Settings { const allSections = stats.changeSections.length === 0 ? ['_No changes to apply._', errorSection] - : [stats.changeOverview, ...pluginSectionList, errorSection] + : [...pluginSectionList, errorSection] const repoCount = Object.keys(stats.reposProcessed).length const makeHeader = (page, total) => diff --git a/test/unit/lib/handleResults.test.js b/test/unit/lib/handleResults.test.js index e7a5e088c..ae198d799 100644 --- a/test/unit/lib/handleResults.test.js +++ b/test/unit/lib/handleResults.test.js @@ -344,10 +344,10 @@ describe('handleResults()', () => { }) // ------------------------------------------------------------------------- - // Test 7 — org-level result rows retain (org) labeling + // Test 7 — org-level result rows display the admin repo // ------------------------------------------------------------------------- describe('org-level labeling', () => { - it('includes repos tagged with (org) in output', async () => { + it('shows the admin repo name instead of the org target in output', async () => { // Arrange const { context, createComment } = buildContext() const orgResult = makeNopResult({ @@ -362,15 +362,16 @@ describe('handleResults()', () => { // Assert const body = getCombinedCommentBody(createComment) - expect(body).toContain('test-org (org)') + expect(body).toContain('admin') + expect(body).not.toContain('test-org (org)') }) }) // ------------------------------------------------------------------------- - // Test 8 — expanded per-rule rendering + // Test 8 — table-free rendering with trimmed changed fields // ------------------------------------------------------------------------- - describe('expanded per-rule rendering', () => { - it('shows affected repo overview, changed policy, and field-level ruleset diff', async () => { + describe('table-free rendering with trimmed changed fields', () => { + it('shows affected admin target, changed policy, and field-level ruleset diff', async () => { const { context, createComment } = buildContext() const baseConfig = { @@ -418,19 +419,60 @@ describe('handleResults()', () => { const body = getCombinedCommentBody(createComment) expect(body).toContain('**Repos affected:** 1') - expect(body).toContain('Only affected repositories are listed.') - expect(body).toContain('| Repo | Rulesets settings |') - expect(body).toContain('test-org (org)') - expect(body).toContain('#### Agent Studio - Required Workflows') - expect(body).toContain('
ChangeFieldBeforeAfterModifiedconditions.repository_name.includeagent-*mythapi-*
') + expect(body).not.toContain('test-org (org)') + expect(body).toContain('- `Agent Studio - Required Workflows`') + expect(body).toContain(' - ~ `conditions.repository_name.include`') + expect(body).toContain(' - before: `agent-*`') + expect(body).toContain(' - after: `mythapi-*`') + expect(body).not.toContain('bypass_actors') }) - it('uses the same expanded display model in the check-run summary', async () => { + it('does not render config-changed rulesets absent from NOP actions', async () => { + const { context, createComment } = buildContext() + const baseConfig = { + rulesets: [ + { name: 'Rule B', conditions: { repository_name: { include: ['agent-*'] } } }, + { name: 'Rule D', enforcement: 'evaluate' } + ] + } + const prConfig = { + rulesets: [ + { name: 'Rule B', conditions: { repository_name: { include: ['mythapi-*'] } } }, + { name: 'Rule D', enforcement: 'active' } + ] + } + const orgResult = { + type: 'NOP', + plugin: 'Rulesets', + repo: 'test-org (org)', + endpoint: '', + body: {}, + action: { + additions: [], + deletions: [{ name: 'Rule B', conditions: { repository_name: { include: ['agent-*'] } } }], + modifications: [{ name: 'Rule B', conditions: { repository_name: { include: ['mythapi-*'] } } }] + } + } + const settings = buildSettings(context, [orgResult], prConfig, baseConfig) + + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('Rule B') + expect(body).not.toContain('Rule D') + expect(body).toContain('Rulesets — 1 repo, 1 policy changed') + }) + + it('uses the same table-free trimmed details in the check-run summary', async () => { const { context, checksUpdate } = buildContext() const result = makeNopResult({ repo: 'my-repo', plugin: 'labels', + additions: null, modifications: [{ name: 'bug', color: 'blue' }] }) const settings = buildSettings(context, [result]) @@ -439,10 +481,32 @@ describe('handleResults()', () => { const summary = checksUpdate.mock.calls[0][0].output.summary expect(summary).toContain('Number of repos affected') - expect(summary).toContain('') - expect(summary).toContain('my-repo') - expect(summary).toContain('#### bug') - expect(summary).toContain('') + expect(summary).toContain('labels — 1 repo, 1 setting changed') + expect(summary).toContain('**my-repo**') + expect(summary).toContain('- `bug`') + expect(summary).toContain(' - ~ `color`') + expect(summary).toContain(' - after: `blue`') + expect(summary).not.toContain('| Repo |') + expect(summary).not.toContain('
ChangeFieldBeforeAfterModifiedcolorblue
') + }) + + it('pairs action diff entries by non-name identity fields', async () => { + const { context, createComment } = buildContext() + const result = makeNopResult({ + repo: 'my-repo', + plugin: 'teams', + deletions: [{ login: 'admin-team', permission: 'pull' }], + modifications: [{ login: 'admin-team', permission: 'push' }] + }) + const settings = buildSettings(context, [result]) + + await settings.handleResults() + + const body = getCombinedCommentBody(createComment) + expect(body).toContain('- `admin-team`') + expect(body).toContain(' - ~ `permission`') + expect(body).toContain(' - before: `pull`') + expect(body).toContain(' - after: `push`') }) it('prefers structured action fields over generic msg text', async () => { @@ -466,11 +530,11 @@ describe('handleResults()', () => { const body = getCombinedCommentBody(createComment) expect(body).toContain('security') - expect(body).toContain('') + expect(body).toContain(' - + `color`: `red`') expect(body).not.toContain('Changes found') }) - it('renders nested object values inside details blocks', async () => { + it('renders large object values as compact inline JSON', async () => { const { context, createComment } = buildContext() const result = makeNopResult({ repo: 'my-repo', @@ -485,9 +549,10 @@ describe('handleResults()', () => { await settings.handleResults() const body = getCombinedCommentBody(createComment) - expect(body).toContain('#### main') - expect(body).toContain('
') + expect(body).toContain('- `main`') + expect(body).toContain('required_workflows') expect(body).toContain('"path"') + expect(body).toContain('.github/workflows/build.yml') }) it('shows added and deleted fields within a matched modification', async () => { @@ -509,14 +574,16 @@ describe('handleResults()', () => { await settings.handleResults() const body = getCombinedCommentBody(createComment) - expect(body).toContain('
') - expect(body).toContain('') - expect(body).toContain('') + expect(body).toContain(' - ~ `color`') + expect(body).toContain(' - before: `red`') + expect(body).toContain(' - after: `blue`') + expect(body).toContain(' - + `newOnly`: `true`') + expect(body).toContain(' - - `oldOnly`: `true`') }) it('detects nested value modifications beyond the display preview', async () => { const { context, createComment } = buildContext() - const sharedPrefix = 'a'.repeat(120) + const sharedPrefix = 'a'.repeat(240) const result = { type: 'NOP', plugin: 'branches', @@ -542,7 +609,7 @@ describe('handleResults()', () => { await settings.handleResults() const body = getCombinedCommentBody(createComment) - expect(body).toContain('') + expect(body).toContain(' - ~ `required_workflows`') expect(body).toContain('-OLD.yml') expect(body).toContain('-NEW.yml') }) @@ -559,8 +626,8 @@ describe('handleResults()', () => { await settings.handleResults() const body = getCombinedCommentBody(createComment) - expect(body).toContain('') - expect(body).toContain('') + expect(body).toContain(' - + `description`: `bug`') + expect(body).toContain(' - + `color`: `red`') }) }) From d86fa03741710d94504c405f899b94e87043167b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Madis=20K=C3=B5osaar?= Date: Wed, 27 May 2026 12:36:36 +0300 Subject: [PATCH 21/21] update schemas --- schema/dereferenced/repos.json | 5 +++-- schema/dereferenced/settings.json | 10 ++++++---- schema/dereferenced/suborgs.json | 5 +++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/schema/dereferenced/repos.json b/schema/dereferenced/repos.json index 9213456a0..7eacb4bd2 100644 --- a/schema/dereferenced/repos.json +++ b/schema/dereferenced/repos.json @@ -1563,7 +1563,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 +1572,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 e3a316a25..5279f02c7 100644 --- a/schema/dereferenced/settings.json +++ b/schema/dereferenced/settings.json @@ -765,7 +765,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", @@ -774,7 +774,8 @@ "OrganizationAdmin", "RepositoryRole", "Team", - "DeployKey" + "DeployKey", + "User" ], "description": "The type of actor that can bypass a ruleset." }, @@ -2711,7 +2712,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", @@ -2720,7 +2721,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..601b32be9 100644 --- a/schema/dereferenced/suborgs.json +++ b/schema/dereferenced/suborgs.json @@ -1597,7 +1597,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 +1606,8 @@ "OrganizationAdmin", "RepositoryRole", "Team", - "DeployKey" + "DeployKey", + "User" ], "description": "The type of actor that can bypass a ruleset." },
AddedcolorredModifiedcolorredblueAddednewOnlytrueDeletedoldOnlytrueModifiedrequired_workflowsAddeddescriptionbugAddedcolorred