From f8c324a9fcd41c062981fc70e92d4e4ba62fa8fe Mon Sep 17 00:00:00 2001 From: Michael Shick Date: Sun, 7 May 2023 08:50:52 -0400 Subject: [PATCH] find-and-replace functionality (#100) * find-and-replace functionality --- .github/workflows/ci.yml | 12 +- README.md | 132 ++++++++++++++ __tests__/add-pr-comment.test.ts | 302 +++++++++++++++++++++++++------ action.yml | 4 +- src/comments.ts | 22 ++- src/config.ts | 4 + src/main.ts | 54 ++++-- src/message.ts | 50 ++++- src/types.ts | 12 ++ 9 files changed, 502 insertions(+), 90 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5aa13f..37b95a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,4 +73,14 @@ jobs: message: | **Hello** 🌏 - ! + ! + + - uses: ./ + with: + message-id: text + find: | + Hello + 🌏 + replace: | + Goodnight + 🌕 diff --git a/README.md b/README.md index a872b75..a527450 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # add-pr-comment + [![All Contributors](https://img.shields.io/badge/all_contributors-6-orange.svg?style=flat-square)](#contributors-) + A GitHub Action which adds a comment to a pull request's issue. @@ -168,6 +170,136 @@ jobs: message-part-*.txt ``` +### Find-and-Replace + +Patterns can be matched and replaced to update comments. This could be useful +for some situations, for instance, updating a checklist comment. + +Find is a regular expression passed to the [RegExp() constructor](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/RegExp). You can also +include modifiers to override the default `gi`. + +**Example** + +Original message: + +``` +[ ] Hello +[ ] World +``` + +Action: + +```yaml +on: + pull_request: + +jobs: + pr: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: mshick/add-pr-comment@v2 + if: always() + with: + find: | + \n\\[ \\] + replace: | + [X] +``` + +Final message: + +``` +[X] Hello +[X] World +``` + +Multiple find and replaces can be used: + +**Example** + +Original message: + +``` +hello world! +``` + +Action: + +```yaml +on: + pull_request: + +jobs: + pr: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: mshick/add-pr-comment@v2 + if: always() + with: + find: | + hello + world + replace: | + goodnight + moon +``` + +Final message: + +``` +goodnight moon! +``` + +It defaults to your resolved message (either from `message` or `message-path`) to +do a replacement: + +**Example** + +Original message: + +``` +hello + +<< FILE_CONTENTS >> + +world +``` + +Action: + +```yaml +on: + pull_request: + +jobs: + pr: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: mshick/add-pr-comment@v2 + if: always() + with: + message-path: | + message.txt + find: | + << FILE_CONTENTS >> +``` + +Final message: + +``` +hello + +secret message from message.txt + +world +``` + ### Bring your own issues You can set an issue id explicitly. Helpful for cases where you want to post diff --git a/__tests__/add-pr-comment.test.ts b/__tests__/add-pr-comment.test.ts index 0c5ae3a..fb70e40 100644 --- a/__tests__/add-pr-comment.test.ts +++ b/__tests__/add-pr-comment.test.ts @@ -25,6 +25,7 @@ type Inputs = { 'repo-token': string 'message-id': string 'allow-repeats': string + 'message-pattern'?: string 'message-success'?: string 'message-failure'?: string 'message-cancelled'?: string @@ -95,55 +96,83 @@ const handlers = [ const server = setupServer(...handlers) +beforeAll(() => { + // vi.spyOn(console, 'log').mockImplementation(() => {}) + // vi.spyOn(core, 'debug').mockImplementation(() => {}) + // vi.spyOn(core, 'info').mockImplementation(() => {}) + // vi.spyOn(core, 'warning').mockImplementation(() => {}) + server.listen({ onUnhandledRequest: 'error' }) +}) +afterAll(() => server.close()) + +beforeEach(() => { + inputs = { ...defaultInputs } + issueNumber = defaultIssueNumber + messagePayload = undefined + + vi.resetModules() + + github.context.sha = commitSha + + // https://developer.github.com/webhooks/event-payloads/#issues + github.context.payload = { + pull_request: { + number: issueNumber, + }, + repository: { + full_name: `${inputs['repo-owner']}/${inputs['repo-name']}`, + name: 'bar', + owner: { + login: 'bar', + }, + }, + } as WebhookPayload +}) + +afterEach(() => { + vi.clearAllMocks() + server.resetHandlers() +}) + +const getInput = (name: string, options?: core.InputOptions) => { + const value = inputs[name] ?? '' + + if (options?.required && value === undefined) { + throw new Error(`${name} is required`) + } + + return value +} + +function getMultilineInput(name, options) { + const inputs = getInput(name, options) + .split('\n') + .filter((x) => x !== '') + + if (options && options.trimWhitespace === false) { + return inputs + } + + return inputs.map((input) => input.trim()) +} + +function getBooleanInput(name, options) { + const trueValue = ['true', 'True', 'TRUE'] + const falseValue = ['false', 'False', 'FALSE'] + const val = getInput(name, options) + if (trueValue.includes(val)) return true + if (falseValue.includes(val)) return false + throw new TypeError( + `Input does not meet YAML 1.2 "Core Schema" specification: ${name}\n` + + `Support boolean input list: \`true | True | TRUE | false | False | FALSE\``, + ) +} + +vi.mocked(core.getInput).mockImplementation(getInput) +vi.mocked(core.getMultilineInput).mockImplementation(getMultilineInput) +vi.mocked(core.getBooleanInput).mockImplementation(getBooleanInput) + describe('add-pr-comment action', () => { - beforeAll(() => { - // vi.spyOn(console, 'log').mockImplementation(() => {}) - // vi.spyOn(core, 'debug').mockImplementation(() => {}) - // vi.spyOn(core, 'info').mockImplementation(() => {}) - // vi.spyOn(core, 'warning').mockImplementation(() => {}) - server.listen({ onUnhandledRequest: 'error' }) - }) - afterAll(() => server.close()) - - beforeEach(() => { - inputs = { ...defaultInputs } - issueNumber = defaultIssueNumber - messagePayload = undefined - - vi.resetModules() - - github.context.sha = commitSha - - // https://developer.github.com/webhooks/event-payloads/#issues - github.context.payload = { - pull_request: { - number: issueNumber, - }, - repository: { - full_name: `${inputs['repo-owner']}/${inputs['repo-name']}`, - name: 'bar', - owner: { - login: 'bar', - }, - }, - } as WebhookPayload - }) - - afterEach(() => { - vi.clearAllMocks() - server.resetHandlers() - }) - - vi.mocked(core.getInput).mockImplementation((name: string, options?: core.InputOptions) => { - const value = inputs[name] ?? '' - - if (options?.required && value === undefined) { - throw new Error(`${name} is required`) - } - - return value - }) - it('creates a comment with message text', async () => { inputs.message = simpleMessage inputs['allow-repeats'] = 'true' @@ -271,7 +300,7 @@ describe('add-pr-comment action', () => { it('creates a message when the message id does not exist', async () => { inputs.message = simpleMessage - inputs['allow-repeats'] = 'false' + inputs['message-id'] = 'custom-id' const replyBody = [ @@ -289,7 +318,6 @@ describe('add-pr-comment action', () => { it('identifies an existing message by id and updates it', async () => { inputs.message = simpleMessage - inputs['allow-repeats'] = 'false' const commentId = 123 @@ -313,7 +341,7 @@ describe('add-pr-comment action', () => { it('overrides the default message with a success message on success', async () => { inputs.message = simpleMessage - inputs['allow-repeats'] = 'false' + inputs['message-success'] = '666' inputs.status = 'success' @@ -334,7 +362,7 @@ describe('add-pr-comment action', () => { it('overrides the default message with a failure message on failure', async () => { inputs.message = simpleMessage - inputs['allow-repeats'] = 'false' + inputs['message-failure'] = '666' inputs.status = 'failure' @@ -355,7 +383,7 @@ describe('add-pr-comment action', () => { it('overrides the default message with a cancelled message on cancelled', async () => { inputs.message = simpleMessage - inputs['allow-repeats'] = 'false' + inputs['message-cancelled'] = '666' inputs.status = 'cancelled' @@ -376,7 +404,7 @@ describe('add-pr-comment action', () => { it('overrides the default message with a skipped message on skipped', async () => { inputs.message = simpleMessage - inputs['allow-repeats'] = 'false' + inputs['message-skipped'] = '666' inputs.status = 'skipped' @@ -408,3 +436,169 @@ describe('add-pr-comment action', () => { expect(core.setOutput).toHaveBeenCalledWith('comment-id', postIssueCommentsResponse.id) }) }) + +describe('find and replace', () => { + it('can find and replace text in an existing comment', async () => { + inputs['find'] = 'world' + inputs['replace'] = 'mars' + + const commentId = 123 + + const replyBody = [ + { + id: commentId, + body: `\n\n${simpleMessage}`, + }, + ] + + getIssueCommentsResponse = replyBody + postIssueCommentsResponse = { + id: commentId, + } + + await run() + + expect(`\n\nhello mars`).toEqual(messagePayload?.body) + expect(core.setOutput).toHaveBeenCalledWith('comment-updated', 'true') + expect(core.setOutput).toHaveBeenCalledWith('comment-id', commentId) + }) + + it('can multiple find and replace text in an existing comment', async () => { + inputs['find'] = 'hello\nworld' + inputs['replace'] = 'goodbye\nmars' + + const body = `\n\nhello\nworld` + + const commentId = 123 + + const replyBody = [ + { + id: commentId, + body, + }, + ] + + getIssueCommentsResponse = replyBody + postIssueCommentsResponse = { + id: commentId, + } + + await run() + + expect(`\n\ngoodbye\nmars`).toEqual(messagePayload?.body) + expect(core.setOutput).toHaveBeenCalledWith('comment-updated', 'true') + expect(core.setOutput).toHaveBeenCalledWith('comment-id', commentId) + }) + + it('can multiple find and replace text using a message', async () => { + inputs['find'] = 'hello\nworld' + inputs['message'] = 'mars' + + const body = `\n\nhello\nworld` + + const commentId = 123 + + const replyBody = [ + { + id: commentId, + body, + }, + ] + + getIssueCommentsResponse = replyBody + postIssueCommentsResponse = { + id: commentId, + } + + await run() + + expect(`\n\nmars\nmars`).toEqual(messagePayload?.body) + expect(core.setOutput).toHaveBeenCalledWith('comment-updated', 'true') + expect(core.setOutput).toHaveBeenCalledWith('comment-id', commentId) + }) + + it('can multiple find and replace text using a message-path', async () => { + inputs['find'] = '<< FILE_CONTENTS >>' + inputs['message-path'] = messagePath1Fixture + + const body = `\n\nhello\n<< FILE_CONTENTS >>\nworld` + + const commentId = 123 + + const replyBody = [ + { + id: commentId, + body, + }, + ] + + getIssueCommentsResponse = replyBody + postIssueCommentsResponse = { + id: commentId, + } + + await run() + + expect( + `\n\nhello\n${messagePath1FixturePayload}\nworld`, + ).toEqual(messagePayload?.body) + expect(core.setOutput).toHaveBeenCalledWith('comment-updated', 'true') + expect(core.setOutput).toHaveBeenCalledWith('comment-id', commentId) + }) + + it('can find and replace patterns and use alternative modifiers', async () => { + inputs['find'] = '(o|l)/g' + inputs['replace'] = 'YY' + + const body = `\n\nHELLO\nworld` + + const commentId = 123 + + const replyBody = [ + { + id: commentId, + body, + }, + ] + + getIssueCommentsResponse = replyBody + postIssueCommentsResponse = { + id: commentId, + } + + await run() + + expect(`\n\nHELLO\nwYYrYYd`).toEqual(messagePayload?.body) + expect(core.setOutput).toHaveBeenCalledWith('comment-updated', 'true') + expect(core.setOutput).toHaveBeenCalledWith('comment-id', commentId) + }) + + it('can check some boxes with find and replace', async () => { + inputs['find'] = '\n\\[ \\]' + inputs['replace'] = '[X]' + + const body = `\n\n[ ] Hello\n[ ] World` + + const commentId = 123 + + const replyBody = [ + { + id: commentId, + body, + }, + ] + + getIssueCommentsResponse = replyBody + postIssueCommentsResponse = { + id: commentId, + } + + await run() + + expect(`\n\n[X] Hello\n[X] World`).toEqual( + messagePayload?.body, + ) + expect(core.setOutput).toHaveBeenCalledWith('comment-updated', 'true') + expect(core.setOutput).toHaveBeenCalledWith('comment-id', commentId) + }) +}) diff --git a/action.yml b/action.yml index a18897f..b16782c 100644 --- a/action.yml +++ b/action.yml @@ -58,7 +58,9 @@ inputs: required: false preformatted: description: "Treat message text (from a file or input) as pre-formatted and place it in a codeblock." - required: false + required: false + message-pattern: + description: "A regular expression to find and replace using the evaluated message." outputs: comment-created: description: "Whether a comment was created." diff --git a/src/comments.ts b/src/comments.ts index d103d33..1d3bcfd 100644 --- a/src/comments.ts +++ b/src/comments.ts @@ -1,16 +1,17 @@ import { GitHub } from '@actions/github/lib/utils' -import { Endpoints } from '@octokit/types' +import { + CreateIssueCommentResponseData, + ExistingIssueComment, + ExistingIssueCommentResponseData, +} from './types' -export type CreateIssueCommentResponseData = - Endpoints['POST /repos/{owner}/{repo}/issues/{issue_number}/comments']['response']['data'] - -export async function getExistingCommentId( +export async function getExistingComment( octokit: InstanceType, owner: string, repo: string, issueNumber: number, messageId: string, -): Promise { +): Promise { const parameters = { owner, repo, @@ -18,7 +19,7 @@ export async function getExistingCommentId( per_page: 100, } - let found + let found: ExistingIssueCommentResponseData | undefined for await (const comments of octokit.paginate.iterator( octokit.rest.issues.listComments, @@ -33,7 +34,12 @@ export async function getExistingCommentId( } } - return found?.id + if (found) { + const { id, body } = found + return { id, body } + } + + return } export async function updateComment( diff --git a/src/config.ts b/src/config.ts index 7fb0489..9429261 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,8 @@ export async function getInputs(): Promise { const messageId = messageIdInput === '' ? 'add-pr-comment' : `add-pr-comment:${messageIdInput}` const messageInput = core.getInput('message', { required: false }) const messagePath = core.getInput('message-path', { required: false }) + const messageFind = core.getMultilineInput('find', { required: false }) + const messageReplace = core.getMultilineInput('replace', { required: false }) const repoOwner = core.getInput('repo-owner', { required: true }) const repoName = core.getInput('repo-name', { required: true }) const repoToken = core.getInput('repo-token', { required: true }) @@ -41,6 +43,8 @@ export async function getInputs(): Promise { messageCancelled, messageSkipped, messagePath, + messageFind, + messageReplace, preformatted, proxyUrl, pullRequestNumber: payload.pull_request?.number, diff --git a/src/main.ts b/src/main.ts index 49786b1..f16cd0b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,16 +1,16 @@ import * as core from '@actions/core' import * as github from '@actions/github' -import { - CreateIssueCommentResponseData, - createComment, - deleteComment, - getExistingCommentId, - updateComment, -} from './comments' +import { createComment, deleteComment, getExistingComment, updateComment } from './comments' import { getInputs } from './config' import { getIssueNumberFromCommitPullsList } from './issues' -import { getMessage } from './message' +import { + addMessageHeader, + findAndReplaceInMessage, + getMessage, + removeMessageHeader, +} from './message' import { createCommentProxy } from './proxy' +import { CreateIssueCommentResponseData, ExistingIssueComment } from './types' const run = async (): Promise => { try { @@ -34,11 +34,13 @@ const run = async (): Promise => { messageSkipped, preformatted, status, + messageFind, + messageReplace, } = await getInputs() const octokit = github.getOctokit(repoToken) - const message = await getMessage({ + let message = await getMessage({ messagePath, messageInput, messageSkipped, @@ -68,20 +70,20 @@ const run = async (): Promise => { return } - let existingCommentId + let existingComment: ExistingIssueComment | undefined if (!allowRepeats) { core.debug('repeat comments are disallowed, checking for existing') - existingCommentId = await getExistingCommentId(octokit, owner, repo, issueNumber, messageId) + existingComment = await getExistingComment(octokit, owner, repo, issueNumber, messageId) - if (existingCommentId) { - core.debug(`existing comment found with id: ${existingCommentId}`) + if (existingComment) { + core.debug(`existing comment found with id: ${existingComment.id}`) } } // if no existing comment and updateOnly is true, exit - if (!existingCommentId && updateOnly) { + if (!existingComment && updateOnly) { core.info('no existing comment found and update-only is true, exiting') core.setOutput('comment-created', 'false') return @@ -89,11 +91,23 @@ const run = async (): Promise => { let comment: CreateIssueCommentResponseData | null | undefined - const body = `${messageId}\n\n${message}` + if (messageFind?.length && (messageReplace?.length || message) && existingComment?.body) { + message = findAndReplaceInMessage( + messageFind, + messageReplace?.length ? messageReplace : [message], + removeMessageHeader(existingComment.body), + ) + } + + if (!message) { + throw new Error('no message, check your message inputs') + } + + const body = addMessageHeader(messageId, message) if (proxyUrl) { comment = await createCommentProxy({ - commentId: existingCommentId, + commentId: existingComment?.id, owner, repo, issueNumber, @@ -101,13 +115,13 @@ const run = async (): Promise => { repoToken, proxyUrl, }) - core.setOutput(existingCommentId ? 'comment-updated' : 'comment-created', 'true') - } else if (existingCommentId) { + core.setOutput(existingComment?.id ? 'comment-updated' : 'comment-created', 'true') + } else if (existingComment?.id) { if (refreshMessagePosition) { - await deleteComment(octokit, owner, repo, existingCommentId, body) + await deleteComment(octokit, owner, repo, existingComment.id, body) comment = await createComment(octokit, owner, repo, issueNumber, body) } else { - comment = await updateComment(octokit, owner, repo, existingCommentId, body) + comment = await updateComment(octokit, owner, repo, existingComment.id, body) } core.setOutput('comment-updated', 'true') } else { diff --git a/src/message.ts b/src/message.ts index b39a57e..27e14ed 100644 --- a/src/message.ts +++ b/src/message.ts @@ -21,7 +21,7 @@ export async function getMessage({ | 'messagePath' | 'preformatted' | 'status' ->) { +>): Promise { let message if (status === 'success' && messageSuccess) { @@ -48,15 +48,11 @@ export async function getMessage({ } } - if (!message) { - throw new Error('no message, check your message inputs') - } - if (preformatted) { message = `\`\`\`\n${message}\n\`\`\`` } - return message + return message ?? '' } export async function getMessageFromPath(searchPath: string) { @@ -74,3 +70,45 @@ export async function getMessageFromPath(searchPath: string) { return message } + +export function addMessageHeader(messageId: string, message: string) { + return `${messageId}\n\n${message}` +} + +export function removeMessageHeader(message: string) { + return message.split('\n').slice(2).join('\n') +} + +function splitFind(find: string) { + const matches = find.match(/\/((i|g|m|s|u|y){1,6})$/) + + if (!matches) { + return { + regExp: find, + modifiers: 'gi', + } + } + + const [, modifiers] = matches + const regExp = find.replace(modifiers, '').slice(0, -1) + + return { + regExp, + modifiers, + } +} + +export function findAndReplaceInMessage( + find: string[], + replacement: string[], + original: string, +): string { + let message = original + + for (const [i, f] of find.entries()) { + const { regExp, modifiers } = splitFind(f) + message = message.replace(new RegExp(regExp, modifiers), replacement[i] ?? replacement[0]) + } + + return message +} diff --git a/src/types.ts b/src/types.ts index c0623af..ffe2675 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import { Endpoints } from '@octokit/types' + export interface Inputs { allowRepeats: boolean attachPath?: string[] @@ -6,6 +8,8 @@ export interface Inputs { messageInput?: string messageId: string messagePath?: string + messageFind?: string[] + messageReplace?: string[] messageSuccess?: string messageFailure?: string messageCancelled?: string @@ -20,3 +24,11 @@ export interface Inputs { owner: string updateOnly: boolean } + +export type CreateIssueCommentResponseData = + Endpoints['POST /repos/{owner}/{repo}/issues/{issue_number}/comments']['response']['data'] + +export type ExistingIssueCommentResponseData = + Endpoints['GET /repos/{owner}/{repo}/issues/{issue_number}/comments']['response']['data'][0] + +export type ExistingIssueComment = Pick