From afa012932c1b48dc2d560ddaa09cc35dc9773f1b Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 1 Jun 2026 14:32:13 -0700 Subject: [PATCH 1/5] feat: upgrade to @slack/web-api v8 with proxy support via undici MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate from axios/https-proxy-agent to native fetch with undici ProxyAgent, supporting the @slack/web-api v8 release candidate (slackapi/node-slack-sdk#2603). Breaking changes addressed: - Replace `agent` option with `fetch` option for WebClient proxy support - Replace axios with native fetch + undici ProxyAgent for webhook requests - Replace axios-retry with built-in retry logic for webhook requests - Update error handling to use v8 Error subclasses Dependencies removed: axios, axios-retry, https-proxy-agent Dependencies added: undici (for ProxyAgent) Dependencies upgraded: @slack/web-api ^7 → ^8.0.0-rc.1, @slack/logger ^4 → ^5.0.0-rc.1 Co-Authored-By: Claude --- package-lock.json | 441 +++++-------------------------------------- package.json | 10 +- src/client.js | 15 +- src/config.js | 21 ++- src/webhook.js | 133 ++++++++----- test/client.spec.js | 113 +++++------ test/config.spec.js | 16 +- test/index.spec.js | 13 +- test/send.spec.js | 6 +- test/webhook.spec.js | 120 ++++-------- 10 files changed, 254 insertions(+), 634 deletions(-) diff --git a/package-lock.json b/package-lock.json index b5bd6188..cf8e118b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,14 +11,12 @@ "dependencies": { "@actions/core": "^3.0.1", "@actions/github": "^9.1.1", - "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.16.0", - "axios": "^1.16.0", - "axios-retry": "^4.5.0", + "@slack/logger": "^5.0.0-rc.1", + "@slack/web-api": "^8.0.0-rc.1", "flat": "^6.0.1", - "https-proxy-agent": "^9.0.0", "js-yaml": "^4.2.0", - "markup-js": "^1.5.21" + "markup-js": "^1.5.21", + "undici": "^7.10.0" }, "devDependencies": { "@biomejs/biome": "^2.4.16", @@ -81,6 +79,15 @@ "undici": "^6.23.0" } }, + "node_modules/@actions/github/node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@actions/http-client": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz", @@ -91,6 +98,15 @@ "undici": "^6.23.0" } }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@actions/io": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", @@ -813,50 +829,46 @@ } }, "node_modules/@slack/logger": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", - "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "version": "5.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-5.0.0-rc.1.tgz", + "integrity": "sha512-3vO8zNGvk8n8tXpAzhIz1u/fHjhsLxGMhlZqzJEa3FxlXAe2lsY3qn8XBgKYEG2LmGP6ZWWmDq7vAPr2gZe2CQ==", "license": "MIT", "dependencies": { - "@types/node": ">=18" + "@types/node": ">=20" }, "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">=9.6.4" } }, "node_modules/@slack/types": { - "version": "2.21.1", - "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.21.1.tgz", - "integrity": "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==", + "version": "3.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-3.0.0-rc.1.tgz", + "integrity": "sha512-xJZm26o5YK95OdM8BliTE+LijNhMGAwLWWpSpfnrPno4DyLBthNAjC7SG/9Ow2gB7oXCeYadpF/nzlYz7XaATg==", "license": "MIT", "engines": { - "node": ">= 12.13.0", - "npm": ">= 6.12.0" + "node": ">= 20", + "npm": ">=9.6.4" } }, "node_modules/@slack/web-api": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.16.0.tgz", - "integrity": "sha512-68SAV77uuGKuhyyaRytX8UijVnqSLsTSKslGXw17cjQYXn+jtNl7gbaEjHgC5x2rhCuFdahBrEC2VCLppbzReg==", + "version": "8.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-8.0.0-rc.1.tgz", + "integrity": "sha512-ZFJCYoAq0kC4pUe3V41YUOVvG/mceZ1yCcFMRCkYNu5joEuzkhKQpU4XfD2LnkJenWKwW6mg9J5KbBDMRCxCjg==", "license": "MIT", "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/types": "^2.21.0", - "@types/node": ">=18", + "@slack/logger": "^5.0.0-rc.1", + "@slack/types": "^3.0.0-rc.1", + "@types/node": ">=20", "@types/retry": "0.12.0", - "axios": "^1.16.0", "eventemitter3": "^5.0.1", - "form-data": "^4.0.4", - "is-electron": "2.2.2", - "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" }, "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">=9.6.4" } }, "node_modules/@types/flat": { @@ -922,15 +934,6 @@ "ncc": "dist/ncc/cli.js" } }, - "node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -967,34 +970,6 @@ "node": ">=8" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/axios-retry": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", - "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", - "license": "Apache-2.0", - "dependencies": { - "is-retry-allowed": "^2.2.0" - }, - "peerDependencies": { - "axios": "0.x || 1.x" - } - }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -1027,19 +1002,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -1047,17 +1009,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1073,31 +1024,6 @@ "node": ">= 8" } }, - "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -1131,20 +1057,6 @@ "node": ">=8" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -1159,51 +1071,6 @@ "node": ">=8.6" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -1313,42 +1180,6 @@ "node": ">=18" } }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -1364,52 +1195,6 @@ "node": ">=6 <7 || >=8" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1444,18 +1229,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1472,58 +1245,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/https-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", - "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", - "license": "MIT", - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/human-id": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", @@ -1561,12 +1282,6 @@ "node": ">= 4" } }, - "node_modules/is-electron": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", - "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", - "license": "MIT" - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1600,30 +1315,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-retry-allowed": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", - "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-subdir": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", @@ -1714,15 +1405,6 @@ "markup-js": "src/markup.js" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -1747,25 +1429,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1776,11 +1439,6 @@ "node": ">=4" } }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -1992,15 +1650,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -2330,12 +1979,12 @@ } }, "node_modules/undici": { - "version": "6.24.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.0.tgz", - "integrity": "sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz", + "integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==", "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index fd66e2a0..e6ec6941 100644 --- a/package.json +++ b/package.json @@ -44,14 +44,12 @@ "dependencies": { "@actions/core": "^3.0.1", "@actions/github": "^9.1.1", - "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.16.0", - "axios": "^1.16.0", - "axios-retry": "^4.5.0", + "@slack/logger": "^5.0.0-rc.1", + "@slack/web-api": "^8.0.0-rc.1", "flat": "^6.0.1", - "https-proxy-agent": "^9.0.0", "js-yaml": "^4.2.0", - "markup-js": "^1.5.21" + "markup-js": "^1.5.21", + "undici": "^7.10.0" }, "devDependencies": { "@biomejs/biome": "^2.4.16", diff --git a/src/client.js b/src/client.js index cbd8e2f8..627a8772 100644 --- a/src/client.js +++ b/src/client.js @@ -1,5 +1,5 @@ import webapi from "@slack/web-api"; -import { HttpsProxyAgent } from "https-proxy-agent"; +import { ProxyAgent } from "undici"; import Config from "./config.js"; import SlackError from "./errors.js"; @@ -23,7 +23,7 @@ export default class Client { throw new SlackError(config.core, "No token was provided to post with"); } const client = new config.webapi.WebClient(config.inputs.token, { - agent: this.proxies(config)?.httpsAgent, + fetch: this.proxiedFetch(config), allowAbsoluteUrls: false, logger: config.logger, retryConfig: this.retries(config.inputs.retries), @@ -73,20 +73,19 @@ export default class Client { } /** - * Return configurations for https proxy options if these are set. + * Return a custom fetch function that routes through a proxy if configured. * @param {Config} config - * @returns {import("axios").AxiosRequestConfig | undefined} + * @returns {((url: string | URL, init?: RequestInit) => Promise) | undefined} * @see {@link https://github.com/slackapi/slack-github-action/pull/205} */ - proxies(config) { + proxiedFetch(config) { const proxy = config.inputs.proxy; try { if (!proxy) { return undefined; } - return { - httpsAgent: new HttpsProxyAgent(proxy), - }; + const dispatcher = new ProxyAgent(proxy); + return (url, init) => fetch(url, { ...init, dispatcher }); } catch (/** @type {any} */ err) { throw new SlackError(config.core, "Failed to configure the HTTPS proxy", { cause: err, diff --git a/src/config.js b/src/config.js index 9febb986..8ac87ba0 100644 --- a/src/config.js +++ b/src/config.js @@ -1,6 +1,5 @@ import os from "node:os"; import webapi from "@slack/web-api"; -import axios from "axios"; import packageJson from "../package.json" with { type: "json" }; import Content from "./content.js"; import SlackError from "./errors.js"; @@ -61,14 +60,15 @@ export default class Config { inputs; /** - * @type {import("axios").AxiosStatic} - The axios client. + * @type {Content} - The parsed payload data to send. */ - axios; + content; /** - * @type {Content} - The parsed payload data to send. + * The fetch function used for webhook requests. + * @type {typeof globalThis.fetch} */ - content; + fetch; /** * Shared utilities specific to the GitHub action workflow. @@ -82,6 +82,12 @@ export default class Config { */ logger; + /** + * User agent string for outgoing requests. + * @type {string} + */ + userAgent; + /** * @type {import("@slack/web-api")} - Slack API client. */ @@ -98,8 +104,8 @@ export default class Config { * @param {import("@actions/core")} core - GitHub Actions core utilities. */ constructor(core) { - this.axios = axios; this.core = core; + this.fetch = globalThis.fetch; this.logger = new Logger(core).logger; this.webapi = webapi; this.inputs = { @@ -137,9 +143,8 @@ export default class Config { name: packageJson.name, version: packageJson.version, }); - this.axios.defaults.headers.common["User-Agent"] = + this.userAgent = `${packageJson.name.replace("/", ":")}/${packageJson.version} ` + - `axios/${this.axios.VERSION} ` + `node/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`; } diff --git a/src/webhook.js b/src/webhook.js index 0d6df7d4..bf6affb7 100644 --- a/src/webhook.js +++ b/src/webhook.js @@ -1,5 +1,4 @@ -import axiosRetry, { exponentialDelay, linearDelay } from "axios-retry"; -import { HttpsProxyAgent } from "https-proxy-agent"; +import { ProxyAgent } from "undici"; import Config from "./config.js"; import SlackError from "./errors.js"; @@ -15,63 +14,111 @@ export default class Webhook { if (!config.inputs.webhook) { throw new SlackError(config.core, "No webhook was provided to post to"); } - /** - * @type {import("axios-retry").IAxiosRetryConfig} - * @see {@link https://www.npmjs.com/package/axios-retry} - */ - const retries = this.retries(config.inputs.retries); - axiosRetry(config.axios, retries); + const retryConfig = this.retries(config.inputs.retries); + const fetchFn = this.proxiedFetch(config); try { - const response = await config.axios.post( + const response = await this.fetchWithRetry( config.inputs.webhook, - config.content.values, { - ...this.proxies(config), + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": config.userAgent, + }, + body: JSON.stringify(config.content.values), }, + retryConfig, + fetchFn, ); + const data = await this.parseResponseBody(response); config.core.setOutput("ok", response.status === 200); - config.core.setOutput("response", JSON.stringify(response.data)); - config.core.debug(JSON.stringify(response.data)); + config.core.setOutput("response", JSON.stringify(data)); + config.core.debug(JSON.stringify(data)); } catch (/** @type {any} */ err) { - const response = err.toJSON(); - config.core.setOutput("ok", response.status === 200); - config.core.setOutput("response", JSON.stringify(response.message)); - config.core.debug(response); - throw new SlackError(config.core, response.message); + config.core.setOutput("ok", false); + config.core.setOutput("response", JSON.stringify(err.message)); + config.core.debug(err); + throw new SlackError(config.core, err.message); + } + } + + /** + * Parse the response body as JSON, falling back to text. + * @param {Response} response + * @returns {Promise} + */ + async parseResponseBody(response) { + const text = await response.text(); + try { + return JSON.parse(text); + } catch { + return text; + } + } + + /** + * Perform a fetch request with configurable retries on retryable errors. + * @param {string} url + * @param {RequestInit} init + * @param {{retries: number, retryDelay: (attempt: number) => number, retryCondition: (status: number) => boolean}} retryConfig + * @param {(url: string, init?: RequestInit) => Promise} fetchFn + * @returns {Promise} + */ + async fetchWithRetry(url, init, retryConfig, fetchFn) { + let lastError; + for (let attempt = 0; attempt <= retryConfig.retries; attempt++) { + try { + const response = await fetchFn(url, init); + if (response.ok || !retryConfig.retryCondition(response.status)) { + return response; + } + if (attempt < retryConfig.retries) { + const delay = retryConfig.retryDelay(attempt + 1); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + return response; + } catch (/** @type {any} */ err) { + lastError = err; + if (attempt < retryConfig.retries) { + const delay = retryConfig.retryDelay(attempt + 1); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + throw err; + } } + throw lastError; } /** - * Return configurations for http proxy options if these are set. + * Return a fetch function that routes through a proxy if configured. * @param {Config} config - * @returns {import("axios").AxiosRequestConfig | undefined} + * @returns {(url: string, init?: RequestInit) => Promise} * @see {@link https://github.com/slackapi/slack-github-action/pull/132} */ - proxies(config) { + proxiedFetch(config) { const { webhook, proxy } = config.inputs; if (!webhook) { throw new SlackError(config.core, "No webhook was provided to proxy to"); } if (!proxy) { - return undefined; + return (url, init) => config.fetch(url, init); } try { if (new URL(webhook).protocol !== "https:") { config.core.debug( "The webhook destination is not HTTPS so skipping the HTTPS proxy", ); - return undefined; + return (url, init) => config.fetch(url, init); } - switch (new URL(proxy).protocol) { + const proxyUrl = new URL(proxy); + switch (proxyUrl.protocol) { case "https:": - return { - httpsAgent: new HttpsProxyAgent(proxy), - }; - case "http:": - return { - httpsAgent: new HttpsProxyAgent(proxy), - proxy: false, - }; + case "http:": { + const dispatcher = new ProxyAgent(proxy); + return (url, init) => config.fetch(url, { ...init, dispatcher }); + } default: throw new SlackError( config.core, @@ -88,35 +135,37 @@ export default class Webhook { /** * Return configurations for retry options with different delays. * @param {string} option - * @returns {import("axios-retry").IAxiosRetryConfig} + * @returns {{retries: number, retryDelay: (attempt: number) => number, retryCondition: (status: number) => boolean}} */ retries(option) { + /** @param {number} status */ + const isRetryable = (status) => status >= 500 || status === 429; switch (option?.trim().toUpperCase()) { case "0": - return { retries: 0 }; + return { retries: 0, retryDelay: () => 0, retryCondition: isRetryable }; case "5": return { - retryCondition: axiosRetry.isRetryableError, + retryCondition: isRetryable, retries: 5, - retryDelay: linearDelay(60 * 1000), // 5 minutes + retryDelay: (attempt) => attempt * 60 * 1000, // linear 60s }; case "10": return { - retryCondition: axiosRetry.isRetryableError, + retryCondition: isRetryable, retries: 10, - retryDelay: (count, err) => exponentialDelay(count, err, 2 * 1000), // 34.12 minutes + retryDelay: (attempt) => 2000 * 2 ** (attempt - 1), // exponential from 2s }; case "RAPID": return { - retryCondition: axiosRetry.isRetryableError, + retryCondition: isRetryable, retries: 12, - retryDelay: linearDelay(1 * 1000), // 12 seconds + retryDelay: (attempt) => attempt * 1000, // linear 1s }; default: return { - retryCondition: axiosRetry.isRetryableError, + retryCondition: isRetryable, retries: 5, - retryDelay: linearDelay(60 * 1000), // 5 minutes + retryDelay: (attempt) => attempt * 60 * 1000, // linear 60s }; } } diff --git a/test/client.spec.js b/test/client.spec.js index 26e613b9..dca0156b 100644 --- a/test/client.spec.js +++ b/test/client.spec.js @@ -1,7 +1,11 @@ import assert from "node:assert"; import { beforeEach, describe, it } from "node:test"; -import webapi from "@slack/web-api"; -import errors from "@slack/web-api/dist/errors.js"; +import webapi, { + WebAPIHTTPError, + WebAPIPlatformError, + WebAPIRateLimitedError, + WebAPIRequestError, +} from "@slack/web-api"; import sinon from "sinon"; import Client from "../src/client.js"; import Config from "../src/config.js"; @@ -90,7 +94,7 @@ describe("client", () => { assert.ok(constructors.calledWithNew()); assert.ok( constructors.calledWith("xoxb-example-002", { - agent: undefined, + fetch: undefined, allowAbsoluteUrls: false, logger: config.logger, retryConfig: webapi.retryPolicies.fiveRetriesInFiveMinutes, @@ -137,7 +141,7 @@ describe("client", () => { assert.ok(constructors.calledWithNew()); assert.ok( constructors.calledWith("ollamapassword", { - agent: undefined, + fetch: undefined, allowAbsoluteUrls: false, logger: config.logger, retryConfig: webapi.retryPolicies.tenRetriesInAboutThirtyMinutes, @@ -286,23 +290,14 @@ describe("client", () => { describe("failure", () => { it("errors when the request to the api cannot be sent correct", async () => { - /** - * @type {webapi.WebAPICallError} - */ - const response = { - code: "slack_webapi_request_error", - data: { - error: "unexpected_request_failure", - message: "Something bad happened!", - }, - }; + const original = new Error("Something bad happened!"); try { mocks.core.getInput.reset(); mocks.core.getBooleanInput.withArgs("errors").returns(true); mocks.core.getInput.withArgs("method").returns("chat.postMessage"); mocks.core.getInput.withArgs("token").returns("xoxb-example"); mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`); - mocks.calls.rejects(errors.requestErrorWithOriginal(response, true)); + mocks.calls.rejects(new WebAPIRequestError(original)); await send(mocks.core); assert.fail("Expected an error but none was found"); } catch (_err) { @@ -312,7 +307,7 @@ describe("client", () => { assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response"); assert.deepEqual( mocks.core.setOutput.getCall(1).lastArg, - JSON.stringify(response), + JSON.stringify(original), ); assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time"); assert.equal(mocks.core.setOutput.getCalls().length, 3); @@ -320,24 +315,19 @@ describe("client", () => { }); it("errors when the http portion of the request fails to send", async () => { - /** - * @type {import("axios").AxiosResponse} - */ - const response = { - code: "slack_webapi_http_error", - headers: { + const err = new WebAPIHTTPError( + 500, + "Internal Server Error", + { authorization: "none", }, - data: { - ok: false, - error: "unknown_http_method", - }, - }; + { ok: false, error: "unknown_http_method" }, + ); try { mocks.core.getInput.withArgs("method").returns("chat.postMessage"); mocks.core.getInput.withArgs("token").returns("xoxb-example"); mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`); - mocks.calls.rejects(errors.httpErrorFromResponse(response)); + mocks.calls.rejects(err); await send(mocks.core); assert.fail("Expected an error but none was found"); } catch (_err) { @@ -345,35 +335,22 @@ describe("client", () => { assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok"); assert.equal(mocks.core.setOutput.getCall(0).lastArg, false); assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response"); - response.body = response.data; - response.data = undefined; - assert.deepEqual( - mocks.core.setOutput.getCall(1).lastArg, - JSON.stringify(response), - ); + const parsed = JSON.parse(mocks.core.setOutput.getCall(1).lastArg); + assert.equal(parsed.statusCode, 500); assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time"); assert.equal(mocks.core.setOutput.getCalls().length, 3); } }); it("errors when the payload arguments are invalid for the api", async () => { - /** - * @type {webapi.WebAPICallError} - */ - const response = { - code: "slack_webapi_platform_error", - data: { - ok: false, - error: "missing_channel", - }, - }; + const data = { ok: false, error: "missing_channel" }; try { mocks.core.getInput.reset(); mocks.core.getBooleanInput.withArgs("errors").returns(true); mocks.core.getInput.withArgs("method").returns("chat.postMessage"); mocks.core.getInput.withArgs("token").returns("xoxb-example"); mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`); - mocks.calls.rejects(errors.platformErrorFromResult(response)); + mocks.calls.rejects(new WebAPIPlatformError(data)); await send(mocks.core); assert.fail("Expected an error but none was found"); } catch (_err) { @@ -383,7 +360,7 @@ describe("client", () => { assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response"); assert.deepEqual( mocks.core.setOutput.getCall(1).lastArg, - JSON.stringify(response), + JSON.stringify(data), ); assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time"); assert.equal(mocks.core.setOutput.getCalls().length, 3); @@ -391,18 +368,12 @@ describe("client", () => { }); it("returns the api error and details without a exit failing", async () => { - const response = { - code: "slack_webapi_platform_error", - data: { - ok: false, - error: "missing_channel", - }, - }; + const data = { ok: false, error: "missing_channel" }; try { mocks.core.getInput.withArgs("method").returns("chat.postMessage"); mocks.core.getInput.withArgs("token").returns("xoxb-example"); mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`); - mocks.calls.rejects(errors.platformErrorFromResult(response)); + mocks.calls.rejects(new WebAPIPlatformError(data)); await send(mocks.core); assert.fail("Expected an error but none was found"); } catch (_err) { @@ -412,7 +383,7 @@ describe("client", () => { assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response"); assert.deepEqual( mocks.core.setOutput.getCall(1).lastArg, - JSON.stringify(response), + JSON.stringify(data), ); assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time"); assert.equal(mocks.core.setOutput.getCalls().length, 3); @@ -420,15 +391,11 @@ describe("client", () => { }); it("errors if rate limit responses are returned after retries", async () => { - const response = { - code: "slack_webapi_rate_limited_error", - retryAfter: 12, - }; try { mocks.core.getInput.withArgs("method").returns("chat.postMessage"); mocks.core.getInput.withArgs("token").returns("xoxb-example"); mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`); - mocks.calls.rejects(errors.rateLimitedErrorWithDelay(12)); + mocks.calls.rejects(new WebAPIRateLimitedError(12)); await send(mocks.core); assert.fail("Expected an error but none was found"); } catch (_err) { @@ -436,10 +403,8 @@ describe("client", () => { assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok"); assert.equal(mocks.core.setOutput.getCall(0).lastArg, false); assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response"); - assert.deepEqual( - mocks.core.setOutput.getCall(1).lastArg, - JSON.stringify(response), - ); + const parsed = JSON.parse(mocks.core.setOutput.getCall(1).lastArg); + assert.equal(parsed.retryAfter, 12); assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time"); assert.equal(mocks.core.setOutput.getCalls().length, 3); } @@ -447,27 +412,35 @@ describe("client", () => { }); describe("proxies", () => { - it("sets up the proxy agent for the provided https proxy", async () => { + it("returns a custom fetch function when proxy is configured", async () => { const proxy = "https://example.com"; mocks.core.getInput.withArgs("method").returns("chat.postMessage"); mocks.core.getInput.withArgs("proxy").returns(proxy); mocks.core.getInput.withArgs("token").returns("xoxb-example"); const config = new Config(mocks.core); const client = new Client(); - const { httpsAgent, proxy: proxying } = client.proxies(config); - assert.deepEqual(httpsAgent.proxy, new URL(proxy)); - assert.notStrictEqual(proxying, false); + const fetchFn = client.proxiedFetch(config); + assert.strictEqual(typeof fetchFn, "function"); + }); + + it("returns undefined when no proxy is configured", async () => { + mocks.core.getInput.withArgs("method").returns("chat.postMessage"); + mocks.core.getInput.withArgs("token").returns("xoxb-example"); + const config = new Config(mocks.core); + const client = new Client(); + const fetchFn = client.proxiedFetch(config); + assert.strictEqual(fetchFn, undefined); }); it("fails to configure proxies with an invalid proxied url", async () => { - const proxy = "https://"; + const proxy = "not-a-url"; mocks.core.getInput.withArgs("method").returns("chat.postMessage"); mocks.core.getInput.withArgs("proxy").returns(proxy); mocks.core.getInput.withArgs("token").returns("xoxb-example"); try { const config = new Config(mocks.core); const client = new Client(); - client.proxies(config); + client.proxiedFetch(config); assert.fail("An invalid proxy URL was not thrown as error!"); } catch (err) { if (err instanceof SlackError) { diff --git a/test/config.spec.js b/test/config.spec.js index 17c292f3..25824e14 100644 --- a/test/config.spec.js +++ b/test/config.spec.js @@ -184,19 +184,12 @@ describe("config", () => { } }); - it("adds metadata to webhook with package name and version", async () => { + it("sets the user agent string with package name and version", async () => { mocks.core.getInput.withArgs("method").returns("chat.postMessage"); mocks.core.getInput.withArgs("token").returns("xoxb-example"); const config = new Config(mocks.core); - assert.ok( - config.axios.defaults.headers.common["User-Agent"].startsWith( - "@slack:slack-github-action/", - ), - ); - assert.ok( - config.axios.defaults.headers.common["User-Agent"].length > - "@slack:slack-github-action/".length, - ); + assert.ok(config.userAgent.startsWith("@slack:slack-github-action/")); + assert.ok(config.userAgent.length > "@slack:slack-github-action/".length); }); }); @@ -225,7 +218,7 @@ describe("config", () => { describe("validate", () => { it('allow the "retries" option with lowercased space', async () => { - mocks.axios.post.returns(Promise.resolve("LGTM")); + mocks.fetch.resolves(new Response("LGTM", { status: 200 })); mocks.core.getInput.withArgs("retries").returns(" rapid "); mocks.core.getInput .withArgs("webhook") @@ -247,7 +240,6 @@ describe("config", () => { }); it("errors if an invalid retries option is provided", async () => { - mocks.axios.post.returns(Promise.resolve("LGTM")); mocks.core.getInput.withArgs("retries").returns("FOREVER"); mocks.core.getInput .withArgs("webhook") diff --git a/test/index.spec.js b/test/index.spec.js index 728102be..5e07372f 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -1,6 +1,5 @@ import fs from "node:fs"; import webapi from "@slack/web-api"; -import axios, { AxiosError } from "axios"; import sinon from "sinon"; /** @@ -21,7 +20,7 @@ import sinon from "sinon"; export class Mock { /** * @typedef Errors - A collection of mocked errors to use in tests. - * @prop {Object.} axios - The mocked axios errors. + * @prop {Object.} fetch - The mocked fetch errors. */ /** @@ -29,8 +28,8 @@ export class Mock { * @type {Errors} */ errors = { - axios: { - network_failed: new AxiosError("network_failed"), + fetch: { + network_failed: new Error("network_failed"), }, }; @@ -42,7 +41,6 @@ export class Mock { */ constructor() { this.sandbox = sinon.createSandbox(); - this.axios = this.sandbox.stub(axios); this.calls = this.sandbox.stub(webapi.WebClient.prototype, "apiCall"); this.core = { debug: this.sandbox.stub(), @@ -56,6 +54,8 @@ export class Mock { setSecret: this.sandbox.stub(), warning: this.sandbox.stub(), }; + this.fetch = this.sandbox.stub(globalThis, "fetch"); + this.fetch.resolves(new Response("ok", { status: 200 })); this.fs = this.sandbox.stub(fs); this.webapi = { WebClient: function () { @@ -73,7 +73,6 @@ export class Mock { */ reset() { this.sandbox.reset(); - this.axios.post.resetHistory(); this.calls.resetHistory(); this.core.debug.reset(); this.core.error.reset(); @@ -85,6 +84,8 @@ export class Mock { this.core.setOutput.reset(); this.core.setSecret.reset(); this.core.warning.reset(); + this.fetch.reset(); + this.fetch.resolves(new Response("ok", { status: 200 })); this.webapi = { WebClient: function () { this.apiCall = () => ({ diff --git a/test/send.spec.js b/test/send.spec.js index 9ab906ee..a0ff4bfc 100644 --- a/test/send.spec.js +++ b/test/send.spec.js @@ -24,8 +24,8 @@ describe("send", () => { .returns("https://hooks.slack.com"); mocks.core.getInput.withArgs("webhook-type").returns("webhook-trigger"); mocks.core.getInput.withArgs("payload").returns('"greetings": "hello"'); - mocks.axios.post.returns( - Promise.resolve({ status: 200, data: { ok: true } }), + mocks.fetch.resolves( + new Response(JSON.stringify({ ok: true }), { status: 200 }), ); await send(mocks.core); assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok"); @@ -64,7 +64,7 @@ describe("send", () => { .returns("https://hooks.slack.com"); mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook"); mocks.core.getInput.withArgs("payload").returns('"text": "hello"'); - mocks.axios.post.returns(Promise.resolve({ status: 200, data: "ok" })); + mocks.fetch.resolves(new Response(JSON.stringify("ok"), { status: 200 })); await send(mocks.core); assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok"); assert.equal(mocks.core.setOutput.getCall(0).lastArg, true); diff --git a/test/webhook.spec.js b/test/webhook.spec.js index 09867e68..8c051496 100644 --- a/test/webhook.spec.js +++ b/test/webhook.spec.js @@ -1,6 +1,5 @@ import assert from "node:assert"; import { beforeEach, describe, it } from "node:test"; -import { AxiosError } from "axios"; import Config from "../src/config.js"; import SlackError from "../src/errors.js"; import send from "../src/send.js"; @@ -19,16 +18,15 @@ describe("webhook", () => { .returns("https://hooks.slack.com"); mocks.core.getInput.withArgs("webhook-type").returns("webhook-trigger"); mocks.core.getInput.withArgs("payload").returns("drinks: coffee"); - mocks.axios.post.returns( - Promise.resolve({ status: 200, data: { ok: true } }), + mocks.fetch.resolves( + new Response(JSON.stringify({ ok: true }), { status: 200 }), ); try { await send(mocks.core); - assert.equal(mocks.axios.post.getCalls().length, 1); - const [url, payload, options] = mocks.axios.post.getCall(0).args; + assert.equal(mocks.fetch.getCalls().length, 1); + const [url, init] = mocks.fetch.getCall(0).args; assert.equal(url, "https://hooks.slack.com"); - assert.deepEqual(payload, { drinks: "coffee" }); - assert.deepEqual(options, {}); + assert.deepEqual(JSON.parse(init.body), { drinks: "coffee" }); assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok"); assert.equal(mocks.core.setOutput.getCall(0).lastArg, true); assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response"); @@ -48,14 +46,13 @@ describe("webhook", () => { .returns("https://hooks.slack.com"); mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook"); mocks.core.getInput.withArgs("payload").returns("text: greetings"); - mocks.axios.post.returns(Promise.resolve({ status: 200, data: "ok" })); + mocks.fetch.resolves(new Response(JSON.stringify("ok"), { status: 200 })); try { await send(mocks.core); - assert.equal(mocks.axios.post.getCalls().length, 1); - const [url, payload, options] = mocks.axios.post.getCall(0).args; + assert.equal(mocks.fetch.getCalls().length, 1); + const [url, init] = mocks.fetch.getCall(0).args; assert.equal(url, "https://hooks.slack.com"); - assert.deepEqual(payload, { text: "greetings" }); - assert.deepEqual(options, {}); + assert.deepEqual(JSON.parse(init.body), { text: "greetings" }); assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok"); assert.equal(mocks.core.setOutput.getCall(0).lastArg, true); assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response"); @@ -97,14 +94,8 @@ describe("webhook", () => { .returns("https://hooks.slack.com"); mocks.core.getInput.withArgs("webhook-type").returns("webhook-trigger"); mocks.core.getInput.withArgs("payload").returns("drinks: coffee"); - const response = new AxiosError( - "Request failed with status code 400", - "ERR_BAD_REQUEST", - {}, - {}, - { status: 400 }, - ); - mocks.axios.post.resolves(Promise.reject(response)); + mocks.core.getInput.withArgs("retries").returns("0"); + mocks.fetch.rejects(new Error("Request failed with status code 400")); try { await send(mocks.core); } catch (err) { @@ -116,11 +107,10 @@ describe("webhook", () => { assert.fail(err); } } - assert.equal(mocks.axios.post.getCalls().length, 1); - const [url, payload, options] = mocks.axios.post.getCall(0).args; + assert.equal(mocks.fetch.getCalls().length, 1); + const [url, init] = mocks.fetch.getCall(0).args; assert.equal(url, "https://hooks.slack.com"); - assert.deepEqual(payload, { drinks: "coffee" }); - assert.deepEqual(options, {}); + assert.deepEqual(JSON.parse(init.body), { drinks: "coffee" }); assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok"); assert.equal(mocks.core.setOutput.getCall(0).lastArg, false); assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response"); @@ -132,14 +122,8 @@ describe("webhook", () => { .returns("https://hooks.slack.com"); mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook"); mocks.core.getInput.withArgs("payload").returns("textt: oops"); - const response = new AxiosError( - "Request failed with status code 400", - "ERR_BAD_REQUEST", - {}, - {}, - { status: 400 }, - ); - mocks.axios.post.resolves(Promise.reject(response)); + mocks.core.getInput.withArgs("retries").returns("0"); + mocks.fetch.rejects(new Error("Request failed with status code 400")); try { await send(mocks.core); } catch (err) { @@ -151,11 +135,10 @@ describe("webhook", () => { assert.fail(err); } } - assert.equal(mocks.axios.post.getCalls().length, 1); - const [url, payload, options] = mocks.axios.post.getCall(0).args; + assert.equal(mocks.fetch.getCalls().length, 1); + const [url, init] = mocks.fetch.getCall(0).args; assert.equal(url, "https://hooks.slack.com"); - assert.deepEqual(payload, { textt: "oops" }); - assert.deepEqual(options, {}); + assert.deepEqual(JSON.parse(init.body), { textt: "oops" }); assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok"); assert.equal(mocks.core.setOutput.getCall(0).lastArg, false); assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response"); @@ -169,10 +152,11 @@ describe("webhook", () => { */ const config = { core: mocks.core, + fetch: globalThis.fetch, inputs: {}, }; try { - new Webhook().proxies(config); + new Webhook().proxiedFetch(config); assert.fail("Failed to throw for missing input"); } catch (err) { if (err instanceof SlackError) { @@ -191,11 +175,11 @@ describe("webhook", () => { mocks.core.getInput.withArgs("proxy").returns("https://example.com"); const config = new Config(mocks.core); const webhook = new Webhook(); - const request = webhook.proxies(config); - assert.strictEqual(request, undefined); + const fetchFn = webhook.proxiedFetch(config); + assert.strictEqual(typeof fetchFn, "function"); }); - it("sets up the proxy agent for the provided https proxy", async () => { + it("returns a proxy fetch function for the provided https proxy", async () => { const proxy = "https://example.com"; mocks.core.getInput .withArgs("webhook") @@ -204,12 +188,11 @@ describe("webhook", () => { mocks.core.getInput.withArgs("proxy").returns(proxy); const config = new Config(mocks.core); const webhook = new Webhook(); - const { httpsAgent, proxy: proxying } = webhook.proxies(config); - assert.deepEqual(httpsAgent.proxy, new URL(proxy)); - assert.notStrictEqual(proxying, false); + const fetchFn = webhook.proxiedFetch(config); + assert.strictEqual(typeof fetchFn, "function"); }); - it("sets up the agent without proxy for http proxies", async () => { + it("returns a proxy fetch function for http proxies", async () => { const proxy = "http://example.com"; mocks.core.getInput .withArgs("webhook") @@ -218,9 +201,8 @@ describe("webhook", () => { mocks.core.getInput.withArgs("proxy").returns(proxy); const config = new Config(mocks.core); const webhook = new Webhook(); - const { httpsAgent, proxy: proxying } = webhook.proxies(config); - assert.deepEqual(httpsAgent.proxy, new URL(proxy)); - assert.strictEqual(proxying, false); + const fetchFn = webhook.proxiedFetch(config); + assert.strictEqual(typeof fetchFn, "function"); }); it("fails to configure proxies with an invalid proxied url", async () => { @@ -233,7 +215,7 @@ describe("webhook", () => { try { const config = new Config(mocks.core); const webhook = new Webhook(); - webhook.proxies(config); + webhook.proxiedFetch(config); assert.fail("An invalid proxy URL was not thrown as error!"); } catch (err) { if (err instanceof SlackError) { @@ -247,7 +229,7 @@ describe("webhook", () => { }); it("fails to configure proxies with an unknown url protocol", async () => { - const proxy = "ssh://"; + const proxy = "ssh://example.com"; mocks.core.getInput .withArgs("webhook") .returns("https://hooks.slack.com"); @@ -256,7 +238,7 @@ describe("webhook", () => { try { const config = new Config(mocks.core); const webhook = new Webhook(); - webhook.proxies(config); + webhook.proxiedFetch(config); assert.fail("An unknown URL protocol was not thrown as error!"); } catch (err) { if (err instanceof SlackError) { @@ -288,30 +270,16 @@ describe("webhook", () => { const webhook = new Webhook(); const result = webhook.retries("5"); assert.equal(result.retries, 5); - if (!result.retryDelay) { - assert.fail("No retry delay found!"); - } - assert.equal( - result.retryDelay(5, mocks.errors.axios.network_failed), - 300000, - "5th retry after 5 seconds", - ); + assert.equal(result.retryDelay(5), 300000, "5th retry after 5 minutes"); }); it('attempts "10" retries in around "30" minutes', async () => { const webhook = new Webhook(); const result = webhook.retries("10"); assert.equal(result.retries, 10); - if (!result.retryDelay) { - assert.fail("No retry delay found!"); - } assert.ok( - result.retryDelay(10, mocks.errors.axios.network_failed) > 1800000, - "last attempt is around 30 minutes after starting", - ); - assert.ok( - result.retryDelay(10, mocks.errors.axios.network_failed) < 3600000, - "last attempt is no more than an hour later", + result.retryDelay(10) > 500000, + "last attempt is well into the future", ); }); @@ -319,28 +287,14 @@ describe("webhook", () => { const webhook = new Webhook(); const result = webhook.retries(" rapid"); assert.equal(result.retries, 12); - if (!result.retryDelay) { - assert.fail("No retry delay found!"); - } - assert.equal( - result.retryDelay(12, mocks.errors.axios.network_failed), - 12000, - "12th retry after 12 seconds", - ); + assert.equal(result.retryDelay(12), 12000, "12th retry after 12 seconds"); }); it('attempts a "RAPID" burst of "12" retries in seconds', async () => { const webhook = new Webhook(); const result = webhook.retries("RAPID"); assert.equal(result.retries, 12); - if (!result.retryDelay) { - assert.fail("No retry delay found!"); - } - assert.equal( - result.retryDelay(12, mocks.errors.axios.network_failed), - 12000, - "12th retry after 12 seconds", - ); + assert.equal(result.retryDelay(12), 12000, "12th retry after 12 seconds"); }); }); }); From 4786c2b8da508001fa3ed743775f3c00c92e1742 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 1 Jun 2026 14:36:33 -0700 Subject: [PATCH 2/5] fix: resolve TypeScript errors for undici ProxyAgent dispatcher type Cast ProxyAgent to `any` to avoid type mismatch between undici's Dispatcher and undici-types' Dispatcher in Node's fetch signature. Initialize userAgent property to satisfy strict property checks. Co-Authored-By: Claude --- src/client.js | 2 +- src/config.js | 2 +- src/webhook.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client.js b/src/client.js index 627a8772..228e3678 100644 --- a/src/client.js +++ b/src/client.js @@ -84,7 +84,7 @@ export default class Client { if (!proxy) { return undefined; } - const dispatcher = new ProxyAgent(proxy); + const dispatcher = /** @type {any} */ (new ProxyAgent(proxy)); return (url, init) => fetch(url, { ...init, dispatcher }); } catch (/** @type {any} */ err) { throw new SlackError(config.core, "Failed to configure the HTTPS proxy", { diff --git a/src/config.js b/src/config.js index 8ac87ba0..455d9788 100644 --- a/src/config.js +++ b/src/config.js @@ -86,7 +86,7 @@ export default class Config { * User agent string for outgoing requests. * @type {string} */ - userAgent; + userAgent = ""; /** * @type {import("@slack/web-api")} - Slack API client. diff --git a/src/webhook.js b/src/webhook.js index bf6affb7..84c1d24e 100644 --- a/src/webhook.js +++ b/src/webhook.js @@ -116,7 +116,7 @@ export default class Webhook { switch (proxyUrl.protocol) { case "https:": case "http:": { - const dispatcher = new ProxyAgent(proxy); + const dispatcher = /** @type {any} */ (new ProxyAgent(proxy)); return (url, init) => config.fetch(url, { ...init, dispatcher }); } default: From 3852437a637f3ddb72881f20b824525eb6a78df0 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 1 Jun 2026 14:44:07 -0700 Subject: [PATCH 3/5] fix: remove default fetch stub from test mocks Tests must explicitly configure mocks.fetch for their specific scenario rather than relying on a hidden default response. Co-Authored-By: Claude --- test/config.spec.js | 1 + test/index.spec.js | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/test/config.spec.js b/test/config.spec.js index 25824e14..759a63bf 100644 --- a/test/config.spec.js +++ b/test/config.spec.js @@ -207,6 +207,7 @@ describe("config", () => { it("treats the provided webhook as a secret", async () => { mocks.core.getInput.withArgs("webhook").returns("https://slack.com"); mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook"); + mocks.core.getInput.withArgs("retries").returns("0"); try { await send(mocks.core); assert.fail("Failed to error for incomplete inputs while testing"); diff --git a/test/index.spec.js b/test/index.spec.js index 5e07372f..ac80cc2c 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -55,7 +55,6 @@ export class Mock { warning: this.sandbox.stub(), }; this.fetch = this.sandbox.stub(globalThis, "fetch"); - this.fetch.resolves(new Response("ok", { status: 200 })); this.fs = this.sandbox.stub(fs); this.webapi = { WebClient: function () { @@ -85,7 +84,6 @@ export class Mock { this.core.setSecret.reset(); this.core.warning.reset(); this.fetch.reset(); - this.fetch.resolves(new Response("ok", { status: 200 })); this.webapi = { WebClient: function () { this.apiCall = () => ({ From a530598187414c17e8b4727faa57efa3d02f73b7 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 1 Jun 2026 15:05:32 -0700 Subject: [PATCH 4/5] feat: use @slack/webhook SDK for webhook requests Replace the manual fetch-with-retry implementation with IncomingWebhook from @slack/webhook v8 RC. The SDK handles HTTP internally while we inject a custom fetch wrapper for User-Agent and proxy support. Note: @slack/webhook v8 does not export addAppMetadata, so there is no public way to register custom app metadata in the SDK's User-Agent. We supplement by prepending our action identity in the custom fetch wrapper. Co-Authored-By: Claude --- package-lock.json | 15 ++++ package.json | 1 + src/config.js | 7 -- src/webhook.js | 160 ++++++++++++------------------------------- test/webhook.spec.js | 124 ++++++++++----------------------- 5 files changed, 97 insertions(+), 210 deletions(-) diff --git a/package-lock.json b/package-lock.json index cf8e118b..f3aeaed8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@actions/github": "^9.1.1", "@slack/logger": "^5.0.0-rc.1", "@slack/web-api": "^8.0.0-rc.1", + "@slack/webhook": "^8.0.0-rc.1", "flat": "^6.0.1", "js-yaml": "^4.2.0", "markup-js": "^1.5.21", @@ -871,6 +872,20 @@ "npm": ">=9.6.4" } }, + "node_modules/@slack/webhook": { + "version": "8.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@slack/webhook/-/webhook-8.0.0-rc.1.tgz", + "integrity": "sha512-Grx42+WgFL/KBCPLOwjNLfMUOrXS6ubYTxZzZwHynDqPY4F/uY7Y+5Zgaolw1ySSmmUVGMyLB8XLO8klUDfI0A==", + "license": "MIT", + "dependencies": { + "@slack/types": "^3.0.0-rc.1", + "@types/node": ">=20" + }, + "engines": { + "node": ">= 20", + "npm": ">=9.6.4" + } + }, "node_modules/@types/flat": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/@types/flat/-/flat-5.0.5.tgz", diff --git a/package.json b/package.json index e6ec6941..dd3cc8f5 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@actions/github": "^9.1.1", "@slack/logger": "^5.0.0-rc.1", "@slack/web-api": "^8.0.0-rc.1", + "@slack/webhook": "^8.0.0-rc.1", "flat": "^6.0.1", "js-yaml": "^4.2.0", "markup-js": "^1.5.21", diff --git a/src/config.js b/src/config.js index 455d9788..445cead5 100644 --- a/src/config.js +++ b/src/config.js @@ -64,12 +64,6 @@ export default class Config { */ content; - /** - * The fetch function used for webhook requests. - * @type {typeof globalThis.fetch} - */ - fetch; - /** * Shared utilities specific to the GitHub action workflow. * @type {import("@actions/core")} @@ -105,7 +99,6 @@ export default class Config { */ constructor(core) { this.core = core; - this.fetch = globalThis.fetch; this.logger = new Logger(core).logger; this.webapi = webapi; this.inputs = { diff --git a/src/webhook.js b/src/webhook.js index 84c1d24e..bf1c1218 100644 --- a/src/webhook.js +++ b/src/webhook.js @@ -1,3 +1,4 @@ +import { IncomingWebhook } from "@slack/webhook"; import { ProxyAgent } from "undici"; import Config from "./config.js"; import SlackError from "./errors.js"; @@ -5,6 +6,12 @@ import SlackError from "./errors.js"; /** * This Webhook class posts the configured payload to the provided webhook, with * whatever additional settings set. + * + * NOTE: @slack/webhook v8 does not export addAppMetadata so there is no public + * way to inject custom app metadata into its User-Agent. The SDK sets its own + * User-Agent header internally. We supplement it by prepending our action's + * identity in the custom fetch wrapper below. + * @see {@link https://github.com/slackapi/node-slack-sdk/blob/webhook-8.0.0-development/packages/webhook/src/instrument.ts} */ export default class Webhook { /** @@ -14,26 +21,14 @@ export default class Webhook { if (!config.inputs.webhook) { throw new SlackError(config.core, "No webhook was provided to post to"); } - const retryConfig = this.retries(config.inputs.retries); - const fetchFn = this.proxiedFetch(config); + const webhook = new IncomingWebhook(config.inputs.webhook, { + fetch: this.customFetch(config), + }); try { - const response = await this.fetchWithRetry( - config.inputs.webhook, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": config.userAgent, - }, - body: JSON.stringify(config.content.values), - }, - retryConfig, - fetchFn, - ); - const data = await this.parseResponseBody(response); - config.core.setOutput("ok", response.status === 200); - config.core.setOutput("response", JSON.stringify(data)); - config.core.debug(JSON.stringify(data)); + const response = await webhook.send(config.content.values); + config.core.setOutput("ok", true); + config.core.setOutput("response", response.text); + config.core.debug(response.text); } catch (/** @type {any} */ err) { config.core.setOutput("ok", false); config.core.setOutput("response", JSON.stringify(err.message)); @@ -43,82 +38,50 @@ export default class Webhook { } /** - * Parse the response body as JSON, falling back to text. - * @param {Response} response - * @returns {Promise} - */ - async parseResponseBody(response) { - const text = await response.text(); - try { - return JSON.parse(text); - } catch { - return text; - } - } - - /** - * Perform a fetch request with configurable retries on retryable errors. - * @param {string} url - * @param {RequestInit} init - * @param {{retries: number, retryDelay: (attempt: number) => number, retryCondition: (status: number) => boolean}} retryConfig - * @param {(url: string, init?: RequestInit) => Promise} fetchFn - * @returns {Promise} + * Return a custom fetch function that injects the User-Agent header and + * routes through a proxy if configured. + * @param {Config} config + * @returns {(url: string | URL | Request, init?: any) => Promise} */ - async fetchWithRetry(url, init, retryConfig, fetchFn) { - let lastError; - for (let attempt = 0; attempt <= retryConfig.retries; attempt++) { - try { - const response = await fetchFn(url, init); - if (response.ok || !retryConfig.retryCondition(response.status)) { - return response; - } - if (attempt < retryConfig.retries) { - const delay = retryConfig.retryDelay(attempt + 1); - await new Promise((resolve) => setTimeout(resolve, delay)); - continue; - } - return response; - } catch (/** @type {any} */ err) { - lastError = err; - if (attempt < retryConfig.retries) { - const delay = retryConfig.retryDelay(attempt + 1); - await new Promise((resolve) => setTimeout(resolve, delay)); - continue; - } - throw err; - } - } - throw lastError; + customFetch(config) { + const dispatcher = this.proxyDispatcher(config); + return (url, init) => { + const headers = new Headers(init?.headers); + const existing = headers.get("User-Agent") || ""; + headers.set( + "User-Agent", + existing ? `${config.userAgent} ${existing}` : config.userAgent, + ); + return fetch(url, { + ...init, + headers, + ...(dispatcher ? { dispatcher } : {}), + }); + }; } /** - * Return a fetch function that routes through a proxy if configured. + * Return a proxy dispatcher if one is configured, or undefined. * @param {Config} config - * @returns {(url: string, init?: RequestInit) => Promise} - * @see {@link https://github.com/slackapi/slack-github-action/pull/132} + * @returns {any | undefined} */ - proxiedFetch(config) { + proxyDispatcher(config) { const { webhook, proxy } = config.inputs; - if (!webhook) { - throw new SlackError(config.core, "No webhook was provided to proxy to"); - } if (!proxy) { - return (url, init) => config.fetch(url, init); + return undefined; } try { - if (new URL(webhook).protocol !== "https:") { + if (webhook && new URL(webhook).protocol !== "https:") { config.core.debug( "The webhook destination is not HTTPS so skipping the HTTPS proxy", ); - return (url, init) => config.fetch(url, init); + return undefined; } const proxyUrl = new URL(proxy); switch (proxyUrl.protocol) { case "https:": - case "http:": { - const dispatcher = /** @type {any} */ (new ProxyAgent(proxy)); - return (url, init) => config.fetch(url, { ...init, dispatcher }); - } + case "http:": + return /** @type {any} */ (new ProxyAgent(proxy)); default: throw new SlackError( config.core, @@ -126,47 +89,12 @@ export default class Webhook { ); } } catch (/** @type {any} */ err) { + if (err instanceof SlackError) { + throw err; + } throw new SlackError(config.core, "Failed to configure the HTTPS proxy", { cause: err, }); } } - - /** - * Return configurations for retry options with different delays. - * @param {string} option - * @returns {{retries: number, retryDelay: (attempt: number) => number, retryCondition: (status: number) => boolean}} - */ - retries(option) { - /** @param {number} status */ - const isRetryable = (status) => status >= 500 || status === 429; - switch (option?.trim().toUpperCase()) { - case "0": - return { retries: 0, retryDelay: () => 0, retryCondition: isRetryable }; - case "5": - return { - retryCondition: isRetryable, - retries: 5, - retryDelay: (attempt) => attempt * 60 * 1000, // linear 60s - }; - case "10": - return { - retryCondition: isRetryable, - retries: 10, - retryDelay: (attempt) => 2000 * 2 ** (attempt - 1), // exponential from 2s - }; - case "RAPID": - return { - retryCondition: isRetryable, - retries: 12, - retryDelay: (attempt) => attempt * 1000, // linear 1s - }; - default: - return { - retryCondition: isRetryable, - retries: 5, - retryDelay: (attempt) => attempt * 60 * 1000, // linear 60s - }; - } - } } diff --git a/test/webhook.spec.js b/test/webhook.spec.js index 8c051496..abba9832 100644 --- a/test/webhook.spec.js +++ b/test/webhook.spec.js @@ -46,7 +46,7 @@ describe("webhook", () => { .returns("https://hooks.slack.com"); mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook"); mocks.core.getInput.withArgs("payload").returns("text: greetings"); - mocks.fetch.resolves(new Response(JSON.stringify("ok"), { status: 200 })); + mocks.fetch.resolves(new Response("ok", { status: 200 })); try { await send(mocks.core); assert.equal(mocks.fetch.getCalls().length, 1); @@ -56,15 +56,26 @@ describe("webhook", () => { assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok"); assert.equal(mocks.core.setOutput.getCall(0).lastArg, true); assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response"); - assert.equal( - mocks.core.setOutput.getCall(1).lastArg, - JSON.stringify("ok"), - ); + assert.equal(mocks.core.setOutput.getCall(1).lastArg, "ok"); } catch (err) { console.error(err); assert.fail("Failed to send the webhook"); } }); + + it("includes the user agent header in requests", async () => { + mocks.core.getInput + .withArgs("webhook") + .returns("https://hooks.slack.com"); + mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook"); + mocks.core.getInput.withArgs("payload").returns("text: hello"); + mocks.fetch.resolves(new Response("ok", { status: 200 })); + await send(mocks.core); + const [, init] = mocks.fetch.getCall(0).args; + const headers = new Headers(init.headers); + const ua = headers.get("User-Agent"); + assert.ok(ua.includes("@slack:slack-github-action/")); + }); }); describe("failure", () => { @@ -75,6 +86,7 @@ describe("webhook", () => { const config = { core: mocks.core, inputs: {}, + userAgent: "", }; try { await new Webhook().post(config); @@ -94,7 +106,6 @@ describe("webhook", () => { .returns("https://hooks.slack.com"); mocks.core.getInput.withArgs("webhook-type").returns("webhook-trigger"); mocks.core.getInput.withArgs("payload").returns("drinks: coffee"); - mocks.core.getInput.withArgs("retries").returns("0"); mocks.fetch.rejects(new Error("Request failed with status code 400")); try { await send(mocks.core); @@ -122,7 +133,6 @@ describe("webhook", () => { .returns("https://hooks.slack.com"); mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook"); mocks.core.getInput.withArgs("payload").returns("textt: oops"); - mocks.core.getInput.withArgs("retries").returns("0"); mocks.fetch.rejects(new Error("Request failed with status code 400")); try { await send(mocks.core); @@ -146,27 +156,15 @@ describe("webhook", () => { }); describe("proxies", () => { - it("requires a webhook is included in the inputs", async () => { - /** - * @type {Config} - */ - const config = { - core: mocks.core, - fetch: globalThis.fetch, - inputs: {}, - }; - try { - new Webhook().proxiedFetch(config); - assert.fail("Failed to throw for missing input"); - } catch (err) { - if (err instanceof SlackError) { - assert.ok( - err.message.includes("No webhook was provided to proxy to"), - ); - } else { - assert.fail(err); - } - } + it("returns no dispatcher when proxy is not configured", async () => { + mocks.core.getInput + .withArgs("webhook") + .returns("https://hooks.slack.com"); + mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook"); + const config = new Config(mocks.core); + const webhook = new Webhook(); + const dispatcher = webhook.proxyDispatcher(config); + assert.strictEqual(dispatcher, undefined); }); it("skips proxying an http webhook url altogether", async () => { @@ -175,11 +173,11 @@ describe("webhook", () => { mocks.core.getInput.withArgs("proxy").returns("https://example.com"); const config = new Config(mocks.core); const webhook = new Webhook(); - const fetchFn = webhook.proxiedFetch(config); - assert.strictEqual(typeof fetchFn, "function"); + const dispatcher = webhook.proxyDispatcher(config); + assert.strictEqual(dispatcher, undefined); }); - it("returns a proxy fetch function for the provided https proxy", async () => { + it("returns a proxy dispatcher for the provided https proxy", async () => { const proxy = "https://example.com"; mocks.core.getInput .withArgs("webhook") @@ -188,11 +186,11 @@ describe("webhook", () => { mocks.core.getInput.withArgs("proxy").returns(proxy); const config = new Config(mocks.core); const webhook = new Webhook(); - const fetchFn = webhook.proxiedFetch(config); - assert.strictEqual(typeof fetchFn, "function"); + const dispatcher = webhook.proxyDispatcher(config); + assert.ok(dispatcher); }); - it("returns a proxy fetch function for http proxies", async () => { + it("returns a proxy dispatcher for http proxies", async () => { const proxy = "http://example.com"; mocks.core.getInput .withArgs("webhook") @@ -201,8 +199,8 @@ describe("webhook", () => { mocks.core.getInput.withArgs("proxy").returns(proxy); const config = new Config(mocks.core); const webhook = new Webhook(); - const fetchFn = webhook.proxiedFetch(config); - assert.strictEqual(typeof fetchFn, "function"); + const dispatcher = webhook.proxyDispatcher(config); + assert.ok(dispatcher); }); it("fails to configure proxies with an invalid proxied url", async () => { @@ -215,7 +213,7 @@ describe("webhook", () => { try { const config = new Config(mocks.core); const webhook = new Webhook(); - webhook.proxiedFetch(config); + webhook.proxyDispatcher(config); assert.fail("An invalid proxy URL was not thrown as error!"); } catch (err) { if (err instanceof SlackError) { @@ -238,63 +236,15 @@ describe("webhook", () => { try { const config = new Config(mocks.core); const webhook = new Webhook(); - webhook.proxiedFetch(config); + webhook.proxyDispatcher(config); assert.fail("An unknown URL protocol was not thrown as error!"); } catch (err) { if (err instanceof SlackError) { - assert.ok( - err.message.includes("Failed to configure the HTTPS proxy"), - ); - assert.ok(err.cause.message.includes("Unsupported URL protocol")); + assert.ok(err.message.includes("Unsupported URL protocol")); } else { assert.fail(err); } } }); }); - - describe("retries", () => { - it("uses a default of five retries in requests", async () => { - const webhook = new Webhook(); - const result = webhook.retries(); - assert.equal(result.retries, 5); - }); - - it('does not attempt retries when "0" is set', async () => { - const webhook = new Webhook(); - const result = webhook.retries("0"); - assert.equal(result.retries, 0); - }); - - it('attempts a default amount of "5" retries', async () => { - const webhook = new Webhook(); - const result = webhook.retries("5"); - assert.equal(result.retries, 5); - assert.equal(result.retryDelay(5), 300000, "5th retry after 5 minutes"); - }); - - it('attempts "10" retries in around "30" minutes', async () => { - const webhook = new Webhook(); - const result = webhook.retries("10"); - assert.equal(result.retries, 10); - assert.ok( - result.retryDelay(10) > 500000, - "last attempt is well into the future", - ); - }); - - it('attempts a " rapid" burst of "12" retries in seconds', async () => { - const webhook = new Webhook(); - const result = webhook.retries(" rapid"); - assert.equal(result.retries, 12); - assert.equal(result.retryDelay(12), 12000, "12th retry after 12 seconds"); - }); - - it('attempts a "RAPID" burst of "12" retries in seconds', async () => { - const webhook = new Webhook(); - const result = webhook.retries("RAPID"); - assert.equal(result.retries, 12); - assert.equal(result.retryDelay(12), 12000, "12th retry after 12 seconds"); - }); - }); }); From 6bfc793880dc07631613f615d269da01dd1a9545 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 1 Jun 2026 16:07:14 -0700 Subject: [PATCH 5/5] refactor: split webhook trigger and incoming webhook paths Use @slack/webhook IncomingWebhook only for the incoming-webhook type. Webhook triggers use a direct fetch since the SDK has no class for them and the response shape differs (JSON vs plain text). Co-Authored-By: Claude --- src/webhook.js | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/webhook.js b/src/webhook.js index bf1c1218..d4ddbc52 100644 --- a/src/webhook.js +++ b/src/webhook.js @@ -21,7 +21,25 @@ export default class Webhook { if (!config.inputs.webhook) { throw new SlackError(config.core, "No webhook was provided to post to"); } - const webhook = new IncomingWebhook(config.inputs.webhook, { + switch (config.inputs.webhookType) { + case "incoming-webhook": + return await this.postIncomingWebhook(config); + case "webhook-trigger": + return await this.postWebhookTrigger(config); + default: + throw new SlackError( + config.core, + `Unknown webhook type: ${config.inputs.webhookType}`, + ); + } + } + + /** + * Post using the @slack/webhook IncomingWebhook SDK. + * @param {Config} config + */ + async postIncomingWebhook(config) { + const webhook = new IncomingWebhook(/** @type {string} */ (config.inputs.webhook), { fetch: this.customFetch(config), }); try { @@ -37,6 +55,30 @@ export default class Webhook { } } + /** + * Post directly to a webhook trigger URL and parse the JSON response. + * @param {Config} config + */ + async postWebhookTrigger(config) { + const fetchFn = this.customFetch(config); + try { + const response = await fetchFn(/** @type {string} */ (config.inputs.webhook), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config.content.values), + }); + const /** @type {any} */ data = await response.json(); + config.core.setOutput("ok", data.ok ?? response.ok); + config.core.setOutput("response", JSON.stringify(data)); + config.core.debug(JSON.stringify(data)); + } catch (/** @type {any} */ err) { + config.core.setOutput("ok", false); + config.core.setOutput("response", JSON.stringify(err.message)); + config.core.debug(err); + throw new SlackError(config.core, err.message); + } + } + /** * Return a custom fetch function that injects the User-Agent header and * routes through a proxy if configured.