Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -134,6 +136,8 @@ _Note: If a team exists but the team doesn't have read access to the repository
### /transfer <destination_repo>

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

Expand Down
5 changes: 4 additions & 1 deletion app/commands/transfer-command.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -43,6 +44,8 @@ export class TransferCommand extends Command {
sourceRepo,
targetRepo,
extractLabelableId(this.payload),
this.payload.sender.login,
transferConfig,
);
} catch (error) {
logger.error(
Expand Down
2 changes: 2 additions & 0 deletions app/default-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const defaultConfig = {
},
transfer: {
enabled: false,
allowTriage: false,
allowedTeamSlugs: [],
},
},
};
140 changes: 138 additions & 2 deletions app/github.js
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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!) {
Expand Down
49 changes: 49 additions & 0 deletions app/github.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
2 changes: 1 addition & 1 deletion app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down