Skip to content

Commit

Permalink
feat: Add annotations @sender @bot @repository @action and template h…
Browse files Browse the repository at this point in the history
…elper {{formatDate}} (#756)
  • Loading branch information
andekande committed Jun 20, 2024
1 parent 2ad0b2a commit 970ef68
Show file tree
Hide file tree
Showing 16 changed files with 211 additions and 70 deletions.
16 changes: 12 additions & 4 deletions __fixtures__/unit/helper.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
const _ = require('lodash')
const yaml = require('js-yaml')
const moment = require('moment-timezone')

const throwNotFound = () => {
const error = new Error('404 error')
Expand All @@ -18,11 +17,14 @@ module.exports = {
action: 'opened',
repository: {
name: (options.repoName) ? options.repoName : 'repoName',
full_name: 'name',
full_name: 'fullRepoName',
owner: {
login: 'owner'
}
},
sender: {
login: 'initiator'
},
check_suite: {
pull_requests: [
{
Expand All @@ -37,8 +39,8 @@ module.exports = {
title: (options.title) ? options.title : 'title',
body: options.body,
number: (options.number) ? options.number : 1,
created_at: options.createdAt ? moment(options.createdAt) : moment(),
updated_at: options.updatedAt ? moment(options.updatedAt) : moment(),
created_at: (options.createdAt) ? options.createdAt : new Date().toISOString(),
updated_at: (options.updatedAt) ? options.updatedAt : new Date().toISOString(),
milestone: (options.milestone) ? options.milestone : null,
requested_reviewers: options.requestedReviewers ? options.requestedReviewers : [],
requested_teams: options.requestedTeams ? options.requestedTeams : [],
Expand All @@ -64,7 +66,13 @@ module.exports = {
user: {
login: 'creator'
},
title: (options.title) ? options.title : 'title',
body: options.body,
number: (options.number) ? options.number : 1,
milestone: (options.milestone) ? options.milestone : null,
created_at: (options.createdAt) ? options.createdAt : new Date().toISOString(),
updated_at: (options.updatedAt) ? options.updatedAt : new Date().toISOString(),
assignees: (options.assignees) ? options.assignees : [],
pull_request: {}
}
},
Expand Down
28 changes: 28 additions & 0 deletions __tests__/unit/actions/action.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,31 @@ describe('Action#getActionables', () => {
).toBe(1)
})
})

describe('Action#getEventAttributes', () => {
const action = new Action()

test('Extracts event properties from pull_request correctly', () => {
const evt = action.getEventAttributes(Helper.mockContext({ eventName: 'pull_request' }))

expect(evt.action).toBe('opened')
expect(evt.repository.full_name).toBe('fullRepoName')
expect(evt.sender.login).toBe('initiator')
})

test('Extracts event properties from issues correctly', () => {
const evt = action.getEventAttributes(Helper.mockContext({ eventName: 'issues' }))

expect(evt.action).toBe('opened')
expect(evt.repository.full_name).toBe('fullRepoName')
expect(evt.sender.login).toBe('initiator')
})

test('Defaults event properties on schedule event', () => {
const evt = action.getEventAttributes(Helper.mockContext({ eventName: 'schedule' }))

expect(evt.action).toBe('')
expect(evt.repository).toEqual({})
expect(evt.sender).toEqual({})
})
})
6 changes: 4 additions & 2 deletions __tests__/unit/actions/assign.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ test('check that assignees are added when afterValidate is called with proper pa
expect(context.octokit.issues.addAssignees.mock.calls[0][0].assignees[1]).toBe('testuser2')
})

test('check that creator is added when assignee is @author', async () => {
test('check that creator is added when assignee is @author or @sender or @bot', async () => {
const settings = {
assignees: ['@author']
assignees: ['@author', '@sender', '@bot']
}

const assign = new Assign()
Expand All @@ -43,6 +43,8 @@ test('check that creator is added when assignee is @author', async () => {
await assign.afterValidate(context, settings)
expect(context.octokit.issues.addAssignees.mock.calls.length).toBe(1)
expect(context.octokit.issues.addAssignees.mock.calls[0][0].assignees[0]).toBe('creator')
expect(context.octokit.issues.addAssignees.mock.calls[0][0].assignees[1]).toBe('initiator')
expect(context.octokit.issues.addAssignees.mock.calls[0][0].assignees[2]).toBe('Mergeable[bot]')
})

test('check only authorized users are added as assignee ', async () => {
Expand Down
38 changes: 38 additions & 0 deletions __tests__/unit/actions/checks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,44 @@ test('that afterValidate is called with properly and output is correct', async (
expect(MetaData.exists(output.text)).toBe(false)
})

test('that afterValidate is replacing special annotations in payload', async () => {
const checks = new Checks()
const context = createMockContext()
const result = {
status: 'pass',
validations: [{
status: 'pass',
name: 'Label'
}],
completed_at: '2024-06-15T19:14:00Z'
}
const settings = {
payload: {
title: '@author @sender @bot @repository @action {{formatDate completed_at}} , completed!',
summary: '@author @sender @bot @repository @action {{formatDate completed_at}} , summary',
text: '@author @sender @bot @repository @action {{formatDate completed_at}} , text'
}
}

const name = undefined

checks.checkRunResult = new Map()

checks.checkRunResult.set(name, {
data: {
id: '3'
}
})

await checks.afterValidate(context, settings, name, result)
const output = context.octokit.checks.update.mock.calls[0][0].output
expect(context.octokit.checks.update.mock.calls.length).toBe(1)
expect(output.title).toBe('creator initiator Mergeable[bot] fullRepoName actionName Jun 15, 2024, 7:14 PM , completed!')
expect(output.summary).toBe('creator initiator Mergeable[bot] fullRepoName actionName Jun 15, 2024, 7:14 PM , summary')
expect(output.text).toContain('creator initiator Mergeable[bot] fullRepoName actionName Jun 15, 2024, 7:14 PM , text')
expect(MetaData.exists(output.text)).toBe(true)
})

test('that afterValidate is correct when validation fails', async () => {
const checks = new Checks()
const context = createMockContext()
Expand Down
13 changes: 7 additions & 6 deletions __tests__/unit/actions/comment.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ test.each([

test('check that comment created when afterValidate is called with proper parameter', async () => {
const comment = new Comment()
const context = createMockContext()
const context = createMockContext([])

const result = {
status: 'pass',
Expand Down Expand Up @@ -257,23 +257,24 @@ test('remove Error comment fail gracefully if payload does not exists', async ()
expect(context.octokit.issues.deleteComment.mock.calls.length).toBe(0)
})

test('error handling includes removing old error comments and creating new error comment', async () => {
test('special annotations are replaced', async () => {
const comment = new Comment()
const context = createMockContext()
const context = createMockContext([])
const settings = {
payload: {
body: '@author , do something!'
body: '@author @sender @bot @repository @action {{formatDate created_at}} , do something!'
}
}

await comment.afterValidate(context, settings, '', result)
await Helper.flushPromises()

expect(context.octokit.issues.createComment.mock.calls[0][0].body).toBe('creator , do something!')
expect(context.octokit.issues.createComment.mock.calls[0][0].body).toBe('creator initiator Mergeable[bot] fullRepoName opened Jun 15, 2024, 7:14 PM , do something!')
})

const createMockContext = (comments, eventName = undefined, event = undefined) => {
const context = Helper.mockContext({ comments, eventName, event })
const createdAt = '2024-06-15T19:14:00Z'
const context = Helper.mockContext({ comments, eventName, createdAt, event })

context.octokit.issues.createComment = jest.fn()
context.octokit.issues.deleteComment = jest.fn()
Expand Down
104 changes: 58 additions & 46 deletions __tests__/unit/actions/lib/searchAndReplaceSpecialAnnotation.test.js
Original file line number Diff line number Diff line change
@@ -1,57 +1,69 @@
const searchAndReplaceSpecialAnnotations = require('../../../../lib/actions/lib/searchAndReplaceSpecialAnnotation')

describe('searchAndReplaceSpecialAnnotations', () => {
test('does not affect input if no special annotations are found', () => {
const payload = {
user: {
login: 'creator'
}
}
expect(searchAndReplaceSpecialAnnotations('no special annotations', payload)).toBe('no special annotations')
})

test('special annotation at the beginning of string works properly', () => {
const payload = {
user: {
login: 'creator'
}
}
expect(searchAndReplaceSpecialAnnotations('@author says hello!', payload)).toBe('creator says hello!')
})

test('escape character works properly', () => {
const payload = {
user: {
login: 'creator'
}
const SPECIAL_ANNOTATION = {
'@author': 'creator',
'@action': 'created',
'@sender': 'initiator',
'@bot': 'Mergeable[bot]',
'@repository': 'botrepo'
}
const tests = [
{
name: 'does not affect input if no special annotations are found',
message: 'no special annotations',
expected: 'no special annotations'
},
{
name: 'special annotation at the beginning of string works properly',
message: '$annotation$ says hello!',
expected: '$annotation$ says hello!'
},
{
name: 'escape character works properly',
message: 'this is \\@author',
expected: 'this is @author'
},
{
name: 'special annotation at the end of string works properly',
message: 'this is $annotation$',
expected: 'this is $annotation$'
},
{
name: '@@annotation is replaced, prepending @ remains',
message: 'this is @$annotation$',
expected: 'this is @$annotation$'
},
{
name: 'replaces special annotation anywhere in the text',
message: 'this is something$annotation$ speaking',
expected: 'this is something$annotation$ speaking'
}
expect(searchAndReplaceSpecialAnnotations('this is \\@author', payload)).toBe('this is @author')
})
]

test('@author is replaced by payload.user.login', () => {
const payload = {
user: {
login: 'creator'
test.each(tests)(
'$name',
async ({ message, expected }) => {
const payload = {
user: {
login: 'creator'
}
}
}
expect(searchAndReplaceSpecialAnnotations('this is @author', payload)).toBe('this is creator')
})

test('@@author is replaced by @payload.user.login', () => {
const payload = {
user: {
login: 'creator'
const evt = {
action: 'created',
repository: {
full_name: 'botrepo'
},
sender: {
login: 'initiator'
}
}
}
expect(searchAndReplaceSpecialAnnotations('this is @@author', payload)).toBe('this is @creator')
})

test('replaces annotation anywhere in the text', () => {
const payload = {
user: {
login: 'creator'
for (const annotation of Object.keys(SPECIAL_ANNOTATION)) {
const messageWithAnnotation = message.replace('$annotation$', annotation)
const messageWithReplacement = expected.replace('$annotation$', SPECIAL_ANNOTATION[annotation])
expect(searchAndReplaceSpecialAnnotations(messageWithAnnotation, payload, evt)).toBe(messageWithReplacement)
}
}
expect(searchAndReplaceSpecialAnnotations('this is something@author speaking', payload)).toBe('this is somethingcreator speaking')
})
)
})
2 changes: 1 addition & 1 deletion docs/actions/assign.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ You can assign specific people to a pull request or issue.
::

- do: assign
assignees: [ 'shine2lay', 'jusx', '@author' ] # only array accepted, use @author for PR/Issue author
assignees: [ 'shine2lay', 'jusx', '@author' ] # only array accepted, use @author for PR/Issue author, use @sender for event initiator, use @bot for Mergable bot

Supported Events:
::
Expand Down
10 changes: 10 additions & 0 deletions docs/actions/comment.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ You can add a comment to a pull request or issue.
payload:
body: >
Your very long comment can go here.
Annotations are replaced:
- @author
- @sender
- @bot
- @repository
- @action
- {{formatDate}} # today's date and time
- {{formatDate created_at}} # PR/issue creation date and time
- {{title}} # PR/issue title
- {{body}} # PR/issue description
leave_old_comment: true # Optional, by default old comments are deleted, if true, old comments will be left alone

Supported Events:
Expand Down
6 changes: 5 additions & 1 deletion docs/annotations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ To bypass the annotation, use ``\`` prefix. (i.e ``\@author`` will be replaced w

::

@author : replace with the login of creator of issues/PR
@author : replaced with the login of creator of issues/PR
@sender : replaced with the login of initiator of the ocurred event
@bot : replaced with the name of the Mergeable bot
@repository : replaced with the name of repository of issues/PR
@action : replaced with action of the ocurred event


Actions supported:
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
CHANGELOG
=====================================
| June 20, 2024: feat: Add annotations @sender @bot @repository @action and template helper {{formatDate}} `#756 <https://github.com/mergeability/mergeable/pull/756>`_
| June 20, 2024: fix: Comments on Issues should not trigger `checks` action `#759 <https://github.com/mergeability/mergeable/pull/759>`_
| June 20, 2024: fix: Respect all comments in lastComment validator and comment action `#755 <https://github.com/mergeability/mergeable/pull/755>`_
| June 12, 2024: feat: Support `issue_comment` event as trigger for actions `#754 <https://github.com/mergeability/mergeable/pull/754>`_
Expand Down
3 changes: 2 additions & 1 deletion lib/actions/assign.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ class Assign extends Action {
async beforeValidate () {}

async afterValidate (context, settings, name, results) {
const evt = this.getEventAttributes(context)
const payload = this.getPayload(context)
const issueNumber = payload.number
const assignees = settings.assignees.map(assignee => searchAndReplaceSpecialAnnotations(assignee, payload))
const assignees = settings.assignees.map(assignee => searchAndReplaceSpecialAnnotations(assignee, payload, evt))
const checkResults = await Promise.all(assignees.map(
assignee => assignee === payload.user.login
? assignee
Expand Down
2 changes: 1 addition & 1 deletion lib/actions/checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class Checks extends Action {
populatePayloadWithResult (settings, results, context) {
const output = {}
Object.keys(settings).forEach(key => {
output[key] = populateTemplate(settings[key], results, this.getPayload(context))
output[key] = populateTemplate(settings[key], results, this.getPayload(context), this.getEventAttributes(context))
})

return output
Expand Down
3 changes: 2 additions & 1 deletion lib/actions/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,15 @@ class Comment extends Action {

async afterValidate (context, settings, name, results) {
const commentables = this.getActionables(context, results)
const evt = this.getEventAttributes(context)

return Promise.all(
// eslint-disable-next-line array-callback-return
commentables.map(issue => {
updateItemWithComment(
context,
issue.number,
populateTemplate(settings.payload.body, results, issue),
populateTemplate(settings.payload.body, results, issue, evt),
settings.leave_old_comment,
this
)
Expand Down
Loading

0 comments on commit 970ef68

Please sign in to comment.