diff --git a/.github/workflows/notify-release.yml b/.github/workflows/notify-release.yml index b48eaed6..bd971ed1 100644 --- a/.github/workflows/notify-release.yml +++ b/.github/workflows/notify-release.yml @@ -5,6 +5,8 @@ on: - main release: types: [published] + issues: + types: [closed] schedule: - cron: '30 8 * * *' jobs: diff --git a/README.md b/README.md index ea7348d2..b1d4e76e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ on: workflow_dispatch: release: types: [published] + issues: + types: [closed] schedule: - cron: '30 8 * * *' jobs: diff --git a/dist/index.js b/dist/index.js index 9cb179e3..90bf4322 100644 --- a/dist/index.js +++ b/dist/index.js @@ -4287,7 +4287,7 @@ const core = __nccwpck_require__(2186) */ function logActionRefWarning() { const actionRef = process.env.GITHUB_ACTION_REF - const repoName = process.env.GITHUB_REPOSITORY + const repoName = process.env.GITHUB_ACTION_REPOSITORY if (actionRef === 'main' || actionRef === 'master') { core.warning( @@ -4303,8 +4303,24 @@ function logActionRefWarning() { } } +/** + * Displays warning message if the repository is under the nearform organisation + */ +function logRepoWarning() { + const repoName = process.env.GITHUB_ACTION_REPOSITORY + const repoOrg = repoName.split('/')[0] + + if (repoOrg != 'nearform-actions') { + core.warning( + `'${repoOrg}' is no longer a valid organisation for this action.` + + `Please update it to be under the 'nearform-actions' organisation.` + ) + } +} + module.exports = { - logActionRefWarning + logActionRefWarning, + logRepoWarning } @@ -12225,7 +12241,7 @@ exports.implementation = class URLImpl { /***/ }), -/***/ 4720: +/***/ 653: /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { "use strict"; @@ -12435,7 +12451,7 @@ module.exports = { "use strict"; -exports.URL = __nccwpck_require__(4720)["interface"]; +exports.URL = __nccwpck_require__(653)["interface"]; exports.serializeURL = __nccwpck_require__(33).serializeURL; exports.serializeURLOrigin = __nccwpck_require__(33).serializeURLOrigin; exports.basicURLParse = __nccwpck_require__(33).basicURLParse; @@ -18009,6 +18025,31 @@ function wrappy (fn, cb) { } +/***/ }), + +/***/ 4438: +/***/ ((module) => { + +"use strict"; + + +const ISSUE_LABEL = 'notify-release' +const ISSUE_TITLE = 'Release pending!' +const STATE_OPEN = 'open' +const STATE_CLOSED = 'closed' +const STATE_CLOSED_NOT_PLANNED = 'not_planned' +const ISSUES_EVENT_NAME = 'issues' + +module.exports = { + ISSUE_LABEL, + ISSUE_TITLE, + STATE_OPEN, + STATE_CLOSED, + STATE_CLOSED_NOT_PLANNED, + ISSUES_EVENT_NAME, +} + + /***/ }), /***/ 5465: @@ -18017,18 +18058,21 @@ function wrappy (fn, cb) { "use strict"; const github = __nccwpck_require__(5438) -const { logInfo } = __nccwpck_require__(653) -const { isStale } = __nccwpck_require__(3590) +const { logInfo } = __nccwpck_require__(4353) +const { isStale, getNotifyDate } = __nccwpck_require__(3590) const fs = __nccwpck_require__(7147) const path = __nccwpck_require__(1017) const { promisify } = __nccwpck_require__(3837) const readFile = promisify(fs.readFile) const handlebars = __nccwpck_require__(7492) - -const ISSUE_LABEL = 'notify-release' -const ISSUE_TITLE = 'Release pending!' -const STATE_OPEN = 'open' -const STATE_CLOSED = 'closed' +const { + STATE_OPEN, + ISSUE_TITLE, + STATE_CLOSED, + ISSUE_LABEL, + ISSUES_EVENT_NAME, + STATE_CLOSED_NOT_PLANNED, +} = __nccwpck_require__(4438) function registerHandlebarHelpers(config) { const { commitMessageLines } = config @@ -18118,7 +18162,8 @@ async function createOrUpdateIssue( await updateLastOpenPendingIssue(token, issueBody, pendingIssue.number) logInfo(`Issue ${pendingIssue.number} has been updated`) } else { - const issueNo = await createIssue(token, issueBody) + const { data } = await createIssue(token, issueBody) + const { number: issueNo } = data logInfo(`New issue has been created. Issue No. - ${issueNo}`) } } @@ -18160,6 +18205,63 @@ async function isSnoozed(token, latestReleaseDate, notifyDate) { return !isStale(closedNotifyIssues[0].closed_at, notifyDate) } +function getIsSnoozingIssue(context) { + const { eventName, payload } = context + const { issue } = payload + + if (!issue) { + return false + } + + const { state, state_reason: stateReason, labels } = issue + + const isClosing = eventName === ISSUES_EVENT_NAME && state === STATE_CLOSED + const stateClosedNotPlanned = stateReason === STATE_CLOSED_NOT_PLANNED + const isNotifyReleaseIssue = labels.some( + (label) => label.name === ISSUE_LABEL + ) + + const isSnoozingIssue = + isClosing && stateClosedNotPlanned && isNotifyReleaseIssue + return isSnoozingIssue +} + +function getIsClosingIssue(context) { + const { eventName, payload } = context + const { issue } = payload + + if (!issue) { + return false + } + + const { state } = issue + + const isClosing = eventName === ISSUES_EVENT_NAME && state === STATE_CLOSED + + return isClosing +} + +async function addSnoozingComment(token, notifyAfter, issueNumber) { + logInfo('Adding a snoozing comment to the issue.') + + const notifyDate = getNotifyDate(notifyAfter) + + const octokit = github.getOctokit(token) + const { owner, repo } = github.context.repo + + await octokit.request( + `POST /repos/{owner}/{repo}/issues/{issue_number}/comments`, + { + owner, + repo, + issue_number: issueNumber, + body: `This issue has been snoozed. A new issue will be opened for you on ${notifyDate}.`, + } + ) + + logInfo('Snoozing comment added to the issue.') +} + module.exports = { createIssue, getLastOpenPendingIssue, @@ -18167,12 +18269,15 @@ module.exports = { createOrUpdateIssue, closeIssue, isSnoozed, + getIsSnoozingIssue, + getIsClosingIssue, + addSnoozingComment, } /***/ }), -/***/ 653: +/***/ 4353: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; @@ -18195,7 +18300,7 @@ exports.logWarning = log(warning) "use strict"; -const { logInfo, logWarning } = __nccwpck_require__(653) +const { logInfo, logWarning } = __nccwpck_require__(4353) const { getLatestRelease, getUnreleasedCommits } = __nccwpck_require__(2026) const { createOrUpdateIssue, @@ -18317,7 +18422,7 @@ module.exports = { "use strict"; const ms = __nccwpck_require__(900) -const { logWarning } = __nccwpck_require__(653) +const { logWarning } = __nccwpck_require__(4353) function notifyAfterToMs(input) { const stringToMs = ms(input) @@ -18360,11 +18465,22 @@ function parseNotifyAfter(notifyAfter, staleDays) { return isNaN(Number(staleDays)) ? staleDays : staleDaysToStr(staleDays) } +function getNotifyDate(input) { + const stringToMs = ms(input) + + if (isNaN(stringToMs)) { + throw new Error('Invalid time value') + } + + return new Date(Date.now() + stringToMs) +} + module.exports = { isSomeCommitStale, isStale, parseNotifyAfter, notifyAfterToMs, + getNotifyDate, staleDaysToStr, } @@ -18552,8 +18668,15 @@ var __webpack_exports__ = {}; const core = __nccwpck_require__(2186) const toolkit = __nccwpck_require__(2020) +const { context } = __nccwpck_require__(5438) const { parseNotifyAfter } = __nccwpck_require__(3590) const { runAction } = __nccwpck_require__(1254) +const { + getIsSnoozingIssue, + getIsClosingIssue, + addSnoozingComment, +} = __nccwpck_require__(5465) +const { logInfo } = __nccwpck_require__(4353) async function run() { toolkit.logActionRefWarning() @@ -18566,8 +18689,22 @@ async function run() { core.getInput('stale-days') ) - const commitMessageLines = Number(core.getInput('commit-messages-lines')) + const isSnoozing = getIsSnoozingIssue(context) + if (isSnoozing) { + logInfo('Snoozing issue ...') + const { number } = context.issue + return addSnoozingComment(token, notifyAfter, number) + } + + const isClosing = getIsClosingIssue(context) + if (isClosing) { + logInfo('Closing issue. Nothing to do ...') + return + } + + logInfo('Workflow dispatched or release published ...') + const commitMessageLines = Number(core.getInput('commit-messages-lines')) await runAction(token, notifyAfter, commitMessageLines) } diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 00000000..5622a4bd --- /dev/null +++ b/src/constants.js @@ -0,0 +1,17 @@ +'use strict' + +const ISSUE_LABEL = 'notify-release' +const ISSUE_TITLE = 'Release pending!' +const STATE_OPEN = 'open' +const STATE_CLOSED = 'closed' +const STATE_CLOSED_NOT_PLANNED = 'not_planned' +const ISSUES_EVENT_NAME = 'issues' + +module.exports = { + ISSUE_LABEL, + ISSUE_TITLE, + STATE_OPEN, + STATE_CLOSED, + STATE_CLOSED_NOT_PLANNED, + ISSUES_EVENT_NAME, +} diff --git a/src/index.js b/src/index.js index ff94e8cb..8bd2f770 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,15 @@ 'use strict' const core = require('@actions/core') const toolkit = require('actions-toolkit') +const { context } = require('@actions/github') const { parseNotifyAfter } = require('./time-utils.js') const { runAction } = require('./release-notify-action') +const { + getIsSnoozingIssue, + getIsClosingIssue, + addSnoozingComment, +} = require('./issue.js') +const { logInfo } = require('./log.js') async function run() { toolkit.logActionRefWarning() @@ -15,8 +22,22 @@ async function run() { core.getInput('stale-days') ) - const commitMessageLines = Number(core.getInput('commit-messages-lines')) + const isSnoozing = getIsSnoozingIssue(context) + + if (isSnoozing) { + logInfo('Snoozing issue ...') + const { number } = context.issue + return addSnoozingComment(token, notifyAfter, number) + } + const isClosing = getIsClosingIssue(context) + if (isClosing) { + logInfo('Closing issue. Nothing to do ...') + return + } + + logInfo('Workflow dispatched or release published ...') + const commitMessageLines = Number(core.getInput('commit-messages-lines')) await runAction(token, notifyAfter, commitMessageLines) } diff --git a/src/issue.js b/src/issue.js index 4c5100e9..71b34a95 100644 --- a/src/issue.js +++ b/src/issue.js @@ -1,17 +1,20 @@ 'use strict' const github = require('@actions/github') const { logInfo } = require('./log') -const { isStale } = require('./time-utils.js') +const { isStale, getNotifyDate } = require('./time-utils.js') const fs = require('fs') const path = require('path') const { promisify } = require('util') const readFile = promisify(fs.readFile) const handlebars = require('handlebars') - -const ISSUE_LABEL = 'notify-release' -const ISSUE_TITLE = 'Release pending!' -const STATE_OPEN = 'open' -const STATE_CLOSED = 'closed' +const { + STATE_OPEN, + ISSUE_TITLE, + STATE_CLOSED, + ISSUE_LABEL, + ISSUES_EVENT_NAME, + STATE_CLOSED_NOT_PLANNED, +} = require('./constants.js') function registerHandlebarHelpers(config) { const { commitMessageLines } = config @@ -101,7 +104,8 @@ async function createOrUpdateIssue( await updateLastOpenPendingIssue(token, issueBody, pendingIssue.number) logInfo(`Issue ${pendingIssue.number} has been updated`) } else { - const issueNo = await createIssue(token, issueBody) + const { data } = await createIssue(token, issueBody) + const { number: issueNo } = data logInfo(`New issue has been created. Issue No. - ${issueNo}`) } } @@ -143,6 +147,63 @@ async function isSnoozed(token, latestReleaseDate, notifyDate) { return !isStale(closedNotifyIssues[0].closed_at, notifyDate) } +function getIsSnoozingIssue(context) { + const { eventName, payload } = context + const { issue } = payload + + if (!issue) { + return false + } + + const { state, state_reason: stateReason, labels } = issue + + const isClosing = eventName === ISSUES_EVENT_NAME && state === STATE_CLOSED + const stateClosedNotPlanned = stateReason === STATE_CLOSED_NOT_PLANNED + const isNotifyReleaseIssue = labels.some( + (label) => label.name === ISSUE_LABEL + ) + + const isSnoozingIssue = + isClosing && stateClosedNotPlanned && isNotifyReleaseIssue + return isSnoozingIssue +} + +function getIsClosingIssue(context) { + const { eventName, payload } = context + const { issue } = payload + + if (!issue) { + return false + } + + const { state } = issue + + const isClosing = eventName === ISSUES_EVENT_NAME && state === STATE_CLOSED + + return isClosing +} + +async function addSnoozingComment(token, notifyAfter, issueNumber) { + logInfo('Adding a snoozing comment to the issue.') + + const notifyDate = getNotifyDate(notifyAfter) + + const octokit = github.getOctokit(token) + const { owner, repo } = github.context.repo + + await octokit.request( + `POST /repos/{owner}/{repo}/issues/{issue_number}/comments`, + { + owner, + repo, + issue_number: issueNumber, + body: `This issue has been snoozed. A new issue will be opened for you on ${notifyDate}.`, + } + ) + + logInfo('Snoozing comment added to the issue.') +} + module.exports = { createIssue, getLastOpenPendingIssue, @@ -150,4 +211,7 @@ module.exports = { createOrUpdateIssue, closeIssue, isSnoozed, + getIsSnoozingIssue, + getIsClosingIssue, + addSnoozingComment, } diff --git a/src/time-utils.js b/src/time-utils.js index 97cb6773..6c2beab8 100644 --- a/src/time-utils.js +++ b/src/time-utils.js @@ -43,10 +43,21 @@ function parseNotifyAfter(notifyAfter, staleDays) { return isNaN(Number(staleDays)) ? staleDays : staleDaysToStr(staleDays) } +function getNotifyDate(input) { + const stringToMs = ms(input) + + if (isNaN(stringToMs)) { + throw new Error('Invalid time value') + } + + return new Date(Date.now() + stringToMs) +} + module.exports = { isSomeCommitStale, isStale, parseNotifyAfter, notifyAfterToMs, + getNotifyDate, staleDaysToStr, } diff --git a/test/issue.spec.js b/test/issue.spec.js index a4b1432e..65322c8a 100644 --- a/test/issue.spec.js +++ b/test/issue.spec.js @@ -3,6 +3,7 @@ const { getOctokit } = require('@actions/github') const issue = require('../src/issue') +const { getNotifyDate } = require('../src/time-utils') const { unreleasedCommitsData1, closedNotifyIssues } = require('./testData') @@ -107,6 +108,11 @@ test('Close an issue', async () => { test('Create an issue when no existing issue exists', async () => { const create = jest.fn() getOctokit.mockReturnValue({ rest: { issues: { create } } }) + create.mockResolvedValue({ + data: { + number: 1, + }, + }) await issue.createOrUpdateIssue( token, @@ -141,6 +147,11 @@ test('Update an issue when exists', async () => { test('Create issue body that contains commits shortened SHA identifiers', async () => { const create = jest.fn() getOctokit.mockReturnValue({ rest: { issues: { create } } }) + create.mockResolvedValue({ + data: { + number: 1, + }, + }) await issue.createOrUpdateIssue( token, @@ -212,6 +223,11 @@ test('', async () => { test('Creates a snooze issue when no pending', async () => { const create = jest.fn() getOctokit.mockReturnValue({ rest: { issues: { create } } }) + create.mockResolvedValue({ + data: { + number: 1, + }, + }) await issue.createOrUpdateIssue( token, @@ -239,3 +255,121 @@ test('Update a snooze issue when pending', async () => { ) expect(request).toHaveBeenCalled() }) + +test('getIsSnoozingIssue returns true if the issue is closing as not_planned', () => { + const mockedContext = { + eventName: 'issues', + payload: { + issue: { + number: 1, + state: 'closed', + state_reason: 'not_planned', + labels: [{ name: 'notify-release' }], + }, + }, + } + + const isSnoozingIssue = issue.getIsSnoozingIssue(mockedContext) + + expect(isSnoozingIssue).toEqual(true) +}) + +test('getIsSnoozingIssue returns false if the issue is closing as complete', () => { + const mockedContext = { + eventName: 'issues', + payload: { + issue: { + number: 1, + state: 'closed', + state_reason: 'complete', + labels: [{ name: 'notify-release' }], + }, + }, + } + + const isSnoozingIssue = issue.getIsSnoozingIssue(mockedContext) + + expect(isSnoozingIssue).toEqual(false) +}) + +test('getIsSnoozingIssue returns false if the issue is closing without a notify-release label', () => { + const mockedContext = { + eventName: 'issues', + payload: { + issue: { + number: 1, + state: 'closed', + state_reason: 'not_planned', + labels: [], + }, + }, + } + + const isSnoozingIssue = issue.getIsSnoozingIssue(mockedContext) + + expect(isSnoozingIssue).toEqual(false) +}) + +test('getIsSnoozingIssue returns false if the issue is not closing', () => { + const mockedContext = { + eventName: 'workflow_dispatch', + payload: {}, + } + + const isSnoozingIssue = issue.getIsSnoozingIssue(mockedContext) + + expect(isSnoozingIssue).toEqual(false) +}) + +test('getIsClosingIssue returns true if the issue is closing', () => { + const mockedContext = { + eventName: 'issues', + payload: { + issue: { + number: 1, + state: 'closed', + state_reason: 'complete', + labels: [], + }, + }, + } + + const isClosingIssue = issue.getIsClosingIssue(mockedContext) + + expect(isClosingIssue).toEqual(true) +}) + +test('getIsClosingIssue returns false if the issue is not closing', () => { + const mockedContext = { + eventName: 'workflow_dispatch', + payload: {}, + } + + const isClosingIssue = issue.getIsClosingIssue(mockedContext) + + expect(isClosingIssue).toEqual(false) +}) + +test('Add a snoozing comment to a closing issue', async () => { + const request = jest.fn() + getOctokit.mockReturnValue({ request }) + request.mockResolvedValue({ + data: {}, + }) + + const notifyAfter = '7 days' + const notifyDate = getNotifyDate(notifyAfter) + const issueNumber = 1 + + await issue.addSnoozingComment(token, notifyAfter, issueNumber) + + expect(request).toHaveBeenCalledWith( + `POST /repos/{owner}/{repo}/issues/{issue_number}/comments`, + { + owner, + repo, + issue_number: 1, + body: `This issue has been snoozed. A new issue will be opened for you on ${notifyDate}.`, + } + ) +}) diff --git a/test/time-utils.spec.js b/test/time-utils.spec.js index 377d1d34..a5b96e6d 100644 --- a/test/time-utils.spec.js +++ b/test/time-utils.spec.js @@ -6,6 +6,7 @@ const { isStale, parseNotifyAfter, staleDaysToStr, + getNotifyDate, } = require('../src/time-utils.js') const { @@ -127,3 +128,14 @@ test('parseNotifyAfter numeric notify-after', () => { expect(parseNotifyAfter('-1', undefined)).toEqual('-1 ms') }) + +test('getNotifyDate should return a valid date object', () => { + const input = '1 day' + const result = getNotifyDate(input) + expect(result).toBeInstanceOf(Date) +}) + +test('getNotifyDate should throw an error when input is a string in an invalid format', () => { + const input = 'invalid input' + expect(() => getNotifyDate(input)).toThrow('Invalid time value') +})