From 251cb0dd99f58eca55a7d993bce98757cb8c78b8 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski Date: Tue, 3 Nov 2026 14:22:37 -0300 Subject: [PATCH 1/4] Let a test rule answer 204 with an empty body --- tests/support/actions.js | 5 ++++- tests/support/actions.test.js | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/support/actions.js b/tests/support/actions.js index 35a02a4..a579552 100644 --- a/tests/support/actions.js +++ b/tests/support/actions.js @@ -32,7 +32,10 @@ const path = require('node:path') * @returns {Response} The response for the fetch to return. */ function jsonResponse({ status = 200, body = {}, headers = {} }) { - return new Response(JSON.stringify(body), { + // A 204 or 304 has no body, and Response refuses one. + const content = status === 204 || status === 304 ? null : JSON.stringify(body) + + return new Response(content, { status, headers: { 'content-type': 'application/json', ...headers }, }) diff --git a/tests/support/actions.test.js b/tests/support/actions.test.js index 4788a1c..97e683f 100644 --- a/tests/support/actions.test.js +++ b/tests/support/actions.test.js @@ -3,7 +3,7 @@ const { describe, it } = require('node:test') const assert = require('node:assert') -const { actionsFor, coreWithOutputs, recordingExec, isLogWrite } = require('./actions.js') +const { actionsFor, coreWithOutputs, octokitFor, recordingExec, isLogWrite } = require('./actions.js') describe('tests/support/actions', () => { describe('the captured log', () => { @@ -53,6 +53,23 @@ describe('tests/support/actions', () => { }) }) + describe('recordingFetch', () => { + it('answers a 204 rule with an empty body, as a DELETE does', async () => { + const { github, requests } = await octokitFor([ + { method: 'DELETE', path: '/repos/example-org/my-plugin/issues/comments/42', status: 204 }, + ]) + + const response = await github.rest.issues.deleteComment({ + owner: 'example-org', + repo: 'my-plugin', + comment_id: 42, + }) + + assert.equal(response.status, 204) + assert.equal(requests.length, 1) + }) + }) + describe('recordingExec', () => { it('answers from the first matching rule and records every call', async () => { const { exec, calls } = recordingExec([ From 85f332445e1eb077776a6e10d4232044fbc4513a Mon Sep 17 00:00:00 2001 From: Paulo Iankoski Date: Tue, 3 Nov 2026 14:23:54 -0300 Subject: [PATCH 2/4] Add pr-comment: find, create, update, delete or replace a pull request comment --- .github/actions/pr-comment/README.md | 141 ++++++ .github/actions/pr-comment/action.yml | 89 ++++ .github/actions/pr-comment/pr-comment.js | 441 +++++++++++++++++ tests/actions/pr-comment/pr-comment.test.js | 505 ++++++++++++++++++++ 4 files changed, 1176 insertions(+) create mode 100644 .github/actions/pr-comment/README.md create mode 100644 .github/actions/pr-comment/action.yml create mode 100644 .github/actions/pr-comment/pr-comment.js create mode 100644 tests/actions/pr-comment/pr-comment.test.js diff --git a/.github/actions/pr-comment/README.md b/.github/actions/pr-comment/README.md new file mode 100644 index 0000000..b511c3d --- /dev/null +++ b/.github/actions/pr-comment/README.md @@ -0,0 +1,141 @@ +# pr-comment + +Finds, creates, updates or deletes a comment on a pull request or an issue. A comment that a +workflow keeps up to date is found by a hidden marker, its first line, and replaced on every run. + +It calls the GitHub API through [actions/github-script](https://github.com/actions/github-script). +No third-party action is involved. + +```yaml +- name: Post the result + uses: stellarwp/plugin-toolbox/.github/actions/pr-comment@main + with: + marker: '' + body: | + + The check passed. +``` + +Inside this repository, call it as `uses: $/.github/actions/pr-comment`. + +A pull request comment is an issue comment in the API, so the action also works on issues. + +## Inputs + +| Input | Default | What it does | +|---|---|---| +| `operation` | `upsert` | `upsert`, `create`, `update`, `delete` or `find` | +| `marker` | empty | Hidden first line that finds the comment. Needed for `upsert` and `find`. For `update` and `delete` it finds the comment when there is no `comment-id` | +| `body` | empty | The comment text. Give `body` or `body-file`, not both | +| `body-file` | empty | Path of a file that holds the comment text. Prefer it for long text | +| `comment-id` | empty | The comment to `update` or `delete`, instead of searching by `marker` | +| `issue-number` | the pull request or issue of the event | The pull request or issue to read and write | +| `repository` | current repository | The `/` of the comment | +| `token` | `github.token` | Reads and writes the comment. See [Permissions](#permissions) | + +## Outputs + +| Output | What it is | +|---|---| +| `comment-id` | The comment found, created, updated or deleted. Empty when there was none | +| `comment-url` | Link to the comment. Empty when there was none, and after a delete | +| `result` | `created`, `updated`, `deleted`, `found` or `not-found` | + +## Operations + +- `upsert` replaces the comment that starts with `marker`, or creates one. This is the one a check + that reports on every push wants. +- `create` always adds a comment. +- `update` replaces the text of a comment. It fails when there is no comment to update. +- `delete` removes a comment. A comment that is already gone is not an error, so a repeated run + stays green and reports `not-found`. +- `find` only looks. It reports `not-found` and does not fail when nothing matches. + +## The marker + +A comment matches when its body starts with the marker. The first match, the oldest comment, wins. +The author does not matter, so the comment is still found when a caller passes a personal access +token or an app token. A person who quotes the marker writes `> `, which does not match. + +Use a marker that names the repository, such as ``, so two +checks never share a comment. + +When `marker` is set and the body does not start with it, the action writes the marker and a line +break in front of the body, so the next search always finds the comment. + +An empty marker is refused where it would be used to find a comment: it would match every comment, +and an update or a delete could hit a comment a person wrote. A marker with a line break is refused +too, since it could never be the first line. + +## The text + +- An empty or blank body is refused before any request, instead of as a 422 from GitHub. +- A body longer than 65,536 characters is refused before any request, with its length in the message. +- Prefer `body-file` for long text. An environment variable holds at most 128 KiB on Linux. + +## Permissions + +The job needs the permission to write the comment: `pull-requests: write` for a pull request, or +`issues: write` for an issue. + +```yaml +permissions: + pull-requests: write +``` + +`github.token` is the token of the workflow that runs the action, so a repository needs no secret to +comment on its own pull requests. + +On a pull request from a fork, the workflow token is read-only. The action does not look at forks, so +skip the step for them in the workflow, as in `if: github.event.pull_request.head.repo.full_name == github.repository`. +A refused write says this in its message. + +## When the API fails + +The step fails with the message. The action does not hide the error. Add `continue-on-error: true` to +the step when a comment must never fail the job: + +```yaml +- name: Post the result + continue-on-error: true + uses: stellarwp/plugin-toolbox/.github/actions/pr-comment@main + with: + marker: ${{ env.COMMENT_MARKER }} + body-file: comment.md +``` + +## From another action's script + +`pr-comment.js` is a plain Node module. Each function takes an Octokit, so a script that github-script +runs can pass the client it was given: + +```js +const { upsertComment } = require('../pr-comment/pr-comment.js') + +const { id, created } = await upsertComment({ github, owner, repo, issue, marker, body }) +``` + +The module exports `findComment`, `createComment`, `updateComment`, `deleteComment` and +`upsertComment`, which read and write the same way as the operations above. + +The path is relative to the file that requires it. github-script's own `require` resolves against the +workspace of the repository being built, which is why an action's manifest passes +`${{ github.action_path }}`. A file loaded that way is an ordinary module, so its own `require` +resolves against itself. + +## Two jobs at once + +Two jobs that upsert the same marker at the same moment can both create a comment. There is no +locking. Make the second job wait for the first with `needs`. + +## How it runs + +The logic is in `pr-comment.js`, run by actions/github-script, which supplies the Node runtime and an +Octokit already authenticated with `token`. There is no dependency to install and no bundle to +build: the file in the repository is the file that runs. + +Inputs reach the script through `process.env`, never by being interpolated into it, so a comment body +is data and cannot become part of the program. + +`tests/actions/pr-comment/pr-comment.test.js` drives the real Octokit with the network replaced, so +the assertions are the requests this action would send. Run `npm install` once, then `node --test`. diff --git a/.github/actions/pr-comment/action.yml b/.github/actions/pr-comment/action.yml new file mode 100644 index 0000000..06505a6 --- /dev/null +++ b/.github/actions/pr-comment/action.yml @@ -0,0 +1,89 @@ +# Finds, creates, updates or deletes a comment on a pull request or an issue. A comment a workflow +# keeps up to date is found by a hidden marker, its first line, and replaced on every run. +# +# The work lives in pr-comment.js. actions/github-script runs it with a pre-authenticated Octokit, +# so there is no dependency to install and nothing to build: the file in the repository is the file +# that runs. Other actions' scripts require the same file, so every comment in this repository is +# written by the same code. +name: Pull request comment +description: Finds, creates, updates or deletes a pull request comment by a hidden marker + +inputs: + operation: + description: >- + `upsert` (the default) replaces the comment that starts with `marker`, or creates one. + `create` always adds a comment. `update` and `delete` act on `comment-id`, or on the comment + that starts with `marker`. `find` only looks. + required: false + default: 'upsert' + marker: + description: >- + Hidden first line that finds the comment, e.g. ``. It + is added to the body when the body does not start with it. An empty marker is refused when it + is needed to find a comment: it would match every comment. + required: false + default: '' + body: + description: 'The comment text. Give this or `body-file`, not both.' + required: false + default: '' + body-file: + description: 'Path of a file holding the comment text. Prefer it for long text.' + required: false + default: '' + comment-id: + description: 'The comment to update or delete, instead of searching by `marker`.' + required: false + default: '' + issue-number: + description: 'The pull request or issue. Defaults to the one of the event.' + required: false + default: ${{ github.event.pull_request.number || github.event.issue.number }} + repository: + description: 'The `/` of the comment. Defaults to the current repository.' + required: false + default: '' + token: + description: >- + Token that reads and writes the comment. It needs `pull-requests: write` for a pull request + or `issues: write` for an issue, so the job needs that permission too. On a pull request from + a fork the workflow token is read-only: skip the step there. + required: false + default: ${{ github.token }} + +outputs: + comment-id: + description: 'The comment found, created, updated or deleted. Empty when there was none.' + value: ${{ steps.comment.outputs.comment-id }} + comment-url: + description: 'Link to the comment. Empty when there was none, and after a delete.' + value: ${{ steps.comment.outputs.comment-url }} + result: + description: '`created`, `updated`, `deleted`, `found` or `not-found`.' + value: ${{ steps.comment.outputs.result }} + +runs: + using: 'composite' + steps: + - name: Write the comment + id: comment + uses: actions/github-script@v9 + env: + # github-script's require() resolves against the workspace of the repository being built, + # not this action's directory, so the path has to be passed in. github.action_path is the + # documented expression for it. + ACTION_PATH: ${{ github.action_path }} + # Read from process.env rather than interpolated into the script, so a body is data and + # never part of the program. + INPUT_OPERATION: ${{ inputs.operation }} + INPUT_MARKER: ${{ inputs.marker }} + INPUT_BODY: ${{ inputs.body }} + INPUT_BODY_FILE: ${{ inputs.body-file }} + INPUT_COMMENT_ID: ${{ inputs.comment-id }} + INPUT_ISSUE_NUMBER: ${{ inputs.issue-number }} + INPUT_REPOSITORY: ${{ inputs.repository }} + with: + github-token: ${{ inputs.token }} + script: | + const { run } = require(`${process.env.ACTION_PATH}/pr-comment.js`) + await run({ github, core, env: process.env }) diff --git a/.github/actions/pr-comment/pr-comment.js b/.github/actions/pr-comment/pr-comment.js new file mode 100644 index 0000000..19a0187 --- /dev/null +++ b/.github/actions/pr-comment/pr-comment.js @@ -0,0 +1,441 @@ +'use strict' + +/** + * Finds, creates, updates and deletes the comments of a pull request or an issue. + * + * A comment that a workflow keeps up to date is found by a hidden marker: its first line, such as + * ``. Every function takes an Octokit, so another action's + * script can require this file and pass the client github-script gave it, and a test can pass a + * real Octokit with the network replaced. + * + * run() is the entry point this action's manifest calls. + */ + +const fs = require('node:fs') + +/** GitHub refuses a comment body longer than this. */ +const MAX_COMMENT_LENGTH = 65536 + +const OPERATIONS = ['upsert', 'create', 'update', 'delete', 'find'] + +/** + * Checks a marker before it is used to pick a comment. + * + * An empty marker would match every comment, so an update or a delete could hit a comment a person + * wrote. A marker with a line break could never be the first line. + * + * @param {string} marker The hidden first line. + * + * @throws {Error} When it is empty or holds a line break. + * + * @returns {string} The marker, as given. + */ +function checkMarker(marker) { + const value = String(marker ?? '') + + if (value.trim() === '') { + throw new Error('The marker is empty. It would match every comment, so nothing is changed.') + } + + if (/[\r\n]/.test(value)) { + throw new Error('The marker holds a line break. It has to fit on the first line of the comment.') + } + + return value +} + +/** + * Puts the marker on the first line, unless the body already starts with it. + * + * @param {string} body The comment text. + * @param {string} marker The hidden first line, or empty for none. + * + * @returns {string} The body the next search will find. + */ +function withMarker(body, marker) { + return marker && !body.startsWith(marker) ? `${marker}\n${body}` : body +} + +/** + * Checks a body before it is sent, so a mistake fails here and not as a 422 from the API. + * + * @param {string} body The comment text. + * + * @throws {Error} When it is blank or longer than GitHub accepts. + * + * @returns {string} The body. + */ +function checkBody(body) { + if (String(body ?? '').trim() === '') { + throw new Error('The comment body is empty. GitHub refuses an empty comment.') + } + + if (body.length > MAX_COMMENT_LENGTH) { + throw new Error(`The comment would be ${body.length} characters, and GitHub takes at most ${MAX_COMMENT_LENGTH}.`) + } + + return body +} + +/** + * The text to send: the body with the marker on its first line, checked. + * + * @param {string} body The comment text. + * @param {string} marker The hidden first line, or empty for none. + * + * @throws {Error} When the marker or the body is not valid. + * + * @returns {string} The text to send. + */ +function prepareBody(body, marker) { + const first = String(marker ?? '') === '' ? '' : checkMarker(marker) + + // The blank check looks at the body alone: with the marker in front, an empty body would pass and + // post a comment that holds nothing but the marker. + checkBody(body) + + return checkBody(withMarker(String(body), first)) +} + +/** + * Reads the comment text from the string input or from the file input. + * + * @param {object} options + * @param {string} options.body The `body` input. + * @param {string} options.bodyFile The `body-file` input. + * + * @throws {Error} When both or neither are given, or the file cannot be read. + * + * @returns {string} The comment text. + */ +function readBody({ body, bodyFile }) { + const hasBody = String(body ?? '') !== '' + const hasFile = String(bodyFile ?? '') !== '' + + if (hasBody && hasFile) { + throw new Error('Give the comment as `body` or as `body-file`, not both.') + } + + if (!hasBody && !hasFile) { + throw new Error('There is no comment text. Give `body` or `body-file`.') + } + + if (hasBody) { + return String(body) + } + + try { + return fs.readFileSync(bodyFile, 'utf8') + } catch (error) { + throw new Error(`Could not read the comment text from '${bodyFile}': ${error.message}`) + } +} + +/** + * Finds the first comment whose body starts with the marker. + * + * Found by its first line only, not by author, so it is still found when the caller passes a token + * other than the workflow's own. Pages are read until one has a match, so a long pull request + * costs only the pages up to the comment. + * + * @param {object} options + * @param {object} options.github An Octokit. + * @param {string} options.owner Repository owner. + * @param {string} options.repo Repository name. + * @param {number} options.issue Pull request or issue number. + * @param {string} options.marker The first line to look for. + * + * @throws {Error} When the marker is empty, or the API call fails. + * + * @returns {Promise} The comment as the API returns it, or null. + */ +async function findComment({ github, owner, repo, issue, marker }) { + checkMarker(marker) + + const pages = github.paginate.iterator(github.rest.issues.listComments, { + owner, + repo, + issue_number: issue, + per_page: 100, + }) + + for await (const { data } of pages) { + const found = data.find((comment) => typeof comment.body === 'string' && comment.body.startsWith(marker)) + + if (found) { + return found + } + } + + return null +} + +/** + * Adds a comment. + * + * @param {object} options + * @param {object} options.github An Octokit. + * @param {string} options.owner Repository owner. + * @param {string} options.repo Repository name. + * @param {number} options.issue Pull request or issue number. + * @param {string} options.body The comment text. + * @param {string} options.marker The hidden first line to write when the body lacks it, or empty. + * + * @throws {Error} On a bad marker or body, or a failed API call. + * + * @returns {Promise<{id: number, url: string}>} The new comment. + */ +async function createComment({ github, owner, repo, issue, body, marker = '' }) { + const { data } = await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue, + body: prepareBody(body, marker), + }) + + return { id: data.id, url: data.html_url } +} + +/** + * Replaces the text of a comment. + * + * @param {object} options + * @param {object} options.github An Octokit. + * @param {string} options.owner Repository owner. + * @param {string} options.repo Repository name. + * @param {number} options.id The comment. + * @param {string} options.body The new text. + * @param {string} options.marker The hidden first line to write when the body lacks it, or empty. + * + * @throws {Error} On a bad marker or body, or a failed API call. + * + * @returns {Promise<{id: number, url: string}>} The comment. + */ +async function updateComment({ github, owner, repo, id, body, marker = '' }) { + const { data } = await github.rest.issues.updateComment({ + owner, + repo, + comment_id: id, + body: prepareBody(body, marker), + }) + + return { id: data.id, url: data.html_url } +} + +/** + * Replaces the comment that starts with the marker, or creates one. + * + * @param {object} options + * @param {object} options.github An Octokit. + * @param {string} options.owner Repository owner. + * @param {string} options.repo Repository name. + * @param {number} options.issue Pull request or issue number. + * @param {string} options.marker The first line to look for, and to write. + * @param {string} options.body The new body. + * + * @throws {Error} On a bad marker or body, or a failed API call. + * + * @returns {Promise<{id: number, url: string, created: boolean}>} The comment, and whether it is new. + */ +async function upsertComment({ github, owner, repo, issue, marker, body }) { + checkMarker(marker) + // Checked before the search, so a bad body costs no request. + prepareBody(body, marker) + + const existing = await findComment({ github, owner, repo, issue, marker }) + + if (existing) { + return { ...(await updateComment({ github, owner, repo, id: existing.id, body, marker })), created: false } + } + + return { ...(await createComment({ github, owner, repo, issue, body, marker })), created: true } +} + +/** + * Deletes a comment. A comment that is already gone is not an error, so a repeated run succeeds. + * + * @param {object} options + * @param {object} options.github An Octokit. + * @param {string} options.owner Repository owner. + * @param {string} options.repo Repository name. + * @param {number} options.id The comment. + * + * @throws {Error} When the API call fails with anything but a 404. + * + * @returns {Promise<{deleted: boolean}>} Whether a comment was deleted. + */ +async function deleteComment({ github, owner, repo, id }) { + try { + await github.rest.issues.deleteComment({ owner, repo, comment_id: id }) + } catch (error) { + if (error.status !== 404) { + throw error + } + + return { deleted: false } + } + + return { deleted: true } +} + +/** + * Reads an input that has to be a whole number above zero, or is empty. + * + * @param {string} name The input's name, for the message. + * @param {string} value The input's value. + * + * @throws {Error} When it is set but is not a whole number above zero. + * + * @returns {number|null} The number, or null when the input is empty. + */ +function readNumber(name, value) { + const text = String(value ?? '').trim() + + if (text === '') { + return null + } + + if (!/^[0-9]{1,15}$/.test(text) || Number(text) === 0) { + throw new Error(`\`${name}\` must be a whole number above zero, not '${text}'.`) + } + + return Number(text) +} + +/** + * Says what a 403 means here, and keeps GitHub's own words at the end. + * + * @param {Error} error What the API call threw. + * @param {string} target What was being written, for the message. + * + * @returns {Error} A clearer error for a 403, the same error for anything else. + */ +function explain(error, target) { + if (error.status !== 403) { + return error + } + + return new Error( + `The token cannot write comments on ${target}. On a pull request from a fork the token is ` + + 'read-only: grant `pull-requests: write` (or `issues: write` for an issue), or skip this step ' + + `for forks. GitHub said: ${error.message}` + ) +} + +/** + * Does the operation the inputs name and writes the action's outputs. + * + * @param {object} github An Octokit, as actions/github-script supplies it. + * @param {object} core The @actions/core toolkit, for the log and the outputs. + * @param {object} env The environment the action manifest put its inputs in. + * + * @throws {Error} On a bad input, or a failed API call. + * + * @returns {Promise} Resolves once the outputs are set. + */ +async function run({ github, core, env }) { + const operation = String(env.INPUT_OPERATION ?? '').trim() || 'upsert' + + if (!OPERATIONS.includes(operation)) { + throw new Error(`Unknown operation '${operation}'. Use ${OPERATIONS.join(', ')}.`) + } + + const [owner, repo] = String(env.INPUT_REPOSITORY || env.GITHUB_REPOSITORY || '').split('/') + const marker = String(env.INPUT_MARKER ?? '') + const issue = readNumber('issue-number', env.INPUT_ISSUE_NUMBER) + const commentId = readNumber('comment-id', env.INPUT_COMMENT_ID) + const target = issue ? `#${issue}` : `comment ${commentId}` + + // The comment to act on, found by its marker unless the caller gave its id. + const locate = async () => { + if (commentId) { + return { id: commentId } + } + + if (!issue) { + throw new Error('No pull request or issue number. Pass `issue-number`, or run on a `pull_request` event.') + } + + return findComment({ github, owner, repo, issue, marker }) + } + + const needIssue = () => { + if (!issue) { + throw new Error('No pull request or issue number. Pass `issue-number`, or run on a `pull_request` event.') + } + } + + const done = (result, comment) => { + core.setOutput('result', result) + core.setOutput('comment-id', comment?.id ?? '') + core.setOutput('comment-url', comment?.url ?? comment?.html_url ?? '') + } + + try { + switch (operation) { + case 'find': { + needIssue() + const found = await findComment({ github, owner, repo, issue, marker }) + core.info(found ? `Found comment ${found.id} on ${target}.` : `No comment on ${target} starts with the marker.`) + done(found ? 'found' : 'not-found', found) + break + } + + case 'create': { + needIssue() + const body = readBody({ body: env.INPUT_BODY, bodyFile: env.INPUT_BODY_FILE }) + const comment = await createComment({ github, owner, repo, issue, body, marker }) + core.info(`Created comment ${comment.id} on ${target}.`) + done('created', comment) + break + } + + case 'upsert': { + needIssue() + const body = readBody({ body: env.INPUT_BODY, bodyFile: env.INPUT_BODY_FILE }) + const comment = await upsertComment({ github, owner, repo, issue, marker, body }) + core.info(`${comment.created ? 'Created' : 'Updated'} comment ${comment.id} on ${target}.`) + done(comment.created ? 'created' : 'updated', comment) + break + } + + case 'update': { + const body = readBody({ body: env.INPUT_BODY, bodyFile: env.INPUT_BODY_FILE }) + const existing = await locate() + + if (!existing) { + throw new Error(`There is no comment to update: none on ${target} starts with the marker.`) + } + + const comment = await updateComment({ github, owner, repo, id: existing.id, body, marker }) + core.info(`Updated comment ${comment.id}.`) + done('updated', comment) + break + } + + case 'delete': { + const existing = await locate() + const { deleted } = existing ? await deleteComment({ github, owner, repo, id: existing.id }) : { deleted: false } + + core.info(deleted ? `Deleted comment ${existing.id}.` : 'There was no comment to delete.') + done(deleted ? 'deleted' : 'not-found', deleted ? { id: existing.id } : null) + break + } + } + } catch (error) { + throw explain(error, target) + } +} + +module.exports = { + run, + findComment, + createComment, + updateComment, + deleteComment, + upsertComment, + readBody, + checkMarker, + MAX_COMMENT_LENGTH, + OPERATIONS, +} diff --git a/tests/actions/pr-comment/pr-comment.test.js b/tests/actions/pr-comment/pr-comment.test.js new file mode 100644 index 0000000..b564793 --- /dev/null +++ b/tests/actions/pr-comment/pr-comment.test.js @@ -0,0 +1,505 @@ +'use strict' + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { describe, it } = require('node:test') +const assert = require('node:assert') + +const { + run, + findComment, + createComment, + updateComment, + deleteComment, + upsertComment, + readBody, + checkMarker, + MAX_COMMENT_LENGTH, +} = require('../../../.github/actions/pr-comment/pr-comment.js') +const { actionsFor, octokitFor } = require('../../support/actions.js') + +const REPO = { owner: 'example-org', repo: 'my-plugin' } +const MARKER = '' + +/** The comments of pull request 12, and one comment by id. */ +const COMMENTS = '/repos/example-org/my-plugin/issues/12/comments?per_page=100' +const COMMENT = (id) => `/repos/example-org/my-plugin/issues/comments/${id}` + +/** A `link` header that points at a second page. */ +const NEXT_PAGE = { + link: '; rel="next"', +} + +/** + * Each request as `METHOD path`, for asserting the order the action worked in. + * + * @param {object[]} requests The requests recordingFetch recorded. + * + * @returns {string[]} One `METHOD path` per request, in the order they were sent. + */ +function sequence(requests) { + return requests.map((request) => `${request.method} ${request.path}`) +} + +/** + * The environment the action manifest builds from its inputs. + * + * @param {object} inputs Input values by their INPUT_ name, without the prefix. + * + * @returns {object} The env to hand to run(). + */ +function envFor(inputs = {}) { + const env = { GITHUB_REPOSITORY: 'example-org/my-plugin', INPUT_ISSUE_NUMBER: '12' } + + for (const [name, value] of Object.entries(inputs)) { + env[`INPUT_${name}`] = value + } + + return env +} + +describe('pr-comment', () => { + describe('checkMarker', () => { + it('refuses an empty or blank marker, which would match every comment', () => { + assert.throws(() => checkMarker(''), /marker is empty/) + assert.throws(() => checkMarker(' '), /marker is empty/) + assert.throws(() => checkMarker(undefined), /marker is empty/) + }) + + it('refuses a marker with a line break', () => { + assert.throws(() => checkMarker('\n'), /line break/) + }) + }) + + describe('readBody', () => { + it('reads the body from a string', () => { + assert.equal(readBody({ body: 'hello', bodyFile: '' }), 'hello') + }) + + it('reads the body from a file', () => { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pr-comment-')), 'body.md') + fs.writeFileSync(file, 'from a file\n') + + assert.equal(readBody({ body: '', bodyFile: file }), 'from a file\n') + }) + + it('fails on a missing file and names it', () => { + assert.throws(() => readBody({ body: '', bodyFile: '/nowhere/body.md' }), /\/nowhere\/body\.md/) + }) + + it('refuses both a body and a file', () => { + assert.throws(() => readBody({ body: 'a', bodyFile: 'b.md' }), /not both/) + }) + + it('refuses neither a body nor a file', () => { + assert.throws(() => readBody({ body: '', bodyFile: '' }), /no comment text/) + }) + }) + + describe('findComment', () => { + it('finds the comment that starts with the marker', async () => { + const { github } = await octokitFor([ + { + method: 'GET', + path: COMMENTS, + body: [ + { id: 1, body: 'a person wrote this' }, + { id: 2, body: `${MARKER}\nthe sticky comment` }, + ], + }, + ]) + + const found = await findComment({ github, ...REPO, issue: 12, marker: MARKER }) + + assert.equal(found.id, 2) + }) + + it('finds it on a later page', async () => { + const { github, requests } = await octokitFor([ + { + method: 'GET', + path: /\/issues\/12\/comments\?/, + responses: [ + { body: [{ id: 1, body: 'first page' }], headers: NEXT_PAGE }, + { body: [{ id: 2, body: `${MARKER}\non the second page` }] }, + ], + }, + ]) + + const found = await findComment({ github, ...REPO, issue: 12, marker: MARKER }) + + assert.equal(found.id, 2) + assert.equal(requests.length, 2) + }) + + it('stops reading pages once a page has a match', async () => { + const { github, requests } = await octokitFor([ + { + method: 'GET', + path: /\/issues\/12\/comments\?/, + responses: [ + { body: [{ id: 1, body: `${MARKER}\nold` }], headers: NEXT_PAGE }, + { body: [{ id: 2, body: `${MARKER}\nnewer copy` }] }, + ], + }, + ]) + + const found = await findComment({ github, ...REPO, issue: 12, marker: MARKER }) + + assert.equal(found.id, 1) + assert.equal(requests.length, 1, 'the second page was never read') + }) + + it('ignores a comment that only quotes the marker', async () => { + const { github } = await octokitFor([ + { method: 'GET', path: COMMENTS, body: [{ id: 1, body: `> ${MARKER}\nquoted by a person` }] }, + ]) + + assert.equal(await findComment({ github, ...REPO, issue: 12, marker: MARKER }), null) + }) + + it('ignores a comment that holds the marker on a later line', async () => { + const { github } = await octokitFor([ + { method: 'GET', path: COMMENTS, body: [{ id: 1, body: `text\n${MARKER}` }] }, + ]) + + assert.equal(await findComment({ github, ...REPO, issue: 12, marker: MARKER }), null) + }) + + it('skips a comment with no body', async () => { + const { github } = await octokitFor([ + { method: 'GET', path: COMMENTS, body: [{ id: 1, body: null }, { id: 2, body: `${MARKER}\nok` }] }, + ]) + + const found = await findComment({ github, ...REPO, issue: 12, marker: MARKER }) + + assert.equal(found.id, 2) + }) + + it('returns the oldest match when there are two', async () => { + const { github } = await octokitFor([ + { + method: 'GET', + path: COMMENTS, + body: [ + { id: 7, body: `${MARKER}\nolder` }, + { id: 9, body: `${MARKER}\nnewer` }, + ], + }, + ]) + + const found = await findComment({ github, ...REPO, issue: 12, marker: MARKER }) + + assert.equal(found.id, 7) + }) + + it('returns null when no comment matches', async () => { + const { github } = await octokitFor([{ method: 'GET', path: COMMENTS, body: [{ id: 1, body: 'other' }] }]) + + assert.equal(await findComment({ github, ...REPO, issue: 12, marker: MARKER }), null) + }) + + it('refuses an empty marker before calling the API', async () => { + const { github, requests } = await octokitFor([]) + + await assert.rejects(findComment({ github, ...REPO, issue: 12, marker: '' }), /marker is empty/) + assert.equal(requests.length, 0) + }) + + it('fails on an API error rather than reading it as no comment', async () => { + const { github, requests } = await octokitFor([ + { method: 'GET', path: COMMENTS, status: 500, body: { message: 'Server Error' } }, + ]) + + await assert.rejects(upsertComment({ github, ...REPO, issue: 12, marker: MARKER, body: 'text' })) + assert.deepEqual(sequence(requests), [`GET ${COMMENTS}`]) + }) + }) + + describe('createComment', () => { + it("posts the body to the issue's comments", async () => { + const { github, requests } = await octokitFor([ + { method: 'POST', path: '/repos/example-org/my-plugin/issues/12/comments', body: { id: 5, html_url: 'https://x/5' } }, + ]) + + const comment = await createComment({ github, ...REPO, issue: 12, body: 'hello' }) + + assert.deepEqual(comment, { id: 5, url: 'https://x/5' }) + assert.deepEqual(requests[0].body, { body: 'hello' }) + }) + + it('adds the marker as the first line when the body lacks it', async () => { + const { github, requests } = await octokitFor([ + { method: 'POST', path: '/repos/example-org/my-plugin/issues/12/comments', body: { id: 5 } }, + ]) + + await createComment({ github, ...REPO, issue: 12, body: 'hello', marker: MARKER }) + + assert.equal(requests[0].body.body, `${MARKER}\nhello`) + }) + + it('refuses an empty body before calling the API', async () => { + const { github, requests } = await octokitFor([]) + + await assert.rejects(createComment({ github, ...REPO, issue: 12, body: ' \n' }), /body is empty/) + assert.equal(requests.length, 0) + }) + + it('refuses a body longer than GitHub accepts before calling the API', async () => { + const { github, requests } = await octokitFor([]) + const body = 'x'.repeat(MAX_COMMENT_LENGTH + 1) + + await assert.rejects(createComment({ github, ...REPO, issue: 12, body }), /65537 characters/) + assert.equal(requests.length, 0) + }) + }) + + describe('updateComment', () => { + it('patches the comment by id with the new body', async () => { + const { github, requests } = await octokitFor([ + { method: 'PATCH', path: COMMENT(42), body: { id: 42, html_url: 'https://x/42' } }, + ]) + + const comment = await updateComment({ github, ...REPO, id: 42, body: 'new text' }) + + assert.deepEqual(comment, { id: 42, url: 'https://x/42' }) + assert.deepEqual(requests[0].body, { body: 'new text' }) + }) + + it('keeps a body that already starts with the marker as it is', async () => { + const { github, requests } = await octokitFor([{ method: 'PATCH', path: COMMENT(42), body: { id: 42 } }]) + + await updateComment({ github, ...REPO, id: 42, body: `${MARKER}\nnew`, marker: MARKER }) + + assert.equal(requests[0].body.body, `${MARKER}\nnew`) + }) + }) + + describe('deleteComment', () => { + it('deletes the comment by id', async () => { + const { github, requests } = await octokitFor([{ method: 'DELETE', path: COMMENT(42), status: 204 }]) + + const result = await deleteComment({ github, ...REPO, id: 42 }) + + assert.deepEqual(result, { deleted: true }) + assert.deepEqual(sequence(requests), [`DELETE ${COMMENT(42)}`]) + }) + + it('reports a comment that is already gone as not deleted', async () => { + const { github } = await octokitFor([]) + + assert.deepEqual(await deleteComment({ github, ...REPO, id: 42 }), { deleted: false }) + }) + + it('fails on a refused delete', async () => { + const { github } = await octokitFor([ + { method: 'DELETE', path: COMMENT(42), status: 403, body: { message: 'Forbidden' } }, + ]) + + await assert.rejects(deleteComment({ github, ...REPO, id: 42 }), /Forbidden/) + }) + }) + + describe('upsertComment', () => { + it('replaces the comment that starts with the marker, even on a later page', async () => { + const { github, requests } = await octokitFor([ + { + method: 'GET', + path: /\/issues\/12\/comments\?/, + responses: [ + { body: [{ id: 1, body: 'other' }], headers: NEXT_PAGE }, + { body: [{ id: 5, body: `${MARKER}\nold` }] }, + ], + }, + { method: 'PATCH', path: COMMENT(5), body: { id: 5, html_url: 'https://x/5' } }, + ]) + + const comment = await upsertComment({ github, ...REPO, issue: 12, marker: MARKER, body: `${MARKER}\nnew` }) + + assert.deepEqual(comment, { id: 5, url: 'https://x/5', created: false }) + assert.equal(requests.at(-1).method, 'PATCH') + assert.equal(requests.filter((request) => request.method === 'POST').length, 0) + }) + + it('creates a comment when none starts with the marker', async () => { + const { github, requests } = await octokitFor([ + { method: 'GET', path: COMMENTS, body: [{ id: 1, body: 'other' }] }, + { method: 'POST', path: '/repos/example-org/my-plugin/issues/12/comments', body: { id: 6, html_url: 'https://x/6' } }, + ]) + + const comment = await upsertComment({ github, ...REPO, issue: 12, marker: MARKER, body: 'text' }) + + assert.deepEqual(comment, { id: 6, url: 'https://x/6', created: true }) + assert.equal(requests.at(-1).body.body, `${MARKER}\ntext`) + }) + + it('refuses an empty marker before calling the API', async () => { + const { github, requests } = await octokitFor([]) + + await assert.rejects(upsertComment({ github, ...REPO, issue: 12, marker: '', body: 'text' }), /marker is empty/) + assert.equal(requests.length, 0) + }) + + it('refuses an empty body before searching', async () => { + const { github, requests } = await octokitFor([]) + + await assert.rejects(upsertComment({ github, ...REPO, issue: 12, marker: MARKER, body: '' }), /body is empty/) + assert.equal(requests.length, 0) + }) + }) + + describe('run', () => { + it('upserts by default and sets the outputs', async (t) => { + const { github, core, outputs } = await actionsFor(t, [ + { method: 'GET', path: COMMENTS, body: [] }, + { method: 'POST', path: '/repos/example-org/my-plugin/issues/12/comments', body: { id: 6, html_url: 'https://x/6' } }, + ]) + + await run({ github, core, env: envFor({ MARKER, BODY: 'text' }) }) + + assert.deepEqual(outputs(), { result: 'created', 'comment-id': '6', 'comment-url': 'https://x/6' }) + }) + + it('updates when the marker is found', async (t) => { + const { github, core, outputs } = await actionsFor(t, [ + { method: 'GET', path: COMMENTS, body: [{ id: 5, body: `${MARKER}\nold` }] }, + { method: 'PATCH', path: COMMENT(5), body: { id: 5, html_url: 'https://x/5' } }, + ]) + + await run({ github, core, env: envFor({ MARKER, BODY: 'new' }) }) + + assert.equal(outputs().result, 'updated') + assert.equal(outputs()['comment-id'], '5') + }) + + it('reads the body from body-file', async (t) => { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pr-comment-')), 'body.md') + fs.writeFileSync(file, `${MARKER}\nfrom a file`) + + const { github, core, requests } = await actionsFor(t, [ + { method: 'GET', path: COMMENTS, body: [] }, + { method: 'POST', path: '/repos/example-org/my-plugin/issues/12/comments', body: { id: 6 } }, + ]) + + await run({ github, core, env: envFor({ MARKER, BODY_FILE: file }) }) + + assert.equal(requests.at(-1).body.body, `${MARKER}\nfrom a file`) + }) + + it('finds without writing', async (t) => { + const { github, core, outputs, requests } = await actionsFor(t, [ + { method: 'GET', path: COMMENTS, body: [{ id: 5, body: `${MARKER}\nx`, html_url: 'https://x/5' }] }, + ]) + + await run({ github, core, env: envFor({ OPERATION: 'find', MARKER }) }) + + assert.equal(outputs().result, 'found') + assert.equal(outputs()['comment-url'], 'https://x/5') + assert.deepEqual(sequence(requests), [`GET ${COMMENTS}`]) + }) + + it('reports not-found from find without failing', async (t) => { + const { github, core, outputs } = await actionsFor(t, [{ method: 'GET', path: COMMENTS, body: [] }]) + + await run({ github, core, env: envFor({ OPERATION: 'find', MARKER }) }) + + assert.equal(outputs().result, 'not-found') + assert.equal(outputs()['comment-id'], '') + }) + + it('deletes by marker', async (t) => { + const { github, core, outputs, requests } = await actionsFor(t, [ + { method: 'GET', path: COMMENTS, body: [{ id: 5, body: `${MARKER}\nx` }] }, + { method: 'DELETE', path: COMMENT(5), status: 204 }, + ]) + + await run({ github, core, env: envFor({ OPERATION: 'delete', MARKER }) }) + + assert.equal(outputs().result, 'deleted') + assert.deepEqual(sequence(requests), [`GET ${COMMENTS}`, `DELETE ${COMMENT(5)}`]) + }) + + it('deletes by comment-id without listing comments', async (t) => { + const { github, core, outputs, requests } = await actionsFor(t, [ + { method: 'DELETE', path: COMMENT(5), status: 204 }, + ]) + + await run({ github, core, env: envFor({ OPERATION: 'delete', COMMENT_ID: '5', ISSUE_NUMBER: '' }) }) + + assert.equal(outputs().result, 'deleted') + assert.deepEqual(sequence(requests), [`DELETE ${COMMENT(5)}`]) + }) + + it('reports not-found from delete without failing', async (t) => { + const { github, core, outputs } = await actionsFor(t, [{ method: 'GET', path: COMMENTS, body: [] }]) + + await run({ github, core, env: envFor({ OPERATION: 'delete', MARKER }) }) + + assert.equal(outputs().result, 'not-found') + }) + + it('fails update when there is nothing to update', async (t) => { + const { github, core } = await actionsFor(t, [{ method: 'GET', path: COMMENTS, body: [] }]) + + await assert.rejects(run({ github, core, env: envFor({ OPERATION: 'update', MARKER, BODY: 'x' }) }), /no comment to update/) + }) + + it('creates a second comment with the create operation', async (t) => { + const { github, core, outputs, requests } = await actionsFor(t, [ + { method: 'POST', path: '/repos/example-org/my-plugin/issues/12/comments', body: { id: 8 } }, + ]) + + await run({ github, core, env: envFor({ OPERATION: 'create', BODY: 'one more' }) }) + + assert.equal(outputs().result, 'created') + assert.deepEqual(sequence(requests), ['POST /repos/example-org/my-plugin/issues/12/comments']) + }) + + it('refuses an unknown operation before calling the API', async (t) => { + const { github, core, requests } = await actionsFor(t, []) + + await assert.rejects(run({ github, core, env: envFor({ OPERATION: 'remove' }) }), /Unknown operation 'remove'/) + assert.equal(requests.length, 0) + }) + + it('refuses to upsert without an issue number', async (t) => { + const { github, core, requests } = await actionsFor(t, []) + + await assert.rejects( + run({ github, core, env: envFor({ MARKER, BODY: 'x', ISSUE_NUMBER: '' }) }), + /No pull request or issue number/ + ) + assert.equal(requests.length, 0) + }) + + it('refuses an issue number that is not a number', async (t) => { + const { github, core } = await actionsFor(t, []) + + await assert.rejects(run({ github, core, env: envFor({ MARKER, BODY: 'x', ISSUE_NUMBER: '12abc' }) }), /whole number/) + }) + + it('explains a 403 as a read-only token', async (t) => { + const { github, core } = await actionsFor(t, [ + { method: 'GET', path: COMMENTS, body: [] }, + { method: 'POST', path: '/repos/example-org/my-plugin/issues/12/comments', status: 403, body: { message: 'Resource not accessible by integration' } }, + ]) + + await assert.rejects( + run({ github, core, env: envFor({ MARKER, BODY: 'x' }) }), + /token cannot write comments on #12.*fork.*pull-requests: write.*Resource not accessible/s + ) + }) + + it('writes to the repository it was given', async (t) => { + const { github, core, requests } = await actionsFor(t, [ + { method: 'GET', path: '/repos/other-org/other-plugin/issues/12/comments?per_page=100', body: [] }, + { method: 'POST', path: '/repos/other-org/other-plugin/issues/12/comments', body: { id: 3 } }, + ]) + + await run({ github, core, env: envFor({ MARKER, BODY: 'x', REPOSITORY: 'other-org/other-plugin' }) }) + + assert.equal(requests.at(-1).path, '/repos/other-org/other-plugin/issues/12/comments') + }) + }) +}) From 1ff912896f4ecb8de76c746de288f79ca0f43f2d Mon Sep 17 00:00:00 2001 From: Paulo Iankoski Date: Tue, 3 Nov 2026 15:45:38 -0300 Subject: [PATCH 3/4] Name the inner output outcome, and explain a 403 only for a write --- .github/actions/pr-comment/action.yml | 3 ++- .github/actions/pr-comment/pr-comment.js | 8 ++++-- tests/actions/pr-comment/pr-comment.test.js | 27 +++++++++++++++------ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/actions/pr-comment/action.yml b/.github/actions/pr-comment/action.yml index 06505a6..19ad3ad 100644 --- a/.github/actions/pr-comment/action.yml +++ b/.github/actions/pr-comment/action.yml @@ -60,7 +60,8 @@ outputs: value: ${{ steps.comment.outputs.comment-url }} result: description: '`created`, `updated`, `deleted`, `found` or `not-found`.' - value: ${{ steps.comment.outputs.result }} + # The script's `outcome`, not github-script's own `result`, which is set after the script ends. + value: ${{ steps.comment.outputs.outcome }} runs: using: 'composite' diff --git a/.github/actions/pr-comment/pr-comment.js b/.github/actions/pr-comment/pr-comment.js index 19a0187..5acb595 100644 --- a/.github/actions/pr-comment/pr-comment.js +++ b/.github/actions/pr-comment/pr-comment.js @@ -311,7 +311,9 @@ function readNumber(name, value) { * @returns {Error} A clearer error for a 403, the same error for anything else. */ function explain(error, target) { - if (error.status !== 403) { + // Only a refused write. A 403 on the read that lists comments is a missing read permission or a + // rate limit, and "cannot write" would send the reader the wrong way. + if (error.status !== 403 || !['POST', 'PATCH', 'DELETE'].includes(error.request?.method)) { return error } @@ -366,7 +368,9 @@ async function run({ github, core, env }) { } const done = (result, comment) => { - core.setOutput('result', result) + // Not `result`: actions/github-script sets an output of that name itself after the script ends, + // which would replace this one with an empty value. action.yml maps `outcome` to `result`. + core.setOutput('outcome', result) core.setOutput('comment-id', comment?.id ?? '') core.setOutput('comment-url', comment?.url ?? comment?.html_url ?? '') } diff --git a/tests/actions/pr-comment/pr-comment.test.js b/tests/actions/pr-comment/pr-comment.test.js index b564793..73cc452 100644 --- a/tests/actions/pr-comment/pr-comment.test.js +++ b/tests/actions/pr-comment/pr-comment.test.js @@ -358,7 +358,7 @@ describe('pr-comment', () => { await run({ github, core, env: envFor({ MARKER, BODY: 'text' }) }) - assert.deepEqual(outputs(), { result: 'created', 'comment-id': '6', 'comment-url': 'https://x/6' }) + assert.deepEqual(outputs(), { outcome: 'created', 'comment-id': '6', 'comment-url': 'https://x/6' }) }) it('updates when the marker is found', async (t) => { @@ -369,7 +369,7 @@ describe('pr-comment', () => { await run({ github, core, env: envFor({ MARKER, BODY: 'new' }) }) - assert.equal(outputs().result, 'updated') + assert.equal(outputs().outcome, 'updated') assert.equal(outputs()['comment-id'], '5') }) @@ -394,7 +394,7 @@ describe('pr-comment', () => { await run({ github, core, env: envFor({ OPERATION: 'find', MARKER }) }) - assert.equal(outputs().result, 'found') + assert.equal(outputs().outcome, 'found') assert.equal(outputs()['comment-url'], 'https://x/5') assert.deepEqual(sequence(requests), [`GET ${COMMENTS}`]) }) @@ -404,7 +404,7 @@ describe('pr-comment', () => { await run({ github, core, env: envFor({ OPERATION: 'find', MARKER }) }) - assert.equal(outputs().result, 'not-found') + assert.equal(outputs().outcome, 'not-found') assert.equal(outputs()['comment-id'], '') }) @@ -416,7 +416,7 @@ describe('pr-comment', () => { await run({ github, core, env: envFor({ OPERATION: 'delete', MARKER }) }) - assert.equal(outputs().result, 'deleted') + assert.equal(outputs().outcome, 'deleted') assert.deepEqual(sequence(requests), [`GET ${COMMENTS}`, `DELETE ${COMMENT(5)}`]) }) @@ -427,7 +427,7 @@ describe('pr-comment', () => { await run({ github, core, env: envFor({ OPERATION: 'delete', COMMENT_ID: '5', ISSUE_NUMBER: '' }) }) - assert.equal(outputs().result, 'deleted') + assert.equal(outputs().outcome, 'deleted') assert.deepEqual(sequence(requests), [`DELETE ${COMMENT(5)}`]) }) @@ -436,7 +436,7 @@ describe('pr-comment', () => { await run({ github, core, env: envFor({ OPERATION: 'delete', MARKER }) }) - assert.equal(outputs().result, 'not-found') + assert.equal(outputs().outcome, 'not-found') }) it('fails update when there is nothing to update', async (t) => { @@ -452,7 +452,7 @@ describe('pr-comment', () => { await run({ github, core, env: envFor({ OPERATION: 'create', BODY: 'one more' }) }) - assert.equal(outputs().result, 'created') + assert.equal(outputs().outcome, 'created') assert.deepEqual(sequence(requests), ['POST /repos/example-org/my-plugin/issues/12/comments']) }) @@ -491,6 +491,17 @@ describe('pr-comment', () => { ) }) + it('does not call a refused read a refused write', async (t) => { + const { github, core } = await actionsFor(t, [ + { method: 'GET', path: COMMENTS, status: 403, body: { message: 'API rate limit exceeded' } }, + ]) + + await assert.rejects( + run({ github, core, env: envFor({ MARKER, BODY: 'x' }) }), + (error) => /rate limit/.test(error.message) && !/cannot write/.test(error.message) + ) + }) + it('writes to the repository it was given', async (t) => { const { github, core, requests } = await actionsFor(t, [ { method: 'GET', path: '/repos/other-org/other-plugin/issues/12/comments?per_page=100', body: [] }, From 6a9f6786d68509f92ebb400ee33830111f7a3fb9 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski Date: Wed, 4 Nov 2026 13:50:18 -0300 Subject: [PATCH 4/4] Explain in the pr-comment README why body-file exists --- .github/actions/pr-comment/README.md | 30 +++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/actions/pr-comment/README.md b/.github/actions/pr-comment/README.md index b511c3d..99b1b44 100644 --- a/.github/actions/pr-comment/README.md +++ b/.github/actions/pr-comment/README.md @@ -27,7 +27,7 @@ A pull request comment is an issue comment in the API, so the action also works | `operation` | `upsert` | `upsert`, `create`, `update`, `delete` or `find` | | `marker` | empty | Hidden first line that finds the comment. Needed for `upsert` and `find`. For `update` and `delete` it finds the comment when there is no `comment-id` | | `body` | empty | The comment text. Give `body` or `body-file`, not both | -| `body-file` | empty | Path of a file that holds the comment text. Prefer it for long text | +| `body-file` | empty | Path of a file that holds the comment text. Prefer it for long text. See [Body from a file](#body-from-a-file) | | `comment-id` | empty | The comment to `update` or `delete`, instead of searching by `marker` | | `issue-number` | the pull request or issue of the event | The pull request or issue to read and write | | `repository` | current repository | The `/` of the comment | @@ -41,6 +41,34 @@ A pull request comment is an issue comment in the API, so the action also works | `comment-url` | Link to the comment. Empty when there was none, and after a delete | | `result` | `created`, `updated`, `deleted`, `found` or `not-found` | +## Body from a file + +Most checks write their report to a file. Without `body-file`, a caller has to turn that file into +a multi-line step output first, which takes a step of its own and a delimiter that the text must +never contain: + +```yaml +- id: text + run: | + { + echo 'body<> "$GITHUB_OUTPUT" +- uses: stellarwp/plugin-toolbox/.github/actions/pr-comment@main + with: + body: ${{ steps.text.outputs.body }} +``` + +With `body-file` the caller passes the path and nothing else: + +```yaml +- uses: stellarwp/plugin-toolbox/.github/actions/pr-comment@main + with: + marker: '' + body-file: result.md +``` + ## Operations - `upsert` replaces the comment that starts with `marker`, or creates one. This is the one a check