Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add annotations @sender @bot @repository @action and template helper {{formatDate}} #756

Merged
merged 1 commit into from
Jun 20, 2024
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
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