find-and-replace functionality (#100)

* find-and-replace functionality
This commit is contained in:
Michael Shick 2023-05-07 08:50:52 -04:00 committed by GitHub
parent 5cc4621415
commit f8c324a9fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 502 additions and 90 deletions

View file

@ -74,3 +74,13 @@ jobs:
**Hello** **Hello**
🌏 🌏
! !
- uses: ./
with:
message-id: text
find: |
Hello
🌏
replace: |
Goodnight
🌕

132
README.md
View file

@ -1,7 +1,9 @@
# add-pr-comment # add-pr-comment
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-6-orange.svg?style=flat-square)](#contributors-) [![All Contributors](https://img.shields.io/badge/all_contributors-6-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
A GitHub Action which adds a comment to a pull request's issue. A GitHub Action which adds a comment to a pull request's issue.
@ -168,6 +170,136 @@ jobs:
message-part-*.txt 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 ### Bring your own issues
You can set an issue id explicitly. Helpful for cases where you want to post You can set an issue id explicitly. Helpful for cases where you want to post

View file

@ -25,6 +25,7 @@ type Inputs = {
'repo-token': string 'repo-token': string
'message-id': string 'message-id': string
'allow-repeats': string 'allow-repeats': string
'message-pattern'?: string
'message-success'?: string 'message-success'?: string
'message-failure'?: string 'message-failure'?: string
'message-cancelled'?: string 'message-cancelled'?: string
@ -95,17 +96,16 @@ const handlers = [
const server = setupServer(...handlers) const server = setupServer(...handlers)
describe('add-pr-comment action', () => { beforeAll(() => {
beforeAll(() => {
// vi.spyOn(console, 'log').mockImplementation(() => {}) // vi.spyOn(console, 'log').mockImplementation(() => {})
// vi.spyOn(core, 'debug').mockImplementation(() => {}) // vi.spyOn(core, 'debug').mockImplementation(() => {})
// vi.spyOn(core, 'info').mockImplementation(() => {}) // vi.spyOn(core, 'info').mockImplementation(() => {})
// vi.spyOn(core, 'warning').mockImplementation(() => {}) // vi.spyOn(core, 'warning').mockImplementation(() => {})
server.listen({ onUnhandledRequest: 'error' }) server.listen({ onUnhandledRequest: 'error' })
}) })
afterAll(() => server.close()) afterAll(() => server.close())
beforeEach(() => { beforeEach(() => {
inputs = { ...defaultInputs } inputs = { ...defaultInputs }
issueNumber = defaultIssueNumber issueNumber = defaultIssueNumber
messagePayload = undefined messagePayload = undefined
@ -127,14 +127,14 @@ describe('add-pr-comment action', () => {
}, },
}, },
} as WebhookPayload } as WebhookPayload
}) })
afterEach(() => { afterEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
server.resetHandlers() server.resetHandlers()
}) })
vi.mocked(core.getInput).mockImplementation((name: string, options?: core.InputOptions) => { const getInput = (name: string, options?: core.InputOptions) => {
const value = inputs[name] ?? '' const value = inputs[name] ?? ''
if (options?.required && value === undefined) { if (options?.required && value === undefined) {
@ -142,8 +142,37 @@ describe('add-pr-comment action', () => {
} }
return value 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', () => {
it('creates a comment with message text', async () => { it('creates a comment with message text', async () => {
inputs.message = simpleMessage inputs.message = simpleMessage
inputs['allow-repeats'] = 'true' 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 () => { it('creates a message when the message id does not exist', async () => {
inputs.message = simpleMessage inputs.message = simpleMessage
inputs['allow-repeats'] = 'false'
inputs['message-id'] = 'custom-id' inputs['message-id'] = 'custom-id'
const replyBody = [ const replyBody = [
@ -289,7 +318,6 @@ describe('add-pr-comment action', () => {
it('identifies an existing message by id and updates it', async () => { it('identifies an existing message by id and updates it', async () => {
inputs.message = simpleMessage inputs.message = simpleMessage
inputs['allow-repeats'] = 'false'
const commentId = 123 const commentId = 123
@ -313,7 +341,7 @@ describe('add-pr-comment action', () => {
it('overrides the default message with a success message on success', async () => { it('overrides the default message with a success message on success', async () => {
inputs.message = simpleMessage inputs.message = simpleMessage
inputs['allow-repeats'] = 'false'
inputs['message-success'] = '666' inputs['message-success'] = '666'
inputs.status = 'success' inputs.status = 'success'
@ -334,7 +362,7 @@ describe('add-pr-comment action', () => {
it('overrides the default message with a failure message on failure', async () => { it('overrides the default message with a failure message on failure', async () => {
inputs.message = simpleMessage inputs.message = simpleMessage
inputs['allow-repeats'] = 'false'
inputs['message-failure'] = '666' inputs['message-failure'] = '666'
inputs.status = 'failure' inputs.status = 'failure'
@ -355,7 +383,7 @@ describe('add-pr-comment action', () => {
it('overrides the default message with a cancelled message on cancelled', async () => { it('overrides the default message with a cancelled message on cancelled', async () => {
inputs.message = simpleMessage inputs.message = simpleMessage
inputs['allow-repeats'] = 'false'
inputs['message-cancelled'] = '666' inputs['message-cancelled'] = '666'
inputs.status = 'cancelled' inputs.status = 'cancelled'
@ -376,7 +404,7 @@ describe('add-pr-comment action', () => {
it('overrides the default message with a skipped message on skipped', async () => { it('overrides the default message with a skipped message on skipped', async () => {
inputs.message = simpleMessage inputs.message = simpleMessage
inputs['allow-repeats'] = 'false'
inputs['message-skipped'] = '666' inputs['message-skipped'] = '666'
inputs.status = 'skipped' inputs.status = 'skipped'
@ -408,3 +436,169 @@ describe('add-pr-comment action', () => {
expect(core.setOutput).toHaveBeenCalledWith('comment-id', postIssueCommentsResponse.id) 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: `<!-- add-pr-comment:${inputs['message-id']} -->\n\n${simpleMessage}`,
},
]
getIssueCommentsResponse = replyBody
postIssueCommentsResponse = {
id: commentId,
}
await run()
expect(`<!-- add-pr-comment:add-pr-comment -->\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 = `<!-- add-pr-comment:${inputs['message-id']} -->\n\nhello\nworld`
const commentId = 123
const replyBody = [
{
id: commentId,
body,
},
]
getIssueCommentsResponse = replyBody
postIssueCommentsResponse = {
id: commentId,
}
await run()
expect(`<!-- add-pr-comment:add-pr-comment -->\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 = `<!-- add-pr-comment:${inputs['message-id']} -->\n\nhello\nworld`
const commentId = 123
const replyBody = [
{
id: commentId,
body,
},
]
getIssueCommentsResponse = replyBody
postIssueCommentsResponse = {
id: commentId,
}
await run()
expect(`<!-- add-pr-comment:add-pr-comment -->\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 = `<!-- add-pr-comment:${inputs['message-id']} -->\n\nhello\n<< FILE_CONTENTS >>\nworld`
const commentId = 123
const replyBody = [
{
id: commentId,
body,
},
]
getIssueCommentsResponse = replyBody
postIssueCommentsResponse = {
id: commentId,
}
await run()
expect(
`<!-- add-pr-comment:add-pr-comment -->\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 = `<!-- add-pr-comment:${inputs['message-id']} -->\n\nHELLO\nworld`
const commentId = 123
const replyBody = [
{
id: commentId,
body,
},
]
getIssueCommentsResponse = replyBody
postIssueCommentsResponse = {
id: commentId,
}
await run()
expect(`<!-- add-pr-comment:add-pr-comment -->\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 = `<!-- add-pr-comment:${inputs['message-id']} -->\n\n[ ] Hello\n[ ] World`
const commentId = 123
const replyBody = [
{
id: commentId,
body,
},
]
getIssueCommentsResponse = replyBody
postIssueCommentsResponse = {
id: commentId,
}
await run()
expect(`<!-- add-pr-comment:add-pr-comment -->\n\n[X] Hello\n[X] World`).toEqual(
messagePayload?.body,
)
expect(core.setOutput).toHaveBeenCalledWith('comment-updated', 'true')
expect(core.setOutput).toHaveBeenCalledWith('comment-id', commentId)
})
})

View file

@ -59,6 +59,8 @@ inputs:
preformatted: preformatted:
description: "Treat message text (from a file or input) as pre-formatted and place it in a codeblock." 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: outputs:
comment-created: comment-created:
description: "Whether a comment was created." description: "Whether a comment was created."

View file

@ -1,16 +1,17 @@
import { GitHub } from '@actions/github/lib/utils' import { GitHub } from '@actions/github/lib/utils'
import { Endpoints } from '@octokit/types' import {
CreateIssueCommentResponseData,
ExistingIssueComment,
ExistingIssueCommentResponseData,
} from './types'
export type CreateIssueCommentResponseData = export async function getExistingComment(
Endpoints['POST /repos/{owner}/{repo}/issues/{issue_number}/comments']['response']['data']
export async function getExistingCommentId(
octokit: InstanceType<typeof GitHub>, octokit: InstanceType<typeof GitHub>,
owner: string, owner: string,
repo: string, repo: string,
issueNumber: number, issueNumber: number,
messageId: string, messageId: string,
): Promise<number | undefined> { ): Promise<ExistingIssueComment | undefined> {
const parameters = { const parameters = {
owner, owner,
repo, repo,
@ -18,7 +19,7 @@ export async function getExistingCommentId(
per_page: 100, per_page: 100,
} }
let found let found: ExistingIssueCommentResponseData | undefined
for await (const comments of octokit.paginate.iterator( for await (const comments of octokit.paginate.iterator(
octokit.rest.issues.listComments, 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( export async function updateComment(

View file

@ -7,6 +7,8 @@ export async function getInputs(): Promise<Inputs> {
const messageId = messageIdInput === '' ? 'add-pr-comment' : `add-pr-comment:${messageIdInput}` const messageId = messageIdInput === '' ? 'add-pr-comment' : `add-pr-comment:${messageIdInput}`
const messageInput = core.getInput('message', { required: false }) const messageInput = core.getInput('message', { required: false })
const messagePath = core.getInput('message-path', { 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 repoOwner = core.getInput('repo-owner', { required: true })
const repoName = core.getInput('repo-name', { required: true }) const repoName = core.getInput('repo-name', { required: true })
const repoToken = core.getInput('repo-token', { required: true }) const repoToken = core.getInput('repo-token', { required: true })
@ -41,6 +43,8 @@ export async function getInputs(): Promise<Inputs> {
messageCancelled, messageCancelled,
messageSkipped, messageSkipped,
messagePath, messagePath,
messageFind,
messageReplace,
preformatted, preformatted,
proxyUrl, proxyUrl,
pullRequestNumber: payload.pull_request?.number, pullRequestNumber: payload.pull_request?.number,

View file

@ -1,16 +1,16 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import * as github from '@actions/github' import * as github from '@actions/github'
import { import { createComment, deleteComment, getExistingComment, updateComment } from './comments'
CreateIssueCommentResponseData,
createComment,
deleteComment,
getExistingCommentId,
updateComment,
} from './comments'
import { getInputs } from './config' import { getInputs } from './config'
import { getIssueNumberFromCommitPullsList } from './issues' import { getIssueNumberFromCommitPullsList } from './issues'
import { getMessage } from './message' import {
addMessageHeader,
findAndReplaceInMessage,
getMessage,
removeMessageHeader,
} from './message'
import { createCommentProxy } from './proxy' import { createCommentProxy } from './proxy'
import { CreateIssueCommentResponseData, ExistingIssueComment } from './types'
const run = async (): Promise<void> => { const run = async (): Promise<void> => {
try { try {
@ -34,11 +34,13 @@ const run = async (): Promise<void> => {
messageSkipped, messageSkipped,
preformatted, preformatted,
status, status,
messageFind,
messageReplace,
} = await getInputs() } = await getInputs()
const octokit = github.getOctokit(repoToken) const octokit = github.getOctokit(repoToken)
const message = await getMessage({ let message = await getMessage({
messagePath, messagePath,
messageInput, messageInput,
messageSkipped, messageSkipped,
@ -68,20 +70,20 @@ const run = async (): Promise<void> => {
return return
} }
let existingCommentId let existingComment: ExistingIssueComment | undefined
if (!allowRepeats) { if (!allowRepeats) {
core.debug('repeat comments are disallowed, checking for existing') 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) { if (existingComment) {
core.debug(`existing comment found with id: ${existingCommentId}`) core.debug(`existing comment found with id: ${existingComment.id}`)
} }
} }
// if no existing comment and updateOnly is true, exit // 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.info('no existing comment found and update-only is true, exiting')
core.setOutput('comment-created', 'false') core.setOutput('comment-created', 'false')
return return
@ -89,11 +91,23 @@ const run = async (): Promise<void> => {
let comment: CreateIssueCommentResponseData | null | undefined 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) { if (proxyUrl) {
comment = await createCommentProxy({ comment = await createCommentProxy({
commentId: existingCommentId, commentId: existingComment?.id,
owner, owner,
repo, repo,
issueNumber, issueNumber,
@ -101,13 +115,13 @@ const run = async (): Promise<void> => {
repoToken, repoToken,
proxyUrl, proxyUrl,
}) })
core.setOutput(existingCommentId ? 'comment-updated' : 'comment-created', 'true') core.setOutput(existingComment?.id ? 'comment-updated' : 'comment-created', 'true')
} else if (existingCommentId) { } else if (existingComment?.id) {
if (refreshMessagePosition) { 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) comment = await createComment(octokit, owner, repo, issueNumber, body)
} else { } else {
comment = await updateComment(octokit, owner, repo, existingCommentId, body) comment = await updateComment(octokit, owner, repo, existingComment.id, body)
} }
core.setOutput('comment-updated', 'true') core.setOutput('comment-updated', 'true')
} else { } else {

View file

@ -21,7 +21,7 @@ export async function getMessage({
| 'messagePath' | 'messagePath'
| 'preformatted' | 'preformatted'
| 'status' | 'status'
>) { >): Promise<string> {
let message let message
if (status === 'success' && messageSuccess) { 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) { if (preformatted) {
message = `\`\`\`\n${message}\n\`\`\`` message = `\`\`\`\n${message}\n\`\`\``
} }
return message return message ?? ''
} }
export async function getMessageFromPath(searchPath: string) { export async function getMessageFromPath(searchPath: string) {
@ -74,3 +70,45 @@ export async function getMessageFromPath(searchPath: string) {
return message 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
}

View file

@ -1,3 +1,5 @@
import { Endpoints } from '@octokit/types'
export interface Inputs { export interface Inputs {
allowRepeats: boolean allowRepeats: boolean
attachPath?: string[] attachPath?: string[]
@ -6,6 +8,8 @@ export interface Inputs {
messageInput?: string messageInput?: string
messageId: string messageId: string
messagePath?: string messagePath?: string
messageFind?: string[]
messageReplace?: string[]
messageSuccess?: string messageSuccess?: string
messageFailure?: string messageFailure?: string
messageCancelled?: string messageCancelled?: string
@ -20,3 +24,11 @@ export interface Inputs {
owner: string owner: string
updateOnly: boolean 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<ExistingIssueCommentResponseData, 'id' | 'body'>