From b2b77a81a2aa74eb204b9fdd876808f4c27b6ffd Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 10:47:46 -0500 Subject: [PATCH 01/19] Add CHANGELOG.md modification check and warning comment --- .github/workflows/pr-checks.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 2ae0594407..b0e2aa6b12 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -181,6 +181,31 @@ jobs: path: ${{ runner.temp }}/repo-size/ if-no-files-found: error + - name: Check for CHANGELOG.md changes + id: changelog-check + if: steps.fetch-base.outcome == 'success' + env: + BASE_SHA: ${{ steps.fetch-base.outputs.merge_base }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if ! git diff --exit-code --quiet "$BASE_SHA" "$HEAD_SHA" -- CHANGELOG.md; then + echo "CHANGELOG.md was modified in this PR." + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Post a warning about modifying CHANGELOG.md + if: steps.changelog-check.outputs.changed == 'true' + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + issue-number: ${{ github.event.pull_request.number }} + body: >- + ⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. + Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically + generated from change-notes. For more information, + see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md). + - name: "Backport: Check out base ref" id: checkout-base if: ${{ startsWith(github.head_ref, 'backport-') }} From fbd33ac6fcc84bf24f586f0648d71ac1fde05121 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 10:50:34 -0500 Subject: [PATCH 02/19] Gate comment-post step on successful fetch-base --- .github/workflows/pr-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index b0e2aa6b12..1f19b62a04 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -196,7 +196,7 @@ jobs: fi - name: Post a warning about modifying CHANGELOG.md - if: steps.changelog-check.outputs.changed == 'true' + if: steps.fetch-base.outcome == 'success' && steps.changelog-check.outputs.changed == 'true' uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: issue-number: ${{ github.event.pull_request.number }} From 43ac64c2673106d737b19d3e66ba56c4b54a3b9d Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 11:16:19 -0500 Subject: [PATCH 03/19] Use `gh api` instead of third-party Action --- .github/workflows/pr-checks.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1f19b62a04..911e4583d0 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -197,14 +197,15 @@ jobs: - name: Post a warning about modifying CHANGELOG.md if: steps.fetch-base.outcome == 'success' && steps.changelog-check.outputs.changed == 'true' - uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 - with: - issue-number: ${{ github.event.pull_request.number }} - body: >- - ⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. - Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically - generated from change-notes. For more information, - see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md). + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + body="⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. \ + Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically \ + generated from change-notes. For more information, \ + see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md)." + gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" - name: "Backport: Check out base ref" id: checkout-base From f7aa9dc1ba469698a7f5479c75abd35652c913ba Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 11:40:38 -0500 Subject: [PATCH 04/19] Move post-comment step to `post-pr-comments` job --- .github/workflows/pr-checks.yml | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 911e4583d0..37e06d44e6 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -92,6 +92,8 @@ jobs: contents: read runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + changelog-changed: ${{ steps.changelog-check.outputs.changed }} concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' || false }} @@ -195,18 +197,6 @@ jobs: echo "changed=false" >> "$GITHUB_OUTPUT" fi - - name: Post a warning about modifying CHANGELOG.md - if: steps.fetch-base.outcome == 'success' && steps.changelog-check.outputs.changed == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - body="⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. \ - Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically \ - generated from change-notes. For more information, \ - see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md)." - gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" - - name: "Backport: Check out base ref" id: checkout-base if: ${{ startsWith(github.head_ref, 'backport-') }} @@ -227,8 +217,8 @@ jobs: exit 1 fi - post-repo-size-comment: - name: Post repo size comment + post-pr-comments: + name: Post PR comments needs: other-checks # Keep write permissions isolated from the job that checks out and tests PR code. This job only # posts the candidate comment body produced by the read-only `pr-checks` job. @@ -277,3 +267,15 @@ jobs: else echo "Skipping repo size comment because the delta is below the threshold and no sticky comment exists." fi + + - name: Post a warning about modifying CHANGELOG.md + if: needs.other-checks.outputs.changelog-changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + body="⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. \ + Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically \ + generated from change-notes. For more information, \ + see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md)." + gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" From 780e947861f737e875cddac65cbc9682db4740f9 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 11:48:09 -0500 Subject: [PATCH 05/19] Skip CHANGELOG.md modification warning for release and backport PRs --- .github/workflows/pr-checks.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 37e06d44e6..6dbb457e3f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -269,7 +269,16 @@ jobs: fi - name: Post a warning about modifying CHANGELOG.md - if: needs.other-checks.outputs.changelog-changed == 'true' + # Release and backport PRs created by `update-release-branch.ts` are titled + # `Merge into `, where the target branch is always a + # `releases/v*` branch. These PRs deliberately modify CHANGELOG.md as part of the release + # process, so skip the warning for them. + if: >- + needs.other-checks.outputs.changelog-changed == 'true' && + !( + startsWith(github.event.pull_request.title, 'Merge ') && + contains(github.event.pull_request.title, ' into releases/v') + ) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} From ae75d466cee9de3cf2da4b0c49e9fdff274a62e2 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 12:06:32 -0500 Subject: [PATCH 06/19] Exclude "Mergeback" and "Update default bundle" PRs from CHANGELOG.md change warning --- .github/workflows/pr-checks.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 6dbb457e3f..3c03943289 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -269,15 +269,17 @@ jobs: fi - name: Post a warning about modifying CHANGELOG.md - # Release and backport PRs created by `update-release-branch.ts` are titled - # `Merge into `, where the target branch is always a - # `releases/v*` branch. These PRs deliberately modify CHANGELOG.md as part of the release - # process, so skip the warning for them. + # Release, backport, mergeback, and bundle-update PRs deliberately modify CHANGELOG.md, + # so skip the warning for these automation flows. if: >- needs.other-checks.outputs.changelog-changed == 'true' && !( startsWith(github.event.pull_request.title, 'Merge ') && - contains(github.event.pull_request.title, ' into releases/v') + contains(github.event.pull_request.title, ' into releases/v') || + startsWith(github.event.pull_request.title, 'Mergeback ') && + startsWith(github.event.pull_request.head.ref, 'mergeback/') || + startsWith(github.event.pull_request.title, 'Update default bundle to ') && + startsWith(github.event.pull_request.head.ref, 'update-bundle/') ) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 6e4d3fb63d9c4d75040616dcdf7c77fadc4ef827 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 12:31:10 -0500 Subject: [PATCH 07/19] Use a sticky comment to warn about CHANGELOG.md changes --- .github/workflows/pr-checks.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 3c03943289..9121b16e2e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -282,11 +282,25 @@ jobs: startsWith(github.event.pull_request.head.ref, 'update-bundle/') ) env: + COMMENT_MARKER: "" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | - body="⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. \ - Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically \ - generated from change-notes. For more information, \ + comment_id=$( + gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + --paginate \ + --jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" \ + | head -n 1 + ) + + if [[ -n "$comment_id" ]]; then + echo "CHANGELOG.md warning comment already exists ($comment_id)." + exit 0 + fi + + body="$COMMENT_MARKER + ⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. + Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically + generated from change-notes. For more information, see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md)." gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" From d97a656f70b91aaa5380a8bf2278eed24c0b6b18 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 12:41:52 -0500 Subject: [PATCH 08/19] Update CHANGELOG.md warning message to reference change-notes directory --- .github/workflows/pr-checks.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 9121b16e2e..52cb664845 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -299,8 +299,8 @@ jobs: fi body="$COMMENT_MARKER - ⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. - Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically - generated from change-notes. For more information, - see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md)." + ⚠️ CHANGELOG.md has been modified in this PR. Please do not modify CHANGELOG.md directly. + Instead, create a change-note file in `unreleased-change-notes/`. The CHANGELOG.md + file will be automatically generated from those change-notes. + See [unreleased-change-notes/README.md](unreleased-change-notes/README.md) for more information." gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" From ccdf69089ab5036850dc8544c4801e1c34e48237 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 12:55:10 -0500 Subject: [PATCH 09/19] Escape backticks PR comment body Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/pr-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 52cb664845..39c319e5d0 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -300,7 +300,7 @@ jobs: body="$COMMENT_MARKER ⚠️ CHANGELOG.md has been modified in this PR. Please do not modify CHANGELOG.md directly. - Instead, create a change-note file in `unreleased-change-notes/`. The CHANGELOG.md + Instead, create a change-note file in \`unreleased-change-notes/\`. The CHANGELOG.md file will be automatically generated from those change-notes. See [unreleased-change-notes/README.md](unreleased-change-notes/README.md) for more information." gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" From 928548a094382019a950465f393317b0b7134ea0 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 12:57:52 -0500 Subject: [PATCH 10/19] Refine exclusion conditions for CHANGELOG-modification comment Refactor conditions for PR title checks in workflow. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/pr-checks.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 39c319e5d0..db067db460 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -274,8 +274,13 @@ jobs: if: >- needs.other-checks.outputs.changelog-changed == 'true' && !( - startsWith(github.event.pull_request.title, 'Merge ') && - contains(github.event.pull_request.title, ' into releases/v') || + startsWith(github.event.pull_request.title, 'Merge main into releases/v') && + startsWith(github.event.pull_request.head.ref, 'update-v') && + startsWith(github.event.pull_request.base.ref, 'releases/v') || + startsWith(github.event.pull_request.title, 'Merge releases/v') && + contains(github.event.pull_request.title, ' into releases/v') && + startsWith(github.event.pull_request.head.ref, 'backport-v') && + startsWith(github.event.pull_request.base.ref, 'releases/v') || startsWith(github.event.pull_request.title, 'Mergeback ') && startsWith(github.event.pull_request.head.ref, 'mergeback/') || startsWith(github.event.pull_request.title, 'Update default bundle to ') && From 0707124ab8cf9f077c71211fe07ec8161feabf24 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 13:11:34 -0500 Subject: [PATCH 11/19] Delete CHANGELOG.md-modification comment if CHANGELOG.md no longer modified --- .github/workflows/pr-checks.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index db067db460..c5d9d55927 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -270,9 +270,8 @@ jobs: - name: Post a warning about modifying CHANGELOG.md # Release, backport, mergeback, and bundle-update PRs deliberately modify CHANGELOG.md, - # so skip the warning for these automation flows. + # so skip comment management entirely for these automation flows. if: >- - needs.other-checks.outputs.changelog-changed == 'true' && !( startsWith(github.event.pull_request.title, 'Merge main into releases/v') && startsWith(github.event.pull_request.head.ref, 'update-v') && @@ -290,6 +289,7 @@ jobs: COMMENT_MARKER: "" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} + CHANGED: ${{ needs.other-checks.outputs.changelog-changed }} run: | comment_id=$( gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ @@ -298,6 +298,20 @@ jobs: | head -n 1 ) + # If CHANGELOG.md is no longer modified (e.g. a later commit reverted the change), + # remove any stale warning comment left over from an earlier push instead of leaving it + # to incorrectly claim the file is still modified. + if [[ "$CHANGED" == "false" ]]; then + if [[ -n "$comment_id" ]]; then + echo "CHANGELOG.md is no longer modified; deleting stale warning comment ($comment_id)." + gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" + else + echo "CHANGELOG.md was not modified and no warning comment exists; nothing to do." + fi + exit 0 + fi + + # If CHANGELOG.md has been modified and a warning comment already exists, do not post a duplicate comment. if [[ -n "$comment_id" ]]; then echo "CHANGELOG.md warning comment already exists ($comment_id)." exit 0 From 27d37d97e2a09dd0a5bdde848365258b3472a1b5 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 15:19:38 -0500 Subject: [PATCH 12/19] Format all file references as inline Markdown code spans Co-authored-by: Michael B. Gale --- .github/workflows/pr-checks.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index c5d9d55927..4ae39623c7 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -318,8 +318,8 @@ jobs: fi body="$COMMENT_MARKER - ⚠️ CHANGELOG.md has been modified in this PR. Please do not modify CHANGELOG.md directly. - Instead, create a change-note file in \`unreleased-change-notes/\`. The CHANGELOG.md + ⚠️ \`CHANGELOG.md\` has been modified in this PR. Please do not modify \`CHANGELOG.md\` directly. + Instead, create a change-note file in \`unreleased-change-notes/\`. The \`CHANGELOG.md\` file will be automatically generated from those change-notes. - See [unreleased-change-notes/README.md](unreleased-change-notes/README.md) for more information." + See [\`unreleased-change-notes/README.md\`](unreleased-change-notes/README.md) for more information." gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" From 9fe741e77d850a82a15baa665d9365395d865677 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 15:26:56 -0500 Subject: [PATCH 13/19] Simplify `if` condition of CHANGELOG-comment-post step --- .github/workflows/pr-checks.yml | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 4ae39623c7..6a21611f49 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -272,19 +272,10 @@ jobs: # Release, backport, mergeback, and bundle-update PRs deliberately modify CHANGELOG.md, # so skip comment management entirely for these automation flows. if: >- - !( - startsWith(github.event.pull_request.title, 'Merge main into releases/v') && - startsWith(github.event.pull_request.head.ref, 'update-v') && - startsWith(github.event.pull_request.base.ref, 'releases/v') || - startsWith(github.event.pull_request.title, 'Merge releases/v') && - contains(github.event.pull_request.title, ' into releases/v') && - startsWith(github.event.pull_request.head.ref, 'backport-v') && - startsWith(github.event.pull_request.base.ref, 'releases/v') || - startsWith(github.event.pull_request.title, 'Mergeback ') && - startsWith(github.event.pull_request.head.ref, 'mergeback/') || - startsWith(github.event.pull_request.title, 'Update default bundle to ') && - startsWith(github.event.pull_request.head.ref, 'update-bundle/') - ) + !startsWith(github.event.pull_request.head.ref, 'update-v') && + !startsWith(github.event.pull_request.head.ref, 'backport-v') && + !startsWith(github.event.pull_request.head.ref, 'mergeback/') && + !startsWith(github.event.pull_request.head.ref, 'update-bundle/') env: COMMENT_MARKER: "" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 8c8b0827d5a9af4db0ccee26aad2d80104f81d3d Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 23:02:25 -0500 Subject: [PATCH 14/19] Add script and Action for managing PR comments --- .github/actions/post-comment/action.yml | 46 +++++ pr-checks/post-comment.test.ts | 206 +++++++++++++++++++ pr-checks/post-comment.ts | 253 ++++++++++++++++++++++++ 3 files changed, 505 insertions(+) create mode 100644 .github/actions/post-comment/action.yml create mode 100644 pr-checks/post-comment.test.ts create mode 100644 pr-checks/post-comment.ts diff --git a/.github/actions/post-comment/action.yml b/.github/actions/post-comment/action.yml new file mode 100644 index 0000000000..f2233bc97e --- /dev/null +++ b/.github/actions/post-comment/action.yml @@ -0,0 +1,46 @@ +name: "Post comment" +description: "Create, update, or delete a sticky PR comment identified by an automatically-derived HTML-comment marker. If this action is used more than once within the same job, give each step an explicit 'id:' so the derived marker stays unique." +inputs: + body: + description: "Full comment markdown." + required: true + action-condition: + description: "The business condition ('true' or 'false') that selects which action to perform." + required: true + action-if-true: + description: "Action to perform when action-condition is 'true'. One of: none, insert, upsert, delete." + required: true + action-if-false: + description: "Action to perform when action-condition is 'false'. One of: none, insert, upsert, delete." + required: true + issue-id: + description: "The issue or pull request number to post/update/delete the comment on. Defaults to the current pull request if not provided." + required: false + token: + description: "The GitHub token to authenticate with." + required: true +runs: + using: composite + steps: + - name: Post comment + shell: bash + env: + BODY: ${{ inputs.body }} + # Derived from the workflow name, job id, and step id/sequence-number so that repeated + # runs of the same step find the same comment (for upsert/delete), while distinct steps + # (even within the same job) get distinct markers. + MARKER: + GH_TOKEN: ${{ inputs.token }} + ISSUE_ID: ${{ inputs.issue-id || github.event.pull_request.number }} + ACTION_CONDITION: ${{ inputs.action-condition }} + ACTION_IF_TRUE: ${{ inputs.action-if-true }} + ACTION_IF_FALSE: ${{ inputs.action-if-false }} + run: | + npx tsx ./pr-checks/post-comment.ts \ + --body "$BODY" \ + --marker "$MARKER" \ + --action-condition "$ACTION_CONDITION" \ + --action-if-true "$ACTION_IF_TRUE" \ + --action-if-false "$ACTION_IF_FALSE" \ + --issue-id "$ISSUE_ID" \ + --repository "$GITHUB_REPOSITORY" diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts new file mode 100644 index 0000000000..c6eb76057e --- /dev/null +++ b/pr-checks/post-comment.test.ts @@ -0,0 +1,206 @@ +#!/usr/bin/env npx tsx + +/* +Tests for post-comment.ts. +*/ + +import * as assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { type ApiClient } from "./api-client"; +import { + findExistingComment, + parseOptions, + resolveToken, +} from "./post-comment"; + +/** A baseline set of valid CLI flags, as an array, for use in parseOptions tests. */ +const validFlags = [ + "--body", + "hello", + "--marker", + "", + "--action-condition", + "true", + "--action-if-true", + "insert", + "--action-if-false", + "delete", + "--issue-id", + "42", + "--repository", + "github/codeql-action", +]; + +/** Returns a copy of `validFlags` with the value following `flag` replaced by `value`. */ +function withFlag(flag: string, value: string): string[] { + const flags = [...validFlags]; + const index = flags.indexOf(flag); + flags[index + 1] = value; + return flags; +} + +/** Returns a copy of `validFlags` with `flag` (and its value) removed entirely. */ +function withoutFlag(flag: string): string[] { + const flags = [...validFlags]; + const index = flags.indexOf(flag); + flags.splice(index, 2); + return flags; +} + +describe("parseOptions", async () => { + await it("parses valid flags", () => { + const options = parseOptions(validFlags); + assert.deepEqual(options, { + body: "hello", + marker: "", + actionCondition: true, + actionIfTrue: "insert", + actionIfFalse: "delete", + issueId: 42, + repository: { owner: "github", repo: "codeql-action" }, + }); + }); + + await it("parses action-condition=false", () => { + const options = parseOptions(withFlag("--action-condition", "false")); + assert.equal(options.actionCondition, false); + }); + + await it("rejects a missing --body", () => { + assert.throws( + () => parseOptions(withoutFlag("--body")), + /Missing required flag --body/, + ); + }); + + await it("rejects a missing --marker", () => { + assert.throws( + () => parseOptions(withoutFlag("--marker")), + /Missing required flag --marker/, + ); + }); + + await it("rejects an invalid --action-condition value", () => { + assert.throws( + () => parseOptions(withFlag("--action-condition", "yes")), + /Invalid value 'yes' for --action-condition/, + ); + }); + + await it("rejects an invalid --action-if-true value", () => { + assert.throws( + () => parseOptions(withFlag("--action-if-true", "bogus")), + /Invalid value 'bogus' for --action-if-true/, + ); + }); + + await it("rejects an invalid --action-if-false value", () => { + assert.throws( + () => parseOptions(withFlag("--action-if-false", "bogus")), + /Invalid value 'bogus' for --action-if-false/, + ); + }); + + await it("rejects a missing --issue-id", () => { + assert.throws( + () => parseOptions(withoutFlag("--issue-id")), + /Missing required flag --issue-id/, + ); + }); + + await it("rejects a non-numeric --issue-id", () => { + assert.throws( + () => parseOptions(withFlag("--issue-id", "not-a-number")), + /Invalid value 'not-a-number' for --issue-id/, + ); + }); + + await it("rejects a missing --repository", () => { + assert.throws( + () => parseOptions(withoutFlag("--repository")), + /Missing required flag --repository/, + ); + }); + + await it("rejects a --repository that isn't in the form 'owner/repo'", () => { + assert.throws( + () => parseOptions(withFlag("--repository", "not-a-slug")), + /Invalid value 'not-a-slug' for --repository/, + ); + }); +}); + +describe("resolveToken", async () => { + await it("reads the token from GH_TOKEN", () => { + assert.equal( + resolveToken({ GH_TOKEN: "gh-token", GITHUB_TOKEN: "gha-token" }), + "gh-token", + ); + }); + + await it("falls back to GITHUB_TOKEN", () => { + assert.equal(resolveToken({ GITHUB_TOKEN: "gha-token" }), "gha-token"); + }); + + await it("throws when no token is set", () => { + assert.throws(() => resolveToken({}), /Missing authentication token/); + }); +}); + +/** A minimal fake of the subset of `ApiClient` used by post-comment.ts. */ +function fakeClient(existingComments: Array<{ id: number; body?: string }>) { + const calls: { + createComment: unknown[]; + updateComment: unknown[]; + deleteComment: unknown[]; + } = { createComment: [], updateComment: [], deleteComment: [] }; + + const client = { + paginate: async () => existingComments, + rest: { + issues: { + listComments: () => {}, + createComment: async (params: unknown) => { + calls.createComment.push(params); + }, + updateComment: async (params: unknown) => { + calls.updateComment.push(params); + }, + deleteComment: async (params: unknown) => { + calls.deleteComment.push(params); + }, + }, + }, + }; + + return { client: client as unknown as ApiClient, calls }; +} + +describe("findExistingComment", async () => { + await it("finds the id of the first comment containing the marker", async () => { + const { client } = fakeClient([ + { id: 1, body: "unrelated" }, + { id: 2, body: "\nhello" }, + { id: 3, body: "\nanother" }, + ]); + const id = await findExistingComment( + client, + { owner: "owner", repo: "repo" }, + 1, + "", + ); + assert.equal(id, 2); + }); + + await it("returns undefined when no comment matches", async () => { + const { client } = fakeClient([{ id: 1, body: "unrelated" }]); + const id = await findExistingComment( + client, + { owner: "owner", repo: "repo" }, + 1, + "", + ); + assert.equal(id, undefined); + }); +}); diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts new file mode 100644 index 0000000000..f55da65d2b --- /dev/null +++ b/pr-checks/post-comment.ts @@ -0,0 +1,253 @@ +#!/usr/bin/env npx tsx + +/* +Generic CLI for creating, updating, or deleting a "sticky" PR comment identified by a hidden +HTML-comment marker. This centralizes the gh api logic previously duplicated across PR-check +workflow steps that each managed their own marked comment (e.g. a CHANGELOG.md modification +warning). + +The marker is passed explicitly via `--marker` and can be any non-empty string, though an +HTML comment (e.g. ``) is recommended so it doesn't render visibly. It is used +both to find a pre-existing comment on the pull request so that +`upsert`/`delete` actions know which comment to operate on, and is automatically prepended to +`--body` when posting or updating a comment so that future invocations can find it again. Callers +typically derive this value automatically (see `.github/actions/post-comment`) rather than +hand-authoring it. +*/ + +import { parseArgs } from "node:util"; + +import { type ApiClient, getApiClient } from "./api-client"; + +/** The set of valid actions that can be performed against a (possibly pre-existing) marked comment. */ +export const ACTIONS = ["none", "insert", "upsert", "delete"] as const; +/** An action to perform against a (possibly pre-existing) marked comment. */ +export type Action = (typeof ACTIONS)[number]; + +/** Represents the command-line options. */ +export interface Options { + /** The full comment body. */ + body: string; + /** The hidden HTML-comment marker used to find a pre-existing comment. */ + marker: string; + /** The condition that selects which action to perform. */ + actionCondition: boolean; + /** The action to perform when `actionCondition` is `true`. */ + actionIfTrue: Action; + /** The action to perform when `actionCondition` is `false`. */ + actionIfFalse: Action; + /** The issue/pull request number to post/update/delete the comment on. */ + issueId: number; + /** The repository to operate on. */ + repository: { owner: string; repo: string }; +} + +/** + * Validates and parses a required CLI flag's raw string `value`, delegating the actual + * value-specific validation/conversion to `parse`. + */ +function parseFlag( + flag: string, + value: string | undefined, + parse: (value: string) => T, +): T { + if (value === undefined) { + throw new Error(`Missing required flag --${flag}.`); + } + try { + return parse(value); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid value '${value}' for --${flag}. ${reason}`); + } +} + +/** Parses and validates `value` as an `Action`. */ +function parseAction(flag: string, value: string | undefined): Action { + return parseFlag(flag, value, (raw) => { + if (!(ACTIONS as readonly string[]).includes(raw)) { + throw new Error(`Must be one of: ${ACTIONS.join(", ")}.`); + } + return raw as Action; + }); +} + +/** Parses and validates `value` as a boolean. */ +function parseBoolean(flag: string, value: string | undefined): boolean { + return parseFlag(flag, value, (raw) => { + if (raw !== "true" && raw !== "false") { + throw new Error("Must be 'true' or 'false'."); + } + return raw === "true"; + }); +} + +/** Parses and validates `value` as an issue/pull request number. */ +function parseIssueId(value: string | undefined): number { + return parseFlag("issue-id", value, (raw) => { + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) { + throw new Error("Must be an integer."); + } + return parsed; + }); +} + +/** Parses and validates `value` as an `owner/repo` repository reference. */ +function parseRepository(value: string | undefined): { + owner: string; + repo: string; +} { + return parseFlag("repository", value, (raw) => { + const [owner, repo, ...rest] = raw.split("/"); + if (!owner || !repo || rest.length > 0) { + throw new Error("Must be in the form 'owner/repo'."); + } + return { owner, repo }; + }); +} + +/** Parses the command-line arguments into `Options`. */ +export function parseOptions(argv?: string[]): Options { + const { values } = parseArgs({ + args: argv, + options: { + body: { type: "string" }, + marker: { type: "string" }, + "action-condition": { type: "string" }, + "action-if-true": { type: "string" }, + "action-if-false": { type: "string" }, + "issue-id": { type: "string" }, + repository: { type: "string" }, + }, + strict: true, + }); + + if (values.body === undefined) { + throw new Error("Missing required flag --body."); + } + if (values.marker === undefined) { + throw new Error("Missing required flag --marker."); + } + + return { + body: values.body, + marker: values.marker, + actionCondition: parseBoolean( + "action-condition", + values["action-condition"], + ), + actionIfTrue: parseAction("action-if-true", values["action-if-true"]), + actionIfFalse: parseAction("action-if-false", values["action-if-false"]), + issueId: parseIssueId(values["issue-id"]), + repository: parseRepository(values.repository), + }; +} + +/** Resolves the GitHub API token to use, from `GH_TOKEN` or `GITHUB_TOKEN`. */ +export function resolveToken(env: NodeJS.ProcessEnv): string { + const token = env.GH_TOKEN?.trim() || env.GITHUB_TOKEN?.trim(); + if (!token) { + throw new Error( + "Missing authentication token. Set GH_TOKEN or GITHUB_TOKEN.", + ); + } + return token; +} + +/** + * Finds the first comment on the given issue/pull request whose body starts with `marker`, if any. + */ +export async function findExistingComment( + client: ApiClient, + repository: { owner: string; repo: string }, + issueNumber: number, + marker: string, +): Promise { + const comments = await client.paginate(client.rest.issues.listComments, { + ...repository, + issue_number: issueNumber, + per_page: 100, + }); + return comments.find((comment) => comment.body?.startsWith(marker))?.id; +} + +async function main(): Promise { + const options = parseOptions(); + const action = options.actionCondition + ? options.actionIfTrue + : options.actionIfFalse; + console.info(`Resolved action: ${action}`); + + const token = resolveToken(process.env); + const { issueId, marker, repository } = options; + const client = getApiClient(token); + const body = `${marker}\n${options.body}`; + + let existingCommentId: number | undefined; + if (action === "upsert" || action === "delete") { + existingCommentId = await findExistingComment( + client, + repository, + issueId, + marker, + ); + } + + switch (action) { + case "none": + console.info("No action needed."); + break; + case "insert": + console.info("Creating a new comment."); + await client.rest.issues.createComment({ + ...repository, + issue_number: issueId, + body, + }); + break; + case "upsert": + if (existingCommentId === undefined) { + console.info("No existing comment found; creating a new comment."); + await client.rest.issues.createComment({ + ...repository, + issue_number: issueId, + body, + }); + } else { + console.info(`Updating existing comment ${existingCommentId}.`); + await client.rest.issues.updateComment({ + ...repository, + comment_id: existingCommentId, + body, + }); + } + break; + case "delete": + if (existingCommentId === undefined) { + console.info("No existing comment found to delete; skipping."); + } else { + console.info(`Deleting existing comment ${existingCommentId}.`); + await client.rest.issues.deleteComment({ + ...repository, + comment_id: existingCommentId, + }); + } + break; + } + + return 0; +} + +async function run(): Promise { + try { + process.exit(await main()); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } +} + +if (import.meta.main) { + void run(); +} From 14cefaa03ce3f948a8c839aa160cd1f52100a34c Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 23:03:55 -0500 Subject: [PATCH 15/19] Replace bash/run step with local Action `post-comment` --- .github/workflows/pr-checks.yml | 69 +++++++++++++++------------------ 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 6a21611f49..8ebbcd0ba9 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -238,6 +238,25 @@ jobs: group: check-repo-size-${{ github.event.pull_request.number }} steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Check out the base ref, not the PR's head, since this job runs with + # `pull-requests: write` permissions and must not execute untrusted code from the PR. + ref: ${{ github.event.pull_request.base.sha }} + sparse-checkout: | + pr-checks + .github/actions/post-comment + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: "npm" + + - name: Install dependencies + run: npm ci --workspace=pr-checks + - name: Download repo size comment uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -269,6 +288,7 @@ jobs: fi - name: Post a warning about modifying CHANGELOG.md + id: post-changelog-warning # Release, backport, mergeback, and bundle-update PRs deliberately modify CHANGELOG.md, # so skip comment management entirely for these automation flows. if: >- @@ -276,41 +296,14 @@ jobs: !startsWith(github.event.pull_request.head.ref, 'backport-v') && !startsWith(github.event.pull_request.head.ref, 'mergeback/') && !startsWith(github.event.pull_request.head.ref, 'update-bundle/') - env: - COMMENT_MARKER: "" - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - CHANGED: ${{ needs.other-checks.outputs.changelog-changed }} - run: | - comment_id=$( - gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ - --paginate \ - --jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" \ - | head -n 1 - ) - - # If CHANGELOG.md is no longer modified (e.g. a later commit reverted the change), - # remove any stale warning comment left over from an earlier push instead of leaving it - # to incorrectly claim the file is still modified. - if [[ "$CHANGED" == "false" ]]; then - if [[ -n "$comment_id" ]]; then - echo "CHANGELOG.md is no longer modified; deleting stale warning comment ($comment_id)." - gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" - else - echo "CHANGELOG.md was not modified and no warning comment exists; nothing to do." - fi - exit 0 - fi - - # If CHANGELOG.md has been modified and a warning comment already exists, do not post a duplicate comment. - if [[ -n "$comment_id" ]]; then - echo "CHANGELOG.md warning comment already exists ($comment_id)." - exit 0 - fi - - body="$COMMENT_MARKER - ⚠️ \`CHANGELOG.md\` has been modified in this PR. Please do not modify \`CHANGELOG.md\` directly. - Instead, create a change-note file in \`unreleased-change-notes/\`. The \`CHANGELOG.md\` - file will be automatically generated from those change-notes. - See [\`unreleased-change-notes/README.md\`](unreleased-change-notes/README.md) for more information." - gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" + uses: ./.github/actions/post-comment + with: + body: | + ⚠️ `CHANGELOG.md` has been modified in this PR. Please do not modify `CHANGELOG.md` directly. + Instead, create a change-note file in `unreleased-change-notes/`. The `CHANGELOG.md` + file will be automatically generated from those change-notes. + See [`unreleased-change-notes/README.md`](unreleased-change-notes/README.md) for more information. + action-condition: ${{ needs.other-checks.outputs.changelog-changed }} + action-if-true: upsert + action-if-false: delete + token: ${{ secrets.GITHUB_TOKEN }} From 4d464b069771bb6541931592879afc5c59cd9823 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 9 Oct 2026 10:35:34 -0500 Subject: [PATCH 16/19] Improve issue ID parsing and validation logic Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pr-checks/post-comment.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index f55da65d2b..28e0657863 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -85,9 +85,9 @@ function parseBoolean(flag: string, value: string | undefined): boolean { /** Parses and validates `value` as an issue/pull request number. */ function parseIssueId(value: string | undefined): number { return parseFlag("issue-id", value, (raw) => { - const parsed = Number.parseInt(raw, 10); - if (Number.isNaN(parsed)) { - throw new Error("Must be an integer."); + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error("Must be a positive integer."); } return parsed; }); From 3c452a182da69a19b99b58783430dfe368591568 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 9 Oct 2026 10:36:48 -0500 Subject: [PATCH 17/19] Use HTTP link to `unreleased-change-notes/README.md` Update link in PR checks workflow to point to the correct README. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/pr-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 8ebbcd0ba9..1fbab56124 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -302,7 +302,7 @@ jobs: ⚠️ `CHANGELOG.md` has been modified in this PR. Please do not modify `CHANGELOG.md` directly. Instead, create a change-note file in `unreleased-change-notes/`. The `CHANGELOG.md` file will be automatically generated from those change-notes. - See [`unreleased-change-notes/README.md`](unreleased-change-notes/README.md) for more information. + See [`unreleased-change-notes/README.md`](https://github.com/github/codeql-action/blob/main/unreleased-change-notes/README.md) for more information. action-condition: ${{ needs.other-checks.outputs.changelog-changed }} action-if-true: upsert action-if-false: delete From f1419b8c766d21454b9ac9283a268e3dab6afdac Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 9 Oct 2026 17:11:18 -0500 Subject: [PATCH 18/19] Reject empty `--marker` --- pr-checks/post-comment.test.ts | 7 +++++++ pr-checks/post-comment.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index c6eb76057e..492c45f341 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -81,6 +81,13 @@ describe("parseOptions", async () => { ); }); + await it("rejects an empty --marker", () => { + assert.throws( + () => parseOptions(withFlag("--marker", "")), + /Missing required flag --marker/, + ); + }); + await it("rejects an invalid --action-condition value", () => { assert.throws( () => parseOptions(withFlag("--action-condition", "yes")), diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 28e0657863..01b36cac7f 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -126,7 +126,7 @@ export function parseOptions(argv?: string[]): Options { if (values.body === undefined) { throw new Error("Missing required flag --body."); } - if (values.marker === undefined) { + if (values.marker === undefined || values.marker.trim() === "") { throw new Error("Missing required flag --marker."); } From e7dadca872e576f50526a5e4fb5583ffba2add03 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 9 Oct 2026 17:35:09 -0500 Subject: [PATCH 19/19] Refactor comment handling logic into `performAction` for improved testability --- pr-checks/post-comment.test.ts | 86 ++++++++++++++++++++++++++++++++++ pr-checks/post-comment.ts | 32 +++++++++---- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index 492c45f341..db6d455393 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -10,7 +10,9 @@ import { describe, it } from "node:test"; import { type ApiClient } from "./api-client"; import { findExistingComment, + type Options, parseOptions, + performAction, resolveToken, } from "./post-comment"; @@ -211,3 +213,87 @@ describe("findExistingComment", async () => { assert.equal(id, undefined); }); }); + +/** A baseline set of `Options`, for use in `performAction` tests. */ +const baseOptions: Options = { + body: "hello", + marker: "", + actionCondition: true, + actionIfTrue: "insert", + actionIfFalse: "delete", + issueId: 42, + repository: { owner: "owner", repo: "repo" }, +}; + +describe("performAction", async () => { + await it("does nothing for 'none'", async () => { + const { client, calls } = fakeClient([]); + await performAction(client, "none", baseOptions); + assert.deepEqual(calls, { + createComment: [], + updateComment: [], + deleteComment: [], + }); + }); + + await it("creates a new comment for 'insert', with the marker prepended", async () => { + const { client, calls } = fakeClient([]); + await performAction(client, "insert", baseOptions); + assert.deepEqual(calls.createComment, [ + { + owner: "owner", + repo: "repo", + issue_number: 42, + body: "\nhello", + }, + ]); + assert.deepEqual(calls.updateComment, []); + assert.deepEqual(calls.deleteComment, []); + }); + + await it("creates a new comment for 'upsert' when no existing comment is found", async () => { + const { client, calls } = fakeClient([{ id: 1, body: "unrelated" }]); + await performAction(client, "upsert", baseOptions); + assert.deepEqual(calls.createComment, [ + { + owner: "owner", + repo: "repo", + issue_number: 42, + body: "\nhello", + }, + ]); + assert.deepEqual(calls.updateComment, []); + }); + + await it("updates the existing comment for 'upsert' when one is found", async () => { + const { client, calls } = fakeClient([ + { id: 7, body: "\nold" }, + ]); + await performAction(client, "upsert", baseOptions); + assert.deepEqual(calls.updateComment, [ + { + owner: "owner", + repo: "repo", + comment_id: 7, + body: "\nhello", + }, + ]); + assert.deepEqual(calls.createComment, []); + }); + + await it("does nothing for 'delete' when no existing comment is found", async () => { + const { client, calls } = fakeClient([{ id: 1, body: "unrelated" }]); + await performAction(client, "delete", baseOptions); + assert.deepEqual(calls.deleteComment, []); + }); + + await it("deletes the existing comment for 'delete' when one is found", async () => { + const { client, calls } = fakeClient([ + { id: 9, body: "\nold" }, + ]); + await performAction(client, "delete", baseOptions); + assert.deepEqual(calls.deleteComment, [ + { owner: "owner", repo: "repo", comment_id: 9 }, + ]); + }); +}); diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 01b36cac7f..6c1a1e5e54 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -172,16 +172,17 @@ export async function findExistingComment( return comments.find((comment) => comment.body?.startsWith(marker))?.id; } -async function main(): Promise { - const options = parseOptions(); - const action = options.actionCondition - ? options.actionIfTrue - : options.actionIfFalse; - console.info(`Resolved action: ${action}`); - - const token = resolveToken(process.env); +/** + * Performs the resolved `action` against a (possibly pre-existing) marked comment, using + * `client` to talk to the GitHub API. This is the core dispatch logic of this script, extracted + * from `main` so it can be unit-tested with an injected (fake) `client`. + */ +export async function performAction( + client: ApiClient, + action: Action, + options: Options, +): Promise { const { issueId, marker, repository } = options; - const client = getApiClient(token); const body = `${marker}\n${options.body}`; let existingCommentId: number | undefined; @@ -235,6 +236,19 @@ async function main(): Promise { } break; } +} + +async function main(): Promise { + const options = parseOptions(); + const action = options.actionCondition + ? options.actionIfTrue + : options.actionIfFalse; + console.info(`Resolved action: ${action}`); + + const token = resolveToken(process.env); + const client = getApiClient(token); + + await performAction(client, action, options); return 0; }