diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index 03e344e..579f2c6 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -16,9 +16,14 @@ jobs: - name: 📦 Build run: | yarn build + - name: 🔑 Generate Token + uses: wow-actions/use-app-token@v2 + with: + app_id: ${{ secrets.APP_ID }} + private_key: ${{ secrets.PRIVATE_KEY }} - uses: ./ with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ env.BOT_TOKEN }} addReviewers: true addAssignees: true numberOfReviewers: 1 diff --git a/README.md b/README.md index 0bea86b..5664360 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,25 @@

Auto Assign

-

Automatically add reviewers/assignees to issues/PRs

+

Automatically add reviewers/assignees to issues/PRs

+ +## Features +- [Randomly](https://lodash.com/docs/#sampleSize) pick assignees and reviewers from candidate list. +- Automatically ignore invalid Github username. +- Automatically skip assigned issues/PRs and reviewer requested PRs. +- **Try-to** pick the member of team as assignee when adding [team](https://docs.github.com/en/organizations/organizing-members-into-teams/about-teams) to assignees. + + +**Note that** the default `${{ secrets.GITHUB_TOKEN }}` does not have the permission to **add teams as reviewers** or to **list members of a team**. As a workaround: + + - First, [create a personal access token (PAT)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) with `repo` and `admin:org` permissions. + - Then, make the PAT available to our actions by [adding the token as a secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets). + - Finally, replace the `GITHUB_TOKEN` with the new secret, e.g. `GITHUB_TOKEN: ${{ secrets.NAME_OF_MY_SECRET_CONTAINING_PAT_WITH_REPO_ACCESS }}` instead of `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`. + +Or with a cool but slightly cumbersome solution: create a private [github app](https://probot.github.io/) for your org with custom permissions and avatar, then [use the app token in out workflow](https://github.com/wow-actions/use-app-token), e.g. [wow-actions-bot](https://github.com/apps/wow-actions-bot). + + + + + ## Usage @@ -16,7 +36,7 @@ jobs: run: runs-on: ubuntu-latest steps: - - uses: wow-actions/auto-assign@v2 + - uses: wow-actions/auto-assign@v3 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # using the `org/team_slug` or `/team_slug` syntax to add git team as reviewers @@ -37,20 +57,19 @@ Various inputs are defined to let you configure the action: > Note: [Workflow command and parameter names are not case-sensitive](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#about-workflow-commands). -| Name | Description | Default | -|---------------------|-----------------------------------------------------------------------------------------------------------|---------| -| `GITHUB_TOKEN` | The GitHub token for authentication | N/A | -| `addReviewers` | Set to `true` to add reviewers to PRs. | `true` | -| `addAssignees` | Set to `true` to add assignees to issues/PRs. Set to `'author'` to add issue/PR's author as a assignee. | `true` | -| `reviewers` | A list of reviewers(GitHub user name) to be added to PR. | `[]` | -| `assignees` | A list of assignees(GitHub user name) to be added to issue/PR. Uses `reviewers` if not set. file | `[]` | -| `numberOfReviewers` | Number of reviewers added to the PR. Set `0` to add all the reviewers. | `0` | -| `numberOfAssignees` | Number of assignees added to the PR. Set `0` to add all the assignees. Uses `numberReviewers` if not set. | `0` | -| `skipDraft` | Set to `false` to run on draft PRs. | `true` | -| `skipKeywords` | A list of keywords to be skipped the process if issue/PR's title include it. | `[]` | -| `includeLabels` | Only to run when issue/PR has one of the label. | `[]` | -| `excludeLabels` | Not to run when issue/PR has one of the label. | `[]` | - +| Name | Description | Default | +|---------------------|-----------------------------------------------------------------------------------------------------------------|---------| +| `GITHUB_TOKEN` | The GitHub token for authentication | N/A | +| `addReviewers` | Set to `true` to add reviewers to PRs. | `true` | +| `addAssignees` | Set to `true` to add assignees to issues/PRs. | `true` | +| `reviewers` | Candidate list of reviewers(GitHub username) to be added to PR. | `[]` | +| `assignees` | Candidate list of assignees(GitHub user name) to be added to issue/PR. Uses `reviewers` if not set. | `[]` | +| `numberOfReviewers` | Number of reviewers added to the PR. Set `0` to add all the reviewers. | `0` | +| `numberOfAssignees` | Number of assignees added to the issue/PR. Set `0` to add all the assignees. Uses `numberReviewers` if not set. | `0` | +| `skipDraft` | Set to `false` to run on draft PRs. | `true` | +| `skipKeywords` | A list of keywords to be skipped the process if issue/PR's title include it. | `[]` | +| `includeLabels` | Only to run when issue/PR has one of the label. | `[]` | +| `excludeLabels` | Not to run when issue/PR has one of the label. | `[]` | ## License diff --git a/action.yml b/action.yml index 4169baa..518cba5 100644 --- a/action.yml +++ b/action.yml @@ -10,9 +10,9 @@ inputs: required: false default: true addAssignees: - description: Set to true to add assignees to PRs. Set to 'author' to add PR's author as a assignee. + description: Set to true to add assignees to PRs. required: false - default: author + default: true reviewers: description: A list of reviewers(GitHub user name) to be added to PRs. required: false diff --git a/package.json b/package.json index bb59317..2f37221 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "auto-assign", "description": "Automatically add reviewers/assignees to issues/PRs", - "version": "2.1.0", + "version": "3.0.0", "main": "dist/index.js", "repository": "https://github.com/wow-actions/auto-assign", "files": [ diff --git a/src/index.ts b/src/index.ts index 0635ae0..474f6e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,11 +6,13 @@ import { getInputs } from './inputs' async function run() { try { const { context } = github - const payload = context.payload.pull_request || context.payload.issue core.debug(`event: ${context.eventName}`) core.debug(`action: ${context.payload.action}`) + const pr = context.payload.pull_request + const issue = context.payload.issue + const payload = pr || issue const actions = ['opened', 'edited', 'labeled', 'unlabeled'] if ( payload && @@ -21,105 +23,64 @@ async function run() { const inputs = getInputs() core.debug(`inputs: \n${JSON.stringify(inputs, null, 2)}`) - if (context.payload.pull_request) { - if (payload.draft && inputs.skipDraft !== false) { - return util.skip('is draft') - } + if (pr && pr.draft && inputs.skipDraft !== false) { + return util.skip('is draft') } if ( inputs.skipKeywords && - inputs.skipKeywords.length && util.hasSkipKeywords(payload.title, inputs.skipKeywords) ) { return util.skip('title includes skip-keywords') } const octokit = util.getOctokit() - - const checkIncludeLabels = + const checkIncludings = inputs.includeLabels != null && inputs.includeLabels.length > 0 - const checkExcludeLabels = + const checkExcludings = inputs.excludeLabels != null && inputs.excludeLabels.length > 0 + if (checkIncludings || checkExcludings) { + const labels = await util.getIssueLabels(octokit, payload.number) + const hasAny = (arr: string[]) => labels.some((l) => arr.includes(l)) - if (checkIncludeLabels || checkExcludeLabels) { - const labelsRes = await octokit.rest.issues.listLabelsOnIssue({ - ...context.repo, - issue_number: payload.number, - per_page: 100, - }) - const labels = labelsRes.data.map((item) => item.name) - const hasAnyLabel = (inputs: string[]) => - labels.some((label) => inputs.includes(label)) - - if (checkIncludeLabels) { - const hasLabels = hasAnyLabel(inputs.includeLabels!) - if (!hasLabels) { + if (checkIncludings) { + const any = hasAny(inputs.includeLabels!) + if (!any) { return util.skip(`is not labeled with any of the "includeLabels"`) } } - if (checkExcludeLabels) { - const hasLabels = hasAnyLabel(inputs.excludeLabels!) - if (hasLabels) { + if (checkExcludings) { + const any = hasAny(inputs.excludeLabels!) + if (any) { return util.skip(`is labeled with one of the "excludeLabels"`) } } } - const owner = payload.user.login - - if (inputs.addReviewers && context.payload.pull_request) { - const { reviewers: candidates, teamReviewers } = util.chooseReviewers( - owner, - inputs, - ) - const reviewers: string[] = [] - for (let i = 0; i < candidates.length; i++) { - const username = candidates[i] - // eslint-disable-next-line no-await-in-loop - const valid = await util.isValidUser(octokit, username) - if (valid) { - reviewers.push(username) - } else { - core.info(`Ignored unknown reviewer "${username}"`) - } - } - - core.info(`Reviewers: ${JSON.stringify(reviewers, null, 2)}`) - core.info(`Team Reviewers: ${JSON.stringify(teamReviewers, null, 2)}`) - if (reviewers.length > 0 || teamReviewers.length > 0) { - await octokit.rest.pulls.requestReviewers({ - ...context.repo, - reviewers, - team_reviewers: teamReviewers, - pull_number: payload.number, - }) + const { assignees, teams, reviewers } = await util.getState(octokit) + if (teams.length || reviewers.length) { + const s = (len: number) => (len > 1 ? 's' : '') + const logTeams = `team_reviewer${s(teams.length)} "${teams.join(', ')}"` + const logReviewers = `reviewer${s(reviewers.length)} "${reviewers.join( + ', ', + )}"` + + if (teams.length && reviewers.length) { + util.skip(`has requested ${logReviewers} and ${logTeams}`) + } else if (teams.length) { + util.skip(`has requested ${logTeams}`) + } else { + util.skip(`has requested ${logReviewers}`) } + } else { + await util.addReviewers(octokit, inputs) } - if (inputs.addAssignees) { - const assignees: string[] = [] - const candidates = util.chooseAssignees(owner, inputs) - for (let i = 0; i < candidates.length; i++) { - const username = candidates[i] - // eslint-disable-next-line no-await-in-loop - const valid = await util.isValidUser(octokit, username) - if (valid) { - assignees.push(username) - } else { - core.info(`Ignored unknown assignee "${username}"`) - } - } - - core.info(`Assignees: ${JSON.stringify(assignees, null, 2)}`) - if (assignees.length > 0) { - await octokit.rest.issues.addAssignees({ - ...context.repo, - assignees, - issue_number: payload.number, - }) - } + if (assignees.length) { + util.skip(`has assigned to ${assignees.join(', ')}`) + } else { + await util.addAssignees(octokit, inputs) } } } catch (e) { diff --git a/src/inputs.ts b/src/inputs.ts index 22d3a21..7a2c69f 100644 --- a/src/inputs.ts +++ b/src/inputs.ts @@ -4,7 +4,7 @@ export function getInputs() { return parseInputs({ skipDraft: { type: 'boolean' }, addReviewers: { type: 'boolean', defaultValue: true }, - addAssignees: { type: 'booleanOrString', defaultValue: 'author' }, + addAssignees: { type: 'boolean', defaultValue: true }, reviewers: { type: 'words' }, assignees: { type: 'words' }, numberOfAssignees: { type: 'int', defaultValue: 0 }, diff --git a/src/util.ts b/src/util.ts index 8ea0f10..2f36ce7 100644 --- a/src/util.ts +++ b/src/util.ts @@ -3,9 +3,10 @@ import * as github from '@actions/github' import sampleSize from 'lodash.samplesize' import { Inputs } from './inputs' -export function getOctokit() { - const token = core.getInput('GITHUB_TOKEN', { required: true }) - return github.getOctokit(token) +export function skip(msg: string) { + const { context } = github + const type = context.payload.pull_request ? 'PR' : 'issue' + core.info(`Skip to run since the ${type} ${msg}`) } export function isValidEvent(event: string, action?: string | string[]) { @@ -21,18 +22,13 @@ export function isValidEvent(event: string, action?: string | string[]) { return false } -export async function isValidUser( - octokit: ReturnType, - username: string, -) { - try { - const res = await octokit.rest.users.getByUsername({ username }) - return res.status === 200 && res.data.id > 0 - } catch (error) { - return false - } +export function getOctokit() { + const token = core.getInput('GITHUB_TOKEN', { required: true }) + return github.getOctokit(token) } +type Octokit = ReturnType + export function hasSkipKeywords(title: string, keywords: string[]): boolean { const titleLowerCase = title.toLowerCase() // eslint-disable-next-line no-restricted-syntax @@ -45,6 +41,54 @@ export function hasSkipKeywords(title: string, keywords: string[]): boolean { return false } +async function isValidUser(octokit: Octokit, username: string) { + try { + const res = await octokit.rest.users.getByUsername({ username }) + return res.status === 200 && res.data.id > 0 + } catch (error) { + return false + } +} + +export async function getIssueLabels(octokit: Octokit, issueNumber: number) { + const { context } = github + const res = await octokit.rest.issues.listLabelsOnIssue({ + ...context.repo, + issue_number: issueNumber, + per_page: 100, + }) + return res.data.map((item) => item.name) +} + +export async function getState(octokit: Octokit) { + const { context } = github + const pr = context.payload.pull_request + const issue = context.payload.issue + let assignees: string[] + let teams: string[] + let reviewers: string[] + + if (pr) { + const { data } = await octokit.rest.pulls.get({ + ...context.repo, + pull_number: pr.number, + }) + teams = data.requested_teams ? data.requested_teams.map((t) => t.slug) : [] + reviewers = data.requested_reviewers + ? data.requested_reviewers.map((u) => u.login) + : [] + assignees = data.assignees ? data.assignees.map((u) => u.login) : [] + } else if (issue) { + const { data } = await octokit.rest.issues.get({ + ...context.repo, + issue_number: issue.number, + }) + assignees = data.assignees ? data.assignees.map((u) => u.login) : [] + } + + return { assignees: assignees!, teams: teams!, reviewers: reviewers! } +} + function chooseUsers(candidates: string[], count: number, filterUser: string) { const { teams, users } = candidates.reduce<{ teams: string[] @@ -81,7 +125,7 @@ function chooseUsers(candidates: string[], count: number, filterUser: string) { } } -export function chooseReviewers( +function chooseReviewers( owner: string, inputs: Inputs, ): { @@ -96,30 +140,123 @@ export function chooseReviewers( } } -export function chooseAssignees(owner: string, inputs: Inputs): string[] { - const { - addAssignees, - assignees, - reviewers, - numberOfAssignees, - numberOfReviewers, - } = inputs - if (typeof addAssignees === 'string') { - if (addAssignees !== 'author') { - throw new Error( - "Error in configuration file to do with using `addAssignees`. Expected `addAssignees` variable to be either boolean or 'author'", - ) +export async function addReviewers(octokit: Octokit, inputs: Inputs) { + const pr = github.context.payload.pull_request + if (!inputs.addAssignees || !pr) { + return + } + + core.info('') + core.info(`Adding reviewers for pr #[${pr.number}]`) + const owner = pr.user.login + const { reviewers: candidates, teamReviewers } = chooseReviewers( + owner, + inputs, + ) + const reviewers: string[] = [] + for (let i = 0; i < candidates.length; i++) { + const username = candidates[i] + // eslint-disable-next-line no-await-in-loop + const valid = await isValidUser(octokit, username) + if (valid) { + reviewers.push(username) + } else { + core.info(` ignored unknown reviewer: "${username}"`) } - return [owner] } + core.info(` add reviewers: [${reviewers.join(', ')}]`) + core.info(` add team_reviewers: [${teamReviewers.join(', ')}]`) + + if (reviewers.length > 0 || teamReviewers.length > 0) { + await octokit.rest.pulls.requestReviewers({ + ...github.context.repo, + reviewers, + team_reviewers: teamReviewers, + pull_number: pr.number, + }) + } +} + +async function getTeamMembers(octokit: Octokit, team: string) { + const { context } = github + const parts = team.split('/') + const org = parts[0] || context.repo.owner + const slug = parts[1]! + const res = await octokit.rest.teams.listMembersInOrg({ + org, + team_slug: slug, + per_page: 100, + }) + return res.data.map((item) => item.login) +} + +async function chooseAssignees( + octokit: Octokit, + owner: string, + inputs: Inputs, +) { + const { assignees, reviewers, numberOfAssignees, numberOfReviewers } = inputs const count = numberOfAssignees || numberOfReviewers || 0 const candidates = assignees || reviewers || [] - return chooseUsers(candidates, count, owner).users + const users: string[] = [] + const teams: string[] = [] + + candidates.forEach((item) => { + if (item.includes('/')) { + teams.push(item) + } else { + users.push(item) + } + }) + + try { + for (let i = 0; i < teams.length; i++) { + // eslint-disable-next-line no-await-in-loop + const members = await getTeamMembers(octokit, teams[i]) + users.push(...members) + } + } catch (error) { + core.info('failed to get team members') + } + core.debug(`assignee candidates: [${users.join(', ')}]`) + return chooseUsers(users, count, owner).users } -export function skip(msg: string) { +export async function addAssignees(octokit: Octokit, inputs: Inputs) { + if (!inputs.addAssignees) { + return + } + const { context } = github - const type = context.payload.pull_request ? 'PR' : 'issue' - core.info(`Skip to run since the ${type} ${msg}`) + const pr = context.payload.pull_request + const issue = context.payload.issue + const payload = (pr || issue)! + + core.info('') + core.info(`Adding assignees for ${pr ? 'pr' : 'issue'} #[${payload.number}]`) + + const owner = payload.user.login + const assignees: string[] = [] + const candidates = await chooseAssignees(octokit, owner, inputs) + for (let i = 0; i < candidates.length; i++) { + const username = candidates[i] + // eslint-disable-next-line no-await-in-loop + const valid = await isValidUser(octokit, username) + if (valid) { + assignees.push(username) + } else { + core.info(` ignored unknown assignee: "${username}"`) + } + } + + core.info(` add assignees: [${assignees.join(', ')}]`) + + if (assignees.length > 0) { + await octokit.rest.issues.addAssignees({ + ...context.repo, + assignees, + issue_number: payload.number, + }) + } }