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**
🌏
!
- uses: ./
with:
message-id: text
find: |
Hello
🌏
replace: |
Goodnight
🌕

132
README.md
View file

@ -1,7 +1,9 @@
# add-pr-comment
<!-- 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-BADGE:END -->
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

View file

@ -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: `<!-- 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:
description: "Treat message text (from a file or input) as pre-formatted and place it in a codeblock."
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."

View file

@ -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<typeof GitHub>,
owner: string,
repo: string,
issueNumber: number,
messageId: string,
): Promise<number | undefined> {
): Promise<ExistingIssueComment | undefined> {
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(

View file

@ -7,6 +7,8 @@ export async function getInputs(): Promise<Inputs> {
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<Inputs> {
messageCancelled,
messageSkipped,
messagePath,
messageFind,
messageReplace,
preformatted,
proxyUrl,
pullRequestNumber: payload.pull_request?.number,

View file

@ -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<void> => {
try {
@ -34,11 +34,13 @@ const run = async (): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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 {

View file

@ -21,7 +21,7 @@ export async function getMessage({
| 'messagePath'
| 'preformatted'
| 'status'
>) {
>): Promise<string> {
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
}

View file

@ -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<ExistingIssueCommentResponseData, 'id' | 'body'>