Skip to content

Commit

Permalink
feat: weighted assignments
Browse files Browse the repository at this point in the history
  • Loading branch information
pozil committed Apr 4, 2024
1 parent 3ffd84c commit 6594700
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 43 deletions.
46 changes: 35 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br/><br/>**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.<br/><br/>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.<br/><br/>**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.<br/><br/>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

Expand Down Expand Up @@ -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]
Expand Down
59 changes: 40 additions & 19 deletions src/__tests__/utils.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const {
parseAssignments,
parseIntInput,
parseCsvInput,
pickNRandomFromArray,
getTeamMembers
} = require('../utils.js');
Expand All @@ -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);
Expand All @@ -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);
Expand Down
11 changes: 4 additions & 7 deletions src/action.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,22 +104,19 @@ 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
if (newAssignees.length > 0) {
// Select random assignees
if (numOfAssignee) {
newAssignees = pickNRandomFromArray(newAssignees, numOfAssignee);
} else {
// Remove duplicates from assignees
newAssignees = [...new Set(newAssignees)];
}

// Assign issue
Expand Down
6 changes: 3 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
33 changes: 30 additions & 3 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
};
Expand Down Expand Up @@ -133,7 +160,7 @@ const removeAllReviewers = async (octokit, owner, repo, pull_number) => {
};

module.exports = {
parseCsvInput,
parseAssignments,
parseIntInput,
pickNRandomFromArray,
getAssignees,
Expand Down

0 comments on commit 6594700

Please sign in to comment.