Skip to content

Commit 6594700

Browse files
committed
feat: weighted assignments
1 parent 3ffd84c commit 6594700

5 files changed

Lines changed: 112 additions & 43 deletions

File tree

README.md

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@
44

55
## Inputs
66

7-
| Parameter | Type | Required | Default | Description |
8-
| --------------------------- | ------- | ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
9-
| `assignees` | String | only if `teams` is not specified | n/a | Comma separated list of user names. Issue will be assigned to those users. |
10-
| `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). |
11-
| `numOfAssignee` | Number | false | n/a | Number of assignees that will be randomly picked from the teams or assignees. If not specified, assigns all users. |
12-
| `abortIfPreviousAssignees` | Boolean | false | false | Flag that aborts the action if there were assignees previously. |
13-
| `removePreviousAssignees` | Boolean | false | false | Flag that removes assignees before assigning them (useful the issue is reasigned). |
14-
| `allowNoAssignees` | Boolean | false | false | Flag that prevents the action from failing when there are no assignees. |
15-
| `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. |
16-
| `issueNumber` | Number | false | n/a | Allows to override the issue number. This can be useful when context is missing. |
17-
| `teamIsPullRequestReviewer` | Boolean | false | false | Sets team as the PR reviewer instead of a member of the team. |
7+
| Parameter | Type | Required | Default | Description |
8+
| --------------------------- | ------- | ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9+
| `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. |
10+
| `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). |
11+
| `numOfAssignee` | Number | false | n/a | Number of assignees that will be randomly picked from the teams or assignees. If not specified, assigns all users. |
12+
| `abortIfPreviousAssignees` | Boolean | false | false | Flag that aborts the action if there were assignees previously. |
13+
| `removePreviousAssignees` | Boolean | false | false | Flag that removes assignees before assigning them (useful the issue is reasigned). |
14+
| `allowNoAssignees` | Boolean | false | false | Flag that prevents the action from failing when there are no assignees. |
15+
| `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. |
16+
| `issueNumber` | Number | false | n/a | Allows to override the issue number. This can be useful when context is missing. |
17+
| `teamIsPullRequestReviewer` | Boolean | false | false | Sets team as the PR reviewer instead of a member of the team. |
1818

1919
## Examples
2020

@@ -69,6 +69,30 @@ jobs:
6969
numOfAssignee: 1
7070
```
7171

72+
### Working with weighted assignements
73+
74+
When specifying `assignees` or `teams` values, you may provide weights to balance the randomness of the selection.
75+
The following formats are supported:
76+
77+
```yml
78+
# No weights specified (same weight for all items)
79+
assignees: a, b, c
80+
# Weights specified
81+
assignees: a:1, b:5, c:2
82+
# Some weights specified (item weight defaults to 1 when not specified)
83+
assignees: a, b:2, c
84+
```
85+
86+
Let's look at a practical example:
87+
88+
```yml
89+
assignees: octocat:4,cat
90+
```
91+
92+
- `octocat` has a weight of `4`.
93+
- `cat` has a weight of `1` (default value).
94+
- `octocat` has 4 chances out of 5 to be selected.
95+
7296
### Working with Project Cards
7397

7498
> [!WARNING]

src/__tests__/utils.test.js

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const {
2+
parseAssignments,
23
parseIntInput,
3-
parseCsvInput,
44
pickNRandomFromArray,
55
getTeamMembers
66
} = require('../utils.js');
@@ -25,6 +25,45 @@ describe('utils', () => {
2525
jest.clearAllMocks();
2626
});
2727

28+
describe('parseAssignments', () => {
29+
it('works when value is missing', async () => {
30+
const values = parseAssignments('');
31+
expect(values).toStrictEqual([]);
32+
});
33+
34+
it('works with string list', async () => {
35+
const values = parseAssignments('a,b,c');
36+
expect(values).toStrictEqual(['a', 'b', 'c']);
37+
});
38+
39+
it('works with some missing values and whitespace', async () => {
40+
const values = parseAssignments(',a ,, , b,c,,');
41+
expect(values).toStrictEqual(['a', 'b', 'c']);
42+
});
43+
44+
it('works with weighted list', async () => {
45+
const values = parseAssignments('a:1,b:2,c:3');
46+
expect(values).toStrictEqual(['a', 'b', 'b', 'c', 'c', 'c']);
47+
});
48+
49+
it('works with semi weighted list', async () => {
50+
const values = parseAssignments('a,b:2,c');
51+
expect(values).toStrictEqual(['a', 'b', 'b', 'c']);
52+
});
53+
54+
it('fails when too many arguments', async () => {
55+
expect(() => parseAssignments('a:1:unknown')).toThrow(
56+
/Invalid assignment value/
57+
);
58+
});
59+
60+
it('fails when weight is invalid', async () => {
61+
expect(() => parseAssignments('a:invalid')).toThrow(
62+
/Invalid weight value/
63+
);
64+
});
65+
});
66+
2867
describe('parseIntInput', () => {
2968
it('works when value is a number', async () => {
3069
expect(parseIntInput('3', 0)).toBe(3);
@@ -41,24 +80,6 @@ describe('utils', () => {
4180
});
4281
});
4382

44-
describe('parseCsvInput', () => {
45-
it('works when value is a CSV', async () => {
46-
expect(parseCsvInput('1,2,3')).toStrictEqual(['1', '2', '3']);
47-
});
48-
49-
it('works when value is missing', async () => {
50-
expect(parseCsvInput('')).toStrictEqual([]);
51-
});
52-
53-
it('works with some missing values and whitespace', async () => {
54-
expect(parseCsvInput(',1 ,, , 2,3,,')).toStrictEqual([
55-
'1',
56-
'2',
57-
'3'
58-
]);
59-
});
60-
});
61-
6283
describe('pickNRandomFromArray', () => {
6384
it('works when selection size < array length', () => {
6485
const result = pickNRandomFromArray([1, 2, 3, 4], 2);

src/action.js

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,22 +104,19 @@ const runAction = async (octokit, context, parameters) => {
104104
newAssignees = newAssignees.concat(teamMembers);
105105
}
106106

107-
// Remove duplicates from assignees
108-
newAssignees = [...new Set(newAssignees)];
109-
110107
// Remove author if allowSelfAssign is false
111108
if (!allowSelfAssign) {
112-
const foundIndex = newAssignees.indexOf(author);
113-
if (foundIndex !== -1) {
114-
newAssignees.splice(foundIndex, 1);
115-
}
109+
newAssignees = newAssignees.filter((name) => name !== author);
116110
}
117111

118112
// Check if there are assignees left
119113
if (newAssignees.length > 0) {
120114
// Select random assignees
121115
if (numOfAssignee) {
122116
newAssignees = pickNRandomFromArray(newAssignees, numOfAssignee);
117+
} else {
118+
// Remove duplicates from assignees
119+
newAssignees = [...new Set(newAssignees)];
123120
}
124121

125122
// Assign issue

src/index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
const core = require('@actions/core');
22
const github = require('@actions/github');
33
const { runAction } = require('./action');
4-
const { parseIntInput, parseCsvInput } = require('./utils');
4+
const { parseIntInput, parseAssignments } = require('./utils');
55

66
try {
77
// Get params
88
const gitHubToken = core.getInput('repo-token', { required: true });
9-
const assignees = parseCsvInput(
9+
const assignees = parseAssignments(
1010
core.getInput('assignees', { required: false })
1111
);
12-
const teams = parseCsvInput(core.getInput('teams', { required: false }));
12+
const teams = parseAssignments(core.getInput('teams', { required: false }));
1313
let numOfAssignee;
1414
try {
1515
numOfAssignee = parseIntInput(

src/utils.js

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ const parseCsvInput = (valueString) => {
55
.filter((item) => item !== '');
66
};
77

8+
const parseAssignments = (valueString) => {
9+
const list = parseCsvInput(valueString);
10+
const weightedList = [];
11+
list.forEach((item) => {
12+
const itemValues = item.split(':');
13+
const name = itemValues[0];
14+
let weight = 1;
15+
if (itemValues.length === 2) {
16+
try {
17+
weight = parseIntInput(itemValues[1]);
18+
} catch (e) {
19+
throw new Error(
20+
`Invalid weight value for ${name} assignment: ${itemValues[1]}`
21+
);
22+
}
23+
} else if (itemValues.length > 2) {
24+
throw new Error(`Invalid assignment value: ${valueString}`);
25+
}
26+
for (let i = 0; i < weight; i++) {
27+
weightedList.push(name);
28+
}
29+
});
30+
return weightedList;
31+
};
32+
833
const parseIntInput = (valueString, defaultValue = 0) => {
934
let value = defaultValue;
1035
if (valueString) {
@@ -20,11 +45,13 @@ const pickNRandomFromArray = (arr, n) => {
2045
if (arr.length === 0) {
2146
throw new Error('Can not pick random from empty list.');
2247
}
23-
const available = [...arr];
48+
let available = [...arr];
2449
const result = [];
2550
for (let i = 0; i < n && available.length > 0; i++) {
2651
const randomIndex = Math.floor(Math.random() * available.length);
27-
result.push(available.splice(randomIndex, 1)[0]);
52+
const pick = available[randomIndex];
53+
result.push(pick);
54+
available = available.filter((value) => value !== pick);
2855
}
2956
return result;
3057
};
@@ -133,7 +160,7 @@ const removeAllReviewers = async (octokit, owner, repo, pull_number) => {
133160
};
134161

135162
module.exports = {
136-
parseCsvInput,
163+
parseAssignments,
137164
parseIntInput,
138165
pickNRandomFromArray,
139166
getAssignees,

0 commit comments

Comments
 (0)