Skip to content
Merged
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
86 changes: 86 additions & 0 deletions .github/scripts/assign.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Handles "/assign" and "/unassign" comments on issues (see .github/workflows/assign.yaml).
// It only compares the comment text with fixed strings: nothing from the comment is ever run.

const MESSAGES = {
assigned: 'Assigned to you. Open a PR within a week or comment if you need more time.',
alreadyYours: 'This one is already assigned to you. Open a PR within a week or comment if you need more time.',
taken: (owners) =>
`Thanks for offering to help! ${owners} is already working on this one. ` +
'Have a look at the issues labeled `good first issue` or `help wanted` for something free, ' +
'or comment here if you would like to team up.',
unassigned: 'Unassigned you. Thanks for letting us know! The issue is free for someone else now.',
notYours: (owners) =>
`Only the person working on this (${owners}) can use \`/unassign\` here. ` +
'If something is stuck, a maintainer can help.',
notAssigned: 'Nobody is assigned to this issue, so there is nothing to unassign. Comment `/assign` to take it.',
closed: 'This issue is closed, so there is nothing to pick up here. Thanks for offering!',
failed: 'Sorry, I could not assign you. A maintainer will take a look.',
};

/** Returns "assign", "unassign" or null. The whole comment must be the command. */
function parseCommand(body) {
const text = String(body || '').trim();
if (text === '/assign') return 'assign';
if (text === '/unassign') return 'unassign';
return null;
}

function mentionAll(logins) {
return logins.map((login) => `@${login}`).join(', ');
}

/**
* Decides what to do, without touching GitHub.
* Returns { action: "assign" | "unassign" | "none", reply: string | null }.
*/
function decide({ command, commenter, assignees, issueState }) {
const owners = assignees.map((a) => a.toLowerCase());
const me = commenter.toLowerCase();
if (issueState !== 'open') return { action: 'none', reply: MESSAGES.closed };
if (command === 'assign') {
if (owners.includes(me)) return { action: 'none', reply: MESSAGES.alreadyYours };
if (assignees.length > 0) return { action: 'none', reply: MESSAGES.taken(mentionAll(assignees)) };
return { action: 'assign', reply: MESSAGES.assigned };
}
if (command === 'unassign') {
if (owners.includes(me)) return { action: 'unassign', reply: MESSAGES.unassigned };
if (assignees.length > 0) return { action: 'none', reply: MESSAGES.notYours(mentionAll(assignees)) };
return { action: 'none', reply: MESSAGES.notAssigned };
}
return { action: 'none', reply: null };
}

/** Entry point for actions/github-script: `await require('./.github/scripts/assign.js')({ github, context, core })`. */
async function run({ github, context, core }) {
const { issue, comment } = context.payload;
if (!issue || !comment) return core.info('Not an issue comment.');
if (issue.pull_request) return core.info('Comment is on a pull request: ignored.');
if (comment.user.type === 'Bot') return core.info('Comment is from a bot: ignored.');
const command = parseCommand(comment.body);
if (!command) return core.info('Not a command.');

const { owner, repo } = context.repo;
const issue_number = issue.number;
const commenter = comment.user.login;
// The payload is a snapshot from when the comment was made; read the issue now so two quick claims see each other.
const { data: current } = await github.rest.issues.get({ owner, repo, issue_number });
const assignees = (current.assignees || []).map((a) => a.login);
const { action, reply } = decide({ command, commenter, assignees, issueState: current.state });
let text = reply;

if (action === 'assign') {
const { data } = await github.rest.issues.addAssignees({ owner, repo, issue_number, assignees: [commenter] });
// GitHub silently drops people it can't assign, so check that it worked.
const now = (data.assignees || []).map((a) => a.login.toLowerCase());
if (!now.includes(commenter.toLowerCase())) text = MESSAGES.failed;
} else if (action === 'unassign') {
await github.rest.issues.removeAssignees({ owner, repo, issue_number, assignees: [commenter] });
}
core.info(`/${command} by ${commenter} on #${issue_number}: ${action}`);
if (text) await github.rest.issues.createComment({ owner, repo, issue_number, body: `@${commenter} ${text}` });
}

module.exports = run;
module.exports.parseCommand = parseCommand;
module.exports.decide = decide;
module.exports.MESSAGES = MESSAGES;
145 changes: 145 additions & 0 deletions .github/scripts/assign.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Run with: node --test ".github/scripts/*.test.js"
const test = require('node:test');
const assert = require('node:assert/strict');
const run = require('./assign.js');
const { parseCommand, decide, MESSAGES } = run;

// `live` is the issue as GitHub has it now, which can differ from the comment's payload.
function fakeGithub({ assignable = true, live = null } = {}) {
const calls = [];
const github = {
rest: {
issues: {
get: async () => ({ data: live }),
addAssignees: async (args) => {
calls.push(['addAssignees', args]);
return { data: { assignees: assignable ? args.assignees.map((login) => ({ login })) : [] } };
},
removeAssignees: async (args) => calls.push(['removeAssignees', args]),
createComment: async (args) => calls.push(['createComment', args]),
},
},
};
return { github, calls };
}

const core = { info: () => {} };

// Runs the handler. GitHub's live issue matches the comment's payload unless the fake was given `live`.
function runWith(github, ctx) {
const { state, assignees } = ctx.payload.issue;
const get = github.rest.issues.get;
github.rest.issues.get = async (args) => ({ data: (await get(args)).data || { state, assignees } });
return run({ github, context: ctx, core });
}

function context({ body, login = 'maya-rao', assignees = [], state = 'open', pr = false, type = 'User' }) {
return {
repo: { owner: 'existence-master', repo: 'Sentient' },
payload: {
issue: {
number: 42,
state,
assignees: assignees.map((a) => ({ login: a })),
...(pr ? { pull_request: { url: 'https://api.github.com/x' } } : {}),
},
comment: { body, user: { login, type } },
},
};
}

test('only an exact command counts', () => {
assert.equal(parseCommand('/assign'), 'assign');
assert.equal(parseCommand(' /assign\n'), 'assign');
assert.equal(parseCommand('/unassign'), 'unassign');
assert.equal(parseCommand('/assign please'), null);
assert.equal(parseCommand('I will /assign myself'), null);
assert.equal(parseCommand('/assign @someone'), null);
assert.equal(parseCommand(''), null);
assert.equal(parseCommand(undefined), null);
});

test('decide covers every case', () => {
const free = { commenter: 'maya', assignees: [], issueState: 'open' };
assert.equal(decide({ ...free, command: 'assign' }).action, 'assign');
assert.equal(decide({ ...free, command: 'assign', assignees: ['Maya'] }).action, 'none');
assert.equal(decide({ ...free, command: 'assign', assignees: ['Maya'] }).reply, MESSAGES.alreadyYours);
const taken = decide({ ...free, command: 'assign', assignees: ['ravi'] });
assert.equal(taken.action, 'none');
assert.match(taken.reply, /@ravi is already working on this/);
assert.equal(decide({ ...free, command: 'unassign', assignees: ['maya'] }).action, 'unassign');
assert.match(decide({ ...free, command: 'unassign', assignees: ['ravi'] }).reply, /Only the person working on this \(@ravi\)/);
assert.equal(decide({ ...free, command: 'unassign' }).reply, MESSAGES.notAssigned);
assert.equal(decide({ ...free, command: 'assign', issueState: 'closed' }).reply, MESSAGES.closed);
});

test('/assign on a free issue assigns the commenter and says so', async () => {
const { github, calls } = fakeGithub();
await runWith(github, context({ body: '/assign' }));
assert.deepEqual(calls[0], ['addAssignees', { owner: 'existence-master', repo: 'Sentient', issue_number: 42, assignees: ['maya-rao'] }]);
assert.equal(calls[1][0], 'createComment');
assert.equal(calls[1][1].body, `@maya-rao ${MESSAGES.assigned}`);
assert.equal(calls.length, 2);
});

test('/assign on a taken issue only replies with who has it', async () => {
const { github, calls } = fakeGithub();
await runWith(github, context({ body: '/assign', assignees: ['ravi'] }));
assert.equal(calls.length, 1);
assert.equal(calls[0][0], 'createComment');
assert.match(calls[0][1].body, /^@maya-rao Thanks for offering to help! @ravi is already working on this one/);
});

test('/unassign by the assignee removes them', async () => {
const { github, calls } = fakeGithub();
await runWith(github, context({ body: '/unassign', assignees: ['maya-rao'] }));
assert.deepEqual(calls[0], ['removeAssignees', { owner: 'existence-master', repo: 'Sentient', issue_number: 42, assignees: ['maya-rao'] }]);
assert.equal(calls[1][1].body, `@maya-rao ${MESSAGES.unassigned}`);
});

test('/unassign by someone else changes nothing', async () => {
const { github, calls } = fakeGithub();
await runWith(github, context({ body: '/unassign', assignees: ['ravi'] }));
assert.equal(calls.length, 1);
assert.equal(calls[0][0], 'createComment');
});

test('comments on pull requests, from bots, and other text are ignored', async () => {
for (const ctx of [
context({ body: '/assign', pr: true }),
context({ body: '/assign', type: 'Bot', login: 'coderabbitai[bot]' }),
context({ body: 'looks good, /assign me later' }),
]) {
const { github, calls } = fakeGithub();
await runWith(github, ctx);
assert.equal(calls.length, 0);
}
});

test('a silently dropped assignment is reported, not claimed', async () => {
const { github, calls } = fakeGithub({ assignable: false });
await runWith(github, context({ body: '/assign' }));
assert.equal(calls[1][1].body, `@maya-rao ${MESSAGES.failed}`);
});

test('two quick /assign comments: the second sees the first claim', async () => {
// Both payloads say the issue is free, but by the time the second job runs GitHub has the first claimer.
const { github, calls } = fakeGithub({ live: { state: 'open', assignees: [{ login: 'ravi' }] } });
await runWith(github, context({ body: '/assign' }));
assert.equal(calls.length, 1);
assert.match(calls[0][1].body, /@ravi is already working on this one/);
});

test('an issue closed since the comment is not assigned', async () => {
const { github, calls } = fakeGithub({ live: { state: 'closed', assignees: [] } });
await runWith(github, context({ body: '/assign' }));
assert.deepEqual(calls.map((c) => c[0]), ['createComment']);
assert.equal(calls[0][1].body, `@maya-rao ${MESSAGES.closed}`);
});

test('the comment text never reaches the API or a reply', async () => {
const { github, calls } = fakeGithub();
const body = '/assign\n$(curl evil) ${{ secrets.X }}';
await runWith(github, context({ body }));
assert.equal(calls.length, 0);
});
Loading
Loading