From d742a961fb67009fe8103ae3df99488e9c72d6af Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:01:06 +0000 Subject: [PATCH 1/3] fix(core): use filesystem path for createRequire in events-db When events-db is bundled into the Next.js server, the bundler's createRequire shim rejects file:// URL strings with ERR_INVALID_ARG_VALUE, crashing the dashboard on startup. Convert import.meta.url with fileURLToPath before calling createRequire. Closes #2051 Co-Authored-By: Liang Huang --- .../fix-events-db-create-require-windows.md | 7 +++++++ packages/core/src/__tests__/events-db.test.ts | 19 +++++++++++++++++++ packages/core/src/events-db.ts | 6 +++++- 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-events-db-create-require-windows.md diff --git a/.changeset/fix-events-db-create-require-windows.md b/.changeset/fix-events-db-create-require-windows.md new file mode 100644 index 0000000000..c79f347d22 --- /dev/null +++ b/.changeset/fix-events-db-create-require-windows.md @@ -0,0 +1,7 @@ +--- +"@aoagents/ao-core": patch +--- + +fix: use a filesystem path for `createRequire` in events-db so the dashboard boots when bundled + +When `events-db.ts` is bundled into the Next.js server build, the bundler's `createRequire` shim only accepts absolute path strings and rejects `file://` URLs, so passing `import.meta.url` threw `ERR_INVALID_ARG_VALUE` and crashed the dashboard on startup. Convert the ESM URL with `fileURLToPath` before calling `createRequire`. Closes #2051. diff --git a/packages/core/src/__tests__/events-db.test.ts b/packages/core/src/__tests__/events-db.test.ts index f1f21237a8..19801b961d 100644 --- a/packages/core/src/__tests__/events-db.test.ts +++ b/packages/core/src/__tests__/events-db.test.ts @@ -1,3 +1,7 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -70,3 +74,18 @@ describe("activity-events DB unavailable warning", () => { expect(console.warn).toHaveBeenCalledTimes(1); }); }); + +describe("createRequire source pattern", () => { + const source = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "..", "events-db.ts"), + "utf8", + ); + + // When events-db is bundled into the Next.js server, the bundler's createRequire + // shim rejects file:// URL strings with ERR_INVALID_ARG_VALUE. The URL must be + // converted to a filesystem path first. See #2051. + it("converts import.meta.url to a path before calling createRequire", () => { + expect(source).not.toContain("createRequire(import.meta.url)"); + expect(source).toContain("createRequire(fileURLToPath(import.meta.url))"); + }); +}); diff --git a/packages/core/src/events-db.ts b/packages/core/src/events-db.ts index ca04f00623..9b84a02493 100644 --- a/packages/core/src/events-db.ts +++ b/packages/core/src/events-db.ts @@ -9,10 +9,14 @@ import { createRequire } from "node:module"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { getAoBaseDir } from "./paths.js"; // Use createRequire so we can try/catch on native module load without top-level await. -const _require = createRequire(import.meta.url); +// Pass a filesystem path, not import.meta.url directly: when this module is bundled +// into the Next.js server the bundler's createRequire shim only accepts absolute path +// strings and rejects file:// URLs with ERR_INVALID_ARG_VALUE. +const _require = createRequire(fileURLToPath(import.meta.url)); type BetterSqlite3Database = { pragma(source: string, options?: { simple?: boolean }): unknown; From e61a99d6525dde823487db33ad5157c2afc059a2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:13:44 +0000 Subject: [PATCH 2/3] chore: remove events-db createRequire changeset Co-Authored-By: Liang Huang --- .changeset/fix-events-db-create-require-windows.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .changeset/fix-events-db-create-require-windows.md diff --git a/.changeset/fix-events-db-create-require-windows.md b/.changeset/fix-events-db-create-require-windows.md deleted file mode 100644 index c79f347d22..0000000000 --- a/.changeset/fix-events-db-create-require-windows.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@aoagents/ao-core": patch ---- - -fix: use a filesystem path for `createRequire` in events-db so the dashboard boots when bundled - -When `events-db.ts` is bundled into the Next.js server build, the bundler's `createRequire` shim only accepts absolute path strings and rejects `file://` URLs, so passing `import.meta.url` threw `ERR_INVALID_ARG_VALUE` and crashed the dashboard on startup. Convert the ESM URL with `fileURLToPath` before calling `createRequire`. Closes #2051. From 1fcd9ad5feadef438c4d7e5c18639864700ecafb Mon Sep 17 00:00:00 2001 From: Shailesh Shivam Date: Fri, 27 Mar 2026 21:55:58 +0530 Subject: [PATCH 3/3] feat(plugins): add Bitbucket Cloud SCM and Jira Cloud tracker plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new plugins that extend Agent Orchestrator to work with Atlassian's ecosystem — Bitbucket Cloud for source control and Jira Cloud for issue tracking. **scm-bitbucket** — Full Bitbucket Cloud SCM implementation (783 lines, 19 tests): - PR lifecycle: detect by branch, create, merge (squash/merge/ff), close/decline - CI status: pipeline status aggregation with graceful fallback for scoped API token limitations (commit status endpoint returns 403) - Reviews: approval tracking, review decision aggregation, bot filtering - Comments: unresolved inline comment extraction, automated comment detection with severity inference - Webhooks: HMAC-SHA256 signature verification, event parsing for all PR/push events - Auth: Bitbucket API tokens (new) with legacy App Password fallback (deprecated Sep 2025, removed June 2026) **tracker-jira** — Full Jira Cloud tracker implementation (377 lines, 45 tests): - Issues: CRUD, JQL-based search via new /search/jql POST endpoint (old /search GET deprecated by Atlassian) - ADF conversion: Atlassian Document Format ↔ plaintext (178 lines, 30 tests) - Workflow: state transitions, label management, comment posting - Config: domain resolution, configurable project key and issue type - Security: JQL injection prevention via escapeJql() on all filter values **Integration across core, CLI, and web dashboard:** - Core: plugin registry entries, config inference for bitbucket→jira pairing, PR URL parsing for Bitbucket and GitLab URLs (fixes empty owner/repo that caused "rate limited" banner on dashboard) - CLI: dynamic import with graceful fallback when credentials not configured - Web: static imports, transpilePackages, try/catch on registration to prevent dashboard crash when Bitbucket/Jira credentials are not set Both plugins are hardened against API edge cases: - HTTP clients: retry with exponential backoff, 429 handling with clamped Retry-After (1-60s, NaN-safe), 30s request timeouts - Null-safety: all API response fields use optional chaining (deleted forks, removed users, missing participants, null content) - Auth errors: detectPR re-throws 401/403 instead of silently returning null - Input escaping: branch names in Bitbucket queries, filter values in JQL - Bitbucket /commit/{sha}/statuses/build returns 403 with scoped API tokens (Bitbucket-side gap) — getCIChecks returns [] and getCISummary reports "none" - Jira assignee updates require accountId (not display name) — logged as warning - Bitbucket PRs have no "assignee" concept — assignPRToCurrentUser is a no-op - Merge conflict detection: Bitbucket API doesn't expose conflicts, assumes no conflicts (merge endpoint fails safely if conflicts exist) ```yaml projects: my-app: repo: workspace/repo # Bitbucket workspace/repo-slug scm: plugin: bitbucket tracker: plugin: jira domain: yourcompany # or yourcompany.atlassian.net projectKey: PROJ # optional, for JQL filtering ``` Environment variables: - BITBUCKET_USERNAME (Atlassian email), BITBUCKET_API_TOKEN - JIRA_EMAIL, JIRA_API_TOKEN, JIRA_DOMAIN - Bitbucket Cloud REST API v2.0 (live instance — PR state, reviews, CI, merge readiness, webhook verification all verified) - Jira Cloud REST API v3 (live instance — project listing, issue search via /search/jql, issue creation, ADF rendering all verified) - Full E2E: Jira issue → ao spawn → agent codes → git push → Bitbucket PR created → dashboard shows session in "Ready" column with PR link Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 + packages/cli/package.json | 2 + packages/cli/src/lib/plugins.ts | 12 +- .../src/__tests__/config-generator.test.ts | 5 +- packages/core/src/__tests__/utils.test.ts | 20 +- packages/core/src/config-generator.ts | 9 +- packages/core/src/config.ts | 13 +- packages/core/src/plugin-registry.ts | 2 + packages/core/src/utils/pr.ts | 23 +- packages/plugins/scm-bitbucket/package.json | 42 + .../scm-bitbucket/src/__tests__/index.test.ts | 411 +++++++++ .../plugins/scm-bitbucket/src/http-client.ts | 118 +++ packages/plugins/scm-bitbucket/src/index.ts | 781 ++++++++++++++++++ packages/plugins/scm-bitbucket/src/types.ts | 99 +++ packages/plugins/scm-bitbucket/tsconfig.json | 8 + packages/plugins/tracker-jira/package.json | 42 + .../tracker-jira/src/__tests__/adf.test.ts | 599 ++++++++++++++ .../tracker-jira/src/__tests__/index.test.ts | 321 +++++++ packages/plugins/tracker-jira/src/adf.ts | 178 ++++ .../plugins/tracker-jira/src/http-client.ts | 107 +++ packages/plugins/tracker-jira/src/index.ts | 401 +++++++++ packages/plugins/tracker-jira/src/types.ts | 83 ++ packages/plugins/tracker-jira/tsconfig.json | 8 + packages/web/next.config.js | 2 + packages/web/package.json | 2 + packages/web/src/components/Dashboard.tsx | 2 +- .../__tests__/Dashboard.mobile.test.tsx | 4 +- packages/web/src/lib/services.ts | 8 + packages/web/vitest.config.ts | 12 + pnpm-lock.yaml | 44 + .../agent-orchestrator/references/config.md | 17 +- 31 files changed, 3357 insertions(+), 21 deletions(-) create mode 100644 packages/plugins/scm-bitbucket/package.json create mode 100644 packages/plugins/scm-bitbucket/src/__tests__/index.test.ts create mode 100644 packages/plugins/scm-bitbucket/src/http-client.ts create mode 100644 packages/plugins/scm-bitbucket/src/index.ts create mode 100644 packages/plugins/scm-bitbucket/src/types.ts create mode 100644 packages/plugins/scm-bitbucket/tsconfig.json create mode 100644 packages/plugins/tracker-jira/package.json create mode 100644 packages/plugins/tracker-jira/src/__tests__/adf.test.ts create mode 100644 packages/plugins/tracker-jira/src/__tests__/index.test.ts create mode 100644 packages/plugins/tracker-jira/src/adf.ts create mode 100644 packages/plugins/tracker-jira/src/http-client.ts create mode 100644 packages/plugins/tracker-jira/src/index.ts create mode 100644 packages/plugins/tracker-jira/src/types.ts create mode 100644 packages/plugins/tracker-jira/tsconfig.json diff --git a/.gitignore b/.gitignore index 44e7f9935f..91bc80c164 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ agent-orchestrator.yaml .DS_Store Thumbs.db package-lock.json + +# Personal developer notes (not for repo) +.developer/ diff --git a/packages/cli/package.json b/packages/cli/package.json index a7501c411b..f9f411817a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -51,10 +51,12 @@ "@aoagents/ao-plugin-notifier-webhook": "workspace:*", "@aoagents/ao-plugin-runtime-process": "workspace:*", "@aoagents/ao-plugin-runtime-tmux": "workspace:*", + "@aoagents/ao-plugin-scm-bitbucket": "workspace:*", "@aoagents/ao-plugin-scm-github": "workspace:*", "@aoagents/ao-plugin-terminal-iterm2": "workspace:*", "@aoagents/ao-plugin-terminal-web": "workspace:*", "@aoagents/ao-plugin-tracker-github": "workspace:*", + "@aoagents/ao-plugin-tracker-jira": "workspace:*", "@aoagents/ao-plugin-tracker-linear": "workspace:*", "@aoagents/ao-plugin-workspace-clone": "workspace:*", "@aoagents/ao-plugin-workspace-worktree": "workspace:*", diff --git a/packages/cli/src/lib/plugins.ts b/packages/cli/src/lib/plugins.ts index 3e7f2f3115..5997a04fc6 100644 --- a/packages/cli/src/lib/plugins.ts +++ b/packages/cli/src/lib/plugins.ts @@ -18,10 +18,20 @@ const agentPlugins: Record = { opencode: opencodePlugin, }; -const scmPlugins: Record = { +// SCM plugins — loaded lazily to avoid import errors when credentials are not set +const scmPlugins: Record): SCM }> = { github: githubSCMPlugin, }; +// Register optional SCM plugins (they may fail if package is not installed) +try { + const bitbucketPlugin = await import("@aoagents/ao-plugin-scm-bitbucket"); + scmPlugins.bitbucket = bitbucketPlugin.default ?? bitbucketPlugin; +} catch { + // Bitbucket plugin not available +} + + /** * Resolve the Agent plugin for a project (or fall back to the config default). * Direct import — no dynamic loading needed since the CLI depends on all agent plugins. diff --git a/packages/core/src/__tests__/config-generator.test.ts b/packages/core/src/__tests__/config-generator.test.ts index 1e6ce22f74..a7150ee0f3 100644 --- a/packages/core/src/__tests__/config-generator.test.ts +++ b/packages/core/src/__tests__/config-generator.test.ts @@ -316,15 +316,14 @@ describe("generateConfigFromUrl", () => { expect(project.tracker).toEqual({ plugin: "github" }); }); - it("sets bitbucket SCM and github tracker for Bitbucket repos", () => { + it("sets bitbucket SCM and jira tracker for Bitbucket repos", () => { const parsed = parseRepoUrl("https://bitbucket.org/team/app"); const config = generateConfigFromUrl({ parsed, repoPath: tmpDir }); const projects = config.projects as Record>; const project = projects.app; expect(project.scm).toEqual({ plugin: "bitbucket" }); - // Bitbucket tracker not implemented, falls back to github - expect(project.tracker).toEqual({ plugin: "github" }); + expect(project.tracker).toEqual({ plugin: "jira" }); }); it("does not set postCreate for non-JS projects", () => { diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts index e925ba31f3..ff9da0ae08 100644 --- a/packages/core/src/__tests__/utils.test.ts +++ b/packages/core/src/__tests__/utils.test.ts @@ -247,7 +247,16 @@ describe("parsePrFromUrl", () => { }); }); - it("falls back to trailing number for non-GitHub URLs", () => { + it("parses Bitbucket Cloud PR URLs", () => { + expect(parsePrFromUrl("https://bitbucket.org/myworkspace/myrepo/pull-requests/789")).toEqual({ + owner: "myworkspace", + repo: "myrepo", + number: 789, + url: "https://bitbucket.org/myworkspace/myrepo/pull-requests/789", + }); + }); + + it("parses GitLab merge request URLs", () => { expect(parsePrFromUrl("https://gitlab.com/foo/bar/-/merge_requests/456")).toEqual({ owner: "foo", repo: "bar", @@ -274,6 +283,15 @@ describe("parsePrFromUrl", () => { }); }); + it("falls back to trailing number for unknown SCM URLs", () => { + expect(parsePrFromUrl("https://custom-git.example.com/project/123")).toEqual({ + owner: "", + repo: "", + number: 123, + url: "https://custom-git.example.com/project/123", + }); + }); + it("returns null when the URL has no PR number", () => { expect(parsePrFromUrl("https://example.com/foo/bar/pull/not-a-number")).toBeNull(); }); diff --git a/packages/core/src/config-generator.ts b/packages/core/src/config-generator.ts index d4c5def0d6..037e258f9f 100644 --- a/packages/core/src/config-generator.ts +++ b/packages/core/src/config-generator.ts @@ -262,9 +262,14 @@ export function generateConfigFromUrl(options: GenerateConfigOptions): Record; tracker?: Record; -}): "github" | "gitlab" { +}): "github" | "gitlab" | "bitbucket" { const scmPlugin = project.scm?.["plugin"]; if (scmPlugin === "gitlab") { return "gitlab"; } + if (scmPlugin === "bitbucket") { + return "bitbucket"; + } const scmHost = project.scm?.["host"]; if (typeof scmHost === "string" && scmHost.toLowerCase().includes("gitlab")) { return "gitlab"; } + if (typeof scmHost === "string" && scmHost.toLowerCase().includes("bitbucket")) { + return "bitbucket"; + } const trackerPlugin = project.tracker?.["plugin"]; if (trackerPlugin === "gitlab") { @@ -605,9 +611,10 @@ function applyProjectDefaults(config: OrchestratorConfig): OrchestratorConfig { project.scm = { plugin: inferredPlugin }; } - // Infer tracker from repo if not set (default to github issues) + // Infer tracker from repo if not set (default to github issues). + // Bitbucket uses Jira for issue tracking (both Atlassian products). if (!project.tracker && project.repo?.includes("/")) { - project.tracker = { plugin: inferredPlugin }; + project.tracker = { plugin: inferredPlugin === "bitbucket" ? "jira" : inferredPlugin }; } } diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index 304d8a893c..32ee2cb1ee 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -55,9 +55,11 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = { slot: "tracker", name: "github", pkg: "@aoagents/ao-plugin-tracker-github" }, { slot: "tracker", name: "linear", pkg: "@aoagents/ao-plugin-tracker-linear" }, { slot: "tracker", name: "gitlab", pkg: "@aoagents/ao-plugin-tracker-gitlab" }, + { slot: "tracker", name: "jira", pkg: "@aoagents/ao-plugin-tracker-jira" }, // SCM { slot: "scm", name: "github", pkg: "@aoagents/ao-plugin-scm-github" }, { slot: "scm", name: "gitlab", pkg: "@aoagents/ao-plugin-scm-gitlab" }, + { slot: "scm", name: "bitbucket", pkg: "@aoagents/ao-plugin-scm-bitbucket" }, // Notifiers { slot: "notifier", name: "composio", pkg: "@aoagents/ao-plugin-notifier-composio" }, { slot: "notifier", name: "dashboard", pkg: "@aoagents/ao-plugin-notifier-dashboard" }, diff --git a/packages/core/src/utils/pr.ts b/packages/core/src/utils/pr.ts index b4a2d067db..f81f095d5c 100644 --- a/packages/core/src/utils/pr.ts +++ b/packages/core/src/utils/pr.ts @@ -23,6 +23,22 @@ export function parsePrFromUrl(prUrl: string): ParsedPrUrl | null { } } + // Bitbucket Cloud: bitbucket.org/{workspace}/{repo}/pull-requests/{id} + const bitbucketPullIndex = pathSegments.findIndex((segment) => segment === "pull-requests"); + if (bitbucketPullIndex >= 2 && bitbucketPullIndex + 1 < pathSegments.length) { + const owner = pathSegments[bitbucketPullIndex - 2]; + const repo = pathSegments[bitbucketPullIndex - 1]; + const prNumber = pathSegments[bitbucketPullIndex + 1]; + if (owner && repo && prNumber && /^\d+$/.test(prNumber)) { + return { + owner, + repo, + number: Number.parseInt(prNumber, 10), + url: prUrl, + }; + } + } + const gitlabMergeRequestIndex = pathSegments.findIndex( (segment, index) => segment === "-" && @@ -46,12 +62,7 @@ export function parsePrFromUrl(prUrl: string): ParsedPrUrl | null { const trailingNumberMatch = prUrl.match(TRAILING_NUMBER_REGEX); if (trailingNumberMatch) { - return { - owner: "", - repo: "", - number: parseInt(trailingNumberMatch[1], 10), - url: prUrl, - }; + return { owner: "", repo: "", number: parseInt(trailingNumberMatch[1], 10), url: prUrl }; } return null; diff --git a/packages/plugins/scm-bitbucket/package.json b/packages/plugins/scm-bitbucket/package.json new file mode 100644 index 0000000000..320459d1a2 --- /dev/null +++ b/packages/plugins/scm-bitbucket/package.json @@ -0,0 +1,42 @@ +{ + "name": "@aoagents/ao-plugin-scm-bitbucket", + "version": "0.1.0", + "description": "SCM plugin: Bitbucket Cloud (PRs, CI, reviews, webhooks)", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist"], + "repository": { + "type": "git", + "url": "https://github.com/ComposioHQ/agent-orchestrator.git", + "directory": "packages/plugins/scm-bitbucket" + }, + "homepage": "https://github.com/ComposioHQ/agent-orchestrator", + "bugs": { + "url": "https://github.com/ComposioHQ/agent-orchestrator/issues" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@aoagents/ao-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/scm-bitbucket/src/__tests__/index.test.ts b/packages/plugins/scm-bitbucket/src/__tests__/index.test.ts new file mode 100644 index 0000000000..cfe648e809 --- /dev/null +++ b/packages/plugins/scm-bitbucket/src/__tests__/index.test.ts @@ -0,0 +1,411 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { create, manifest } from "../index.js"; + +// --------------------------------------------------------------------------- +// Mock fetch globally +// --------------------------------------------------------------------------- +const fetchMock = vi.fn() as Mock; +vi.stubGlobal("fetch", fetchMock); + +function jsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Map([["content-type", "application/json"]]), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }; +} + +// Provide env vars so the plugin creates a real client +// Uses BITBUCKET_API_TOKEN (preferred) with legacy BITBUCKET_APP_PASSWORD fallback +beforeEach(() => { + vi.stubEnv("BITBUCKET_USERNAME", "testuser"); + vi.stubEnv("BITBUCKET_API_TOKEN", "testpass"); + fetchMock.mockReset(); +}); + +// --------------------------------------------------------------------------- +// Manifest +// --------------------------------------------------------------------------- + +describe("manifest", () => { + it("has correct name and slot", () => { + expect(manifest.name).toBe("bitbucket"); + expect(manifest.slot).toBe("scm"); + }); +}); + +// --------------------------------------------------------------------------- +// Plugin factory +// --------------------------------------------------------------------------- + +describe("create", () => { + it("returns an SCM object with required methods", () => { + const scm = create(); + expect(scm.name).toBe("bitbucket"); + expect(typeof scm.detectPR).toBe("function"); + expect(typeof scm.getPRState).toBe("function"); + expect(typeof scm.getCIChecks).toBe("function"); + expect(typeof scm.getCISummary).toBe("function"); + expect(typeof scm.getReviews).toBe("function"); + expect(typeof scm.getReviewDecision).toBe("function"); + expect(typeof scm.getPendingComments).toBe("function"); + expect(typeof scm.getReviewThreads).toBe("function"); + expect(typeof scm.getMergeability).toBe("function"); + expect(typeof scm.mergePR).toBe("function"); + expect(typeof scm.closePR).toBe("function"); + }); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const pr = { + number: 42, + url: "https://bitbucket.org/myws/myrepo/pull-requests/42", + title: "feat: add thing", + owner: "myws", + repo: "myrepo", + branch: "feat/thing", + baseBranch: "main", + isDraft: false, +}; + +const project = { + repo: "myws/myrepo", + path: "/tmp/myrepo", + defaultBranch: "main", +} as never; + +// --------------------------------------------------------------------------- +// getPRState +// --------------------------------------------------------------------------- + +describe("getPRState", () => { + it("maps OPEN to open", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ state: "OPEN" })); + const scm = create(); + expect(await scm.getPRState(pr)).toBe("open"); + }); + + it("maps MERGED to merged", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ state: "MERGED" })); + const scm = create(); + expect(await scm.getPRState(pr)).toBe("merged"); + }); + + it("maps DECLINED to closed", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ state: "DECLINED" })); + const scm = create(); + expect(await scm.getPRState(pr)).toBe("closed"); + }); + + it("maps SUPERSEDED to closed", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ state: "SUPERSEDED" })); + const scm = create(); + expect(await scm.getPRState(pr)).toBe("closed"); + }); +}); + +// --------------------------------------------------------------------------- +// getCIChecks +// --------------------------------------------------------------------------- + +describe("getCIChecks", () => { + it("maps Bitbucket commit statuses to CICheck", async () => { + // First call: get PR to find HEAD sha + fetchMock.mockResolvedValueOnce( + jsonResponse({ source: { commit: { hash: "abc123" } } }), + ); + // Second call: get commit statuses + fetchMock.mockResolvedValueOnce( + jsonResponse({ + values: [ + { + key: "build-1", + name: "CI Build", + state: "SUCCESSFUL", + url: "https://ci.example.com/1", + created_on: "2026-01-01T00:00:00Z", + updated_on: "2026-01-01T00:01:00Z", + }, + { + key: "lint-1", + name: "Lint", + state: "FAILED", + url: "https://ci.example.com/2", + created_on: "2026-01-01T00:00:00Z", + updated_on: "2026-01-01T00:01:00Z", + }, + { + key: "test-1", + name: "Tests", + state: "INPROGRESS", + url: "https://ci.example.com/3", + created_on: "2026-01-01T00:00:00Z", + updated_on: null, + }, + ], + }), + ); + + const scm = create(); + const checks = await scm.getCIChecks(pr); + + expect(checks).toHaveLength(3); + expect(checks[0].name).toBe("CI Build"); + expect(checks[0].status).toBe("passed"); + expect(checks[1].name).toBe("Lint"); + expect(checks[1].status).toBe("failed"); + expect(checks[2].name).toBe("Tests"); + expect(checks[2].status).toBe("running"); + }); +}); + +// --------------------------------------------------------------------------- +// getCISummary +// --------------------------------------------------------------------------- + +describe("getCISummary", () => { + it("returns failing when any check fails", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ source: { commit: { hash: "abc" } } }), + ); + fetchMock.mockResolvedValueOnce( + jsonResponse({ + values: [ + { key: "a", name: "A", state: "SUCCESSFUL", url: "", created_on: "", updated_on: "" }, + { key: "b", name: "B", state: "FAILED", url: "", created_on: "", updated_on: "" }, + ], + }), + ); + const scm = create(); + expect(await scm.getCISummary(pr)).toBe("failing"); + }); + + it("returns none when no statuses", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ source: { commit: { hash: "abc" } } }), + ); + fetchMock.mockResolvedValueOnce(jsonResponse({ values: [] })); + const scm = create(); + expect(await scm.getCISummary(pr)).toBe("none"); + }); +}); + +// --------------------------------------------------------------------------- +// getReviews +// --------------------------------------------------------------------------- + +describe("getReviews", () => { + it("extracts reviews from participants with REVIEWER role", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + participants: [ + { + user: { display_name: "Alice", uuid: "{a}", type: "user" }, + role: "REVIEWER", + approved: true, + state: "approved", + }, + { + user: { display_name: "Bob", uuid: "{b}", type: "user" }, + role: "REVIEWER", + approved: false, + state: "changes_requested", + }, + { + user: { display_name: "Author", uuid: "{c}", type: "user" }, + role: "AUTHOR", + approved: false, + state: null, + }, + ], + updated_on: "2026-01-01T00:00:00Z", + }), + ); + + const scm = create(); + const reviews = await scm.getReviews(pr); + + expect(reviews).toHaveLength(2); + expect(reviews[0].author).toBe("Alice"); + expect(reviews[0].state).toBe("approved"); + expect(reviews[1].author).toBe("Bob"); + expect(reviews[1].state).toBe("changes_requested"); + }); +}); + +// --------------------------------------------------------------------------- +// getReviewDecision +// --------------------------------------------------------------------------- + +describe("getReviewDecision", () => { + it("returns changes_requested when any reviewer requests changes", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + participants: [ + { user: { display_name: "A", uuid: "{a}", type: "user" }, role: "REVIEWER", state: "approved" }, + { user: { display_name: "B", uuid: "{b}", type: "user" }, role: "REVIEWER", state: "changes_requested" }, + ], + updated_on: "2026-01-01T00:00:00Z", + }), + ); + const scm = create(); + expect(await scm.getReviewDecision(pr)).toBe("changes_requested"); + }); + + it("returns approved when all reviewers approve", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + participants: [ + { user: { display_name: "A", uuid: "{a}", type: "user" }, role: "REVIEWER", state: "approved" }, + ], + updated_on: "2026-01-01T00:00:00Z", + }), + ); + const scm = create(); + expect(await scm.getReviewDecision(pr)).toBe("approved"); + }); + + it("returns none when no reviewers", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + participants: [ + { user: { display_name: "Author", uuid: "{a}", type: "user" }, role: "AUTHOR", state: null }, + ], + updated_on: "2026-01-01T00:00:00Z", + }), + ); + const scm = create(); + expect(await scm.getReviewDecision(pr)).toBe("none"); + }); +}); + +// --------------------------------------------------------------------------- +// mergePR +// --------------------------------------------------------------------------- + +describe("mergePR", () => { + it("calls merge endpoint with squash strategy by default", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({})); + const scm = create(); + await scm.mergePR(pr); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toContain("/pullrequests/42/merge"); + expect(opts.method).toBe("POST"); + const body = JSON.parse(opts.body); + expect(body.merge_strategy).toBe("squash"); + expect(body.close_source_branch).toBe(true); + }); + + it("maps rebase to fast_forward", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({})); + const scm = create(); + await scm.mergePR(pr, "rebase"); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.merge_strategy).toBe("fast_forward"); + }); +}); + +// --------------------------------------------------------------------------- +// closePR +// --------------------------------------------------------------------------- + +describe("closePR", () => { + it("calls decline endpoint", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({})); + const scm = create(); + await scm.closePR(pr); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toContain("/pullrequests/42/decline"); + expect(opts.method).toBe("POST"); + }); +}); + +// --------------------------------------------------------------------------- +// verifyWebhook +// --------------------------------------------------------------------------- + +describe("verifyWebhook", () => { + it("verifies HMAC-SHA256 signature", async () => { + const { createHmac } = await import("node:crypto"); + const secret = "test-secret"; + vi.stubEnv("BITBUCKET_WEBHOOK_SECRET", secret); + + const body = '{"test": true}'; + const sig = "sha256=" + createHmac("sha256", secret).update(body).digest("hex"); + + const scm = create(); + const result = await scm.verifyWebhook!( + { + method: "POST", + headers: { + "x-hub-signature": sig, + "x-event-key": "pullrequest:created", + "x-request-uuid": "delivery-123", + }, + body, + }, + Object.assign({}, project, { scm: { plugin: "bitbucket", webhook: { secretEnvVar: "BITBUCKET_WEBHOOK_SECRET" } } }) as never, + ); + + expect(result.ok).toBe(true); + expect(result.eventType).toBe("pullrequest:created"); + }); +}); + +// --------------------------------------------------------------------------- +// parseWebhook +// --------------------------------------------------------------------------- + +describe("parseWebhook", () => { + it("parses pullrequest:created event", async () => { + const scm = create(); + const event = await scm.parseWebhook!( + { + method: "POST", + headers: { + "x-event-key": "pullrequest:created", + "x-request-uuid": "del-1", + }, + body: JSON.stringify({ + repository: { full_name: "myws/myrepo" }, + pullrequest: { + id: 10, + source: { branch: { name: "feat/x" }, commit: { hash: "aaa" } }, + }, + }), + }, + project as never, + ); + + expect(event).not.toBeNull(); + expect(event!.kind).toBe("pull_request"); + expect(event!.action).toBe("opened"); + expect(event!.prNumber).toBe(10); + expect(event!.provider).toBe("bitbucket"); + }); + + it("parses repo:commit_status_updated as ci event", async () => { + const scm = create(); + const event = await scm.parseWebhook!( + { + method: "POST", + headers: { "x-event-key": "repo:commit_status_updated" }, + body: JSON.stringify({ repository: { full_name: "myws/myrepo" } }), + }, + project as never, + ); + + expect(event).not.toBeNull(); + expect(event!.kind).toBe("ci"); + }); +}); diff --git a/packages/plugins/scm-bitbucket/src/http-client.ts b/packages/plugins/scm-bitbucket/src/http-client.ts new file mode 100644 index 0000000000..b3d3a975b8 --- /dev/null +++ b/packages/plugins/scm-bitbucket/src/http-client.ts @@ -0,0 +1,118 @@ +import type { BbPaginatedResponse } from "./types.js"; + +export interface BitbucketClientConfig { + baseUrl?: string; + username: string; + /** API token (preferred) or legacy app password */ + apiToken: string; + timeout?: number; + maxRetries?: number; +} + +export function createBitbucketClient(config: BitbucketClientConfig) { + const baseUrl = config.baseUrl ?? "https://api.bitbucket.org/2.0"; + const timeout = config.timeout ?? 30_000; + const maxRetries = config.maxRetries ?? 3; + const authHeader = + "Basic " + Buffer.from(`${config.username}:${config.apiToken}`).toString("base64"); + + async function request( + method: string, + path: string, + body?: unknown, + params?: Record, + ): Promise { + const url = new URL(path.startsWith("https://") || path.startsWith("http://") ? path : `${baseUrl}${path}`); + if (params) { + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + } + + let lastError: Error | undefined; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + const res = await fetch(url.toString(), { + method, + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: body !== null && body !== undefined ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + clearTimeout(timer); + + if (res.status === 429) { + await res.body?.cancel().catch(() => {}); // release socket + const retryAfter = Math.min(60, Math.max(1, parseInt(res.headers.get("Retry-After") ?? "5", 10) || 5)); + await sleep(retryAfter * 1000); + continue; + } + if (res.status >= 500 && attempt < maxRetries) { + await res.body?.cancel().catch(() => {}); // release socket + await sleep(1000 * Math.pow(2, attempt)); + continue; + } + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Bitbucket API ${method} ${path} failed (${res.status}): ${text}`); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } catch (err) { + clearTimeout(timer); + if (err instanceof Error && err.name === "AbortError") { + lastError = new Error( + `Bitbucket API ${method} ${path} timed out after ${timeout}ms`, + ); + } else if (err instanceof Error && err.message.includes("Bitbucket API")) { + throw err; + } else { + lastError = err as Error; + } + if (attempt < maxRetries) { + await sleep(1000 * Math.pow(2, attempt)); + } + } + } + throw lastError ?? new Error(`Bitbucket API ${method} ${path} failed after ${maxRetries} retries`); + } + + async function paginate( + path: string, + params?: Record, + maxPages = 10, + ): Promise { + const allValues: T[] = []; + let nextUrl: string | undefined = undefined; + const mergedParams = { pagelen: "50", ...params }; + + for (let page = 0; page < maxPages; page++) { + const resp: BbPaginatedResponse = + page === 0 + ? await request>("GET", path, undefined, mergedParams) + : await request>("GET", nextUrl!, undefined); + allValues.push(...resp.values); + if (!resp.next) break; + nextUrl = resp.next; + } + return allValues; + } + + return { + get: (path: string, params?: Record) => + request("GET", path, undefined, params), + post: (path: string, body?: unknown) => request("POST", path, body), + put: (path: string, body?: unknown) => request("PUT", path, body), + del: (path: string) => request("DELETE", path), + paginate, + }; +} + +export type BitbucketClient = ReturnType; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/plugins/scm-bitbucket/src/index.ts b/packages/plugins/scm-bitbucket/src/index.ts new file mode 100644 index 0000000000..0c3dd3b7d9 --- /dev/null +++ b/packages/plugins/scm-bitbucket/src/index.ts @@ -0,0 +1,781 @@ +/** + * scm-bitbucket plugin — Bitbucket Cloud PRs, CI, reviews, webhooks. + * + * Uses the Bitbucket Cloud REST API v2.0 with API token authentication + * (app passwords deprecated Sep 2025, removed June 2026). + */ + +import { execFile } from "node:child_process"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import { promisify } from "node:util"; +import { + CI_STATUS, + type PluginModule, + type SCM, + type SCMWebhookEvent, + type SCMWebhookEventKind, + type SCMWebhookRequest, + type SCMWebhookVerificationResult, + type Session, + type ProjectConfig, + type PRInfo, + type PRState, + type MergeMethod, + type CICheck, + type CIStatus, + type Review, + type ReviewDecision, + type ReviewComment, + type ReviewSummary, + type ReviewThreadsResult, + type MergeReadiness, +} from "@aoagents/ao-core"; +import { + getWebhookHeader, + parseWebhookJsonObject, + parseWebhookTimestamp, +} from "@aoagents/ao-core/scm-webhook-utils"; + +import { createBitbucketClient, type BitbucketClient } from "./http-client.js"; +import type { + BbPullRequest, + BbComment, + BbCommitStatus, + BbDiffstatEntry, + BbPaginatedResponse, +} from "./types.js"; + +const execFileAsync = promisify(execFile); + +// --------------------------------------------------------------------------- +// Bot detection patterns +// --------------------------------------------------------------------------- + +const BOT_DISPLAY_NAMES = new Set([ + "Renovate Bot", + "Dependabot", + "Snyk Bot", + "SonarCloud", + "Codecov", + "CodeClimate", + "DeepSource", +]); + +function isBot(user: { display_name: string; type: string }): boolean { + if (user.type !== "user") return true; + const lower = user.display_name.toLowerCase(); + if (lower.includes("bot")) return true; + if (lower.includes("[bot]")) return true; + for (const name of BOT_DISPLAY_NAMES) { + if (lower === name.toLowerCase()) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseProjectRepo(projectRepo: string): [string, string] { + const parts = projectRepo.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error(`Invalid repo format "${projectRepo}", expected "workspace/repo-slug"`); + } + return [parts[0], parts[1]]; +} + +function repoPath(pr: PRInfo): string { + return `/repositories/${pr.owner}/${pr.repo}`; +} + +function prApiPath(pr: PRInfo): string { + return `${repoPath(pr)}/pullrequests/${pr.number}`; +} + +function parseDate(val: string | undefined | null): Date { + if (!val) return new Date(0); + const d = new Date(val); + return isNaN(d.getTime()) ? new Date(0) : d; +} + +function mapBbState(state: BbPullRequest["state"]): PRState { + if (state === "OPEN") return "open"; + if (state === "MERGED") return "merged"; + return "closed"; // DECLINED, SUPERSEDED +} + +function mapStatusToCheck(status: BbCommitStatus): CICheck { + let checkStatus: CICheck["status"]; + switch (status.state) { + case "SUCCESSFUL": + checkStatus = "passed"; + break; + case "FAILED": + checkStatus = "failed"; + break; + case "INPROGRESS": + checkStatus = "running"; + break; + case "STOPPED": + checkStatus = "failed"; + break; + default: + checkStatus = "pending"; + } + + return { + name: status.name || status.key, + status: checkStatus, + url: status.url || undefined, + conclusion: status.state, + startedAt: status.created_on ? new Date(status.created_on) : undefined, + completedAt: status.updated_on ? new Date(status.updated_on) : undefined, + }; +} + +function bbPrToInfo(pr: BbPullRequest, owner: string, repo: string): PRInfo { + // source.branch can be null when the source repository (fork) has been deleted + const branch = pr.source?.branch?.name ?? "unknown"; + const baseBranch = pr.destination?.branch?.name ?? "main"; + return { + number: pr.id, + url: pr.links?.html?.href ?? "", + title: pr.title, + owner, + repo, + branch, + baseBranch, + // Bitbucket Cloud has no native draft PR — convention is "WIP:" prefix + isDraft: pr.title.startsWith("WIP:") || pr.title.startsWith("WIP "), + }; +} + +// --------------------------------------------------------------------------- +// Webhook helpers +// --------------------------------------------------------------------------- + +function getBitbucketWebhookConfig(project: ProjectConfig) { + const webhook = project.scm?.webhook; + return { + enabled: webhook?.enabled !== false, + path: webhook?.path ?? "/api/webhooks/bitbucket", + secretEnvVar: webhook?.secretEnvVar, + signatureHeader: webhook?.signatureHeader ?? "x-hub-signature", + eventHeader: webhook?.eventHeader ?? "x-event-key", + deliveryHeader: webhook?.deliveryHeader ?? "x-request-uuid", + maxBodyBytes: webhook?.maxBodyBytes, + }; +} + +function verifyBitbucketSignature( + body: string | Uint8Array, + secret: string, + signatureHeader: string, +): boolean { + if (!signatureHeader.startsWith("sha256=")) return false; + const expected = createHmac("sha256", secret).update(body).digest("hex"); + const provided = signatureHeader.slice("sha256=".length); + const expectedBuffer = Buffer.from(expected, "hex"); + const providedBuffer = Buffer.from(provided, "hex"); + if (expectedBuffer.length !== providedBuffer.length) return false; + return timingSafeEqual(expectedBuffer, providedBuffer); +} + +/** Map Bitbucket x-event-key to our normalized event kind and action */ +function mapBitbucketEventKey( + eventKey: string, +): { kind: SCMWebhookEventKind; action: string } { + switch (eventKey) { + case "pullrequest:created": + return { kind: "pull_request", action: "opened" }; + case "pullrequest:updated": + return { kind: "pull_request", action: "synchronize" }; + case "pullrequest:fulfilled": + return { kind: "pull_request", action: "merged" }; + case "pullrequest:rejected": + return { kind: "pull_request", action: "closed" }; + case "pullrequest:approved": + return { kind: "review", action: "approved" }; + case "pullrequest:unapproved": + return { kind: "review", action: "dismissed" }; + case "pullrequest:changes_request_created": + return { kind: "review", action: "changes_requested" }; + case "pullrequest:comment_created": + return { kind: "comment", action: "created" }; + case "pullrequest:comment_updated": + return { kind: "comment", action: "updated" }; + case "repo:push": + return { kind: "push", action: "push" }; + case "repo:commit_status_created": + return { kind: "ci", action: "created" }; + case "repo:commit_status_updated": + return { kind: "ci", action: "updated" }; + default: + return { kind: "unknown", action: eventKey }; + } +} + +function parseBitbucketRepository(payload: Record) { + const repository = payload["repository"]; + if (!repository || typeof repository !== "object") return undefined; + const repo = repository as Record; + const fullName = repo["full_name"]; + if (typeof fullName === "string" && fullName.includes("/")) { + const parts = fullName.split("/"); + if (parts.length === 2 && parts[0] && parts[1]) { + return { owner: parts[0], name: parts[1] }; + } + } + return undefined; +} + +function parseBitbucketWebhookEvent( + request: SCMWebhookRequest, + payload: Record, + config: ReturnType, +): SCMWebhookEvent | null { + const rawEventType = getWebhookHeader(request.headers, config.eventHeader); + if (!rawEventType) return null; + + const deliveryId = getWebhookHeader(request.headers, config.deliveryHeader); + const repository = parseBitbucketRepository(payload); + const { kind, action } = mapBitbucketEventKey(rawEventType); + + const pullRequest = + payload["pullrequest"] && typeof payload["pullrequest"] === "object" + ? (payload["pullrequest"] as Record) + : undefined; + + // Extract PR number + let prNumber: number | undefined; + if (pullRequest && typeof pullRequest["id"] === "number") { + prNumber = pullRequest["id"] as number; + } + + // Extract branch + let branch: string | undefined; + if (pullRequest) { + const source = pullRequest["source"] as Record | undefined; + const sourceBranch = source?.["branch"] as Record | undefined; + if (typeof sourceBranch?.["name"] === "string") { + branch = sourceBranch["name"] as string; + } + } + + // Extract sha + let sha: string | undefined; + if (pullRequest) { + const source = pullRequest["source"] as Record | undefined; + const commit = source?.["commit"] as Record | undefined; + if (typeof commit?.["hash"] === "string") { + sha = commit["hash"] as string; + } + } + + // For push events, extract from the push payload + if (kind === "push") { + const push = payload["push"] as Record | undefined; + const changes = Array.isArray(push?.["changes"]) + ? (push!["changes"] as Array>) + : []; + const firstChange = changes[0]; + if (firstChange) { + const newTarget = firstChange["new"] as Record | undefined; + if (typeof newTarget?.["name"] === "string") { + branch = newTarget["name"] as string; + } + const target = newTarget?.["target"] as Record | undefined; + if (typeof target?.["hash"] === "string") { + sha = target["hash"] as string; + } + } + } + + // For CI events, extract from commit_status payload + if (kind === "ci") { + const commitStatus = payload["commit_status"] as Record | undefined; + const commit = commitStatus?.["commit"] as Record | undefined; + if (typeof commit?.["hash"] === "string") { + sha = commit["hash"] as string; + } + } + + // Extract timestamp + let timestamp: Date | undefined; + if (pullRequest) { + timestamp = parseWebhookTimestamp(pullRequest["updated_on"]); + } else if (kind === "push") { + const push = payload["push"] as Record | undefined; + const changes = Array.isArray(push?.["changes"]) + ? (push!["changes"] as Array>) + : []; + const firstChange = changes[0]; + const newTarget = firstChange?.["new"] as Record | undefined; + const target = newTarget?.["target"] as Record | undefined; + timestamp = parseWebhookTimestamp(target?.["date"]); + } else if (kind === "ci") { + const commitStatus = payload["commit_status"] as Record | undefined; + timestamp = parseWebhookTimestamp(commitStatus?.["updated_on"]); + } + + return { + provider: "bitbucket", + kind, + action, + rawEventType, + deliveryId, + repository, + prNumber, + branch, + sha, + timestamp, + data: payload, + }; +} + +// --------------------------------------------------------------------------- +// SCM implementation +// --------------------------------------------------------------------------- + +function createBitbucketSCM(config?: Record): SCM { + // Bitbucket Cloud deprecated App Passwords (Sep 2025) in favor of API Tokens. + // API Tokens use Basic auth with Atlassian email + API token (same wire format). + // We accept both: new BITBUCKET_API_TOKEN or legacy BITBUCKET_APP_PASSWORD. + const usernameEnvVar = + typeof config?.usernameEnvVar === "string" ? config.usernameEnvVar : "BITBUCKET_USERNAME"; + const tokenEnvVar = + typeof config?.tokenEnvVar === "string" + ? config.tokenEnvVar + : typeof config?.passwordEnvVar === "string" + ? config.passwordEnvVar + : undefined; + + const username = process.env[usernameEnvVar]; + const token = + tokenEnvVar + ? process.env[tokenEnvVar] + : process.env.BITBUCKET_API_TOKEN ?? process.env.BITBUCKET_APP_PASSWORD; + + if (!username || !token) { + throw new Error( + `Bitbucket credentials not found. Set ${usernameEnvVar} (Atlassian account email) ` + + `and BITBUCKET_API_TOKEN (API token from https://id.atlassian.com/manage-profile/security/api-tokens) ` + + `environment variables. Legacy BITBUCKET_APP_PASSWORD is also accepted but deprecated.`, + ); + } + + const baseUrl = + typeof config?.baseUrl === "string" ? config.baseUrl : undefined; + + const client: BitbucketClient = createBitbucketClient({ + username, + apiToken: token, + baseUrl, + }); + + return { + name: "bitbucket", + + async verifyWebhook( + request: SCMWebhookRequest, + project: ProjectConfig, + ): Promise { + const webhookConfig = getBitbucketWebhookConfig(project); + if (!webhookConfig.enabled) { + return { ok: false, reason: "Webhook is disabled for this project" }; + } + if (request.method.toUpperCase() !== "POST") { + return { ok: false, reason: "Webhook requests must use POST" }; + } + if ( + webhookConfig.maxBodyBytes !== undefined && + Buffer.byteLength(request.body, "utf8") > webhookConfig.maxBodyBytes + ) { + return { ok: false, reason: "Webhook payload exceeds configured maxBodyBytes" }; + } + + const eventType = getWebhookHeader(request.headers, webhookConfig.eventHeader); + if (!eventType) { + return { ok: false, reason: `Missing ${webhookConfig.eventHeader} header` }; + } + + const deliveryId = getWebhookHeader(request.headers, webhookConfig.deliveryHeader); + const secretName = webhookConfig.secretEnvVar; + if (!secretName) { + return { ok: true, deliveryId, eventType }; + } + + const secret = process.env[secretName]; + if (!secret) { + return { ok: false, reason: `Webhook secret env var ${secretName} is not configured` }; + } + + const signature = getWebhookHeader(request.headers, webhookConfig.signatureHeader); + if (!signature) { + return { ok: false, reason: `Missing ${webhookConfig.signatureHeader} header` }; + } + + if (!verifyBitbucketSignature(request.rawBody ?? request.body, secret, signature)) { + return { + ok: false, + reason: "Webhook signature verification failed", + deliveryId, + eventType, + }; + } + + return { ok: true, deliveryId, eventType }; + }, + + async parseWebhook( + request: SCMWebhookRequest, + project: ProjectConfig, + ): Promise { + const webhookConfig = getBitbucketWebhookConfig(project); + const payload = parseWebhookJsonObject(request.body); + return parseBitbucketWebhookEvent(request, payload, webhookConfig); + }, + + async detectPR(session: Session, project: ProjectConfig): Promise { + if (!session.branch || !project.repo) return null; + const [ws, repo] = parseProjectRepo(project.repo); + + try { + const safeBranch = session.branch.replace(/"/g, '\\"'); + const q = `source.branch.name="${safeBranch}" AND state="OPEN"`; + const resp = await client.get>( + `/repositories/${ws}/${repo}/pullrequests`, + { q, pagelen: "1" }, + ); + + if (!resp.values || resp.values.length === 0) return null; + return bbPrToInfo(resp.values[0], ws, repo); + } catch (err) { + // Re-throw auth errors so the user knows credentials are wrong + if (err instanceof Error && (err.message.includes("(401)") || err.message.includes("(403)"))) { + throw err; + } + return null; + } + }, + + async resolvePR(reference: string, project: ProjectConfig): Promise { + if (!project.repo) { + throw new Error("Cannot resolve PR: project has no repo configured"); + } + const [ws, repo] = parseProjectRepo(project.repo); + const pr = await client.get( + `/repositories/${ws}/${repo}/pullrequests/${reference}`, + ); + return bbPrToInfo(pr, ws, repo); + }, + + async assignPRToCurrentUser(_pr: PRInfo): Promise { + // Bitbucket Cloud PRs have no "assignee" concept — only reviewers + // (who review) and participants (who interact). Adding the PR author + // as a reviewer would be semantically wrong. The core handles this + // gracefully when the method is a no-op (claim-pr logs a warning). + }, + + async checkoutPR(pr: PRInfo, workspacePath: string): Promise { + const exec = (args: string[]) => + execFileAsync("git", args, { + cwd: workspacePath, + maxBuffer: 10 * 1024 * 1024, + timeout: 30_000, + }); + + const { stdout: currentBranch } = await exec(["branch", "--show-current"]); + if (currentBranch.trim() === pr.branch) return false; + + const { stdout: dirty } = await exec(["status", "--porcelain"]); + if (dirty.trim()) { + throw new Error( + `Workspace has uncommitted changes; cannot switch to PR branch "${pr.branch}" safely`, + ); + } + + // Fetch the source branch and force-update the local ref (handles + // both new branches and existing branches that need updating) + await exec(["fetch", "origin", pr.branch]); + await exec(["checkout", "-B", pr.branch, `origin/${pr.branch}`]); + return true; + }, + + async getPRState(pr: PRInfo): Promise { + const data = await client.get(prApiPath(pr)); + return mapBbState(data.state); + }, + + async getPRSummary(pr: PRInfo) { + const [data, diffstats] = await Promise.all([ + client.get(prApiPath(pr)), + client.paginate(`${prApiPath(pr)}/diffstat`, { pagelen: "100" }), + ]); + + let additions = 0; + let deletions = 0; + for (const entry of diffstats) { + additions += entry.lines_added ?? 0; + deletions += entry.lines_removed ?? 0; + } + + return { + state: mapBbState(data.state), + title: data.title ?? "", + additions, + deletions, + changedFiles: diffstats.length, + }; + }, + + async mergePR(pr: PRInfo, method: MergeMethod = "squash"): Promise { + let mergeStrategy: string; + switch (method) { + case "merge": + mergeStrategy = "merge_commit"; + break; + case "rebase": + mergeStrategy = "fast_forward"; + break; + case "squash": + default: + mergeStrategy = "squash"; + } + + await client.post(`${prApiPath(pr)}/merge`, { + merge_strategy: mergeStrategy, + close_source_branch: true, + }); + }, + + async closePR(pr: PRInfo): Promise { + await client.post(`${prApiPath(pr)}/decline`); + }, + + async getCIChecks(pr: PRInfo): Promise { + // Get HEAD commit SHA from the PR + const data = await client.get(prApiPath(pr)); + const sha = data.source?.commit?.hash; + if (!sha) return []; // source branch/commit may be null (deleted fork) + + try { + const statuses = await client.paginate( + `${repoPath(pr)}/commit/${sha}/statuses/build`, + { pagelen: "50" }, + ); + return statuses.map(mapStatusToCheck); + } catch (err) { + // The /commit/{sha}/statuses/build endpoint returns 403 with + // scoped API tokens (Bitbucket-side limitation). Return empty + // instead of failing the entire enrichment pipeline. + if (err instanceof Error && err.message.includes("(403)")) { + return []; + } + throw err; + } + }, + + async getCISummary(pr: PRInfo): Promise { + let checks: CICheck[]; + try { + checks = await this.getCIChecks(pr); + } catch { + try { + const state = await this.getPRState(pr); + if (state === "merged" || state === "closed") return "none"; + } catch { + // Cannot determine state either; fail closed. + } + return "failing"; + } + if (checks.length === 0) return "none"; + + const hasFailing = checks.some((c) => c.status === "failed"); + if (hasFailing) return "failing"; + + const hasPending = checks.some((c) => c.status === "pending" || c.status === "running"); + if (hasPending) return "pending"; + + const hasPassing = checks.some((c) => c.status === "passed"); + if (!hasPassing) return "none"; + + return "passing"; + }, + + async getReviews(pr: PRInfo): Promise { + const data = await client.get(prApiPath(pr)); + const reviewers = (data.participants ?? []).filter((p) => p.role === "REVIEWER"); + + return reviewers.map((p) => { + let state: Review["state"]; + if (p.state === "approved") { + state = "approved"; + } else if (p.state === "changes_requested") { + state = "changes_requested"; + } else { + state = "commented"; + } + + return { + author: p.user?.display_name ?? "unknown", + state, + submittedAt: parseDate(data.updated_on), + }; + }); + }, + + async getReviewDecision(pr: PRInfo): Promise { + const data = await client.get(prApiPath(pr)); + const reviewers = (data.participants ?? []).filter((p) => p.role === "REVIEWER"); + + if (reviewers.length === 0) return "none"; + + const hasChangesRequested = reviewers.some((p) => p.state === "changes_requested"); + if (hasChangesRequested) return "changes_requested"; + + const hasApproved = reviewers.some((p) => p.state === "approved"); + if (hasApproved) return "approved"; + + return "pending"; + }, + + async getPendingComments(pr: PRInfo): Promise { + const comments = await client.paginate( + `${prApiPath(pr)}/comments`, + { pagelen: "50" }, + ); + + return comments + .filter((c) => { + if (c.deleted) return false; + if (!c.inline) return false; // only file-level (inline) comments + if (c.resolution !== null && c.resolution !== undefined) return false; // resolved + if (isBot(c.user)) return false; + return true; + }) + .map((c) => ({ + id: String(c.id), + author: c.user.display_name, + body: c.content?.raw ?? "", + path: c.inline?.path, + line: c.inline?.to ?? c.inline?.from ?? undefined, + isResolved: false, + createdAt: parseDate(c.created_on), + url: c.links?.html?.href ?? "", + })); + }, + + async getReviewThreads(pr: PRInfo): Promise { + const comments = await client.paginate( + `${prApiPath(pr)}/comments`, + { pagelen: "50" }, + ); + + // Unresolved inline review threads (both human and bot), tagged with isBot. + const threads: ReviewComment[] = comments + .filter((c) => { + if (c.deleted) return false; + if (!c.inline) return false; // only file-level (inline) comments + if (c.resolution !== null && c.resolution !== undefined) return false; // resolved + return true; + }) + .map((c) => ({ + id: String(c.id), + author: c.user.display_name, + body: c.content?.raw ?? "", + path: c.inline?.path, + line: c.inline?.to ?? c.inline?.from ?? undefined, + isResolved: false, + createdAt: parseDate(c.created_on), + url: c.links?.html?.href ?? "", + isBot: isBot(c.user), + })); + + // Bitbucket has no first-class review summary concept like GitHub. + const reviews: ReviewSummary[] = []; + + return { threads, reviews }; + }, + + async getMergeability(pr: PRInfo): Promise { + const data = await client.get(prApiPath(pr)); + const state = mapBbState(data.state); + + if (state === "merged") { + return { + mergeable: true, + ciPassing: true, + approved: true, + noConflicts: true, + blockers: [], + }; + } + + if (state === "closed") { + return { + mergeable: false, + ciPassing: false, + approved: false, + noConflicts: true, + blockers: ["PR is closed (declined/superseded)"], + }; + } + + const blockers: string[] = []; + + // CI + const ciStatus = await this.getCISummary(pr); + const ciPassing = ciStatus === CI_STATUS.PASSING || ciStatus === CI_STATUS.NONE; + if (!ciPassing) { + blockers.push(`CI is ${ciStatus}`); + } + + // Reviews + const reviewDecision = await this.getReviewDecision(pr); + const approved = reviewDecision === "approved"; + if (reviewDecision === "changes_requested") { + blockers.push("Changes requested in review"); + } else if (reviewDecision === "pending") { + blockers.push("Review pending"); + } + + // Conflicts — Bitbucket doesn't expose merge conflicts directly in the + // PR object, but we can attempt a dry-run merge check. For simplicity, + // assume no conflicts if the PR is in OPEN state (the merge endpoint + // will fail if there are actual conflicts). + const noConflicts = true; + + // Draft (convention-based) + const isDraft = data.title.startsWith("WIP:") || data.title.startsWith("WIP "); + if (isDraft) { + blockers.push("PR is marked as WIP (draft)"); + } + + return { + mergeable: blockers.length === 0, + ciPassing, + approved, + noConflicts, + blockers, + }; + }, + }; +} + +// --------------------------------------------------------------------------- +// Plugin module export +// --------------------------------------------------------------------------- + +export const manifest = { + name: "bitbucket", + slot: "scm" as const, + description: "Bitbucket Cloud SCM — PRs, CI, reviews, webhooks", + version: "0.1.0", +}; + +export function create(config?: Record): SCM { + return createBitbucketSCM(config); +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/scm-bitbucket/src/types.ts b/packages/plugins/scm-bitbucket/src/types.ts new file mode 100644 index 0000000000..4565414e99 --- /dev/null +++ b/packages/plugins/scm-bitbucket/src/types.ts @@ -0,0 +1,99 @@ +// Bitbucket Cloud REST API v2.0 response types + +export interface BbPaginatedResponse { + pagelen: number; + size?: number; + page?: number; + next?: string; + previous?: string; + values: T[]; +} + +export interface BbUser { + display_name: string; + uuid: string; + nickname?: string; + type: string; + account_id?: string; +} + +export interface BbBranch { + name: string; +} + +export interface BbCommit { + hash: string; + date?: string; +} + +export interface BbPullRequest { + id: number; + title: string; + description: string; + state: "OPEN" | "MERGED" | "DECLINED" | "SUPERSEDED"; + source: { + branch: BbBranch | null; // null when fork repo is deleted + commit: BbCommit | null; + repository?: { full_name: string }; + } | null; + destination: { + branch: BbBranch | null; + commit?: BbCommit | null; + } | null; + author: BbUser; + participants: BbParticipant[]; + close_source_branch: boolean; + created_on: string; + updated_on: string; + comment_count: number; + task_count: number; + merge_commit?: BbCommit | null; + links: { + html: { href: string }; + diff?: { href: string }; + diffstat?: { href: string }; + }; +} + +export interface BbParticipant { + user: BbUser; + role: "PARTICIPANT" | "REVIEWER" | "AUTHOR"; + approved: boolean; + state: "approved" | "changes_requested" | null; +} + +export interface BbCommitStatus { + key: string; + state: "SUCCESSFUL" | "FAILED" | "INPROGRESS" | "STOPPED"; + name: string; + url: string; + description?: string; + created_on: string; + updated_on: string; +} + +export interface BbComment { + id: number; + content: { raw: string; markup: string; html?: string }; + user: BbUser; + created_on: string; + updated_on: string; + inline?: { + from?: number | null; + to?: number | null; + path: string; + }; + parent?: { id: number }; + deleted: boolean; + resolution?: { user: BbUser; created_on: string } | null; + links: { html: { href: string } }; +} + +export interface BbDiffstatEntry { + type: string; + status: string; + lines_added: number; + lines_removed: number; + old?: { path: string }; + new?: { path: string }; +} diff --git a/packages/plugins/scm-bitbucket/tsconfig.json b/packages/plugins/scm-bitbucket/tsconfig.json new file mode 100644 index 0000000000..e1b71318a6 --- /dev/null +++ b/packages/plugins/scm-bitbucket/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/plugins/tracker-jira/package.json b/packages/plugins/tracker-jira/package.json new file mode 100644 index 0000000000..efaa1395b5 --- /dev/null +++ b/packages/plugins/tracker-jira/package.json @@ -0,0 +1,42 @@ +{ + "name": "@aoagents/ao-plugin-tracker-jira", + "version": "0.1.0", + "description": "Tracker plugin: Jira Cloud (issues, JQL search, transitions)", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist"], + "repository": { + "type": "git", + "url": "https://github.com/ComposioHQ/agent-orchestrator.git", + "directory": "packages/plugins/tracker-jira" + }, + "homepage": "https://github.com/ComposioHQ/agent-orchestrator", + "bugs": { + "url": "https://github.com/ComposioHQ/agent-orchestrator/issues" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@aoagents/ao-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/tracker-jira/src/__tests__/adf.test.ts b/packages/plugins/tracker-jira/src/__tests__/adf.test.ts new file mode 100644 index 0000000000..0d0474a86a --- /dev/null +++ b/packages/plugins/tracker-jira/src/__tests__/adf.test.ts @@ -0,0 +1,599 @@ +import { describe, it, expect } from "vitest"; +import { adfToPlainText, plainTextToAdf } from "../adf.js"; +import type { AdfNode } from "../types.js"; + +// --------------------------------------------------------------------------- +// adfToPlainText +// --------------------------------------------------------------------------- + +describe("adfToPlainText", () => { + it("returns empty string for null input", () => { + expect(adfToPlainText(null)).toBe(""); + }); + + it("returns empty string for undefined input", () => { + expect(adfToPlainText(undefined)).toBe(""); + }); + + it("converts a simple paragraph", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello, world!" }], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("Hello, world!"); + }); + + it("converts multiple paragraphs separated by double newlines", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "First paragraph." }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Second paragraph." }], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("First paragraph.\n\nSecond paragraph."); + }); + + it("converts heading levels", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Title" }], + }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Subtitle" }], + }, + { + type: "heading", + attrs: { level: 3 }, + content: [{ type: "text", text: "Section" }], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("# Title\n\n## Subtitle\n\n### Section"); + }); + + it("handles bold, italic, and code marks", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "bold", marks: [{ type: "bold" }] }, + { type: "text", text: " " }, + { type: "text", text: "italic", marks: [{ type: "italic" }] }, + { type: "text", text: " " }, + { type: "text", text: "code", marks: [{ type: "code" }] }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("**bold** *italic* `code`"); + }); + + it("handles strong and em marks", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "strong", marks: [{ type: "strong" }] }, + { type: "text", text: " " }, + { type: "text", text: "emphasis", marks: [{ type: "em" }] }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("**strong** *emphasis*"); + }); + + it("handles link marks", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Click here", + marks: [{ type: "link", attrs: { href: "https://example.com" } }], + }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("Click here (https://example.com)"); + }); + + it("converts bullet lists", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Item A" }] }, + ], + }, + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Item B" }] }, + ], + }, + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Item C" }] }, + ], + }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("- Item A\n- Item B\n- Item C"); + }); + + it("converts ordered lists", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "orderedList", + content: [ + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "First" }] }, + ], + }, + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Second" }] }, + ], + }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("1. First\n2. Second"); + }); + + it("converts code blocks", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "codeBlock", + attrs: { language: "typescript" }, + content: [{ type: "text", text: 'const x = 42;\nconsole.log(x);' }], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("```\nconst x = 42;\nconsole.log(x);\n```"); + }); + + it("converts blockquotes", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "blockquote", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Quoted text" }], + }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("> Quoted text"); + }); + + it("handles hardBreak nodes", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Line 1" }, + { type: "hardBreak" }, + { type: "text", text: "Line 2" }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("Line 1\nLine 2"); + }); + + it("handles rule nodes", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { type: "paragraph", content: [{ type: "text", text: "Above" }] }, + { type: "rule" }, + { type: "paragraph", content: [{ type: "text", text: "Below" }] }, + ], + }; + expect(adfToPlainText(adf)).toBe("Above\n\n---\n\nBelow"); + }); + + it("handles mention nodes", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Hey " }, + { type: "mention", attrs: { text: "John Doe", id: "abc123" } }, + { type: "text", text: ", please review" }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("Hey @John Doe, please review"); + }); + + it("handles mention node without text attr", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [{ type: "mention", attrs: { id: "abc123" } }], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("@unknown"); + }); + + it("handles emoji nodes", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Great " }, + { type: "emoji", attrs: { shortName: ":thumbsup:" } }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("Great :thumbsup:"); + }); + + it("handles media nodes", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "mediaSingle", + content: [ + { type: "media", attrs: { type: "file", id: "abc" } }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("[media]"); + }); + + it("handles table nodes", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableHeader", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Name" }] }, + ], + }, + ], + }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("[table]"); + }); + + it("handles unknown node types with content", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "unknownBlock", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Fallback content" }] }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe("Fallback content"); + }); + + it("handles unknown node types without content", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { type: "unknownEmptyNode" }, + ], + }; + expect(adfToPlainText(adf)).toBe(""); + }); + + it("handles nested content (heading + bullet list + code block)", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Steps" }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Step one" }] }, + ], + }, + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Step two" }] }, + ], + }, + ], + }, + { + type: "codeBlock", + content: [{ type: "text", text: "npm install" }], + }, + ], + }; + expect(adfToPlainText(adf)).toBe( + "## Steps\n\n- Step one\n- Step two\n\n```\nnpm install\n```", + ); + }); + + it("converts a complex real-world ADF document", () => { + const adf: AdfNode = { + type: "doc", + version: 1, + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Bug Report" }], + }, + { + type: "paragraph", + content: [ + { type: "text", text: "When clicking the " }, + { type: "text", text: "Submit", marks: [{ type: "bold" }] }, + { type: "text", text: " button, the form " }, + { type: "text", text: "silently fails", marks: [{ type: "italic" }] }, + { type: "text", text: "." }, + ], + }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Steps to Reproduce" }], + }, + { + type: "orderedList", + content: [ + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Open the form" }] }, + ], + }, + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Fill in all fields" }] }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Click " }, + { type: "text", text: "Submit", marks: [{ type: "code" }] }, + ], + }, + ], + }, + ], + }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Error Log" }], + }, + { + type: "codeBlock", + attrs: { language: "javascript" }, + content: [{ type: "text", text: "TypeError: Cannot read property 'id' of null" }], + }, + { + type: "paragraph", + content: [ + { type: "text", text: "CC " }, + { type: "mention", attrs: { text: "Alice", id: "user1" } }, + { type: "text", text: " " }, + { type: "emoji", attrs: { shortName: ":warning:" } }, + ], + }, + ], + }; + + const expected = [ + "# Bug Report", + "", + "When clicking the **Submit** button, the form *silently fails*.", + "", + "## Steps to Reproduce", + "", + "1. Open the form", + "2. Fill in all fields", + "3. Click `Submit`", + "", + "## Error Log", + "", + "```", + "TypeError: Cannot read property 'id' of null", + "```", + "", + "CC @Alice :warning:", + ].join("\n"); + + expect(adfToPlainText(adf)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// plainTextToAdf +// --------------------------------------------------------------------------- + +describe("plainTextToAdf", () => { + it("converts empty text to doc with empty paragraph", () => { + const result = plainTextToAdf(""); + expect(result).toEqual({ + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "" }], + }, + ], + }); + }); + + it("converts whitespace-only text to doc with empty paragraph", () => { + const result = plainTextToAdf(" "); + expect(result).toEqual({ + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "" }], + }, + ], + }); + }); + + it("converts single paragraph text", () => { + const result = plainTextToAdf("Hello, world!"); + expect(result).toEqual({ + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello, world!" }], + }, + ], + }); + }); + + it("splits on double newlines into paragraphs", () => { + const result = plainTextToAdf("First paragraph.\n\nSecond paragraph."); + expect(result).toEqual({ + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "First paragraph." }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Second paragraph." }], + }, + ], + }); + }); + + it("handles triple+ newlines (collapses to paragraph break)", () => { + const result = plainTextToAdf("A\n\n\n\nB"); + expect(result.content).toHaveLength(2); + expect(result.content![0].content![0].text).toBe("A"); + expect(result.content![1].content![0].text).toBe("B"); + }); + + it("round-trips simple text through adfToPlainText", () => { + const text = "Hello world.\n\nSecond paragraph."; + const adf = plainTextToAdf(text); + const back = adfToPlainText(adf); + expect(back).toBe(text); + }); + + it("preserves single newlines within paragraphs", () => { + const text = "Line one\nLine two"; + const adf = plainTextToAdf(text); + expect(adf.content).toHaveLength(1); + expect(adf.content![0].content![0].text).toBe("Line one\nLine two"); + }); +}); diff --git a/packages/plugins/tracker-jira/src/__tests__/index.test.ts b/packages/plugins/tracker-jira/src/__tests__/index.test.ts new file mode 100644 index 0000000000..d25c6d1a0e --- /dev/null +++ b/packages/plugins/tracker-jira/src/__tests__/index.test.ts @@ -0,0 +1,321 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { create, manifest } from "../index.js"; + +// --------------------------------------------------------------------------- +// Mock fetch globally +// --------------------------------------------------------------------------- +const fetchMock = vi.fn() as Mock; +vi.stubGlobal("fetch", fetchMock); + +function jsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Map([["content-type", "application/json"]]), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }; +} + +const project = { + repo: "myws/myrepo", + path: "/tmp/myrepo", + defaultBranch: "main", + tracker: { plugin: "jira", domain: "mycompany", projectKey: "PROJ" }, +} as never; + +beforeEach(() => { + vi.stubEnv("JIRA_DOMAIN", "mycompany"); + vi.stubEnv("JIRA_EMAIL", "test@example.com"); + vi.stubEnv("JIRA_API_TOKEN", "test-token"); + fetchMock.mockReset(); +}); + +// --------------------------------------------------------------------------- +// Manifest +// --------------------------------------------------------------------------- + +describe("manifest", () => { + it("has correct name and slot", () => { + expect(manifest.name).toBe("jira"); + expect(manifest.slot).toBe("tracker"); + }); +}); + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +describe("create", () => { + it("returns a Tracker with required methods", () => { + const tracker = create({ domain: "mycompany" }); + expect(tracker.name).toBe("jira"); + expect(typeof tracker.getIssue).toBe("function"); + expect(typeof tracker.isCompleted).toBe("function"); + expect(typeof tracker.issueUrl).toBe("function"); + expect(typeof tracker.branchName).toBe("function"); + expect(typeof tracker.generatePrompt).toBe("function"); + }); +}); + +// --------------------------------------------------------------------------- +// Helpers for mock responses +// --------------------------------------------------------------------------- + +function jiraIssue(overrides: Record = {}) { + return { + id: "10001", + key: "PROJ-123", + self: "https://mycompany.atlassian.net/rest/api/3/issue/10001", + fields: { + summary: "Fix the bug", + description: { + type: "doc", + version: 1, + content: [ + { type: "paragraph", content: [{ type: "text", text: "Description text here" }] }, + ], + }, + status: { + id: "3", + name: "In Progress", + statusCategory: { id: 4, key: "indeterminate", name: "In Progress" }, + }, + issuetype: { id: "10001", name: "Story" }, + priority: { id: "3", name: "Medium" }, + labels: ["backend", "urgent"], + assignee: { + accountId: "abc123", + displayName: "Alice Smith", + emailAddress: "alice@example.com", + }, + project: { key: "PROJ", name: "My Project" }, + created: "2026-01-01T00:00:00.000+0000", + updated: "2026-01-15T00:00:00.000+0000", + }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// getIssue +// --------------------------------------------------------------------------- + +describe("getIssue", () => { + it("maps Jira issue to core Issue type", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(jiraIssue())); + + const tracker = create({ domain: "mycompany" }); + const issue = await tracker.getIssue("PROJ-123", project); + + expect(issue.id).toBe("PROJ-123"); + expect(issue.title).toBe("Fix the bug"); + expect(issue.description).toContain("Description text here"); + expect(issue.state).toBe("in_progress"); + expect(issue.labels).toEqual(["backend", "urgent"]); + expect(issue.assignee).toBe("Alice Smith"); + expect(issue.priority).toBe(3); + expect(issue.url).toBe("https://mycompany.atlassian.net/browse/PROJ-123"); + }); + + it("handles done status", async () => { + const done = jiraIssue(); + (done.fields as Record).status = { + id: "5", + name: "Done", + statusCategory: { id: 3, key: "done", name: "Done" }, + }; + fetchMock.mockResolvedValueOnce(jsonResponse(done)); + + const tracker = create({ domain: "mycompany" }); + const issue = await tracker.getIssue("PROJ-456", project); + expect(issue.state).toBe("closed"); + }); +}); + +// --------------------------------------------------------------------------- +// isCompleted +// --------------------------------------------------------------------------- + +describe("isCompleted", () => { + it("returns true when status category is done", async () => { + const done = jiraIssue(); + (done.fields as Record).status = { + id: "5", + name: "Done", + statusCategory: { id: 3, key: "done", name: "Done" }, + }; + fetchMock.mockResolvedValueOnce(jsonResponse(done)); + + const tracker = create({ domain: "mycompany" }); + expect(await tracker.isCompleted("PROJ-123", project)).toBe(true); + }); + + it("returns false when status category is not done", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(jiraIssue())); + + const tracker = create({ domain: "mycompany" }); + expect(await tracker.isCompleted("PROJ-123", project)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// issueUrl / issueLabel / branchName +// --------------------------------------------------------------------------- + +describe("issueUrl", () => { + it("generates correct URL", () => { + const tracker = create({ domain: "mycompany" }); + expect(tracker.issueUrl("PROJ-123", project)).toBe( + "https://mycompany.atlassian.net/browse/PROJ-123", + ); + }); + + it("handles full domain", () => { + const tracker = create({ domain: "mycompany.atlassian.net" }); + expect(tracker.issueUrl("PROJ-123", project)).toBe( + "https://mycompany.atlassian.net/browse/PROJ-123", + ); + }); +}); + +describe("issueLabel", () => { + it("extracts key from URL", () => { + const tracker = create({ domain: "mycompany" }); + expect(tracker.issueLabel!("https://mycompany.atlassian.net/browse/PROJ-123", project)).toBe( + "PROJ-123", + ); + }); +}); + +describe("branchName", () => { + it("generates feat/KEY branch name", () => { + const tracker = create({ domain: "mycompany" }); + expect(tracker.branchName("PROJ-123", project)).toBe("feat/PROJ-123"); + }); +}); + +// --------------------------------------------------------------------------- +// generatePrompt +// --------------------------------------------------------------------------- + +describe("generatePrompt", () => { + it("generates prompt with issue details", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(jiraIssue())); + + const tracker = create({ domain: "mycompany" }); + const prompt = await tracker.generatePrompt("PROJ-123", project); + + expect(prompt).toContain("PROJ-123"); + expect(prompt).toContain("Fix the bug"); + expect(prompt).toContain("Description text here"); + expect(prompt).toContain("mycompany.atlassian.net/browse/PROJ-123"); + }); +}); + +// --------------------------------------------------------------------------- +// listIssues +// --------------------------------------------------------------------------- + +describe("listIssues", () => { + it("builds JQL from filters", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + startAt: 0, + maxResults: 30, + total: 1, + issues: [jiraIssue()], + }), + ); + + const tracker = create({ domain: "mycompany", projectKey: "PROJ" }); + const issues = await tracker.listIssues!( + { state: "open", labels: ["backend"], limit: 10 }, + project, + ); + + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe("PROJ-123"); + + // Verify JQL was sent + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.jql).toContain('project = "PROJ"'); + expect(body.jql).toContain('statusCategory != "Done"'); + expect(body.jql).toContain('labels in ("backend")'); + expect(body.maxResults).toBe(10); + }); + + it("escapes JQL special characters in filter values to prevent injection", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + startAt: 0, + maxResults: 30, + total: 0, + issues: [], + }), + ); + + const tracker = create({ domain: "mycompany", projectKey: "PROJ" }); + await tracker.listIssues!( + { + state: "open", + labels: ['label"with"quotes'], + assignee: 'user" OR project = "SECRET', + }, + project, + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + // Quotes must be escaped — no JQL breakout + expect(body.jql).toContain('labels in ("label\\"with\\"quotes")'); + expect(body.jql).toContain('assignee = "user\\" OR project = \\"SECRET"'); + // The injected clause should NOT appear as a separate JQL clause + expect(body.jql).not.toMatch(/AND project = "SECRET"/); + }); +}); + +// --------------------------------------------------------------------------- +// updateIssue — transitions +// --------------------------------------------------------------------------- + +describe("updateIssue", () => { + it("executes a transition to close an issue", async () => { + // GET transitions + fetchMock.mockResolvedValueOnce( + jsonResponse({ + transitions: [ + { + id: "31", + name: "Done", + to: { + id: "5", + name: "Done", + statusCategory: { id: 3, key: "done", name: "Done" }, + }, + hasScreen: false, + }, + ], + }), + ); + // POST transition + fetchMock.mockResolvedValueOnce(jsonResponse(null, 204)); + + const tracker = create({ domain: "mycompany" }); + await tracker.updateIssue!("PROJ-123", { state: "closed" }, project); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const transitionBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(transitionBody.transition.id).toBe("31"); + }); + + it("posts a comment when provided", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ id: "comment-1" })); + + const tracker = create({ domain: "mycompany" }); + await tracker.updateIssue!("PROJ-123", { comment: "Hello from AO" }, project); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.body.type).toBe("doc"); + expect(body.body.content[0].content[0].text).toBe("Hello from AO"); + }); +}); diff --git a/packages/plugins/tracker-jira/src/adf.ts b/packages/plugins/tracker-jira/src/adf.ts new file mode 100644 index 0000000000..1bd4907280 --- /dev/null +++ b/packages/plugins/tracker-jira/src/adf.ts @@ -0,0 +1,178 @@ +/** + * ADF (Atlassian Document Format) <-> plain text converter. + * + * Handles conversion between Jira's rich-text ADF format and plain text + * for use in issue descriptions and comments. + */ + +import type { AdfNode } from "./types.js"; + +// --------------------------------------------------------------------------- +// ADF -> Plain Text +// --------------------------------------------------------------------------- + +/** + * Convert an ADF document node to plain text. + * Recursively traverses the ADF tree and produces a Markdown-like representation. + */ +export function adfToPlainText(node: AdfNode | null | undefined): string { + if (!node) return ""; + return renderNode(node).trim(); +} + +function renderNode(node: AdfNode): string { + switch (node.type) { + case "doc": + return renderChildren(node, "\n\n"); + + case "paragraph": + return renderChildren(node, ""); + + case "heading": { + const level = (node.attrs?.["level"] as number) ?? 1; + const prefix = "#".repeat(level) + " "; + return prefix + renderChildren(node, ""); + } + + case "text": + return renderTextNode(node); + + case "bulletList": + return renderListItems(node, "bullet"); + + case "orderedList": + return renderListItems(node, "ordered"); + + case "listItem": + return renderChildren(node, "\n"); + + case "codeBlock": { + const code = renderChildren(node, ""); + return "```\n" + code + "\n```"; + } + + case "blockquote": { + const inner = renderChildren(node, "\n\n"); + return inner + .split("\n") + .map((line) => "> " + line) + .join("\n"); + } + + case "hardBreak": + return "\n"; + + case "rule": + return "---"; + + case "mention": { + const mentionText = (node.attrs?.["text"] as string) ?? "unknown"; + return "@" + mentionText; + } + + case "emoji": + return (node.attrs?.["shortName"] as string) ?? ""; + + case "mediaSingle": + case "mediaGroup": + case "media": + return "[media]"; + + case "table": + return "[table]"; + + case "tableRow": + return renderChildren(node, " | "); + + case "tableHeader": + case "tableCell": + return renderChildren(node, ""); + + default: + // Unknown node types: recurse into content if present + if (node.content && node.content.length > 0) { + return renderChildren(node, ""); + } + return ""; + } +} + +function renderChildren(node: AdfNode, separator: string): string { + if (!node.content || node.content.length === 0) return ""; + return node.content.map(renderNode).join(separator); +} + +function renderTextNode(node: AdfNode): string { + let text = node.text ?? ""; + if (!node.marks || node.marks.length === 0) return text; + + for (const mark of node.marks) { + switch (mark.type) { + case "bold": + case "strong": + text = `**${text}**`; + break; + case "italic": + case "em": + text = `*${text}*`; + break; + case "code": + text = "`" + text + "`"; + break; + case "link": { + const href = mark.attrs?.["href"] as string | undefined; + if (href) { + text = `${text} (${href})`; + } + break; + } + } + } + return text; +} + +function renderListItems(node: AdfNode, style: "bullet" | "ordered"): string { + if (!node.content) return ""; + return node.content + .map((item, index) => { + const prefix = style === "bullet" ? "- " : `${index + 1}. `; + const content = renderNode(item); + return prefix + content; + }) + .join("\n"); +} + +// --------------------------------------------------------------------------- +// Plain Text -> ADF +// --------------------------------------------------------------------------- + +/** + * Convert plain text to a minimal ADF document. + * Splits on double newlines to create paragraphs. + */ +export function plainTextToAdf(text: string): AdfNode { + if (!text || text.trim() === "") { + return { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "" }], + }, + ], + }; + } + + const paragraphs = text.split(/\n\n+/); + const content: AdfNode[] = paragraphs.map((para) => ({ + type: "paragraph", + content: [{ type: "text", text: para }], + })); + + return { + type: "doc", + version: 1, + content, + }; +} diff --git a/packages/plugins/tracker-jira/src/http-client.ts b/packages/plugins/tracker-jira/src/http-client.ts new file mode 100644 index 0000000000..3a06cfe194 --- /dev/null +++ b/packages/plugins/tracker-jira/src/http-client.ts @@ -0,0 +1,107 @@ +/** + * HTTP client for Jira Cloud REST API v3. + * + * Handles authentication, retry logic, and rate limiting. + * Modelled after the Bitbucket HTTP client pattern used in scm-bitbucket. + */ + +export interface JiraClientConfig { + /** Jira domain — e.g. "mycompany" becomes mycompany.atlassian.net, or a full domain */ + domain: string; + /** Jira account email */ + email: string; + /** Jira API token */ + apiToken: string; + /** Request timeout in ms (default: 30000) */ + timeout?: number; + /** Max retries on transient failures (default: 3) */ + maxRetries?: number; +} + +export function createJiraClient(config: JiraClientConfig) { + const baseUrl = config.domain.includes(".") + ? `https://${config.domain}/rest/api/3` + : `https://${config.domain}.atlassian.net/rest/api/3`; + const timeout = config.timeout ?? 30_000; + const maxRetries = config.maxRetries ?? 3; + const authHeader = + "Basic " + Buffer.from(`${config.email}:${config.apiToken}`).toString("base64"); + + async function request( + method: string, + path: string, + body?: unknown, + params?: Record, + ): Promise { + const url = new URL(path.startsWith("https://") || path.startsWith("http://") ? path : `${baseUrl}${path}`); + if (params) { + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + } + + let lastError: Error | undefined; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + const res = await fetch(url.toString(), { + method, + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: body !== null && body !== undefined ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + clearTimeout(timer); + + if (res.status === 429) { + await res.body?.cancel().catch(() => {}); // release socket + const retryAfter = Math.min(60, Math.max(1, parseInt(res.headers.get("Retry-After") ?? "5", 10) || 5)); + await sleep(retryAfter * 1000); + continue; + } + if (res.status >= 500 && attempt < maxRetries) { + await res.body?.cancel().catch(() => {}); // release socket + await sleep(1000 * Math.pow(2, attempt)); + continue; + } + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Jira API ${method} ${path} failed (${res.status}): ${text}`); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } catch (err) { + clearTimeout(timer); + if (err instanceof Error && err.name === "AbortError") { + lastError = new Error( + `Jira API ${method} ${path} timed out after ${timeout}ms`, + ); + } else if (err instanceof Error && err.message.includes("Jira API")) { + throw err; + } else { + lastError = err as Error; + } + if (attempt < maxRetries) { + await sleep(1000 * Math.pow(2, attempt)); + } + } + } + throw lastError ?? new Error(`Jira API ${method} ${path} failed after ${maxRetries} retries`); + } + + return { + get: (path: string, params?: Record) => + request("GET", path, undefined, params), + post: (path: string, body?: unknown) => request("POST", path, body), + put: (path: string, body?: unknown) => request("PUT", path, body), + del: (path: string) => request("DELETE", path), + }; +} + +export type JiraClient = ReturnType; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/plugins/tracker-jira/src/index.ts b/packages/plugins/tracker-jira/src/index.ts new file mode 100644 index 0000000000..10a2b892b9 --- /dev/null +++ b/packages/plugins/tracker-jira/src/index.ts @@ -0,0 +1,401 @@ +/** + * tracker-jira plugin — Jira Cloud as an issue tracker. + * + * Uses the Jira Cloud REST API v3 with Basic auth (email + API token). + * + * Configuration (via project.tracker in YAML): + * tracker: + * plugin: jira + * domain: mycompany # or mycompany.atlassian.net + * projectKey: PROJ # optional, for JQL filtering + * + * Environment variables: + * JIRA_DOMAIN — Jira Cloud domain (fallback if not in config) + * JIRA_EMAIL — Account email for Basic auth + * JIRA_API_TOKEN — API token for Basic auth + */ + +import type { + PluginModule, + Tracker, + Issue, + IssueFilters, + IssueUpdate, + CreateIssueInput, + ProjectConfig, +} from "@aoagents/ao-core"; + +import { createJiraClient, type JiraClient } from "./http-client.js"; +import type { + JiraIssue, + JiraSearchResponse, + JiraTransitionsResponse, + JiraCreateIssueResponse, +} from "./types.js"; +import { adfToPlainText, plainTextToAdf } from "./adf.js"; + +// --------------------------------------------------------------------------- +// JQL escaping — prevent injection via filter values +// --------------------------------------------------------------------------- + +function escapeJql(value: string): string { + // JQL strings are delimited by double quotes. Escape backslashes first, + // then double quotes, newlines, and tabs to prevent breakout. + return value + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); +} + +// --------------------------------------------------------------------------- +// State mapping +// --------------------------------------------------------------------------- + +function mapJiraState(statusCategoryKey: string): Issue["state"] { + switch (statusCategoryKey) { + case "done": + return "closed"; + case "indeterminate": + return "in_progress"; + case "new": + default: + return "open"; + } +} + +// --------------------------------------------------------------------------- +// Domain resolution helper +// --------------------------------------------------------------------------- + +function resolveDomain(domain: string): string { + // If already a full domain, return as-is + if (domain.includes(".")) return domain; + return `${domain}.atlassian.net`; +} + +// --------------------------------------------------------------------------- +// Jira issue → core Issue mapper +// --------------------------------------------------------------------------- + +function toIssue(jira: JiraIssue, domain: string): Issue { + const fullDomain = resolveDomain(domain); + return { + id: jira.key, + title: jira.fields.summary, + description: adfToPlainText(jira.fields.description), + url: `https://${fullDomain}/browse/${jira.key}`, + state: mapJiraState(jira.fields?.status?.statusCategory?.key ?? "new"), + labels: jira.fields.labels ?? [], + assignee: jira.fields.assignee?.displayName, + priority: (() => { const pId = parseInt(jira.fields?.priority?.id ?? "", 10); return isNaN(pId) ? undefined : pId; })(), + }; +} + +// --------------------------------------------------------------------------- +// Tracker implementation +// --------------------------------------------------------------------------- + +function createJiraTracker(client: JiraClient, domain: string, projectKey?: string, config?: Record): Tracker { + const fullDomain = resolveDomain(domain); + + return { + name: "jira", + + async getIssue(identifier: string, _project: ProjectConfig): Promise { + const jira = await client.get( + `/issue/${identifier}`, + { fields: "summary,status,description,labels,assignee,priority,project" }, + ); + return toIssue(jira, domain); + }, + + async isCompleted(identifier: string, _project: ProjectConfig): Promise { + const jira = await client.get( + `/issue/${identifier}`, + { fields: "status" }, + ); + return jira.fields?.status?.statusCategory?.key === "done"; + }, + + issueUrl(identifier: string, _project: ProjectConfig): string { + return `https://${fullDomain}/browse/${identifier}`; + }, + + issueLabel(url: string, _project: ProjectConfig): string { + // Extract issue key from Jira URL + // Examples: + // https://mycompany.atlassian.net/browse/PROJ-123 + // https://mycompany.atlassian.net/browse/PROJ-123?extra=params + const match = url.match(/\/browse\/([A-Z][A-Z0-9_]+-\d+)/); + if (match) { + return match[1]; + } + // Fallback: return the last path segment + const parts = url.split("/"); + return parts[parts.length - 1] || url; + }, + + branchName(identifier: string, _project: ProjectConfig): string { + return `feat/${identifier}`; + }, + + async generatePrompt(identifier: string, _project: ProjectConfig): Promise { + // Fetch raw Jira issue to get priority name (not just numeric ID) + const jira = await client.get( + `/issue/${identifier}`, + { fields: "summary,status,description,labels,assignee,priority,project,comment" }, + ); + + const lines = [ + `You are working on Jira issue ${jira.key}: ${jira.fields.summary}`, + `Issue URL: ${this.issueUrl(identifier, _project)}`, + "", + ]; + + if (jira.fields.labels && jira.fields.labels.length > 0) { + lines.push(`Labels: ${jira.fields.labels.join(", ")}`); + } + + const priorityName = jira.fields?.priority?.name; + if (priorityName) { + lines.push(`Priority: ${priorityName}`); + } + + const status = jira.fields?.status?.name; + if (status) { + lines.push(`Status: ${status}`); + } + + if (jira.fields.description) { + lines.push("", "## Description", "", adfToPlainText(jira.fields.description)); + } + + // Include comments for additional context + const comments = jira.fields.comment?.comments; + if (comments && comments.length > 0) { + lines.push("", "## Comments"); + for (const c of comments.slice(-5)) { // last 5 comments + const author = c.author?.displayName ?? "Unknown"; + const body = c.body ? adfToPlainText(c.body) : ""; + if (body) { + lines.push("", `**${author}:**`, body); + } + } + } + + lines.push( + "", + "Please implement the changes described in this issue. When done, commit and push your changes.", + ); + + return lines.join("\n"); + }, + + async listIssues(filters: IssueFilters, _project: ProjectConfig): Promise { + // Build JQL from filters — escape values to prevent JQL injection + const clauses: string[] = []; + + if (projectKey) { + clauses.push(`project = "${escapeJql(projectKey)}"`); + } + + if (filters.state === "open") { + clauses.push(`statusCategory != "Done"`); + } else if (filters.state === "closed") { + clauses.push(`statusCategory = "Done"`); + } + // "all" → no state filter + + if (filters.labels && filters.labels.length > 0) { + const labelList = filters.labels.map((l) => `"${escapeJql(l)}"`).join(", "); + clauses.push(`labels in (${labelList})`); + } + + if (filters.assignee) { + clauses.push(`assignee = "${escapeJql(filters.assignee)}"`); + } + + const jql = clauses.length > 0 ? clauses.join(" AND ") : "ORDER BY created DESC"; + const maxResults = filters.limit ?? 30; + + // Jira Cloud deprecated /search (GET) in favor of /search/jql (POST) + // See: https://developer.atlassian.com/changelog/#CHANGE-2046 + const resp = await client.post("/search/jql", { + jql: clauses.length > 0 ? jql + " ORDER BY created DESC" : jql, + maxResults, + fields: ["summary", "status", "description", "labels", "assignee", "priority", "project"], + }); + + return (resp.issues ?? []).map((issue) => toIssue(issue, domain)); + }, + + async updateIssue( + identifier: string, + update: IssueUpdate, + _project: ProjectConfig, + ): Promise { + // Handle state change via transitions + if (update.state) { + const transResp = await client.get( + `/issue/${identifier}/transitions`, + ); + + const targetCategoryKey = + update.state === "closed" + ? "done" + : update.state === "in_progress" + ? "indeterminate" + : "new"; + + const transition = (transResp.transitions ?? []).find( + (t) => t.to.statusCategory.key === targetCategoryKey, + ); + + if (!transition) { + const available = (transResp.transitions ?? []) + .map((t) => `"${t.name}" (${t.to.statusCategory.key})`) + .join(", "); + throw new Error( + `No Jira transition found to move issue ${identifier} to state "${update.state}" ` + + `(target category: ${targetCategoryKey}). Available transitions: ${available}`, + ); + } + + await client.post(`/issue/${identifier}/transitions`, { + transition: { id: transition.id }, + }); + } + + // Handle labels (merge existing + new, remove removeLabels) + if ( + (update.labels && update.labels.length > 0) || + (update.removeLabels && update.removeLabels.length > 0) + ) { + // Fetch current labels + const current = await client.get( + `/issue/${identifier}`, + { fields: "labels" }, + ); + const existingLabels = new Set(current.fields.labels ?? []); + + // Add new labels + if (update.labels) { + for (const label of update.labels) { + existingLabels.add(label); + } + } + + // Remove labels + if (update.removeLabels) { + for (const label of update.removeLabels) { + existingLabels.delete(label); + } + } + + await client.put(`/issue/${identifier}`, { + fields: { labels: [...existingLabels] }, + }); + } + + // Handle comment + if (update.comment) { + await client.post(`/issue/${identifier}/comment`, { + body: plainTextToAdf(update.comment), + }); + } + + // Handle assignee — needs accountId lookup, log warning for now + if (update.assignee) { + // Jira requires accountId, not display name. Direct assignment by name + // is not supported without a user search. Log a warning. + // eslint-disable-next-line no-console -- intentional operational warning + console.warn( + `[tracker-jira] Assignee update for "${update.assignee}" skipped: ` + + `Jira requires accountId for assignment. Use the Jira UI or provide accountId directly.`, + ); + } + }, + + async createIssue(input: CreateIssueInput, _project: ProjectConfig): Promise { + const pKey = projectKey; + if (!pKey) { + throw new Error( + "Jira tracker requires 'projectKey' in project tracker config to create issues", + ); + } + + const fields: Record = { + project: { key: pKey }, + summary: input.title, + description: plainTextToAdf(input.description ?? ""), + issuetype: { name: typeof config?.issueType === "string" ? config.issueType : "Task" }, + labels: input.labels ?? [], + }; + + if (input.priority) { + fields["priority"] = { id: String(input.priority) }; + } + + const created = await client.post("/issue", { fields }); + + // Fetch the full issue to return a complete Issue object + const jira = await client.get( + `/issue/${created.key}`, + { fields: "summary,status,description,labels,assignee,priority,project" }, + ); + return toIssue(jira, domain); + }, + }; +} + +// --------------------------------------------------------------------------- +// Plugin module export +// --------------------------------------------------------------------------- + +export const manifest = { + name: "jira", + slot: "tracker" as const, + description: "Jira Cloud tracker — issues, JQL search, transitions", + version: "0.1.0", +}; + +export function create(config?: Record): Tracker { + // Resolve domain + const domain = + (config?.["domain"] as string | undefined) ?? + process.env["JIRA_DOMAIN"]; + if (!domain) { + throw new Error( + "Jira tracker requires 'domain' in tracker config or JIRA_DOMAIN environment variable", + ); + } + + // Resolve email + const emailEnvVar = (config?.["emailEnvVar"] as string | undefined) ?? "JIRA_EMAIL"; + const email = process.env[emailEnvVar]; + if (!email) { + throw new Error( + `Jira tracker requires ${emailEnvVar} environment variable`, + ); + } + + // Resolve API token + const tokenEnvVar = (config?.["tokenEnvVar"] as string | undefined) ?? "JIRA_API_TOKEN"; + const apiToken = process.env[tokenEnvVar]; + if (!apiToken) { + throw new Error( + `Jira tracker requires ${tokenEnvVar} environment variable`, + ); + } + + // Optional project key for filtering + const projectKey = config?.["projectKey"] as string | undefined; + + const client = createJiraClient({ domain, email, apiToken }); + + return createJiraTracker(client, domain, projectKey, config); +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/tracker-jira/src/types.ts b/packages/plugins/tracker-jira/src/types.ts new file mode 100644 index 0000000000..7b70c12747 --- /dev/null +++ b/packages/plugins/tracker-jira/src/types.ts @@ -0,0 +1,83 @@ +// Jira Cloud REST API v3 response types + +export interface JiraIssue { + id: string; + key: string; + self: string; + fields: { + summary: string; + description: AdfNode | null; + status: JiraStatus; + issuetype: { id: string; name: string }; + priority?: { id: string; name: string }; + labels: string[]; + assignee: JiraUser | null; + reporter?: JiraUser | null; + project: { key: string; name: string }; + created: string; + updated: string; + comment?: { + total: number; + comments: JiraComment[]; + }; + }; +} + +export interface JiraUser { + accountId: string; + displayName: string; + emailAddress?: string; +} + +export interface JiraStatus { + id: string; + name: string; + statusCategory: { + id: number; + key: "new" | "indeterminate" | "done" | string; + name: string; + }; +} + +export interface JiraTransition { + id: string; + name: string; + to: JiraStatus; + hasScreen: boolean; + fields?: Record; +} + +export interface JiraTransitionsResponse { + transitions: JiraTransition[]; +} + +export interface JiraSearchResponse { + startAt: number; + maxResults: number; + total: number; + issues: JiraIssue[]; +} + +export interface JiraComment { + id: string; + author: JiraUser; + body: AdfNode; + created: string; + updated: string; +} + +export interface JiraCreateIssueResponse { + id: string; + key: string; + self: string; +} + +// ADF (Atlassian Document Format) +export interface AdfNode { + type: string; + version?: number; + content?: AdfNode[]; + text?: string; + attrs?: Record; + marks?: Array<{ type: string; attrs?: Record }>; +} diff --git a/packages/plugins/tracker-jira/tsconfig.json b/packages/plugins/tracker-jira/tsconfig.json new file mode 100644 index 0000000000..e1b71318a6 --- /dev/null +++ b/packages/plugins/tracker-jira/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/web/next.config.js b/packages/web/next.config.js index 2639442dc4..8cf196ba49 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -13,8 +13,10 @@ const nextConfig = { "@aoagents/ao-plugin-agent-codex", "@aoagents/ao-plugin-agent-opencode", "@aoagents/ao-plugin-runtime-tmux", + "@aoagents/ao-plugin-scm-bitbucket", "@aoagents/ao-plugin-scm-github", "@aoagents/ao-plugin-tracker-github", + "@aoagents/ao-plugin-tracker-jira", "@aoagents/ao-plugin-tracker-linear", "@aoagents/ao-plugin-workspace-worktree", ], diff --git a/packages/web/package.json b/packages/web/package.json index 095901ab3b..6c45b0107d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -55,8 +55,10 @@ "@aoagents/ao-plugin-agent-opencode": "workspace:*", "@aoagents/ao-plugin-runtime-process": "workspace:*", "@aoagents/ao-plugin-runtime-tmux": "workspace:*", + "@aoagents/ao-plugin-scm-bitbucket": "workspace:*", "@aoagents/ao-plugin-scm-github": "workspace:*", "@aoagents/ao-plugin-tracker-github": "workspace:*", + "@aoagents/ao-plugin-tracker-jira": "workspace:*", "@aoagents/ao-plugin-tracker-linear": "workspace:*", "@aoagents/ao-plugin-workspace-worktree": "workspace:*", "@xterm/addon-fit": "0.12.0-beta.256", diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index af87038288..e593751fe4 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -673,7 +673,7 @@ function DashboardInner({ - GitHub API rate limited — PR data (CI status, review state, sizes) may be stale. + SCM API rate limited — PR data (CI status, review state, sizes) may be stale. Will retry automatically on next refresh.