diff --git a/README.md b/README.md index 9d911de3a..d02ca95ee 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,17 @@ ## Inputs -| Parameter | Type | Required | Default | Description | -| --------------------------- | ------- | ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `assignees` | String | only if `teams` is not specified | n/a | Comma separated list of user names. Issue will be assigned to those users. | -| `teams` | String | only if `assignees` is not specified | n/a | Comma separated list of team names without the org prefix. Issue will be assigned to the team members.

**Important Requirement:** if using the `teams` input parameter, you need to use a personal access token with `read:org` scope (the default `GITHUB_TOKEN` is not enough). | -| `numOfAssignee` | Number | false | n/a | Number of assignees that will be randomly picked from the teams or assignees. If not specified, assigns all users. | -| `abortIfPreviousAssignees` | Boolean | false | false | Flag that aborts the action if there were assignees previously. | -| `removePreviousAssignees` | Boolean | false | false | Flag that removes assignees before assigning them (useful the issue is reasigned). | -| `allowNoAssignees` | Boolean | false | false | Flag that prevents the action from failing when there are no assignees. | -| `allowSelfAssign` | Boolean | false | true | Flag that allows self-assignment to the issue author.

This flag is ignored when working with PRs as self assigning a PR for review is forbidden by GitHub. | -| `issueNumber` | Number | false | n/a | Allows to override the issue number. This can be useful when context is missing. | -| `teamIsPullRequestReviewer` | Boolean | false | false | Sets team as the PR reviewer instead of a member of the team. | +| Parameter | Type | Required | Default | Description | +| --------------------------- | ------- | ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `assignees` | String | only if `teams` is not specified | n/a | Comma separated list of user names with optional [weights](#working-with-weighted-assignements). Issue will be assigned to those users. | +| `teams` | String | only if `assignees` is not specified | n/a | Comma separated list of team names without the org prefix with optional [weights](#working-with-weighted-assignements). Issue will be assigned to the team members.

**Important Requirement:** if using the `teams` input parameter, you need to use a personal access token with `read:org` scope (the default `GITHUB_TOKEN` is not enough). | +| `numOfAssignee` | Number | false | n/a | Number of assignees that will be randomly picked from the teams or assignees. If not specified, assigns all users. | +| `abortIfPreviousAssignees` | Boolean | false | false | Flag that aborts the action if there were assignees previously. | +| `removePreviousAssignees` | Boolean | false | false | Flag that removes assignees before assigning them (useful the issue is reasigned). | +| `allowNoAssignees` | Boolean | false | false | Flag that prevents the action from failing when there are no assignees. | +| `allowSelfAssign` | Boolean | false | true | Flag that allows self-assignment to the issue author.

This flag is ignored when working with PRs as self assigning a PR for review is forbidden by GitHub. | +| `issueNumber` | Number | false | n/a | Allows to override the issue number. This can be useful when context is missing. | +| `teamIsPullRequestReviewer` | Boolean | false | false | Sets team as the PR reviewer instead of a member of the team. | ## Examples @@ -69,6 +69,30 @@ jobs: numOfAssignee: 1 ``` +### Working with weighted assignements + +When specifying `assignees` or `teams` values, you may provide weights to balance the randomness of the selection. +The following formats are supported: + +```yml +# No weights specified (same weight for all items) +assignees: a, b, c +# Weights specified +assignees: a:1, b:5, c:2 +# Some weights specified (item weight defaults to 1 when not specified) +assignees: a, b:2, c +``` + +Let's look at a practical example: + +```yml +assignees: octocat:4,cat +``` + +- `octocat` has a weight of `4`. +- `cat` has a weight of `1` (default value). +- `octocat` has 4 chances out of 5 to be selected. + ### Working with Project Cards > [!WARNING] diff --git a/src/__tests__/utils.test.js b/src/__tests__/utils.test.js index b64675eb7..03e37b596 100644 --- a/src/__tests__/utils.test.js +++ b/src/__tests__/utils.test.js @@ -1,6 +1,6 @@ const { + parseAssignments, parseIntInput, - parseCsvInput, pickNRandomFromArray, getTeamMembers } = require('../utils.js'); @@ -25,6 +25,45 @@ describe('utils', () => { jest.clearAllMocks(); }); + describe('parseAssignments', () => { + it('works when value is missing', async () => { + const values = parseAssignments(''); + expect(values).toStrictEqual([]); + }); + + it('works with string list', async () => { + const values = parseAssignments('a,b,c'); + expect(values).toStrictEqual(['a', 'b', 'c']); + }); + + it('works with some missing values and whitespace', async () => { + const values = parseAssignments(',a ,, , b,c,,'); + expect(values).toStrictEqual(['a', 'b', 'c']); + }); + + it('works with weighted list', async () => { + const values = parseAssignments('a:1,b:2,c:3'); + expect(values).toStrictEqual(['a', 'b', 'b', 'c', 'c', 'c']); + }); + + it('works with semi weighted list', async () => { + const values = parseAssignments('a,b:2,c'); + expect(values).toStrictEqual(['a', 'b', 'b', 'c']); + }); + + it('fails when too many arguments', async () => { + expect(() => parseAssignments('a:1:unknown')).toThrow( + /Invalid assignment value/ + ); + }); + + it('fails when weight is invalid', async () => { + expect(() => parseAssignments('a:invalid')).toThrow( + /Invalid weight value/ + ); + }); + }); + describe('parseIntInput', () => { it('works when value is a number', async () => { expect(parseIntInput('3', 0)).toBe(3); @@ -41,24 +80,6 @@ describe('utils', () => { }); }); - describe('parseCsvInput', () => { - it('works when value is a CSV', async () => { - expect(parseCsvInput('1,2,3')).toStrictEqual(['1', '2', '3']); - }); - - it('works when value is missing', async () => { - expect(parseCsvInput('')).toStrictEqual([]); - }); - - it('works with some missing values and whitespace', async () => { - expect(parseCsvInput(',1 ,, , 2,3,,')).toStrictEqual([ - '1', - '2', - '3' - ]); - }); - }); - describe('pickNRandomFromArray', () => { it('works when selection size < array length', () => { const result = pickNRandomFromArray([1, 2, 3, 4], 2); diff --git a/src/action.js b/src/action.js index f1c627c18..9f9ab452d 100644 --- a/src/action.js +++ b/src/action.js @@ -104,15 +104,9 @@ const runAction = async (octokit, context, parameters) => { newAssignees = newAssignees.concat(teamMembers); } - // Remove duplicates from assignees - newAssignees = [...new Set(newAssignees)]; - // Remove author if allowSelfAssign is false if (!allowSelfAssign) { - const foundIndex = newAssignees.indexOf(author); - if (foundIndex !== -1) { - newAssignees.splice(foundIndex, 1); - } + newAssignees = newAssignees.filter((name) => name !== author); } // Check if there are assignees left @@ -120,6 +114,9 @@ const runAction = async (octokit, context, parameters) => { // Select random assignees if (numOfAssignee) { newAssignees = pickNRandomFromArray(newAssignees, numOfAssignee); + } else { + // Remove duplicates from assignees + newAssignees = [...new Set(newAssignees)]; } // Assign issue diff --git a/src/index.js b/src/index.js index 3533b92ec..f3481207f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,15 +1,15 @@ const core = require('@actions/core'); const github = require('@actions/github'); const { runAction } = require('./action'); -const { parseIntInput, parseCsvInput } = require('./utils'); +const { parseIntInput, parseAssignments } = require('./utils'); try { // Get params const gitHubToken = core.getInput('repo-token', { required: true }); - const assignees = parseCsvInput( + const assignees = parseAssignments( core.getInput('assignees', { required: false }) ); - const teams = parseCsvInput(core.getInput('teams', { required: false })); + const teams = parseAssignments(core.getInput('teams', { required: false })); let numOfAssignee; try { numOfAssignee = parseIntInput( diff --git a/src/utils.js b/src/utils.js index b23965a0b..594aff98c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -5,6 +5,31 @@ const parseCsvInput = (valueString) => { .filter((item) => item !== ''); }; +const parseAssignments = (valueString) => { + const list = parseCsvInput(valueString); + const weightedList = []; + list.forEach((item) => { + const itemValues = item.split(':'); + const name = itemValues[0]; + let weight = 1; + if (itemValues.length === 2) { + try { + weight = parseIntInput(itemValues[1]); + } catch (e) { + throw new Error( + `Invalid weight value for ${name} assignment: ${itemValues[1]}` + ); + } + } else if (itemValues.length > 2) { + throw new Error(`Invalid assignment value: ${valueString}`); + } + for (let i = 0; i < weight; i++) { + weightedList.push(name); + } + }); + return weightedList; +}; + const parseIntInput = (valueString, defaultValue = 0) => { let value = defaultValue; if (valueString) { @@ -20,11 +45,13 @@ const pickNRandomFromArray = (arr, n) => { if (arr.length === 0) { throw new Error('Can not pick random from empty list.'); } - const available = [...arr]; + let available = [...arr]; const result = []; for (let i = 0; i < n && available.length > 0; i++) { const randomIndex = Math.floor(Math.random() * available.length); - result.push(available.splice(randomIndex, 1)[0]); + const pick = available[randomIndex]; + result.push(pick); + available = available.filter((value) => value !== pick); } return result; }; @@ -133,7 +160,7 @@ const removeAllReviewers = async (octokit, owner, repo, pull_number) => { }; module.exports = { - parseCsvInput, + parseAssignments, parseIntInput, pickNRandomFromArray, getAssignees,