Skip to content

Commit a875058

Browse files
committed
add new functionality and tests
add “remove comment” enable de-dupling of “add comment” split all tasks into individual subtasks
1 parent 4bcea86 commit a875058

File tree

6 files changed

+494
-103
lines changed

6 files changed

+494
-103
lines changed

action.js

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
const core = require('@actions/core');
2+
const github = require('@actions/github');
3+
const asana = require('asana');
4+
5+
async function moveSection(client, taskId, targets) {
6+
const task = await client.tasks.findById(taskId);
7+
8+
targets.forEach(async target => {
9+
const targetProject = task.projects.find(project => project.name === target.project);
10+
if (!targetProject) {
11+
core.info(`This task does not exist in "${target.project}" project`);
12+
return;
13+
}
14+
let targetSection = await client.sections.findByProject(targetProject.gid)
15+
.then(sections => sections.find(section => section.name === target.section));
16+
if (targetSection) {
17+
await client.sections.addTask(targetSection.gid, { task: taskId });
18+
core.info(`Moved to: ${target.project}/${target.section}`);
19+
} else {
20+
core.error(`Asana section ${target.section} not found.`);
21+
}
22+
});
23+
}
24+
25+
async function findComment(client, taskId, commentId) {
26+
let stories;
27+
try {
28+
const storiesCollection = await client.tasks.stories(taskId);
29+
stories = await storiesCollection.fetch(200);
30+
} catch (error) {
31+
throw error;
32+
}
33+
34+
return stories.find(story => story.text.indexOf(commentId) !== -1);
35+
}
36+
37+
async function addComment(client, taskId, commentId, text, isPinned) {
38+
if(commentId){
39+
text += '\n'+commentId+'\n';
40+
}
41+
try {
42+
const comment = await client.tasks.addComment(taskId, {
43+
text: text,
44+
is_pinned: isPinned,
45+
});
46+
return comment;
47+
} catch (error) {
48+
console.error('rejecting promise', error);
49+
}
50+
}
51+
52+
module.exports = async function action() {
53+
const
54+
ASANA_PAT = core.getInput('asana-pat', {required: true}),
55+
ACTION = core.getInput('action', {required: true}),
56+
TRIGGER_PHRASE = core.getInput('trigger-phrase') || '',
57+
PULL_REQUEST = github.context.payload.pull_request,
58+
REGEX_STRING = `${TRIGGER_PHRASE}(?:\s*)https:\\/\\/app.asana.com\\/(\\d+)\\/(?<project>\\d+)\\/(?<task>\\d+)`,
59+
REGEX = new RegExp(REGEX_STRING,'g')
60+
;
61+
62+
console.log('pull_request', PULL_REQUEST);
63+
64+
const client = await asana.Client.create({
65+
defaultHeaders: { 'asana-enable': 'new-sections,string_ids' },
66+
logAsanaChangeWarnings: false
67+
}).useAccessToken(ASANA_PAT).authorize();
68+
69+
if(client === null){
70+
throw new Error('client authorization failed');
71+
}
72+
73+
console.info('looking in body', PULL_REQUEST.body, 'regex', REGEX_STRING);
74+
let foundAsanaTasks = [];
75+
while ((parseAsanaURL = REGEX.exec(PULL_REQUEST.body)) !== null) {
76+
const taskId = parseAsanaURL.groups.task;
77+
if (!taskId) {
78+
core.error(`Invalid Asana task URL after the trigger phrase ${TRIGGER_PHRASE}`);
79+
continue;
80+
}
81+
foundAsanaTasks.push(taskId);
82+
}
83+
console.info(`found ${foundAsanaTasks.length} taskIds:`, foundAsanaTasks.join(','));
84+
85+
console.info('calling', ACTION);
86+
switch(ACTION){
87+
case 'assert-link': {
88+
const githubToken = core.getInput('github-token', {required: true});
89+
const octokit = new github.GitHub(githubToken);
90+
const statusState = (foundAsanaTasks.length > 0) ? 'success' : 'error';
91+
core.info(`setting ${statusState} for ${github.context.payload.pull_request.head.sha}`);
92+
octokit.repos.createStatus({
93+
...github.context.repo,
94+
'context': 'asana-link-presence',
95+
'state': statusState,
96+
'description': 'asana link not found',
97+
'sha': github.context.payload.pull_request.head.sha,
98+
});
99+
break;
100+
}
101+
case 'add-comment': {
102+
const commentId = core.getInput('comment-id'),
103+
htmlText = core.getInput('text', {required: true}),
104+
isPinned = core.getInput('is-pinned') === 'true';
105+
const comments = [];
106+
for(const taskId of foundAsanaTasks) {
107+
if(commentId){
108+
const comment = await findComment(client, taskId, commentId);
109+
if(comment){
110+
console.info('found existing comment', comment.gid);
111+
continue;
112+
}
113+
}
114+
const comment = await addComment(client, taskId, commentId, htmlText, isPinned);
115+
comments.push(comment);
116+
};
117+
return comments;
118+
}
119+
case 'remove-comment': {
120+
const commentId = core.getInput('comment-id', {required: true});
121+
const removedCommentIds = [];
122+
for(const taskId of foundAsanaTasks) {
123+
const comment = await findComment(client, taskId, commentId);
124+
if(comment){
125+
console.info("removing comment", comment.gid);
126+
try {
127+
await client.stories.delete(comment.gid);
128+
} catch (error) {
129+
console.error('rejecting promise', error);
130+
}
131+
removedCommentIds.push(comment.gid);
132+
}
133+
}
134+
return removedCommentIds;
135+
}
136+
case 'move-section': {
137+
const targetJSON = core.getInput('targets', {required: true});
138+
const targets = JSON.parse(targetJSON);
139+
const movedTasks = [];
140+
for(const taskId of foundAsanaTasks) {
141+
await moveSection(client, taskId, targets);
142+
movedTasks.push(taskId);
143+
}
144+
return movedTasks;
145+
}
146+
default:
147+
console.info('lame');
148+
core.setFailed("unexpected action ${ACTION}");
149+
}
150+
}

action.test.js

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
const action = require('./action');
2+
const core = require('@actions/core');
3+
const github = require('@actions/github');
4+
5+
describe('asana github actions', () => {
6+
let inputs = {};
7+
const defaultBody = 'Implement https://app.asana.com/0/1178251625498057/1178251625498064 in record time';
8+
const asanaPAT = process.env['ASANA_PAT'];
9+
if(!asanaPAT) {
10+
throw new Error('need ASANA_PAT in the test env');
11+
}
12+
13+
const commentId = Date.now().toString();
14+
15+
beforeAll(() => {
16+
// Mock getInput
17+
jest.spyOn(core, 'getInput').mockImplementation((name, options) => {
18+
if(inputs[name] === undefined && options && options.required){
19+
throw new Error(name + " was not expected to be empty");
20+
}
21+
return inputs[name]
22+
})
23+
24+
// Mock error/warning/info/debug
25+
jest.spyOn(core, 'error').mockImplementation(jest.fn())
26+
jest.spyOn(core, 'warning').mockImplementation(jest.fn())
27+
jest.spyOn(core, 'info').mockImplementation(jest.fn())
28+
jest.spyOn(core, 'debug').mockImplementation(jest.fn())
29+
30+
github.context.ref = 'refs/heads/some-ref'
31+
github.context.sha = '1234567890123456789012345678901234567890'
32+
33+
process.env['GITHUB_REPOSITORY'] = 'a-cool-owner/a-cool-repo'
34+
})
35+
36+
beforeEach(() => {
37+
// Reset inputs
38+
inputs = {}
39+
github.context.payload = {};
40+
})
41+
42+
test('asserting a links presence', async () => {
43+
inputs = {
44+
'asana-pat': asanaPAT,
45+
'action': 'assert-link',
46+
'github-token': 'fake'
47+
}
48+
github.context.payload = {
49+
pull_request: {
50+
'body': defaultBody,
51+
'head': {
52+
'sha': '1234567890123456789012345678901234567890'
53+
}
54+
}
55+
};
56+
57+
const mockCreateStatus = jest.fn()
58+
github.GitHub = jest.fn().mockImplementation(() => {
59+
return {
60+
repos: {
61+
createStatus: mockCreateStatus,
62+
}
63+
}
64+
});
65+
66+
await action();
67+
68+
expect(mockCreateStatus).toHaveBeenCalledWith({
69+
owner: 'a-cool-owner',
70+
repo: 'a-cool-repo',
71+
context: 'asana-link-presence',
72+
state: 'success',
73+
description: 'asana link not found',
74+
sha: '1234567890123456789012345678901234567890',
75+
});
76+
});
77+
78+
test('creating a comment', async () => {
79+
inputs = {
80+
'asana-pat': asanaPAT,
81+
'action': 'add-comment',
82+
'comment-id': commentId,
83+
'text': 'rad stuff',
84+
'is-pinned': 'true'
85+
}
86+
// Mock github context
87+
github.context.payload = {
88+
pull_request: {
89+
'body': defaultBody
90+
}
91+
};
92+
93+
await expect(action()).resolves.toHaveLength(1);
94+
95+
// rerunning with the same comment-Id should not create a new comment
96+
await expect(action()).resolves.toHaveLength(0);
97+
});
98+
99+
test('removing a comment', async () => {
100+
inputs = {
101+
'asana-pat': asanaPAT,
102+
'action': 'remove-comment',
103+
// note: relies on the task being created in `creating a comment` test
104+
'comment-id': commentId,
105+
}
106+
github.context.payload = {
107+
pull_request: {
108+
'body': defaultBody
109+
}
110+
};
111+
112+
await expect(action()).resolves.toHaveLength(1);
113+
});
114+
115+
test('moving sections', async () => {
116+
inputs = {
117+
'asana-pat': asanaPAT,
118+
'action': 'move-section',
119+
'targets': '[{"project": "Asana bot test environment", "section": "Done"}]'
120+
}
121+
github.context.payload = {
122+
pull_request: {
123+
'body': defaultBody
124+
}
125+
};
126+
127+
await expect(action()).resolves.toHaveLength(1);
128+
129+
inputs = {
130+
'asana-pat': asanaPAT,
131+
'action': 'move-section',
132+
'targets': '[{"project": "Asana bot test environment", "section": "New"}]'
133+
}
134+
135+
await expect(action()).resolves.toHaveLength(1);
136+
});
137+
});

action.yml

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
1-
name: 'Asana Git with enforcement'
1+
name: 'Asana Github Actions'
22
description: 'Asana Github Actions'
33
inputs:
44
asana-pat:
55
description: 'Asana Public Access Token.'
66
required: true
7-
task-comment:
8-
description: 'Provide text, which will add a comment with the pull request link to the asana task.'
9-
required: false
10-
targets:
11-
description: 'JSON array of objects having project and section where to move current task. Move task only if it exists in target project.'
7+
action:
8+
description: 'The action to be performed assert-link|add-comment|remove-comment|move-section'
129
required: false
1310
trigger-phrase:
1411
description: 'Prefix before the task i.e ASANA TASK: https://app.asana.com/1/2/3'
1512
required: false
16-
link-required:
17-
description: 'When set to true requires an asana link in the body'
18-
required: false
19-
default: false
2013
github-token:
2114
description: 'your github auth token'
22-
required: true
15+
required: false
16+
comment-id:
17+
description: 'an (optional) unique comment id to avoid duplicate comments'
18+
required: false
19+
text:
20+
description: 'Provide text, which will add a comment with the pull request link to the asana task.'
21+
required: false
22+
is-pinned:
23+
description: 'Should the asana comment be pinned'
24+
required: false
25+
default: false
26+
targets:
27+
description: 'JSON array of objects having project and section where to move current task. Move task only if it exists in target project.'
28+
required: false
2329
branding:
2430
icon: 'chevron-right'
2531
color: 'gray-dark'

0 commit comments

Comments
 (0)