diff --git a/README.md b/README.md index e78209d..2ac4157 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,114 @@ -# Dependabot for Git +# dependabot-for-git -Reusable CLI that runs [dependabot/cli](https://github.com/dependabot/cli) update jobs against a -Bitbucket Cloud repo and turns the results into real pull requests. +Reusable CLI that runs [dependabot/cli](https://github.com/dependabot/cli) update jobs against a git +repo and turns the results into real pull/merge requests — across Bitbucket Cloud and +GitLab, without copying scripts into every repo. + +## Supported providers + +| Provider | Status | Notes | +| ---------------------------- | ------ | ----------------------------------------------------------------- | +| Bitbucket Cloud | ✅ | writes files via the `src` API, opens PRs via `/pullrequests` | +| GitLab (SaaS or self-hosted) | ✅ | writes files via the Commits API, opens MRs via `/merge_requests` | + +Provider is auto-detected from CI environment variables (Bitbucket Pipelines sets +`BITBUCKET_WORKSPACE`; GitLab CI sets `CI_PROJECT_ID`/`GITLAB_CI`), so most repos never +need to configure it explicitly. Override with `GIT_PROVIDER=bitbucket` or +`GIT_PROVIDER=gitlab` if you ever need to force it. + +Adding a new provider means implementing the interface in `src/providers/bitbucket.mjs` / +`src/providers/gitlab.mjs` (`getContext`, `buildSourceBlock`, `buildGitSourceCredential`, +`getDefaultBranch`, `commitFiles`, `findOpenPr`, `createPullRequest`, `closePullRequest`) +and registering it in `src/providers/index.mjs` — `src/run-updates.mjs` and +`src/create-prs.mjs` don't need to change at all. + +## Publishing + +```bash +npm publish --access public +``` + +Nothing in this package is secret — all tokens are supplied at runtime via env vars, +never embedded — so publishing publicly means consuming pipelines don't need any npm auth +just to install the tool. If your org requires it private, publish with +`--access restricted` and set up an `.npmrc` in each consuming pipeline pointing at an npm +token with read access to your scope. ## Using it in a repo -Copy the two files from `templates/` into the repo: +### Bitbucket Cloud -- `bitbucket-pipelines.yml` — the custom `dependabot-update` pipeline -- `dependabot-config.json` — which ecosystems/directories to check (edit as needed, same - idea as the `updates:` list in a normal `.github/dependabot.yml`) +Copy `templates/bitbucket-pipelines.yml` -> `bitbucket-pipelines.yml` and +`templates/dependabot-config.json` -> `dependabot-config.json`. Set the three repository +variables listed in the template's header comment, then trigger the `dependabot-update` +custom pipeline manually or on a schedule (Repo settings -> Pipelines -> Schedules). -Set the three repository variables (see comments at the top of the pipeline template), -then trigger it manually from the Pipelines UI ("Run pipeline" -> custom: -dependabot-update), or wire it to a schedule under Repo settings -> Pipelines -> -Schedules. +### GitLab -That's it — no scripts to copy, no logic to keep in sync. Bumping behaviour for every -repo now comes from bumping this package's version. +Copy `templates/gitlab-ci.yml` -> `.gitlab-ci.yml` and `templates/dependabot-config.json` +-> `dependabot-config.json`. Set the CI/CD variables listed in the template's header +comment (Settings -> CI/CD -> Variables), then trigger it manually ("Run pipeline") or on +a schedule (Build -> Pipeline schedules). + +Either way: no scripts to copy, no logic to keep in sync. Behaviour for every repo comes +from bumping this package's version. ## What the two CLI commands do ### `dependabot-run-updates` -Reads `./dependabot-config.json` in the current working directory (the consuming repo), -builds a job description per entry, and runs `dependabot update` against Bitbucket Cloud, -writing `.dependabot-run/output-N.yaml` + a `manifest.json` describing what ran. - -Same required env vars as before: `BITBUCKET_WORKSPACE`, `BITBUCKET_REPO_SLUG`, -`BITBUCKET_COMMIT` (all provided automatically by Bitbucket Pipelines), plus -`BITBUCKET_GIT_TOKEN` and optionally `NPM_TOKEN`. +Reads `./dependabot-config.json` in the current working directory, builds a job +description per entry (using the detected provider's `source` block + `git_source` +credential), and runs `dependabot update`, writing `.dependabot-run/output-N.yaml` + a +`manifest.json` describing what ran. ### `dependabot-create-prs` Reads `.dependabot-run/manifest.json` (or explicit `output-*.yaml` paths passed as arguments) and turns each recorded `create_pull_request` / `update_pull_request` / -`close_pull_request` entry into a real Bitbucket PR via the REST API — writing files -straight to a branch via the `src` endpoint, no local git clone needed. - -Requires `BITBUCKET_WORKSPACE`, `BITBUCKET_REPO_SLUG`, and `BITBUCKET_PR_TOKEN` (or -`BITBUCKET_USERNAME` + `BITBUCKET_APP_PASSWORD`). +`close_pull_request` entry into a real PR/MR via the detected provider — no local git +clone needed. -**Filters applied automatically:** +**Filters applied automatically, on every provider:** - Skips `update_dependency_list` / `mark_as_processed` entries (informational, not PR actions). - Skips transitive-only dependency bumps by default (updates that only touch a lockfile, never the manifest) — otherwise `npm_and_yarn` in particular can produce 100+ PRs for a small project. Set `SKIP_TRANSITIVE_ONLY_UPDATES=false` to disable. -- Truncates PR descriptions over ~30KB (release notes for monorepo packages like - `@aws-sdk/*` can be huge; Bitbucket rejects descriptions over ~32KB). +- Truncates PR/MR descriptions over ~30KB (release notes for monorepo packages like + `@aws-sdk/*` can be huge; Bitbucket in particular rejects descriptions over ~32KB). -`DRY_RUN=true` runs fully offline and just logs what it would do. +`DRY_RUN=true` runs fully offline and just logs what it would do, for either provider. + +## Token env vars + +Each provider checks its own env var names first, falling back to the generic +`GIT_READ_TOKEN` / `GIT_WRITE_TOKEN` if unset - handy if you'd rather configure tokens +once and reuse the same CI/CD variable names across providers. + +| | Bitbucket | GitLab | +| --------------------------------- | --------------------- | ------------------------------------------------ | +| Read (used by the updater itself) | `BITBUCKET_GIT_TOKEN` | `GITLAB_TOKEN` | +| Write (used to open PRs/MRs) | `BITBUCKET_PR_TOKEN` | `GITLAB_PR_TOKEN` (falls back to `GITLAB_TOKEN`) | + +## Testing + +```bash +npm install +npm test +``` + +Unit tests (mocha + chai + sinon) live in `test/`: + +- `test/lib/format.test.mjs` - pure helpers (branch naming, title/body generation, + truncation, the transitive-dependency filter). +- `test/providers/bitbucket.test.mjs`, `test/providers/gitlab.test.mjs` - each provider's + API calls, with `fetch` stubbed via sinon so nothing touches the network. +- `test/providers/index.test.mjs` - provider auto-detection. +- `test/create-prs.test.mjs`, `test/run-updates.test.mjs` - the orchestration logic, using + a fake provider object so these tests exercise the actual routing/filtering decisions + without depending on any specific provider's implementation. ## Local development on this package @@ -63,3 +120,14 @@ cd /path/to/some/other/repo npm link dependabot-for-git BITBUCKET_WORKSPACE=... BITBUCKET_REPO_SLUG=... ... dependabot-run-updates ``` + +## ⚠️ Still worth verifying against dpenedabot-cli + +The dependabot CLI's job/output schema isn't formally documented. The Bitbucket field +names this package relies on (`updated-dependency-files`, `pr-title`, `pr-body`, +`commit-message`, etc.) were confirmed against real output from `dependabot-cli`; the +GitLab `source` block schema was confirmed against the upstream CLI's own README and a +GitHub issue showing a real GitLab job description, but hasn't been exercised against a +live GitLab run the way the Bitbucket path has. If your fork diverges further, re-check +`src/create-prs.mjs` and the relevant `src/providers/*.mjs` against a fresh `output.yaml`, +ideally with `DRY_RUN=true` first. diff --git a/bin/create-bitbucket-prs.mjs b/bin/create-bitbucket-prs.mjs deleted file mode 100755 index 3f97d0a..0000000 --- a/bin/create-bitbucket-prs.mjs +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -import { createPullRequests } from '../src/create-bitbucket-prs.mjs'; - -createPullRequests(process.argv.slice(2)); diff --git a/bin/create-prs.mjs b/bin/create-prs.mjs new file mode 100755 index 0000000..d060035 --- /dev/null +++ b/bin/create-prs.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { createPullRequests } from '../src/create-prs.mjs'; + +createPullRequests(process.argv.slice(2)); diff --git a/package-lock.json b/package-lock.json index 487c89b..317fda2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,30 +1,580 @@ { "name": "dependabot-for-git", - "version": "0.0.2", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dependabot-for-git", - "version": "0.0.2", + "version": "0.0.3", "license": "MIT", "dependencies": { "js-yaml": "^5.2.3" }, "bin": { - "dependabot-create-prs": "bin/create-bitbucket-prs.mjs", + "dependabot-create-prs": "bin/create-prs.mjs", "dependabot-run-updates": "bin/run-updates.mjs" }, + "devDependencies": { + "chai": "^6.2.2", + "mocha": "^11.8.0", + "sinon": "^22.1.0" + }, "engines": { "node": ">=22" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-yaml": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", @@ -46,6 +596,724 @@ "bin": { "js-yaml": "bin/js-yaml.mjs" } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz", + "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/mocha/node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sinon": { + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.1.0.tgz", + "integrity": "sha512-n1ajF2rBWMTtEwbKcw4UdFg4nCnDdq/U6RDoxtOd7oapOlRoJ5ynwFx60owROyhDpA9QhMZi0pCO/xtmwFjG7w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.4.0", + "@sinonjs/samsam": "^10.0.2", + "diff": "^9.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 9173b86..1b21238 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,19 @@ { "name": "dependabot-for-git", - "version": "0.0.2", + "version": "0.0.3", "private": false, "type": "module", - "description": "Runs dependabot/cli update jobs against a Bitbucket Cloud repo and turns the results into Bitbucket pull requests.", + "description": "Runs dependabot/cli update jobs against Bitbucket Cloud or GitLab repos and turns the results into real pull/merge requests.", "license": "MIT", "engines": { "node": ">=22" }, + "scripts": { + "test": "mocha \"test/**/*.test.mjs\"" + }, "bin": { "dependabot-run-updates": "bin/run-updates.mjs", - "dependabot-create-prs": "bin/create-bitbucket-prs.mjs" + "dependabot-create-prs": "bin/create-prs.mjs" }, "files": [ "bin", @@ -26,14 +29,24 @@ "keywords": [ "dependabot", "bitbucket", - "dependency updates" + "dependency updates", + "gitlab", + "dependencies", + "dependency update" ], "exports": { ".": "./src/run-updates.mjs", "./run-updates": "./src/run-updates.mjs", - "./create-bitbucket-prs": "./src/create-bitbucket-prs.mjs" + "./create-prs": "./src/create-prs.mjs", + "./providers/*": "./src/providers/*.mjs", + "./lib/format": "./src/lib/format.mjs" }, "dependencies": { "js-yaml": "^5.2.3" + }, + "devDependencies": { + "chai": "^6.2.2", + "mocha": "^11.8.0", + "sinon": "^22.1.0" } } diff --git a/src/create-bitbucket-prs.mjs b/src/create-bitbucket-prs.mjs deleted file mode 100644 index 10f3d96..0000000 --- a/src/create-bitbucket-prs.mjs +++ /dev/null @@ -1,379 +0,0 @@ -// Reads the output-*.yaml files produced by `dependabot update -o ...` -// (see src/run-updates.mjs) and turns each recorded call into a real -// Bitbucket Cloud pull request. -// -// The dependabot CLI's job-description/output format isn't formally -// documented (see https://github.com/dependabot/cli#job-description-file), -// so this is defensive: it logs anything it doesn't recognise instead of -// crashing, and falls back to sane defaults for titles/messages when the -// updater didn't provide one. -// -// Required env vars: -// BITBUCKET_WORKSPACE, BITBUCKET_REPO_SLUG (provided by Bitbucket Pipelines) -// BITBUCKET_PR_TOKEN - Bitbucket access token with write/PR permissions -// (kept separate from the read-only token used to -// run the update job, per Dependabot's own guidance) -// Optional: -// BITBUCKET_USERNAME, BITBUCKET_APP_PASSWORD - alternative to BITBUCKET_PR_TOKEN -// DRY_RUN=true - log what would happen without calling the API -// SKIP_TRANSITIVE_ONLY_UPDATES - defaults to true; set "false" to also PR transitive-only bumps - -import { readFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { createRequire } from 'node:module'; - -// js-yaml is CommonJS-only; loading it via createRequire avoids ESM/CJS -// default-export interop issues that can crop up depending on the local -// npm/node_modules setup ("does not provide an export named 'default'"). -const require = createRequire(import.meta.url); -const yaml = require('js-yaml'); - -function requireEnv(name, value) { - if (!value) { - console.error(`Missing required env var: ${name}`); - process.exit(1); - } - return value; -} - -// The real output shape nests the payload under `expect.data`, e.g.: -// - type: create_pull_request -// expect: -// data: -// dependencies: [...] -// updated-dependency-files: [...] -// Fall back to a top-level `data` key too, in case that ever changes. -function getData(entry) { - return entry.expect?.data ?? entry.data ?? {}; -} - -// Output types the CLI emits that don't correspond to a PR action - safe to -// skip quietly rather than logging them as "unhandled". -const IGNORED_OUTPUT_TYPES = new Set(['update_dependency_list', 'mark_as_processed']); - -// Filenames that count as "the manifest" per ecosystem - an update whose -// updated-dependency-files ONLY touches lockfiles (e.g. package-lock.json, -// yarn.lock) and never one of these was a transitive/indirect dependency -// bump, not something listed directly in package.json. Left unfiltered, -// npm_and_yarn in particular can produce a separate create_pull_request for -// every transitive package in the lockfile - easily 100+ PRs for a small -// project. Skipped by default; set SKIP_TRANSITIVE_ONLY_UPDATES=false to -// disable this filter and get a PR for every update, direct or not. -const MANIFEST_FILENAMES = new Set(['package.json', 'Gemfile', 'requirements.txt', 'go.mod', 'Cargo.toml', 'composer.json']); - -function touchesManifestFile(files) { - return files.some((f) => MANIFEST_FILENAMES.has(f.name)); -} - -function slugify(text) { - return String(text) - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 60); -} - -function branchNameFor(packageManager, dependencies) { - const depSlug = dependencies.map((d) => slugify(d.name ?? d['dependency-name'])).join('-and-'); - return `dependabot/${slugify(packageManager)}/${depSlug}`; -} - -function defaultTitle(dependencies) { - if (dependencies.length === 1) { - const d = dependencies[0]; - return `Bump ${d.name} from ${d['previous-version']} to ${d.version}`; - } - return `Bump ${dependencies.map((d) => d.name).join(', ')}`; -} - -function defaultBody(dependencies) { - const lines = dependencies.map( - (d) => `- \`${d.name}\`: ${d['previous-version'] ?? '?'} -> ${d.version ?? '?'}` - ); - return `Dependency updates produced by dependabot CLI:\n\n${lines.join('\n')}`; -} - -// pr-body from dependabot-core can be very large (release notes + changelog + -// full commit list per dependency, sometimes tens of KB for monorepo -// packages like @aws-sdk/*). Bitbucket Cloud rejects PR descriptions above -// ~32KB, so truncate defensively rather than let the API call fail outright. -const MAX_PR_BODY_LENGTH = 30000; -function truncateBody(body) { - if (!body || body.length <= MAX_PR_BODY_LENGTH) return body; - return `${body.slice(0, MAX_PR_BODY_LENGTH)}\n\n... (truncated, see the original dependency's release notes for the full changelog)`; -} - -export async function createPullRequests(argv = []) { - const { - BITBUCKET_WORKSPACE, - BITBUCKET_REPO_SLUG, - BITBUCKET_PR_TOKEN, - BITBUCKET_USERNAME, - BITBUCKET_APP_PASSWORD, - DRY_RUN, - SKIP_TRANSITIVE_ONLY_UPDATES, - } = process.env; - - requireEnv('BITBUCKET_WORKSPACE', BITBUCKET_WORKSPACE); - requireEnv('BITBUCKET_REPO_SLUG', BITBUCKET_REPO_SLUG); - - if (!BITBUCKET_PR_TOKEN && !(BITBUCKET_USERNAME && BITBUCKET_APP_PASSWORD)) { - console.error('Set BITBUCKET_PR_TOKEN, or BITBUCKET_USERNAME + BITBUCKET_APP_PASSWORD'); - process.exit(1); - } - - const dryRun = DRY_RUN === 'true'; - const skipTransitiveOnly = SKIP_TRANSITIVE_ONLY_UPDATES !== 'false'; - const API_ROOT = 'https://api.bitbucket.org/2.0'; - const repoPath = `${BITBUCKET_WORKSPACE}/${BITBUCKET_REPO_SLUG}`; - - function authHeader() { - if (BITBUCKET_PR_TOKEN) return `Bearer ${BITBUCKET_PR_TOKEN}`; - const basic = Buffer.from(`${BITBUCKET_USERNAME}:${BITBUCKET_APP_PASSWORD}`).toString('base64'); - return `Basic ${basic}`; - } - - async function bbFetch(path, options = {}) { - const url = path.startsWith('http') ? path : `${API_ROOT}${path}`; - const res = await fetch(url, { - ...options, - headers: { - Authorization: authHeader(), - ...(options.headers ?? {}), - }, - }); - if (!res.ok) { - const body = await res.text().catch(() => ''); - throw new Error(`Bitbucket API ${options.method ?? 'GET'} ${url} -> ${res.status}: ${body}`); - } - return res; - } - - let defaultBranchCache; - async function getDefaultBranch() { - if (defaultBranchCache) return defaultBranchCache; - if (dryRun) { - // Avoid a real API call so DRY_RUN=true works fully offline. - defaultBranchCache = ''; - return defaultBranchCache; - } - const res = await bbFetch(`/repositories/${repoPath}`); - const data = await res.json(); - defaultBranchCache = data.mainbranch?.name ?? 'main'; - return defaultBranchCache; - } - - // Writes/deletes the files from an `updated-dependency-files` entry directly - // to a branch using Bitbucket's "src" endpoint, creating the branch if it - // doesn't already exist. This avoids needing a local git clone. - async function commitFiles({ branch, baseBranch, message, files }) { - const form = new FormData(); - form.append('branch', branch); - form.append('message', message); - form.append('author', 'dependabot '); - - for (const file of files) { - const path = [file.directory, file.name].filter(Boolean).join('/').replace(/^\/+/, ''); - if (file.deleted || file.operation === 'delete') { - form.append('files', path); - continue; - } - const content = - file.content_encoding === 'base64' - ? Buffer.from(file.content, 'base64') - : Buffer.from(file.content ?? '', 'utf8'); - form.append(path, new Blob([content])); - } - - if (dryRun) { - console.log(`[dry-run] would commit ${files.length} file(s) to ${branch} (base: ${baseBranch})`); - return; - } - - await bbFetch(`/repositories/${repoPath}/src`, { - method: 'POST', - body: form, - }); - } - - async function findOpenPrByBranch(branch) { - const q = encodeURIComponent(`state="OPEN" AND source.branch.name="${branch}"`); - const res = await bbFetch(`/repositories/${repoPath}/pullrequests?q=${q}`); - const data = await res.json(); - return data.values?.[0]; - } - - async function createPullRequest({ branch, title, body }) { - const destination = await getDefaultBranch(); - - if (dryRun) { - console.log(`[dry-run] would open PR "${title}" ${branch} -> ${destination}`); - return; - } - - const existing = await findOpenPrByBranch(branch); - if (existing) { - console.log(`PR already open for ${branch}: !${existing.id} - leaving as-is (new commit was still pushed)`); - return existing; - } - - const res = await bbFetch(`/repositories/${repoPath}/pullrequests`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title, - description: truncateBody(body), - source: { branch: { name: branch } }, - destination: { branch: { name: destination } }, - close_source_branch: true, - }), - }); - const pr = await res.json(); - console.log(`Opened PR !${pr.id}: ${title}`); - return pr; - } - - async function declinePullRequest(pr) { - if (dryRun) { - console.log(`[dry-run] would decline PR !${pr.id}`); - return; - } - await bbFetch(`/repositories/${repoPath}/pullrequests/${pr.id}/decline`, { method: 'POST' }); - console.log(`Declined PR !${pr.id}`); - } - - async function handleCreatePullRequest(entry) { - const data = getData(entry); - const dependencies = data.dependencies ?? []; - const files = data['updated-dependency-files'] ?? data.updatedDependencyFiles ?? []; - const packageManager = entry['package-manager'] ?? data['package-manager'] ?? 'dependencies'; - - if (!files.length) { - console.warn('create_pull_request entry had no updated-dependency-files, skipping:', dependencies.map((d) => d.name)); - return; - } - - if (skipTransitiveOnly && !touchesManifestFile(files)) { - console.log( - `Skipping transitive-only update for ${dependencies.map((d) => d.name).join(', ')} ` + - `(only touches ${files.map((f) => f.name).join(', ')}, not the manifest)` - ); - return; - } - - const branch = branchNameFor(packageManager, dependencies); - const title = data['pr-title'] ?? defaultTitle(dependencies); - const body = data['pr-body'] ?? data['commit-message'] ?? defaultBody(dependencies); - const message = data['commit-message'] ?? title; - - await commitFiles({ branch, baseBranch: await getDefaultBranch(), message, files }); - await createPullRequest({ branch, title, body }); - } - - async function handleUpdatePullRequest(entry) { - const data = getData(entry); - const dependencies = data.dependencies ?? []; - const files = data['updated-dependency-files'] ?? data.updatedDependencyFiles ?? []; - const packageManager = entry['package-manager'] ?? data['package-manager'] ?? 'dependencies'; - const branch = branchNameFor(packageManager, dependencies); - - if (!files.length) { - console.warn('update_pull_request entry had no updated-dependency-files, skipping:', dependencies.map((d) => d.name)); - return; - } - - if (skipTransitiveOnly && !touchesManifestFile(files)) { - console.log( - `Skipping transitive-only update for ${dependencies.map((d) => d.name).join(', ')} ` + - `(only touches ${files.map((f) => f.name).join(', ')}, not the manifest)` - ); - return; - } - - const message = data['commit-message'] ?? `Update ${dependencies.map((d) => d.name).join(', ')}`; - await commitFiles({ branch, baseBranch: await getDefaultBranch(), message, files }); - - const pr = await findOpenPrByBranch(branch); - if (!pr) { - // No open PR found for this branch (e.g. it was closed manually) - open a fresh one. - await createPullRequest({ - branch, - title: data['pr-title'] ?? defaultTitle(dependencies), - body: data['pr-body'] ?? defaultBody(dependencies), - }); - } else { - console.log(`Pushed update to existing PR !${pr.id} (${branch})`); - } - } - - async function handleClosePullRequest(entry) { - const data = getData(entry); - const dependencies = data.dependencies ?? []; - const packageManager = entry['package-manager'] ?? data['package-manager'] ?? 'dependencies'; - const branch = branchNameFor(packageManager, dependencies); - - const pr = await findOpenPrByBranch(branch); - if (!pr) { - console.log(`close_pull_request: no open PR found for ${branch}, nothing to do`); - return; - } - await declinePullRequest(pr); - } - - async function processOutputFile(outputPath, packageManager) { - if (!existsSync(outputPath)) { - console.log(`No output file at ${outputPath} (likely no updates found), skipping`); - return; - } - const parsed = yaml.load(readFileSync(outputPath, 'utf8')); - const calls = parsed?.output ?? parsed ?? []; - - for (const entry of calls) { - entry['package-manager'] ??= packageManager; - - if (IGNORED_OUTPUT_TYPES.has(entry.type)) { - continue; - } - - try { - switch (entry.type) { - case 'create_pull_request': - await handleCreatePullRequest(entry); - break; - case 'update_pull_request': - await handleUpdatePullRequest(entry); - break; - case 'close_pull_request': - await handleClosePullRequest(entry); - break; - default: - console.log(`Unhandled output type "${entry.type}", skipping`); - } - } catch (err) { - console.error(`Failed to process ${entry.type} entry:`, err.message); - process.exitCode = 1; - } - } - } - - const manifestPath = resolve('.dependabot-run/manifest.json'); - const explicitFiles = argv; - - if (explicitFiles.length) { - for (const file of explicitFiles) { - await processOutputFile(resolve(file), 'dependencies'); - } - return; - } - - if (!existsSync(manifestPath)) { - console.error(`No manifest found at ${manifestPath} and no files passed as arguments.`); - console.error('Run dependabot-run-updates first, or pass output-*.yaml paths directly.'); - process.exit(1); - } - - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - for (const { packageManager, outputPath } of manifest) { - await processOutputFile(outputPath, packageManager); - } -} diff --git a/src/create-prs.mjs b/src/create-prs.mjs new file mode 100644 index 0000000..ce3fb41 --- /dev/null +++ b/src/create-prs.mjs @@ -0,0 +1,181 @@ +// Reads the output-*.yaml files produced by `dependabot update -o ...` +// (see src/run-updates.mjs) and turns each recorded call into a real PR/MR +// via whichever git provider is detected/configured. +// +// The dependabot CLI's job-description/output format isn't formally +// documented (see https://github.com/dependabot/cli#job-description-file), +// so this is defensive: it logs anything it doesn't recognise instead of +// crashing, and falls back to sane defaults for titles/messages when the +// updater didn't provide one. +// +// Provider is auto-detected from CI env vars (or set explicitly via +// GIT_PROVIDER=bitbucket|gitlab). +// +// Optional env vars: +// DRY_RUN=true - log what would happen without calling any provider API +// SKIP_TRANSITIVE_ONLY_UPDATES - defaults to true; set "false" to also PR transitive-only bumps + +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { createProvider } from './providers/index.mjs'; +import { + getData, + IGNORED_OUTPUT_TYPES, + touchesManifestFile, + branchNameFor, + defaultTitle, + defaultBody, + truncateBody, +} from './lib/format.mjs'; + +// js-yaml is CommonJS-only; loading it via createRequire avoids ESM/CJS +// default-export interop issues that can crop up depending on the local +// npm/node_modules setup ("does not provide an export named 'default'"). +const require = createRequire(import.meta.url); +const yaml = require('js-yaml'); + +export async function createPullRequests(argv = [], { env = process.env, provider } = {}) { + const dryRun = env.DRY_RUN === 'true'; + const skipTransitiveOnly = env.SKIP_TRANSITIVE_ONLY_UPDATES !== 'false'; + + provider = provider ?? createProvider({ env, dryRun }); + + async function handleCreatePullRequest(entry) { + const data = getData(entry); + const dependencies = data.dependencies ?? []; + const files = data['updated-dependency-files'] ?? data.updatedDependencyFiles ?? []; + const packageManager = entry['package-manager'] ?? data['package-manager'] ?? 'dependencies'; + + if (!files.length) { + console.warn('create_pull_request entry had no updated-dependency-files, skipping:', dependencies.map((d) => d.name)); + return; + } + + if (skipTransitiveOnly && !touchesManifestFile(files)) { + console.log( + `Skipping transitive-only update for ${dependencies.map((d) => d.name).join(', ')} ` + + `(only touches ${files.map((f) => f.name).join(', ')}, not the manifest)` + ); + return; + } + + const branch = branchNameFor(packageManager, dependencies); + const title = data['pr-title'] ?? defaultTitle(dependencies); + const body = data['pr-body'] ?? data['commit-message'] ?? defaultBody(dependencies); + const message = data['commit-message'] ?? title; + const destination = await provider.getDefaultBranch(); + + await provider.commitFiles({ branch, baseBranch: destination, message, files }); + await provider.createPullRequest({ branch, title, body: truncateBody(body), destination }); + } + + async function handleUpdatePullRequest(entry) { + const data = getData(entry); + const dependencies = data.dependencies ?? []; + const files = data['updated-dependency-files'] ?? data.updatedDependencyFiles ?? []; + const packageManager = entry['package-manager'] ?? data['package-manager'] ?? 'dependencies'; + const branch = branchNameFor(packageManager, dependencies); + + if (!files.length) { + console.warn('update_pull_request entry had no updated-dependency-files, skipping:', dependencies.map((d) => d.name)); + return; + } + + if (skipTransitiveOnly && !touchesManifestFile(files)) { + console.log( + `Skipping transitive-only update for ${dependencies.map((d) => d.name).join(', ')} ` + + `(only touches ${files.map((f) => f.name).join(', ')}, not the manifest)` + ); + return; + } + + const message = data['commit-message'] ?? `Update ${dependencies.map((d) => d.name).join(', ')}`; + const destination = await provider.getDefaultBranch(); + await provider.commitFiles({ branch, baseBranch: destination, message, files }); + + const pr = await provider.findOpenPr(branch); + if (!pr) { + // No open PR/MR found for this branch (e.g. it was closed manually) - open a fresh one. + await provider.createPullRequest({ + branch, + title: data['pr-title'] ?? defaultTitle(dependencies), + body: truncateBody(data['pr-body'] ?? defaultBody(dependencies)), + destination, + }); + } else { + console.log(`Pushed update to existing PR/MR !${pr.id} (${branch})`); + } + } + + async function handleClosePullRequest(entry) { + const data = getData(entry); + const dependencies = data.dependencies ?? []; + const packageManager = entry['package-manager'] ?? data['package-manager'] ?? 'dependencies'; + const branch = branchNameFor(packageManager, dependencies); + + const pr = await provider.findOpenPr(branch); + if (!pr) { + console.log(`close_pull_request: no open PR/MR found for ${branch}, nothing to do`); + return; + } + await provider.closePullRequest(pr); + } + + async function processOutputFile(outputPath, packageManager) { + if (!existsSync(outputPath)) { + console.log(`No output file at ${outputPath} (likely no updates found), skipping`); + return; + } + const parsed = yaml.load(readFileSync(outputPath, 'utf8')); + const calls = parsed?.output ?? parsed ?? []; + + for (const entry of calls) { + entry['package-manager'] ??= packageManager; + + if (IGNORED_OUTPUT_TYPES.has(entry.type)) { + continue; + } + + try { + switch (entry.type) { + case 'create_pull_request': + await handleCreatePullRequest(entry); + break; + case 'update_pull_request': + await handleUpdatePullRequest(entry); + break; + case 'close_pull_request': + await handleClosePullRequest(entry); + break; + default: + console.log(`Unhandled output type "${entry.type}", skipping`); + } + } catch (err) { + console.error(`Failed to process ${entry.type} entry:`, err.message); + process.exitCode = 1; + } + } + } + + const manifestPath = resolve('.dependabot-run/manifest.json'); + const explicitFiles = argv; + + if (explicitFiles.length) { + for (const file of explicitFiles) { + await processOutputFile(resolve(file), 'dependencies'); + } + return; + } + + if (!existsSync(manifestPath)) { + console.error(`No manifest found at ${manifestPath} and no files passed as arguments.`); + console.error('Run dependabot-run-updates first, or pass output-*.yaml paths directly.'); + process.exit(1); + } + + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + for (const { packageManager, outputPath } of manifest) { + await processOutputFile(outputPath, packageManager); + } +} diff --git a/src/lib/format.mjs b/src/lib/format.mjs new file mode 100644 index 0000000..eb121f2 --- /dev/null +++ b/src/lib/format.mjs @@ -0,0 +1,76 @@ +// Pure, provider-agnostic helpers. No network calls, no process.env reads - +// kept this way deliberately so they're trivial to unit test. + +export function slugify(text) { + return String(text) + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} + +export function branchNameFor(packageManager, dependencies) { + const depSlug = dependencies.map((d) => slugify(d.name ?? d['dependency-name'])).join('-and-'); + return `dependabot/${slugify(packageManager)}/${depSlug}`; +} + +export function defaultTitle(dependencies) { + if (dependencies.length === 1) { + const d = dependencies[0]; + return `Bump ${d.name} from ${d['previous-version']} to ${d.version}`; + } + return `Bump ${dependencies.map((d) => d.name).join(', ')}`; +} + +export function defaultBody(dependencies) { + const lines = dependencies.map( + (d) => `- \`${d.name}\`: ${d['previous-version'] ?? '?'} -> ${d.version ?? '?'}` + ); + return `Dependency updates produced by dependabot CLI:\n\n${lines.join('\n')}`; +} + +// pr-body from dependabot-core can be very large (release notes + changelog + +// full commit list per dependency, sometimes tens of KB for monorepo +// packages like @aws-sdk/*). Bitbucket Cloud rejects PR descriptions above +// ~32KB and GitLab has its own (larger, but still finite) limit, so truncate +// defensively rather than let a provider API call fail outright. +export const MAX_PR_BODY_LENGTH = 30000; +export function truncateBody(body) { + if (!body || body.length <= MAX_PR_BODY_LENGTH) return body; + return `${body.slice(0, MAX_PR_BODY_LENGTH)}\n\n... (truncated, see the original dependency's release notes for the full changelog)`; +} + +// The real dependabot CLI output shape nests the payload under `expect.data`, e.g.: +// - type: create_pull_request +// expect: +// data: +// dependencies: [...] +// updated-dependency-files: [...] +// Fall back to a top-level `data` key too, in case that ever changes. +export function getData(entry) { + return entry?.expect?.data ?? entry?.data ?? {}; +} + +// Output types the CLI emits that don't correspond to a PR action - safe to +// skip quietly rather than logging them as "unhandled". +export const IGNORED_OUTPUT_TYPES = new Set(['update_dependency_list', 'mark_as_processed']); + +// Filenames that count as "the manifest" per ecosystem - an update whose +// updated-dependency-files ONLY touches lockfiles (e.g. package-lock.json, +// yarn.lock) and never one of these was a transitive/indirect dependency +// bump, not something listed directly in package.json. Left unfiltered, +// npm_and_yarn in particular can produce a separate create_pull_request for +// every transitive package in the lockfile - easily 100+ PRs for a small +// project. +export const MANIFEST_FILENAMES = new Set([ + 'package.json', + 'Gemfile', + 'requirements.txt', + 'go.mod', + 'Cargo.toml', + 'composer.json', +]); + +export function touchesManifestFile(files) { + return files.some((f) => MANIFEST_FILENAMES.has(f.name)); +} diff --git a/src/providers/bitbucket.mjs b/src/providers/bitbucket.mjs new file mode 100644 index 0000000..cfef9c3 --- /dev/null +++ b/src/providers/bitbucket.mjs @@ -0,0 +1,164 @@ +// Bitbucket Cloud provider. Implements the common provider interface used by +// src/run-updates.mjs and src/create-prs.mjs: +// getContext() -> { repo, commit, host } +// buildSourceBlock(directory) -> job.source block for job.yaml +// buildGitSourceCredential() -> job.credentials[] entry (read-only) +// getDefaultBranch() +// commitFiles({ branch, baseBranch, message, files }) +// findOpenPr(branch) -> { id, ... } | undefined +// createPullRequest({ branch, title, body, destination }) +// closePullRequest(pr) +// +// `env` and `fetchImpl` are injectable so this can be unit tested without +// touching real env vars or the network. +export function createBitbucketProvider({ env = process.env, fetchImpl = globalThis.fetch, dryRun = false } = {}) { + const workspace = env.BITBUCKET_WORKSPACE; + const repoSlug = env.BITBUCKET_REPO_SLUG; + const commit = env.BITBUCKET_COMMIT; + // Provider-specific env var names take priority; GIT_READ_TOKEN/GIT_WRITE_TOKEN + // are the generic fallbacks shared across providers. + const readToken = env.BITBUCKET_GIT_TOKEN ?? env.GIT_READ_TOKEN; + const writeToken = env.BITBUCKET_PR_TOKEN ?? env.GIT_WRITE_TOKEN; + const username = env.BITBUCKET_USERNAME; + const appPassword = env.BITBUCKET_APP_PASSWORD; + + const API_ROOT = 'https://api.bitbucket.org/2.0'; + const repoPath = `${workspace}/${repoSlug}`; + + function authHeader() { + if (writeToken) return `Bearer ${writeToken}`; + if (username && appPassword) { + return `Basic ${Buffer.from(`${username}:${appPassword}`).toString('base64')}`; + } + throw new Error('Bitbucket: set BITBUCKET_PR_TOKEN (or GIT_WRITE_TOKEN), or BITBUCKET_USERNAME + BITBUCKET_APP_PASSWORD'); + } + + async function apiFetch(path, options = {}) { + const url = path.startsWith('http') ? path : `${API_ROOT}${path}`; + const res = await fetchImpl(url, { + ...options, + headers: { + Authorization: authHeader(), + ...(options.headers ?? {}), + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Bitbucket API ${options.method ?? 'GET'} ${url} -> ${res.status}: ${body}`); + } + return res; + } + + let defaultBranchCache; + + return { + name: 'bitbucket', + requiredContextEnv: ['BITBUCKET_WORKSPACE', 'BITBUCKET_REPO_SLUG', 'BITBUCKET_COMMIT'], + + getContext() { + return { repo: repoPath, commit, host: 'bitbucket.org' }; + }, + + buildSourceBlock(directory) { + return { provider: 'bitbucket', repo: repoPath, directory, commit }; + }, + + buildGitSourceCredential() { + if (!readToken) throw new Error('Bitbucket: set BITBUCKET_GIT_TOKEN (or GIT_READ_TOKEN)'); + return { + type: 'git_source', + host: 'bitbucket.org', + username: 'x-token-auth', + password: readToken, + token: readToken, + }; + }, + + async getDefaultBranch() { + if (defaultBranchCache) return defaultBranchCache; + if (dryRun) { + // Avoid a real API call so DRY_RUN=true works fully offline. + defaultBranchCache = ''; + return defaultBranchCache; + } + const res = await apiFetch(`/repositories/${repoPath}`); + const data = await res.json(); + defaultBranchCache = data.mainbranch?.name ?? 'main'; + return defaultBranchCache; + }, + + // Writes/deletes files directly to a branch using Bitbucket's "src" + // endpoint, creating the branch if it doesn't already exist. Avoids + // needing a local git clone. + async commitFiles({ branch, baseBranch, message, files }) { + if (dryRun) { + console.log(`[dry-run] would commit ${files.length} file(s) to ${branch} (base: ${baseBranch})`); + return; + } + + const form = new FormData(); + form.append('branch', branch); + form.append('message', message); + form.append('author', 'dependabot '); + + for (const file of files) { + const path = [file.directory, file.name].filter(Boolean).join('/').replace(/^\/+/, ''); + if (file.deleted || file.operation === 'delete') { + form.append('files', path); + continue; + } + const content = + file.content_encoding === 'base64' + ? Buffer.from(file.content, 'base64') + : Buffer.from(file.content ?? '', 'utf8'); + form.append(path, new Blob([content])); + } + + await apiFetch(`/repositories/${repoPath}/src`, { method: 'POST', body: form }); + }, + + async findOpenPr(branch) { + const q = encodeURIComponent(`state="OPEN" AND source.branch.name="${branch}"`); + const res = await apiFetch(`/repositories/${repoPath}/pullrequests?q=${q}`); + const data = await res.json(); + return data.values?.[0]; + }, + + async createPullRequest({ branch, title, body, destination }) { + if (dryRun) { + console.log(`[dry-run] would open PR "${title}" ${branch} -> ${destination}`); + return; + } + + const existing = await this.findOpenPr(branch); + if (existing) { + console.log(`PR already open for ${branch}: !${existing.id} - leaving as-is (new commit was still pushed)`); + return existing; + } + + const res = await apiFetch(`/repositories/${repoPath}/pullrequests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + description: body, + source: { branch: { name: branch } }, + destination: { branch: { name: destination } }, + close_source_branch: true, + }), + }); + const pr = await res.json(); + console.log(`Opened PR !${pr.id}: ${title}`); + return pr; + }, + + async closePullRequest(pr) { + if (dryRun) { + console.log(`[dry-run] would decline PR !${pr.id}`); + return; + } + await apiFetch(`/repositories/${repoPath}/pullrequests/${pr.id}/decline`, { method: 'POST' }); + console.log(`Declined PR !${pr.id}`); + }, + }; +} diff --git a/src/providers/gitlab.mjs b/src/providers/gitlab.mjs new file mode 100644 index 0000000..37a0d61 --- /dev/null +++ b/src/providers/gitlab.mjs @@ -0,0 +1,182 @@ +// GitLab provider (GitLab.com or self-hosted). Implements the same interface +// as src/providers/bitbucket.mjs - see that file for the interface contract. +// +// Confirmed against the dependabot CLI's own gitlab job-description examples +// (see https://github.com/dependabot/cli/issues/193 and the CLI README): +// source: +// provider: gitlab +// hostname: gitlab.com +// api-endpoint: https://gitlab.com/api/v4 +// repo: group/project +// directory: / +// credentials: +// - type: git_source +// host: gitlab.com +// password: +// +// GitLab CI predefined variables (https://docs.gitlab.com/ee/ci/variables/predefined_variables.html) +// give us the repo context for free when running inside a GitLab CI job: +// CI_PROJECT_ID, CI_PROJECT_PATH, CI_COMMIT_SHA, CI_SERVER_HOST, CI_API_V4_URL, +// CI_DEFAULT_BRANCH. +export function createGitlabProvider({ env = process.env, fetchImpl = globalThis.fetch, dryRun = false } = {}) { + const host = env.CI_SERVER_HOST ?? env.GITLAB_HOST ?? 'gitlab.com'; + const apiRoot = env.CI_API_V4_URL ?? `https://${host}/api/v4`; + const projectId = env.CI_PROJECT_ID ?? env.GITLAB_PROJECT_ID; + const projectPath = env.CI_PROJECT_PATH ?? env.GITLAB_PROJECT_PATH; + const commit = env.CI_COMMIT_SHA ?? env.GITLAB_COMMIT; + // Provider-specific env var names take priority; GIT_READ_TOKEN/GIT_WRITE_TOKEN + // are the generic fallbacks shared across providers. A single "api"-scoped + // token can usually serve as both, so GITLAB_TOKEN alone is enough for most setups. + const readToken = env.GITLAB_TOKEN ?? env.GIT_READ_TOKEN; + const writeToken = env.GITLAB_PR_TOKEN ?? env.GIT_WRITE_TOKEN ?? readToken; + + // Project ID is preferred (no URL-encoding needed); fall back to the + // URL-encoded path if only that was supplied. + const projectIdentifier = projectId ?? (projectPath ? encodeURIComponent(projectPath) : undefined); + + function authHeaders() { + if (!writeToken) throw new Error('GitLab: set GITLAB_PR_TOKEN (or GIT_WRITE_TOKEN)'); + return { 'PRIVATE-TOKEN': writeToken }; + } + + async function apiFetch(path, options = {}) { + const url = path.startsWith('http') ? path : `${apiRoot}${path}`; + const res = await fetchImpl(url, { + ...options, + headers: { + ...authHeaders(), + ...(options.headers ?? {}), + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`GitLab API ${options.method ?? 'GET'} ${url} -> ${res.status}: ${body}`); + } + return res; + } + + let defaultBranchCache = env.CI_DEFAULT_BRANCH; + + return { + name: 'gitlab', + requiredContextEnv: ['CI_PROJECT_PATH', 'CI_COMMIT_SHA'], + + getContext() { + return { repo: projectPath, commit, host }; + }, + + buildSourceBlock(directory) { + return { + provider: 'gitlab', + hostname: host, + 'api-endpoint': apiRoot, + repo: projectPath, + directory, + commit, + }; + }, + + buildGitSourceCredential() { + if (!readToken) throw new Error('GitLab: set GITLAB_TOKEN (or GIT_READ_TOKEN)'); + return { type: 'git_source', host, password: readToken, token: readToken }; + }, + + async getDefaultBranch() { + if (defaultBranchCache) return defaultBranchCache; + if (dryRun) { + defaultBranchCache = ''; + return defaultBranchCache; + } + const res = await apiFetch(`/projects/${projectIdentifier}`); + const data = await res.json(); + defaultBranchCache = data.default_branch ?? 'main'; + return defaultBranchCache; + }, + + // Creates the branch (if needed) and writes/deletes files on it in one + // commit via the Commits API. No local git clone needed. + async commitFiles({ branch, baseBranch, message, files }) { + if (dryRun) { + console.log(`[dry-run] would commit ${files.length} file(s) to ${branch} (base: ${baseBranch})`); + return; + } + + // Create the branch from baseBranch; ignore failure if it already + // exists (GitLab returns 400 in that case). + await apiFetch(`/projects/${projectIdentifier}/repository/branches`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch, ref: baseBranch }), + }).catch(() => {}); + + const actions = files.map((file) => { + const path = [file.directory, file.name].filter(Boolean).join('/').replace(/^\/+/, ''); + if (file.deleted || file.operation === 'delete') { + return { action: 'delete', file_path: path }; + } + return { + action: 'update', + file_path: path, + content: file.content ?? '', + encoding: file.content_encoding === 'base64' ? 'base64' : 'text', + }; + }); + + await apiFetch(`/projects/${projectIdentifier}/repository/commits`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch, commit_message: message, actions }), + }); + }, + + async findOpenPr(branch) { + const res = await apiFetch( + `/projects/${projectIdentifier}/merge_requests?source_branch=${encodeURIComponent(branch)}&state=opened` + ); + const data = await res.json(); + const mr = data[0]; + return mr ? { ...mr, id: mr.iid } : undefined; + }, + + async createPullRequest({ branch, title, body, destination }) { + if (dryRun) { + console.log(`[dry-run] would open MR "${title}" ${branch} -> ${destination}`); + return; + } + + const existing = await this.findOpenPr(branch); + if (existing) { + console.log(`MR already open for ${branch}: !${existing.id} - leaving as-is (new commit was still pushed)`); + return existing; + } + + const res = await apiFetch(`/projects/${projectIdentifier}/merge_requests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source_branch: branch, + target_branch: destination, + title, + description: body, + remove_source_branch: true, + }), + }); + const mr = await res.json(); + console.log(`Opened MR !${mr.iid}: ${title}`); + return { ...mr, id: mr.iid }; + }, + + async closePullRequest(pr) { + if (dryRun) { + console.log(`[dry-run] would close MR !${pr.id}`); + return; + } + await apiFetch(`/projects/${projectIdentifier}/merge_requests/${pr.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state_event: 'close' }), + }); + console.log(`Closed MR !${pr.id}`); + }, + }; +} diff --git a/src/providers/index.mjs b/src/providers/index.mjs new file mode 100644 index 0000000..d2a03f0 --- /dev/null +++ b/src/providers/index.mjs @@ -0,0 +1,29 @@ +import { createBitbucketProvider } from './bitbucket.mjs'; +import { createGitlabProvider } from './gitlab.mjs'; + +// Auto-detects which CI system we're running in from the env vars that +// system provides automatically, so most repos never need to set +// GIT_PROVIDER explicitly. Explicit GIT_PROVIDER always wins. +export function detectProviderName(env = process.env) { + if (env.GIT_PROVIDER) return env.GIT_PROVIDER; + if (env.BITBUCKET_WORKSPACE) return 'bitbucket'; + if (env.GITLAB_CI || env.CI_PROJECT_ID) return 'gitlab'; + return undefined; +} + +const FACTORIES = { + bitbucket: createBitbucketProvider, + gitlab: createGitlabProvider, +}; + +export function createProvider({ env = process.env, fetchImpl = globalThis.fetch, dryRun = false, name } = {}) { + const providerName = name ?? detectProviderName(env); + const factory = providerName && FACTORIES[providerName]; + if (!factory) { + throw new Error( + `Unknown or undetected git provider "${providerName}". ` + + `Set GIT_PROVIDER to one of: ${Object.keys(FACTORIES).join(', ')}.` + ); + } + return factory({ env, fetchImpl, dryRun }); +} diff --git a/src/run-updates.mjs b/src/run-updates.mjs index f45dc3c..1e93b33 100644 --- a/src/run-updates.mjs +++ b/src/run-updates.mjs @@ -1,12 +1,14 @@ // Runs `dependabot update` once per entry in ./dependabot-config.json // (resolved relative to the current working directory, i.e. the consuming -// repo) against a Bitbucket Cloud repo, writing one output-N.yaml per entry. +// repo) against whichever git provider is detected/configured, writing one +// output-N.yaml per entry. +// +// Provider is auto-detected from CI env vars (or set explicitly via +// GIT_PROVIDER=bitbucket|gitlab). See src/providers/*.mjs for the env vars +// each provider reads for repo context and tokens. // -// Required env vars: -// BITBUCKET_WORKSPACE, BITBUCKET_REPO_SLUG, BITBUCKET_COMMIT (provided by Bitbucket Pipelines) -// BITBUCKET_GIT_TOKEN - Bitbucket access token used to read the repo (git_source credential) -// NPM_TOKEN - npm token for private npmjs.org packages/scopes (optional) // Optional env vars: +// NPM_TOKEN - npm token for private npmjs.org packages/scopes // DEPENDABOT_BIN - path to the dependabot binary (default: "dependabot" on PATH) // DEPENDABOT_UPDATER_IMAGE, DEPENDABOT_PROXY_IMAGE // DEPENDABOT_CONFIG_PATH - override the path to dependabot-config.json (default: ./dependabot-config.json) @@ -15,6 +17,7 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { resolve } from 'node:path'; import { createRequire } from 'node:module'; +import { createProvider } from './providers/index.mjs'; // js-yaml is CommonJS-only; loading it via createRequire avoids ESM/CJS // default-export interop issues that can crop up depending on the local @@ -22,31 +25,22 @@ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const yaml = require('js-yaml'); -function requireEnv(name, value) { - if (!value) { - console.error(`Missing required env var: ${name}`); +export async function runUpdates({ env = process.env, provider, execFileSyncImpl = execFileSync } = {}) { + provider = provider ?? createProvider({ env }); + + const missing = provider.requiredContextEnv.filter((name) => !env[name]); + if (missing.length) { + console.error(`Missing required env var(s) for provider "${provider.name}": ${missing.join(', ')}`); process.exit(1); } - return value; -} -export async function runUpdates() { const { - BITBUCKET_WORKSPACE, - BITBUCKET_REPO_SLUG, - BITBUCKET_COMMIT, - BITBUCKET_GIT_TOKEN, NPM_TOKEN, DEPENDABOT_BIN = 'dependabot', DEPENDABOT_UPDATER_IMAGE, DEPENDABOT_PROXY_IMAGE, DEPENDABOT_CONFIG_PATH = 'dependabot-config.json', - } = process.env; - - requireEnv('BITBUCKET_WORKSPACE', BITBUCKET_WORKSPACE); - requireEnv('BITBUCKET_REPO_SLUG', BITBUCKET_REPO_SLUG); - requireEnv('BITBUCKET_COMMIT', BITBUCKET_COMMIT); - requireEnv('BITBUCKET_GIT_TOKEN', BITBUCKET_GIT_TOKEN); + } = env; const workDir = resolve('.dependabot-run'); mkdirSync(workDir, { recursive: true }); @@ -59,17 +53,10 @@ export async function runUpdates() { const packageManager = entry['package-manager']; const directory = entry.directory ?? '/'; - const credentials = [ - { - type: 'git_source', - host: 'bitbucket.org', - username: 'x-token-auth', - password: BITBUCKET_GIT_TOKEN, - token: BITBUCKET_GIT_TOKEN, - }, - ]; + const credentials = [provider.buildGitSourceCredential()]; - // npm_and_yarn / npm private packages on npmjs.org + // npm_and_yarn / npm private packages on npmjs.org - provider-agnostic, + // dependabot-core supports this credential type regardless of git host. if (NPM_TOKEN) { credentials.push({ type: 'npm_registry', @@ -82,12 +69,7 @@ export async function runUpdates() { job: { 'package-manager': packageManager, 'allowed-updates': [{ 'update-type': 'all' }], - source: { - provider: 'bitbucket', - repo: `${BITBUCKET_WORKSPACE}/${BITBUCKET_REPO_SLUG}`, - directory, - commit: BITBUCKET_COMMIT, - }, + source: provider.buildSourceBlock(directory), }, credentials, }; @@ -100,14 +82,14 @@ export async function runUpdates() { if (DEPENDABOT_UPDATER_IMAGE) args.push('--updater-image', DEPENDABOT_UPDATER_IMAGE); if (DEPENDABOT_PROXY_IMAGE) args.push('--proxy-image', DEPENDABOT_PROXY_IMAGE); - console.log(`\n=== Running update: ${packageManager} @ ${directory} ===`); + console.log(`\n=== Running update: ${packageManager} @ ${directory} (${provider.name}) ===`); try { - execFileSync(DEPENDABOT_BIN, args, { stdio: 'inherit' }); + execFileSyncImpl(DEPENDABOT_BIN, args, { stdio: 'inherit' }); } catch (err) { // A non-zero exit here usually just means "no updates" in some CLI // versions, but treat genuine failures as fatal. Surface either way and - // let create-bitbucket-prs decide there's nothing to do if the output - // file wasn't produced. + // let create-prs decide there's nothing to do if the output file + // wasn't produced. console.error(`dependabot update exited with an error for ${packageManager} @ ${directory}:`, err.message); } @@ -116,4 +98,6 @@ export async function runUpdates() { writeFileSync(resolve(workDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); console.log(`\nWrote manifest with ${manifest.length} entr${manifest.length === 1 ? 'y' : 'ies'} to ${workDir}/manifest.json`); + + return manifest; } diff --git a/templates/bitbucket-pipelines.yml b/templates/bitbucket-pipelines.yml index 9d7438b..9c781a1 100644 --- a/templates/bitbucket-pipelines.yml +++ b/templates/bitbucket-pipelines.yml @@ -1,12 +1,15 @@ -# Copy this into any repo that should get dependency-update PRs. +# Copy this into any Bitbucket repo that should get dependency-update PRs. # The only per-repo files needed are this one and dependabot-config.json - -# everything else lives in the published dependabot-for-git -# package, so there's nothing else to keep in sync across repos. +# everything else lives in the published dependabot-for-git package, so +# there's nothing else to keep in sync across repos. # # Repository variables to set (Repo settings -> Pipelines -> Repository variables): # BITBUCKET_GIT_TOKEN - Bitbucket access token, repo READ scope # NPM_TOKEN - npm token for private npmjs.org packages/scopes (optional) # BITBUCKET_PR_TOKEN - Bitbucket access token, repo WRITE + PR scopes +# +# Provider is auto-detected from BITBUCKET_WORKSPACE (set automatically by +# Bitbucket Pipelines) - no GIT_PROVIDER needed here. image: node:22 diff --git a/templates/gitlab-ci.yml b/templates/gitlab-ci.yml new file mode 100644 index 0000000..7458905 --- /dev/null +++ b/templates/gitlab-ci.yml @@ -0,0 +1,69 @@ +# Copy this into any GitLab repo that should get dependency-update MRs. +# The only per-repo files needed are this one and dependabot-config.json - +# everything else lives in the published dependabot-for-git package. +# +# CI/CD variables to set (Settings -> CI/CD -> Variables, masked + protected): +# GITLAB_TOKEN - access token (project or personal), read_repository scope +# GITLAB_PR_TOKEN - access token, api scope (needed to write files + open MRs) +# NPM_TOKEN - npm token for private npmjs.org packages/scopes (optional) +# +# Provider is auto-detected from CI_PROJECT_ID (set automatically by GitLab +# CI) - no GIT_PROVIDER needed here. Repo context (CI_PROJECT_PATH, +# CI_COMMIT_SHA, CI_SERVER_HOST, CI_API_V4_URL, CI_DEFAULT_BRANCH) all come +# from GitLab's predefined variables automatically too. +# +# Not run on every push - trigger manually ("Run pipeline" in the UI) or set +# up a schedule under Build -> Pipeline schedules pointing at this file. + +stages: + - build-cli + - run-updates + - create-prs + +workflow: + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" + - if: $CI_PIPELINE_SOURCE == "web" + +build-cli: + stage: build-cli + image: golang:1.26 + script: + - git clone --depth 1 https://github.com/dependabot/cli.git /tmp/dependabot-cli + - cd /tmp/dependabot-cli + - go build -o dependabot ./cmd/dependabot + - mkdir -p "$CI_PROJECT_DIR/.bin" + - mv dependabot "$CI_PROJECT_DIR/.bin/dependabot" + artifacts: + paths: + - .bin/ + +run-updates: + stage: run-updates + image: node:22 + needs: ["build-cli"] + services: + - docker:24-dind + variables: + # Simplest working dind setup for this pipeline; consider TLS-secured + # dind (the GitLab-recommended default) if your runner config requires it. + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + before_script: + - apt-get update -qq && apt-get install -y --no-install-recommends docker.io + script: + - export PATH="$CI_PROJECT_DIR/.bin:$PATH" + - chmod +x .bin/dependabot + - npm install -g dependabot-for-git + - dependabot-run-updates + artifacts: + paths: + - .dependabot-run/ + +create-prs: + stage: create-prs + image: node:22 + needs: ["run-updates"] + script: + - npm install -g dependabot-for-git + - dependabot-create-prs diff --git a/test/create-prs.test.mjs b/test/create-prs.test.mjs new file mode 100644 index 0000000..7dc808b --- /dev/null +++ b/test/create-prs.test.mjs @@ -0,0 +1,239 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import { createPullRequests } from '../src/create-prs.mjs'; + +const require = createRequire(import.meta.url); +const yaml = require('js-yaml'); + +function makeFakeProvider(overrides = {}) { + return { + name: 'fake', + getDefaultBranch: sinon.stub().resolves('main'), + commitFiles: sinon.stub().resolves(), + findOpenPr: sinon.stub().resolves(undefined), + createPullRequest: sinon.stub().resolves({ id: 1 }), + closePullRequest: sinon.stub().resolves(), + ...overrides, + }; +} + +// Runs createPullRequests with cwd temporarily pointed at a scratch dir so +// the relative path lookups (.dependabot-run/manifest.json etc.) are isolated +// per test. +async function withScratchDir(fn) { + const dir = mkdtempSync(join(tmpdir(), 'dependabot-for-git-test-')); + const originalCwd = process.cwd(); + process.chdir(dir); + try { + await fn(dir); + } finally { + process.chdir(originalCwd); + rmSync(dir, { recursive: true, force: true }); + } +} + +function writeOutputFile(dir, filename, outputEntries) { + const path = join(dir, filename); + writeFileSync(path, yaml.dump({ output: outputEntries })); + return path; +} + +const DIRECT_DEP_ENTRY = { + type: 'create_pull_request', + expect: { + data: { + 'base-commit-sha': 'abc', + dependencies: [{ name: 'lodash', 'previous-version': '4.17.20', version: '4.17.21' }], + 'updated-dependency-files': [ + { name: 'package.json', directory: '/', content: '{}', operation: 'update' }, + { name: 'package-lock.json', directory: '/', content: '{}', operation: 'update' }, + ], + }, + }, +}; + +const TRANSITIVE_ONLY_ENTRY = { + type: 'create_pull_request', + expect: { + data: { + 'base-commit-sha': 'abc', + dependencies: [{ name: 'some-transitive-dep', 'previous-version': '1.0.0', version: '1.0.1' }], + 'updated-dependency-files': [{ name: 'package-lock.json', directory: '/', content: '{}', operation: 'update' }], + }, + }, +}; + +describe('create-prs orchestration', () => { + afterEach(() => { + delete process.env.DRY_RUN; + delete process.env.SKIP_TRANSITIVE_ONLY_UPDATES; + }); + + it('opens a PR for a direct dependency update', async () => { + await withScratchDir(async (dir) => { + const outputPath = writeOutputFile(dir, 'output-0.yaml', [DIRECT_DEP_ENTRY]); + const provider = makeFakeProvider(); + + await createPullRequests([outputPath], { env: {}, provider }); + + expect(provider.commitFiles.calledOnce).to.equal(true); + expect(provider.createPullRequest.calledOnce).to.equal(true); + const call = provider.createPullRequest.firstCall.args[0]; + expect(call.branch).to.equal('dependabot/dependencies/lodash'); + expect(call.title).to.equal('Bump lodash from 4.17.20 to 4.17.21'); + }); + }); + + it('skips a transitive-only dependency update by default', async () => { + await withScratchDir(async (dir) => { + const outputPath = writeOutputFile(dir, 'output-0.yaml', [TRANSITIVE_ONLY_ENTRY]); + const provider = makeFakeProvider(); + + await createPullRequests([outputPath], { env: {}, provider }); + + expect(provider.commitFiles.called).to.equal(false); + expect(provider.createPullRequest.called).to.equal(false); + }); + }); + + it('processes transitive-only updates when SKIP_TRANSITIVE_ONLY_UPDATES=false', async () => { + await withScratchDir(async (dir) => { + const outputPath = writeOutputFile(dir, 'output-0.yaml', [TRANSITIVE_ONLY_ENTRY]); + const provider = makeFakeProvider(); + + await createPullRequests([outputPath], { env: { SKIP_TRANSITIVE_ONLY_UPDATES: 'false' }, provider }); + + expect(provider.commitFiles.calledOnce).to.equal(true); + expect(provider.createPullRequest.calledOnce).to.equal(true); + }); + }); + + it('handles a mixed file: one direct update opens a PR, one transitive update is skipped', async () => { + await withScratchDir(async (dir) => { + const outputPath = writeOutputFile(dir, 'output-0.yaml', [DIRECT_DEP_ENTRY, TRANSITIVE_ONLY_ENTRY]); + const provider = makeFakeProvider(); + + await createPullRequests([outputPath], { env: {}, provider }); + + expect(provider.createPullRequest.calledOnce).to.equal(true); + }); + }); + + it('silently ignores update_dependency_list and mark_as_processed entries', async () => { + await withScratchDir(async (dir) => { + const outputPath = writeOutputFile(dir, 'output-0.yaml', [ + { type: 'update_dependency_list', expect: { data: { dependencies: [] } } }, + { type: 'mark_as_processed', expect: { data: {} } }, + ]); + const provider = makeFakeProvider(); + + await createPullRequests([outputPath], { env: {}, provider }); + + expect(provider.commitFiles.called).to.equal(false); + expect(provider.createPullRequest.called).to.equal(false); + expect(provider.closePullRequest.called).to.equal(false); + }); + }); + + it('pushes a new commit to the existing PR/MR on update_pull_request without opening a duplicate', async () => { + await withScratchDir(async (dir) => { + const outputPath = writeOutputFile(dir, 'output-0.yaml', [ + { ...DIRECT_DEP_ENTRY, type: 'update_pull_request' }, + ]); + const provider = makeFakeProvider({ findOpenPr: sinon.stub().resolves({ id: 5 }) }); + + await createPullRequests([outputPath], { env: {}, provider }); + + expect(provider.commitFiles.calledOnce).to.equal(true); + expect(provider.createPullRequest.called).to.equal(false); // already open, don't duplicate + }); + }); + + it('opens a fresh PR/MR on update_pull_request if the original was closed manually', async () => { + await withScratchDir(async (dir) => { + const outputPath = writeOutputFile(dir, 'output-0.yaml', [ + { ...DIRECT_DEP_ENTRY, type: 'update_pull_request' }, + ]); + const provider = makeFakeProvider({ findOpenPr: sinon.stub().resolves(undefined) }); + + await createPullRequests([outputPath], { env: {}, provider }); + + expect(provider.createPullRequest.calledOnce).to.equal(true); + }); + }); + + it('closes the matching PR/MR on close_pull_request', async () => { + await withScratchDir(async (dir) => { + const outputPath = writeOutputFile(dir, 'output-0.yaml', [ + { ...DIRECT_DEP_ENTRY, type: 'close_pull_request' }, + ]); + const provider = makeFakeProvider({ findOpenPr: sinon.stub().resolves({ id: 9 }) }); + + await createPullRequests([outputPath], { env: {}, provider }); + + expect(provider.closePullRequest.calledOnceWith({ id: 9 })).to.equal(true); + }); + }); + + it('does nothing on close_pull_request when no matching PR/MR is open', async () => { + await withScratchDir(async (dir) => { + const outputPath = writeOutputFile(dir, 'output-0.yaml', [ + { ...DIRECT_DEP_ENTRY, type: 'close_pull_request' }, + ]); + const provider = makeFakeProvider({ findOpenPr: sinon.stub().resolves(undefined) }); + + await createPullRequests([outputPath], { env: {}, provider }); + + expect(provider.closePullRequest.called).to.equal(false); + }); + }); + + it('continues processing later entries when one entry throws', async () => { + await withScratchDir(async (dir) => { + const outputPath = writeOutputFile(dir, 'output-0.yaml', [DIRECT_DEP_ENTRY, DIRECT_DEP_ENTRY]); + const provider = makeFakeProvider(); + provider.commitFiles.onFirstCall().rejects(new Error('boom')); + + await createPullRequests([outputPath], { env: {}, provider }); + + expect(provider.commitFiles.callCount).to.equal(2); // second entry still processed + expect(process.exitCode).to.equal(1); + process.exitCode = 0; // reset for subsequent tests + }); + }); + + it('reads from .dependabot-run/manifest.json when no explicit files are passed', async () => { + await withScratchDir(async (dir) => { + mkdirSync(join(dir, '.dependabot-run')); + const outputPath = writeOutputFile(dir, join('.dependabot-run', 'output-0.yaml'), [DIRECT_DEP_ENTRY]); + writeFileSync( + join(dir, '.dependabot-run', 'manifest.json'), + JSON.stringify([{ packageManager: 'npm_and_yarn', outputPath }]) + ); + const provider = makeFakeProvider(); + + await createPullRequests([], { env: {}, provider }); + + expect(provider.createPullRequest.calledOnce).to.equal(true); + }); + }); + + it('does not throw when an output file referenced by the manifest is missing (no updates found)', async () => { + await withScratchDir(async (dir) => { + mkdirSync(join(dir, '.dependabot-run')); + writeFileSync( + join(dir, '.dependabot-run', 'manifest.json'), + JSON.stringify([{ packageManager: 'npm_and_yarn', outputPath: join(dir, '.dependabot-run', 'missing.yaml') }]) + ); + const provider = makeFakeProvider(); + + await createPullRequests([], { env: {}, provider }); + + expect(provider.createPullRequest.called).to.equal(false); + }); + }); +}); diff --git a/test/lib/format.test.mjs b/test/lib/format.test.mjs new file mode 100644 index 0000000..c973356 --- /dev/null +++ b/test/lib/format.test.mjs @@ -0,0 +1,156 @@ +import { expect } from 'chai'; +import { + slugify, + branchNameFor, + defaultTitle, + defaultBody, + truncateBody, + MAX_PR_BODY_LENGTH, + getData, + touchesManifestFile, + IGNORED_OUTPUT_TYPES, + MANIFEST_FILENAMES, +} from '../../src/lib/format.mjs'; + +describe('lib/format', () => { + describe('slugify', () => { + it('lowercases and replaces disallowed characters with hyphens', () => { + expect(slugify('@aws-sdk/client-dynamodb')).to.equal('aws-sdk-client-dynamodb'); + }); + + it('strips leading/trailing hyphens produced by disallowed chars', () => { + expect(slugify('!!!hello!!!')).to.equal('hello'); + }); + + it('truncates to 60 characters', () => { + const long = 'a'.repeat(100); + expect(slugify(long)).to.have.length(60); + }); + + it('coerces non-string input to a string first', () => { + expect(slugify(123)).to.equal('123'); + }); + }); + + describe('branchNameFor', () => { + it('builds a branch name from package manager and a single dependency', () => { + const branch = branchNameFor('npm_and_yarn', [{ name: '@aws-sdk/client-dynamodb' }]); + expect(branch).to.equal('dependabot/npm_and_yarn/aws-sdk-client-dynamodb'); + }); + + it('joins multiple dependencies with "-and-"', () => { + const branch = branchNameFor('npm_and_yarn', [{ name: 'lodash' }, { name: 'date-fns' }]); + expect(branch).to.equal('dependabot/npm_and_yarn/lodash-and-date-fns'); + }); + + it('falls back to dependency-name key when name is absent', () => { + const branch = branchNameFor('npm_and_yarn', [{ 'dependency-name': 'lodash' }]); + expect(branch).to.equal('dependabot/npm_and_yarn/lodash'); + }); + }); + + describe('defaultTitle', () => { + it('formats a single-dependency bump', () => { + const title = defaultTitle([{ name: 'lodash', 'previous-version': '4.17.20', version: '4.17.21' }]); + expect(title).to.equal('Bump lodash from 4.17.20 to 4.17.21'); + }); + + it('lists names for multiple dependencies', () => { + const title = defaultTitle([{ name: 'lodash' }, { name: 'date-fns' }]); + expect(title).to.equal('Bump lodash, date-fns'); + }); + }); + + describe('defaultBody', () => { + it('lists each dependency with its version bump', () => { + const body = defaultBody([{ name: 'lodash', 'previous-version': '4.17.20', version: '4.17.21' }]); + expect(body).to.include('`lodash`: 4.17.20 -> 4.17.21'); + }); + + it('uses "?" for missing version info', () => { + const body = defaultBody([{ name: 'lodash' }]); + expect(body).to.include('`lodash`: ? -> ?'); + }); + }); + + describe('truncateBody', () => { + it('leaves short bodies untouched', () => { + expect(truncateBody('short body')).to.equal('short body'); + }); + + it('passes through null/undefined unchanged', () => { + expect(truncateBody(undefined)).to.equal(undefined); + expect(truncateBody(null)).to.equal(null); + }); + + it('truncates bodies over the max length and appends a note', () => { + const big = 'x'.repeat(MAX_PR_BODY_LENGTH + 10000); + const result = truncateBody(big); + expect(result.length).to.be.lessThan(big.length); + expect(result).to.include('... (truncated'); + expect(result.startsWith('x'.repeat(100))).to.equal(true); + }); + + it('truncates to exactly MAX_PR_BODY_LENGTH before appending the note', () => { + const big = 'y'.repeat(MAX_PR_BODY_LENGTH + 1); + const result = truncateBody(big); + const notePrefix = '\n\n... (truncated'; + const noteIndex = result.indexOf(notePrefix); + expect(noteIndex).to.equal(MAX_PR_BODY_LENGTH); + }); + }); + + describe('getData', () => { + it('reads the real nested expect.data shape', () => { + const entry = { type: 'create_pull_request', expect: { data: { dependencies: ['x'] } } }; + expect(getData(entry)).to.deep.equal({ dependencies: ['x'] }); + }); + + it('falls back to a top-level data key', () => { + const entry = { type: 'create_pull_request', data: { dependencies: ['y'] } }; + expect(getData(entry)).to.deep.equal({ dependencies: ['y'] }); + }); + + it('returns an empty object when neither shape is present', () => { + expect(getData({ type: 'create_pull_request' })).to.deep.equal({}); + }); + + it('handles undefined/null entries without throwing', () => { + expect(getData(undefined)).to.deep.equal({}); + expect(getData(null)).to.deep.equal({}); + }); + }); + + describe('IGNORED_OUTPUT_TYPES', () => { + it('includes the known non-PR output types', () => { + expect(IGNORED_OUTPUT_TYPES.has('update_dependency_list')).to.equal(true); + expect(IGNORED_OUTPUT_TYPES.has('mark_as_processed')).to.equal(true); + }); + + it('does not include PR-action types', () => { + expect(IGNORED_OUTPUT_TYPES.has('create_pull_request')).to.equal(false); + }); + }); + + describe('touchesManifestFile / MANIFEST_FILENAMES', () => { + it('returns true when a manifest file is present', () => { + const files = [{ name: 'package.json' }, { name: 'package-lock.json' }]; + expect(touchesManifestFile(files)).to.equal(true); + }); + + it('returns false when only lockfiles are present (transitive-only update)', () => { + const files = [{ name: 'package-lock.json' }]; + expect(touchesManifestFile(files)).to.equal(false); + }); + + it('returns false for an empty file list', () => { + expect(touchesManifestFile([])).to.equal(false); + }); + + it('recognises manifests for non-npm ecosystems', () => { + expect(MANIFEST_FILENAMES.has('Gemfile')).to.equal(true); + expect(MANIFEST_FILENAMES.has('go.mod')).to.equal(true); + expect(MANIFEST_FILENAMES.has('Cargo.toml')).to.equal(true); + }); + }); +}); diff --git a/test/providers/bitbucket.test.mjs b/test/providers/bitbucket.test.mjs new file mode 100644 index 0000000..9e1fd51 --- /dev/null +++ b/test/providers/bitbucket.test.mjs @@ -0,0 +1,237 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { createBitbucketProvider } from '../../src/providers/bitbucket.mjs'; + +function jsonResponse(body, { ok = true, status = 200 } = {}) { + return { + ok, + status, + json: async () => body, + text: async () => JSON.stringify(body), + }; +} + +const BASE_ENV = { + BITBUCKET_WORKSPACE: 'my-workspace', + BITBUCKET_REPO_SLUG: 'my-repo', + BITBUCKET_COMMIT: 'abc123', + BITBUCKET_GIT_TOKEN: 'read-token', + BITBUCKET_PR_TOKEN: 'write-token', +}; + +describe('providers/bitbucket', () => { + describe('static/context methods (no network)', () => { + it('reports the provider name and required env vars', () => { + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl: sinon.stub() }); + expect(provider.name).to.equal('bitbucket'); + expect(provider.requiredContextEnv).to.deep.equal([ + 'BITBUCKET_WORKSPACE', + 'BITBUCKET_REPO_SLUG', + 'BITBUCKET_COMMIT', + ]); + }); + + it('builds repo context from workspace/slug/commit', () => { + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl: sinon.stub() }); + expect(provider.getContext()).to.deep.equal({ + repo: 'my-workspace/my-repo', + commit: 'abc123', + host: 'bitbucket.org', + }); + }); + + it('builds a job.yaml source block', () => { + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl: sinon.stub() }); + expect(provider.buildSourceBlock('/packages/api')).to.deep.equal({ + provider: 'bitbucket', + repo: 'my-workspace/my-repo', + directory: '/packages/api', + commit: 'abc123', + }); + }); + + it('builds a git_source credential from BITBUCKET_GIT_TOKEN', () => { + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl: sinon.stub() }); + expect(provider.buildGitSourceCredential()).to.deep.equal({ + type: 'git_source', + host: 'bitbucket.org', + username: 'x-token-auth', + password: 'read-token', + token: 'read-token', + }); + }); + + it('falls back to the generic GIT_READ_TOKEN when BITBUCKET_GIT_TOKEN is unset', () => { + const env = { ...BASE_ENV, BITBUCKET_GIT_TOKEN: undefined, GIT_READ_TOKEN: 'generic-read' }; + const provider = createBitbucketProvider({ env, fetchImpl: sinon.stub() }); + expect(provider.buildGitSourceCredential().token).to.equal('generic-read'); + }); + + it('throws when no read token is configured at all', () => { + const env = { ...BASE_ENV, BITBUCKET_GIT_TOKEN: undefined }; + const provider = createBitbucketProvider({ env, fetchImpl: sinon.stub() }); + expect(() => provider.buildGitSourceCredential()).to.throw(/BITBUCKET_GIT_TOKEN/); + }); + }); + + describe('getDefaultBranch', () => { + it('fetches and caches the repo main branch', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse({ mainbranch: { name: 'main' } })); + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl }); + + const branch1 = await provider.getDefaultBranch(); + const branch2 = await provider.getDefaultBranch(); + + expect(branch1).to.equal('main'); + expect(branch2).to.equal('main'); + expect(fetchImpl.callCount).to.equal(1); // cached on second call + }); + + it('avoids any network call in dry-run mode', async () => { + const fetchImpl = sinon.stub(); + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl, dryRun: true }); + + const branch = await provider.getDefaultBranch(); + + expect(branch).to.equal(''); + expect(fetchImpl.called).to.equal(false); + }); + }); + + describe('commitFiles', () => { + it('POSTs a multipart form to the src endpoint with branch/message/author', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse({})); + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl }); + + await provider.commitFiles({ + branch: 'dependabot/npm_and_yarn/lodash', + baseBranch: 'main', + message: 'bump lodash', + files: [ + { name: 'package.json', directory: '/', content: '{}', operation: 'update' }, + { name: 'old-file.json', directory: '/', deleted: true, operation: 'delete' }, + ], + }); + + expect(fetchImpl.callCount).to.equal(1); + const [url, options] = fetchImpl.firstCall.args; + expect(url).to.equal('https://api.bitbucket.org/2.0/repositories/my-workspace/my-repo/src'); + expect(options.method).to.equal('POST'); + expect(options.body).to.be.instanceOf(FormData); + expect(options.body.get('branch')).to.equal('dependabot/npm_and_yarn/lodash'); + expect(options.body.get('message')).to.equal('bump lodash'); + expect(options.body.get('files')).to.equal('old-file.json'); + }); + + it('sends an Authorization Bearer header using the write token', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse({})); + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl }); + + await provider.commitFiles({ branch: 'b', baseBranch: 'main', message: 'm', files: [] }); + + const [, options] = fetchImpl.firstCall.args; + expect(options.headers.Authorization).to.equal('Bearer write-token'); + }); + + it('does not call fetch in dry-run mode', async () => { + const fetchImpl = sinon.stub(); + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl, dryRun: true }); + + await provider.commitFiles({ branch: 'b', baseBranch: 'main', message: 'm', files: [{ name: 'x' }] }); + + expect(fetchImpl.called).to.equal(false); + }); + + it('throws a descriptive error when the API call fails', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse({ error: 'nope' }, { ok: false, status: 400 })); + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl }); + + let error; + try { + await provider.commitFiles({ branch: 'b', baseBranch: 'main', message: 'm', files: [] }); + } catch (err) { + error = err; + } + expect(error).to.exist; + expect(error.message).to.include('400'); + }); + }); + + describe('findOpenPr', () => { + it('queries by open state and branch name, returning the first match', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse({ values: [{ id: 42 }] })); + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl }); + + const pr = await provider.findOpenPr('dependabot/npm_and_yarn/lodash'); + + expect(pr).to.deep.equal({ id: 42 }); + const [url] = fetchImpl.firstCall.args; + expect(url).to.include('/pullrequests?q='); + expect(decodeURIComponent(url)).to.include('state="OPEN"'); + expect(decodeURIComponent(url)).to.include('source.branch.name="dependabot/npm_and_yarn/lodash"'); + }); + + it('returns undefined when there are no matches', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse({ values: [] })); + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl }); + + expect(await provider.findOpenPr('some-branch')).to.equal(undefined); + }); + }); + + describe('createPullRequest', () => { + it('creates a new PR when none is open for the branch', async () => { + const fetchImpl = sinon.stub(); + fetchImpl.onCall(0).resolves(jsonResponse({ values: [] })); // findOpenPr + fetchImpl.onCall(1).resolves(jsonResponse({ id: 7 })); // POST pullrequests + + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl }); + const pr = await provider.createPullRequest({ + branch: 'dependabot/npm_and_yarn/lodash', + title: 'Bump lodash', + body: 'body', + destination: 'main', + }); + + expect(pr).to.deep.equal({ id: 7 }); + const [, postOptions] = fetchImpl.secondCall.args; + const payload = JSON.parse(postOptions.body); + expect(payload.title).to.equal('Bump lodash'); + expect(payload.source.branch.name).to.equal('dependabot/npm_and_yarn/lodash'); + expect(payload.destination.branch.name).to.equal('main'); + expect(payload.close_source_branch).to.equal(true); + }); + + it('does not create a duplicate PR when one is already open', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse({ values: [{ id: 99 }] })); + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl }); + + const pr = await provider.createPullRequest({ branch: 'b', title: 't', body: 'x', destination: 'main' }); + + expect(pr).to.deep.equal({ id: 99 }); + expect(fetchImpl.callCount).to.equal(1); // only the findOpenPr lookup, no POST + }); + + it('does not call fetch in dry-run mode', async () => { + const fetchImpl = sinon.stub(); + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl, dryRun: true }); + + await provider.createPullRequest({ branch: 'b', title: 't', body: 'x', destination: 'main' }); + + expect(fetchImpl.called).to.equal(false); + }); + }); + + describe('closePullRequest', () => { + it('POSTs to the decline endpoint', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse({})); + const provider = createBitbucketProvider({ env: BASE_ENV, fetchImpl }); + + await provider.closePullRequest({ id: 15 }); + + const [url, options] = fetchImpl.firstCall.args; + expect(url).to.equal('https://api.bitbucket.org/2.0/repositories/my-workspace/my-repo/pullrequests/15/decline'); + expect(options.method).to.equal('POST'); + }); + }); +}); diff --git a/test/providers/gitlab.test.mjs b/test/providers/gitlab.test.mjs new file mode 100644 index 0000000..23e71c2 --- /dev/null +++ b/test/providers/gitlab.test.mjs @@ -0,0 +1,288 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { createGitlabProvider } from '../../src/providers/gitlab.mjs'; + +function jsonResponse(body, { ok = true, status = 200 } = {}) { + return { + ok, + status, + json: async () => body, + text: async () => JSON.stringify(body), + }; +} + +const BASE_ENV = { + CI_PROJECT_ID: '123', + CI_PROJECT_PATH: 'my-group/my-project', + CI_COMMIT_SHA: 'abc123', + CI_SERVER_HOST: 'gitlab.com', + CI_API_V4_URL: 'https://gitlab.com/api/v4', + GITLAB_TOKEN: 'read-token', + GITLAB_PR_TOKEN: 'write-token', +}; + +describe('providers/gitlab', () => { + describe('static/context methods (no network)', () => { + it('reports the provider name and required env vars', () => { + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl: sinon.stub() }); + expect(provider.name).to.equal('gitlab'); + expect(provider.requiredContextEnv).to.deep.equal(['CI_PROJECT_PATH', 'CI_COMMIT_SHA']); + }); + + it('builds repo context from CI predefined variables', () => { + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl: sinon.stub() }); + expect(provider.getContext()).to.deep.equal({ + repo: 'my-group/my-project', + commit: 'abc123', + host: 'gitlab.com', + }); + }); + + it('builds a job.yaml source block matching the dependabot CLI gitlab schema', () => { + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl: sinon.stub() }); + expect(provider.buildSourceBlock('/')).to.deep.equal({ + provider: 'gitlab', + hostname: 'gitlab.com', + 'api-endpoint': 'https://gitlab.com/api/v4', + repo: 'my-group/my-project', + directory: '/', + commit: 'abc123', + }); + }); + + it('builds a git_source credential from GITLAB_TOKEN', () => { + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl: sinon.stub() }); + expect(provider.buildGitSourceCredential()).to.deep.equal({ + type: 'git_source', + host: 'gitlab.com', + password: 'read-token', + token: 'read-token', + }); + }); + + it('falls back to the generic GIT_READ_TOKEN when GITLAB_TOKEN is unset', () => { + const env = { ...BASE_ENV, GITLAB_TOKEN: undefined, GIT_READ_TOKEN: 'generic-read' }; + const provider = createGitlabProvider({ env, fetchImpl: sinon.stub() }); + expect(provider.buildGitSourceCredential().token).to.equal('generic-read'); + }); + + it('falls back to GITLAB_TOKEN as the write token when GITLAB_PR_TOKEN is unset', () => { + const env = { ...BASE_ENV, GITLAB_PR_TOKEN: undefined }; + const provider = createGitlabProvider({ env, fetchImpl: sinon.stub() }); + // exercised indirectly: closePullRequest should not throw for missing write token + expect(() => provider.buildGitSourceCredential()).to.not.throw(); + }); + + it('defaults hostname/api-endpoint to gitlab.com when not self-hosted', () => { + const env = { + CI_PROJECT_ID: '1', + CI_PROJECT_PATH: 'g/p', + CI_COMMIT_SHA: 'c', + GITLAB_TOKEN: 't', + }; + const provider = createGitlabProvider({ env, fetchImpl: sinon.stub() }); + const source = provider.buildSourceBlock('/'); + expect(source.hostname).to.equal('gitlab.com'); + expect(source['api-endpoint']).to.equal('https://gitlab.com/api/v4'); + }); + + it('respects a self-hosted CI_SERVER_HOST/CI_API_V4_URL', () => { + const env = { + ...BASE_ENV, + CI_SERVER_HOST: 'gitlab.internal.example.com', + CI_API_V4_URL: 'https://gitlab.internal.example.com/api/v4', + }; + const provider = createGitlabProvider({ env, fetchImpl: sinon.stub() }); + expect(provider.buildSourceBlock('/').hostname).to.equal('gitlab.internal.example.com'); + }); + }); + + describe('getDefaultBranch', () => { + it('uses CI_DEFAULT_BRANCH without a network call when present', async () => { + const fetchImpl = sinon.stub(); + const env = { ...BASE_ENV, CI_DEFAULT_BRANCH: 'main' }; + const provider = createGitlabProvider({ env, fetchImpl }); + + expect(await provider.getDefaultBranch()).to.equal('main'); + expect(fetchImpl.called).to.equal(false); + }); + + it('falls back to the projects API when CI_DEFAULT_BRANCH is absent', async () => { + const env = { ...BASE_ENV, CI_DEFAULT_BRANCH: undefined }; + const fetchImpl = sinon.stub().resolves(jsonResponse({ default_branch: 'develop' })); + const provider = createGitlabProvider({ env, fetchImpl }); + + expect(await provider.getDefaultBranch()).to.equal('develop'); + expect(fetchImpl.callCount).to.equal(1); + }); + + it('avoids any network call in dry-run mode', async () => { + const env = { ...BASE_ENV, CI_DEFAULT_BRANCH: undefined }; + const fetchImpl = sinon.stub(); + const provider = createGitlabProvider({ env, fetchImpl, dryRun: true }); + + expect(await provider.getDefaultBranch()).to.equal(''); + expect(fetchImpl.called).to.equal(false); + }); + }); + + describe('commitFiles', () => { + it('creates the branch then commits via the Commits API with the right actions', async () => { + const fetchImpl = sinon.stub(); + fetchImpl.onCall(0).resolves(jsonResponse({})); // create branch + fetchImpl.onCall(1).resolves(jsonResponse({})); // commits + + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl }); + await provider.commitFiles({ + branch: 'dependabot/npm_and_yarn/lodash', + baseBranch: 'main', + message: 'bump lodash', + files: [ + { name: 'package.json', directory: '/', content: '{}', operation: 'update' }, + { name: 'old-file.json', directory: '/', deleted: true, operation: 'delete' }, + ], + }); + + expect(fetchImpl.callCount).to.equal(2); + + const [branchUrl, branchOptions] = fetchImpl.firstCall.args; + expect(branchUrl).to.equal('https://gitlab.com/api/v4/projects/123/repository/branches'); + const branchPayload = JSON.parse(branchOptions.body); + expect(branchPayload).to.deep.equal({ branch: 'dependabot/npm_and_yarn/lodash', ref: 'main' }); + + const [commitUrl, commitOptions] = fetchImpl.secondCall.args; + expect(commitUrl).to.equal('https://gitlab.com/api/v4/projects/123/repository/commits'); + const commitPayload = JSON.parse(commitOptions.body); + expect(commitPayload.branch).to.equal('dependabot/npm_and_yarn/lodash'); + expect(commitPayload.commit_message).to.equal('bump lodash'); + expect(commitPayload.actions).to.deep.equal([ + { action: 'update', file_path: 'package.json', content: '{}', encoding: 'text' }, + { action: 'delete', file_path: 'old-file.json' }, + ]); + }); + + it('does not fail the commit when branch creation 400s because it already exists', async () => { + const fetchImpl = sinon.stub(); + fetchImpl.onCall(0).resolves(jsonResponse({ message: 'Branch already exists' }, { ok: false, status: 400 })); + fetchImpl.onCall(1).resolves(jsonResponse({})); + + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl }); + + await provider.commitFiles({ branch: 'b', baseBranch: 'main', message: 'm', files: [] }); + + expect(fetchImpl.callCount).to.equal(2); // still proceeds to the commit call + }); + + it('sends content as base64 when the file was base64-encoded', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse({})); + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl }); + + await provider.commitFiles({ + branch: 'b', + baseBranch: 'main', + message: 'm', + files: [{ name: 'binary.bin', directory: '/', content: 'aGVsbG8=', content_encoding: 'base64' }], + }); + + const commitPayload = JSON.parse(fetchImpl.secondCall.args[1].body); + expect(commitPayload.actions[0].encoding).to.equal('base64'); + }); + + it('sends the PRIVATE-TOKEN header using the write token', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse({})); + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl }); + + await provider.commitFiles({ branch: 'b', baseBranch: 'main', message: 'm', files: [] }); + + const [, options] = fetchImpl.firstCall.args; + expect(options.headers['PRIVATE-TOKEN']).to.equal('write-token'); + }); + + it('does not call fetch in dry-run mode', async () => { + const fetchImpl = sinon.stub(); + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl, dryRun: true }); + + await provider.commitFiles({ branch: 'b', baseBranch: 'main', message: 'm', files: [{ name: 'x' }] }); + + expect(fetchImpl.called).to.equal(false); + }); + }); + + describe('findOpenPr', () => { + it('queries merge_requests by source_branch and state=opened, mapping iid to id', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse([{ iid: 42, title: 't' }])); + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl }); + + const mr = await provider.findOpenPr('dependabot/npm_and_yarn/lodash'); + + expect(mr).to.deep.equal({ iid: 42, title: 't', id: 42 }); + const [url] = fetchImpl.firstCall.args; + expect(url).to.include('state=opened'); + expect(decodeURIComponent(url)).to.include('source_branch=dependabot/npm_and_yarn/lodash'); + }); + + it('returns undefined when there are no matches', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse([])); + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl }); + + expect(await provider.findOpenPr('some-branch')).to.equal(undefined); + }); + }); + + describe('createPullRequest', () => { + it('creates a new MR when none is open for the branch', async () => { + const fetchImpl = sinon.stub(); + fetchImpl.onCall(0).resolves(jsonResponse([])); // findOpenPr + fetchImpl.onCall(1).resolves(jsonResponse({ iid: 7 })); // POST merge_requests + + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl }); + const mr = await provider.createPullRequest({ + branch: 'dependabot/npm_and_yarn/lodash', + title: 'Bump lodash', + body: 'body', + destination: 'main', + }); + + expect(mr.id).to.equal(7); + const [, postOptions] = fetchImpl.secondCall.args; + const payload = JSON.parse(postOptions.body); + expect(payload.source_branch).to.equal('dependabot/npm_and_yarn/lodash'); + expect(payload.target_branch).to.equal('main'); + expect(payload.title).to.equal('Bump lodash'); + expect(payload.remove_source_branch).to.equal(true); + }); + + it('does not create a duplicate MR when one is already open', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse([{ iid: 99 }])); + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl }); + + const mr = await provider.createPullRequest({ branch: 'b', title: 't', body: 'x', destination: 'main' }); + + expect(mr.id).to.equal(99); + expect(fetchImpl.callCount).to.equal(1); + }); + + it('does not call fetch in dry-run mode', async () => { + const fetchImpl = sinon.stub(); + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl, dryRun: true }); + + await provider.createPullRequest({ branch: 'b', title: 't', body: 'x', destination: 'main' }); + + expect(fetchImpl.called).to.equal(false); + }); + }); + + describe('closePullRequest', () => { + it('PUTs state_event=close to the merge request', async () => { + const fetchImpl = sinon.stub().resolves(jsonResponse({})); + const provider = createGitlabProvider({ env: BASE_ENV, fetchImpl }); + + await provider.closePullRequest({ id: 15 }); + + const [url, options] = fetchImpl.firstCall.args; + expect(url).to.equal('https://gitlab.com/api/v4/projects/123/merge_requests/15'); + expect(options.method).to.equal('PUT'); + expect(JSON.parse(options.body)).to.deep.equal({ state_event: 'close' }); + }); + }); +}); diff --git a/test/providers/index.test.mjs b/test/providers/index.test.mjs new file mode 100644 index 0000000..db9383f --- /dev/null +++ b/test/providers/index.test.mjs @@ -0,0 +1,65 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { detectProviderName, createProvider } from '../../src/providers/index.mjs'; + +describe('providers/index', () => { + describe('detectProviderName', () => { + it('prefers an explicit GIT_PROVIDER over anything auto-detected', () => { + const env = { GIT_PROVIDER: 'gitlab', BITBUCKET_WORKSPACE: 'w' }; + expect(detectProviderName(env)).to.equal('gitlab'); + }); + + it('detects bitbucket from BITBUCKET_WORKSPACE', () => { + expect(detectProviderName({ BITBUCKET_WORKSPACE: 'w' })).to.equal('bitbucket'); + }); + + it('detects gitlab from GITLAB_CI', () => { + expect(detectProviderName({ GITLAB_CI: 'true' })).to.equal('gitlab'); + }); + + it('detects gitlab from CI_PROJECT_ID', () => { + expect(detectProviderName({ CI_PROJECT_ID: '123' })).to.equal('gitlab'); + }); + + it('returns undefined when nothing matches', () => { + expect(detectProviderName({})).to.equal(undefined); + }); + }); + + describe('createProvider', () => { + it('creates a bitbucket provider when detected', () => { + const env = { + BITBUCKET_WORKSPACE: 'w', + BITBUCKET_REPO_SLUG: 'r', + BITBUCKET_COMMIT: 'c', + BITBUCKET_GIT_TOKEN: 't', + }; + const provider = createProvider({ env, fetchImpl: sinon.stub() }); + expect(provider.name).to.equal('bitbucket'); + }); + + it('creates a gitlab provider when detected', () => { + const env = { + CI_PROJECT_ID: '1', + CI_PROJECT_PATH: 'g/p', + CI_COMMIT_SHA: 'c', + GITLAB_TOKEN: 't', + }; + const provider = createProvider({ env, fetchImpl: sinon.stub() }); + expect(provider.name).to.equal('gitlab'); + }); + + it('respects an explicit name override even without matching env vars', () => { + const provider = createProvider({ env: {}, name: 'gitlab', fetchImpl: sinon.stub() }); + expect(provider.name).to.equal('gitlab'); + }); + + it('throws a helpful error when no provider can be determined', () => { + expect(() => createProvider({ env: {} })).to.throw(/Unknown or undetected git provider/); + }); + + it('throws a helpful error for an unrecognised explicit provider name', () => { + expect(() => createProvider({ env: {}, name: 'gitea' })).to.throw(/gitea/); + }); + }); +}); diff --git a/test/run-updates.test.mjs b/test/run-updates.test.mjs new file mode 100644 index 0000000..479aa77 --- /dev/null +++ b/test/run-updates.test.mjs @@ -0,0 +1,204 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import { runUpdates } from '../src/run-updates.mjs'; + +const require = createRequire(import.meta.url); +const yaml = require('js-yaml'); + +function makeFakeProvider(overrides = {}) { + return { + name: 'fake', + requiredContextEnv: [], + getContext: () => ({ repo: 'group/project', commit: 'abc123', host: 'example.com' }), + buildSourceBlock: (directory) => ({ provider: 'fake', repo: 'group/project', directory, commit: 'abc123' }), + buildGitSourceCredential: () => ({ type: 'git_source', host: 'example.com', password: 'token', token: 'token' }), + ...overrides, + }; +} + +async function withScratchDir(fn) { + const dir = mkdtempSync(join(tmpdir(), 'dependabot-for-git-run-updates-test-')); + const originalCwd = process.cwd(); + process.chdir(dir); + try { + await fn(dir); + } finally { + process.chdir(originalCwd); + rmSync(dir, { recursive: true, force: true }); + } +} + +function writeConfig(dir, entries) { + writeFileSync(join(dir, 'dependabot-config.json'), JSON.stringify(entries)); +} + +describe('run-updates orchestration', () => { + it('generates a job.yaml per config entry using the provider source block and credential', async () => { + await withScratchDir(async (dir) => { + writeConfig(dir, [{ 'package-manager': 'npm_and_yarn', directory: '/' }]); + const provider = makeFakeProvider(); + const execFileSyncImpl = sinon.stub(); + + await runUpdates({ env: {}, provider, execFileSyncImpl }); + + const jobPath = join(dir, '.dependabot-run', 'job-0.yaml'); + expect(existsSync(jobPath)).to.equal(true); + const job = yaml.load(readFileSync(jobPath, 'utf8')); + + expect(job.job['package-manager']).to.equal('npm_and_yarn'); + expect(job.job.source).to.deep.equal({ provider: 'fake', repo: 'group/project', directory: '/', commit: 'abc123' }); + expect(job.credentials).to.deep.equal([{ type: 'git_source', host: 'example.com', password: 'token', token: 'token' }]); + }); + }); + + it('adds an npm_registry credential when NPM_TOKEN is set', async () => { + await withScratchDir(async (dir) => { + writeConfig(dir, [{ 'package-manager': 'npm_and_yarn', directory: '/' }]); + const provider = makeFakeProvider(); + const execFileSyncImpl = sinon.stub(); + + await runUpdates({ env: { NPM_TOKEN: 'npm-secret' }, provider, execFileSyncImpl }); + + const job = yaml.load(readFileSync(join(dir, '.dependabot-run', 'job-0.yaml'), 'utf8')); + expect(job.credentials).to.deep.include({ + type: 'npm_registry', + registry: 'registry.npmjs.org', + token: 'npm-secret', + }); + }); + }); + + it('omits the npm_registry credential when NPM_TOKEN is unset', async () => { + await withScratchDir(async (dir) => { + writeConfig(dir, [{ 'package-manager': 'npm_and_yarn', directory: '/' }]); + const provider = makeFakeProvider(); + const execFileSyncImpl = sinon.stub(); + + await runUpdates({ env: {}, provider, execFileSyncImpl }); + + const job = yaml.load(readFileSync(join(dir, '.dependabot-run', 'job-0.yaml'), 'utf8')); + expect(job.credentials).to.have.length(1); + }); + }); + + it('generates one job/output pair per config entry, in order', async () => { + await withScratchDir(async (dir) => { + writeConfig(dir, [ + { 'package-manager': 'npm_and_yarn', directory: '/' }, + { 'package-manager': 'npm_and_yarn', directory: '/packages/api' }, + ]); + const provider = makeFakeProvider(); + const execFileSyncImpl = sinon.stub(); + + const manifest = await runUpdates({ env: {}, provider, execFileSyncImpl }); + + expect(manifest).to.have.length(2); + expect(manifest[0].directory).to.equal('/'); + expect(manifest[1].directory).to.equal('/packages/api'); + expect(execFileSyncImpl.callCount).to.equal(2); + }); + }); + + it('invokes the dependabot binary with -f/-o pointing at the generated files', async () => { + await withScratchDir(async (dir) => { + writeConfig(dir, [{ 'package-manager': 'npm_and_yarn', directory: '/' }]); + const provider = makeFakeProvider(); + const execFileSyncImpl = sinon.stub(); + + await runUpdates({ env: { DEPENDABOT_BIN: '/usr/local/bin/dependabot' }, provider, execFileSyncImpl }); + + expect(execFileSyncImpl.calledOnce).to.equal(true); + const [bin, args] = execFileSyncImpl.firstCall.args; + expect(bin).to.equal('/usr/local/bin/dependabot'); + expect(args).to.include('update'); + expect(args).to.include('-f'); + expect(args).to.include('-o'); + expect(args[args.indexOf('-f') + 1]).to.equal(join(dir, '.dependabot-run', 'job-0.yaml')); + expect(args[args.indexOf('-o') + 1]).to.equal(join(dir, '.dependabot-run', 'output-0.yaml')); + }); + }); + + it('passes through updater/proxy image overrides when set', async () => { + await withScratchDir(async (dir) => { + writeConfig(dir, [{ 'package-manager': 'npm_and_yarn', directory: '/' }]); + const provider = makeFakeProvider(); + const execFileSyncImpl = sinon.stub(); + + await runUpdates({ + env: { DEPENDABOT_UPDATER_IMAGE: 'my/updater:latest', DEPENDABOT_PROXY_IMAGE: 'my/proxy:latest' }, + provider, + execFileSyncImpl, + }); + + const [, args] = execFileSyncImpl.firstCall.args; + expect(args).to.include('--updater-image'); + expect(args).to.include('my/updater:latest'); + expect(args).to.include('--proxy-image'); + expect(args).to.include('my/proxy:latest'); + }); + }); + + it('does not throw when the dependabot binary exits non-zero, and still records the manifest entry', async () => { + await withScratchDir(async (dir) => { + writeConfig(dir, [{ 'package-manager': 'npm_and_yarn', directory: '/' }]); + const provider = makeFakeProvider(); + const execFileSyncImpl = sinon.stub().throws(new Error('exit 1')); + + const manifest = await runUpdates({ env: {}, provider, execFileSyncImpl }); + + expect(manifest).to.have.length(1); + }); + }); + + it('exits with an error when a required context env var is missing for the provider', async () => { + await withScratchDir(async (dir) => { + writeConfig(dir, [{ 'package-manager': 'npm_and_yarn', directory: '/' }]); + const provider = makeFakeProvider({ requiredContextEnv: ['SOME_REQUIRED_VAR'] }); + const execFileSyncImpl = sinon.stub(); + const exitStub = sinon.stub(process, 'exit').throws(new Error('process.exit called')); + + try { + await runUpdates({ env: {}, provider, execFileSyncImpl }); + expect.fail('expected runUpdates to call process.exit'); + } catch (err) { + expect(err.message).to.equal('process.exit called'); + } finally { + exitStub.restore(); + } + }); + }); + + it('writes manifest.json summarising every job that ran', async () => { + await withScratchDir(async (dir) => { + writeConfig(dir, [{ 'package-manager': 'npm_and_yarn', directory: '/' }]); + const provider = makeFakeProvider(); + const execFileSyncImpl = sinon.stub(); + + await runUpdates({ env: {}, provider, execFileSyncImpl }); + + const manifest = JSON.parse(readFileSync(join(dir, '.dependabot-run', 'manifest.json'), 'utf8')); + expect(manifest).to.have.length(1); + expect(manifest[0]).to.include({ packageManager: 'npm_and_yarn', directory: '/' }); + }); + }); + + it('respects DEPENDABOT_CONFIG_PATH to read config from a custom location', async () => { + await withScratchDir(async (dir) => { + writeFileSync(join(dir, 'custom-config.json'), JSON.stringify([{ 'package-manager': 'bundler', directory: '/' }])); + const provider = makeFakeProvider(); + const execFileSyncImpl = sinon.stub(); + + const manifest = await runUpdates({ + env: { DEPENDABOT_CONFIG_PATH: 'custom-config.json' }, + provider, + execFileSyncImpl, + }); + + expect(manifest[0].packageManager).to.equal('bundler'); + }); + }); +});