diff --git a/README.md b/README.md index 46fcb82..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,6 +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 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 18cd95f..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, @@ -43,6 +44,8 @@ export class TransferCommand extends Command { sourceRepo, 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 7e25477..c9459ec 100644 --- a/app/github.js +++ b/app/github.js @@ -1,5 +1,90 @@ 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( + (edge) => edge?.node?.login?.toLowerCase() === username.toLowerCase(), + ); + + return collaborator?.permission; +} + +export function hasCommitterAccess( + sourcePermission, + targetPermission, + allowTriage, +) { + const allowedPermissions = allowTriage + ? new Set([...committerPermissions, triagePermission]) + : committerPermissions; + + return ( + 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( @@ -391,24 +476,75 @@ export async function transferIssue( sourceRepo, targetRepo, issueId, + username, + transferConfig, ) { - const { target } = await graphql( + const authorizationConfig = getTransferAuthorizationConfig(transferConfig); + 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}`, }, }, ); + const allowedByTeam = + authorizationConfig.allowedTeamSlugs.length > 0 + ? await hasAllowedTeamMembership( + token, + owner, + username, + authorizationConfig.allowedTeamSlugs, + ) + : false; + + if ( + !hasTransferAccess( + getCollaboratorPermission(source, username), + getCollaboratorPermission(target, username), + authorizationConfig.allowTriage, + allowedByTeam, + ) + ) { + 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..5593c0c --- /dev/null +++ b/app/github.test.js @@ -0,0 +1,49 @@ +import { hasCommitterAccess, hasTransferAccess } from "./github.js"; + +describe("github", () => { + describe("hasCommitterAccess", () => { + test("returns true when source permission is write", () => { + const result = hasCommitterAccess("WRITE", "READ", false); + + expect(result).toBeTruthy(); + }); + + test("returns true when target permission is maintain", () => { + const result = hasCommitterAccess("READ", "MAINTAIN", false); + + expect(result).toBeTruthy(); + }); + + test("returns true when target permission is admin", () => { + const result = hasCommitterAccess("READ", "ADMIN", false); + + expect(result).toBeTruthy(); + }); + + test("returns false when neither side has committer access", () => { + 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) {