From c8d4512e189a0776d896cceed30e00323b6c97ee Mon Sep 17 00:00:00 2001 From: ramaghanta Date: Thu, 15 Oct 2026 14:31:33 -0400 Subject: [PATCH 1/6] created new action to create a GitHub for dependency updates --- check-dependencies/README.md | 57 +++ check-dependencies/action.yml | 19 + package-lock.json | 343 ++++++-------- package.json | 4 +- .../check-dependencies.spec.ts | 424 ++++++++++++++++++ src/check-dependencies/check-dependencies.ts | 198 ++++++++ src/check-dependencies/index.ts | 5 + 7 files changed, 854 insertions(+), 196 deletions(-) create mode 100644 check-dependencies/README.md create mode 100644 check-dependencies/action.yml create mode 100644 src/check-dependencies/check-dependencies.spec.ts create mode 100644 src/check-dependencies/check-dependencies.ts create mode 100644 src/check-dependencies/index.ts diff --git a/check-dependencies/README.md b/check-dependencies/README.md new file mode 100644 index 0000000..78dead8 --- /dev/null +++ b/check-dependencies/README.md @@ -0,0 +1,57 @@ +# Check Dependencies + +Creates an **Update dependencies** issue when the latest matching issue closed as +completed is at least `max-age-days` old. If no completed issue exists, maintenance +is due immediately. An open matching issue suppresses creation. Pull requests and +issues closed as not planned do not reset the interval. + +New issues receive the `DEPENDENCY UPDATE` label. Matching recognizes that label +(case insensitive), the hidden `` marker, or historical +maintenance titles. Historical titles include `update dependencies` with optional +suffixes (such as `and workflows` or `and setup OIDC publishing`), +`Update to latest dependencies`, and `dependency / CI updates yyyy-mm-dd`. +Title matching ignores case and surrounding whitespace. Labeled issues can have +any title. Historical issues do not need to be relabeled to count toward the +maintenance interval or suppress a duplicate. + +Recent human commit authors on the default branch are ranked by commit count, +then latest authorship date, then username. Bots and commits without a linked +GitHub user are excluded. The action tries each author's assignment eligibility +in order, then `fallback-assignee`. If nobody is eligible, it creates an +unassigned issue and logs a warning. API errors other than an ineligible +assignee's 404 fail the action. + +```yaml +name: Dependency maintenance +on: + schedule: + - cron: '0 9 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: write + +# Serialize checks to prevent overlapping runs from opening duplicate issues. +concurrency: + group: dependency-maintenance + cancel-in-progress: false + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: checkdigit/github-actions/check-dependencies@main + id: dependencies + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + max-age-days: '60' + contributor-lookback-days: '90' + fallback-assignee: '' +``` + +Both day inputs must be positive integers. `GITHUB_REPOSITORY` is supplied by +GitHub Actions. The `issue-url` output contains the created issue URL, or an empty +string when creation is skipped. Close maintenance issues as **completed** once +the dependency updates are merged. diff --git a/check-dependencies/action.yml b/check-dependencies/action.yml new file mode 100644 index 0000000..b9e18ff --- /dev/null +++ b/check-dependencies/action.yml @@ -0,0 +1,19 @@ +name: Check Dependencies +description: Check dependencies and create one overdue maintenance issue with recent-contributor ownership +inputs: + max-age-days: + description: Days since the latest dependency-update issue closed as completed + default: '60' + contributor-lookback-days: + description: Days of default-branch commit authorship to rank + default: '90' + fallback-assignee: + description: GitHub username to try after eligible recent contributors + default: '' +outputs: + issue-url: + description: GitHub URL of created issue +runs: + using: node24 + pre: '../src/setup.ts' + main: ../src/check-dependencies/index.ts diff --git a/package-lock.json b/package-lock.json index f48510c..7bf622f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@checkdigit/github-actions", - "version": "4.1.0", + "version": "4.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@checkdigit/github-actions", - "version": "4.1.0", + "version": "4.2.0", "license": "MIT", "dependencies": { "@actions/core": "^3.0.1", @@ -22,7 +22,7 @@ "@checkdigit/typescript-config": "^10.2.2", "@types/debug": "^4.1.13", "@types/semver": "^7.8.0", - "nock": "^14.0.17" + "nock": "^15.0.1" }, "engines": { "node": ">=24.20.0" @@ -89,14 +89,13 @@ "license": "MIT" }, "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-16.0.3.tgz", - "integrity": "sha512-7lZp4XCTZhU/AgiGpIG+j4uJeWPdGbpQ+8wqXrzuHy+tboKsjSacjGH9/kNThRDIFhMWHF3ba+ejS8J3PeSI1Q==", + "version": "16.0.5", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-16.0.5.tgz", + "integrity": "sha512-z3LiEMqTP2FNu6u8bdUgBYErVuwMSZReOJy1MCEIB0BDHMoNgBtkciby32HOlcrfWwrOSzRU8YIsZ0CogYRuYQ==", "dev": true, "license": "MIT", "dependencies": { - "js-yaml": "^5.4.2", - "undici": "^8.10.2" + "js-yaml": "^5.4.2" }, "engines": { "node": ">=22.19.0" @@ -106,9 +105,9 @@ } }, "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", - "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.3.tgz", + "integrity": "sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==", "dev": true, "funding": [ { @@ -128,16 +127,6 @@ "js-yaml": "bin/js-yaml.mjs" } }, - "node_modules/@apidevtools/json-schema-ref-parser/node_modules/undici": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", - "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/@checkdigit/eslint-athena-plugin": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@checkdigit/eslint-athena-plugin/-/eslint-athena-plugin-2.0.1.tgz", @@ -1095,9 +1084,9 @@ } }, "node_modules/@jsep-plugin/assignment": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", - "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.4.0.tgz", + "integrity": "sha512-nw2Px23Eirx0PFTVoPrb3FEq9Wkmg6vxcDtt38MtBCf1G0tjLCB9cK+3seOXyiw+6+aalgYhvJabQbWwMVKlEQ==", "dev": true, "license": "MIT", "engines": { @@ -1108,9 +1097,9 @@ } }, "node_modules/@jsep-plugin/regex": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", - "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.1.0.tgz", + "integrity": "sha512-2Vkbda+XRjK6VTI6Lrh0sNBuMT8mZ51EhqMvO+V2rSdmqjs7NAGssxPZfkL23EFzYfSrvxyd1ZjOdqPQMM50og==", "dev": true, "license": "MIT", "engines": { @@ -1121,27 +1110,27 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.41.9", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", - "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.44.0.tgz", + "integrity": "sha512-IYHevRStF4ijM20GI9c9Ndhw483NSAjCydQhUr4/zIB0BNLsTVw3vFAxTo6TlvX4QnV60wEHttUa9539TTAq+A==", "dev": true, "license": "MIT", "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", + "@open-draft/until": "^3.0.1", + "@types/debug": "^4.1.13", + "debug": "^4.4.3", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" + "rettime": "^0.11.11" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", - "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", "dev": true, "license": "MIT", "optional": true, @@ -1345,31 +1334,16 @@ "@octokit/openapi-types": "^29.0.1" } }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "node_modules/@open-draft/until": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-3.0.1.tgz", + "integrity": "sha512-s7/9ELP4aP9YZtW7RJaa0Xf3RISaRH9+EFN18FtykJYRHGNrl8f9ymvoNXzjhrjmftFvkQudtuDU9RYhk0xs3A==", "dev": true, "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" + "funding": { + "url": "https://github.com/sponsors/kettanaito" } }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT" - }, "node_modules/@ota-meshi/ast-token-store": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@ota-meshi/ast-token-store/-/ast-token-store-0.3.0.tgz", @@ -1901,9 +1875,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", - "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", "dev": true, "license": "MIT", "peer": true, @@ -2289,14 +2263,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", - "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.0", - "@typescript-eslint/types": "^8.71.0", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -2311,14 +2285,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", - "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0" + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2329,9 +2303,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", - "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, "license": "MIT", "engines": { @@ -2346,15 +2320,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", - "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0", - "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2371,9 +2345,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", - "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "dev": true, "license": "MIT", "engines": { @@ -2385,16 +2359,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", - "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.0", - "@typescript-eslint/tsconfig-utils": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2413,16 +2387,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", - "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0" + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2437,13 +2411,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", - "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/types": "8.71.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2845,9 +2819,9 @@ ] }, "node_modules/@xml-tools/parser": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@xml-tools/parser/-/parser-1.0.11.tgz", - "integrity": "sha512-aKqQ077XnR+oQtHJlrAflaZaL7qZsulWc/i/ZEooar5JiWj1eLt0+Wg28cpa+XLney107wXqneC+oG1IZvxkTA==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@xml-tools/parser/-/parser-1.0.12.tgz", + "integrity": "sha512-YNIPbmr/7ClPpWyk0MLImLBLyVLgW+BeOkbd4UDp8w0tCcgU8lmA4karXBF9EQwaQzDTzaBbJtmSA0G2NKKlPA==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -2856,9 +2830,9 @@ } }, "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, "license": "MIT", "peer": true, @@ -2937,9 +2911,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.26", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", - "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2969,9 +2943,9 @@ } }, "node_modules/browserslist": { - "version": "4.29.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.2.tgz", - "integrity": "sha512-/u9r8k8ue4ZhHCw9ovDtltpWdXeXzjhdxCGj6vm1RGLPXb8lu33EMdoCHF5Sx8oYBS+Q/FYYNmAqlHncxR3RmA==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -2990,7 +2964,7 @@ "license": "MIT", "dependencies": { "baseline-browser-mapping": "^2.11.26", - "caniuse-lite": "^1.0.30001812", + "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" @@ -3026,9 +3000,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001813", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001813.tgz", - "integrity": "sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ==", + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", "dev": true, "funding": [ { @@ -3301,16 +3275,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.439", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.439.tgz", - "integrity": "sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==", + "version": "1.5.456", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.456.tgz", + "integrity": "sha512-8hboqI6xesozteFb8aa7OGknvxUtTeRJLz0FoePysUEh/oWbddykIGdgpEJRbPRg/nIMfBf2DFos+H2MtvJl2A==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.25.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", - "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", "dev": true, "license": "MIT", "dependencies": { @@ -3707,9 +3681,9 @@ } }, "node_modules/eslint-plugin-sonarjs/node_modules/globals": { - "version": "17.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", - "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", "engines": { @@ -3758,9 +3732,9 @@ } }, "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "17.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", - "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", "engines": { @@ -4292,9 +4266,9 @@ } }, "node_modules/ignore": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", - "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -4468,13 +4442,13 @@ } }, "node_modules/jsep": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", - "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.6.0.tgz", + "integrity": "sha512-Y8Tr54CX7B5YGAwo7/n8eumidEX3Opx98RyyQVnlHqUaZcPA8uUzL2v1rshr9ypW9Q6hNjfJtg3sL+8kaNE7TA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10.16.0" + "node": ">= 20.16.0" } }, "node_modules/jsesc": { @@ -4523,13 +4497,6 @@ "license": "MIT", "peer": true }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC" - }, "node_modules/json-with-bigint": { "version": "3.5.12", "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", @@ -4701,9 +4668,9 @@ } }, "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.3.tgz", + "integrity": "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==", "dev": true, "license": "MIT", "dependencies": { @@ -4731,9 +4698,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", "dev": true, "license": "MIT", "dependencies": { @@ -4930,9 +4897,9 @@ } }, "node_modules/mdast-util-to-markdown": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.3.tgz", - "integrity": "sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz", + "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", "dev": true, "license": "MIT", "dependencies": { @@ -5680,24 +5647,22 @@ "license": "MIT" }, "node_modules/nock": { - "version": "14.0.17", - "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.17.tgz", - "integrity": "sha512-EjRr1weMa4ALQX35AgZTEnP+weJJjlW1KGDiNM2IQC2069YDHas4f4B4UUYR+TTLyKWxJvOz2wObDKQs/LNreA==", + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/nock/-/nock-15.0.1.tgz", + "integrity": "sha512-Mjx5Xl4t9vP13eUZPWqwwpjjXNjvhUTo1qTdryVM3+7t6Z2w6WHZ9GVZk/zQ8Ym1vbmloWaglye3cloyHAJ8FA==", "dev": true, "license": "MIT", "dependencies": { - "@mswjs/interceptors": "^0.41.0", - "json-stringify-safe": "^5.0.1", - "propagate": "^2.0.0" + "@mswjs/interceptors": "^0.44.0" }, "engines": { - "node": ">=18.20.0 <20 || >=20.12.1" + "node": ">=20.12.1" } }, "node_modules/node-releases": { - "version": "2.0.57", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", - "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", "dev": true, "license": "MIT", "engines": { @@ -6042,16 +6007,6 @@ } } }, - "node_modules/propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6112,9 +6067,9 @@ "peer": true }, "node_modules/regjsparser": { - "version": "0.13.3", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.3.tgz", - "integrity": "sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==", + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.4.tgz", + "integrity": "sha512-EWV80YgFaVgFrjGCAsV7oybp4GOxbls5RzBg8OB0nKEVXsBJR1JlV8Di1JhCROg3MzTfe1RI8yitIm1eOs68iA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6157,6 +6112,13 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rettime": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.12.tgz", + "integrity": "sha512-5ExtR1ppiDBQkVoPw5NAqdbfH4Iq2VOmQKHMtUoaFxA9bo2krzFYBtyq6K40xxbYp+2w/YOr6D+1ToF6tTN53A==", + "dev": true, + "license": "MIT" + }, "node_modules/scslre": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", @@ -6241,9 +6203,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -6260,13 +6222,6 @@ "node": ">=12.0.0" } }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, - "license": "MIT" - }, "node_modules/strip-indent": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", @@ -6299,9 +6254,9 @@ } }, "node_modules/svelte": { - "version": "5.57.1", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", - "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", "dev": true, "license": "MIT", "peer": true, @@ -6622,9 +6577,9 @@ } }, "node_modules/undici": { - "version": "6.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", - "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -6757,9 +6712,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", - "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 18716c2..6667ab5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@checkdigit/github-actions", - "version": "4.1.0", + "version": "4.2.0", "description": " Provides supporting operations for github action builds.", "homepage": "https://github.com/checkdigit/github-actions#readme", "bugs": { @@ -43,7 +43,7 @@ "@checkdigit/typescript-config": "^10.2.2", "@types/debug": "^4.1.13", "@types/semver": "^7.8.0", - "nock": "^14.0.17" + "nock": "^15.0.1" }, "engines": { "node": ">=24.20.0" diff --git a/src/check-dependencies/check-dependencies.spec.ts b/src/check-dependencies/check-dependencies.spec.ts new file mode 100644 index 0000000..50f6afa --- /dev/null +++ b/src/check-dependencies/check-dependencies.spec.ts @@ -0,0 +1,424 @@ +// check-dependencies/check-dependencies.spec.ts + +/* eslint-disable camelcase -- GitHub API request and response fields use snake case. */ + +import { strict as assert } from 'node:assert'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import nock from 'nock'; + +import main from './check-dependencies.ts'; + +const NOW = new Date('2026-10-15T12:00:00Z'); +const ISSUE_URL = 'https://github.com/checkdigit/test/issues/42'; +const REPO_PATH = '/repos/checkdigit/test'; +const originalEnvironment = { ...process.env }; + +function issue(overrides: object = {}) { + return { + number: 1, + title: 'Update dependencies', + labels: [], + state: 'closed', + state_reason: 'completed', + closed_at: '2026-08-01T12:00:00Z', + ...overrides, + }; +} + +function commit(login: string | null, date = NOW.toISOString(), type = 'User') { + return { + author: login === null ? null : { login, type }, + commit: { author: { date } }, + }; +} + +function mockIssues(issues: object[] = []) { + return nock('https://api.github.com') + .get(`${REPO_PATH}/issues`) + .query({ state: 'all', per_page: 100 }) + .reply(200, issues); +} + +function mockCommits(commits: object[] = []) { + return nock('https://api.github.com') + .get(`${REPO_PATH}/commits`) + .query({ + since: '2026-07-17T12:00:00.000Z', + until: NOW.toISOString(), + per_page: 100, + }) + .reply(200, commits); +} + +function mockAssignee(login: string, status = 204) { + return nock('https://api.github.com') + .get(`${REPO_PATH}/assignees/${login}`) + .reply(status); +} + +function mockCreation(assignees: string[] = []) { + return nock('https://api.github.com') + .post(`${REPO_PATH}/issues`, (body) => { + assert.equal(body.title, 'Update dependencies'); + assert.deepEqual(body.labels, ['DEPENDENCY UPDATE']); + assert.equal( + body.body, + '\n\nupdate to the latest dependencies and workflows', + ); + assert.deepEqual(body.assignees, assignees); + return true; + }) + .reply(201, { html_url: ISSUE_URL }); +} + +describe('check dependencies', () => { + let outputDirectory: string; + let outputFile: string; + + beforeEach(async () => { + process.env = { ...originalEnvironment }; + process.env['GITHUB_TOKEN'] = 'test-token'; + process.env['GITHUB_REPOSITORY'] = 'checkdigit/test'; + delete process.env['INPUT_MAX-AGE-DAYS']; + delete process.env['INPUT_CONTRIBUTOR-LOOKBACK-DAYS']; + delete process.env['INPUT_FALLBACK-ASSIGNEE']; + outputDirectory = await mkdtemp( + path.join(os.tmpdir(), 'check-dependencies-'), + ); + outputFile = path.join(outputDirectory, 'output'); + await writeFile(outputFile, ''); + // Hooks run sequentially within this suite. + // eslint-disable-next-line require-atomic-updates + process.env['GITHUB_OUTPUT'] = outputFile; + mock.timers.enable({ apis: ['Date'], now: NOW }); + nock.disableNetConnect(); + }); + + afterEach(async () => { + const pending = nock.pendingMocks(); + mock.timers.reset(); + nock.cleanAll(); + nock.enableNetConnect(); + process.env = { ...originalEnvironment }; + await rm(outputDirectory, { recursive: true, force: true }); + assert.deepEqual(pending, []); + }); + + [ + issue({ state: 'open', closed_at: null }), + issue({ + state: 'open', + title: 'Renamed maintenance', + body: '', + closed_at: null, + }), + issue({ closed_at: '2026-10-01T12:00:00Z' }), + ].forEach((existing) => { + it(`skips creation for ${existing.title}, ${existing.state}, ${existing.closed_at}`, async () => { + mockIssues([existing]); + await main(); + const output = await readFile(outputFile, 'utf8'); + assert.ok(output.includes('issue-url<<')); + assert.ok(!output.includes(ISSUE_URL)); + }); + }); + + it('uses the most recent completed closure rather than issue order', async () => { + mockIssues([ + issue(), + issue({ closed_at: '2026-10-14T12:00:00Z' }), + issue({ closed_at: '2026-08-01T12:00:00Z' }), + ]); + await main(); + }); + + for (const title of [ + 'update dependencies', + 'update dependencies and workflows', + 'update dependencies and setup OIDC publishing', + 'Update to latest dependencies', + 'dependency / CI updates 2026-10-01', + 'update to latest dependencies', + 'Dependency updates', + ' UPDATE DEPENDENCIES AND WORKFLOWS ', + ]) { + for (const state of ['open', 'closed']) { + it(`recognizes historical title "${title}" when ${state}`, async () => { + mockIssues([ + issue({ title, state, closed_at: '2026-10-01T12:00:00Z' }), + ]); + await main(); + }); + } + } + + for (const labels of [ + [{ name: 'DEPENDENCY UPDATE' }], + ['DEPENDENCY UPDATE'], + [{ name: 'dependency update' }], + ]) { + for (const state of ['open', 'closed']) { + it(`recognizes label ${JSON.stringify(labels)} when ${state} regardless of title`, async () => { + mockIssues([ + issue({ + title: 'Refresh project tooling', + labels, + state, + closed_at: '2026-10-01T12:00:00Z', + }), + ]); + await main(); + }); + } + } + + it('excludes unrelated titles and labels from maintenance history', async () => { + const unrelatedTitles = [ + 'Investigate dependencies failure', + 'Update documentation about dependencies', + 'CI updates 2026-10-01', + 'Update dependenciesChecker', + ]; + mockIssues( + unrelatedTitles.flatMap((title) => [ + issue({ + title, + state: 'open', + labels: [{ name: 'DEPENDENCY UPDATE REQUEST' }], + }), + issue({ title, closed_at: '2026-10-14T12:00:00Z', labels: ['bug'] }), + ]), + ); + mockCommits(); + mockCreation(); + await main(); + }); + + it('still ignores PRs and not-planned closures when matched by label or historical title', async () => { + mockIssues([ + issue({ + title: 'Refresh tooling', + labels: ['DEPENDENCY UPDATE'], + state: 'open', + pull_request: {}, + }), + issue({ + title: 'dependency / CI updates 2026-10-01', + state: 'open', + pull_request: {}, + }), + issue({ + title: 'Refresh tooling', + labels: ['DEPENDENCY UPDATE'], + state_reason: 'not_planned', + closed_at: '2026-10-14T12:00:00Z', + }), + issue({ + title: 'update dependencies and workflows', + state_reason: 'not_planned', + closed_at: '2026-10-14T12:00:00Z', + }), + ]); + mockCommits(); + mockCreation(); + await main(); + }); + + it('creates one overdue issue, ignoring PRs and closures not completed', async () => { + mockIssues([ + issue(), + issue({ closed_at: '2026-10-14T12:00:00Z', state_reason: 'not_planned' }), + issue({ closed_at: '2026-10-14T12:00:00Z', state_reason: null }), + issue({ state: 'open', pull_request: { url: 'https://example.com/pr' } }), + issue({ title: 'Unrelated issue', state: 'open' }), + ]); + mockCommits(); + mockCreation(); + await main(); + assert.ok((await readFile(outputFile, 'utf8')).includes(ISSUE_URL)); + }); + + it('ranks human authors by commit count and skips ineligible contributors', async () => { + mockIssues(); + mockCommits([ + commit('frequent'), + commit('frequent'), + commit('frequent'), + commit('eligible'), + commit('eligible'), + commit('other'), + commit('automation[bot]', undefined, 'Bot'), + commit('automation[bot]', undefined, 'Bot'), + commit('automation[bot]', undefined, 'Bot'), + commit('automation[bot]', undefined, 'Bot'), + commit(null), + ]); + mockAssignee('frequent', 404); + mockAssignee('eligible'); + mockCreation(['eligible']); + await main(); + }); + + it('breaks contribution ties by latest authored commit then username', async () => { + mockIssues(); + mockCommits([ + commit('older', '2026-10-01T12:00:00Z'), + commit('zeta'), + commit('alpha'), + ]); + mockAssignee('alpha'); + mockCreation(['alpha']); + await main(); + }); + + it('tries the fallback after recent contributors', async () => { + process.env['INPUT_FALLBACK-ASSIGNEE'] = 'Fallback'; + mockIssues(); + mockCommits([commit('recent')]); + mockAssignee('recent', 404); + mockAssignee('fallback'); + mockCreation(['fallback']); + await main(); + }); + + it('excludes commits authored outside the lookback even if recently committed', async () => { + mockIssues(); + mockCommits([ + commit('stale', '2026-01-01T12:00:00Z'), + commit('future', '2026-10-16T12:00:00Z'), + commit('recent'), + ]); + mockAssignee('recent'); + mockCreation(['recent']); + await main(); + }); + + it('does not retry a fallback already checked as a recent contributor', async () => { + process.env['INPUT_FALLBACK-ASSIGNEE'] = 'recent'; + mockIssues(); + mockCommits([commit('recent')]); + mockAssignee('recent', 404); + mockCreation(); + await main(); + }); + + it('creates an unassigned issue if nobody is eligible', async () => { + process.env['INPUT_FALLBACK-ASSIGNEE'] = 'fallback'; + mockIssues(); + mockCommits(); + mockAssignee('fallback', 404); + mockCreation(); + await main(); + }); + + it('propagates assignment permission failures without creating an issue', async () => { + mockIssues(); + mockCommits([commit('recent')]); + mockAssignee('recent', 403); + await assert.rejects(main, { status: 403 }); + }); + + it('paginates issues before deciding whether to create one', async () => { + nock('https://api.github.com') + .get(`${REPO_PATH}/issues`) + .query({ state: 'all', per_page: 100 }) + .reply(200, [issue()], { + Link: '; rel="next"', + }) + .get(`${REPO_PATH}/issues`) + .query({ state: 'all', per_page: 100, page: 2 }) + .reply(200, [issue({ state: 'open' })]); + await main(); + }); + + it('paginates commits when ranking authors', async () => { + mockIssues(); + nock('https://api.github.com') + .get(`${REPO_PATH}/commits`) + .query(true) + .reply(200, [commit('first'), commit('second')], { + Link: '; rel="next"', + }) + .get(`${REPO_PATH}/commits`) + .query({ page: 2 }) + .reply(200, [commit('second')]); + mockAssignee('second'); + mockCreation(['second']); + await main(); + }); + + it('honors custom intervals and creates at the exact due boundary', async () => { + process.env['INPUT_MAX-AGE-DAYS'] = '14'; + process.env['INPUT_CONTRIBUTOR-LOOKBACK-DAYS'] = '7'; + mockIssues([issue({ closed_at: '2026-10-01T12:00:00Z' })]); + nock('https://api.github.com') + .get(`${REPO_PATH}/commits`) + .query({ + since: '2026-10-08T12:00:00.000Z', + until: NOW.toISOString(), + per_page: 100, + }) + .reply(200, []); + mockCreation(); + await main(); + }); + + for (const input of ['0', '-1', '1.5', 'invalid', 'Infinity', '100000001']) { + it(`rejects invalid day input ${input}`, async () => { + process.env['INPUT_MAX-AGE-DAYS'] = input; + await assert.rejects(main, /max-age-days must be a positive integer/u); + }); + } + + it('validates the contributor lookback', async () => { + process.env['INPUT_CONTRIBUTOR-LOOKBACK-DAYS'] = '-1'; + await assert.rejects(main, { + message: + 'incorrect action configuration: contributor-lookback-days must be a positive integer number of days', + }); + }); + + it('requires a token', async () => { + delete process.env['GITHUB_TOKEN']; + await assert.rejects(main, { + message: 'incorrect action configuration: GITHUB_TOKEN is not set', + }); + }); + + for (const repository of [ + '', + 'owner', + '/repo', + 'owner/', + 'owner/repo/extra', + ]) { + it(`rejects malformed repository ${repository}`, async () => { + process.env['GITHUB_REPOSITORY'] = repository; + await assert.rejects(main, /unable to get repo/u); + }); + } + + it('propagates GitHub failures', async () => { + nock('https://api.github.com') + .get(`${REPO_PATH}/issues`) + .query(true) + .reply(403, { message: 'Forbidden' }); + await assert.rejects(main, { status: 403 }); + }); + + it('propagates issue creation failures without publishing a URL', async () => { + mockIssues(); + mockCommits(); + nock('https://api.github.com') + .post(`${REPO_PATH}/issues`) + .reply(422, { message: 'Validation failed' }); + await assert.rejects(main, { status: 422 }); + assert.ok(!(await readFile(outputFile, 'utf8')).includes(ISSUE_URL)); + }); +}); + +/* eslint-enable camelcase */ diff --git a/src/check-dependencies/check-dependencies.ts b/src/check-dependencies/check-dependencies.ts new file mode 100644 index 0000000..ccd33b4 --- /dev/null +++ b/src/check-dependencies/check-dependencies.ts @@ -0,0 +1,198 @@ +// check-dependencies/check-dependencies.ts + +import debug from 'debug'; +import { getInput, setOutput, warning } from '@actions/core'; +import { Octokit } from '@octokit/rest'; + +const log = debug('github-actions:check-dependencies'); +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const ISSUE_TITLE = 'Update dependencies'; +const ISSUE_LABEL = 'DEPENDENCY UPDATE'; +const ISSUE_MARKER = ''; +const LEGACY_ISSUE_TITLES = [ + // Match "update dependencies" and "update to latest dependencies", allowing suffixes. + /^update\s+(?:(?:to\s+)?(?:the\s+)?latest\s+)?dependencies\b/iu, + // Match "dependency updates" and "dependency / CI updates", allowing date suffixes. + /^dependenc(?:y|ies)(?:\s*\/\s*ci)?\s+updates?\b/iu, +]; +const MAX_DATE_MS = 8.64e15; +const DEFAULT_LOOKBACK_DAYS = 90; +const NOT_FOUND = 404; + +function getDaysInput(name: string, defaultValue: number): number { + const input = getInput(name) || String(defaultValue); + const days = Number(input); + if ( + !Number.isSafeInteger(days) || + days <= 0 || + days * DAY_IN_MS > MAX_DATE_MS + ) { + throw new Error( + `incorrect action configuration: ${name} must be a positive integer number of days`, + ); + } + return days; +} + +function hasStatus(error: unknown, status: number): boolean { + return error instanceof Error && 'status' in error && error.status === status; +} + +async function selectAssignee( + octokit: Octokit, + owner: string, + repo: string, + since: string, + until: string, +): Promise { + // Without a sha, GitHub lists commits on the default branch. + const commits = await octokit.paginate(octokit.rest.repos.listCommits, { + owner, + repo, + since, + until, + // eslint-disable-next-line camelcase + per_page: 100, + }); + const contributors = new Map(); + for (const commit of commits) { + const author = commit.author; + const authoredAt = Date.parse(commit.commit.author?.date ?? ''); + if ( + author?.type === 'User' && + !author.login.endsWith('[bot]') && + authoredAt >= Date.parse(since) && + authoredAt <= Date.parse(until) + ) { + const login = author.login.toLowerCase(); + const contributor = contributors.get(login) ?? { count: 0, latest: 0 }; + contributor.count += 1; + contributor.latest = Math.max(contributor.latest, authoredAt); + contributors.set(login, contributor); + } + } + const candidates = contributors + .entries() + .toArray() + .toSorted( + ([leftLogin, left], [rightLogin, right]) => + right.count - left.count || + right.latest - left.latest || + leftLogin.localeCompare(rightLogin), + ) + .map(([login]) => login); + const fallback = getInput('fallback-assignee').toLowerCase(); + if (fallback !== '' && !candidates.includes(fallback)) { + candidates.push(fallback); + } + for (const assignee of candidates) { + try { + // Try candidates in rank order and stop at the first eligible user. + // eslint-disable-next-line no-await-in-loop + await octokit.rest.issues.checkUserCanBeAssigned({ + owner, + repo, + assignee, + }); + return assignee; + } catch (error) { + if (!hasStatus(error, NOT_FOUND)) { + throw error; + } + log('Skipping ineligible assignee %s', assignee); + } + } + return undefined; +} + +export default async function main(): Promise { + log('Action start'); + setOutput('issue-url', ''); + const maxAgeDays = getDaysInput('max-age-days', 60); + const lookbackDays = getDaysInput( + 'contributor-lookback-days', + DEFAULT_LOOKBACK_DAYS, + ); + + // eslint-disable-next-line n/no-process-env + const token = process.env['GITHUB_TOKEN']; + if (token === undefined || token === '') { + throw new Error('incorrect action configuration: GITHUB_TOKEN is not set'); + } + const octokit = new Octokit({ auth: token }); + + // eslint-disable-next-line n/no-process-env + const gitHubRepo = process.env['GITHUB_REPOSITORY'] ?? ''; + const [owner, repo, extra] = gitHubRepo.split('/'); + if ( + owner === undefined || + owner === '' || + repo === undefined || + repo === '' || + extra !== undefined + ) { + throw new Error('unable to get repo'); + } + + const now = Date.now(); + const issues = await octokit.paginate(octokit.rest.issues.listForRepo, { + owner, + repo, + state: 'all', + // eslint-disable-next-line camelcase + per_page: 100, + }); + const maintenanceIssues = issues.filter( + (issue) => + issue.pull_request === undefined && + (issue.labels.some( + (label) => + (typeof label === 'string' ? label : label.name)?.toUpperCase() === + ISSUE_LABEL, + ) || + LEGACY_ISSUE_TITLES.some((pattern) => + pattern.test(issue.title.trim()), + ) || + issue.body?.includes(ISSUE_MARKER) === true), + ); + if (maintenanceIssues.some((issue) => issue.state === 'open')) { + log('Dependency maintenance issue already open'); + return; + } + let lastCompleted = 0; + for (const issue of maintenanceIssues) { + if (issue.state === 'closed' && issue.state_reason === 'completed') { + lastCompleted = Math.max( + lastCompleted, + Date.parse(issue.closed_at ?? '') || 0, + ); + } + } + if (lastCompleted !== 0 && lastCompleted > now - maxAgeDays * DAY_IN_MS) { + log('Dependency maintenance is not due'); + return; + } + + const assignee = await selectAssignee( + octokit, + owner, + repo, + new Date(now - lookbackDays * DAY_IN_MS).toISOString(), + new Date(now).toISOString(), + ); + if (assignee === undefined) { + warning( + 'No eligible recent contributor or fallback assignee; creating an unassigned dependency maintenance issue.', + ); + } + const { data: issue } = await octokit.rest.issues.create({ + owner, + repo, + title: ISSUE_TITLE, + labels: [ISSUE_LABEL], + body: `${ISSUE_MARKER}\n\nupdate to the latest dependencies and workflows`, + assignees: assignee === undefined ? [] : [assignee], + }); + setOutput('issue-url', issue.html_url); + log('Action end: %s', issue.html_url); +} diff --git a/src/check-dependencies/index.ts b/src/check-dependencies/index.ts new file mode 100644 index 0000000..46f2a61 --- /dev/null +++ b/src/check-dependencies/index.ts @@ -0,0 +1,5 @@ +// check-dependencies/index.ts + +import main from './check-dependencies.ts'; + +await main(); From 2fe1108bf17f22cdc22075bbc686bd7207ba11cf Mon Sep 17 00:00:00 2001 From: ramaghanta Date: Thu, 15 Oct 2026 16:36:36 -0400 Subject: [PATCH 2/6] created new action that will create a GitHub issue for dependency updates --- check-dependencies/README.md | 2 +- check-dependencies/action.yml | 2 +- src/check-dependencies/check-dependencies.spec.ts | 2 +- src/check-dependencies/check-dependencies.ts | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/check-dependencies/README.md b/check-dependencies/README.md index 78dead8..8b272cc 100644 --- a/check-dependencies/README.md +++ b/check-dependencies/README.md @@ -46,7 +46,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - max-age-days: '60' + max-age-days: '90' contributor-lookback-days: '90' fallback-assignee: '' ``` diff --git a/check-dependencies/action.yml b/check-dependencies/action.yml index b9e18ff..fc214d2 100644 --- a/check-dependencies/action.yml +++ b/check-dependencies/action.yml @@ -3,7 +3,7 @@ description: Check dependencies and create one overdue maintenance issue with re inputs: max-age-days: description: Days since the latest dependency-update issue closed as completed - default: '60' + default: '90' contributor-lookback-days: description: Days of default-branch commit authorship to rank default: '90' diff --git a/src/check-dependencies/check-dependencies.spec.ts b/src/check-dependencies/check-dependencies.spec.ts index 50f6afa..5b3ae20 100644 --- a/src/check-dependencies/check-dependencies.spec.ts +++ b/src/check-dependencies/check-dependencies.spec.ts @@ -24,7 +24,7 @@ function issue(overrides: object = {}) { labels: [], state: 'closed', state_reason: 'completed', - closed_at: '2026-08-01T12:00:00Z', + closed_at: '2026-07-01T12:00:00Z', ...overrides, }; } diff --git a/src/check-dependencies/check-dependencies.ts b/src/check-dependencies/check-dependencies.ts index ccd33b4..dabeeba 100644 --- a/src/check-dependencies/check-dependencies.ts +++ b/src/check-dependencies/check-dependencies.ts @@ -6,6 +6,7 @@ import { Octokit } from '@octokit/rest'; const log = debug('github-actions:check-dependencies'); const DAY_IN_MS = 24 * 60 * 60 * 1000; +const MAX_AGE_DAYS = 90; const ISSUE_TITLE = 'Update dependencies'; const ISSUE_LABEL = 'DEPENDENCY UPDATE'; const ISSUE_MARKER = ''; @@ -108,7 +109,7 @@ async function selectAssignee( export default async function main(): Promise { log('Action start'); setOutput('issue-url', ''); - const maxAgeDays = getDaysInput('max-age-days', 60); + const maxAgeDays = getDaysInput('max-age-days', MAX_AGE_DAYS); const lookbackDays = getDaysInput( 'contributor-lookback-days', DEFAULT_LOOKBACK_DAYS, From 59e3c61f5dfd66bce74080c5a450e6c8d960412c Mon Sep 17 00:00:00 2001 From: ramaghanta Date: Thu, 15 Oct 2026 17:16:48 -0400 Subject: [PATCH 3/6] updated cron expression in readme --- check-dependencies/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check-dependencies/README.md b/check-dependencies/README.md index 8b272cc..c18b891 100644 --- a/check-dependencies/README.md +++ b/check-dependencies/README.md @@ -25,7 +25,7 @@ assignee's 404 fail the action. name: Dependency maintenance on: schedule: - - cron: '0 9 * * 1' + - cron: '0 10 * * 1-5' workflow_dispatch: permissions: From 851239810ee9ce7942072d0ba3eaef711d110987 Mon Sep 17 00:00:00 2001 From: ramaghanta Date: Thu, 15 Oct 2026 17:19:04 -0400 Subject: [PATCH 4/6] updated example in readme --- check-dependencies/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/check-dependencies/README.md b/check-dependencies/README.md index c18b891..e8e06cc 100644 --- a/check-dependencies/README.md +++ b/check-dependencies/README.md @@ -22,7 +22,7 @@ unassigned issue and logs a warning. API errors other than an ineligible assignee's 404 fail the action. ```yaml -name: Dependency maintenance +name: Check Dependencies on: schedule: - cron: '0 10 * * 1-5' @@ -34,7 +34,7 @@ permissions: # Serialize checks to prevent overlapping runs from opening duplicate issues. concurrency: - group: dependency-maintenance + group: check-dependencies cancel-in-progress: false jobs: @@ -42,7 +42,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: checkdigit/github-actions/check-dependencies@main - id: dependencies env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From f32f7a996513dab990802991baa9ed0934c47049 Mon Sep 17 00:00:00 2001 From: ramaghanta Date: Thu, 15 Oct 2026 17:25:44 -0400 Subject: [PATCH 5/6] updated readme --- check-dependencies/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check-dependencies/README.md b/check-dependencies/README.md index e8e06cc..b263d72 100644 --- a/check-dependencies/README.md +++ b/check-dependencies/README.md @@ -26,7 +26,7 @@ name: Check Dependencies on: schedule: - cron: '0 10 * * 1-5' - workflow_dispatch: + workflow_dispatch: {} permissions: contents: read From e8e73329440ccd8b5bd0f4ce6e893f31b0e610e7 Mon Sep 17 00:00:00 2001 From: ramaghanta Date: Fri, 16 Oct 2026 10:27:09 -0400 Subject: [PATCH 6/6] create issue label if it doesn't exist --- check-dependencies/README.md | 4 ++ .../check-dependencies.spec.ts | 61 +++++++++++++++++++ src/check-dependencies/check-dependencies.ts | 32 ++++++++++ 3 files changed, 97 insertions(+) diff --git a/check-dependencies/README.md b/check-dependencies/README.md index b263d72..4852937 100644 --- a/check-dependencies/README.md +++ b/check-dependencies/README.md @@ -5,6 +5,10 @@ completed is at least `max-age-days` old. If no completed issue exists, maintena is due immediately. An open matching issue suppresses creation. Pull requests and issues closed as not planned do not reset the interval. +Each run creates the repository's `DEPENDENCY UPDATE` label if it is missing, +even when no new issue is due. Existing label settings are preserved. No +organization default label configuration is required. + New issues receive the `DEPENDENCY UPDATE` label. Matching recognizes that label (case insensitive), the hidden `` marker, or historical maintenance titles. Historical titles include `update dependencies` with optional diff --git a/src/check-dependencies/check-dependencies.spec.ts b/src/check-dependencies/check-dependencies.spec.ts index 5b3ae20..c695c7c 100644 --- a/src/check-dependencies/check-dependencies.spec.ts +++ b/src/check-dependencies/check-dependencies.spec.ts @@ -78,6 +78,7 @@ function mockCreation(assignees: string[] = []) { describe('check dependencies', () => { let outputDirectory: string; let outputFile: string; + const labelResponse = { status: 200 }; beforeEach(async () => { process.env = { ...originalEnvironment }; @@ -96,6 +97,11 @@ describe('check dependencies', () => { process.env['GITHUB_OUTPUT'] = outputFile; mock.timers.enable({ apis: ['Date'], now: NOW }); nock.disableNetConnect(); + labelResponse.status = 200; + nock('https://api.github.com') + .get(`${REPO_PATH}/labels/DEPENDENCY%20UPDATE`) + .optionally() + .reply(() => [labelResponse.status, { name: 'DEPENDENCY UPDATE' }]); }); afterEach(async () => { @@ -108,6 +114,61 @@ describe('check dependencies', () => { assert.deepEqual(pending, []); }); + for (const existing of [ + issue({ state: 'open' }), + issue({ closed_at: '2026-10-14T12:00:00Z' }), + undefined, + ]) { + it(`creates a missing label when maintenance is ${existing?.state ?? 'due'}`, async () => { + labelResponse.status = 404; + nock('https://api.github.com') + .post(`${REPO_PATH}/labels`, { + name: 'DEPENDENCY UPDATE', + color: '2f1144', + description: 'Dependency and workflow maintenance', + }) + .reply(201, { name: 'DEPENDENCY UPDATE' }); + mockIssues(existing === undefined ? [] : [existing]); + if (existing === undefined) { + mockCommits(); + mockCreation(); + } + await main(); + }); + } + + it('continues if another run creates the label first', async () => { + labelResponse.status = 404; + nock('https://api.github.com') + .post(`${REPO_PATH}/labels`) + .reply(422) + .get(`${REPO_PATH}/labels/DEPENDENCY%20UPDATE`) + .reply(200, { name: 'DEPENDENCY UPDATE' }); + mockIssues([issue({ state: 'open' })]); + await main(); + }); + + it('propagates label lookup permission failures', async () => { + labelResponse.status = 403; + await assert.rejects(main, { status: 403 }); + }); + + it('propagates label creation permission failures', async () => { + labelResponse.status = 404; + nock('https://api.github.com').post(`${REPO_PATH}/labels`).reply(403); + await assert.rejects(main, { status: 403 }); + }); + + it('fails if a rejected label creation leaves the label missing', async () => { + labelResponse.status = 404; + nock('https://api.github.com') + .post(`${REPO_PATH}/labels`) + .reply(422) + .get(`${REPO_PATH}/labels/DEPENDENCY%20UPDATE`) + .reply(404); + await assert.rejects(main, { status: 404 }); + }); + [ issue({ state: 'open', closed_at: null }), issue({ diff --git a/src/check-dependencies/check-dependencies.ts b/src/check-dependencies/check-dependencies.ts index dabeeba..a6a1695 100644 --- a/src/check-dependencies/check-dependencies.ts +++ b/src/check-dependencies/check-dependencies.ts @@ -19,6 +19,7 @@ const LEGACY_ISSUE_TITLES = [ const MAX_DATE_MS = 8.64e15; const DEFAULT_LOOKBACK_DAYS = 90; const NOT_FOUND = 404; +const UNPROCESSABLE_ENTITY = 422; function getDaysInput(name: string, defaultValue: number): number { const input = getInput(name) || String(defaultValue); @@ -39,6 +40,35 @@ function hasStatus(error: unknown, status: number): boolean { return error instanceof Error && 'status' in error && error.status === status; } +async function ensureLabel( + octokit: Octokit, + owner: string, + repo: string, +): Promise { + const parameters = { owner, repo, name: ISSUE_LABEL }; + try { + await octokit.rest.issues.getLabel(parameters); + return; + } catch (error) { + if (!hasStatus(error, NOT_FOUND)) { + throw error; + } + } + try { + await octokit.rest.issues.createLabel({ + ...parameters, + color: '2f1144', + description: 'Dependency and workflow maintenance', + }); + } catch (error) { + // Another run may have created the label after our lookup. + if (!hasStatus(error, UNPROCESSABLE_ENTITY)) { + throw error; + } + await octokit.rest.issues.getLabel(parameters); + } +} + async function selectAssignee( octokit: Octokit, owner: string, @@ -135,6 +165,8 @@ export default async function main(): Promise { throw new Error('unable to get repo'); } + await ensureLabel(octokit, owner, repo); + const now = Date.now(); const issues = await octokit.paginate(octokit.rest.issues.listForRepo, { owner,