From 5e8697c04c712731ffddaf1a49a71efde432d808 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:59:32 +0000 Subject: [PATCH 1/2] Initial plan From 63eb953b096c0d97b4034d6e7fc6a909e8f3b455 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:02:22 +0000 Subject: [PATCH 2/2] Add protected label permission checks --- README.md | 6 +++ app/command-enabled.js | 35 ++++++++++++-- app/command-enabled.test.js | 72 ++++++++++++++++++++++++++++ app/commands/label-command.js | 3 +- app/commands/remove-label-command.js | 7 ++- app/comment-extractor.js | 12 +++++ app/comment-extractor.test.js | 33 +++++++++++++ app/default-config.js | 2 + 8 files changed, 163 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 46fcb82..068bfca 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,11 @@ commands: enabled: false label: allowedLabels: [] # any label is allowed + protectedLabels: [] # only collaborators, members and owners can modify these enabled: false removeLabel: allowedLabels: [] # any label is allowed + protectedLabels: [] # only collaborators, members and owners can modify these enabled: false reopen: enabled: false @@ -89,6 +91,8 @@ Closes the current issue. `not-planned` can be passed as the closure reason, e.g Adds a label to the current issue or pull request +`protectedLabels` can be used to restrict specific labels so they can only be modified by repository collaborators, members and owners. + #### Permissions required - Issues @@ -100,6 +104,8 @@ Adds a label to the current issue or pull request Removes a label from the current issue or pull request +`protectedLabels` can be used to restrict specific labels so they can only be modified by repository collaborators, members and owners. + #### Permissions required - Issues diff --git a/app/command-enabled.js b/app/command-enabled.js index ff84516..d4d3d82 100644 --- a/app/command-enabled.js +++ b/app/command-enabled.js @@ -9,10 +9,35 @@ function notEnabled(command) { }; } -function trimLabels(labels) { +function trimLabels(labels = []) { return labels.map((it) => it.trim()); } +const privilegedAuthorAssociations = new Set([ + "COLLABORATOR", + "MEMBER", + "OWNER", +]); + +function protectedLabelEnabled(labelConfig, labels, authorAssociation) { + const protectedLabels = trimLabels(labelConfig.protectedLabels); + const protectedRequestedLabels = labels.filter((label) => + protectedLabels.includes(label), + ); + if (protectedRequestedLabels.length === 0) { + return enabled; + } + + if (privilegedAuthorAssociations.has(authorAssociation)) { + return enabled; + } + + return { + enabled: false, + error: `${protectedRequestedLabels.join(",")} are protected labels and can only be modified by repository collaborators`, + }; +} + export function transferEnabled(config) { if (!config.commands.transfer.enabled) { return notEnabled("transfer"); @@ -21,7 +46,7 @@ export function transferEnabled(config) { return enabled; } -export function labelEnabled(config, labels) { +export function labelEnabled(config, labels, authorAssociation) { const labelConfig = config.commands.label; if (!labelConfig.enabled) { @@ -42,7 +67,7 @@ export function labelEnabled(config, labels) { }; } - return enabled; + return protectedLabelEnabled(labelConfig, labels, authorAssociation); } export function closeEnabled(config) { @@ -61,7 +86,7 @@ export function reopenEnabled(config) { return enabled; } -export function removeLabelEnabled(config, labels) { +export function removeLabelEnabled(config, labels, authorAssociation) { const labelConfig = config.commands.removeLabel; if (!labelConfig.enabled) { return notEnabled("remove-label"); @@ -81,7 +106,7 @@ export function removeLabelEnabled(config, labels) { }; } - return enabled; + return protectedLabelEnabled(labelConfig, labels, authorAssociation); } export function reviewerEnabled(config) { diff --git a/app/command-enabled.test.js b/app/command-enabled.test.js index 929774c..aa8c006 100644 --- a/app/command-enabled.test.js +++ b/app/command-enabled.test.js @@ -125,6 +125,42 @@ describe("command-enabled", () => { expect(sut.enabled).toEqual(false); }); + + test("is disabled when protected label is modified by non-collaborator", () => { + const sut = labelEnabled( + { + commands: { + label: { + enabled: true, + allowedLabels: [], + protectedLabels: ["status:active-incident"], + }, + }, + }, + ["status:active-incident"], + "CONTRIBUTOR", + ); + + expect(sut.enabled).toEqual(false); + }); + + test("is enabled when protected label is modified by collaborator", () => { + const sut = labelEnabled( + { + commands: { + label: { + enabled: true, + allowedLabels: [], + protectedLabels: ["status:active-incident"], + }, + }, + }, + ["status:active-incident"], + "COLLABORATOR", + ); + + expect(sut.enabled).toEqual(true); + }); }); test("is enabled when label is in allowedLabels with blanks", () => { @@ -284,6 +320,42 @@ describe("command-enabled", () => { expect(sut.enabled).toEqual(false); }); + + test("is disabled when protected label is removed by non-collaborator", () => { + const sut = removeLabelEnabled( + { + commands: { + removeLabel: { + enabled: true, + allowedLabels: [], + protectedLabels: ["status:active-incident"], + }, + }, + }, + ["status:active-incident"], + "FIRST_TIME_CONTRIBUTOR", + ); + + expect(sut.enabled).toEqual(false); + }); + + test("is enabled when protected label is removed by member", () => { + const sut = removeLabelEnabled( + { + commands: { + removeLabel: { + enabled: true, + allowedLabels: [], + protectedLabels: ["status:active-incident"], + }, + }, + }, + ["status:active-incident"], + "MEMBER", + ); + + expect(sut.enabled).toEqual(true); + }); }); describe("reviewerEnabled", () => { diff --git a/app/commands/label-command.js b/app/commands/label-command.js index 3737eb5..a5de4e8 100644 --- a/app/commands/label-command.js +++ b/app/commands/label-command.js @@ -6,6 +6,7 @@ import { extractCommaSeparated } from "../converters.js"; import { getLogger } from "../logger.js"; import { + extractAuthorAssociation, extractBody, extractHtmlUrl, extractLabelableId, @@ -24,7 +25,7 @@ export class LabelCommand extends Command { enabled(config) { const labels = extractCommaSeparated(this.matches()[1]); - return labelEnabled(config, labels); + return labelEnabled(config, labels, extractAuthorAssociation(this.payload)); } async run(authToken) { diff --git a/app/commands/remove-label-command.js b/app/commands/remove-label-command.js index 9b09517..0a9b097 100644 --- a/app/commands/remove-label-command.js +++ b/app/commands/remove-label-command.js @@ -5,6 +5,7 @@ import { Command } from "./command.js"; import { extractCommaSeparated } from "../converters.js"; import { getLogger } from "../logger.js"; import { + extractAuthorAssociation, extractBody, extractHtmlUrl, extractLabelableId, @@ -23,7 +24,11 @@ export class RemoveLabelCommand extends Command { enabled(config) { const removeLabels = extractCommaSeparated(this.matches()[1]); - return removeLabelEnabled(config, removeLabels); + return removeLabelEnabled( + config, + removeLabels, + extractAuthorAssociation(this.payload), + ); } async run(authToken) { diff --git a/app/comment-extractor.js b/app/comment-extractor.js index 89da86b..4c90aeb 100644 --- a/app/comment-extractor.js +++ b/app/comment-extractor.js @@ -25,3 +25,15 @@ export function extractLabelableId(payload) { return payload.issue.node_id; } + +export function extractAuthorAssociation(payload) { + if (payload.review) { + return payload.review.author_association; + } + + if (payload.pull_request) { + return payload.pull_request.author_association; + } + + return payload.comment.author_association; +} diff --git a/app/comment-extractor.test.js b/app/comment-extractor.test.js index 6e8610c..69dc1be 100644 --- a/app/comment-extractor.test.js +++ b/app/comment-extractor.test.js @@ -1,4 +1,5 @@ import { + extractAuthorAssociation, extractBody, extractHtmlUrl, extractLabelableId, @@ -76,4 +77,36 @@ describe("extractors", () => { ).toEqual("PR_abcdefgh"); }); }); + + describe("extractAuthorAssociation", () => { + test("is a review", () => { + expect( + extractAuthorAssociation({ + review: { + author_association: "MEMBER", + }, + }), + ).toEqual("MEMBER"); + }); + + test("is a pull request body command", () => { + expect( + extractAuthorAssociation({ + pull_request: { + author_association: "OWNER", + }, + }), + ).toEqual("OWNER"); + }); + + test("is an issue comment", () => { + expect( + extractAuthorAssociation({ + comment: { + author_association: "CONTRIBUTOR", + }, + }), + ).toEqual("CONTRIBUTOR"); + }); + }); }); diff --git a/app/default-config.js b/app/default-config.js index 21d20b1..e38236a 100644 --- a/app/default-config.js +++ b/app/default-config.js @@ -2,10 +2,12 @@ export const defaultConfig = { commands: { label: { allowedLabels: [], + protectedLabels: [], enabled: false, }, removeLabel: { allowedLabels: [], + protectedLabels: [], enabled: false, }, reopen: {