diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 00000000..cdbce9c9 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,4 @@ +ignore: + - "spec-helper.mjs" + - "test-reporter.mjs" + - "**/*.test.mjs" diff --git a/.github/workflows/check-commit-format.yml b/.github/workflows/check-commit-format.yml deleted file mode 100644 index 6e1084c2..00000000 --- a/.github/workflows/check-commit-format.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Check commit format - -on: - pull_request: - paths: - - '**check-commit-format**' - - 'package.json' - - 'package-lock.json' - - 'node_modules/**' - -permissions: - pull-requests: write - statuses: write - -jobs: - check-commit-format: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Test - uses: ./check-commit-format/ - with: - failure_label: enhancement - - - name: Unlabel - continue-on-error: true - env: - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - PR: ${{github.event.pull_request.number}} - run: gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/$PR/labels/enhancement" diff --git a/.github/workflows/dismiss-approvals.yml b/.github/workflows/dismiss-approvals.yml deleted file mode 100644 index 7a4ed1ed..00000000 --- a/.github/workflows/dismiss-approvals.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Dismiss approvals - -on: - pull_request: - paths: - - '**dismiss-approvals**' - - 'package.json' - - 'package-lock.json' - - 'node_modules/**' - -permissions: - pull-requests: write - -jobs: - dismiss-approvals: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Approve - env: - GITHUB_TOKEN: ${{github.token}} - run: gh pr review --approve ${{github.event.pull_request.number}} - - - name: Dismiss - uses: ./dismiss-approvals/ - with: - pr: ${{github.event.pull_request.number}} - message: test diff --git a/.github/workflows/label-pull-requests.yml b/.github/workflows/label-pull-requests.yml deleted file mode 100644 index 4f21f6e8..00000000 --- a/.github/workflows/label-pull-requests.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Label pull requests - -on: - pull_request: - paths: - - '**label-pull-requests**' - - 'package.json' - - 'package-lock.json' - - 'node_modules/**' - -permissions: - issues: write - pull-requests: write - -jobs: - label-pull-requests: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Test JSON - uses: ./label-pull-requests/ - with: - def: | - [ - { - "label": "wontfix", - "path": ".+" - } - ] - - - name: Unlabel - env: - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - PR: ${{github.event.pull_request.number}} - run: gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/$PR/labels/wontfix" - - - name: Test YAML - uses: ./label-pull-requests/ - with: - def: | - - label: invalid - path: .+ - - - name: Unlabel - env: - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - PR: ${{github.event.pull_request.number}} - run: gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/$PR/labels/invalid" diff --git a/.github/workflows/post-comment.yml b/.github/workflows/post-comment.yml deleted file mode 100644 index bf272c1a..00000000 --- a/.github/workflows/post-comment.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Post comment - -on: - pull_request: - paths: - - '**post-comment**' - - 'package.json' - - 'package-lock.json' - - 'node_modules/**' - -permissions: - pull-requests: write - -jobs: - post-comment: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Test - uses: ./post-comment/ - with: - issue: ${{github.event.pull_request.number}} - body: body - bot_body: bot body - bot: BrewTestBot diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..fc6df8bf --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Node test + +on: + push: + branches: + - master + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run tests + run: npm test -- --experimental-test-coverage --test-reporter lcov --test-reporter-destination lcov.info + + - name: Upload coverage results + uses: codecov/codecov-action@125fc84a9a348dbcf27191600683ec096ec9021c # v4.4.1 + with: + files: lcov.info + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index f2a971aa..00000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -ruby 3.2.2 diff --git a/check-commit-format/action.yml b/check-commit-format/action.yml index 735eec58..1af3fd3b 100644 --- a/check-commit-format/action.yml +++ b/check-commit-format/action.yml @@ -23,4 +23,4 @@ inputs: default: CI-published-bottle-commits runs: using: node20 - main: main.js + main: main.mjs diff --git a/check-commit-format/main.js b/check-commit-format/main.mjs similarity index 96% rename from check-commit-format/main.js rename to check-commit-format/main.mjs index 2b823876..8cfb7778 100644 --- a/check-commit-format/main.js +++ b/check-commit-format/main.mjs @@ -1,7 +1,7 @@ -const core = require('@actions/core') -const github = require('@actions/github') -const fs = require('fs') -const path = require('path') +import core from "@actions/core" +import github from "@actions/github" +import fs from "fs" +import path from "path" async function main() { try { @@ -37,7 +37,7 @@ async function main() { ref: commit.sha }) - short_sha = commit.sha.substring(0, 10); + const short_sha = commit.sha.substring(0, 10); // Autosquash doesn't support merge commits. if (commit_info.data.parents.length != 1) { @@ -167,8 +167,8 @@ async function main() { labels: updatedLabels }) } catch (error) { - core.setFailed(error.message) + core.setFailed(error) } } -main() +await main() diff --git a/check-commit-format/main.test.mjs b/check-commit-format/main.test.mjs new file mode 100644 index 00000000..c74d225a --- /dev/null +++ b/check-commit-format/main.test.mjs @@ -0,0 +1,236 @@ +import fs from "fs" +import os from "os" +import path from "path" +import util from "util" + +describe("check-commit-format", async () => { + const token = "fake-token" + const pr = 12345 + const sha = "abcdef1234567890abcdef1234567890abcdef12" + const failureLabel = "failure-label" + const autosquashLabel = "autosquash-label" + const ignoreLabel = "ignore-label" + + beforeEach(() => { + mockInput("token", token) + mockInput("failure_label", failureLabel) + mockInput("autosquash_label", autosquashLabel) + mockInput("ignore_label", ignoreLabel) + + const tempdir = fs.mkdtempSync(path.join(os.tmpdir(), "check-commit-format-")) + const tempfile = `${tempdir}/event.json` + fs.writeFileSync(tempfile, JSON.stringify({ + pull_request: { + number: pr, + }, + })) + process.env.GITHUB_EVENT_PATH = tempfile + }) + + afterEach(() => { + fs.rmSync(path.dirname(process.env.GITHUB_EVENT_PATH), { recursive: true }) + }) + + describe("on a correct commit", async () => { + beforeEach(() => { + const mockPool = githubMockPool() + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/pulls/${pr}/commits`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, [ + { + sha: sha, + }, + ]) + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/commits/${sha}`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, { + sha: sha, + parents: [{ sha: "abcdef1234567890abcdef1234567890abcdef11" }], + files: [{ filename: "Formula/foo.rb" }], + commit: { + message: "foo: some commit", + } + }) + + mockPool.intercept({ + method: "POST", + path: `/repos/${GITHUB_REPOSITORY}/statuses/${sha}`, + headers: { + Authorization: `token ${token}`, + }, + body: (body) => util.isDeepStrictEqual(JSON.parse(body), { + state: "success", + description: "Commit format is correct.", + context: "Commit style", + target_url: "https://docs.brew.sh/Formula-Cookbook#commit" + }), + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, {}) + }) + + it("succeeds without updating labels", async () => { + const mockPool = githubMockPool() + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/issues/${pr}/labels`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, []) + + await loadMain() + }) + + it("succeeds while removing existing failure labels", async () => { + const mockPool = githubMockPool() + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/issues/${pr}/labels`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, [ + { name: "other-label" }, + { name: failureLabel }, + ]) + + mockPool.intercept({ + method: "PATCH", + path: `/repos/${GITHUB_REPOSITORY}/issues/${pr}`, + headers: { + Authorization: `token ${token}`, + }, + body: (body) => util.isDeepStrictEqual(JSON.parse(body), { + labels: ["other-label"], + }), + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, {}) + + await loadMain() + }) + }) + + describe("on an autosquashable incorrect commit", async () => { + beforeEach(() => { + const mockPool = githubMockPool() + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/pulls/${pr}/commits`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, [ + { + sha: sha, + }, + ]) + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/commits/${sha}`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, { + sha: sha, + parents: [{ sha: "abcdef1234567890abcdef1234567890abcdef11" }], + files: [{ filename: "Formula/foo.rb" }], + commit: { + message: "Update foo.rb", + } + }) + + mockPool.intercept({ + method: "POST", + path: `/repos/${GITHUB_REPOSITORY}/statuses/${sha}`, + headers: { + Authorization: `token ${token}`, + }, + body: (body) => util.isDeepStrictEqual(JSON.parse(body), { + state: "failure", + description: "Please follow the commit style guidelines, or this pull request will be replaced.", + context: "Commit style", + target_url: "https://docs.brew.sh/Formula-Cookbook#commit" + }), + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, {}) + }) + + it("fails and adds a autosquash label", async () => { + const mockPool = githubMockPool() + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/issues/${pr}/labels`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, [ + { name: "other-label" }, + ]) + + mockPool.intercept({ + method: "PATCH", + path: `/repos/${GITHUB_REPOSITORY}/issues/${pr}`, + headers: { + Authorization: `token ${token}`, + }, + body: (body) => util.isDeepStrictEqual(JSON.parse(body), { + labels: ["other-label", autosquashLabel], + }), + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, {}) + + await loadMain() + }) + + it("fails while retaining existing autosquash labels", async () => { + const mockPool = githubMockPool() + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/issues/${pr}/labels`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, [ + { name: autosquashLabel }, + ]) + + await loadMain() + }) + }) +}) diff --git a/check-commit-format/package.json b/check-commit-format/package.json index 49ef8545..80e7206f 100644 --- a/check-commit-format/package.json +++ b/check-commit-format/package.json @@ -1,4 +1,4 @@ { "name": "check-commit-format", - "main": "main.js" + "main": "main.mjs" } diff --git a/dismiss-approvals/action.yml b/dismiss-approvals/action.yml index 1bbc20b6..04f72e97 100644 --- a/dismiss-approvals/action.yml +++ b/dismiss-approvals/action.yml @@ -17,4 +17,4 @@ inputs: required: true runs: using: node20 - main: main.js + main: main.mjs diff --git a/dismiss-approvals/main.js b/dismiss-approvals/main.mjs similarity index 82% rename from dismiss-approvals/main.js rename to dismiss-approvals/main.mjs index 6059cb5a..787eda04 100644 --- a/dismiss-approvals/main.js +++ b/dismiss-approvals/main.mjs @@ -1,5 +1,5 @@ -const core = require('@actions/core') -const github = require('@actions/github') +import core from "@actions/core" +import github from "@actions/github" async function main() { try { @@ -20,7 +20,7 @@ async function main() { core.info(`==> Dismissing approvals in PR #${pr}`) - client.rest.pulls.dismissReview({ + await client.rest.pulls.dismissReview({ ...github.context.repo, pull_number: pr, review_id: review.id, @@ -28,8 +28,8 @@ async function main() { }); } } catch (error) { - core.setFailed(error.message) + core.setFailed(error) } } -main() +await main() diff --git a/dismiss-approvals/main.test.mjs b/dismiss-approvals/main.test.mjs new file mode 100644 index 00000000..bc30e622 --- /dev/null +++ b/dismiss-approvals/main.test.mjs @@ -0,0 +1,40 @@ +test("dismiss-approvals", async () => { + const mockPool = githubMockPool() + + const token = "fake-token" + const pr = "12345" + const message = "Some message" + + mockInput("token", token) + mockInput("pr", pr) + mockInput("message", message) + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/pulls/${pr}/reviews`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, [ + { id: 1, state: "APPROVED" }, + { id: 2, state: "CHANGES_REQUESTED" }, + { id: 3, state: "APPROVED" }, + ]) + + for (const review of [1, 3]) { + mockPool.intercept({ + method: "PUT", + path: `/repos/${GITHUB_REPOSITORY}/pulls/${pr}/reviews/${review}/dismissals`, + headers: { + Authorization: `token ${token}`, + }, + body: (body) => JSON.parse(body).message === message, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, {}) + } + + await loadMain() +}) diff --git a/dismiss-approvals/package.json b/dismiss-approvals/package.json index 1b0b98d8..aaecfcf4 100644 --- a/dismiss-approvals/package.json +++ b/dismiss-approvals/package.json @@ -1,4 +1,4 @@ { "name": "dismiss-approvals", - "main": "main.js" + "main": "main.mjs" } diff --git a/label-pull-requests/action.yml b/label-pull-requests/action.yml index e1a2a4c8..3a27fb87 100644 --- a/label-pull-requests/action.yml +++ b/label-pull-requests/action.yml @@ -78,4 +78,4 @@ inputs: required: true runs: using: node20 - main: main.js + main: main.mjs diff --git a/label-pull-requests/main.js b/label-pull-requests/main.mjs similarity index 97% rename from label-pull-requests/main.js rename to label-pull-requests/main.mjs index b42f7850..cabffc3d 100644 --- a/label-pull-requests/main.js +++ b/label-pull-requests/main.mjs @@ -1,7 +1,7 @@ -const core = require('@actions/core') -const github = require('@actions/github') -const fs = require('fs') -const yaml = require('js-yaml') +import core from "@actions/core" +import github from "@actions/github" +import fs from "fs" +import yaml from "js-yaml" async function main() { try { @@ -215,7 +215,7 @@ async function main() { labels: updatedLabels }) } catch (error) { - core.setFailed(error.message) + core.setFailed(error) } } @@ -283,4 +283,4 @@ function doesConstraintApply(constraint, file) { return true } -main() +await main() diff --git a/label-pull-requests/main.test.mjs b/label-pull-requests/main.test.mjs new file mode 100644 index 00000000..76025ce2 --- /dev/null +++ b/label-pull-requests/main.test.mjs @@ -0,0 +1,124 @@ +import fs from "fs" +import os from "os" +import path from "path" +import util from "util" + +describe("label-pull-requests", async () => { + const token = "fake-token" + const pr = 12345 + const fileSha = "abcdef1234567890abcdef1234567890abcdef12" + const label = "wontfix" + const existingLabel = "existing-label" + + beforeEach(() => { + mockInput("token", token) + + const tempdir = fs.mkdtempSync(path.join(os.tmpdir(), "check-commit-format-")) + const tempfile = `${tempdir}/event.json` + fs.writeFileSync(tempfile, JSON.stringify({ + pull_request: { + number: pr, + }, + })) + process.env.GITHUB_EVENT_PATH = tempfile + }) + + afterEach(() => { + fs.rmSync(path.dirname(process.env.GITHUB_EVENT_PATH), { recursive: true }) + }) + + describe("correctly labels", async () => { + beforeEach(() => { + const mockPool = githubMockPool() + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/pulls/${pr}/files`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, [ + { + sha: fileSha, + filename: "some/file.txt", + }, + ]) + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/pulls/${pr}`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, { + body: "Created with `brew bump-formula-pr`.", + }) + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/git/blobs/${fileSha}`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, { + content: "This is the file contents.", + encoding: "ascii", + }) + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/issues/${pr}/labels`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, [ + { + name: existingLabel, + }, + ]) + + mockPool.intercept({ + method: "PATCH", + path: `/repos/${GITHUB_REPOSITORY}/issues/${pr}`, + headers: { + Authorization: `token ${token}`, + }, + body: (body) => util.isDeepStrictEqual(JSON.parse(body), { + labels: [existingLabel, label], + }), + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, {}) + }) + + it("with JSON input", async () => { + mockInput("def", ` +[ + { + "label": "${label}", + "path": ".+" + } +] + `) + + await loadMain() + }) + + it("with YAML input", async () => { + mockInput("def", ` +- label: ${label} + path: .+ + `) + + await loadMain() + }) + }) +}) diff --git a/label-pull-requests/package.json b/label-pull-requests/package.json index 902f7d7f..5c122670 100644 --- a/label-pull-requests/package.json +++ b/label-pull-requests/package.json @@ -1,4 +1,4 @@ { "name": "label-pull-requests", - "main": "main.js" + "main": "main.mjs" } diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index fd356965..3676f94d 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -251,6 +251,11 @@ "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" }, + "node_modules/esm-reload": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/esm-reload/-/esm-reload-1.0.1.tgz", + "integrity": "sha512-kQUtZmuj3QutOF+zj3nJboc8PDdxjIwz1FF3hgCgGcxXwj0x/oBzIww0wH1RY5paehQdnnp6/ZeNmMSbLZ7cgA==" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", diff --git a/node_modules/esm-reload/.github/workflows/CI.yaml b/node_modules/esm-reload/.github/workflows/CI.yaml new file mode 100644 index 00000000..e6ec7e59 --- /dev/null +++ b/node_modules/esm-reload/.github/workflows/CI.yaml @@ -0,0 +1,32 @@ +# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Node.js CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - run: npm run test + - run: npm run cover + - uses: codecov/codecov-action@v3 diff --git a/node_modules/esm-reload/LICENSE b/node_modules/esm-reload/LICENSE new file mode 100644 index 00000000..bcfb4a64 --- /dev/null +++ b/node_modules/esm-reload/LICENSE @@ -0,0 +1,7 @@ +ISC License + +Copyright 2024 Marcel Laverdet & Pierre-Yves Gérardy + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. \ No newline at end of file diff --git a/node_modules/esm-reload/README.md b/node_modules/esm-reload/README.md new file mode 100644 index 00000000..2a2126db --- /dev/null +++ b/node_modules/esm-reload/README.md @@ -0,0 +1,102 @@ +# ES module reload + +This module lets you reload an ES module and its dependencies in `Node.js`. It does so by adding a [module resolution hook](https://nodejs.org/api/module.html#resolvespecifier-context-nextresolve). + +## Background + +Per spec, ES modules are cached the first time they are imported, and subsequent import statements return the same object. + +```JS +import {assert} from 'node:assert/strict' + +const m1 = await import('./my-module.js') +const m2 = await import('./my-module.js') + + +assert.equal(m1, m2) // passes +``` + +This is desirable in most scenarios, but you can sometimes want to instantiate a module several times. For example, you may want to test a module that branches at load time depending on its environment. To some extent, that can be achieved by tacking a query string at the end of the `import` specifier: + +```JS +import {assert} from 'node:assert/strict' + +const m1 = await import('./my-module.js?dev') +process.env.NODE_ENV='production' +const m2 = await import('./my-module.js?prod') + +assert.notEqual(mDev, mProd) // passes +``` + +However, this doesn't work transitively. `./my-module.js?prod` will import the dependencies that were cached when `./my-module.js?dev` was loaded. + +If you want to load a module multpile times from scratch with its dependencies you can use this module. + +## Usage: + +The resolver hook gives a special meaning to `?instance=...` and `?reload` query strings. + +If you want to retrieve a specific instance, use the former with an identifier of your choice. + +```JS +import "esm-reload" // this registers the hook + +const mDev = await import("./myModule.js?instance=dev") +process.env.NODE_ENV='production' +const mProd = await import("./myModule.js?instance=prod") +const mDev2 = await import("./myModule.js?instance=dev") + +assert.equal(mDev, mDev2) // passes +assert.notEqual(mDev, mProd) // passes +``` + +If you just want a fresh instance you can use `?reload` + +```JS +const mReloaded = await import("./myModule.js?reload") +assert.notEqual(mDev, mReloaded) // passes +assert.notEqual(mProd, mReloaded) // passes + +// ?reload is "magic" +const mReloaded2 = await import("./myModule.js?reload") +assert.notEqual(mReloaded, mReloaded2) // passes +``` + +In both cases, instances come with a fresh set of dependencies (except for the builtin `node:xxx` modules that don't support query strings at all). + +### With dependencies + +Suppose these files: + +```JS +// foo.js +export {x} from "./bar.js" + +// bar.js +export const x = {} +``` + +We can then do + +```JS +import "esm-reload" + +const foo1 = await import("./foo.js?instance=1") +const bar1 = await import("./bar.js?instance=1") + +const foo2 = await import("./foo.js?instance=2") +const bar2 = await import("./bar.js?instance=2") + +assert.equal(foo1.x, bar1.x) +assert.equal(foo2.x, bar1.x) + +assert.notEqual(bar1.x, bar2.x) +``` + +## Credit: + +The hook was originally written by Marcel Laverdet([@laverdet](https://github.com/laverdet)) then tweaked, tested and documented by yours truly. + +## License + +ISC diff --git a/node_modules/esm-reload/loader.js b/node_modules/esm-reload/loader.js new file mode 100644 index 00000000..75052fc9 --- /dev/null +++ b/node_modules/esm-reload/loader.js @@ -0,0 +1,37 @@ +export {resolve} + +import {register, isBuiltin} from 'node:module'; + +if (!import.meta.url.endsWith("?loader")) { + register(`${import.meta.url}?loader`); +} + +const selfURL = import.meta.url.replace(/\?loader$/, '') +const isSelf = url => url === selfURL + +let id = 0; + +async function resolve(specifier, context, nextResolve) { + const result = await nextResolve(specifier, context); + + if (!isSelf(result.url) && !isBuiltin(result.url) && context.parentURL) { + const url = new URL(result.url); + const parentUrl = new URL(context.parentURL); + const instance = url.searchParams.get("reload") === "" + ? `esm-reload-${id++}` + : parentUrl.searchParams.get("instance"); + + if (instance !== null) { + if (url.searchParams.has('reload')) { + url.searchParams.delete('reload') + } + url.searchParams.set("instance", instance); + + return { + ...result, + url: `${url}`, + }; + } + } + return result; +} diff --git a/node_modules/esm-reload/package.json b/node_modules/esm-reload/package.json new file mode 100644 index 00000000..ff637f3e --- /dev/null +++ b/node_modules/esm-reload/package.json @@ -0,0 +1,25 @@ +{ + "name": "esm-reload", + "version": "1.0.1", + "description": "\"Reload an ES module and its dependencies\"", + "type": "module", + "main": "loader.js", + "scripts": { + "test": "ospec tests/tests.js", + "cover": "c8 ./node_modules/.bin/ospec tests/tests.js" + }, + "author": "Marcel Laverdet (@laverdet on GitHub)", + "contributors": [ + "Pierre-Yves Gérardy (@pygy on GitHub)" + ], + "license": "ISC", + "devDependencies": { + "c8": "^9.1.0", + "ospec": "^4.2.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/pygy/esm-reload.git" + }, + "homepage": "https://github.com/pygy/esm-reload" +} diff --git a/node_modules/esm-reload/tests/fixtures/simple/dep.js b/node_modules/esm-reload/tests/fixtures/simple/dep.js new file mode 100644 index 00000000..2e0aa984 --- /dev/null +++ b/node_modules/esm-reload/tests/fixtures/simple/dep.js @@ -0,0 +1,2 @@ +import "./transitive.js" +import 'node:path' \ No newline at end of file diff --git a/node_modules/esm-reload/tests/fixtures/simple/index.js b/node_modules/esm-reload/tests/fixtures/simple/index.js new file mode 100644 index 00000000..5ce21834 --- /dev/null +++ b/node_modules/esm-reload/tests/fixtures/simple/index.js @@ -0,0 +1,8 @@ +import '../../../loader.js' + +await import("./dep.js") +await import("./dep.js?reload") +await import("./dep.js?instance=1") +await import("./dep.js?reload") +await import("./dep.js?instance=2") +await import("./dep.js?instance=1") diff --git a/node_modules/esm-reload/tests/fixtures/simple/package.json b/node_modules/esm-reload/tests/fixtures/simple/package.json new file mode 100644 index 00000000..1632c2c4 --- /dev/null +++ b/node_modules/esm-reload/tests/fixtures/simple/package.json @@ -0,0 +1 @@ +{"type": "module"} \ No newline at end of file diff --git a/node_modules/esm-reload/tests/fixtures/simple/transitive.js b/node_modules/esm-reload/tests/fixtures/simple/transitive.js new file mode 100644 index 00000000..ab8ab8fd --- /dev/null +++ b/node_modules/esm-reload/tests/fixtures/simple/transitive.js @@ -0,0 +1,2 @@ +console.log(import.meta.url) +import 'node:path' \ No newline at end of file diff --git a/node_modules/esm-reload/tests/helper.js b/node_modules/esm-reload/tests/helper.js new file mode 100644 index 00000000..1ed0e743 --- /dev/null +++ b/node_modules/esm-reload/tests/helper.js @@ -0,0 +1,32 @@ +import {spawn} from 'node:child_process' + +export function spawnPromisified(...args) { + let stderr = ''; + let stdout = ''; + + const child = spawn(...args); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (data) => { stderr += data; }); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (data) => { stdout += data; }); + + return new Promise((resolve, reject) => { + child.on('close', (code, signal) => { + resolve({ + code, + signal, + stderr, + stdout, + }); + }); + child.on('error', (code, signal) => { + reject({ + code, + signal, + stderr, + stdout, + }); + }); + }); +} + diff --git a/node_modules/esm-reload/tests/tests.js b/node_modules/esm-reload/tests/tests.js new file mode 100644 index 00000000..28a70294 --- /dev/null +++ b/node_modules/esm-reload/tests/tests.js @@ -0,0 +1,24 @@ +import o from "ospec" + +import {spawnPromisified} from './helper.js' +import { execPath, cwd } from 'node:process'; +import {join} from 'node:path' + +const fixture = id => join(cwd(), `./tests/fixtures/${id}/index.js`) + +const localDir = import.meta.url.replace(/tests.js$/, '') + +const prepare = stdout=> stdout.trim().split('\n').map(l => l.replace(localDir, '')) + +o("works", async()=>{ + const {code, signal, stderr, stdout} = await spawnPromisified(execPath, [fixture('simple')]) + o(code).equals(0) + o(stderr).equals('') + o(prepare(stdout)).deepEquals([ + 'fixtures/simple/transitive.js', + 'fixtures/simple/transitive.js?instance=esm-reload-0', + 'fixtures/simple/transitive.js?instance=1', + 'fixtures/simple/transitive.js?instance=esm-reload-1', + 'fixtures/simple/transitive.js?instance=2' + ]) +}) diff --git a/package-lock.json b/package-lock.json index 21e4305b..d9adf5fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "@actions/core": "^1.10.1", "@actions/exec": "^1.1.1", "@actions/github": "^6.0.0", + "esm-reload": "^1.0.1", "js-yaml": "^4.1.0" } }, @@ -260,6 +261,11 @@ "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" }, + "node_modules/esm-reload": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/esm-reload/-/esm-reload-1.0.1.tgz", + "integrity": "sha512-kQUtZmuj3QutOF+zj3nJboc8PDdxjIwz1FF3hgCgGcxXwj0x/oBzIww0wH1RY5paehQdnnp6/ZeNmMSbLZ7cgA==" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", diff --git a/package.json b/package.json index a8abf5aa..926d5fd0 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,13 @@ { "name": "homebrew-actions", + "scripts": { + "test": "node --test --import ./spec-helper.mjs --test-reporter ./test-reporter.mjs --test-reporter-destination stdout" + }, "dependencies": { "@actions/core": "^1.10.1", "@actions/exec": "^1.1.1", "@actions/github": "^6.0.0", + "esm-reload": "^1.0.1", "js-yaml": "^4.1.0" } } diff --git a/post-comment/action.yml b/post-comment/action.yml index faff8c80..fe56563d 100644 --- a/post-comment/action.yml +++ b/post-comment/action.yml @@ -23,4 +23,4 @@ inputs: required: false runs: using: node20 - main: main.js + main: main.mjs diff --git a/post-comment/main.js b/post-comment/main.mjs similarity index 83% rename from post-comment/main.js rename to post-comment/main.mjs index 9858e32b..496543ba 100644 --- a/post-comment/main.js +++ b/post-comment/main.mjs @@ -1,5 +1,5 @@ -const core = require('@actions/core') -const github = require('@actions/github') +import core from "@actions/core" +import github from "@actions/github" async function main() { try { @@ -19,8 +19,8 @@ async function main() { body: github.context.actor == bot ? bot_body : body }) } catch (error) { - core.setFailed(error.message) + core.setFailed(error) } } -main() +await main() diff --git a/post-comment/main.test.mjs b/post-comment/main.test.mjs new file mode 100644 index 00000000..a989f1b3 --- /dev/null +++ b/post-comment/main.test.mjs @@ -0,0 +1,49 @@ +describe("post-comment", async () => { + const token = "fake-token" + const issue = "12345" + const body = "Some comment" + const botBody = "Some bot comment" + const bot = "IAmBot" + + beforeEach(() => { + mockInput("token", token) + mockInput("issue", issue) + mockInput("body", body) + mockInput("bot_body", botBody) + mockInput("bot", bot) + }) + + it("posts a regular comment for non-bots", async () => { + process.env.GITHUB_ACTOR = "NotABot" + + githubMockPool().intercept({ + method: "POST", + path: `/repos/${GITHUB_REPOSITORY}/issues/${issue}/comments`, + headers: { + Authorization: `token ${token}`, + }, + body: (requestBody) => JSON.parse(requestBody).body === body, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, {}) + + await loadMain() + }) + + it("posts a bot comment for bots", async () => { + process.env.GITHUB_ACTOR = bot + + githubMockPool().intercept({ + method: "POST", + path: `/repos/${GITHUB_REPOSITORY}/issues/${issue}/comments`, + headers: { + Authorization: `token ${token}`, + }, + body: (requestBody) => JSON.parse(requestBody).body === botBody, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, {}) + + await loadMain() + }) +}) diff --git a/post-comment/package.json b/post-comment/package.json index a9f52081..e44ee347 100644 --- a/post-comment/package.json +++ b/post-comment/package.json @@ -1,4 +1,4 @@ { "name": "post-comment", - "main": "main.js" + "main": "main.mjs" } diff --git a/spec-helper.mjs b/spec-helper.mjs new file mode 100644 index 00000000..a6075be9 --- /dev/null +++ b/spec-helper.mjs @@ -0,0 +1,66 @@ +/* node:coverage disable */ +import { executionAsyncId } from "node:async_hooks" +import { createRequire } from "node:module" +import { test, beforeEach, afterEach, describe, it } from "node:test" +import { MockAgent, setGlobalDispatcher } from "undici" +import core from "@actions/core" +import "esm-reload" + +globalThis.test = test +globalThis.beforeEach = beforeEach +globalThis.afterEach = afterEach +globalThis.describe = describe +globalThis.it = it +globalThis.mockInput = function(input, value) { + process.env[`INPUT_${input.replaceAll(" ", "_").toUpperCase()}`] = value +} +globalThis.githubMockPool = function() { + return mockAgent.get("https://api.github.com") +} +globalThis.loadMain = async function() { + const testFile = process.argv[1] + if (!testFile.endsWith(".test.mjs")) { + throw new Error("Could not detect test file.") + } + const mainFile = testFile.substring(0, testFile.length - 8) + "mjs" + await import(`${mainFile}?instance=${executionAsyncId()}`) +} + +// Don't inherit CI environment variables +for (const key of Object.keys(process.env)) { + if (key.startsWith("GITHUB_") || key.startsWith("INPUT_")) { + delete process.env[key] + } +} + +// Consistent fake variables +process.env.GITHUB_ACTIONS = "true" +globalThis.GITHUB_REPOSITORY = "fake-owner/fake-repo" +process.env.GITHUB_REPOSITORY = GITHUB_REPOSITORY +process.env.GITHUB_REPOSITORY_OWNER = GITHUB_REPOSITORY.split("/", 1)[0] + +const originalEnv = process.env +const require = createRequire(import.meta.url) +beforeEach((t) => { + process.env = structuredClone(originalEnv) + + delete require.cache[require.resolve("@actions/github")] + + const agent = new MockAgent() + agent.disableNetConnect() + setGlobalDispatcher(agent) + globalThis.mockAgent = agent + + // Make core.setFailed raise an error rather than print to stdout + t.mock.method(core, "setFailed", (error) => { + if (typeof error === "string") { + throw new Error(error) + } else { + throw error + } + }) +}) + +afterEach(() => { + mockAgent.assertNoPendingInterceptors() +}) diff --git a/test-reporter.mjs b/test-reporter.mjs new file mode 100644 index 00000000..b052fba0 --- /dev/null +++ b/test-reporter.mjs @@ -0,0 +1,28 @@ +/* node:coverage disable */ +import { spec } from "node:test/reporters" +import { Transform } from "node:stream" + +class Reporter extends Transform { + constructor() { + super({ __proto__: null, writableObjectMode: true }) + this.specReporter = new spec() + } + + _transform(event, encoding, callback) { + switch (event.type) { + case "test:stdout": + case "test:stderr": + callback(null) + break + default: + this.specReporter._transform(event, encoding, callback) + break + } + } + + _flush(callback) { + this.specReporter._flush(callback) + } +} + +export default new Reporter()