From 2e231c2200523f6f3e5220b6ed630ed4c75ea15a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:57:47 +0000 Subject: [PATCH 1/3] Initial plan From 3839395cc4b82a24bcb5af7fea39089672567912 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:00:18 +0000 Subject: [PATCH 2/3] Require committer access for issue transfers --- README.md | 1 + app/commands/transfer-command.js | 1 + app/github.js | 58 ++++++++++++++++++++++++++++++-- app/github.test.js | 29 ++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 app/github.test.js diff --git a/README.md b/README.md index 46fcb82..dc6c301 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ _Note: If a team exists but the team doesn't have read access to the repository ### /transfer Transfers a GitHub issue to another repository in the same organization. +Only users with committer access on either the source or destination repository can use this command. #### Permissions required diff --git a/app/commands/transfer-command.js b/app/commands/transfer-command.js index 18cd95f..14712d4 100644 --- a/app/commands/transfer-command.js +++ b/app/commands/transfer-command.js @@ -43,6 +43,7 @@ export class TransferCommand extends Command { sourceRepo, targetRepo, extractLabelableId(this.payload), + this.payload.sender.login, ); } catch (error) { logger.error( diff --git a/app/github.js b/app/github.js index 7e25477..34235c6 100644 --- a/app/github.js +++ b/app/github.js @@ -1,5 +1,22 @@ import { graphql } from "@octokit/graphql"; +const committerPermissions = new Set(["WRITE", "MAINTAIN", "ADMIN"]); + +function getCollaboratorPermission(repository, username) { + const collaborator = repository?.collaborators?.edges?.find( + (edge) => edge?.node?.login?.toLowerCase() === username.toLowerCase(), + ); + + return collaborator?.permission; +} + +export function hasCommitterAccess(sourcePermission, targetPermission) { + return ( + committerPermissions.has(sourcePermission) || + committerPermissions.has(targetPermission) + ); +} + async function convertLabelsToIds(labels, token, login, repository) { const convertedLabels = await Promise.all( labels.map( @@ -391,24 +408,61 @@ export async function transferIssue( sourceRepo, targetRepo, issueId, + username, ) { - const { target } = await graphql( + const { source, target } = await graphql( ` - query ($owner: String!, $targetRepo: String!) { + query ( + $owner: String! + $sourceRepo: String! + $targetRepo: String! + $username: String! + ) { + source: repository(owner: $owner, name: $sourceRepo) { + collaborators(query: $username, first: 1) { + edges { + permission + node { + login + } + } + } + } target: repository(owner: $owner, name: $targetRepo) { id + collaborators(query: $username, first: 1) { + edges { + permission + node { + login + } + } + } } } `, { owner, + sourceRepo, targetRepo, + username, headers: { authorization: `token ${token}`, }, }, ); + if ( + !hasCommitterAccess( + getCollaboratorPermission(source, username), + getCollaboratorPermission(target, username), + ) + ) { + throw new Error( + `User ${username} requires committer access on ${sourceRepo} or ${targetRepo} to transfer issues`, + ); + } + await graphql( ` mutation ($issue: ID!, $repo: ID!) { diff --git a/app/github.test.js b/app/github.test.js new file mode 100644 index 0000000..0607369 --- /dev/null +++ b/app/github.test.js @@ -0,0 +1,29 @@ +import { hasCommitterAccess } from "./github.js"; + +describe("github", () => { + describe("hasCommitterAccess", () => { + test("returns true when source permission is write", () => { + const result = hasCommitterAccess("WRITE", "READ"); + + expect(result).toBeTruthy(); + }); + + test("returns true when target permission is maintain", () => { + const result = hasCommitterAccess("READ", "MAINTAIN"); + + expect(result).toBeTruthy(); + }); + + test("returns true when target permission is admin", () => { + const result = hasCommitterAccess("READ", "ADMIN"); + + expect(result).toBeTruthy(); + }); + + test("returns false when neither side has committer access", () => { + const result = hasCommitterAccess("TRIAGE", "READ"); + + expect(result).toBeFalsy(); + }); + }); +}); From 8b9d58bf0bae9e487f01c18128e37102c11fe5e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:16:01 +0000 Subject: [PATCH 3/3] Add configurable transfer permission options --- README.md | 5 +- app/commands/transfer-command.js | 4 +- app/default-config.js | 2 + app/github.js | 90 ++++++++++++++++++++++++++++++-- app/github.test.js | 30 +++++++++-- app/router.js | 2 +- 6 files changed, 121 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dc6c301..d815814 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ commands: reviewer: enabled: false transfer: + allowTriage: false # allow TRIAGE permission on either repository + allowedTeamSlugs: [] # allow members of any listed org team slug enabled: false ``` @@ -134,7 +136,8 @@ _Note: If a team exists but the team doesn't have read access to the repository ### /transfer Transfers a GitHub issue to another repository in the same organization. -Only users with committer access on either the source or destination repository can use this command. +Only users with committer access on either the source or destination repository can use this command by default. +This can be extended via configuration to also allow users with triage permission and/or users in specific org teams. #### Permissions required diff --git a/app/commands/transfer-command.js b/app/commands/transfer-command.js index 14712d4..24fe7a9 100644 --- a/app/commands/transfer-command.js +++ b/app/commands/transfer-command.js @@ -24,9 +24,10 @@ export class TransferCommand extends Command { return transferEnabled(config); } - async run(authToken) { + async run(authToken, config) { const targetRepo = this.matches()[1]; const sourceRepo = this.payload.repository.name; + const transferConfig = config.commands.transfer; const logger = classLogger.child({ user: this.payload.sender.login, @@ -44,6 +45,7 @@ export class TransferCommand extends Command { targetRepo, extractLabelableId(this.payload), this.payload.sender.login, + transferConfig, ); } catch (error) { logger.error( diff --git a/app/default-config.js b/app/default-config.js index 21d20b1..03238f7 100644 --- a/app/default-config.js +++ b/app/default-config.js @@ -19,6 +19,8 @@ export const defaultConfig = { }, transfer: { enabled: false, + allowTriage: false, + allowedTeamSlugs: [], }, }, }; diff --git a/app/github.js b/app/github.js index 34235c6..c9459ec 100644 --- a/app/github.js +++ b/app/github.js @@ -1,6 +1,16 @@ import { graphql } from "@octokit/graphql"; const committerPermissions = new Set(["WRITE", "MAINTAIN", "ADMIN"]); +const triagePermission = "TRIAGE"; + +function getTransferAuthorizationConfig(transferConfig) { + return { + allowTriage: transferConfig?.allowTriage ?? false, + allowedTeamSlugs: (transferConfig?.allowedTeamSlugs ?? []) + .map((slug) => slug.trim()) + .filter((slug) => slug.length > 0), + }; +} function getCollaboratorPermission(repository, username) { const collaborator = repository?.collaborators?.edges?.find( @@ -10,13 +20,71 @@ function getCollaboratorPermission(repository, username) { return collaborator?.permission; } -export function hasCommitterAccess(sourcePermission, targetPermission) { +export function hasCommitterAccess( + sourcePermission, + targetPermission, + allowTriage, +) { + const allowedPermissions = allowTriage + ? new Set([...committerPermissions, triagePermission]) + : committerPermissions; + return ( - committerPermissions.has(sourcePermission) || - committerPermissions.has(targetPermission) + allowedPermissions.has(sourcePermission) || + allowedPermissions.has(targetPermission) ); } +export function hasTransferAccess( + sourcePermission, + targetPermission, + allowTriage, + allowedByTeam, +) { + return ( + allowedByTeam || + hasCommitterAccess(sourcePermission, targetPermission, allowTriage) + ); +} + +async function hasAllowedTeamMembership(token, owner, username, teamSlugs) { + const memberships = await Promise.all( + teamSlugs.map(async (teamSlug) => { + const result = await graphql( + ` + query ($owner: String!, $teamSlug: String!, $username: String!) { + organization(login: $owner) { + team(slug: $teamSlug) { + members(query: $username, first: 1) { + nodes { + login + } + } + } + } + } + `, + { + owner, + teamSlug, + username, + headers: { + authorization: `token ${token}`, + }, + }, + ); + + return ( + result.organization?.team?.members?.nodes?.some( + (node) => node.login.toLowerCase() === username.toLowerCase(), + ) ?? false + ); + }), + ); + + return memberships.some(Boolean); +} + async function convertLabelsToIds(labels, token, login, repository) { const convertedLabels = await Promise.all( labels.map( @@ -409,7 +477,9 @@ export async function transferIssue( targetRepo, issueId, username, + transferConfig, ) { + const authorizationConfig = getTransferAuthorizationConfig(transferConfig); const { source, target } = await graphql( ` query ( @@ -452,10 +522,22 @@ export async function transferIssue( }, ); + const allowedByTeam = + authorizationConfig.allowedTeamSlugs.length > 0 + ? await hasAllowedTeamMembership( + token, + owner, + username, + authorizationConfig.allowedTeamSlugs, + ) + : false; + if ( - !hasCommitterAccess( + !hasTransferAccess( getCollaboratorPermission(source, username), getCollaboratorPermission(target, username), + authorizationConfig.allowTriage, + allowedByTeam, ) ) { throw new Error( diff --git a/app/github.test.js b/app/github.test.js index 0607369..5593c0c 100644 --- a/app/github.test.js +++ b/app/github.test.js @@ -1,27 +1,47 @@ -import { hasCommitterAccess } from "./github.js"; +import { hasCommitterAccess, hasTransferAccess } from "./github.js"; describe("github", () => { describe("hasCommitterAccess", () => { test("returns true when source permission is write", () => { - const result = hasCommitterAccess("WRITE", "READ"); + const result = hasCommitterAccess("WRITE", "READ", false); expect(result).toBeTruthy(); }); test("returns true when target permission is maintain", () => { - const result = hasCommitterAccess("READ", "MAINTAIN"); + const result = hasCommitterAccess("READ", "MAINTAIN", false); expect(result).toBeTruthy(); }); test("returns true when target permission is admin", () => { - const result = hasCommitterAccess("READ", "ADMIN"); + const result = hasCommitterAccess("READ", "ADMIN", false); expect(result).toBeTruthy(); }); test("returns false when neither side has committer access", () => { - const result = hasCommitterAccess("TRIAGE", "READ"); + const result = hasCommitterAccess("TRIAGE", "READ", false); + + expect(result).toBeFalsy(); + }); + + test("returns true when triage is enabled and source has triage", () => { + const result = hasCommitterAccess("TRIAGE", "READ", true); + + expect(result).toBeTruthy(); + }); + }); + + describe("hasTransferAccess", () => { + test("returns true when allowed by team", () => { + const result = hasTransferAccess("READ", "READ", false, true); + + expect(result).toBeTruthy(); + }); + + test("returns false when no repository permission and no team access", () => { + const result = hasTransferAccess("READ", "READ", false, false); expect(result).toBeFalsy(); }); diff --git a/app/router.js b/app/router.js index f70673b..e2277dd 100644 --- a/app/router.js +++ b/app/router.js @@ -56,7 +56,7 @@ export async function router(auth, id, payload, verbose) { for (const command of commands) { const result = command.enabled(config); await (result.enabled - ? command.run(authToken) + ? command.run(authToken, config) : reportError(authToken, extractLabelableId(payload), result.error)); } } catch (error) {