diff --git a/.github/test/file-1.txt b/.github/test/file-1.txt new file mode 100644 index 0000000..5ab2f8a --- /dev/null +++ b/.github/test/file-1.txt @@ -0,0 +1 @@ +Hello \ No newline at end of file diff --git a/.github/test/file-2.txt b/.github/test/file-2.txt new file mode 100644 index 0000000..e80f09a --- /dev/null +++ b/.github/test/file-2.txt @@ -0,0 +1 @@ +Goodbye \ No newline at end of file diff --git a/.github/test/image.png b/.github/test/image.png new file mode 100644 index 0000000..e1f3ac6 Binary files /dev/null and b/.github/test/image.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26cec3d..d0c9f2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,14 @@ jobs: - uses: ./ with: + message-id: path + message-path: | + .github/test/file-*.txt + + - uses: ./ + with: + message-id: text message: | - **Hello ${{ github.run_number }}** + **Hello** 🌏 - ! + ! diff --git a/README.md b/README.md index 07e5a5f..339dc3f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # add-pr-comment + + [![All Contributors](https://img.shields.io/badge/all_contributors-5-orange.svg?style=flat-square)](#contributors-) + A GitHub Action which adds a comment to a pull request's issue. @@ -65,25 +68,25 @@ jobs: ## Configuration options -| Input | Location | Description | Required | Default | -|--------------------------|----------|------------------------------------------------------------------------------------------------------|----------|------------------------------------| -| message | with | The message you'd like displayed, supports Markdown and all valid Unicode characters. | maybe | | -| message-path | with | Path to a message you'd like displayed. Will be read and displayed just like a normal message. | maybe | | -| message-success | with | A message override, printed in case of success. | no | | -| message-failure | with | A message override, printed in case of failure. | no | | -| message-cancelled | with | A message override, printed in case of cancelled. | no | | -| message-skipped | with | A message override, printed in case of skipped. | no | | -| status | with | Required if you want to use message status overrides. | no | {{ job.status }} | -| repo-owner | with | Owner of the repo. | no | {{ github.repository_owner }} | -| repo-name | with | Name of the repo. | no | {{ github.event.repository.name }} | -| repo-token | with | Valid GitHub token, either the temporary token GitHub provides or a personal access token. | no | {{ github.token }} | -| message-id | with | Message id to use when searching existing comments. If found, updates the existing (sticky comment). | no | | -| refresh-message-position | with | Should the sticky message be the last one in the PR's feed. | no | false | -| allow-repeats | with | Boolean flag to allow identical messages to be posted each time this action is run. | no | false | -| proxy-url | with | String for your proxy service URL if you'd like this to work with fork-based PRs. | no | | -| issue | with | Optional issue number override. | no | | -| update-only | with | Only update the comment if it already exists. | no | false | -| GITHUB_TOKEN | env | Valid GitHub token, can alternatively be defined in the env. | no | | +| Input | Location | Description | Required | Default | +| ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------- | +| message | with | The message you'd like displayed, supports Markdown and all valid Unicode characters. | maybe | | +| message-path | with | Path to a message you'd like displayed. Will be read and displayed just like a normal message. Supports multi-line input and globs. Multiple messages will be concatenated. | maybe | | +| message-success | with | A message override, printed in case of success. | no | | +| message-failure | with | A message override, printed in case of failure. | no | | +| message-cancelled | with | A message override, printed in case of cancelled. | no | | +| message-skipped | with | A message override, printed in case of skipped. | no | | +| status | with | Required if you want to use message status overrides. | no | {{ job.status }} | +| repo-owner | with | Owner of the repo. | no | {{ github.repository_owner }} | +| repo-name | with | Name of the repo. | no | {{ github.event.repository.name }} | +| repo-token | with | Valid GitHub token, either the temporary token GitHub provides or a personal access token. | no | {{ github.token }} | +| message-id | with | Message id to use when searching existing comments. If found, updates the existing (sticky comment). | no | | +| refresh-message-position | with | Should the sticky message be the last one in the PR's feed. | no | false | +| allow-repeats | with | Boolean flag to allow identical messages to be posted each time this action is run. | no | false | +| proxy-url | with | String for your proxy service URL if you'd like this to work with fork-based PRs. | no | | +| issue | with | Optional issue number override. | no | | +| update-only | with | Only update the comment if it already exists. | no | false | +| GITHUB_TOKEN | env | Valid GitHub token, can alternatively be defined in the env. | no | | ## Advanced Uses @@ -141,6 +144,31 @@ jobs: Uh oh! ``` +### Multiple Message Files + +Instead of directly setting the message you can also load a file with the text +of your message using `message-path`. `message-path` supports loading multiple +files and files on multiple lines, the contents of which will be concatenated. + +**Example** + +````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-part-*.txt +``` + ### Bring your own issues You can set an issue id explicitly. Helpful for cases where you want to post @@ -173,7 +201,7 @@ jobs: issue: ${{ steps.pr.outputs.issue }} message: | **Howdie!** -``` +```` ## Contributors ✨ diff --git a/__tests__/add-pr-comment.test.ts b/__tests__/add-pr-comment.test.ts index 269f8c3..237a89c 100644 --- a/__tests__/add-pr-comment.test.ts +++ b/__tests__/add-pr-comment.test.ts @@ -3,11 +3,16 @@ import * as github from '@actions/github' import { WebhookPayload } from '@actions/github/lib/interfaces' import { rest } from 'msw' import { setupServer } from 'msw/node' +import * as fs from 'node:fs/promises' import * as path from 'node:path' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import run from '../src/main' import apiResponse from './sample-pulls-api-response.json' +const messagePath1Fixture = path.resolve(__dirname, './message-part-1.txt') +const messagePath1FixturePayload = await fs.readFile(messagePath1Fixture, 'utf-8') +const messagePath2Fixture = path.resolve(__dirname, './message-part-2.txt') + const repoToken = '12345' const commitSha = 'abc123' const simpleMessage = 'hello world' @@ -89,12 +94,19 @@ const handlers = [ const server = setupServer(...handlers) describe('add-pr-comment action', () => { - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + 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() @@ -141,17 +153,46 @@ describe('add-pr-comment action', () => { it('creates a comment with a message-path', async () => { inputs.message = undefined - inputs['message-path'] = path.resolve(__dirname, './message.txt') + inputs['message-path'] = messagePath1Fixture inputs['allow-repeats'] = 'true' await expect(run()).resolves.not.toThrow() + expect(`\n\n${messagePath1FixturePayload}`).toEqual( + messagePayload?.body, + ) + expect(core.setOutput).toHaveBeenCalledWith('comment-created', 'true') + expect(core.setOutput).toHaveBeenCalledWith('comment-id', postIssueCommentsResponse.id) + }) + + it('creates a comment with multiple message-paths concatenated', async () => { + inputs.message = undefined + inputs['message-path'] = `${messagePath1Fixture}\n${messagePath2Fixture}` + inputs['allow-repeats'] = 'true' + + await expect(run()).resolves.not.toThrow() + expect( + `\n\n${messagePath1FixturePayload}\n${messagePath1FixturePayload}`, + ).toEqual(messagePayload?.body) + expect(core.setOutput).toHaveBeenCalledWith('comment-created', 'true') + expect(core.setOutput).toHaveBeenCalledWith('comment-id', postIssueCommentsResponse.id) + }) + + it('supports globs in message paths', async () => { + inputs.message = undefined + inputs['message-path'] = `${path.resolve(__dirname)}/message-part-*.txt` + inputs['allow-repeats'] = 'true' + + await expect(run()).resolves.not.toThrow() + expect( + `\n\n${messagePath1FixturePayload}\n${messagePath1FixturePayload}`, + ).toEqual(messagePayload?.body) expect(core.setOutput).toHaveBeenCalledWith('comment-created', 'true') expect(core.setOutput).toHaveBeenCalledWith('comment-id', postIssueCommentsResponse.id) }) it('fails when both message and message-path are defined', async () => { inputs.message = 'foobar' - inputs['message-path'] = path.resolve(__dirname, './message.txt') + inputs['message-path'] = messagePath1Fixture await expect(run()).resolves.not.toThrow() expect(core.setFailed).toHaveBeenCalledWith('must specify only one, message or message-path') diff --git a/__tests__/message.txt b/__tests__/message-part-1.txt similarity index 100% rename from __tests__/message.txt rename to __tests__/message-part-1.txt diff --git a/__tests__/message-part-2.txt b/__tests__/message-part-2.txt new file mode 100644 index 0000000..b039b81 --- /dev/null +++ b/__tests__/message-part-2.txt @@ -0,0 +1,7 @@ +## [Preview link](https://antares-blog-staging-pr-${{ github.event.number }}.azurewebsites.net) + +- Your changes have been deployed to the preview site. The preview site will update as you add more commits to this branch. +- The preview site shows any future-dated articles. Don't worry, if you are publishing a future-dated article, it will not show on the production site until the file's specified date. +- The preview link is shareable, but will be deleted when the pull request is merged or closed. + +> *This is an automated message.* diff --git a/action.yml b/action.yml index fa6f84a..991ce58 100644 --- a/action.yml +++ b/action.yml @@ -5,7 +5,7 @@ inputs: description: "The message to print." required: false message-path: - description: "A path to a file to print as a message instead of a string." + description: "A path or list of paths to a file to print as a message instead of a string." required: false message-id: description: "An optional id to use for this message." diff --git a/package-lock.json b/package-lock.json index 541c5a8..3e57a9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "2.4.0", "license": "MIT", "dependencies": { + "@actions/artifact": "^1.1.1", "@actions/core": "^1.10.0", "@actions/github": "^5.1.1", + "@actions/glob": "^0.4.0", "@actions/http-client": "^2.1.0" }, "devDependencies": { @@ -38,6 +40,28 @@ "node": "^14.15.0 || ^16.13.0 || ^18.0.0" } }, + "node_modules/@actions/artifact": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/artifact/-/artifact-1.1.1.tgz", + "integrity": "sha512-Vv4y0EW0ptEkU+Pjs5RGS/0EryTvI6s79LjSV9Gg/h+O3H/ddpjhuX/Bi/HZE4pbNPyjGtQjbdFWphkZhmgabA==", + "dependencies": { + "@actions/core": "^1.9.1", + "@actions/http-client": "^2.0.1", + "tmp": "^0.2.1", + "tmp-promise": "^3.0.2" + } + }, + "node_modules/@actions/artifact/node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, "node_modules/@actions/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.0.tgz", @@ -58,6 +82,15 @@ "@octokit/plugin-rest-endpoint-methods": "^5.13.0" } }, + "node_modules/@actions/glob": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.4.0.tgz", + "integrity": "sha512-+eKIGFhsFa4EBwaf/GMyzCdWrXWymGXfFmZU3FHQvYS8mPcHtTtZONbkcqqUMzw9mJ/pImEBFET1JNifhqGsAQ==", + "dependencies": { + "@actions/core": "^1.9.1", + "minimatch": "^3.0.4" + } + }, "node_modules/@actions/http-client": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.0.tgz", @@ -1799,8 +1832,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.5.1", @@ -1879,7 +1911,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2405,8 +2436,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/concordance": { "version": "5.0.4", @@ -3753,8 +3783,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.2", @@ -3891,7 +3920,6 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -4427,7 +4455,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -4436,8 +4463,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "1.3.7", @@ -7483,7 +7509,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -8921,7 +8946,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -9618,7 +9642,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -10312,6 +10335,25 @@ "node": ">=0.6.0" } }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/tmp-promise/node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, "node_modules/to-readable-stream": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-2.1.0.tgz", diff --git a/package.json b/package.json index dc426d5..ad2799e 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,23 @@ "@typescript-eslint/no-explicit-any": "off" } }, + { + "files": [ + "**/*.test.ts" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "extends": [ + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-explicit-any": "off" + } + }, { "files": [ "*.json" @@ -103,8 +120,10 @@ "dist" ], "dependencies": { + "@actions/artifact": "^1.1.1", "@actions/core": "^1.10.0", "@actions/github": "^5.1.1", + "@actions/glob": "^0.4.0", "@actions/http-client": "^2.1.0" }, "devDependencies": { diff --git a/src/config.ts b/src/config.ts index aad7a93..e5045b0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,12 @@ import * as core from '@actions/core' import * as github from '@actions/github' -import fs from 'node:fs/promises' +import { getMessageFromPaths } from './util' interface Inputs { - refreshMessagePosition: boolean allowRepeats: boolean + attachPath?: string[] + commitSha: string + issue?: number message?: string messageId: string messagePath?: string @@ -12,12 +14,11 @@ interface Inputs { messageFailure?: string messageCancelled?: string proxyUrl?: string + pullRequestNumber?: number + refreshMessagePosition: boolean + repo: string repoToken: string status?: string - issue?: number - commitSha: string - pullRequestNumber?: number - repo: string owner: string updateOnly: boolean } @@ -38,14 +39,14 @@ export async function getInputs(): Promise { core.getInput('refresh-message-position', { required: false }) === 'true' const updateOnly = core.getInput('update-only', { required: false }) === 'true' - if (messageInput && messagePath) { + if (messageInput && messagePath.length) { throw new Error('must specify only one, message or message-path') } let message - if (messagePath) { - message = await fs.readFile(messagePath, { encoding: 'utf8' }) + if (messagePath.length) { + message = await getMessageFromPaths(messagePath) } else { message = messageInput } @@ -78,16 +79,16 @@ export async function getInputs(): Promise { const { payload } = github.context return { - refreshMessagePosition, allowRepeats, + commitSha: github.context.sha, + issue: issue ? Number(issue) : payload.issue?.number, message, messageId: ``, proxyUrl, + pullRequestNumber: payload.pull_request?.number, + refreshMessagePosition, repoToken, status, - issue: issue ? Number(issue) : payload.issue?.number, - pullRequestNumber: payload.pull_request?.number, - commitSha: github.context.sha, owner: repoOwner || payload.repo.owner, repo: repoName || payload.repo.repo, updateOnly: updateOnly, diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..ed58c01 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,48 @@ +import * as core from '@actions/core' +import * as glob from '@actions/glob' +import fs from 'node:fs/promises' + +export async function getMessageFromPaths(searchPath: string) { + let message = '' + + const files = await findFiles(searchPath) + + for (const [index, path] of files.entries()) { + if (index > 0) { + message += '\n' + } + + message += await fs.readFile(path, { encoding: 'utf8' }) + } + + return message +} + +function getDefaultGlobOptions(): glob.GlobOptions { + return { + followSymbolicLinks: true, + implicitDescendants: true, + omitBrokenSymbolicLinks: true, + } +} + +export async function findFiles( + searchPath: string, + globOptions?: glob.GlobOptions, +): Promise { + const searchResults: string[] = [] + const globber = await glob.create(searchPath, globOptions || getDefaultGlobOptions()) + const rawSearchResults: string[] = await globber.glob() + + for (const searchResult of rawSearchResults) { + const fileStats = await fs.stat(searchResult) + if (!fileStats.isDirectory()) { + core.debug(`File: ${searchResult} was found using the provided searchPath`) + searchResults.push(searchResult) + } else { + core.debug(`Removing ${searchResult} from rawSearchResults because it is a directory`) + } + } + + return searchResults +} diff --git a/tsconfig.json b/tsconfig.json index 7a9a1db..a7a2e40 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "removeComments": false, "preserveConstEnums": true, "resolveJsonModule": true, - "rootDir": "./src", + "rootDir": "src", "outDir": "./lib" }, "exclude": ["node_modules", "**/*.test.ts"]