/** * Pi Bitbucket Extension * * Integrates Bitbucket Cloud REST API v2 with Pi. * Provides tools for repositories, pull requests, branches, pipelines, and issues. * * Config: .pi/bitbucket.json or ~/.pi/agent/bitbucket.json * Run: pi -e extensions/bitbucket.ts */ import type { ExtensionAPI, ExtensionContext, Theme, } from "@mariozechner/pi-coding-agent"; import { getAgentDir } from "@mariozechner/pi-coding-agent"; import { Type, type Static } from "@sinclair/typebox"; import { StringEnum } from "@mariozechner/pi-ai"; import { Text } from "@mariozechner/pi-tui"; import { existsSync, readFileSync, writeFileSync, mkdirSync, } from "node:fs"; import { join, dirname } from "node:path"; // ─── Config ────────────────────────────────────────────────────── interface BitbucketConfig { workspace: string; username: string; apiToken: string; defaultRepo?: string; readOnly: boolean; } function resolveValue(val: string): string { if (val.startsWith("ENV:")) { return process.env[val.slice(4)] ?? ""; } return val; } function getConfigPaths(cwd: string): string[] { return [ join(cwd, ".pi", "bitbucket.json"), join(getAgentDir(), "bitbucket.json"), ]; } function loadConfig(cwd: string): BitbucketConfig | undefined { const paths = getConfigPaths(cwd); for (const p of paths) { if (!existsSync(p)) continue; try { const raw = JSON.parse(readFileSync(p, "utf-8")); const cfg: BitbucketConfig = { workspace: resolveValue(raw.workspace ?? ""), username: resolveValue(raw.username ?? ""), apiToken: resolveValue(raw.apiToken ?? raw.appPassword ?? ""), defaultRepo: raw.defaultRepo ? resolveValue(raw.defaultRepo) : undefined, readOnly: raw.readOnly === true || raw.readOnly === "true", }; if ( cfg.workspace && !cfg.workspace.includes("YOUR_") && cfg.username && !cfg.username.includes("YOUR_") && cfg.apiToken && !cfg.apiToken.includes("YOUR_") ) { return cfg; } } catch { /* ignore bad JSON */ } } // Fallback: environment variables const workspace = process.env.BITBUCKET_WORKSPACE; const username = process.env.BITBUCKET_USERNAME; const apiToken = process.env.BITBUCKET_API_TOKEN ?? process.env.BITBUCKET_APP_PASSWORD; if (workspace && username && apiToken) { return { workspace, username, apiToken, defaultRepo: process.env.BITBUCKET_DEFAULT_REPO, readOnly: process.env.BITBUCKET_READ_ONLY === "true", }; } return undefined; } function scaffoldConfig(cwd: string): string { const projectPath = join(cwd, ".pi", "bitbucket.json"); const globalPath = join(getAgentDir(), "bitbucket.json"); if (existsSync(projectPath)) return projectPath; if (existsSync(globalPath)) return globalPath; const dir = join(cwd, ".pi"); mkdirSync(dir, { recursive: true }); const template = { $comment: "Bitbucket Cloud config. Create an API token at https://bitbucket.org/account/settings/app-passwords/ with read/write permissions for Repositories, Pull Requests, Pipelines, and Issues.", workspace: "YOUR_WORKSPACE_SLUG", username: "YOUR_BITBUCKET_USERNAME", apiToken: "YOUR_API_TOKEN", defaultRepo: "", readOnly: false, }; writeFileSync(projectPath, JSON.stringify(template, null, 2) + "\n", "utf-8"); return projectPath; } // ─── API Client ────────────────────────────────────────────────── const BB_API = "https://api.bitbucket.org/2.0"; interface ApiOptions { method?: string; body?: unknown; params?: Record; } async function bbFetch( config: BitbucketConfig, path: string, opts: ApiOptions = {}, ): Promise { const url = new URL(`${BB_API}${path}`); if (opts.params) { for (const [k, v] of Object.entries(opts.params)) { url.searchParams.set(k, v); } } const auth = Buffer.from(`${config.username}:${config.apiToken}`).toString("base64"); const headers: Record = { Authorization: `Basic ${auth}`, Accept: "application/json", }; const fetchOpts: RequestInit = { method: opts.method ?? "GET", headers, }; if (opts.body) { headers["Content-Type"] = "application/json"; fetchOpts.body = JSON.stringify(opts.body); } const res = await fetch(url.toString(), fetchOpts); if (!res.ok) { const errText = await res.text().catch(() => ""); let errMsg = `Bitbucket API ${res.status}: ${res.statusText}`; try { const errJson = JSON.parse(errText); if (errJson.error?.message) errMsg += ` — ${errJson.error.message}`; } catch { if (errText) errMsg += ` — ${errText.slice(0, 200)}`; } throw new Error(errMsg); } if (res.status === 204) return {}; return res.json(); } function resolveRepo(config: BitbucketConfig, repo?: string): string { const r = repo || config.defaultRepo; if (!r) throw new Error("No repo specified and no defaultRepo in config."); return r; } // ─── Tool Schema ───────────────────────────────────────────────── const BitbucketParams = Type.Object({ action: StringEnum([ "list_repos", "get_repo", "list_prs", "get_pr", "create_pr", "merge_pr", "decline_pr", "comment_pr", "approve_pr", "list_branches", "list_pipelines", "get_pipeline", "trigger_pipeline", "list_issues", "get_issue", "create_issue", ] as const), // Common repo: Type.Optional(Type.String({ description: "Repository slug (uses defaultRepo from config if omitted)" })), // Pull requests pr_id: Type.Optional(Type.Number({ description: "Pull request ID" })), title: Type.Optional(Type.String({ description: "PR or issue title" })), description: Type.Optional(Type.String({ description: "PR or issue description/body" })), source_branch: Type.Optional(Type.String({ description: "Source branch for PR" })), destination_branch: Type.Optional(Type.String({ description: "Destination branch for PR (default: main)" })), close_source: Type.Optional(Type.Boolean({ description: "Close source branch on merge (default: true)" })), comment: Type.Optional(Type.String({ description: "Comment text for PR" })), merge_strategy: Type.Optional(StringEnum(["merge_commit", "squash", "fast_forward"] as const)), // Pipelines pipeline_uuid: Type.Optional(Type.String({ description: "Pipeline UUID" })), target_branch: Type.Optional(Type.String({ description: "Branch to run pipeline on" })), // Issues issue_id: Type.Optional(Type.Number({ description: "Issue ID" })), kind: Type.Optional(StringEnum(["bug", "enhancement", "proposal", "task"] as const)), priority: Type.Optional(StringEnum(["trivial", "minor", "major", "critical", "blocker"] as const)), // Pagination page: Type.Optional(Type.Number({ description: "Page number (default: 1)" })), state: Type.Optional(StringEnum(["OPEN", "MERGED", "DECLINED", "SUPERSEDED", "new", "open", "resolved", "on hold", "invalid", "duplicate", "wontfix", "closed"] as const)), }); export type BitbucketInput = Static; // ─── Formatters ────────────────────────────────────────────────── function fmtRepo(r: any): string { return [ `📦 ${r.full_name}`, r.description ? ` ${r.description}` : null, ` Language: ${r.language || "n/a"} | Updated: ${r.updated_on?.slice(0, 10) ?? "?"}`, ` Clone SSH: ${r.links?.clone?.find((c: any) => c.name === "ssh")?.href ?? "n/a"}`, ].filter(Boolean).join("\n"); } function fmtPR(pr: any): string { const state = pr.state ?? "UNKNOWN"; const icon = state === "OPEN" ? "🟢" : state === "MERGED" ? "🟣" : "🔴"; return [ `${icon} #${pr.id}: ${pr.title}`, ` ${pr.source?.branch?.name ?? "?"} → ${pr.destination?.branch?.name ?? "?"}`, ` Author: ${pr.author?.display_name ?? "?"} | State: ${state}`, ` Created: ${pr.created_on?.slice(0, 10) ?? "?"} | Updated: ${pr.updated_on?.slice(0, 10) ?? "?"}`, pr.comment_count ? ` Comments: ${pr.comment_count}` : null, ].filter(Boolean).join("\n"); } function fmtBranch(b: any): string { const target = b.target; return ` 🌿 ${b.name} — ${target?.hash?.slice(0, 8) ?? "?"} ${target?.message?.split("\n")[0]?.slice(0, 60) ?? ""} (${target?.date?.slice(0, 10) ?? "?"})`; } function fmtPipeline(p: any): string { const state = p.state?.name ?? "UNKNOWN"; const icon = state === "COMPLETED" && p.state?.result?.name === "SUCCESSFUL" ? "✅" : state === "COMPLETED" ? "❌" : state === "IN_PROGRESS" || state === "RUNNING" ? "🔄" : "⏸️"; return [ `${icon} Pipeline ${p.uuid}`, ` Branch: ${p.target?.ref_name ?? "?"} | Trigger: ${p.trigger?.name ?? "?"}`, ` State: ${state}${p.state?.result?.name ? ` (${p.state.result.name})` : ""}`, ` Created: ${p.created_on?.slice(0, 16) ?? "?"}`, p.duration_in_seconds ? ` Duration: ${Math.round(p.duration_in_seconds)}s` : null, ].filter(Boolean).join("\n"); } function fmtIssue(i: any): string { const state = i.state ?? "new"; const icon = state === "new" || state === "open" ? "🔵" : state === "resolved" ? "✅" : "⚪"; return [ `${icon} #${i.id}: ${i.title}`, ` Kind: ${i.kind ?? "?"} | Priority: ${i.priority ?? "?"} | State: ${state}`, ` Reporter: ${i.reporter?.display_name ?? "?"} | Assignee: ${i.assignee?.display_name ?? "unassigned"}`, ` Created: ${i.created_on?.slice(0, 10) ?? "?"}`, ].filter(Boolean).join("\n"); } // ─── Extension Entry Point ─────────────────────────────────────── export default function (pi: ExtensionAPI) { let config: BitbucketConfig | undefined; // ─── Session events ──────────────────────────────────────────── pi.on("session_start", async (_event, ctx) => { config = loadConfig(ctx.cwd); if (config) { ctx.ui.notify("Bitbucket connected ✓", "info"); ctx.ui.setStatus("bitbucket", `🪣 ${config.workspace}`); } else { const settingsPath = scaffoldConfig(ctx.cwd); ctx.ui.notify( `Bitbucket: config created at ${settingsPath} — fill in your credentials, then restart.\nCreate an API token at https://bitbucket.org/account/settings/app-passwords/`, "warning", ); } }); pi.on("session_switch", async (_event, ctx) => { config = loadConfig(ctx.cwd); }); // ─── Tool registration ──────────────────────────────────────── pi.registerTool({ name: "bitbucket", label: "Bitbucket", description: `Interact with Bitbucket Cloud. Actions: - list_repos: List workspace repositories - get_repo: Get repository details (repo) - list_prs: List pull requests (repo, state?) - get_pr: Get PR details (repo, pr_id) - create_pr: Create PR (repo, title, source_branch, destination_branch?, description?, close_source?) - merge_pr: Merge PR (repo, pr_id, merge_strategy?) - decline_pr: Decline PR (repo, pr_id) - approve_pr: Approve PR (repo, pr_id) - comment_pr: Comment on PR (repo, pr_id, comment) - list_branches: List branches (repo) - list_pipelines: List pipelines (repo) - get_pipeline: Get pipeline details (repo, pipeline_uuid) - trigger_pipeline: Trigger pipeline (repo, target_branch) - list_issues: List issues (repo, state?) - get_issue: Get issue (repo, issue_id) - create_issue: Create issue (repo, title, description?, kind?, priority?)`, promptSnippet: "Interact with Bitbucket Cloud: repos, PRs, branches, pipelines, issues", promptGuidelines: [ "Use bitbucket tool for any Bitbucket Cloud operations the user requests.", "If repo is not specified, the extension uses defaultRepo from config.", "For creating PRs, always include title and source_branch at minimum.", "Check readOnly mode before write operations — the tool will reject them if enabled.", ], parameters: BitbucketParams, async execute(toolCallId, params, signal, onUpdate, ctx) { if (signal?.aborted) { return { content: [{ type: "text", text: "Cancelled" }] }; } if (!config) { throw new Error( "Bitbucket not configured. Edit .pi/bitbucket.json with your workspace, username, and API token.", ); } // Block write operations in read-only mode const writeActions = [ "create_pr", "merge_pr", "decline_pr", "approve_pr", "comment_pr", "trigger_pipeline", "create_issue", ]; if (config.readOnly && writeActions.includes(params.action)) { throw new Error( `Bitbucket is in read-only mode. Set "readOnly": false in your config to allow ${params.action}.`, ); } const ws = config.workspace; switch (params.action) { // ─── Repositories ────────────────────────────────────── case "list_repos": { const data: any = await bbFetch(config, `/repositories/${ws}`, { params: { pagelen: "25", sort: "-updated_on", ...(params.page ? { page: String(params.page) } : {}), }, }); const repos = data.values ?? []; const text = repos.length ? repos.map(fmtRepo).join("\n\n") : "No repositories found."; return { content: [{ type: "text", text: `Repositories in ${ws} (${data.size ?? repos.length} total):\n\n${text}` }], }; } case "get_repo": { const repo = resolveRepo(config, params.repo); const data: any = await bbFetch(config, `/repositories/${ws}/${repo}`); return { content: [{ type: "text", text: fmtRepo(data) + `\n Default branch: ${data.mainbranch?.name ?? "?"}\n Size: ${data.size ? Math.round(data.size / 1024) + "KB" : "?"}\n Forks: ${data.forks_count ?? 0} | Watchers: ${data.watchers_count ?? 0}\n Private: ${data.is_private ?? "?"}\n URL: ${data.links?.html?.href ?? "?"}` }], }; } // ─── Pull Requests ───────────────────────────────────── case "list_prs": { const repo = resolveRepo(config, params.repo); const queryParams: Record = { pagelen: "25", ...(params.page ? { page: String(params.page) } : {}), }; if (params.state && ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"].includes(params.state)) { queryParams.state = params.state; } const data: any = await bbFetch(config, `/repositories/${ws}/${repo}/pullrequests`, { params: queryParams, }); const prs = data.values ?? []; const text = prs.length ? prs.map(fmtPR).join("\n\n") : "No pull requests found."; return { content: [{ type: "text", text: `Pull Requests for ${ws}/${repo} (${data.size ?? prs.length} total):\n\n${text}` }], }; } case "get_pr": { const repo = resolveRepo(config, params.repo); if (!params.pr_id) throw new Error("pr_id is required for get_pr"); const pr: any = await bbFetch(config, `/repositories/${ws}/${repo}/pullrequests/${params.pr_id}`); const diffstat: any = await bbFetch(config, `/repositories/${ws}/${repo}/pullrequests/${params.pr_id}/diffstat`).catch(() => null); const filesChanged = diffstat?.values?.length ?? "?"; let text = fmtPR(pr); text += `\n Files changed: ${filesChanged}`; text += `\n Close source branch: ${pr.close_source_branch ?? "?"}`; if (pr.description) text += `\n\n Description:\n ${pr.description.replace(/\n/g, "\n ")}`; if (pr.reviewers?.length) { text += `\n\n Reviewers: ${pr.reviewers.map((r: any) => r.display_name).join(", ")}`; } if (pr.participants?.length) { const approved = pr.participants.filter((p: any) => p.approved); if (approved.length) { text += `\n Approved by: ${approved.map((p: any) => p.user?.display_name).join(", ")}`; } } text += `\n URL: ${pr.links?.html?.href ?? "?"}`; return { content: [{ type: "text", text }] }; } case "create_pr": { const repo = resolveRepo(config, params.repo); if (!params.title) throw new Error("title is required for create_pr"); if (!params.source_branch) throw new Error("source_branch is required for create_pr"); const body: any = { title: params.title, source: { branch: { name: params.source_branch } }, destination: { branch: { name: params.destination_branch ?? "main" } }, close_source_branch: params.close_source ?? true, }; if (params.description) body.description = params.description; const pr: any = await bbFetch(config, `/repositories/${ws}/${repo}/pullrequests`, { method: "POST", body, }); return { content: [{ type: "text", text: `✅ PR #${pr.id} created: ${pr.title}\n ${pr.source?.branch?.name} → ${pr.destination?.branch?.name}\n URL: ${pr.links?.html?.href ?? "?"}` }], }; } case "merge_pr": { const repo = resolveRepo(config, params.repo); if (!params.pr_id) throw new Error("pr_id is required for merge_pr"); const body: any = {}; if (params.merge_strategy) body.merge_strategy = params.merge_strategy; if (params.close_source !== undefined) body.close_source_branch = params.close_source; const pr: any = await bbFetch(config, `/repositories/${ws}/${repo}/pullrequests/${params.pr_id}/merge`, { method: "POST", body, }); return { content: [{ type: "text", text: `✅ PR #${pr.id} merged successfully.\n Strategy: ${pr.merge_commit?.hash ? "merge" : params.merge_strategy ?? "default"}\n State: ${pr.state}` }], }; } case "decline_pr": { const repo = resolveRepo(config, params.repo); if (!params.pr_id) throw new Error("pr_id is required for decline_pr"); const pr: any = await bbFetch(config, `/repositories/${ws}/${repo}/pullrequests/${params.pr_id}/decline`, { method: "POST", }); return { content: [{ type: "text", text: `🔴 PR #${pr.id} declined.` }], }; } case "approve_pr": { const repo = resolveRepo(config, params.repo); if (!params.pr_id) throw new Error("pr_id is required for approve_pr"); await bbFetch(config, `/repositories/${ws}/${repo}/pullrequests/${params.pr_id}/approve`, { method: "POST", }); return { content: [{ type: "text", text: `👍 PR #${params.pr_id} approved.` }], }; } case "comment_pr": { const repo = resolveRepo(config, params.repo); if (!params.pr_id) throw new Error("pr_id is required for comment_pr"); if (!params.comment) throw new Error("comment is required for comment_pr"); const cmt: any = await bbFetch(config, `/repositories/${ws}/${repo}/pullrequests/${params.pr_id}/comments`, { method: "POST", body: { content: { raw: params.comment } }, }); return { content: [{ type: "text", text: `💬 Comment added to PR #${params.pr_id} (comment id: ${cmt.id})` }], }; } // ─── Branches ────────────────────────────────────────── case "list_branches": { const repo = resolveRepo(config, params.repo); const data: any = await bbFetch(config, `/repositories/${ws}/${repo}/refs/branches`, { params: { pagelen: "50", sort: "-target.date", ...(params.page ? { page: String(params.page) } : {}), }, }); const branches = data.values ?? []; const text = branches.length ? branches.map(fmtBranch).join("\n") : "No branches found."; return { content: [{ type: "text", text: `Branches for ${ws}/${repo}:\n\n${text}` }], }; } // ─── Pipelines ──────────────────────────────────────── case "list_pipelines": { const repo = resolveRepo(config, params.repo); const data: any = await bbFetch(config, `/repositories/${ws}/${repo}/pipelines`, { params: { pagelen: "15", sort: "-created_on", ...(params.page ? { page: String(params.page) } : {}), }, }); const pipelines = data.values ?? []; const text = pipelines.length ? pipelines.map(fmtPipeline).join("\n\n") : "No pipelines found."; return { content: [{ type: "text", text: `Pipelines for ${ws}/${repo}:\n\n${text}` }], }; } case "get_pipeline": { const repo = resolveRepo(config, params.repo); if (!params.pipeline_uuid) throw new Error("pipeline_uuid is required for get_pipeline"); const p: any = await bbFetch(config, `/repositories/${ws}/${repo}/pipelines/${params.pipeline_uuid}`); let text = fmtPipeline(p); if (p.build_number) text += `\n Build #: ${p.build_number}`; if (p.completed_on) text += `\n Completed: ${p.completed_on.slice(0, 16)}`; return { content: [{ type: "text", text }] }; } case "trigger_pipeline": { const repo = resolveRepo(config, params.repo); if (!params.target_branch) throw new Error("target_branch is required for trigger_pipeline"); const p: any = await bbFetch(config, `/repositories/${ws}/${repo}/pipelines/`, { method: "POST", body: { target: { ref_type: "branch", type: "pipeline_ref_target", ref_name: params.target_branch, }, }, }); return { content: [{ type: "text", text: `🚀 Pipeline triggered on branch ${params.target_branch}\n UUID: ${p.uuid}\n State: ${p.state?.name ?? "PENDING"}` }], }; } // ─── Issues ──────────────────────────────────────────── case "list_issues": { const repo = resolveRepo(config, params.repo); const queryParams: Record = { pagelen: "25", sort: "-created_on", ...(params.page ? { page: String(params.page) } : {}), }; if (params.state && ["new", "open", "resolved", "on hold", "invalid", "duplicate", "wontfix", "closed"].includes(params.state)) { queryParams.q = `state="${params.state}"`; } const data: any = await bbFetch(config, `/repositories/${ws}/${repo}/issues`, { params: queryParams, }); const issues = data.values ?? []; const text = issues.length ? issues.map(fmtIssue).join("\n\n") : "No issues found. (Is the issue tracker enabled for this repo?)"; return { content: [{ type: "text", text: `Issues for ${ws}/${repo}:\n\n${text}` }], }; } case "get_issue": { const repo = resolveRepo(config, params.repo); if (!params.issue_id) throw new Error("issue_id is required for get_issue"); const i: any = await bbFetch(config, `/repositories/${ws}/${repo}/issues/${params.issue_id}`); let text = fmtIssue(i); if (i.content?.raw) text += `\n\n Description:\n ${i.content.raw.replace(/\n/g, "\n ")}`; text += `\n URL: ${i.links?.html?.href ?? "?"}`; return { content: [{ type: "text", text }] }; } case "create_issue": { const repo = resolveRepo(config, params.repo); if (!params.title) throw new Error("title is required for create_issue"); const body: any = { title: params.title, kind: params.kind ?? "bug", priority: params.priority ?? "major", }; if (params.description) body.content = { raw: params.description }; const i: any = await bbFetch(config, `/repositories/${ws}/${repo}/issues`, { method: "POST", body, }); return { content: [{ type: "text", text: `✅ Issue #${i.id} created: ${i.title}\n Kind: ${i.kind} | Priority: ${i.priority}\n URL: ${i.links?.html?.href ?? "?"}` }], }; } default: throw new Error(`Unknown action: ${params.action}`); } }, // ─── TUI rendering ────────────────────────────────────────── renderCall(args: BitbucketInput, theme: Theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); let content = theme.fg("toolTitle", theme.bold("bitbucket ")); content += theme.fg("accent", args.action ?? ""); if (args.repo) content += " " + theme.fg("muted", args.repo); if (args.pr_id) content += " " + theme.fg("dim", `PR #${args.pr_id}`); if (args.issue_id) content += " " + theme.fg("dim", `Issue #${args.issue_id}`); if (args.title) content += " " + theme.fg("dim", `"${args.title}"`); if (args.source_branch) content += " " + theme.fg("dim", `${args.source_branch}→${args.destination_branch ?? "main"}`); if (args.target_branch) content += " " + theme.fg("dim", `branch:${args.target_branch}`); if (args.state) content += " " + theme.fg("muted", `[${args.state}]`); text.setText(content); return text; }, renderResult(result, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const content = typeof result.content === "string" ? result.content : result.content?.map((c: any) => c.text ?? "").join("\n") ?? ""; text.setText(content); return text; }, }); }