diff --git a/eslint.config.js b/eslint.config.js index 14765ed2c..cdb715cb5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -37,7 +37,10 @@ export default [ "newlines-between": "always", }, ], - "jsdoc/no-undefined-types": [1, { definedTypes: ["NodeJS"] }], + "jsdoc/no-undefined-types": [ + 1, + { definedTypes: ["NodeJS", "RequestInit"] }, + ], "jsdoc/require-hyphen-before-param-description": "warn", "unicorn/comment-content": "off", "unicorn/consistent-optional-chaining": "off", diff --git a/helpers/mock-agent/store-bitbucket.js b/helpers/mock-agent/store-bitbucket.js new file mode 100644 index 000000000..e6e11c7bf --- /dev/null +++ b/helpers/mock-agent/store-bitbucket.js @@ -0,0 +1,110 @@ +import { MockAgent } from "undici"; + +/** + * @param {string} message - Error message + * @returns {object} Bitbucket-shaped error response body + */ +function errorBody(message) { + return { type: "error", error: { message } }; +} + +/** + * @returns {import("undici").MockAgent} Undici MockAgent + * @see {@link https://undici.nodejs.org/#/docs/api/MockAgent} + */ +export function mockClient() { + const agent = new MockAgent(); + agent.disableNetConnect(); + + const origin = "https://api.bitbucket.org"; + const sourcePath = "/2.0/repositories/username/repo/src"; + + // File exists (foo.txt) + agent + .get(origin) + .intercept({ + path: `${sourcePath}/main/foo.txt`, + method: "GET", + query: { format: "meta" }, + }) + .reply(200, { path: "foo.txt", type: "commit_file" }) + .persist(); + + // File doesn’t exist (404.txt, new.txt, 401.txt) + agent + .get(origin) + .intercept({ + path: /\/main\/(404|401|new)\.txt\?format=meta$/, + method: "GET", + }) + .reply(404, errorBody("Not found")) + .persist(); + + // Read file (foo.txt) + agent + .get(origin) + .intercept({ path: `${sourcePath}/main/foo.txt`, method: "GET" }) + .reply(200, "foo") + .persist(); + + // Read file (Not found) + agent + .get(origin) + .intercept({ path: `${sourcePath}/main/404.txt`, method: "GET" }) + .reply(404, errorBody("Not found")) + .persist(); + + /** + * Bitbucket’s `src` endpoint is a single fixed URL for every create, + * update and delete — the file path only appears as a field name inside + * the multipart request body, which Undici’s `MockAgent` can’t inspect + * (it sees a `FormData` body as the literal string `[object FormData]`). + * So, rather than matching per file, these are registered as a queue: + * each `it()` block below consumes the next one in order. + */ + + // 1. Creates file (new.txt) + agent.get(origin).intercept({ path: sourcePath, method: "POST" }).reply(201, { + type: "commit", + }); + + // 2. Throws error creating file (401.txt) + agent + .get(origin) + .intercept({ path: sourcePath, method: "POST" }) + .reply(401, errorBody("Unauthorized")); + + // 3. Updates file (foo.txt) + agent.get(origin).intercept({ path: sourcePath, method: "POST" }).reply(200, { + type: "commit", + }); + + // 4. Updates and renames file (bar.txt) … + agent.get(origin).intercept({ path: sourcePath, method: "POST" }).reply(200, { + type: "commit", + }); + + // …5. … then deletes the old path (foo.txt) + agent.get(origin).intercept({ path: sourcePath, method: "POST" }).reply(200, { + type: "commit", + }); + + // 6. Throws error updating file (401.txt) + agent + .get(origin) + .intercept({ path: sourcePath, method: "POST" }) + .reply(401, errorBody("Unauthorized")); + + // 7. Deletes a file (foo.txt) + agent.get(origin).intercept({ path: sourcePath, method: "POST" }).reply(200, { + type: "commit", + }); + + // 8. Throws error deleting a file (401.txt) + agent + .get(origin) + .intercept({ path: sourcePath, method: "POST" }) + .reply(401, errorBody("Unauthorized")); + + return agent; +} diff --git a/package-lock.json b/package-lock.json index 22e07bf5a..edf9c29cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,6 @@ "mock-req-res": "^1.2.1", "mock-session": "^0.0.5", "mongodb-memory-server": "^11.2.0", - "nock": "^14.0.16", "prettier": "^3.9.4", "stylelint": "^17.14.0", "stylelint-config-recommended": "^18.0.0", @@ -5878,24 +5877,6 @@ "sparse-bitfield": "^3.0.3" } }, - "node_modules/@mswjs/interceptors": { - "version": "0.41.9", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", - "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", @@ -6892,31 +6873,6 @@ "@octokit/openapi-types": "^24.2.0" } }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } - }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT" - }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -10256,19 +10212,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/bitbucket": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/bitbucket/-/bitbucket-2.12.0.tgz", - "integrity": "sha512-YqaiTPEmn5mkwdU2gGcJZcQ6B8/DhCHhc3SSYqSpnef6nSTTSa/2GSBoLEgPLqAuqrQITGKq8MgYkfDMtnJPuw==", - "license": "MIT", - "dependencies": { - "before-after-hook": "^2.1.0", - "deepmerge": "^4.2.2", - "is-plain-object": "^3.0.0", - "node-fetch": "^2.6.0", - "url-template": "^2.0.8" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -17235,13 +17178,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, - "license": "MIT" - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -17304,15 +17240,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-plain-object": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz", - "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -20206,21 +20133,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/nock": { - "version": "14.0.16", - "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.16.tgz", - "integrity": "sha512-8r4KEc6nT1D/fdLD/R1BO1CPaVEL8o40u/guFRJlXabN7vr3RmMqyjsY5Krt0nMwhsOAwXQ/mtN5vy5Jh3aErg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@mswjs/interceptors": "^0.41.0", - "json-stringify-safe": "^5.0.1", - "propagate": "^2.0.0" - }, - "engines": { - "node": ">=18.20.0 <20 || >=20.12.1" - } - }, "node_modules/node-abi": { "version": "3.94.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", @@ -20272,54 +20184,12 @@ "semver": "bin/semver.js" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "license": "MIT" }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -21324,13 +21194,6 @@ "node": ">=8" } }, - "node_modules/outvariant": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, - "license": "MIT" - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -22568,16 +22431,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -24657,13 +24510,6 @@ "text-decoder": "^1.1.0" } }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, - "license": "MIT" - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -26518,12 +26364,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/url-template": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", - "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", - "license": "BSD" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -31556,8 +31396,7 @@ "version": "1.0.0-beta.27", "license": "MIT", "dependencies": { - "@indiekit/error": "^1.0.0-beta.27", - "bitbucket": "^2.12.0" + "@indiekit/error": "^1.0.0-beta.27" }, "engines": { "node": ">=24.17" diff --git a/package.json b/package.json index f89484b9c..cd7fb37af 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,6 @@ "mock-req-res": "^1.2.1", "mock-session": "^0.0.5", "mongodb-memory-server": "^11.2.0", - "nock": "^14.0.16", "prettier": "^3.9.4", "stylelint": "^17.14.0", "stylelint-config-recommended": "^18.0.0", diff --git a/packages/store-bitbucket/README.md b/packages/store-bitbucket/README.md index 996d17190..333c13bfe 100644 --- a/packages/store-bitbucket/README.md +++ b/packages/store-bitbucket/README.md @@ -8,10 +8,10 @@ ## Requirements -A [Bitbucket app password](https://bitbucket.org/account/settings/app-passwords/). +A [Bitbucket API token](https://id.atlassian.com/manage-profile/security/api-tokens) with `read:repository:bitbucket` and `write:repository:bitbucket` scopes. > [!IMPORTANT] -> Store your app password in an environment variable called `BITBUCKET_PASSWORD` so that only you and the application can see it. +> Store your app password in an environment variable called `BITBUCKET_TOKEN` so that only you and the application can see it. ## Usage @@ -21,6 +21,7 @@ Add `@indiekit/store-bitbucket` to your list of plug-ins, specifying options as { "plugins": ["@indiekit/store-bitbucket"], "@indiekit/store-bitbucket": { + "email": "username@website.example", "user": "username", "repo": "reponame" } @@ -29,9 +30,10 @@ Add `@indiekit/store-bitbucket` to your list of plug-ins, specifying options as ## Options -| Option | Type | Description | -| :--------- | :------- | :---------------------------------------------------------------------------------- | -| `user` | `string` | Your Bitbucket username. _Required_. | -| `repo` | `string` | The name of your Bitbucket repository. _Required_. | -| `branch` | `string` | The branch files will be saved to. _Optional_, defaults to `main`. | -| `password` | `string` | A Bitbucket app password. _Required_, defaults to `process.env.BITBUCKET_PASSWORD`. | +| Option | Type | Description | +| :------- | :------- | :---------------------------------------------------------------------------- | +| `email` | `string` | Your Atlassian email. _Required_. | +| `user` | `string` | Your Bitbucket workspace. _Required_. | +| `repo` | `string` | The name of your Bitbucket repository. _Required_. | +| `branch` | `string` | The branch files will be saved to. _Optional_, defaults to `main`. | +| `token` | `string` | A Bitbucket API token. _Required_, defaults to `process.env.BITBUCKET_TOKEN`. | diff --git a/packages/store-bitbucket/assets/icon.svg b/packages/store-bitbucket/assets/icon.svg index 782ac9f3b..417f5eaa1 100644 --- a/packages/store-bitbucket/assets/icon.svg +++ b/packages/store-bitbucket/assets/icon.svg @@ -1,4 +1,4 @@ - - + + diff --git a/packages/store-bitbucket/index.js b/packages/store-bitbucket/index.js index 5e87b720a..086a42c76 100644 --- a/packages/store-bitbucket/index.js +++ b/packages/store-bitbucket/index.js @@ -1,27 +1,24 @@ +import { Buffer } from "node:buffer"; import path from "node:path"; import process from "node:process"; import { IndiekitError } from "@indiekit/error"; -// eslint-disable-next-line import-x/default -import bitbucket from "bitbucket"; const defaults = { branch: "main", - password: process.env.BITBUCKET_PASSWORD, + token: process.env.BITBUCKET_TOKEN, }; -/** - * @typedef {import("bitbucket").APIClient} APIClient - */ export default class BitbucketStore { name = "Bitbucket store"; /** * @param {object} [options] - Plug-in options - * @param {string} [options.user] - Username + * @param {string} [options.email] - Atlassian account email address + * @param {string} [options.user] - Workspace (used in repository paths) * @param {string} [options.repo] - Repository * @param {string} [options.branch] - Branch - * @param {string} [options.password] - Password + * @param {string} [options.token] - API token */ constructor(options = {}) { this.options = { ...defaults, ...options }; @@ -29,22 +26,49 @@ export default class BitbucketStore { /** * @access private - * @returns {APIClient} Bitbucket client interface + * @param {string} [requestPath] - Path appended to the repository’s `src` endpoint + * @param {RequestInit} [requestOptions] - Fetch options + * @returns {Promise} Fetch response + * @see {@link https://developer.atlassian.com/cloud/bitbucket/rest/api-group-source/} */ - get #client() { - // eslint-disable-next-line import-x/no-named-as-default-member - const { Bitbucket } = bitbucket; - return new Bitbucket({ - auth: { - username: this.options.user, - password: this.options.password, - }, - notice: false, - }); + async #client(requestPath = "", requestOptions = {}) { + const { user, repo, email, token } = this.options; + const apiPath = path.join( + "2.0/repositories", + user, + repo, + "src", + requestPath, + ); + const url = new URL(apiPath, "https://api.bitbucket.org"); + const credentials = Buffer.from(`${email}:${token}`).toString("base64"); + + try { + const response = await fetch(url.href, { + ...requestOptions, + headers: { + authorization: `Basic ${credentials}`, + ...requestOptions.headers, + }, + }); + + if (!response.ok) { + const body = await response.json(); + throw new Error(body.error.message); + } + + return response; + } catch (error) { + throw new IndiekitError(error.message, { + cause: error.cause, + plugin: this.name, + status: error.cause?.status, + }); + } } get environment() { - return ["BITBUCKET_PASSWORD"]; + return ["BITBUCKET_TOKEN"]; } get info() { @@ -61,13 +85,19 @@ export default class BitbucketStore { { type: "text", name: "user", - message: "What is your Bitbucket username?", + message: "What is your Bitbucket workspace?", }, { type: "text", name: "repo", message: "Which repository is your publication stored on?", }, + { + type: "text", + name: "email", + message: + "What email address is associated with your Bitbucket account?", + }, { type: "text", name: "branch", @@ -81,17 +111,11 @@ export default class BitbucketStore { * Check if file exists * @param {string} filePath - Path to file * @returns {Promise} File exists - * @see {@link https://bitbucketjs.netlify.app/#api-repositories-repositories_readSrc} + * @see {@link https://developer.atlassian.com/cloud/bitbucket/rest/api-group-source/#api-repositories-workspace-repo-slug-src-commit-path-get} */ async fileExists(filePath) { try { - await this.#client.repositories.readSrc({ - format: "meta", - commit: this.options.branch, - path: filePath, - repo_slug: this.options.repo, - workspace: this.options.user, - }); + await this.#client(`${this.options.branch}/${filePath}?format=meta`); return true; } catch { @@ -105,62 +129,38 @@ export default class BitbucketStore { * @param {string} content - File content * @param {object} options - Options * @param {string} options.message - Commit message - * @returns {Promise} Created file URL - * @see {@link https://bitbucketjs.netlify.app/#api-repositories-repositories_createSrcFileCommit} + * @returns {Promise} Created file URL + * @see {@link https://developer.atlassian.com/cloud/bitbucket/rest/api-group-source/#api-repositories-workspace-repo-slug-src-post} */ async createFile(filePath, content, { message }) { - try { - const fileExists = await this.fileExists(filePath); - if (fileExists) { - return; - } + const fileExists = await this.fileExists(filePath); + if (fileExists) { + return; + } - await this.#client.repositories.createSrcFileCommit({ - [filePath]: content, - branch: this.options.branch, - message, - repo_slug: this.options.repo, - workspace: this.options.user, - }); + const body = new FormData(); + body.set(filePath, new Blob([content]), path.basename(filePath)); + body.set("branch", this.options.branch); + body.set("message", message); - const url = new URL(this.info.uid); - url.pathname = path.join(url.pathname, filePath); + await this.#client("", { method: "POST", body }); - return url.href; - } catch (error) { - throw new IndiekitError(error.message, { - cause: error, - plugin: this.name, - status: error.status, - }); - } + const url = new URL(this.info.uid); + url.pathname = path.join(url.pathname, filePath); + + return url.href; } /** * Read file * @param {string} filePath - Path to file * @returns {Promise} File content - * @see {@link https://bitbucketjs.netlify.app/#api-repositories-repositories_readSrc} + * @see {@link https://developer.atlassian.com/cloud/bitbucket/rest/api-group-source/#api-repositories-workspace-repo-slug-src-commit-path-get} */ async readFile(filePath) { - try { - const readResponse = await this.#client.repositories.readSrc({ - format: "rendered", - commit: this.options.branch, - path: filePath, - repo_slug: this.options.repo, - workspace: this.options.user, - }); - const content = readResponse.data.raw; + const response = await this.#client(`${this.options.branch}/${filePath}`); - return content; - } catch (error) { - throw new IndiekitError(error.message, { - cause: error, - plugin: this.name, - status: error.status, - }); - } + return response.text(); } /** @@ -171,35 +171,30 @@ export default class BitbucketStore { * @param {string} options.message - Commit message * @param {string} [options.newPath] - New path to file * @returns {Promise} Updated file URL - * @see {@link https://bitbucketjs.netlify.app/#api-repositories-repositories_createSrcFileCommit} + * @see {@link https://developer.atlassian.com/cloud/bitbucket/rest/api-group-source/#api-repositories-workspace-repo-slug-src-post} */ async updateFile(filePath, content, { message, newPath }) { - try { - const updateFilePath = newPath || filePath; - - await this.#client.repositories.createSrcFileCommit({ - [updateFilePath]: content, - branch: this.options.branch, - message, - repo_slug: this.options.repo, - workspace: this.options.user, - }); + const updateFilePath = newPath || filePath; - if (newPath) { - await this.deleteFile(filePath, { message }); - } + const body = new FormData(); + body.set( + updateFilePath, + new Blob([content]), + path.basename(updateFilePath), + ); + body.set("branch", this.options.branch); + body.set("message", message); - const url = new URL(this.info.uid); - url.pathname = path.join(url.pathname, updateFilePath); + await this.#client("", { method: "POST", body }); - return url.href; - } catch (error) { - throw new IndiekitError(error.message, { - cause: error, - plugin: this.name, - status: error.status, - }); + if (newPath) { + await this.deleteFile(filePath, { message }); } + + const url = new URL(this.info.uid); + url.pathname = path.join(url.pathname, updateFilePath); + + return url.href; } /** @@ -208,26 +203,17 @@ export default class BitbucketStore { * @param {object} options - Options * @param {string} options.message - Commit message * @returns {Promise} File deleted - * @see {@link https://bitbucketjs.netlify.app/#api-repositories-repositories_createSrcFileCommit} + * @see {@link https://developer.atlassian.com/cloud/bitbucket/rest/api-group-source/#api-repositories-workspace-repo-slug-src-post} */ async deleteFile(filePath, { message }) { - try { - await this.#client.repositories.createSrcFileCommit({ - branch: this.options.branch, - files: filePath, - message, - repo_slug: this.options.repo, - workspace: this.options.user, - }); + const body = new FormData(); + body.set("files", filePath); + body.set("branch", this.options.branch); + body.set("message", message); - return true; - } catch (error) { - throw new IndiekitError(error.message, { - cause: error, - plugin: this.name, - status: error.status, - }); - } + await this.#client("", { method: "POST", body }); + + return true; } init(Indiekit) { diff --git a/packages/store-bitbucket/package.json b/packages/store-bitbucket/package.json index 956b967e9..5ec508090 100644 --- a/packages/store-bitbucket/package.json +++ b/packages/store-bitbucket/package.json @@ -32,8 +32,7 @@ "directory": "packages/store-bitbucket" }, "dependencies": { - "@indiekit/error": "^1.0.0-beta.27", - "bitbucket": "^2.12.0" + "@indiekit/error": "^1.0.0-beta.27" }, "publishConfig": { "access": "public" diff --git a/packages/store-bitbucket/test/index.js b/packages/store-bitbucket/test/index.js index b09123756..0fa5b6d23 100644 --- a/packages/store-bitbucket/test/index.js +++ b/packages/store-bitbucket/test/index.js @@ -2,21 +2,22 @@ import { strict as assert } from "node:assert"; import { describe, it } from "node:test"; import { Indiekit } from "@indiekit/indiekit"; -import nock from "nock"; +import { mockAgent } from "@indiekit-test/mock-agent"; import BitbucketStore from "../index.js"; -describe("store-bitbucket", () => { +await mockAgent("store-bitbucket"); + +describe("store-bitbucket", async () => { const bitbucket = new BitbucketStore({ + email: "username@website.example", user: "username", - password: "password", + token: "abcd1234", repo: "repo", }); - const bitbucketUrl = "https://api.bitbucket.org"; - it("Gets plug-in environment", () => { - assert.deepEqual(bitbucket.environment, ["BITBUCKET_PASSWORD"]); + assert.deepEqual(bitbucket.environment, ["BITBUCKET_TOKEN"]); }); it("Gets plug-in info", () => { @@ -28,7 +29,7 @@ describe("store-bitbucket", () => { it("Gets plug-in installation prompts", () => { assert.equal( bitbucket.prompts[0].message, - "What is your Bitbucket username?", + "What is your Bitbucket workspace?", ); }); @@ -50,47 +51,19 @@ describe("store-bitbucket", () => { }); it("Checks if file exists", async () => { - nock(bitbucketUrl) - .get("/2.0/repositories/username/repo/src/main/foo.txt") - .query({ format: "meta" }) - .reply(201, { path: "foo.txt", type: "meta" }); - nock(bitbucketUrl) - .post("/2.0/repositories/username/repo/src/main/bar.txt") - .query({ format: "meta" }) - .replyWithError("Not found"); - assert.equal(await bitbucket.fileExists("foo.txt"), true); - assert.equal(await bitbucket.fileExists("bar.txt"), false); + assert.equal(await bitbucket.fileExists("404.txt"), false); }); it("Creates file", async () => { - nock(bitbucketUrl).post("/2.0/repositories/username/repo/src").reply(201, { - "content-type": "application/json", - }); - - const result = await bitbucket.createFile("foo.txt", "foo", { + const result = await bitbucket.createFile("new.txt", "new", { message: "Message", }); - assert.equal(result, "https://bitbucket.org/username/repo/foo.txt"); + assert.equal(result, "https://bitbucket.org/username/repo/new.txt"); }); it("Doesn’t create file if already exists", async () => { - nock(bitbucketUrl).post("/2.0/repositories/username/repo/src").reply(201, { - "content-type": "application/json", - }); - - // Create file - await bitbucket.createFile("foo.txt", "foo", { - message: "Message", - }); - - nock(bitbucketUrl) - .get("/2.0/repositories/username/repo/src/main/foo.txt") - .query({ format: "meta" }) - .reply(201, { path: "foo.txt", type: "meta" }); - - // Create file a second time const result = await bitbucket.createFile("foo.txt", "foo", { message: "Message", }); @@ -99,47 +72,27 @@ describe("store-bitbucket", () => { }); it("Throws error creating file", async () => { - nock(bitbucketUrl) - .post("/2.0/repositories/username/repo/src") - .replyWithError("Not found"); - await assert.rejects( - bitbucket.createFile("foo.txt", "foo", { message: "Message" }), + bitbucket.createFile("401.txt", "foo", { message: "Message" }), { - message: - "Bitbucket store: request to https://api.bitbucket.org/2.0/repositories/username/repo/src failed, reason: Not found", + message: "Bitbucket store: Unauthorized", }, ); }); it("Reads file", async () => { - nock(bitbucketUrl) - .get("/2.0/repositories/username/repo/src/main/foo.txt") - .query({ format: "rendered" }) - .reply(201, { raw: "foo", type: "rendered" }); - const result = await bitbucket.readFile("foo.txt"); assert.equal(result, "foo"); }); it("Throws error reading file", async () => { - nock(bitbucketUrl) - .get("/2.0/repositories/username/repo/src/main/foo.txt") - .query({ format: "rendered" }) - .replyWithError("Not found"); - - await assert.rejects(bitbucket.readFile("foo.txt"), { - message: - "Bitbucket store: request to https://api.bitbucket.org/2.0/repositories/username/repo/src/main/foo.txt?format=rendered failed, reason: Not found", + await assert.rejects(bitbucket.readFile("404.txt"), { + message: "Bitbucket store: Not found", }); }); it("Updates file", async () => { - nock(bitbucketUrl).post("/2.0/repositories/username/repo/src").reply(201, { - "content-type": "application/json", - }); - const result = await bitbucket.updateFile("foo.txt", "foo", { message: "Message", }); @@ -148,13 +101,6 @@ describe("store-bitbucket", () => { }); it("Updates and renames file", async () => { - nock(bitbucketUrl) - .post("/2.0/repositories/username/repo/src") - .twice() - .reply(201, { - "content-type": "application/json", - }); - const result = await bitbucket.updateFile("foo.txt", "foo", { message: "Message", newPath: "bar.txt", @@ -164,24 +110,15 @@ describe("store-bitbucket", () => { }); it("Throws error updating file", async () => { - nock(bitbucketUrl) - .post("/2.0/repositories/username/repo/src") - .replyWithError("Not found"); - await assert.rejects( - bitbucket.updateFile("foo.txt", "foo", { message: "Message" }), + bitbucket.updateFile("401.txt", "foo", { message: "Message" }), { - message: - "Bitbucket store: request to https://api.bitbucket.org/2.0/repositories/username/repo/src failed, reason: Not found", + message: "Bitbucket store: Unauthorized", }, ); }); it("Deletes a file", async () => { - nock(bitbucketUrl).post("/2.0/repositories/username/repo/src").reply(201, { - "content-type": "application/json", - }); - const result = await bitbucket.deleteFile("foo.txt", { message: "Message", }); @@ -190,15 +127,10 @@ describe("store-bitbucket", () => { }); it("Throws error deleting a file", async () => { - nock(bitbucketUrl) - .post("/2.0/repositories/username/repo/src") - .replyWithError("Not found"); - await assert.rejects( - bitbucket.deleteFile("foo.txt", { message: "Message" }), + bitbucket.deleteFile("401.txt", { message: "Message" }), { - message: - "Bitbucket store: request to https://api.bitbucket.org/2.0/repositories/username/repo/src failed, reason: Not found", + message: "Bitbucket store: Unauthorized", }, ); });