From 970ef68f303806a53c5af9cb20305466743ec0c1 Mon Sep 17 00:00:00 2001 From: Jan Hesse Date: Thu, 20 Jun 2024 23:24:16 +0200 Subject: [PATCH] feat: Add annotations @sender @bot @repository @action and template helper {{formatDate}} (#756) --- __fixtures__/unit/helper.js | 16 ++- __tests__/unit/actions/action.test.js | 28 +++++ __tests__/unit/actions/assign.test.js | 6 +- __tests__/unit/actions/checks.test.js | 38 +++++++ __tests__/unit/actions/comment.test.js | 13 ++- .../searchAndReplaceSpecialAnnotation.test.js | 104 ++++++++++-------- docs/actions/assign.rst | 2 +- docs/actions/comment.rst | 10 ++ docs/annotations.rst | 6 +- docs/changelog.rst | 1 + lib/actions/assign.js | 3 +- lib/actions/checks.js | 2 +- lib/actions/comment.js | 3 +- lib/actions/handlebars/populateTemplate.js | 22 +++- .../lib/searchAndReplaceSpecialAnnotation.js | 12 +- lib/eventAware.js | 15 +++ 16 files changed, 211 insertions(+), 70 deletions(-) diff --git a/__fixtures__/unit/helper.js b/__fixtures__/unit/helper.js index 8445714a..b92b14d6 100644 --- a/__fixtures__/unit/helper.js +++ b/__fixtures__/unit/helper.js @@ -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') @@ -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: [ { @@ -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 : [], @@ -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: {} } }, diff --git a/__tests__/unit/actions/action.test.js b/__tests__/unit/actions/action.test.js index 84f553cb..1eb2098c 100644 --- a/__tests__/unit/actions/action.test.js +++ b/__tests__/unit/actions/action.test.js @@ -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({}) + }) +}) diff --git a/__tests__/unit/actions/assign.test.js b/__tests__/unit/actions/assign.test.js index 6f828ff7..d215cd3c 100644 --- a/__tests__/unit/actions/assign.test.js +++ b/__tests__/unit/actions/assign.test.js @@ -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() @@ -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 () => { diff --git a/__tests__/unit/actions/checks.test.js b/__tests__/unit/actions/checks.test.js index 0bfb75d0..27842c06 100644 --- a/__tests__/unit/actions/checks.test.js +++ b/__tests__/unit/actions/checks.test.js @@ -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() diff --git a/__tests__/unit/actions/comment.test.js b/__tests__/unit/actions/comment.test.js index fb82cb57..0269e383 100644 --- a/__tests__/unit/actions/comment.test.js +++ b/__tests__/unit/actions/comment.test.js @@ -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', @@ -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() diff --git a/__tests__/unit/actions/lib/searchAndReplaceSpecialAnnotation.test.js b/__tests__/unit/actions/lib/searchAndReplaceSpecialAnnotation.test.js index 0824c233..f9ae01ba 100644 --- a/__tests__/unit/actions/lib/searchAndReplaceSpecialAnnotation.test.js +++ b/__tests__/unit/actions/lib/searchAndReplaceSpecialAnnotation.test.js @@ -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') - }) + ) }) diff --git a/docs/actions/assign.rst b/docs/actions/assign.rst index 7da8c0be..4724e4fa 100644 --- a/docs/actions/assign.rst +++ b/docs/actions/assign.rst @@ -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: :: diff --git a/docs/actions/comment.rst b/docs/actions/comment.rst index df4f129f..126dc977 100644 --- a/docs/actions/comment.rst +++ b/docs/actions/comment.rst @@ -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: diff --git a/docs/annotations.rst b/docs/annotations.rst index 3e65c2e5..b0dc1863 100644 --- a/docs/annotations.rst +++ b/docs/annotations.rst @@ -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: diff --git a/docs/changelog.rst b/docs/changelog.rst index 628ff596..d8f5fd4a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,5 +1,6 @@ CHANGELOG ===================================== +| June 20, 2024: feat: Add annotations @sender @bot @repository @action and template helper {{formatDate}} `#756 `_ | June 20, 2024: fix: Comments on Issues should not trigger `checks` action `#759 `_ | June 20, 2024: fix: Respect all comments in lastComment validator and comment action `#755 `_ | June 12, 2024: feat: Support `issue_comment` event as trigger for actions `#754 `_ diff --git a/lib/actions/assign.js b/lib/actions/assign.js index 9631cdcc..28f184db 100644 --- a/lib/actions/assign.js +++ b/lib/actions/assign.js @@ -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 diff --git a/lib/actions/checks.js b/lib/actions/checks.js index 2ef0d95b..78d1bd94 100644 --- a/lib/actions/checks.js +++ b/lib/actions/checks.js @@ -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 diff --git a/lib/actions/comment.js b/lib/actions/comment.js index 3417039c..88fb0073 100644 --- a/lib/actions/comment.js +++ b/lib/actions/comment.js @@ -60,6 +60,7 @@ 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 @@ -67,7 +68,7 @@ class Comment extends Action { updateItemWithComment( context, issue.number, - populateTemplate(settings.payload.body, results, issue), + populateTemplate(settings.payload.body, results, issue, evt), settings.leave_old_comment, this ) diff --git a/lib/actions/handlebars/populateTemplate.js b/lib/actions/handlebars/populateTemplate.js index 5b847e30..7217b555 100644 --- a/lib/actions/handlebars/populateTemplate.js +++ b/lib/actions/handlebars/populateTemplate.js @@ -1,5 +1,6 @@ const handlebars = require('handlebars') const searchAndReplaceSpecialAnnotations = require('../lib/searchAndReplaceSpecialAnnotation') +const _ = require('lodash') handlebars.registerHelper('breaklines', function (text) { text = handlebars.Utils.escapeExpression(text) @@ -11,6 +12,21 @@ handlebars.registerHelper('toUpperCase', function (str) { return str.toUpperCase() }) +handlebars.registerHelper('formatDate', function (str) { + let date = new Date() + if (str === undefined) { + return str + } + if (typeof str === 'string') { + try { + date = new Date(str) + } catch { + return str + } + } + return date.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }) +}) + handlebars.registerHelper('displaySettings', function (settings) { return `\`\`\`${JSON.stringify(settings)}\`\`\`` }) @@ -34,10 +50,10 @@ handlebars.registerHelper('statusIcon', function (str) { } }) -const populateTemplate = (template, validationResult, payload) => { - const newTemplate = searchAndReplaceSpecialAnnotations(template, payload) +const populateTemplate = (template, validationResult, payload, event) => { + const newTemplate = searchAndReplaceSpecialAnnotations(template, payload, event) const handlebarsTemplate = handlebars.compile(newTemplate) - return handlebarsTemplate(validationResult) + return handlebarsTemplate(_.merge({}, payload, validationResult)) } module.exports = populateTemplate diff --git a/lib/actions/lib/searchAndReplaceSpecialAnnotation.js b/lib/actions/lib/searchAndReplaceSpecialAnnotation.js index b3edb823..74031e14 100644 --- a/lib/actions/lib/searchAndReplaceSpecialAnnotation.js +++ b/lib/actions/lib/searchAndReplaceSpecialAnnotation.js @@ -1,8 +1,12 @@ const SPECIAL_ANNOTATION = { - '@author': payload => payload.user.login + '@author': (payload, event) => payload.user.login, + '@action': (payload, event) => event.action, + '@bot': (payload, event) => process.env.APP_NAME ? `${process.env.APP_NAME}[bot]` : 'Mergeable[bot]', + '@repository': (payload, event) => event.repository?.full_name ?? '', + '@sender': (payload, event) => event.sender.login ?? '' } -const searchAndReplaceSpecialAnnotations = (template, payload) => { +const searchAndReplaceSpecialAnnotations = (template, payload, event) => { let newTemplate = template for (const annotation of Object.keys(SPECIAL_ANNOTATION)) { @@ -10,9 +14,9 @@ const searchAndReplaceSpecialAnnotations = (template, payload) => { const annotationAtStartRegex = new RegExp(`^${annotation}`) const escapeAnnotationRegex = new RegExp(`(\\\\){1}${annotation}`) - newTemplate = newTemplate.replace(specialAnnotationRegex, `${SPECIAL_ANNOTATION[annotation](payload)}`) + newTemplate = newTemplate.replace(specialAnnotationRegex, `${SPECIAL_ANNOTATION[annotation](payload, event)}`) newTemplate = newTemplate.replace(escapeAnnotationRegex, annotation) - newTemplate = newTemplate.replace(annotationAtStartRegex, SPECIAL_ANNOTATION[annotation](payload)) + newTemplate = newTemplate.replace(annotationAtStartRegex, SPECIAL_ANNOTATION[annotation](payload, event)) } return newTemplate } diff --git a/lib/eventAware.js b/lib/eventAware.js index 54859878..dba5543f 100644 --- a/lib/eventAware.js +++ b/lib/eventAware.js @@ -27,6 +27,21 @@ class EventAware { return context.payload[context.eventName] } } + + getEventAttributes (context) { + if (context.eventName === 'schedule') { + return { + action: '', + repository: {}, + sender: {} + } + } + return { + action: context.payload.action, + repository: context.payload.repository, + sender: context.payload.sender + } + } } module.exports = EventAware