feat(integrations): add Bitbucket Cloud (#6860)

* feat(integrations): add Bitbucket Cloud

* fix(bitbucket): enforce selector workspace slugs

* fix(bitbucket): overfetch small pipeline log tails

* fix(bitbucket): harden provider edge cases

* fix(bitbucket): accept provider diff redirect specs

* fix(bitbucket): stop advanced-field leakage and harden log, status, and selector paths

Splits the `closeSourceBranch` advanced subBlock into per-operation ids. Advanced
fields serialize without evaluating their condition, so a value set on Create Pull
Request reached Merge Pull Request and closed the source branch unprompted.

Also:
- read step logs through the byte-capped server transport and map an empty-log 416
  to an empty result, keeping a genuine 416 an error
- trim a step log's partial leading line after the character cap rather than before,
  and never return an empty log when the retained window held content
- surface Bitbucket's `error.detail` alongside `error.message`
- treat commit-status `key`/`state` as nullable so one malformed row cannot drop a page
- match repository `full_name` case-insensitively and reject dot segments in a
  workspace slug before the outbound request
- type `reviewerAccountIds` as the comma-separated string it is
- trim optional Bitbucket query strings; correct the token lifetime to two hours

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Waleed Latif <walif6@gmail.com>
This commit is contained in:
Bill Leoutsakos
2026-08-19 20:32:44 -07:00
committed by GitHub
co-authored by Bill Leoutsakos Waleed Latif
parent 9f346765fe
commit e3a4874ece
81 changed files with 13285 additions and 8 deletions
+32
View File
@@ -754,6 +754,38 @@ export function GithubOutlineIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function BitbucketIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const gradientId = `bitbucket_original_a_${id}`
return (
<svg {...props} viewBox='0 0 128 128' xmlns='http://www.w3.org/2000/svg'>
<defs>
<linearGradient
id={gradientId}
gradientUnits='userSpaceOnUse'
x1='28.593'
y1='14.226'
x2='16.672'
y2='23.532'
gradientTransform='scale(4)'
>
<stop offset='.176' stopColor='#0052cc' />
<stop offset='1' stopColor='#2684ff' />
</linearGradient>
</defs>
<path
d='M19.082 20c-1.918 0-3.355 1.758-3.039 3.516l12.95 79.289c.32 2.078 2.077 3.515 4.155 3.515h62.66c1.442 0 2.72-1.12 3.04-2.558l13.109-80.086c.316-1.918-1.121-3.516-3.039-3.516zM74.07 77.227H54.09l-5.278-28.293h30.215zm0 0'
fill='#2684ff'
/>
<path
d='M107.64 48.934H78.868L74.07 77.227H54.09l-23.5 27.972s1.12.961 2.719.961h62.66c1.441 0 2.719-1.12 3.039-2.558zm0 0'
fill={`url(#${gradientId})`}
/>
</svg>
)
}
export function GitLabIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
+2
View File
@@ -24,6 +24,7 @@ import {
AttioIcon,
AzureDataExplorerIcon,
AzureIcon,
BitbucketIcon,
BoxCompanyIcon,
BrainIcon,
BrandfetchIcon,
@@ -288,6 +289,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
attio: AttioIcon,
azure_data_explorer: AzureDataExplorerIcon,
azure_devops: AzureIcon,
bitbucket: BitbucketIcon,
box: BoxCompanyIcon,
brandfetch: BrandfetchIcon,
brex: BrexIcon,
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,7 @@
"attio-service-account",
"azure_data_explorer",
"azure_devops",
"bitbucket",
"box",
"box-service-account",
"brandfetch",
@@ -127,6 +127,7 @@ The same variables also power "Sign in with Microsoft".
| ClickUp | `CLICKUP_CLIENT_ID` / `CLICKUP_CLIENT_SECRET` | `clickup` |
| Monday | `MONDAY_CLIENT_ID` / `MONDAY_CLIENT_SECRET` | `monday` |
| Airtable | `AIRTABLE_CLIENT_ID` / `AIRTABLE_CLIENT_SECRET` | `airtable` |
| Bitbucket | `BITBUCKET_CLIENT_ID` / `BITBUCKET_CLIENT_SECRET` | `bitbucket` |
| HubSpot | `HUBSPOT_CLIENT_ID` / `HUBSPOT_CLIENT_SECRET` | `hubspot` |
| Salesforce | `SALESFORCE_CLIENT_ID` / `SALESFORCE_CLIENT_SECRET` | `salesforce` |
| Pipedrive | `PIPEDRIVE_CLIENT_ID` / `PIPEDRIVE_CLIENT_SECRET` | `pipedrive` |
@@ -146,6 +147,13 @@ The same variables also power "Sign in with Microsoft".
| Spotify | `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` | `spotify` |
| TikTok | `TIKTOK_CLIENT_ID` / `TIKTOK_CLIENT_SECRET` | `tiktok` |
For Bitbucket, create an OAuth consumer and register
`https://<your-domain>/api/auth/oauth2/callback/bitbucket` as its callback URL. Bitbucket fixes
permissions on the consumer instead of narrowing them per authorization request. Enable exactly
Account read, Repositories read/write, Pull requests read/write, and Pipelines read/write
(`account`, `repository`, `repository:write`, `pullrequest`, `pullrequest:write`, `pipeline`, and
`pipeline:write`). Webhook permission is not required for the integration-only release.
### Services with a different flow
| Service | Configuration | Notes |
@@ -0,0 +1,326 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockAuthorizeCredentialUse,
mockCheckSessionOrInternalAuth,
mockFetch,
mockGetCredential,
mockRefreshAccessTokenIfNeeded,
} = vi.hoisted(() => ({
mockAuthorizeCredentialUse: vi.fn(),
mockCheckSessionOrInternalAuth: vi.fn(),
mockFetch: vi.fn(),
mockGetCredential: vi.fn(),
mockRefreshAccessTokenIfNeeded: vi.fn(),
}))
vi.mock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))
vi.mock('@/lib/oauth/credential-service', () => ({
getCredential: mockGetCredential,
refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded,
}))
import { POST } from '@/app/api/tools/bitbucket/repositories/route'
const URL = 'http://localhost:3000/api/tools/bitbucket/repositories'
const FIRST_PAGE_URL =
'https://api.bitbucket.org/2.0/repositories/acme-platform?pagelen=100&fields=values.slug%2Cvalues.uuid%2Cvalues.name%2Cvalues.full_name%2Cnext'
const SECOND_PAGE_URL =
'https://api.bitbucket.org/2.0/repositories/acme-platform?page=2&pagelen=100'
const REQUEST_BODY = {
credential: 'credential-1',
workflowId: 'workflow-1',
workspaceSlug: 'acme-platform',
} as const
function request(body: unknown): NextRequest {
return new NextRequest(URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: typeof body === 'string' ? body : JSON.stringify(body),
})
}
function providerResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
async function json(response: Response): Promise<Record<string, unknown>> {
return (await response.json()) as Record<string, unknown>
}
describe('POST /api/tools/bitbucket/repositories', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', mockFetch)
mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'caller-1' })
mockAuthorizeCredentialUse.mockResolvedValue({
ok: true,
credentialOwnerUserId: 'owner-1',
resolvedCredentialId: 'account-1',
credentialType: 'oauth',
})
mockGetCredential.mockResolvedValue({ providerId: 'bitbucket' })
mockRefreshAccessTokenIfNeeded.mockResolvedValue('server-only-token')
mockFetch.mockResolvedValue(providerResponse({ values: [] }))
})
afterAll(() => vi.unstubAllGlobals())
it('authenticates before attempting to parse an invalid body', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const response = await POST(request('{not-json'), {})
expect(response.status).toBe(401)
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
})
it('requires the workspace dependency before credential authorization', async () => {
const response = await POST(
request({ credential: 'credential-1', workflowId: 'workflow-1' }),
{}
)
expect(response.status).toBe(400)
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
})
it.each(['{0f6f9d4d-70cf-4831-aaa2-d196fc5eebcb}', '0f6f9d4d-70cf-4831-aaa2-d196fc5eebcb'])(
'rejects workspace UUID %s because repository discovery is slug-scoped',
async (uuid) => {
const response = await POST(request({ ...REQUEST_BODY, workspaceSlug: uuid }), {})
expect(response.status).toBe(400)
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
}
)
it('authorizes the exact credential before resolving or refreshing it', async () => {
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(200)
expect(mockAuthorizeCredentialUse).toHaveBeenCalledWith(expect.any(NextRequest), {
credentialId: 'credential-1',
workflowId: 'workflow-1',
callerUserId: 'caller-1',
})
expect(mockGetCredential).toHaveBeenCalledWith(expect.any(String), 'account-1', 'owner-1')
expect(mockRefreshAccessTokenIfNeeded).toHaveBeenCalledWith(
'account-1',
'owner-1',
expect.any(String)
)
})
it('fails closed when credential authorization is denied', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Forbidden' })
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(403)
expect(mockGetCredential).not.toHaveBeenCalled()
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
})
it('does not send a credential for another provider to Bitbucket', async () => {
mockGetCredential.mockResolvedValueOnce({ providerId: 'gitlab' })
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(400)
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
})
it('returns an auth-required response when token refresh cannot resolve a token', async () => {
mockRefreshAccessTokenIfNeeded.mockResolvedValueOnce(null)
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(401)
expect(await json(response)).toMatchObject({ authRequired: true })
expect(mockFetch).not.toHaveBeenCalled()
})
it.each([
['plain HTTP', 'http://api.bitbucket.org/2.0/repositories/acme-platform?page=2'],
[
'lookalike host',
'https://api.bitbucket.org.evil.example/2.0/repositories/acme-platform?page=2',
],
['different workspace', 'https://api.bitbucket.org/2.0/repositories/other-team?page=2'],
['wrong endpoint', 'https://api.bitbucket.org/2.0/user/workspaces?page=2'],
[
'embedded credentials',
'https://attacker:secret@api.bitbucket.org/2.0/repositories/acme-platform?page=2',
],
])('rejects a %s cursor before resolving a bearer token', async (_label, cursor) => {
const response = await POST(request({ ...REQUEST_BODY, cursor }), {})
expect(response.status).toBe(400)
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
})
it('normalizes a page and returns slug ids with UUID/full-name data, never the token', async () => {
mockFetch.mockResolvedValueOnce(
providerResponse({
values: [
{
uuid: '{repository-uuid}',
full_name: 'acme-platform/payments-api',
links: { html: { href: 'https://bitbucket.org/acme-platform/payments-api' } },
},
],
next: SECOND_PAGE_URL,
})
)
const response = await POST(request(REQUEST_BODY), {})
const body = await json(response)
expect(response.status).toBe(200)
expect(mockFetch).toHaveBeenCalledWith(
FIRST_PAGE_URL,
expect.objectContaining({ method: 'GET', redirect: 'error' })
)
const init = mockFetch.mock.calls[0]?.[1] as RequestInit
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer server-only-token')
expect(body).toEqual({
repositories: [
{
slug: 'payments-api',
uuid: '{repository-uuid}',
name: 'payments-api',
fullName: 'acme-platform/payments-api',
},
],
nextCursor: SECOND_PAGE_URL,
})
expect(JSON.stringify(body)).not.toContain('server-only-token')
})
it('uses a validated cursor for one page without dropping the workspace scope', async () => {
const response = await POST(request({ ...REQUEST_BODY, cursor: SECOND_PAGE_URL }), {})
expect(response.status).toBe(200)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(SECOND_PAGE_URL, expect.any(Object))
})
it.each([
['missing values', {}],
['non-array values', { values: { slug: 'payments-api' } }],
[
'malformed repository',
{
values: [
{
slug: 'payments-api',
uuid: '{repository-uuid}',
name: 'Payments API',
},
],
},
],
[
'oversized page',
{
values: Array.from({ length: 101 }, (_, index) => ({
slug: `repository-${index}`,
uuid: `{repository-${index}}`,
name: `Repository ${index}`,
full_name: `acme-platform/repository-${index}`,
})),
},
],
])('fails closed on a %s provider response', async (_label, providerBody) => {
mockFetch.mockResolvedValueOnce(providerResponse(providerBody))
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(502)
expect(await json(response)).toEqual({
error: 'Bitbucket returned an invalid repository response.',
})
})
it('fails closed on invalid provider JSON', async () => {
mockFetch.mockResolvedValueOnce(new Response('{not-json', { status: 200 }))
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(502)
})
it('accepts a case-mismatched workspace slug the way Bitbucket itself resolves it', async () => {
mockFetch.mockResolvedValueOnce(
providerResponse({
values: [
{
slug: 'sdk-core',
uuid: '{repo-1}',
name: 'SDK Core',
full_name: 'acme-platform/sdk-core',
},
],
next: 'https://api.bitbucket.org/2.0/repositories/acme-platform?page=2',
})
)
const response = await POST(request({ ...REQUEST_BODY, workspaceSlug: 'ACME-Platform' }), {})
expect(response.status).toBe(200)
expect(await json(response)).toMatchObject({
repositories: [{ slug: 'sdk-core', fullName: 'acme-platform/sdk-core' }],
})
})
it('rejects a workspace slug containing dot segments before it reaches Bitbucket', async () => {
for (const workspaceSlug of ['.', '..', 'acme.platform']) {
const response = await POST(request({ ...REQUEST_BODY, workspaceSlug }), {})
expect(response.status, workspaceSlug).toBe(400)
expect(mockFetch).not.toHaveBeenCalled()
}
})
it('rejects a provider next link that crosses the selected workspace', async () => {
mockFetch.mockResolvedValueOnce(
providerResponse({
values: [],
next: 'https://api.bitbucket.org/2.0/repositories/other-team?page=2',
})
)
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(502)
expect(await json(response)).toEqual({
error: 'Bitbucket returned an invalid repository response.',
})
})
})
@@ -0,0 +1,173 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
BITBUCKET_SELECTOR_PAGE_SIZE,
bitbucketRepositoriesSelectorContract,
bitbucketRepositoryProviderPageSchema,
isBitbucketRepositoriesCursor,
} from '@/lib/api/contracts/selectors/bitbucket'
import { parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredential, refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service'
export const dynamic = 'force-dynamic'
const logger = createLogger('BitbucketRepositoriesAPI')
const BITBUCKET_PROVIDER_ID = 'bitbucket'
const BITBUCKET_REPOSITORIES_URL = 'https://api.bitbucket.org/2.0/repositories'
const BITBUCKET_REPOSITORY_FIELDS = 'values.slug,values.uuid,values.name,values.full_name,next'
const SELECTOR_REQUEST_MAX_BYTES = 8 * 1024
const PROVIDER_RESPONSE_MAX_BYTES = 1024 * 1024
function bitbucketFailureResponse(status: number): NextResponse {
if (status === 401) {
return NextResponse.json(
{
error: 'Bitbucket rejected this credential. Reconnect it and try again.',
authRequired: true,
},
{ status: 401 }
)
}
if (status === 403) {
return NextResponse.json(
{ error: 'Bitbucket denied access to repository discovery.' },
{ status: 403 }
)
}
if (status === 429) {
return NextResponse.json(
{ error: 'Bitbucket rate-limited repository discovery. Try again shortly.' },
{ status: 429 }
)
}
return NextResponse.json({ error: 'Bitbucket repository discovery failed.' }, { status: 502 })
}
/** Lists one workspace-scoped page for the `bitbucket.repositories` selector. */
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
const caller = await checkSessionOrInternalAuth(request, { requireWorkflowId: true })
if (!caller.success || !caller.userId) {
return NextResponse.json({ error: caller.error || 'Authentication required' }, { status: 401 })
}
const parsed = await parseRequest(
bitbucketRepositoriesSelectorContract,
request,
{},
{
maxBodyBytes: SELECTOR_REQUEST_MAX_BYTES,
}
)
if (!parsed.success) return parsed.response
const { credential, workflowId, workspaceSlug, cursor } = parsed.data.body
const authorization = await authorizeCredentialUse(request, {
credentialId: credential,
workflowId,
callerUserId: caller.userId,
})
if (!authorization.ok || !authorization.credentialOwnerUserId) {
return NextResponse.json({ error: authorization.error || 'Unauthorized' }, { status: 403 })
}
const resolvedCredentialId = authorization.resolvedCredentialId ?? credential
const storedCredential = await getCredential(
requestId,
resolvedCredentialId,
authorization.credentialOwnerUserId
)
if (!storedCredential || storedCredential.providerId !== BITBUCKET_PROVIDER_ID) {
return NextResponse.json({ error: 'Select a Bitbucket OAuth credential.' }, { status: 400 })
}
const accessToken = await refreshAccessTokenIfNeeded(
resolvedCredentialId,
authorization.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json(
{ error: 'Could not retrieve a Bitbucket access token.', authRequired: true },
{ status: 401 }
)
}
const firstPage = new URL(`${BITBUCKET_REPOSITORIES_URL}/${encodeURIComponent(workspaceSlug)}`)
firstPage.searchParams.set('pagelen', String(BITBUCKET_SELECTOR_PAGE_SIZE))
firstPage.searchParams.set('fields', BITBUCKET_REPOSITORY_FIELDS)
const providerUrl = cursor ?? firstPage.toString()
let response: Response
try {
response = await fetch(providerUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
redirect: 'error',
signal: request.signal,
})
} catch (error) {
if (request.signal.aborted) throw error
logger.warn('Bitbucket repository request failed', {
workspaceSlug,
errorType: error instanceof Error ? error.name : 'unknown',
})
return NextResponse.json({ error: 'Bitbucket repository discovery failed.' }, { status: 502 })
}
if (!response.ok) return bitbucketFailureResponse(response.status)
let providerBody: unknown
try {
providerBody = await readResponseJsonWithLimit(response, {
label: 'Bitbucket repository response',
maxBytes: PROVIDER_RESPONSE_MAX_BYTES,
signal: request.signal,
})
} catch (error) {
if (request.signal.aborted) throw error
logger.warn('Bitbucket repository response was not bounded JSON', {
workspaceSlug,
errorType: error instanceof Error ? error.name : 'unknown',
})
return NextResponse.json(
{ error: 'Bitbucket returned an invalid repository response.' },
{ status: 502 }
)
}
const expectedFullNamePrefix = workspaceSlug.toLowerCase()
const page = bitbucketRepositoryProviderPageSchema.safeParse(providerBody)
if (
!page.success ||
(page.data.next && !isBitbucketRepositoriesCursor(page.data.next, workspaceSlug)) ||
page.data.values.some(
(repository) => !repository.full_name.toLowerCase().startsWith(`${expectedFullNamePrefix}/`)
)
) {
logger.warn('Bitbucket returned a malformed repository page', { workspaceSlug })
return NextResponse.json(
{ error: 'Bitbucket returned an invalid repository response.' },
{ status: 502 }
)
}
return NextResponse.json({
repositories: page.data.values.map((repository) => ({
slug: repository.slug,
uuid: repository.uuid,
name: repository.name ?? repository.slug,
fullName: repository.full_name,
})),
...(page.data.next ? { nextCursor: page.data.next } : {}),
})
})
@@ -0,0 +1,284 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockAuthorizeCredentialUse,
mockCheckSessionOrInternalAuth,
mockFetch,
mockGetCredential,
mockRefreshAccessTokenIfNeeded,
} = vi.hoisted(() => ({
mockAuthorizeCredentialUse: vi.fn(),
mockCheckSessionOrInternalAuth: vi.fn(),
mockFetch: vi.fn(),
mockGetCredential: vi.fn(),
mockRefreshAccessTokenIfNeeded: vi.fn(),
}))
vi.mock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))
vi.mock('@/lib/oauth/credential-service', () => ({
getCredential: mockGetCredential,
refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded,
}))
import { POST } from '@/app/api/tools/bitbucket/workspaces/route'
const URL = 'http://localhost:3000/api/tools/bitbucket/workspaces'
const FIRST_PAGE_URL =
'https://api.bitbucket.org/2.0/user/workspaces?pagelen=100&fields=values.administrator%2Cvalues.workspace.slug%2Cvalues.workspace.uuid%2Cvalues.workspace.name%2Cnext'
const SECOND_PAGE_URL = 'https://api.bitbucket.org/2.0/user/workspaces?page=2&pagelen=100'
const REQUEST_BODY = { credential: 'credential-1', workflowId: 'workflow-1' } as const
function request(body: unknown): NextRequest {
return new NextRequest(URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: typeof body === 'string' ? body : JSON.stringify(body),
})
}
function providerResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
async function json(response: Response): Promise<Record<string, unknown>> {
return (await response.json()) as Record<string, unknown>
}
describe('POST /api/tools/bitbucket/workspaces', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', mockFetch)
mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'caller-1' })
mockAuthorizeCredentialUse.mockResolvedValue({
ok: true,
credentialOwnerUserId: 'owner-1',
resolvedCredentialId: 'account-1',
credentialType: 'oauth',
})
mockGetCredential.mockResolvedValue({ providerId: 'bitbucket' })
mockRefreshAccessTokenIfNeeded.mockResolvedValue('server-only-token')
mockFetch.mockResolvedValue(providerResponse({ values: [] }))
})
afterAll(() => vi.unstubAllGlobals())
it('authenticates before attempting to parse an invalid body', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const response = await POST(request('{not-json'), {})
expect(response.status).toBe(401)
expect(mockCheckSessionOrInternalAuth).toHaveBeenCalledWith(expect.any(NextRequest), {
requireWorkflowId: true,
})
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
})
it('authorizes the exact credential before resolving or refreshing it', async () => {
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(200)
expect(mockAuthorizeCredentialUse).toHaveBeenCalledWith(expect.any(NextRequest), {
credentialId: 'credential-1',
workflowId: 'workflow-1',
callerUserId: 'caller-1',
})
expect(mockGetCredential).toHaveBeenCalledWith(expect.any(String), 'account-1', 'owner-1')
expect(mockRefreshAccessTokenIfNeeded).toHaveBeenCalledWith(
'account-1',
'owner-1',
expect.any(String)
)
})
it('fails closed when credential authorization is denied', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Forbidden' })
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(403)
expect(await json(response)).toMatchObject({ error: 'Forbidden' })
expect(mockGetCredential).not.toHaveBeenCalled()
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
})
it('does not send a credential for another provider to Bitbucket', async () => {
mockGetCredential.mockResolvedValueOnce({ providerId: 'github' })
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(400)
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
})
it('returns an auth-required response when token refresh cannot resolve a token', async () => {
mockRefreshAccessTokenIfNeeded.mockResolvedValueOnce(null)
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(401)
expect(await json(response)).toMatchObject({ authRequired: true })
expect(mockFetch).not.toHaveBeenCalled()
})
it.each([
['plain HTTP', 'http://api.bitbucket.org/2.0/user/workspaces?page=2'],
['lookalike host', 'https://api.bitbucket.org.evil.example/2.0/user/workspaces?page=2'],
['non-default port', 'https://api.bitbucket.org:444/2.0/user/workspaces?page=2'],
['wrong v2 endpoint', 'https://api.bitbucket.org/2.0/repositories/acme?page=2'],
['embedded credentials', 'https://attacker:secret@api.bitbucket.org/2.0/user/workspaces'],
])('rejects a %s cursor before resolving a bearer token', async (_label, cursor) => {
const response = await POST(request({ ...REQUEST_BODY, cursor }), {})
expect(response.status).toBe(400)
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
})
it('normalizes a page and returns only the provider cursor, never the bearer token', async () => {
mockFetch.mockResolvedValueOnce(
providerResponse({
values: [
{
administrator: true,
workspace: {
slug: 'acme-platform',
uuid: '{workspace-uuid}',
name: 'Acme Platform',
links: { html: { href: 'https://bitbucket.org/acme-platform' } },
},
},
],
next: SECOND_PAGE_URL,
})
)
const response = await POST(request(REQUEST_BODY), {})
const body = await json(response)
expect(response.status).toBe(200)
expect(mockFetch).toHaveBeenCalledWith(
FIRST_PAGE_URL,
expect.objectContaining({ method: 'GET', redirect: 'error' })
)
const init = mockFetch.mock.calls[0]?.[1] as RequestInit
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer server-only-token')
expect(body).toEqual({
workspaces: [
{
slug: 'acme-platform',
uuid: '{workspace-uuid}',
name: 'Acme Platform',
administrator: true,
},
],
nextCursor: SECOND_PAGE_URL,
})
expect(JSON.stringify(body)).not.toContain('server-only-token')
})
it('uses a validated provider cursor for exactly one progressive page', async () => {
const response = await POST(request({ ...REQUEST_BODY, cursor: SECOND_PAGE_URL }), {})
expect(response.status).toBe(200)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(SECOND_PAGE_URL, expect.any(Object))
})
it('uses the slug when the current workspace-access shape omits a display name', async () => {
mockFetch.mockResolvedValueOnce(
providerResponse({
values: [
{
administrator: false,
workspace: { slug: 'acme-platform', uuid: '{workspace-uuid}' },
},
],
})
)
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(200)
expect(await json(response)).toEqual({
workspaces: [
{
slug: 'acme-platform',
uuid: '{workspace-uuid}',
name: 'acme-platform',
administrator: false,
},
],
})
})
it.each([
['missing values', {}],
['non-array values', { values: 'not-an-array' }],
['malformed workspace', { values: [{ workspace: { slug: 'acme', uuid: 42, name: 'Acme' } }] }],
[
'oversized page',
{
values: Array.from({ length: 101 }, (_, index) => ({
workspace: {
slug: `workspace-${index}`,
uuid: `{workspace-${index}}`,
name: `Workspace ${index}`,
},
})),
},
],
])('fails closed on a %s provider response', async (_label, providerBody) => {
mockFetch.mockResolvedValueOnce(providerResponse(providerBody))
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(502)
expect(await json(response)).toEqual({
error: 'Bitbucket returned an invalid workspace response.',
})
})
it('fails closed on invalid provider JSON', async () => {
mockFetch.mockResolvedValueOnce(new Response('{not-json', { status: 200 }))
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(502)
})
it('rejects a provider next link that could redirect the bearer token elsewhere', async () => {
mockFetch.mockResolvedValueOnce(
providerResponse({
values: [],
next: 'https://evil.example/2.0/user/workspaces?page=2',
})
)
const response = await POST(request(REQUEST_BODY), {})
expect(response.status).toBe(502)
expect(await json(response)).toEqual({
error: 'Bitbucket returned an invalid workspace response.',
})
})
})
@@ -0,0 +1,165 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
BITBUCKET_SELECTOR_PAGE_SIZE,
bitbucketWorkspaceProviderPageSchema,
bitbucketWorkspacesSelectorContract,
isBitbucketWorkspacesCursor,
} from '@/lib/api/contracts/selectors/bitbucket'
import { parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredential, refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service'
export const dynamic = 'force-dynamic'
const logger = createLogger('BitbucketWorkspacesAPI')
const BITBUCKET_PROVIDER_ID = 'bitbucket'
const BITBUCKET_WORKSPACES_URL = 'https://api.bitbucket.org/2.0/user/workspaces'
const BITBUCKET_WORKSPACE_FIELDS =
'values.administrator,values.workspace.slug,values.workspace.uuid,values.workspace.name,next'
const SELECTOR_REQUEST_MAX_BYTES = 8 * 1024
const PROVIDER_RESPONSE_MAX_BYTES = 1024 * 1024
function bitbucketFailureResponse(status: number): NextResponse {
if (status === 401) {
return NextResponse.json(
{
error: 'Bitbucket rejected this credential. Reconnect it and try again.',
authRequired: true,
},
{ status: 401 }
)
}
if (status === 403) {
return NextResponse.json(
{ error: 'Bitbucket denied access to workspace discovery.' },
{ status: 403 }
)
}
if (status === 429) {
return NextResponse.json(
{ error: 'Bitbucket rate-limited workspace discovery. Try again shortly.' },
{ status: 429 }
)
}
return NextResponse.json({ error: 'Bitbucket workspace discovery failed.' }, { status: 502 })
}
/** Lists one normalized page for the `bitbucket.workspaces` selector. */
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
const caller = await checkSessionOrInternalAuth(request, { requireWorkflowId: true })
if (!caller.success || !caller.userId) {
return NextResponse.json({ error: caller.error || 'Authentication required' }, { status: 401 })
}
const parsed = await parseRequest(
bitbucketWorkspacesSelectorContract,
request,
{},
{
maxBodyBytes: SELECTOR_REQUEST_MAX_BYTES,
}
)
if (!parsed.success) return parsed.response
const { credential, workflowId, cursor } = parsed.data.body
const authorization = await authorizeCredentialUse(request, {
credentialId: credential,
workflowId,
callerUserId: caller.userId,
})
if (!authorization.ok || !authorization.credentialOwnerUserId) {
return NextResponse.json({ error: authorization.error || 'Unauthorized' }, { status: 403 })
}
const resolvedCredentialId = authorization.resolvedCredentialId ?? credential
const storedCredential = await getCredential(
requestId,
resolvedCredentialId,
authorization.credentialOwnerUserId
)
if (!storedCredential || storedCredential.providerId !== BITBUCKET_PROVIDER_ID) {
return NextResponse.json({ error: 'Select a Bitbucket OAuth credential.' }, { status: 400 })
}
const accessToken = await refreshAccessTokenIfNeeded(
resolvedCredentialId,
authorization.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json(
{ error: 'Could not retrieve a Bitbucket access token.', authRequired: true },
{ status: 401 }
)
}
const firstPage = new URL(BITBUCKET_WORKSPACES_URL)
firstPage.searchParams.set('pagelen', String(BITBUCKET_SELECTOR_PAGE_SIZE))
firstPage.searchParams.set('fields', BITBUCKET_WORKSPACE_FIELDS)
const providerUrl = cursor ?? firstPage.toString()
let response: Response
try {
response = await fetch(providerUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
redirect: 'error',
signal: request.signal,
})
} catch (error) {
if (request.signal.aborted) throw error
logger.warn('Bitbucket workspace request failed', {
errorType: error instanceof Error ? error.name : 'unknown',
})
return NextResponse.json({ error: 'Bitbucket workspace discovery failed.' }, { status: 502 })
}
if (!response.ok) return bitbucketFailureResponse(response.status)
let providerBody: unknown
try {
providerBody = await readResponseJsonWithLimit(response, {
label: 'Bitbucket workspace response',
maxBytes: PROVIDER_RESPONSE_MAX_BYTES,
signal: request.signal,
})
} catch (error) {
if (request.signal.aborted) throw error
logger.warn('Bitbucket workspace response was not bounded JSON', {
errorType: error instanceof Error ? error.name : 'unknown',
})
return NextResponse.json(
{ error: 'Bitbucket returned an invalid workspace response.' },
{ status: 502 }
)
}
const page = bitbucketWorkspaceProviderPageSchema.safeParse(providerBody)
if (!page.success || (page.data.next && !isBitbucketWorkspacesCursor(page.data.next))) {
logger.warn('Bitbucket returned a malformed workspace page')
return NextResponse.json(
{ error: 'Bitbucket returned an invalid workspace response.' },
{ status: 502 }
)
}
return NextResponse.json({
workspaces: page.data.values.map(({ administrator, workspace }) => ({
slug: workspace.slug,
uuid: workspace.uuid,
name: workspace.name ?? workspace.slug,
administrator,
})),
...(page.data.next ? { nextCursor: page.data.next } : {}),
})
})
@@ -416,7 +416,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
{displayScopes.map((scope) => (
<InfoCardItem key={scope}>
<span className='flex items-center gap-2'>
{getScopeDescription(scope)}
{getScopeDescription(scope, providerId)}
{!isConnect && newScopesSet.has(scope) && (
<Badge variant='amber' size='sm'>
New
+389
View File
@@ -0,0 +1,389 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { BitbucketBlock, BitbucketBlockMeta } from '@/blocks/blocks/bitbucket'
import { tools as toolRegistry } from '@/tools/registry'
vi.unmock('@/tools/registry')
const SOURCE_COMMIT_SHA = 'a'.repeat(40)
const PIPELINE_COMMIT_SHA = 'b'.repeat(40)
const TARGET_COMMIT_SHA = 'c'.repeat(40)
const EXPECTED_TOOL_IDS = [
'bitbucket_list_workspaces',
'bitbucket_list_repositories',
'bitbucket_get_repository',
'bitbucket_list_branches',
'bitbucket_create_branch',
'bitbucket_delete_branch',
'bitbucket_list_commits',
'bitbucket_get_commit',
'bitbucket_list_directory',
'bitbucket_get_file_metadata',
'bitbucket_get_file',
'bitbucket_list_pull_requests',
'bitbucket_get_pull_request',
'bitbucket_create_pull_request',
'bitbucket_merge_pull_request',
'bitbucket_get_pull_request_merge_task_status',
'bitbucket_decline_pull_request',
'bitbucket_approve_pull_request',
'bitbucket_request_pull_request_changes',
'bitbucket_get_pull_request_diff',
'bitbucket_get_pull_request_diffstat',
'bitbucket_list_pull_request_comments',
'bitbucket_create_pull_request_comment',
'bitbucket_list_pull_request_commit_statuses',
'bitbucket_list_pipelines',
'bitbucket_get_pipeline',
'bitbucket_trigger_pipeline',
'bitbucket_stop_pipeline',
'bitbucket_list_pipeline_steps',
'bitbucket_get_pipeline_step_log',
] as const
const SAMPLE_VALUES: Record<string, unknown> = {
oauthCredential: 'credential-1',
workspaceSlug: 'acme',
repoSlug: 'platform',
pageLen: '25',
nextUrl: 'https://api.bitbucket.org/2.0/example?page=2',
query: 'state = "OPEN"',
sort: '-updated_on',
administrator: 'true',
role: 'contributor',
branchName: 'feature/review',
target: SOURCE_COMMIT_SHA,
revision: SOURCE_COMMIT_SHA,
path: 'src/index.ts',
state: 'OPEN',
prId: '42',
title: 'Improve pipeline diagnostics',
sourceBranch: 'feature/review',
destinationBranch: 'main',
description: 'Adds bounded log diagnostics.',
reviewerAccountIds: '{reviewer-a}, {reviewer-b}',
createCloseSourceBranch: 'true',
mergeCloseSourceBranch: 'true',
draft: 'true',
mergeStrategy: 'squash',
message: 'Merge pull request 42',
taskId: 'task-1',
content: 'Looks good after the test fix.',
parentId: '7',
pipelineRefType: 'BRANCH',
pipelineRefName: 'main',
pipelineCommitHash: PIPELINE_COMMIT_SHA,
pipelineSelectorType: 'BRANCH',
pipelineSelectorPattern: 'main',
pipelineTriggerType: 'MANUAL',
pipelineStatus: 'FAILED',
pipelineUuid: '{pipeline-uuid}',
targetRef: 'main',
targetCommitHash: TARGET_COMMIT_SHA,
stepUuid: '{step-uuid}',
maxCharacters: '65536',
}
type BitbucketOperation = (typeof EXPECTED_TOOL_IDS)[number]
function mapBlockParams(
operation: BitbucketOperation,
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
const mapper = BitbucketBlock.tools.config?.params
if (!mapper) throw new Error('Bitbucket block is missing tools.config.params')
return mapper({ operation, ...SAMPLE_VALUES, ...overrides }) as Record<string, unknown>
}
function buildRequestUrl(
operation: BitbucketOperation,
overrides: Record<string, unknown> = {}
): string {
const toolId = BitbucketBlock.tools.config?.tool({ operation })
if (!toolId) throw new Error(`Bitbucket operation ${operation} did not resolve to a tool`)
const url = toolRegistry[toolId].request.url
if (typeof url !== 'function') return url
return url({ ...mapBlockParams(operation, overrides), accessToken: 'oauth-token' } as never)
}
function buildRequestBody(
operation: BitbucketOperation,
overrides: Record<string, unknown> = {}
): unknown {
const toolId = BitbucketBlock.tools.config?.tool({ operation })
if (!toolId) throw new Error(`Bitbucket operation ${operation} did not resolve to a tool`)
const body = toolRegistry[toolId].request.body
if (!body) throw new Error(`Bitbucket tool ${toolId} is missing request.body`)
return body({ ...mapBlockParams(operation, overrides), accessToken: 'oauth-token' } as never)
}
describe('BitbucketBlock', () => {
it('exposes every Bitbucket action one-to-one through the operation dropdown', () => {
const operation = BitbucketBlock.subBlocks.find((subBlock) => subBlock.id === 'operation')
const options =
typeof operation?.options === 'function' ? operation.options() : operation?.options
expect(options?.map((option) => option.id).sort()).toEqual([...EXPECTED_TOOL_IDS].sort())
expect([...BitbucketBlock.tools.access].sort()).toEqual([...EXPECTED_TOOL_IDS].sort())
expect(new Set(options?.map((option) => option.id)).size).toBe(EXPECTED_TOOL_IDS.length)
})
it.each(EXPECTED_TOOL_IDS)('%s resolves to a registered tool', (operation) => {
const toolId = BitbucketBlock.tools.config?.tool({ operation })
expect(toolId).toBe(operation)
expect(toolRegistry[toolId]).toBeDefined()
})
it.each(EXPECTED_TOOL_IDS)('%s supplies every required user-facing tool param', (operation) => {
const toolId = BitbucketBlock.tools.config?.tool({ operation }) as string
const mapped = BitbucketBlock.tools.config?.params?.({ operation, ...SAMPLE_VALUES }) ?? {}
const required = Object.entries(toolRegistry[toolId].params ?? {})
.filter(([id, config]) => config.required && id !== 'accessToken')
.map(([id]) => id)
expect(required.filter((id) => mapped[id] === undefined || mapped[id] === '')).toEqual([])
})
it.each(EXPECTED_TOOL_IDS)('%s maps every non-hidden tool parameter', (operation) => {
const toolId = BitbucketBlock.tools.config?.tool({ operation }) as string
const mapped = BitbucketBlock.tools.config?.params?.({ operation, ...SAMPLE_VALUES }) ?? {}
const exposed = Object.entries(toolRegistry[toolId].params ?? {})
.filter(([id, config]) => id !== 'accessToken' && config.visibility !== 'hidden')
.map(([id]) => id)
expect(exposed.filter((id) => mapped[id] === undefined)).toEqual([])
})
it.each(EXPECTED_TOOL_IDS)('%s does not emit unsupported execution params', (operation) => {
const toolId = BitbucketBlock.tools.config?.tool({ operation }) as string
const mapped = BitbucketBlock.tools.config?.params?.({ operation, ...SAMPLE_VALUES }) ?? {}
const accepted = new Set([...Object.keys(toolRegistry[toolId].params ?? {}), 'oauthCredential'])
expect(Object.keys(mapped).filter((key) => !accepted.has(key))).toEqual([])
})
it.each(EXPECTED_TOOL_IDS)('%s exposes every tool output through the block', (operation) => {
const toolOutputs = Object.keys(toolRegistry[operation].outputs ?? {})
const blockOutputs = new Set(Object.keys(BitbucketBlock.outputs))
expect(toolOutputs.filter((key) => !blockOutputs.has(key))).toEqual([])
})
it('uses canonical picker/manual pairs without colliding with visual subblock IDs', () => {
const expectedPairs = {
oauthCredential: ['accountPicker', 'credentialIdInput'],
workspaceSlug: ['workspacePicker', 'workspaceSlugInput'],
repoSlug: ['repositoryPicker', 'repositorySlugInput'],
}
for (const [canonicalParamId, ids] of Object.entries(expectedPairs)) {
const members = BitbucketBlock.subBlocks.filter(
(subBlock) => subBlock.canonicalParamId === canonicalParamId
)
expect(members.map((member) => member.id).sort()).toEqual([...ids].sort())
expect(members.filter((member) => member.mode === 'basic')).toHaveLength(1)
expect(members.filter((member) => member.mode === 'advanced')).toHaveLength(1)
expect(members[0].required).toEqual(members[1].required)
expect(members[0].condition).toEqual(members[1].condition)
expect(BitbucketBlock.subBlocks.some((member) => member.id === canonicalParamId)).toBe(false)
}
expect(
BitbucketBlock.subBlocks.find((member) => member.id === 'workspacePicker')?.dependsOn
).toEqual(['accountPicker'])
expect(
BitbucketBlock.subBlocks.find((member) => member.id === 'workspaceSlugInput')?.dependsOn
).toEqual(['credentialIdInput'])
expect(
BitbucketBlock.subBlocks.find((member) => member.id === 'repositoryPicker')?.dependsOn
).toEqual(['accountPicker', 'workspacePicker'])
expect(
BitbucketBlock.subBlocks.find((member) => member.id === 'repositorySlugInput')?.dependsOn
).toEqual(['credentialIdInput', 'workspaceSlugInput'])
})
it('coerces execution values and fixes pipeline triggering to a branch ref target', () => {
expect(
BitbucketBlock.tools.config?.params?.({
operation: 'bitbucket_create_pull_request',
...SAMPLE_VALUES,
})
).toMatchObject({
reviewerUuids: ['{reviewer-a}', '{reviewer-b}'],
closeSourceBranch: true,
})
expect(
BitbucketBlock.tools.config?.params?.({
operation: 'bitbucket_trigger_pipeline',
...SAMPLE_VALUES,
})
).toMatchObject({ refType: 'branch', refName: 'main' })
expect(
BitbucketBlock.tools.config?.params?.({
operation: 'bitbucket_list_repositories',
...SAMPLE_VALUES,
})
).toMatchObject({ pageLen: 25 })
expect(
BitbucketBlock.tools.config?.params?.({
operation: 'bitbucket_get_pull_request_diff',
...SAMPLE_VALUES,
})
).toMatchObject({ prId: 42, path: 'src/index.ts', maxCharacters: 65536 })
expect(
BitbucketBlock.tools.config?.params?.({
operation: 'bitbucket_get_file_metadata',
...SAMPLE_VALUES,
})
).toMatchObject({ commit: SOURCE_COMMIT_SHA, path: 'src/index.ts' })
expect(
BitbucketBlock.tools.config?.params?.({
operation: 'bitbucket_get_pull_request_merge_task_status',
...SAMPLE_VALUES,
})
).toMatchObject({ prId: 42, taskId: 'task-1' })
})
it.each([undefined, null, '', ' '])(
'treats %p as omission across optional execution value kinds',
(value) => {
expect(
mapBlockParams('bitbucket_create_pull_request_comment', { parentId: value }).parentId
).toBeUndefined()
expect(
mapBlockParams('bitbucket_create_pull_request', { draft: value }).draft
).toBeUndefined()
expect(
mapBlockParams('bitbucket_create_pull_request', { description: value }).description
).toBeUndefined()
expect(mapBlockParams('bitbucket_list_branches', { query: value }).q).toBeUndefined()
expect(
mapBlockParams('bitbucket_create_pull_request', { reviewerAccountIds: value }).reviewerUuids
).toBeUndefined()
}
)
it('coerces canonical positive integer strings and reaches the real tool URL', () => {
expect(buildRequestUrl('bitbucket_decline_pull_request', { prId: '42' })).toMatch(
/\/pullrequests\/42\/decline$/
)
})
it.each([
true,
false,
[7],
{ value: 7 },
0,
1.5,
Number.MAX_SAFE_INTEGER + 1,
'false',
' 42 ',
'01',
'7.0',
'1e2',
'-1',
])('rejects malformed PR ids before a mutation request is built: %p', (prId) => {
expect(() => buildRequestUrl('bitbucket_decline_pull_request', { prId })).toThrow(/prId/)
})
it('does not let a create-pull-request close setting reach a merge', () => {
const merged = buildRequestBody('bitbucket_merge_pull_request', {
createCloseSourceBranch: 'true',
mergeCloseSourceBranch: '',
}) as Record<string, unknown>
expect(merged).not.toHaveProperty('close_source_branch')
const created = buildRequestBody('bitbucket_create_pull_request', {
createCloseSourceBranch: '',
mergeCloseSourceBranch: 'true',
}) as Record<string, unknown>
expect(created).not.toHaveProperty('close_source_branch')
})
it('does not turn a malformed optional parent ID into a top-level comment', () => {
expect(() =>
buildRequestBody('bitbucket_create_pull_request_comment', { parentId: { id: 7 } })
).toThrow(/parentId/)
})
it('preserves explicit false booleans and rejects other present boolean shapes', () => {
expect(
buildRequestBody('bitbucket_create_pull_request', {
createCloseSourceBranch: false,
draft: 'false',
})
).toMatchObject({ close_source_branch: false, draft: false })
for (const draft of [0, 1, 'yes', ' false ', [], {}]) {
expect(
() => mapBlockParams('bitbucket_create_pull_request', { draft }),
String(draft)
).toThrow(/draft/)
}
})
it('validates reviewer lists atomically instead of filtering malformed elements', () => {
expect(
mapBlockParams('bitbucket_create_pull_request', {
reviewerAccountIds: [' {reviewer-a} ', '{reviewer-b}'],
}).reviewerUuids
).toEqual(['{reviewer-a}', '{reviewer-b}'])
expect(
mapBlockParams('bitbucket_create_pull_request', { reviewerAccountIds: [] }).reviewerUuids
).toEqual([])
for (const reviewerAccountIds of [
['{reviewer-a}', 7],
['{reviewer-a}', ' '],
'{reviewer-a},, {reviewer-b}',
{ uuid: '{reviewer-a}' },
]) {
expect(
() => mapBlockParams('bitbucket_create_pull_request', { reviewerAccountIds }),
JSON.stringify(reviewerAccountIds)
).toThrow(/reviewerAccountIds/)
}
})
it('rejects malformed present string values instead of silently omitting them', () => {
expect(() => mapBlockParams('bitbucket_list_branches', { query: false })).toThrow(/query/)
expect(() => mapBlockParams('bitbucket_create_pull_request', { description: {} })).toThrow(
/description/
)
})
it('preserves meaningful whitespace in repository paths and authored text', () => {
expect(
BitbucketBlock.tools.config?.params?.({
operation: 'bitbucket_get_file',
...SAMPLE_VALUES,
path: ' docs/release notes.md ',
})
).toMatchObject({ path: ' docs/release notes.md ' })
expect(
BitbucketBlock.tools.config?.params?.({
operation: 'bitbucket_create_pull_request_comment',
...SAMPLE_VALUES,
content: ' indented Markdown\n',
})
).toMatchObject({ content: ' indented Markdown\n' })
})
it('covers every action with a canvas sentence and declares no trigger support', () => {
const sentences = BitbucketBlock.canvasPresentation?.sentences?.byOperation ?? {}
expect(Object.keys(sentences).sort()).toEqual([...EXPECTED_TOOL_IDS].sort())
expect(BitbucketBlock.triggerAllowed).toBeUndefined()
expect(BitbucketBlock.triggers).toBeUndefined()
expect(BitbucketBlockMeta.tags).not.toContain('webhooks')
})
})
File diff suppressed because it is too large Load Diff
+3
View File
@@ -23,6 +23,7 @@ import {
AzureDataExplorerBlockMeta,
} from '@/blocks/blocks/azure_data_explorer'
import { AzureDevOpsBlock, AzureDevOpsBlockMeta } from '@/blocks/blocks/azure_devops'
import { BitbucketBlock, BitbucketBlockMeta } from '@/blocks/blocks/bitbucket'
import { BoxBlock, BoxBlockMeta } from '@/blocks/blocks/box'
import { BrandfetchBlock, BrandfetchBlockMeta } from '@/blocks/blocks/brandfetch'
import { BrexBlock, BrexBlockMeta } from '@/blocks/blocks/brex'
@@ -386,6 +387,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
attio: AttioBlock,
azure_data_explorer: AzureDataExplorerBlock,
azure_devops: AzureDevOpsBlock,
bitbucket: BitbucketBlock,
box: BoxBlock,
brandfetch: BrandfetchBlock,
brex: BrexBlock,
@@ -730,6 +732,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
attio: AttioBlockMeta,
azure_data_explorer: AzureDataExplorerBlockMeta,
azure_devops: AzureDevOpsBlockMeta,
bitbucket: BitbucketBlockMeta,
box: BoxBlockMeta,
brandfetch: BrandfetchBlockMeta,
brex: BrexBlockMeta,
+32
View File
@@ -754,6 +754,38 @@ export function GithubOutlineIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function BitbucketIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const gradientId = `bitbucket_original_a_${id}`
return (
<svg {...props} viewBox='0 0 128 128' xmlns='http://www.w3.org/2000/svg'>
<defs>
<linearGradient
id={gradientId}
gradientUnits='userSpaceOnUse'
x1='28.593'
y1='14.226'
x2='16.672'
y2='23.532'
gradientTransform='scale(4)'
>
<stop offset='.176' stopColor='#0052cc' />
<stop offset='1' stopColor='#2684ff' />
</linearGradient>
</defs>
<path
d='M19.082 20c-1.918 0-3.355 1.758-3.039 3.516l12.95 79.289c.32 2.078 2.077 3.515 4.155 3.515h62.66c1.442 0 2.72-1.12 3.04-2.558l13.109-80.086c.316-1.918-1.121-3.516-3.039-3.516zM74.07 77.227H54.09l-5.278-28.293h30.215zm0 0'
fill='#2684ff'
/>
<path
d='M107.64 48.934H78.868L74.07 77.227H54.09l-23.5 27.972s1.12.961 2.719.961h62.66c1.441 0 2.719-1.12 3.039-2.558zm0 0'
fill={`url(#${gradientId})`}
/>
</svg>
)
}
export function GitLabIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
@@ -0,0 +1,213 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() }))
vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson }))
import { getSelectorDefinition } from '@/hooks/selectors/registry'
import type { SelectorQueryArgs } from '@/hooks/selectors/types'
const workspaces = getSelectorDefinition('bitbucket.workspaces')
const repositories = getSelectorDefinition('bitbucket.repositories')
const workspaceArgs = (
overrides: Partial<SelectorQueryArgs['context']> = {}
): SelectorQueryArgs => ({
key: 'bitbucket.workspaces',
context: { oauthCredential: 'credential-1', workflowId: 'workflow-1', ...overrides },
})
const repositoryArgs = (
overrides: Partial<SelectorQueryArgs['context']> = {}
): SelectorQueryArgs => ({
key: 'bitbucket.repositories',
context: {
oauthCredential: 'credential-1',
workflowId: 'workflow-1',
workspaceSlug: 'acme-platform',
...overrides,
},
})
describe('bitbucket.workspaces selector', () => {
beforeEach(() => vi.clearAllMocks())
it('is enabled only after a credential is selected and isolates each authorization context', () => {
expect(workspaces.enabled?.(workspaceArgs())).toBe(true)
expect(workspaces.enabled?.(workspaceArgs({ oauthCredential: undefined }))).toBe(false)
expect(workspaces.getQueryKey(workspaceArgs())).toEqual([
'selectors',
'bitbucket.workspaces',
'credential-1',
'workflow-1',
])
expect(workspaces.getQueryKey(workspaceArgs({ oauthCredential: 'credential-2' }))).toEqual([
'selectors',
'bitbucket.workspaces',
'credential-2',
'workflow-1',
])
expect(workspaces.getQueryKey(workspaceArgs({ workflowId: 'workflow-2' }))).toEqual([
'selectors',
'bitbucket.workspaces',
'credential-1',
'workflow-2',
])
})
it('loads one page using a credential id and preserves provider metadata', async () => {
const nextCursor = 'https://api.bitbucket.org/2.0/user/workspaces?page=2&pagelen=100'
mockRequestJson.mockResolvedValue({
workspaces: [
{
slug: 'acme-platform',
uuid: '{workspace-uuid}',
name: 'Acme Platform',
administrator: true,
},
],
nextCursor,
})
const page = await workspaces.fetchPage?.({
...workspaceArgs(),
cursor: 'https://api.bitbucket.org/2.0/user/workspaces?page=1&pagelen=100',
})
expect(mockRequestJson).toHaveBeenCalledWith(
expect.objectContaining({ path: '/api/tools/bitbucket/workspaces' }),
expect.objectContaining({
body: {
credential: 'credential-1',
workflowId: 'workflow-1',
cursor: 'https://api.bitbucket.org/2.0/user/workspaces?page=1&pagelen=100',
},
})
)
expect(page).toEqual({
items: [
{
id: 'acme-platform',
label: 'Acme Platform',
meta: {
slug: 'acme-platform',
uuid: '{workspace-uuid}',
fullName: 'Acme Platform',
administrator: true,
},
},
],
nextCursor,
})
})
it('rejects a missing credential before making a route request', async () => {
await expect(
workspaces.fetchPage?.(workspaceArgs({ oauthCredential: undefined }))
).rejects.toThrow(/Missing credential/)
expect(mockRequestJson).not.toHaveBeenCalled()
})
})
describe('bitbucket.repositories selector', () => {
beforeEach(() => vi.clearAllMocks())
it('waits for both dependencies and isolates pages by auth context and workspace', () => {
expect(repositories.enabled?.(repositoryArgs())).toBe(true)
expect(repositories.enabled?.(repositoryArgs({ oauthCredential: undefined }))).toBe(false)
expect(repositories.enabled?.(repositoryArgs({ workspaceSlug: undefined }))).toBe(false)
expect(repositories.getQueryKey(repositoryArgs())).toEqual([
'selectors',
'bitbucket.repositories',
'credential-1',
'workflow-1',
'acme-platform',
])
expect(repositories.getQueryKey(repositoryArgs({ workspaceSlug: 'other-team' }))).toEqual([
'selectors',
'bitbucket.repositories',
'credential-1',
'workflow-1',
'other-team',
])
expect(repositories.getQueryKey(repositoryArgs({ oauthCredential: 'credential-2' }))).toEqual([
'selectors',
'bitbucket.repositories',
'credential-2',
'workflow-1',
'acme-platform',
])
expect(repositories.getQueryKey(repositoryArgs({ workflowId: 'workflow-2' }))).toEqual([
'selectors',
'bitbucket.repositories',
'credential-1',
'workflow-2',
'acme-platform',
])
})
it('keeps the workspace dependency on every page and maps slug ids with UUID/full-name metadata', async () => {
const nextCursor = 'https://api.bitbucket.org/2.0/repositories/acme-platform?page=3&pagelen=100'
mockRequestJson.mockResolvedValue({
repositories: [
{
slug: 'payments-api',
uuid: '{repository-uuid}',
name: 'Payments API',
fullName: 'acme-platform/payments-api',
},
],
nextCursor,
})
const page = await repositories.fetchPage?.({
...repositoryArgs(),
cursor: 'https://api.bitbucket.org/2.0/repositories/acme-platform?page=2&pagelen=100',
})
expect(mockRequestJson).toHaveBeenCalledWith(
expect.objectContaining({ path: '/api/tools/bitbucket/repositories' }),
expect.objectContaining({
body: {
credential: 'credential-1',
workflowId: 'workflow-1',
workspaceSlug: 'acme-platform',
cursor: 'https://api.bitbucket.org/2.0/repositories/acme-platform?page=2&pagelen=100',
},
})
)
expect(page).toEqual({
items: [
{
id: 'payments-api',
label: 'Payments API',
meta: {
slug: 'payments-api',
uuid: '{repository-uuid}',
fullName: 'acme-platform/payments-api',
workspaceSlug: 'acme-platform',
},
},
],
nextCursor,
})
})
it('rejects a missing workspace dependency instead of issuing an unscoped request', async () => {
await expect(
repositories.fetchPage?.(repositoryArgs({ workspaceSlug: undefined }))
).rejects.toThrow(/Missing workspace slug/)
expect(mockRequestJson).not.toHaveBeenCalled()
})
it('rejects a missing credential before making a route request', async () => {
await expect(
repositories.fetchPage?.(repositoryArgs({ oauthCredential: undefined }))
).rejects.toThrow(/Missing credential/)
expect(mockRequestJson).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,95 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
export const bitbucketSelectors = {
'bitbucket.workspaces': {
key: 'bitbucket.workspaces',
contracts: [selectorContracts.bitbucketWorkspacesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'bitbucket.workspaces',
context.oauthCredential ?? 'none',
context.workflowId ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
/** Loads one Bitbucket page so the shared selector hook can progressively drain it. */
fetchPage: async ({ context, cursor, signal }) => {
const credentialId = ensureCredential(context, 'bitbucket.workspaces')
const data = await requestJson(selectorContracts.bitbucketWorkspacesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
cursor,
},
signal,
})
return {
items: data.workspaces.map((workspace) => ({
id: workspace.slug,
label: workspace.name,
meta: {
slug: workspace.slug,
uuid: workspace.uuid,
fullName: workspace.name,
administrator: workspace.administrator,
},
})),
nextCursor: data.nextCursor,
}
},
},
'bitbucket.repositories': {
key: 'bitbucket.repositories',
contracts: [selectorContracts.bitbucketRepositoriesSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'bitbucket.repositories',
context.oauthCredential ?? 'none',
context.workflowId ?? 'none',
context.workspaceSlug ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.workspaceSlug),
/**
* Repository discovery is scoped to the selected workspace on every page;
* no unscoped request is sent while the dependency is absent.
*/
fetchPage: async ({ context, cursor, signal }) => {
const credentialId = ensureCredential(context, 'bitbucket.repositories')
if (!context.workspaceSlug) {
throw new Error('Missing workspace slug for bitbucket.repositories selector')
}
const data = await requestJson(selectorContracts.bitbucketRepositoriesSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
workspaceSlug: context.workspaceSlug,
cursor,
},
signal,
})
return {
items: data.repositories.map((repository) => ({
id: repository.slug,
label: repository.name,
meta: {
slug: repository.slug,
uuid: repository.uuid,
fullName: repository.fullName,
workspaceSlug: context.workspaceSlug,
},
})),
nextCursor: data.nextCursor,
}
},
},
} satisfies Record<
Extract<SelectorKey, 'bitbucket.workspaces' | 'bitbucket.repositories'>,
SelectorDefinition
>
+2
View File
@@ -2,6 +2,7 @@ import { airtableSelectors } from '@/hooks/selectors/providers/airtable/selector
import { asanaSelectors } from '@/hooks/selectors/providers/asana/selectors'
import { attioSelectors } from '@/hooks/selectors/providers/attio/selectors'
import { bigquerySelectors } from '@/hooks/selectors/providers/bigquery/selectors'
import { bitbucketSelectors } from '@/hooks/selectors/providers/bitbucket/selectors'
import { calcomSelectors } from '@/hooks/selectors/providers/calcom/selectors'
import { clickupSelectors } from '@/hooks/selectors/providers/clickup/selectors'
import { cloudwatchSelectors } from '@/hooks/selectors/providers/cloudwatch/selectors'
@@ -37,6 +38,7 @@ export const selectorRegistry = {
...asanaSelectors,
...attioSelectors,
...bigquerySelectors,
...bitbucketSelectors,
...calcomSelectors,
...confluenceSelectors,
...jsmSelectors,
+4
View File
@@ -10,6 +10,8 @@ export type SelectorKey =
| 'attio.objects'
| 'bigquery.datasets'
| 'bigquery.tables'
| 'bitbucket.workspaces'
| 'bitbucket.repositories'
| 'calcom.eventTypes'
| 'calcom.schedules'
| 'clickup.workspaces'
@@ -121,6 +123,8 @@ export interface SelectorContext {
schema?: string
/** Zoho Desk organization (portal) id — the `orgId` header every Desk call but `/organizations` requires. */
orgId?: string
/** Bitbucket Cloud workspace slug that scopes repository discovery. */
workspaceSlug?: string
}
export interface SelectorQueryArgs {
@@ -0,0 +1,199 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
optionalString,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractBody, ContractJsonResponse } from '@/lib/api/contracts/types'
const BITBUCKET_API_ORIGIN = 'https://api.bitbucket.org'
const BITBUCKET_WORKSPACES_PATH = '/2.0/user/workspaces'
const BITBUCKET_REPOSITORIES_PATH = '/2.0/repositories'
const BITBUCKET_CURSOR_MAX_LENGTH = 4_096
export const BITBUCKET_SELECTOR_PAGE_SIZE = 100
const bitbucketSlugSchema = z
.string()
.trim()
.min(1, 'Bitbucket slug is required')
.max(255, 'Bitbucket slug must be 255 characters or fewer')
const bitbucketWorkspaceUuidPattern =
/^(?:\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i
/** Bitbucket workspace ids are alphanumeric with hyphens and underscores only. */
const bitbucketWorkspaceSlugPattern = /^[a-z0-9][a-z0-9_-]*$/i
const bitbucketWorkspaceSlugSchema = bitbucketSlugSchema.refine(
(slug) => bitbucketWorkspaceSlugPattern.test(slug) && !bitbucketWorkspaceUuidPattern.test(slug),
'Bitbucket workspace must be identified by its slug, not a UUID or path'
)
const bitbucketRepositorySlugSchema = bitbucketSlugSchema.refine(
(slug) => slug.length <= 62,
'Bitbucket repository slug must be 62 characters or fewer'
)
/**
* Parses only absolute Bitbucket Cloud API URLs that are safe to receive an
* OAuth bearer token. Userinfo, fragments, non-default ports, lookalike hosts,
* and paths outside the v2 API are rejected.
*/
function parseBitbucketApiCursor(value: string): URL | null {
try {
const url = new URL(value)
if (
url.origin !== BITBUCKET_API_ORIGIN ||
url.username ||
url.password ||
url.hash ||
!url.pathname.startsWith('/2.0/')
) {
return null
}
return url
} catch {
return null
}
}
/** Validates a provider cursor for the authenticated user's workspace stream. */
export function isBitbucketWorkspacesCursor(value: string): boolean {
return parseBitbucketApiCursor(value)?.pathname === BITBUCKET_WORKSPACES_PATH
}
/**
* Validates a repository cursor against the workspace dependency selected in
* the block, preventing a stale or crafted cursor from crossing workspaces.
*/
export function isBitbucketRepositoriesCursor(value: string, workspaceSlug: string): boolean {
const url = parseBitbucketApiCursor(value)
const expected = `${BITBUCKET_REPOSITORIES_PATH}/${encodeURIComponent(workspaceSlug)}`
return url?.pathname.toLowerCase() === expected.toLowerCase()
}
const bitbucketCursorSchema = z
.string()
.min(1, 'Bitbucket cursor cannot be empty')
.max(BITBUCKET_CURSOR_MAX_LENGTH, 'Bitbucket cursor is too long')
export const bitbucketWorkspacesBodySchema = credentialWorkflowBodySchema.extend({
cursor: bitbucketCursorSchema
.refine(isBitbucketWorkspacesCursor, 'Invalid Bitbucket workspaces cursor')
.optional(),
})
export const bitbucketRepositoriesBodySchema = credentialWorkflowBodySchema
.extend({
workspaceSlug: bitbucketWorkspaceSlugSchema,
cursor: bitbucketCursorSchema.optional(),
})
.superRefine((body, ctx) => {
if (body.cursor && !isBitbucketRepositoriesCursor(body.cursor, body.workspaceSlug)) {
ctx.addIssue({
code: 'custom',
path: ['cursor'],
message: 'Invalid Bitbucket repositories cursor',
})
}
})
const bitbucketUuidSchema = z.string().trim().min(1).max(100)
const bitbucketNameSchema = z.string().trim().min(1).max(512)
const bitbucketWorkspaceProviderSchema = z
.object({
administrator: z.boolean(),
workspace: z
.object({
slug: bitbucketSlugSchema,
uuid: bitbucketUuidSchema,
name: bitbucketNameSchema.optional(),
})
.passthrough(),
})
.passthrough()
const bitbucketRepositoryProviderSchema = z
.object({
slug: bitbucketRepositorySlugSchema.optional(),
uuid: bitbucketUuidSchema,
name: bitbucketNameSchema.optional(),
full_name: bitbucketNameSchema,
})
.passthrough()
.refine((repository) => {
const slash = repository.full_name.indexOf('/')
if (slash <= 0 || slash !== repository.full_name.lastIndexOf('/')) return false
const fullNameSlug = repository.full_name.slice(slash + 1)
return (
bitbucketRepositorySlugSchema.safeParse(fullNameSlug).success &&
(!repository.slug || repository.slug === fullNameSlug)
)
}, 'Bitbucket repository full_name does not match its slug')
.transform((repository) => ({
...repository,
slug: repository.slug ?? repository.full_name.slice(repository.full_name.indexOf('/') + 1),
}))
/** Strictly narrows the untrusted Bitbucket Cloud workspace page. */
export const bitbucketWorkspaceProviderPageSchema = z
.object({
values: z.array(bitbucketWorkspaceProviderSchema).max(BITBUCKET_SELECTOR_PAGE_SIZE),
next: bitbucketCursorSchema.optional(),
})
.passthrough()
/** Strictly narrows the untrusted Bitbucket Cloud repository page. */
export const bitbucketRepositoryProviderPageSchema = z
.object({
values: z.array(bitbucketRepositoryProviderSchema).max(BITBUCKET_SELECTOR_PAGE_SIZE),
next: bitbucketCursorSchema.optional(),
})
.passthrough()
const bitbucketWorkspaceSchema = z.object({
slug: bitbucketSlugSchema,
uuid: bitbucketUuidSchema,
name: bitbucketNameSchema,
administrator: z.boolean(),
})
const bitbucketRepositorySchema = z.object({
slug: bitbucketRepositorySlugSchema,
uuid: bitbucketUuidSchema,
name: bitbucketNameSchema,
fullName: bitbucketNameSchema,
})
export const bitbucketWorkspacesSelectorContract = definePostSelector(
'/api/tools/bitbucket/workspaces',
bitbucketWorkspacesBodySchema,
z.object({
workspaces: z.array(bitbucketWorkspaceSchema).max(BITBUCKET_SELECTOR_PAGE_SIZE),
nextCursor: optionalString,
})
)
export const bitbucketRepositoriesSelectorContract = definePostSelector(
'/api/tools/bitbucket/repositories',
bitbucketRepositoriesBodySchema,
z.object({
repositories: z.array(bitbucketRepositorySchema).max(BITBUCKET_SELECTOR_PAGE_SIZE),
nextCursor: optionalString,
})
)
export type BitbucketWorkspacesSelectorBody = ContractBody<
typeof bitbucketWorkspacesSelectorContract
>
export type BitbucketRepositoriesSelectorBody = ContractBody<
typeof bitbucketRepositoriesSelectorContract
>
export type BitbucketWorkspacesSelectorResponse = ContractJsonResponse<
typeof bitbucketWorkspacesSelectorContract
>
export type BitbucketRepositoriesSelectorResponse = ContractJsonResponse<
typeof bitbucketRepositoriesSelectorContract
>
@@ -11,6 +11,10 @@ import {
bigQueryDatasetsSelectorContract,
bigQueryTablesSelectorContract,
} from '@/lib/api/contracts/selectors/bigquery'
import {
bitbucketRepositoriesSelectorContract,
bitbucketWorkspacesSelectorContract,
} from '@/lib/api/contracts/selectors/bitbucket'
import {
calcomEventTypesSelectorContract,
calcomSchedulesSelectorContract,
@@ -120,6 +124,7 @@ export * from '@/lib/api/contracts/selectors/airtable'
export * from '@/lib/api/contracts/selectors/asana'
export * from '@/lib/api/contracts/selectors/attio'
export * from '@/lib/api/contracts/selectors/bigquery'
export * from '@/lib/api/contracts/selectors/bitbucket'
export * from '@/lib/api/contracts/selectors/calcom'
export * from '@/lib/api/contracts/selectors/clickup'
export * from '@/lib/api/contracts/selectors/cloudwatch'
@@ -153,6 +158,8 @@ export const selectorContractsByPath = {
'/api/tools/attio/lists': attioListsSelectorContract,
'/api/tools/google_bigquery/datasets': bigQueryDatasetsSelectorContract,
'/api/tools/google_bigquery/tables': bigQueryTablesSelectorContract,
'/api/tools/bitbucket/workspaces': bitbucketWorkspacesSelectorContract,
'/api/tools/bitbucket/repositories': bitbucketRepositoriesSelectorContract,
'/api/tools/calcom/event-types': calcomEventTypesSelectorContract,
'/api/tools/calcom/schedules': calcomSchedulesSelectorContract,
'/api/tools/clickup/workspaces': clickupWorkspacesSelectorContract,
+122
View File
@@ -79,6 +79,22 @@ interface AttioWorkspaceMemberResponse {
}
}
/**
* Shape of `GET https://api.bitbucket.org/2.0/user` for the authenticated user.
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-users/#api-user-get
*/
interface BitbucketCurrentUserResponse {
account_id?: string | null
uuid?: string | null
display_name?: string | null
nickname?: string | null
links?: {
avatar?: {
href?: string | null
}
}
}
/**
* Builds a Salesforce connector bound to one login host — `genericOAuth` takes
* static endpoints, so each authorization server needs its own registration.
@@ -1458,6 +1474,112 @@ export function buildConnectorProviders(): GenericOAuthConfig[] {
},
},
{
providerId: 'bitbucket',
clientId: env.BITBUCKET_CLIENT_ID as string,
clientSecret: env.BITBUCKET_CLIENT_SECRET as string,
authorizationUrl: 'https://bitbucket.org/site/oauth2/authorize',
tokenUrl: 'https://bitbucket.org/site/oauth2/access_token',
userInfoUrl: 'https://api.bitbucket.org/2.0/user',
scopes: getCanonicalScopesForProvider('bitbucket'),
responseType: 'code',
pkce: false,
authentication: 'basic',
accessTokenExpiresIn: 7200,
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/bitbucket`,
getToken: async ({ code, redirectURI }) => {
const basicAuth = Buffer.from(
`${env.BITBUCKET_CLIENT_ID as string}:${env.BITBUCKET_CLIENT_SECRET as string}`
).toString('base64')
const response = await fetch('https://bitbucket.org/site/oauth2/access_token', {
method: 'POST',
headers: {
Authorization: `Basic ${basicAuth}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
code,
grant_type: 'authorization_code',
redirect_uri: redirectURI,
}).toString(),
})
const data = await readResponseJsonWithLimit<Record<string, unknown>>(response, {
maxBytes: 1024 * 1024,
label: 'Bitbucket OAuth token response',
})
if (!response.ok || !isRecordLike(data)) {
logger.error('Bitbucket OAuth token exchange failed', { status: response.status })
throw new Error(`Bitbucket OAuth token exchange failed with HTTP ${response.status}`)
}
const tokens = getOAuth2Tokens(data)
if (!tokens.accessToken) {
throw new Error('Bitbucket OAuth token response did not include an access token')
}
const grantedScopes = data.scopes ?? data.scope
if (typeof grantedScopes === 'string') {
tokens.scopes = grantedScopes.split(/\s+/).filter(Boolean)
} else if (Array.isArray(grantedScopes)) {
tokens.scopes = grantedScopes.filter(
(scope): scope is string => typeof scope === 'string'
)
}
return tokens
},
getUserInfo: async (tokens) => {
try {
const signal = AbortSignal.timeout(15_000)
const response = await fetch('https://api.bitbucket.org/2.0/user', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
signal,
})
if (!response.ok) {
await readResponseTextWithLimit(response, {
maxBytes: 1024 * 1024,
label: 'Bitbucket OAuth user info error response',
signal,
}).catch(() => {})
logger.error('Error fetching Bitbucket user info:', {
status: response.status,
statusText: response.statusText,
})
return null
}
const data = await readResponseJsonWithLimit<BitbucketCurrentUserResponse>(response, {
maxBytes: 1024 * 1024,
label: 'Bitbucket OAuth user info response',
signal,
})
const stableId = data.account_id ?? data.uuid
if (!stableId) {
logger.error('Bitbucket user info did not include an account_id or uuid')
return null
}
const now = new Date()
return {
id: `${stableId}-${generateId()}`,
name: data.display_name || data.nickname || 'Bitbucket User',
email: syntheticConnectorEmail('bitbucket', stableId),
image: data.links?.avatar?.href || undefined,
emailVerified: false,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in Bitbucket getUserInfo:', { error })
return null
}
},
},
{
providerId: 'notion',
clientId: env.NOTION_CLIENT_ID as string,
+2
View File
@@ -431,6 +431,8 @@ export const env = createEnv({
ASANA_CLIENT_SECRET: z.string().optional(), // Asana OAuth client secret
AIRTABLE_CLIENT_ID: z.string().optional(), // Airtable OAuth client ID
AIRTABLE_CLIENT_SECRET: z.string().optional(), // Airtable OAuth client secret
BITBUCKET_CLIENT_ID: z.string().optional(), // Bitbucket OAuth consumer key
BITBUCKET_CLIENT_SECRET: z.string().optional(), // Bitbucket OAuth consumer secret
APOLLO_API_KEY: z.string().optional(), // Apollo API key (optional system-wide config)
SUPABASE_CLIENT_ID: z.string().optional(), // Supabase OAuth client ID
SUPABASE_CLIENT_SECRET: z.string().optional(), // Supabase OAuth client secret
@@ -24,6 +24,7 @@ import {
AttioIcon,
AzureDataExplorerIcon,
AzureIcon,
BitbucketIcon,
BoxCompanyIcon,
BrainIcon,
BrandfetchIcon,
@@ -285,6 +286,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
attio: AttioIcon,
azure_data_explorer: AzureDataExplorerIcon,
azure_devops: AzureIcon,
bitbucket: BitbucketIcon,
box: BoxCompanyIcon,
brandfetch: BrandfetchIcon,
brex: BrexIcon,
@@ -24,6 +24,7 @@ const EXPECTED_PROVIDER_BY_SLUG: Record<string, string> = {
asana: 'asana',
attio: 'attio',
'azure-ad': 'microsoft-ad',
bitbucket: 'bitbucket',
box: 'box',
'cal-com': 'calcom',
confluence: 'confluence',
+167
View File
@@ -1,8 +1,10 @@
import { getOAuth2Tokens } from '@better-auth/core/oauth2'
import { createMockFetch, resetEnvMock, setEnv } from '@sim/testing'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
setEnv({
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
GOOGLE_CLIENT_ID: 'google_client_id',
GOOGLE_CLIENT_SECRET: 'google_client_secret',
GITHUB_CLIENT_ID: 'github_client_id',
@@ -19,6 +21,8 @@ beforeAll(() => {
JIRA_CLIENT_SECRET: 'jira_client_secret',
AIRTABLE_CLIENT_ID: 'airtable_client_id',
AIRTABLE_CLIENT_SECRET: 'airtable_client_secret',
BITBUCKET_CLIENT_ID: 'bitbucket_client_id',
BITBUCKET_CLIENT_SECRET: 'bitbucket_client_secret',
NOTION_CLIENT_ID: 'notion_client_id',
NOTION_CLIENT_SECRET: 'notion_client_secret',
MICROSOFT_CLIENT_ID: 'microsoft_client_id',
@@ -64,6 +68,7 @@ beforeAll(() => {
afterAll(resetEnvMock)
import { GoogleIcon, GoogleVaultIcon } from '@/components/icons'
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
import { DEFAULT_MAX_ERROR_BODY_BYTES } from '@/lib/core/utils/stream-limits'
import { OAUTH_PROVIDERS, refreshOAuthToken } from '@/lib/oauth'
import { REDDIT_USER_AGENT } from '@/tools/reddit/constants'
@@ -100,6 +105,142 @@ describe('OAuth Provider Branding', () => {
})
})
function getBitbucketConnector() {
const connector = buildConnectorProviders().find(
(candidate) => candidate.providerId === 'bitbucket'
)
if (!connector) throw new Error('Bitbucket OAuth connector is not configured in this test')
return connector
}
describe('Bitbucket OAuth Connector', () => {
it('uses the canonical endpoints, scopes, Basic auth, and two-hour expiry', () => {
expect(getBitbucketConnector()).toMatchObject({
providerId: 'bitbucket',
authorizationUrl: 'https://bitbucket.org/site/oauth2/authorize',
tokenUrl: 'https://bitbucket.org/site/oauth2/access_token',
userInfoUrl: 'https://api.bitbucket.org/2.0/user',
scopes: [
'account',
'repository',
'repository:write',
'pullrequest',
'pullrequest:write',
'pipeline',
'pipeline:write',
],
responseType: 'code',
pkce: false,
authentication: 'basic',
accessTokenExpiresIn: 7200,
redirectURI: 'http://localhost:3000/api/auth/oauth2/callback/bitbucket',
})
})
it('exchanges the authorization code with Basic auth and normalizes plural scopes', async () => {
const connector = getBitbucketConnector()
const getToken = connector.getToken
if (!getToken) throw new Error('Bitbucket connector must define getToken')
const scopes = [
'account',
'repository',
'repository:write',
'pullrequest',
'pullrequest:write',
'pipeline',
'pipeline:write',
]
const mockFetch = createMockFetch({
json: {
access_token: 'bitbucket_access_token',
expires_in: 3600,
refresh_token: 'bitbucket_refresh_token',
scopes: scopes.join(' '),
token_type: 'bearer',
},
})
const tokens = await withMockFetch(mockFetch, () =>
getToken({
code: 'authorization_code',
redirectURI: 'http://localhost:3000/api/auth/oauth2/callback/bitbucket',
})
)
expect(tokens.accessToken).toBe('bitbucket_access_token')
expect(tokens.refreshToken).toBe('bitbucket_refresh_token')
expect(tokens.scopes).toEqual(scopes)
expect(tokens.accessTokenExpiresAt).toBeInstanceOf(Date)
const [endpoint, requestOptions] = mockFetch.mock.calls[0] as [
string,
{ headers: Record<string, string>; body: string },
]
expect(endpoint).toBe('https://bitbucket.org/site/oauth2/access_token')
expect(requestOptions.headers.Authorization).toBe(
`Basic ${Buffer.from('bitbucket_client_id:bitbucket_client_secret').toString('base64')}`
)
expect(Object.fromEntries(new URLSearchParams(requestOptions.body))).toEqual({
code: 'authorization_code',
grant_type: 'authorization_code',
redirect_uri: 'http://localhost:3000/api/auth/oauth2/callback/bitbucket',
})
})
it('uses account_id before uuid and always synthesizes an internal email', async () => {
const connector = getBitbucketConnector()
const getUserInfo = connector.getUserInfo
if (!getUserInfo) throw new Error('Bitbucket connector must define getUserInfo')
const tokens = getOAuth2Tokens({ access_token: 'bitbucket_access_token' })
const accountIdentity = await withMockFetch(
createMockFetch({
json: {
account_id: 'account-123',
uuid: '{uuid-ignored}',
display_name: 'Ada Lovelace',
links: { avatar: { href: 'https://example.invalid/avatar.png' } },
},
}),
() => getUserInfo(tokens)
)
expect(accountIdentity?.id).toMatch(/^account-123-/)
expect(accountIdentity?.email).toBe('bitbucket-account-123@connectors.sim.invalid')
expect(accountIdentity?.name).toBe('Ada Lovelace')
expect(accountIdentity?.image).toBe('https://example.invalid/avatar.png')
const uuidIdentity = await withMockFetch(
createMockFetch({ json: { uuid: '{uuid-456}', nickname: 'grace' } }),
() => getUserInfo(tokens)
)
expect(uuidIdentity?.id).toMatch(/^\{uuid-456\}-/)
expect(uuidIdentity?.email).toBe('bitbucket-uuid-456@connectors.sim.invalid')
expect(uuidIdentity?.name).toBe('grace')
})
it('bounds Bitbucket user-info responses and supplies a provider deadline', async () => {
const getUserInfo = getBitbucketConnector().getUserInfo
if (!getUserInfo) throw new Error('Bitbucket connector must define getUserInfo')
const mockFetch = vi.fn(async (_url: string, init?: RequestInit) => {
expect(init?.signal).toBeInstanceOf(AbortSignal)
return new Response('{}', {
headers: {
'content-length': String(1024 * 1024 + 1),
'content-type': 'application/json',
},
})
})
await expect(
withMockFetch(mockFetch, () =>
getUserInfo(getOAuth2Tokens({ access_token: 'bitbucket_access_token' }))
)
).resolves.toBeNull()
expect(mockFetch).toHaveBeenCalledOnce()
})
})
describe('OAuth Token Refresh', () => {
describe('Basic Auth Providers', () => {
const basicAuthProviders = [
@@ -108,6 +249,11 @@ describe('OAuth Token Refresh', () => {
providerId: 'airtable',
endpoint: 'https://airtable.com/oauth2/v1/token',
},
{
name: 'Bitbucket',
providerId: 'bitbucket',
endpoint: 'https://bitbucket.org/site/oauth2/access_token',
},
{ name: 'X (Twitter)', providerId: 'x', endpoint: 'https://api.x.com/2/oauth2/token' },
{
name: 'Confluence',
@@ -502,6 +648,27 @@ describe('OAuth Token Refresh', () => {
})
})
it.concurrent('should return Bitbucket rotating refresh tokens', async () => {
const mockFetch = createMockFetch({
json: {
access_token: 'new_bitbucket_access_token',
expires_in: 3600,
refresh_token: 'rotated_bitbucket_refresh_token',
},
})
const result = await withMockFetch(mockFetch, () =>
refreshOAuthToken('bitbucket', 'old_bitbucket_refresh_token')
)
expect(result).toEqual({
ok: true,
accessToken: 'new_bitbucket_access_token',
expiresIn: 3600,
refreshToken: 'rotated_bitbucket_refresh_token',
})
})
it.concurrent(
'should rotate refresh token for Microsoft providers (microsoft, outlook, onedrive, sharepoint)',
async () => {
+38
View File
@@ -7,6 +7,7 @@ import {
AtlassianIcon,
AttioIcon,
AzureIcon,
BitbucketIcon,
BoxCompanyIcon,
CalComIcon,
ClaudeIcon,
@@ -689,6 +690,29 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
},
defaultService: 'airtable',
},
bitbucket: {
name: 'Bitbucket',
icon: BitbucketIcon,
services: {
bitbucket: {
name: 'Bitbucket',
description: 'Read repositories, collaborate on pull requests, and manage pipelines.',
providerId: 'bitbucket',
icon: BitbucketIcon,
baseProviderIcon: BitbucketIcon,
scopes: [
'account',
'repository',
'repository:write',
'pullrequest',
'pullrequest:write',
'pipeline',
'pipeline:write',
],
},
},
defaultService: 'bitbucket',
},
notion: {
name: 'Notion',
icon: NotionIcon,
@@ -1443,6 +1467,20 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
supportsRefreshTokenRotation: true,
}
}
case 'bitbucket': {
const { clientId, clientSecret } = getConfiguredClientCredentials(
'bitbucket',
'BITBUCKET_CLIENT_ID',
'BITBUCKET_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://bitbucket.org/site/oauth2/access_token',
clientId,
clientSecret,
useBasicAuth: true,
supportsRefreshTokenRotation: true,
}
}
case 'notion': {
const { clientId, clientSecret } = getConfiguredClientCredentials(
'notion',
+2
View File
@@ -51,6 +51,7 @@ export type OAuthProvider =
| 'tiktok'
| 'confluence'
| 'airtable'
| 'bitbucket'
| 'notion'
| 'jira'
| 'atlassian-service-account'
@@ -107,6 +108,7 @@ export type OAuthService =
| 'tiktok'
| 'confluence'
| 'airtable'
| 'bitbucket'
| 'notion'
| 'jira'
| 'atlassian-service-account'
+15
View File
@@ -8,6 +8,7 @@ import {
getCanonicalScopesForProvider,
getMissingRequiredScopes,
getProviderIdFromServiceId,
getScopeDescription,
getScopesForService,
getServiceByProviderAndId,
getServiceConfigByProviderId,
@@ -384,6 +385,20 @@ describe('getCanonicalScopesForProvider', () => {
})
})
describe('getScopeDescription', () => {
it.concurrent('uses provider-specific labels for Bitbucket scope names', () => {
expect(getScopeDescription('account', 'bitbucket')).toBe(
'View your Bitbucket account and workspace memberships'
)
expect(getScopeDescription('pipeline:write', 'bitbucket')).toBe('Run and stop pipelines')
})
it.concurrent('preserves the existing Reddit meaning of the account scope', () => {
expect(getScopeDescription('account', 'reddit')).toBe('Update account preferences and settings')
expect(getScopeDescription('account')).toBe('Update account preferences and settings')
})
})
describe('parseProvider', () => {
it.concurrent('should parse simple provider without hyphen', () => {
const config = parseProvider('slack' as OAuthProvider)
+17 -2
View File
@@ -470,12 +470,27 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
'me:read': 'Read your user profile',
}
/** Scope labels that cannot be keyed by scope alone because providers reuse names. */
const PROVIDER_SCOPE_DESCRIPTIONS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
bitbucket: {
account: 'View your Bitbucket account and workspace memberships',
repository: 'View repositories and source code',
'repository:write': 'Create and modify repositories, branches, and source code',
pullrequest: 'View pull requests, comments, approvals, and statuses',
'pullrequest:write': 'Create, update, approve, decline, and merge pull requests',
pipeline: 'View pipelines, steps, and logs',
'pipeline:write': 'Run and stop pipelines',
},
}
/**
* Get a human-readable description for a scope.
* Falls back to the raw scope string if no description is found.
*/
export function getScopeDescription(scope: string): string {
return SCOPE_DESCRIPTIONS[scope] || scope
export function getScopeDescription(scope: string, providerId?: string): string {
return (
PROVIDER_SCOPE_DESCRIPTIONS[providerId ?? '']?.[scope] || SCOPE_DESCRIPTIONS[scope] || scope
)
}
/**
@@ -123,6 +123,35 @@ describe('buildSelectorContextFromBlock', () => {
expect(ctx.jobId).toBe('job-7')
})
it('exposes the active Bitbucket workspace slug to repository selectors', () => {
const subBlocks = {
operation: {
id: 'operation',
type: 'dropdown',
value: 'bitbucket_get_repository',
},
workspacePicker: {
id: 'workspacePicker',
type: 'project-selector',
value: 'acme-platform',
},
workspaceSlugInput: {
id: 'workspaceSlugInput',
type: 'short-input',
value: 'advanced-team',
},
}
expect(buildSelectorContextFromBlock('bitbucket', subBlocks).workspaceSlug).toBe(
'acme-platform'
)
expect(
buildSelectorContextFromBlock('bitbucket', subBlocks, {
canonicalModes: { workspaceSlug: 'advanced' },
}).workspaceSlug
).toBe('advanced-team')
})
it('should ignore subblock keys not in SELECTOR_CONTEXT_FIELDS', () => {
const ctx = buildSelectorContextFromBlock('knowledge', {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
@@ -41,6 +41,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
'orgId',
'database',
'schema',
'workspaceSlug',
])
/**
@@ -0,0 +1,46 @@
import {
BITBUCKET_PARTICIPANT_OUTPUT_PROPERTIES,
type BitbucketParticipant,
type BitbucketPullRequestParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PULL_REQUEST_PARAMS,
bitbucketHeaders,
bitbucketJson,
bitbucketPullRequestPath,
normalizeBitbucketParticipant,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
export const bitbucketApprovePullRequestTool: ToolConfig<
BitbucketPullRequestParams,
BitbucketToolResponse<{ participant: BitbucketParticipant }>
> = {
id: 'bitbucket_approve_pull_request',
name: 'Bitbucket Approve Pull Request',
description: 'Approve a pull request as the authenticated account',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pullrequest:write'] },
params: { ...BITBUCKET_PULL_REQUEST_PARAMS },
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/approve`,
method: 'POST',
headers: (params) => bitbucketHeaders(params.accessToken),
},
transformResponse: async (response) => ({
success: true,
output: { participant: normalizeBitbucketParticipant(await bitbucketJson(response)) },
}),
outputs: {
participant: {
type: 'object',
description: 'Approval participant record',
properties: BITBUCKET_PARTICIPANT_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
+65
View File
@@ -0,0 +1,65 @@
import {
BITBUCKET_BRANCH_OUTPUT_PROPERTIES,
type BitbucketBranch,
type BitbucketCreateBranchParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
normalizeBitbucketBranch,
requireBitbucketString,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
export const bitbucketCreateBranchTool: ToolConfig<
BitbucketCreateBranchParams,
BitbucketToolResponse<{ branch: BitbucketBranch }>
> = {
id: 'bitbucket_create_branch',
name: 'Bitbucket Create Branch',
description: 'Create a branch at a commit hash or existing ref',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['repository:write'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'New branch name without refs/heads prefix',
},
target: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Full commit hash or existing ref to target',
},
},
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/refs/branches`,
method: 'POST',
headers: (params) => bitbucketHeaders(params.accessToken, { json: true }),
body: (params) => ({
name: requireBitbucketString(params.name, 'name'),
target: { hash: requireBitbucketString(params.target, 'target') },
}),
},
transformResponse: async (response) => ({
success: true,
output: { branch: normalizeBitbucketBranch(await bitbucketJson(response)) },
}),
outputs: {
branch: {
type: 'object',
description: 'Created branch',
properties: BITBUCKET_BRANCH_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,121 @@
import {
BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES,
type BitbucketCreatePullRequestParams,
type BitbucketPullRequest,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
normalizeBitbucketPullRequest,
requireBitbucketString,
} from '@/tools/bitbucket/utils'
import {
optionalBitbucketBoolean,
optionalBitbucketStringArray,
} from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
export const bitbucketCreatePullRequestTool: ToolConfig<
BitbucketCreatePullRequestParams,
BitbucketToolResponse<{ pullRequest: BitbucketPullRequest }>
> = {
id: 'bitbucket_create_pull_request',
name: 'Bitbucket Create Pull Request',
description: 'Create a pull request between repository branches',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pullrequest:write'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
title: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Pull request title',
},
sourceBranch: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Source branch name',
},
destinationBranch: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Destination branch name',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pull request description',
},
closeSourceBranch: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Close the source branch after merge',
},
draft: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Create the pull request as a draft',
},
reviewerUuids: {
type: 'array',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket account UUIDs to add as reviewers',
items: { type: 'string' },
},
},
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/pullrequests`,
method: 'POST',
headers: (params) => bitbucketHeaders(params.accessToken, { json: true }),
body: (params) => {
const closeSourceBranch = optionalBitbucketBoolean(
params.closeSourceBranch,
'closeSourceBranch'
)
const draft = optionalBitbucketBoolean(params.draft, 'draft')
const reviewerUuids = optionalBitbucketStringArray(
params.reviewerUuids,
'reviewerUuids',
'reviewer UUID'
)
return {
title: requireBitbucketString(params.title, 'title'),
source: { branch: { name: requireBitbucketString(params.sourceBranch, 'sourceBranch') } },
destination: {
branch: { name: requireBitbucketString(params.destinationBranch, 'destinationBranch') },
},
...(params.description !== undefined ? { description: params.description } : {}),
...(closeSourceBranch !== undefined ? { close_source_branch: closeSourceBranch } : {}),
...(draft !== undefined ? { draft } : {}),
...(reviewerUuids !== undefined
? { reviewers: reviewerUuids.map((uuid) => ({ uuid })) }
: {}),
}
},
},
transformResponse: async (response) => ({
success: true,
output: { pullRequest: normalizeBitbucketPullRequest(await bitbucketJson(response)) },
}),
outputs: {
pullRequest: {
type: 'object',
description: 'Created pull request',
properties: BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,72 @@
import {
BITBUCKET_COMMENT_OUTPUT_PROPERTIES,
type BitbucketComment,
type BitbucketCreatePullRequestCommentParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PULL_REQUEST_PARAMS,
bitbucketHeaders,
bitbucketJson,
bitbucketPullRequestPath,
normalizeBitbucketComment,
} from '@/tools/bitbucket/utils'
import { requireBitbucketPositiveInteger } from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
export const bitbucketCreatePullRequestCommentTool: ToolConfig<
BitbucketCreatePullRequestCommentParams,
BitbucketToolResponse<{ comment: BitbucketComment }>
> = {
id: 'bitbucket_create_pull_request_comment',
name: 'Bitbucket Create Pull Request Comment',
description: 'Create a global comment or reply on a pull request',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pullrequest'] },
params: {
...BITBUCKET_PULL_REQUEST_PARAMS,
content: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Raw comment content',
},
parentId: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Parent comment ID when creating a reply',
},
},
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/comments`,
method: 'POST',
headers: (params) => bitbucketHeaders(params.accessToken, { json: true }),
body: (params) => {
if (typeof params.content !== 'string' || params.content.trim().length === 0) {
throw new Error('content must be a non-empty string')
}
return {
content: { raw: params.content },
...(params.parentId !== undefined
? { parent: { id: requireBitbucketPositiveInteger(params.parentId, 'parentId') } }
: {}),
}
},
},
transformResponse: async (response) => ({
success: true,
output: { comment: normalizeBitbucketComment(await bitbucketJson(response)) },
}),
outputs: {
comment: {
type: 'object',
description: 'Created comment',
properties: BITBUCKET_COMMENT_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,46 @@
import {
BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES,
type BitbucketPullRequest,
type BitbucketPullRequestParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PULL_REQUEST_PARAMS,
bitbucketHeaders,
bitbucketJson,
bitbucketPullRequestPath,
normalizeBitbucketPullRequest,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
export const bitbucketDeclinePullRequestTool: ToolConfig<
BitbucketPullRequestParams,
BitbucketToolResponse<{ pullRequest: BitbucketPullRequest }>
> = {
id: 'bitbucket_decline_pull_request',
name: 'Bitbucket Decline Pull Request',
description: 'Decline an open pull request',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pullrequest:write'] },
params: { ...BITBUCKET_PULL_REQUEST_PARAMS },
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/decline`,
method: 'POST',
headers: (params) => bitbucketHeaders(params.accessToken),
},
transformResponse: async (response) => ({
success: true,
output: { pullRequest: normalizeBitbucketPullRequest(await bitbucketJson(response)) },
}),
outputs: {
pullRequest: {
type: 'object',
description: 'Declined pull request',
properties: BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
+44
View File
@@ -0,0 +1,44 @@
import type { BitbucketDeleteBranchParams, BitbucketToolResponse } from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketHeaders,
bitbucketRepositoryPath,
encodeBitbucketSegment,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
export const bitbucketDeleteBranchTool: ToolConfig<
BitbucketDeleteBranchParams,
BitbucketToolResponse<{ deleted: boolean }>
> = {
id: 'bitbucket_delete_branch',
name: 'Bitbucket Delete Branch',
description: 'Delete a branch from a Bitbucket Cloud repository',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['repository:write'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Branch name to delete',
},
},
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/refs/branches/${encodeBitbucketSegment(params.name, 'name')}`,
method: 'DELETE',
headers: (params) => bitbucketHeaders(params.accessToken),
},
transformResponse: async (response) => {
if (response.status !== 204) {
throw new Error(`Bitbucket branch deletion returned unexpected HTTP ${response.status}`)
}
return { success: true, output: { deleted: true } }
},
outputs: { deleted: { type: 'boolean', description: 'Whether the branch was deleted' } },
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
+58
View File
@@ -0,0 +1,58 @@
import {
BITBUCKET_COMMIT_OUTPUT_PROPERTIES,
type BitbucketCommit,
type BitbucketGetCommitParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_READ_RETRY,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
encodeBitbucketSegment,
normalizeBitbucketCommit,
} from '@/tools/bitbucket/utils'
import { requireBitbucketSha1 } from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
export const bitbucketGetCommitTool: ToolConfig<
BitbucketGetCommitParams,
BitbucketToolResponse<{ commit: BitbucketCommit }>
> = {
id: 'bitbucket_get_commit',
name: 'Bitbucket Get Commit',
description: 'Get a repository commit by its full SHA-1',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['repository'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
commit: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Full 40-character commit SHA-1',
},
},
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/commit/${encodeBitbucketSegment(requireBitbucketSha1(params.commit, 'commit'), 'commit')}`,
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: { commit: normalizeBitbucketCommit(await bitbucketJson(response)) },
}),
outputs: {
commit: {
type: 'object',
description: 'Commit details',
properties: BITBUCKET_COMMIT_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
+157
View File
@@ -0,0 +1,157 @@
import type { BitbucketGetFileParams, BitbucketToolResponse } from '@/tools/bitbucket/types'
import {
assertBitbucketResponseOk,
BITBUCKET_API_BASE,
BITBUCKET_DEFAULT_MAX_CHARACTERS,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_RAW_TRANSFER_MAX_BYTES,
BITBUCKET_READ_RETRY,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketHeaders,
bitbucketHeadRange,
bitbucketJson,
bitbucketMaxCharacters,
bitbucketRawHead,
bitbucketRepositoryPath,
encodeBitbucketRepositoryPath,
encodeBitbucketSegment,
normalizeBitbucketFileMetadata,
} from '@/tools/bitbucket/utils'
import { requireBitbucketSha1 } from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
interface BitbucketFileOutput {
content: string | null
binary: boolean | null
truncated: boolean | null
returnedBytes: number
fullBytes: number | null
contentType: string | null
}
function fileUrl(params: BitbucketGetFileParams, metadata = false): string {
const commit = requireBitbucketSha1(params.commit, 'commit')
const url = `${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/src/${encodeBitbucketSegment(commit, 'commit')}/${encodeBitbucketRepositoryPath(params.path)}`
return metadata ? `${url}?format=meta` : url
}
export const bitbucketGetFileTool: ToolConfig<
BitbucketGetFileParams,
BitbucketToolResponse<BitbucketFileOutput>
> = {
id: 'bitbucket_get_file',
name: 'Bitbucket Get File',
description: 'Read bounded UTF-8 text from a file at a full repository commit SHA-1',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['repository'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
commit: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Full 40-character commit SHA-1',
},
path: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Repository-relative file path',
},
maxCharacters: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum text characters to return (1-500000)',
default: BITBUCKET_DEFAULT_MAX_CHARACTERS,
},
},
directExecution: async (params, signal) => {
bitbucketMaxCharacters(params.maxCharacters)
const { secureBitbucketRead } = await import('@/tools/bitbucket/utils.server')
const metadataResponse = await secureBitbucketRead(
fileUrl(params, true),
bitbucketHeaders(params.accessToken),
256 * 1024,
{ signal }
)
await assertBitbucketResponseOk(metadataResponse)
const metadata = normalizeBitbucketFileMetadata(await bitbucketJson(metadataResponse))
if (metadata.isBinary === true) {
return {
success: true,
output: {
content: null,
binary: true,
truncated: metadata.size === null ? null : metadata.size > 0,
returnedBytes: 0,
fullBytes: metadata.size,
contentType: null,
},
}
}
const rawResponse = await secureBitbucketRead(
fileUrl(params),
bitbucketHeaders(params.accessToken, {
json: false,
range: bitbucketHeadRange(params.maxCharacters),
}),
BITBUCKET_RAW_TRANSFER_MAX_BYTES,
{ stripAuthOnRedirect: true, signal }
)
await assertBitbucketResponseOk(rawResponse)
const raw = await bitbucketRawHead(rawResponse, params.maxCharacters, metadata.isBinary)
const fullBytes = raw.fullBytes ?? metadata.size
return {
success: true,
output: {
...raw,
truncated:
raw.binary === true && raw.truncated === null && fullBytes !== null
? fullBytes > 0
: raw.truncated,
fullBytes,
},
}
},
request: {
url: (params) => fileUrl(params),
method: 'GET',
headers: (params) =>
bitbucketHeaders(params.accessToken, {
json: false,
range: bitbucketHeadRange(params.maxCharacters),
}),
retry: BITBUCKET_READ_RETRY,
stripAuthOnRedirect: true,
},
transformResponse: async () => {
throw new Error('Bitbucket file reads require the metadata preflight direct execution path')
},
outputs: {
content: {
type: 'string',
description: 'Bounded UTF-8 file text; null for binary content',
nullable: true,
},
binary: {
type: 'boolean',
description: 'Whether documented metadata identifies binary content; null when unknown',
nullable: true,
},
truncated: {
type: 'boolean',
description: 'Whether later content was omitted; null when binary size is unknown',
nullable: true,
},
returnedBytes: { type: 'number', description: 'Provider bytes read for the returned file' },
fullBytes: {
type: 'number',
description: 'Full file byte size when reported',
nullable: true,
},
contentType: { type: 'string', description: 'Response MIME type', nullable: true },
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,65 @@
import {
BITBUCKET_FILE_METADATA_OUTPUT_PROPERTIES,
type BitbucketFileMetadata,
type BitbucketFileParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_READ_RETRY,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
encodeBitbucketRepositoryPath,
encodeBitbucketSegment,
normalizeBitbucketFileMetadata,
} from '@/tools/bitbucket/utils'
import { requireBitbucketSha1 } from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
export const bitbucketGetFileMetadataTool: ToolConfig<
BitbucketFileParams,
BitbucketToolResponse<{ file: BitbucketFileMetadata }>
> = {
id: 'bitbucket_get_file_metadata',
name: 'Bitbucket Get File Metadata',
description: 'Inspect file size and attributes at a full repository commit SHA-1',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['repository'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
commit: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Full 40-character commit SHA-1',
},
path: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Repository-relative file path',
},
},
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/src/${encodeBitbucketSegment(requireBitbucketSha1(params.commit, 'commit'), 'commit')}/${encodeBitbucketRepositoryPath(params.path)}?format=meta`,
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: { file: normalizeBitbucketFileMetadata(await bitbucketJson(response)) },
}),
outputs: {
file: {
type: 'object',
description: 'File metadata',
properties: BITBUCKET_FILE_METADATA_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,105 @@
import {
BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES,
type BitbucketGetMergeTaskStatusParams,
type BitbucketPullRequest,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PULL_REQUEST_PARAMS,
BITBUCKET_READ_RETRY,
bitbucketHeaders,
bitbucketJson,
bitbucketPullRequestPath,
encodeBitbucketSegment,
normalizeBitbucketPullRequest,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
interface BitbucketMergeTaskOutput {
taskStatus: 'PENDING' | 'SUCCESS'
selfUrl: string | null
mergeResult: BitbucketPullRequest | null
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function stringField(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
export const bitbucketGetMergeTaskStatusTool: ToolConfig<
BitbucketGetMergeTaskStatusParams,
BitbucketToolResponse<BitbucketMergeTaskOutput>
> = {
id: 'bitbucket_get_pull_request_merge_task_status',
name: 'Bitbucket Get Merge Task Status',
description: 'Poll the status of an asynchronous pull request merge task',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pullrequest'] },
params: {
...BITBUCKET_PULL_REQUEST_PARAMS,
taskId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Merge task ID returned by Bitbucket Merge Pull Request',
},
},
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/merge/task-status/${encodeBitbucketSegment(params.taskId, 'taskId')}`,
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => {
const data = await bitbucketJson(response)
if (data.type === 'error') {
const error = record(data.error)
const message = stringField(error?.message)?.trim()
if (!message) throw new Error('Bitbucket returned a malformed merge task error')
const detail = stringField(error?.detail)?.trim()
throw new Error(detail && detail !== message ? `${message}: ${detail}` : message)
}
const taskStatus = data.task_status
if (taskStatus !== 'PENDING' && taskStatus !== 'SUCCESS') {
throw new Error('Bitbucket merge task status must be PENDING or SUCCESS')
}
const links = record(data.links)
const self = record(links?.self)
let mergeResult: BitbucketPullRequest | null = null
if (taskStatus === 'SUCCESS') {
const result = record(data.merge_result)
if (!result) throw new Error('Bitbucket successful merge task omitted merge_result')
mergeResult = normalizeBitbucketPullRequest(result)
}
return {
success: true,
output: {
taskStatus,
selfUrl: stringField(self?.href),
mergeResult,
},
}
},
outputs: {
taskStatus: { type: 'string', description: 'PENDING or SUCCESS' },
selfUrl: { type: 'string', description: 'Merge task API URL', nullable: true },
mergeResult: {
type: 'object',
description: 'Merged pull request when the task succeeds',
nullable: true,
properties: BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
+57
View File
@@ -0,0 +1,57 @@
import {
BITBUCKET_PIPELINE_OUTPUT_PROPERTIES,
type BitbucketPipeline,
type BitbucketPipelineParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_READ_RETRY,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
encodeBitbucketSegment,
normalizeBitbucketPipeline,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
export const bitbucketGetPipelineTool: ToolConfig<
BitbucketPipelineParams,
BitbucketToolResponse<{ pipeline: BitbucketPipeline }>
> = {
id: 'bitbucket_get_pipeline',
name: 'Bitbucket Get Pipeline',
description: 'Get a pipeline by UUID',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pipeline'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
pipelineUuid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Pipeline UUID',
},
},
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/pipelines/${encodeBitbucketSegment(params.pipelineUuid, 'pipelineUuid')}`,
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: { pipeline: normalizeBitbucketPipeline(await bitbucketJson(response)) },
}),
outputs: {
pipeline: {
type: 'object',
description: 'Pipeline details',
properties: BITBUCKET_PIPELINE_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,114 @@
import type {
BitbucketGetPipelineStepLogParams,
BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
assertBitbucketResponseOk,
BITBUCKET_API_BASE,
BITBUCKET_DEFAULT_LOG_CHARACTERS,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_LOG_TRANSFER_MAX_BYTES,
BITBUCKET_READ_RETRY,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketHeaders,
bitbucketMaxCharacters,
bitbucketRawTail,
bitbucketRepositoryPath,
bitbucketTailRange,
encodeBitbucketSegment,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
interface BitbucketPipelineLogOutput {
log: string
truncated: boolean
totalBytes: number | null
}
/** A suffix range against an empty log is unsatisfiable; Bitbucket answers 416 rather than 200. */
const BITBUCKET_RANGE_NOT_SATISFIABLE = 416
/** RFC 7233 unsatisfied-range header for a zero-length log; any other 416 is a genuine failure. */
const EMPTY_CONTENT_RANGE_PATTERN = /^bytes \*\/0$/
function stepLogUrl(params: BitbucketGetPipelineStepLogParams): string {
return `${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/pipelines/${encodeBitbucketSegment(params.pipelineUuid, 'pipelineUuid')}/steps/${encodeBitbucketSegment(params.stepUuid, 'stepUuid')}/log`
}
export const bitbucketGetPipelineStepLogTool: ToolConfig<
BitbucketGetPipelineStepLogParams,
BitbucketToolResponse<BitbucketPipelineLogOutput>
> = {
id: 'bitbucket_get_pipeline_step_log',
name: 'Bitbucket Get Pipeline Step Log',
description: 'Read a bounded UTF-8 tail of a pipeline step log',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pipeline'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
pipelineUuid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Pipeline UUID',
},
stepUuid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Pipeline step UUID',
},
maxCharacters: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum trailing log characters to return (1-200000)',
default: BITBUCKET_DEFAULT_LOG_CHARACTERS,
},
},
directExecution: async (params, signal) => {
bitbucketMaxCharacters(params.maxCharacters, true)
const { secureBitbucketRead } = await import('@/tools/bitbucket/utils.server')
const response = await secureBitbucketRead(
stepLogUrl(params),
bitbucketHeaders(params.accessToken, {
json: false,
range: bitbucketTailRange(params.maxCharacters),
}),
BITBUCKET_LOG_TRANSFER_MAX_BYTES,
{ stripAuthOnRedirect: true, signal }
)
if (
response.status === BITBUCKET_RANGE_NOT_SATISFIABLE &&
EMPTY_CONTENT_RANGE_PATTERN.test(response.headers.get('content-range') ?? '')
) {
await response.body?.cancel()
return { success: true, output: { log: '', truncated: false, totalBytes: 0 } }
}
await assertBitbucketResponseOk(response)
return { success: true, output: await bitbucketRawTail(response, params.maxCharacters) }
},
request: {
url: stepLogUrl,
method: 'GET',
headers: (params) =>
bitbucketHeaders(params.accessToken, {
json: false,
range: bitbucketTailRange(params.maxCharacters),
}),
retry: BITBUCKET_READ_RETRY,
stripAuthOnRedirect: true,
},
transformResponse: async () => {
throw new Error('Bitbucket step-log reads require the byte-capped direct execution path')
},
outputs: {
log: { type: 'string', description: 'Bounded trailing UTF-8 log text' },
truncated: { type: 'boolean', description: 'Whether earlier log output was omitted' },
totalBytes: {
type: 'number',
description: 'Full log byte size when reported',
nullable: true,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,48 @@
import {
BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES,
type BitbucketPullRequest,
type BitbucketPullRequestParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PULL_REQUEST_PARAMS,
BITBUCKET_READ_RETRY,
bitbucketHeaders,
bitbucketJson,
bitbucketPullRequestPath,
normalizeBitbucketPullRequest,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
export const bitbucketGetPullRequestTool: ToolConfig<
BitbucketPullRequestParams,
BitbucketToolResponse<{ pullRequest: BitbucketPullRequest }>
> = {
id: 'bitbucket_get_pull_request',
name: 'Bitbucket Get Pull Request',
description: 'Get a pull request by repository-scoped ID',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pullrequest'] },
params: { ...BITBUCKET_PULL_REQUEST_PARAMS },
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}`,
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: { pullRequest: normalizeBitbucketPullRequest(await bitbucketJson(response)) },
}),
outputs: {
pullRequest: {
type: 'object',
description: 'Pull request details',
properties: BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,130 @@
import type {
BitbucketGetPullRequestDiffParams,
BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
assertBitbucketResponseOk,
BITBUCKET_API_BASE,
BITBUCKET_DEFAULT_MAX_CHARACTERS,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PULL_REQUEST_PARAMS,
BITBUCKET_RAW_TRANSFER_MAX_BYTES,
BITBUCKET_READ_RETRY,
bitbucketHeaders,
bitbucketHeadRange,
bitbucketPullRequestPath,
bitbucketRawHead,
bitbucketRepositoryPathQuery,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
interface BitbucketDiffOutput {
diff: string
decodingLossy: boolean
truncated: boolean
returnedBytes: number
fullBytes: number | null
}
function pullRequestDiffUrl(params: BitbucketGetPullRequestDiffParams): string {
bitbucketRepositoryPathQuery(params.path)
return `${BITBUCKET_API_BASE}${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/diff`
}
async function transformDiff(
response: Response,
maxCharacters: number | undefined
): Promise<BitbucketToolResponse<BitbucketDiffOutput>> {
const raw = await bitbucketRawHead(response, maxCharacters, false, { allowLossyUtf8: true })
if (raw.binary || raw.content === null) throw new Error('Bitbucket returned a binary diff')
if (raw.truncated === null) throw new Error('Bitbucket returned an indeterminate diff length')
return {
success: true,
output: {
diff: raw.content,
decodingLossy: raw.decodingLossy ?? false,
truncated: raw.truncated,
returnedBytes: raw.returnedBytes,
fullBytes: raw.fullBytes,
},
}
}
export const bitbucketGetPullRequestDiffTool: ToolConfig<
BitbucketGetPullRequestDiffParams,
BitbucketToolResponse<BitbucketDiffOutput>
> = {
id: 'bitbucket_get_pull_request_diff',
name: 'Bitbucket Get Pull Request Diff',
description: 'Read a bounded UTF-8 unified diff for one pull request file',
version: '1.0.0',
oauth: {
required: true,
provider: 'bitbucket',
requiredScopes: ['pullrequest', 'repository'],
},
params: {
...BITBUCKET_PULL_REQUEST_PARAMS,
path: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Repository-relative file path to include in the diff',
},
maxCharacters: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum diff characters to return (1-500000)',
default: BITBUCKET_DEFAULT_MAX_CHARACTERS,
},
},
directExecution: async (params, signal) => {
const { secureBitbucketPullRequestRedirect } = await import('@/tools/bitbucket/utils.server')
const headers = bitbucketHeaders(params.accessToken, {
json: false,
range: bitbucketHeadRange(params.maxCharacters),
})
const response = await secureBitbucketPullRequestRedirect(
pullRequestDiffUrl(params),
params.workspaceSlug,
params.repoSlug,
'diff',
headers,
BITBUCKET_RAW_TRANSFER_MAX_BYTES,
{
signal,
targetQuery: { path: bitbucketRepositoryPathQuery(params.path), binary: 'false' },
}
)
await assertBitbucketResponseOk(response)
return transformDiff(response, params.maxCharacters)
},
request: {
url: pullRequestDiffUrl,
method: 'GET',
headers: (params) =>
bitbucketHeaders(params.accessToken, {
json: false,
range: bitbucketHeadRange(params.maxCharacters),
}),
retry: BITBUCKET_READ_RETRY,
stripAuthOnRedirect: true,
},
transformResponse: async (response, params) => transformDiff(response, params?.maxCharacters),
outputs: {
diff: { type: 'string', description: 'Bounded unified diff text decoded as UTF-8' },
decodingLossy: {
type: 'boolean',
description: 'Whether invalid UTF-8 source bytes were replaced while decoding',
},
truncated: { type: 'boolean', description: 'Whether later diff text was omitted' },
returnedBytes: { type: 'number', description: 'Provider bytes read for the returned diff' },
fullBytes: {
type: 'number',
description: 'Full diff byte size when reported',
nullable: true,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,133 @@
import {
BITBUCKET_DIFFSTAT_OUTPUT_PROPERTIES,
BITBUCKET_PAGE_OUTPUT,
type BitbucketDiffstat,
type BitbucketListOutput,
type BitbucketPaginatedPullRequestParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
assertBitbucketResponseOk,
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PAGINATION_PARAMS,
BITBUCKET_PULL_REQUEST_PARAMS,
BITBUCKET_READ_RETRY,
bitbucketHeaders,
bitbucketJson,
bitbucketPageLength,
bitbucketPullRequestPath,
normalizeBitbucketDiffstat,
normalizeBitbucketPage,
validateBitbucketPullRequestRedirect,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
function pullRequestDiffstatUrl(params: BitbucketPaginatedPullRequestParams): string {
const url = new URL(
`${BITBUCKET_API_BASE}${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/diffstat`
)
return url.toString()
}
function decodedPathname(url: string): string {
return new URL(url).pathname
.split('/')
.map((segment) => decodeURIComponent(segment))
.join('/')
}
export const bitbucketGetPullRequestDiffstatTool: ToolConfig<
BitbucketPaginatedPullRequestParams,
BitbucketToolResponse<BitbucketListOutput<BitbucketDiffstat>>
> = {
id: 'bitbucket_get_pull_request_diffstat',
name: 'Bitbucket Get Pull Request Diffstat',
description: 'List per-file change statistics for a pull request',
version: '1.0.0',
oauth: {
required: true,
provider: 'bitbucket',
requiredScopes: ['pullrequest', 'repository'],
},
params: { ...BITBUCKET_PULL_REQUEST_PARAMS, ...BITBUCKET_PAGINATION_PARAMS },
directExecution: async (params, signal) => {
const {
resolveBitbucketPullRequestRedirect,
secureBitbucketPullRequestRedirect,
secureBitbucketRead,
} = await import('@/tools/bitbucket/utils.server')
const initialUrl = pullRequestDiffstatUrl(params)
const headers = bitbucketHeaders(params.accessToken)
let response: Response
if (params.nextUrl !== undefined) {
const continuation = validateBitbucketPullRequestRedirect(
params.nextUrl,
params.workspaceSlug,
params.repoSlug,
'diffstat'
)
const resolvedTarget = await resolveBitbucketPullRequestRedirect(
initialUrl,
params.workspaceSlug,
params.repoSlug,
'diffstat',
headers,
{ signal }
)
if (decodedPathname(continuation) !== decodedPathname(resolvedTarget)) {
throw new Error('nextUrl does not belong to this Bitbucket pull request diffstat')
}
response = await secureBitbucketRead(continuation, headers, 2 * 1024 * 1024, {
maxRedirects: 0,
signal,
})
} else {
response = await secureBitbucketPullRequestRedirect(
initialUrl,
params.workspaceSlug,
params.repoSlug,
'diffstat',
headers,
2 * 1024 * 1024,
{
signal,
targetQuery: { pagelen: String(bitbucketPageLength(params.pageLen)) },
}
)
}
await assertBitbucketResponseOk(response)
return {
success: true,
output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketDiffstat),
}
},
request: {
url: (params) =>
params.nextUrl
? validateBitbucketPullRequestRedirect(
params.nextUrl,
params.workspaceSlug,
params.repoSlug,
'diffstat'
)
: pullRequestDiffstatUrl(params),
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
stripAuthOnRedirect: true,
},
transformResponse: async (response) => ({
success: true,
output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketDiffstat),
}),
outputs: {
items: {
type: 'array',
description: 'Per-file diff statistics',
items: { type: 'object', properties: BITBUCKET_DIFFSTAT_OUTPUT_PROPERTIES },
},
page: BITBUCKET_PAGE_OUTPUT,
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,48 @@
import {
BITBUCKET_REPOSITORY_OUTPUT_PROPERTIES,
type BitbucketRepository,
type BitbucketRepositoryParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_READ_RETRY,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
normalizeBitbucketRepository,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
export const bitbucketGetRepositoryTool: ToolConfig<
BitbucketRepositoryParams,
BitbucketToolResponse<{ repository: BitbucketRepository }>
> = {
id: 'bitbucket_get_repository',
name: 'Bitbucket Get Repository',
description: 'Get a Bitbucket Cloud repository',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['repository'] },
params: { ...BITBUCKET_REPOSITORY_PARAMS },
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}`,
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: { repository: normalizeBitbucketRepository(await bitbucketJson(response)) },
}),
outputs: {
repository: {
type: 'object',
description: 'Repository details',
properties: BITBUCKET_REPOSITORY_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
+96
View File
@@ -0,0 +1,96 @@
import { bitbucketApprovePullRequestTool } from '@/tools/bitbucket/approve_pull_request'
import { bitbucketCreateBranchTool } from '@/tools/bitbucket/create_branch'
import { bitbucketCreatePullRequestTool } from '@/tools/bitbucket/create_pull_request'
import { bitbucketCreatePullRequestCommentTool } from '@/tools/bitbucket/create_pull_request_comment'
import { bitbucketDeclinePullRequestTool } from '@/tools/bitbucket/decline_pull_request'
import { bitbucketDeleteBranchTool } from '@/tools/bitbucket/delete_branch'
import { bitbucketGetCommitTool } from '@/tools/bitbucket/get_commit'
import { bitbucketGetFileTool } from '@/tools/bitbucket/get_file'
import { bitbucketGetFileMetadataTool } from '@/tools/bitbucket/get_file_metadata'
import { bitbucketGetMergeTaskStatusTool } from '@/tools/bitbucket/get_merge_task_status'
import { bitbucketGetPipelineTool } from '@/tools/bitbucket/get_pipeline'
import { bitbucketGetPipelineStepLogTool } from '@/tools/bitbucket/get_pipeline_step_log'
import { bitbucketGetPullRequestTool } from '@/tools/bitbucket/get_pull_request'
import { bitbucketGetPullRequestDiffTool } from '@/tools/bitbucket/get_pull_request_diff'
import { bitbucketGetPullRequestDiffstatTool } from '@/tools/bitbucket/get_pull_request_diffstat'
import { bitbucketGetRepositoryTool } from '@/tools/bitbucket/get_repository'
import { bitbucketListBranchesTool } from '@/tools/bitbucket/list_branches'
import { bitbucketListCommitsTool } from '@/tools/bitbucket/list_commits'
import { bitbucketListDirectoryTool } from '@/tools/bitbucket/list_directory'
import { bitbucketListPipelineStepsTool } from '@/tools/bitbucket/list_pipeline_steps'
import { bitbucketListPipelinesTool } from '@/tools/bitbucket/list_pipelines'
import { bitbucketListPullRequestCommentsTool } from '@/tools/bitbucket/list_pull_request_comments'
import { bitbucketListPullRequestCommitStatusesTool } from '@/tools/bitbucket/list_pull_request_commit_statuses'
import { bitbucketListPullRequestsTool } from '@/tools/bitbucket/list_pull_requests'
import { bitbucketListRepositoriesTool } from '@/tools/bitbucket/list_repositories'
import { bitbucketListWorkspacesTool } from '@/tools/bitbucket/list_workspaces'
import { bitbucketMergePullRequestTool } from '@/tools/bitbucket/merge_pull_request'
import { bitbucketRequestPullRequestChangesTool } from '@/tools/bitbucket/request_pull_request_changes'
import { bitbucketStopPipelineTool } from '@/tools/bitbucket/stop_pipeline'
import { bitbucketTriggerPipelineTool } from '@/tools/bitbucket/trigger_pipeline'
export {
bitbucketListWorkspacesTool,
bitbucketListRepositoriesTool,
bitbucketGetRepositoryTool,
bitbucketListBranchesTool,
bitbucketCreateBranchTool,
bitbucketDeleteBranchTool,
bitbucketListCommitsTool,
bitbucketGetCommitTool,
bitbucketListDirectoryTool,
bitbucketGetFileTool,
bitbucketGetFileMetadataTool,
bitbucketListPullRequestsTool,
bitbucketGetPullRequestTool,
bitbucketCreatePullRequestTool,
bitbucketMergePullRequestTool,
bitbucketGetMergeTaskStatusTool,
bitbucketDeclinePullRequestTool,
bitbucketApprovePullRequestTool,
bitbucketRequestPullRequestChangesTool,
bitbucketGetPullRequestDiffTool,
bitbucketGetPullRequestDiffstatTool,
bitbucketListPullRequestCommentsTool,
bitbucketCreatePullRequestCommentTool,
bitbucketListPullRequestCommitStatusesTool,
bitbucketListPipelinesTool,
bitbucketGetPipelineTool,
bitbucketTriggerPipelineTool,
bitbucketStopPipelineTool,
bitbucketListPipelineStepsTool,
bitbucketGetPipelineStepLogTool,
}
export const bitbucketTools = [
bitbucketListWorkspacesTool,
bitbucketListRepositoriesTool,
bitbucketGetRepositoryTool,
bitbucketListBranchesTool,
bitbucketCreateBranchTool,
bitbucketDeleteBranchTool,
bitbucketListCommitsTool,
bitbucketGetCommitTool,
bitbucketListDirectoryTool,
bitbucketGetFileTool,
bitbucketGetFileMetadataTool,
bitbucketListPullRequestsTool,
bitbucketGetPullRequestTool,
bitbucketCreatePullRequestTool,
bitbucketMergePullRequestTool,
bitbucketGetMergeTaskStatusTool,
bitbucketDeclinePullRequestTool,
bitbucketApprovePullRequestTool,
bitbucketRequestPullRequestChangesTool,
bitbucketGetPullRequestDiffTool,
bitbucketGetPullRequestDiffstatTool,
bitbucketListPullRequestCommentsTool,
bitbucketCreatePullRequestCommentTool,
bitbucketListPullRequestCommitStatusesTool,
bitbucketListPipelinesTool,
bitbucketGetPipelineTool,
bitbucketTriggerPipelineTool,
bitbucketStopPipelineTool,
bitbucketListPipelineStepsTool,
bitbucketGetPipelineStepLogTool,
]
+79
View File
@@ -0,0 +1,79 @@
import {
BITBUCKET_BRANCH_OUTPUT_PROPERTIES,
BITBUCKET_PAGE_OUTPUT,
type BitbucketBranch,
type BitbucketListBranchesParams,
type BitbucketListOutput,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PAGINATION_PARAMS,
BITBUCKET_READ_RETRY,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketApiUrl,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
normalizeBitbucketBranch,
normalizeBitbucketPage,
} from '@/tools/bitbucket/utils'
import { optionalBitbucketString } from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
export const bitbucketListBranchesTool: ToolConfig<
BitbucketListBranchesParams,
BitbucketToolResponse<BitbucketListOutput<BitbucketBranch>>
> = {
id: 'bitbucket_list_branches',
name: 'Bitbucket List Branches',
description: 'List branches in a Bitbucket Cloud repository',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['repository'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
q: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket branch filtering expression',
},
sort: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket branch sort expression',
},
...BITBUCKET_PAGINATION_PARAMS,
},
request: {
url: (params) => {
const q = optionalBitbucketString(params.q, 'q')
const sort = optionalBitbucketString(params.sort, 'sort')
return bitbucketApiUrl(
`${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/refs/branches`,
{
nextUrl: params.nextUrl,
pageLen: params.pageLen,
query: { q, sort },
}
)
},
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketBranch),
}),
outputs: {
items: {
type: 'array',
description: 'Branches',
items: { type: 'object', properties: BITBUCKET_BRANCH_OUTPUT_PROPERTIES },
},
page: BITBUCKET_PAGE_OUTPUT,
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
+56
View File
@@ -0,0 +1,56 @@
import {
BITBUCKET_COMMIT_OUTPUT_PROPERTIES,
BITBUCKET_PAGE_OUTPUT,
type BitbucketCommit,
type BitbucketListCommitsParams,
type BitbucketListOutput,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PAGINATION_PARAMS,
BITBUCKET_READ_RETRY,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketApiUrl,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
normalizeBitbucketCommit,
normalizeBitbucketPage,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
export const bitbucketListCommitsTool: ToolConfig<
BitbucketListCommitsParams,
BitbucketToolResponse<BitbucketListOutput<BitbucketCommit>>
> = {
id: 'bitbucket_list_commits',
name: 'Bitbucket List Commits',
description: 'List repository commits in reverse chronological order',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['repository'] },
params: { ...BITBUCKET_REPOSITORY_PARAMS, ...BITBUCKET_PAGINATION_PARAMS },
request: {
url: (params) =>
bitbucketApiUrl(`${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/commits`, {
nextUrl: params.nextUrl,
pageLen: params.pageLen,
}),
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketCommit),
}),
outputs: {
items: {
type: 'array',
description: 'Commits',
items: { type: 'object', properties: BITBUCKET_COMMIT_OUTPUT_PROPERTIES },
},
page: BITBUCKET_PAGE_OUTPUT,
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,98 @@
import {
BITBUCKET_DIRECTORY_ENTRY_OUTPUT_PROPERTIES,
BITBUCKET_PAGE_OUTPUT,
type BitbucketDirectoryEntry,
type BitbucketListDirectoryParams,
type BitbucketListOutput,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PAGINATION_PARAMS,
BITBUCKET_READ_RETRY,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketApiUrl,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
encodeBitbucketRepositoryPath,
encodeBitbucketSegment,
normalizeBitbucketDirectoryEntry,
normalizeBitbucketPage,
} from '@/tools/bitbucket/utils'
import { optionalBitbucketString, requireBitbucketSha1 } from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
export const bitbucketListDirectoryTool: ToolConfig<
BitbucketListDirectoryParams,
BitbucketToolResponse<BitbucketListOutput<BitbucketDirectoryEntry>>
> = {
id: 'bitbucket_list_directory',
name: 'Bitbucket List Directory',
description: 'List one shallow repository directory at a full commit SHA-1',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['repository'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
commit: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Full 40-character commit SHA-1',
},
path: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Repository-relative directory path; omit for the root',
},
q: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket tree-entry filtering expression',
},
sort: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket tree-entry sort expression',
},
...BITBUCKET_PAGINATION_PARAMS,
},
request: {
url: (params) => {
const directoryPath = encodeBitbucketRepositoryPath(params.path ?? '', true)
const commit = requireBitbucketSha1(params.commit, 'commit')
const q = optionalBitbucketString(params.q, 'q')
const sort = optionalBitbucketString(params.sort, 'sort')
return bitbucketApiUrl(
`${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/src/${encodeBitbucketSegment(commit, 'commit')}/${directoryPath}`,
{
nextUrl: params.nextUrl,
pageLen: params.pageLen,
query: { q, sort },
nextPathPrefix: `${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/src`,
nextPathSuffix: directoryPath,
nextRevision: commit,
}
)
},
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketDirectoryEntry),
}),
outputs: {
items: {
type: 'array',
description: 'Directory entries',
items: { type: 'object', properties: BITBUCKET_DIRECTORY_ENTRY_OUTPUT_PROPERTIES },
},
page: BITBUCKET_PAGE_OUTPUT,
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,66 @@
import {
BITBUCKET_PAGE_OUTPUT,
BITBUCKET_PIPELINE_STEP_OUTPUT_PROPERTIES,
type BitbucketListOutput,
type BitbucketListPipelineStepsParams,
type BitbucketPipelineStep,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PAGINATION_PARAMS,
BITBUCKET_READ_RETRY,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketApiUrl,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
encodeBitbucketSegment,
normalizeBitbucketPage,
normalizeBitbucketPipelineStep,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
export const bitbucketListPipelineStepsTool: ToolConfig<
BitbucketListPipelineStepsParams,
BitbucketToolResponse<BitbucketListOutput<BitbucketPipelineStep>>
> = {
id: 'bitbucket_list_pipeline_steps',
name: 'Bitbucket List Pipeline Steps',
description: 'List the steps in a pipeline',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pipeline'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
pipelineUuid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Pipeline UUID',
},
...BITBUCKET_PAGINATION_PARAMS,
},
request: {
url: (params) =>
bitbucketApiUrl(
`${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/pipelines/${encodeBitbucketSegment(params.pipelineUuid, 'pipelineUuid')}/steps`,
{ nextUrl: params.nextUrl, pageLen: params.pageLen }
),
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketPipelineStep),
}),
outputs: {
items: {
type: 'array',
description: 'Pipeline steps',
items: { type: 'object', properties: BITBUCKET_PIPELINE_STEP_OUTPUT_PROPERTIES },
},
page: BITBUCKET_PAGE_OUTPUT,
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
+167
View File
@@ -0,0 +1,167 @@
import {
BITBUCKET_PAGE_OUTPUT,
BITBUCKET_PIPELINE_OUTPUT_PROPERTIES,
type BitbucketListOutput,
type BitbucketListPipelinesParams,
type BitbucketPipeline,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PAGINATION_PARAMS,
BITBUCKET_READ_RETRY,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketApiUrl,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
normalizeBitbucketPage,
normalizeBitbucketPipeline,
} from '@/tools/bitbucket/utils'
import {
optionalBitbucketEnum,
optionalBitbucketSha1,
optionalBitbucketString,
} from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
const BITBUCKET_PIPELINE_LIST_REF_TYPES = ['BRANCH', 'TAG', 'ANNOTATED_TAG'] as const
const BITBUCKET_PIPELINE_LIST_SELECTOR_TYPES = [
'BRANCH',
'TAG',
'CUSTOM',
'PULLREQUESTS',
'DEFAULT',
] as const
const BITBUCKET_PIPELINE_TRIGGER_TYPES = ['PUSH', 'MANUAL', 'SCHEDULED', 'PARENT_STEP'] as const
const BITBUCKET_PIPELINE_STATUSES = [
'PARSING',
'PENDING',
'PAUSED',
'HALTED',
'BUILDING',
'ERROR',
'PASSED',
'FAILED',
'STOPPED',
'UNKNOWN',
] as const
export const bitbucketListPipelinesTool: ToolConfig<
BitbucketListPipelinesParams,
BitbucketToolResponse<BitbucketListOutput<BitbucketPipeline>>
> = {
id: 'bitbucket_list_pipelines',
name: 'Bitbucket List Pipelines',
description: 'List pipelines for a Bitbucket Cloud repository',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pipeline'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
refType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Reference type filter: BRANCH, TAG, or ANNOTATED_TAG',
},
refName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Reference name filter',
},
commitHash: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Full 40-character target commit SHA-1 filter',
},
selectorType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Selector type filter: BRANCH, TAG, CUSTOM, PULLREQUESTS, or DEFAULT',
},
selectorPattern: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pipeline selector pattern filter',
},
triggerType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Trigger filter: PUSH, MANUAL, SCHEDULED, or PARENT_STEP',
},
status: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pipeline status filter',
},
sort: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket pipeline sort expression',
},
...BITBUCKET_PAGINATION_PARAMS,
},
request: {
url: (params) => {
const refType = optionalBitbucketEnum(
params.refType,
'refType',
BITBUCKET_PIPELINE_LIST_REF_TYPES
)
const selectorType = optionalBitbucketEnum(
params.selectorType,
'selectorType',
BITBUCKET_PIPELINE_LIST_SELECTOR_TYPES
)
const triggerType = optionalBitbucketEnum(
params.triggerType,
'triggerType',
BITBUCKET_PIPELINE_TRIGGER_TYPES
)
const status = optionalBitbucketEnum(params.status, 'status', BITBUCKET_PIPELINE_STATUSES)
const commitHash = optionalBitbucketSha1(params.commitHash, 'commitHash')
const refName = optionalBitbucketString(params.refName, 'refName')
const selectorPattern = optionalBitbucketString(params.selectorPattern, 'selectorPattern')
const sort = optionalBitbucketString(params.sort, 'sort')
return bitbucketApiUrl(
`${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/pipelines`,
{
nextUrl: params.nextUrl,
pageLen: params.pageLen,
query: {
'target.ref_type': refType,
'target.ref_name': refName,
'target.commit.hash': commitHash,
'target.selector.type': selectorType,
'target.selector.pattern': selectorPattern,
trigger_type: triggerType,
status,
sort,
},
}
)
},
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketPipeline),
}),
outputs: {
items: {
type: 'array',
description: 'Pipelines',
items: { type: 'object', properties: BITBUCKET_PIPELINE_OUTPUT_PROPERTIES },
},
page: BITBUCKET_PAGE_OUTPUT,
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,79 @@
import {
BITBUCKET_COMMENT_OUTPUT_PROPERTIES,
BITBUCKET_PAGE_OUTPUT,
type BitbucketComment,
type BitbucketListOutput,
type BitbucketListPullRequestCommentsParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PAGINATION_PARAMS,
BITBUCKET_PULL_REQUEST_PARAMS,
BITBUCKET_READ_RETRY,
bitbucketApiUrl,
bitbucketHeaders,
bitbucketJson,
bitbucketPullRequestPath,
normalizeBitbucketComment,
normalizeBitbucketPage,
} from '@/tools/bitbucket/utils'
import { optionalBitbucketString } from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
export const bitbucketListPullRequestCommentsTool: ToolConfig<
BitbucketListPullRequestCommentsParams,
BitbucketToolResponse<BitbucketListOutput<BitbucketComment>>
> = {
id: 'bitbucket_list_pull_request_comments',
name: 'Bitbucket List Pull Request Comments',
description: 'List global, inline, and reply comments on a pull request',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pullrequest'] },
params: {
...BITBUCKET_PULL_REQUEST_PARAMS,
q: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket comment filtering expression',
},
sort: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket comment sort expression',
},
...BITBUCKET_PAGINATION_PARAMS,
},
request: {
url: (params) => {
const q = optionalBitbucketString(params.q, 'q')
const sort = optionalBitbucketString(params.sort, 'sort')
return bitbucketApiUrl(
`${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/comments`,
{
nextUrl: params.nextUrl,
pageLen: params.pageLen,
query: { q, sort },
}
)
},
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketComment),
}),
outputs: {
items: {
type: 'array',
description: 'Pull request comments',
items: { type: 'object', properties: BITBUCKET_COMMENT_OUTPUT_PROPERTIES },
},
page: BITBUCKET_PAGE_OUTPUT,
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,79 @@
import {
BITBUCKET_COMMIT_STATUS_OUTPUT_PROPERTIES,
BITBUCKET_PAGE_OUTPUT,
type BitbucketCommitStatus,
type BitbucketListOutput,
type BitbucketListPullRequestCommitStatusesParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PAGINATION_PARAMS,
BITBUCKET_PULL_REQUEST_PARAMS,
BITBUCKET_READ_RETRY,
bitbucketApiUrl,
bitbucketHeaders,
bitbucketJson,
bitbucketPullRequestPath,
normalizeBitbucketCommitStatus,
normalizeBitbucketPage,
} from '@/tools/bitbucket/utils'
import { optionalBitbucketString } from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
export const bitbucketListPullRequestCommitStatusesTool: ToolConfig<
BitbucketListPullRequestCommitStatusesParams,
BitbucketToolResponse<BitbucketListOutput<BitbucketCommitStatus>>
> = {
id: 'bitbucket_list_pull_request_commit_statuses',
name: 'Bitbucket List Pull Request Commit Statuses',
description: 'List commit statuses associated with a pull request',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pullrequest'] },
params: {
...BITBUCKET_PULL_REQUEST_PARAMS,
q: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket commit status filtering expression',
},
sort: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket commit status sort expression',
},
...BITBUCKET_PAGINATION_PARAMS,
},
request: {
url: (params) => {
const q = optionalBitbucketString(params.q, 'q')
const sort = optionalBitbucketString(params.sort, 'sort')
return bitbucketApiUrl(
`${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/statuses`,
{
nextUrl: params.nextUrl,
pageLen: params.pageLen,
query: { q, sort },
}
)
},
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketCommitStatus),
}),
outputs: {
items: {
type: 'array',
description: 'Pull request commit statuses',
items: { type: 'object', properties: BITBUCKET_COMMIT_STATUS_OUTPUT_PROPERTIES },
},
page: BITBUCKET_PAGE_OUTPUT,
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,88 @@
import {
BITBUCKET_PAGE_OUTPUT,
BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES,
type BitbucketListOutput,
type BitbucketListPullRequestsParams,
type BitbucketPullRequest,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PAGINATION_PARAMS,
BITBUCKET_READ_RETRY,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketApiUrl,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
normalizeBitbucketPage,
normalizeBitbucketPullRequest,
} from '@/tools/bitbucket/utils'
import { optionalBitbucketEnum, optionalBitbucketString } from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
const BITBUCKET_PULL_REQUEST_STATES = ['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED'] as const
export const bitbucketListPullRequestsTool: ToolConfig<
BitbucketListPullRequestsParams,
BitbucketToolResponse<BitbucketListOutput<BitbucketPullRequest>>
> = {
id: 'bitbucket_list_pull_requests',
name: 'Bitbucket List Pull Requests',
description: 'List pull requests in a Bitbucket Cloud repository',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pullrequest'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
state: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'State filter: OPEN, MERGED, DECLINED, or SUPERSEDED',
},
q: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket pull request filtering expression',
},
sort: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket pull request sort expression',
},
...BITBUCKET_PAGINATION_PARAMS,
},
request: {
url: (params) => {
const state = optionalBitbucketEnum(params.state, 'state', BITBUCKET_PULL_REQUEST_STATES)
const q = optionalBitbucketString(params.q, 'q')
const sort = optionalBitbucketString(params.sort, 'sort')
return bitbucketApiUrl(
`${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/pullrequests`,
{
nextUrl: params.nextUrl,
pageLen: params.pageLen,
query: { state, q, sort },
}
)
},
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketPullRequest),
}),
outputs: {
items: {
type: 'array',
description: 'Pull requests',
items: { type: 'object', properties: BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES },
},
page: BITBUCKET_PAGE_OUTPUT,
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,94 @@
import {
BITBUCKET_PAGE_OUTPUT,
BITBUCKET_REPOSITORY_OUTPUT_PROPERTIES,
type BitbucketListOutput,
type BitbucketListRepositoriesParams,
type BitbucketRepository,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_ACCESS_TOKEN_PARAM,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PAGINATION_PARAMS,
BITBUCKET_READ_RETRY,
bitbucketApiUrl,
bitbucketHeaders,
bitbucketJson,
encodeBitbucketSegment,
normalizeBitbucketPage,
normalizeBitbucketRepository,
} from '@/tools/bitbucket/utils'
import { optionalBitbucketEnum, optionalBitbucketString } from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
const BITBUCKET_REPOSITORY_ROLES = ['admin', 'contributor', 'member', 'owner'] as const
export const bitbucketListRepositoriesTool: ToolConfig<
BitbucketListRepositoriesParams,
BitbucketToolResponse<BitbucketListOutput<BitbucketRepository>>
> = {
id: 'bitbucket_list_repositories',
name: 'Bitbucket List Repositories',
description: 'List repositories in a Bitbucket Cloud workspace',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['repository'] },
params: {
workspaceSlug: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Bitbucket workspace slug or UUID',
},
role: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Caller role filter: admin, contributor, member, or owner',
},
q: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket filtering expression',
},
sort: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Bitbucket sort expression',
},
...BITBUCKET_PAGINATION_PARAMS,
accessToken: BITBUCKET_ACCESS_TOKEN_PARAM,
},
request: {
url: (params) => {
const role = optionalBitbucketEnum(params.role, 'role', BITBUCKET_REPOSITORY_ROLES)
const q = optionalBitbucketString(params.q, 'q')
const sort = optionalBitbucketString(params.sort, 'sort')
return bitbucketApiUrl(
`/repositories/${encodeBitbucketSegment(params.workspaceSlug, 'workspaceSlug')}`,
{
nextUrl: params.nextUrl,
pageLen: params.pageLen,
query: { role, q, sort },
}
)
},
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketRepository),
}),
outputs: {
items: {
type: 'array',
description: 'Repositories',
items: { type: 'object', properties: BITBUCKET_REPOSITORY_OUTPUT_PROPERTIES },
},
page: BITBUCKET_PAGE_OUTPUT,
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,80 @@
import type {
BitbucketListOutput,
BitbucketListWorkspacesParams,
BitbucketToolResponse,
BitbucketWorkspaceAccess,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_PAGE_OUTPUT,
BITBUCKET_WORKSPACE_OUTPUT_PROPERTIES,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_ACCESS_TOKEN_PARAM,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PAGINATION_PARAMS,
BITBUCKET_READ_RETRY,
bitbucketApiUrl,
bitbucketHeaders,
bitbucketJson,
normalizeBitbucketPage,
normalizeBitbucketWorkspaceAccess,
} from '@/tools/bitbucket/utils'
import { optionalBitbucketBoolean, optionalBitbucketString } from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
export const bitbucketListWorkspacesTool: ToolConfig<
BitbucketListWorkspacesParams,
BitbucketToolResponse<BitbucketListOutput<BitbucketWorkspaceAccess>>
> = {
id: 'bitbucket_list_workspaces',
name: 'Bitbucket List Workspaces',
description: 'List Bitbucket Cloud workspaces available to the authenticated account',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['account'] },
params: {
sort: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Workspace sort field; Bitbucket currently supports slug',
},
administrator: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Filter by whether the caller is a workspace administrator',
},
...BITBUCKET_PAGINATION_PARAMS,
accessToken: BITBUCKET_ACCESS_TOKEN_PARAM,
},
request: {
url: (params) => {
const administrator = optionalBitbucketBoolean(params.administrator, 'administrator')
const sort = optionalBitbucketString(params.sort, 'sort')
return bitbucketApiUrl('/user/workspaces', {
nextUrl: params.nextUrl,
pageLen: params.pageLen,
query: { sort, administrator },
})
},
method: 'GET',
headers: (params) => bitbucketHeaders(params.accessToken),
retry: BITBUCKET_READ_RETRY,
},
transformResponse: async (response) => ({
success: true,
output: normalizeBitbucketPage(
await bitbucketJson(response),
normalizeBitbucketWorkspaceAccess
),
}),
outputs: {
items: {
type: 'array',
description: 'Workspace access records',
items: { type: 'object', properties: BITBUCKET_WORKSPACE_OUTPUT_PROPERTIES },
},
page: BITBUCKET_PAGE_OUTPUT,
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,153 @@
import {
BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES,
type BitbucketMergePullRequestParams,
type BitbucketPullRequest,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PULL_REQUEST_PARAMS,
bitbucketHeaders,
bitbucketJson,
bitbucketPullRequestPath,
normalizeBitbucketPullRequest,
validateBitbucketOpaqueUrl,
} from '@/tools/bitbucket/utils'
import {
optionalBitbucketBoolean,
optionalBitbucketEnum,
optionalBitbucketUtf8String,
} from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
const BITBUCKET_MERGE_MESSAGE_MAX_BYTES = 128 * 1024
const BITBUCKET_MERGE_STRATEGIES = [
'merge_commit',
'squash',
'fast_forward',
'squash_fast_forward',
'rebase_fast_forward',
'rebase_merge',
] as const
interface BitbucketMergeOutput {
status: 'completed' | 'pending'
taskId: string | null
taskUrl: string | null
pullRequest: BitbucketPullRequest | null
}
function mergeTaskLocation(
response: Response,
params: BitbucketMergePullRequestParams
): {
taskId: string
taskUrl: string
} {
const rawLocation = response.headers.get('location')
if (!rawLocation) throw new Error('Bitbucket async merge response omitted the Location header')
const taskUrl = validateBitbucketOpaqueUrl(
new URL(rawLocation, `${BITBUCKET_API_BASE}/`).toString()
)
const parsed = new URL(taskUrl)
const expectedPrefix = `/2.0${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/merge/task-status/`
if (!parsed.pathname.startsWith(expectedPrefix)) {
throw new Error('Bitbucket merge task Location did not match the requested pull request')
}
const taskId = decodeURIComponent(parsed.pathname.slice(expectedPrefix.length))
if (!taskId || taskId.includes('/')) throw new Error('Bitbucket merge task Location was invalid')
return { taskId, taskUrl }
}
export const bitbucketMergePullRequestTool: ToolConfig<
BitbucketMergePullRequestParams,
BitbucketToolResponse<BitbucketMergeOutput>
> = {
id: 'bitbucket_merge_pull_request',
name: 'Bitbucket Merge Pull Request',
description: 'Start an asynchronous pull request merge and return a task to poll when needed',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pullrequest:write'] },
params: {
...BITBUCKET_PULL_REQUEST_PARAMS,
mergeStrategy: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Merge strategy: merge_commit, squash, fast_forward, squash_fast_forward, rebase_fast_forward, or rebase_merge',
},
message: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Merge commit message (maximum 128 KiB encoded as UTF-8)',
},
closeSourceBranch: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Delete the source branch after merging',
},
},
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/merge?async=true`,
method: 'POST',
headers: (params) => bitbucketHeaders(params.accessToken, { json: true }),
body: (params) => {
const mergeStrategy = optionalBitbucketEnum(
params.mergeStrategy,
'mergeStrategy',
BITBUCKET_MERGE_STRATEGIES
)
const message = optionalBitbucketUtf8String(
params.message,
'message',
BITBUCKET_MERGE_MESSAGE_MAX_BYTES
)
const closeSourceBranch = optionalBitbucketBoolean(
params.closeSourceBranch,
'closeSourceBranch'
)
return {
type: 'pullrequest',
...(mergeStrategy !== undefined ? { merge_strategy: mergeStrategy } : {}),
...(message !== undefined ? { message } : {}),
...(closeSourceBranch !== undefined ? { close_source_branch: closeSourceBranch } : {}),
}
},
},
transformResponse: async (response, params) => {
if (response.status === 202) {
if (!params) throw new Error('Missing merge parameters while reading async merge response')
const task = mergeTaskLocation(response, params)
return {
success: true,
output: { status: 'pending', ...task, pullRequest: null },
}
}
return {
success: true,
output: {
status: 'completed',
taskId: null,
taskUrl: null,
pullRequest: normalizeBitbucketPullRequest(await bitbucketJson(response)),
},
}
},
outputs: {
status: { type: 'string', description: 'Whether the merge completed or remains pending' },
taskId: { type: 'string', description: 'Async merge task ID', nullable: true },
taskUrl: { type: 'string', description: 'Validated task polling URL', nullable: true },
pullRequest: {
type: 'object',
description: 'Merged pull request when completed synchronously',
nullable: true,
properties: BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
+556
View File
@@ -0,0 +1,556 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { bitbucketGetPipelineTool } from '@/tools/bitbucket/get_pipeline'
import { bitbucketGetPipelineStepLogTool } from '@/tools/bitbucket/get_pipeline_step_log'
import { bitbucketListPipelineStepsTool } from '@/tools/bitbucket/list_pipeline_steps'
import { bitbucketListPipelinesTool } from '@/tools/bitbucket/list_pipelines'
import { bitbucketStopPipelineTool } from '@/tools/bitbucket/stop_pipeline'
import { bitbucketTriggerPipelineTool } from '@/tools/bitbucket/trigger_pipeline'
import type {
BitbucketGetPipelineStepLogParams,
BitbucketListPipelineStepsParams,
BitbucketListPipelinesParams,
BitbucketPipelineParams,
BitbucketTriggerPipelineParams,
} from '@/tools/bitbucket/types'
import type { ToolConfig } from '@/tools/types'
const serverMocks = vi.hoisted(() => ({
secureBitbucketRead: vi.fn(),
secureBitbucketPullRequestRedirect: vi.fn(),
}))
vi.mock('@/tools/bitbucket/utils.server', () => serverMocks)
const REPOSITORY_PARAMS = {
accessToken: 'oauth-token',
workspaceSlug: 'acme team',
repoSlug: 'sdk/core',
} as const
const COMMIT_SHA = 'abcdef0123456789abcdef0123456789abcdef01'
/**
* Drives the live step-log path: the tool reads through the byte-capped server transport, so a
* test supplies the provider response there rather than to the unreachable request fallback.
*/
async function runStepLog(
response: Response,
params: BitbucketGetPipelineStepLogParams
): Promise<{ output: { log: string; truncated: boolean; totalBytes: number | null } }> {
serverMocks.secureBitbucketRead.mockResolvedValueOnce(response)
return (await bitbucketGetPipelineStepLogTool.directExecution!(params)) as {
output: { log: string; truncated: boolean; totalBytes: number | null }
}
}
const RAW_USER = {
uuid: '{user-1}',
account_id: 'account-1',
type: 'user',
display_name: 'Ada Lovelace',
links: { self: { href: 'https://api.bitbucket.org/2.0/users/ada' } },
}
const RAW_PIPELINE = {
type: 'pipeline',
uuid: '{pipeline-1}',
build_number: 42,
creator: RAW_USER,
repository: { full_name: 'acme/demo' },
target: {
type: 'pipeline_ref_target',
ref_type: 'branch',
ref_name: 'main',
commit: { hash: 'abc123' },
selector: { type: 'custom', pattern: 'deploy' },
},
trigger: { type: 'pipeline_manual_trigger' },
state: {
name: 'COMPLETED',
stage: { name: 'COMPLETED' },
result: {
name: 'FAILED',
error: { key: 'configuration-error', message: 'Invalid pipeline configuration' },
},
},
created_on: '2026-01-01T00:00:00Z',
completed_on: '2026-01-01T00:02:00Z',
build_seconds_used: 120,
links: {
self: { href: 'https://api.bitbucket.org/2.0/repositories/acme/demo/pipelines/pipeline-1' },
steps: {
href: 'https://api.bitbucket.org/2.0/repositories/acme/demo/pipelines/pipeline-1/steps',
},
},
}
const RAW_PIPELINE_STEP = {
type: 'pipeline_step',
uuid: '{step-1}',
started_on: '2026-01-01T00:00:00Z',
completed_on: '2026-01-01T00:02:00Z',
state: {
name: 'COMPLETED',
result: {
name: 'FAILED',
error: { key: 'script-error', message: 'Tests failed' },
},
},
image: { name: 'node:22' },
setup_commands: [{ name: 'setup', command: 'npm install' }],
script_commands: [
{ name: 'test', command: 'bun test' },
{ name: 'build', command: 'bun run build' },
],
}
function requestUrl<P, R>(tool: ToolConfig<P, R>, params: P): string {
return typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url
}
function requestBody<P, R>(tool: ToolConfig<P, R>, params: P): unknown {
return tool.request.body?.(params)
}
describe('Bitbucket pipeline request builders', () => {
it('builds every documented list filter and bounded pagination parameter', () => {
const url = new URL(
requestUrl(bitbucketListPipelinesTool, {
...REPOSITORY_PARAMS,
refType: 'BRANCH',
refName: 'main',
commitHash: COMMIT_SHA.toUpperCase(),
selectorType: 'CUSTOM',
selectorPattern: 'deploy',
triggerType: 'MANUAL',
status: 'FAILED',
sort: '-created_on,creator.uuid',
pageLen: 25,
} satisfies BitbucketListPipelinesParams)
)
expect(url.pathname).toBe('/2.0/repositories/acme%20team/sdk%2Fcore/pipelines')
expect(Object.fromEntries(url.searchParams)).toEqual({
'target.ref_type': 'BRANCH',
'target.ref_name': 'main',
'target.commit.hash': COMMIT_SHA,
'target.selector.type': 'CUSTOM',
'target.selector.pattern': 'deploy',
trigger_type: 'MANUAL',
status: 'FAILED',
sort: '-created_on,creator.uuid',
pagelen: '25',
})
for (const [field, value] of [
['refType', 'COMMIT'],
['selectorType', 'DEPLOYMENT'],
['triggerType', 'WEBHOOK'],
['status', 'CANCELLED'],
] as const) {
expect(() =>
requestUrl(bitbucketListPipelinesTool, {
...REPOSITORY_PARAMS,
[field]: value,
} as unknown as BitbucketListPipelinesParams)
).toThrow(new RegExp(`${field} must be one of`))
}
expect(() =>
requestUrl(bitbucketListPipelinesTool, {
...REPOSITORY_PARAMS,
sort: { malformed: true },
} as unknown as BitbucketListPipelinesParams)
).toThrow(/sort must be a non-empty string/)
for (const [field, value] of [
['refName', false],
['selectorPattern', 7],
] as const) {
expect(() =>
requestUrl(bitbucketListPipelinesTool, {
...REPOSITORY_PARAMS,
[field]: value,
} as unknown as BitbucketListPipelinesParams)
).toThrow(new RegExp(`${field} must be a non-empty string`))
}
expect(() =>
requestUrl(bitbucketListPipelinesTool, {
...REPOSITORY_PARAMS,
commitHash: 'abc123',
} satisfies BitbucketListPipelinesParams)
).toThrow(/commitHash must be a full 40-character SHA-1/)
})
it('binds a pipeline cursor to the selected repository list endpoint', () => {
const next =
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pipelines?page=2'
expect(
requestUrl(bitbucketListPipelinesTool, {
...REPOSITORY_PARAMS,
nextUrl: next,
} satisfies BitbucketListPipelinesParams)
).toBe(next)
expect(() =>
requestUrl(bitbucketListPipelinesTool, {
...REPOSITORY_PARAMS,
nextUrl:
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pullrequests?page=2',
} satisfies BitbucketListPipelinesParams)
).toThrow(/does not belong/)
})
it('encodes pipeline and step UUID path segments', () => {
const pipelineParams = {
...REPOSITORY_PARAMS,
pipelineUuid: '{pipeline/one ?#}',
} satisfies BitbucketPipelineParams
expect(requestUrl(bitbucketGetPipelineTool, pipelineParams)).toBe(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pipelines/%7Bpipeline%2Fone%20%3F%23%7D'
)
expect(requestUrl(bitbucketStopPipelineTool, pipelineParams)).toBe(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pipelines/%7Bpipeline%2Fone%20%3F%23%7D/stopPipeline'
)
expect(
requestUrl(bitbucketGetPipelineStepLogTool, {
...pipelineParams,
stepUuid: '{step/one ?#}',
} satisfies BitbucketGetPipelineStepLogParams)
).toBe(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pipelines/%7Bpipeline%2Fone%20%3F%23%7D/steps/%7Bstep%2Fone%20%3F%23%7D/log'
)
})
it('builds the restricted pipeline ref target and excludes administration fields', () => {
const params = {
...REPOSITORY_PARAMS,
refType: 'branch',
refName: ' main ',
commitHash: ` ${COMMIT_SHA.toUpperCase()} `,
} satisfies BitbucketTriggerPipelineParams
expect(requestBody(bitbucketTriggerPipelineTool, params)).toEqual({
target: {
type: 'pipeline_ref_target',
ref_type: 'branch',
ref_name: 'main',
commit: { type: 'commit', hash: COMMIT_SHA },
},
})
expect(bitbucketTriggerPipelineTool.params).not.toHaveProperty('variables')
expect(bitbucketTriggerPipelineTool.params).not.toHaveProperty('selector')
expect(bitbucketTriggerPipelineTool.params).not.toHaveProperty('runner')
expect(bitbucketTriggerPipelineTool.oauth?.requiredScopes).toEqual(['pipeline'])
expect(() => requestBody(bitbucketTriggerPipelineTool, { ...params, refName: ' ' })).toThrow(
/refName must be a non-empty string/
)
expect(() =>
requestBody(bitbucketTriggerPipelineTool, {
...params,
refType: 'commit',
} as unknown as BitbucketTriggerPipelineParams)
).toThrow(/refType must be one of/)
expect(() =>
requestBody(bitbucketTriggerPipelineTool, {
...params,
commitHash: 'main',
})
).toThrow(/commitHash must be a full 40-character SHA-1/)
})
it('uses pipeline:write only for stopping and never retries either mutation', () => {
expect(bitbucketStopPipelineTool.oauth?.requiredScopes).toEqual(['pipeline:write'])
expect(bitbucketTriggerPipelineTool.request.retry).toBeUndefined()
expect(bitbucketStopPipelineTool.request.retry).toBeUndefined()
})
})
describe('Bitbucket pipeline response normalization', () => {
it('normalizes pipeline lists, details, and trigger responses consistently', async () => {
const listed = await bitbucketListPipelinesTool.transformResponse!(
Response.json({ values: [RAW_PIPELINE], size: 1, page: 1, pagelen: 20 })
)
const fetched = await bitbucketGetPipelineTool.transformResponse!(Response.json(RAW_PIPELINE))
const triggered = await bitbucketTriggerPipelineTool.transformResponse!(
Response.json(RAW_PIPELINE)
)
expect(listed.output.items[0]).toEqual({
type: 'pipeline',
uuid: '{pipeline-1}',
buildNumber: 42,
creator: {
uuid: '{user-1}',
accountId: 'account-1',
type: 'user',
displayName: 'Ada Lovelace',
createdOn: null,
selfUrl: 'https://api.bitbucket.org/2.0/users/ada',
htmlUrl: null,
avatarUrl: null,
},
repositoryFullName: 'acme/demo',
target: {
type: 'pipeline_ref_target',
refType: 'branch',
refName: 'main',
commitHash: 'abc123',
selectorType: 'custom',
selectorPattern: 'deploy',
},
triggerType: 'pipeline_manual_trigger',
state: {
name: 'COMPLETED',
stage: 'COMPLETED',
result: 'FAILED',
errorKey: 'configuration-error',
errorMessage: 'Invalid pipeline configuration',
},
createdOn: '2026-01-01T00:00:00Z',
completedOn: '2026-01-01T00:02:00Z',
buildSecondsUsed: 120,
selfUrl: 'https://api.bitbucket.org/2.0/repositories/acme/demo/pipelines/pipeline-1',
stepsUrl: 'https://api.bitbucket.org/2.0/repositories/acme/demo/pipelines/pipeline-1/steps',
})
expect(fetched.output.pipeline).toEqual(listed.output.items[0])
expect(triggered.output.pipeline).toEqual(listed.output.items[0])
})
it('normalizes pipeline steps and their documented commands and errors', async () => {
const result = await bitbucketListPipelineStepsTool.transformResponse!(
Response.json({ values: [RAW_PIPELINE_STEP], size: 1 })
)
expect(result.output.items[0]).toEqual({
type: 'pipeline_step',
uuid: '{step-1}',
startedOn: '2026-01-01T00:00:00Z',
completedOn: '2026-01-01T00:02:00Z',
state: {
name: 'COMPLETED',
result: 'FAILED',
errorKey: 'script-error',
errorMessage: 'Tests failed',
},
imageName: 'node:22',
setupCommands: [{ name: 'setup', command: 'npm install' }],
scriptCommands: [
{ name: 'test', command: 'bun test' },
{ name: 'build', command: 'bun run build' },
],
})
})
it('requires top-level resource types while preserving future type values', async () => {
await expect(
bitbucketGetPipelineTool.transformResponse!(
Response.json({ ...RAW_PIPELINE, type: undefined })
)
).rejects.toThrow(/pipeline\.type must be a non-empty string/)
await expect(
bitbucketListPipelineStepsTool.transformResponse!(
Response.json({ values: [{ ...RAW_PIPELINE_STEP, type: '' }] })
)
).rejects.toThrow(/pipeline step\.type must be a non-empty string/)
const futurePipeline = await bitbucketGetPipelineTool.transformResponse!(
Response.json({ ...RAW_PIPELINE, type: 'future_pipeline_variant' })
)
const futureStep = await bitbucketListPipelineStepsTool.transformResponse!(
Response.json({ values: [{ ...RAW_PIPELINE_STEP, type: 'future_step_variant' }] })
)
expect(futurePipeline.output.pipeline.type).toBe('future_pipeline_variant')
expect(futureStep.output.items[0].type).toBe('future_step_variant')
})
it('distinguishes absent, empty, and malformed optional command collections', async () => {
const absent = await bitbucketListPipelineStepsTool.transformResponse!(
Response.json({
values: [{ ...RAW_PIPELINE_STEP, setup_commands: undefined, script_commands: undefined }],
})
)
const empty = await bitbucketListPipelineStepsTool.transformResponse!(
Response.json({ values: [{ ...RAW_PIPELINE_STEP, setup_commands: [], script_commands: [] }] })
)
expect(absent.output.items[0]).toMatchObject({ setupCommands: null, scriptCommands: null })
expect(empty.output.items[0]).toMatchObject({ setupCommands: [], scriptCommands: [] })
await expect(
bitbucketListPipelineStepsTool.transformResponse!(
Response.json({ values: [{ ...RAW_PIPELINE_STEP, setup_commands: null }] })
)
).rejects.toThrow(/setup_commands must be an array when present/)
await expect(
bitbucketListPipelineStepsTool.transformResponse!(
Response.json({ values: [{ ...RAW_PIPELINE_STEP, script_commands: [null] }] })
)
).rejects.toThrow(/script_commands\[0\] must be an object/)
})
it('treats a successful stopPipeline 204 as completion', async () => {
const result = await bitbucketStopPipelineTool.transformResponse!(
new Response(null, { status: 204 })
)
expect(result).toEqual({ success: true, output: { stopped: true } })
for (const status of [200, 202, 205]) {
await expect(
bitbucketStopPipelineTool.transformResponse!(new Response(null, { status }))
).rejects.toThrow(`unexpected HTTP ${status}`)
}
})
it('builds and transforms paginated pipeline steps', async () => {
const params = {
...REPOSITORY_PARAMS,
pipelineUuid: '{pipeline-1}',
pageLen: 30,
} satisfies BitbucketListPipelineStepsParams
expect(requestUrl(bitbucketListPipelineStepsTool, params)).toBe(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pipelines/%7Bpipeline-1%7D/steps?pagelen=30'
)
expect(bitbucketListPipelineStepsTool.request.retry).toMatchObject({
enabled: true,
retryIdempotentOnly: true,
})
})
})
describe('Bitbucket pipeline step logs', () => {
it('requests a bounded byte tail and drops authorization on redirects', () => {
const params = {
...REPOSITORY_PARAMS,
pipelineUuid: '{pipeline-1}',
stepUuid: '{step-1}',
maxCharacters: 4_096,
} satisfies BitbucketGetPipelineStepLogParams
expect(bitbucketGetPipelineStepLogTool.request.headers(params)).toMatchObject({
Accept: '*/*',
Authorization: 'Bearer oauth-token',
Range: 'bytes=-16384',
})
expect(bitbucketGetPipelineStepLogTool.request.stripAuthOnRedirect).toBe(true)
expect(bitbucketGetPipelineStepLogTool.request.retry).toMatchObject({
enabled: true,
maxRetries: 2,
retryIdempotentOnly: true,
})
})
it('overfetches a provider-compatible minimum for small log tails', () => {
const params = {
...REPOSITORY_PARAMS,
pipelineUuid: '{pipeline-1}',
stepUuid: '{step-1}',
maxCharacters: 100,
} satisfies BitbucketGetPipelineStepLogParams
expect(bitbucketGetPipelineStepLogTool.request.headers(params)).toMatchObject({
Range: 'bytes=-4096',
})
})
it('trims the partial leading line of a ranged log and reports total bytes', async () => {
const body = 'ise\nFAILED: expected 1 to be 2\n'
const result = await runStepLog(
new Response(body, {
status: 206,
headers: { 'Content-Range': 'bytes 969-999/1000' },
}),
{ ...REPOSITORY_PARAMS, pipelineUuid: '{pipeline-1}', stepUuid: '{step-1}' }
)
expect(result.output).toEqual({
log: 'FAILED: expected 1 to be 2\n',
truncated: true,
totalBytes: 1000,
})
})
it('locally retains only the useful tail when Range is ignored', async () => {
const body = `${'noise line\n'.repeat(20)}FAILED\n`
const result = await runStepLog(
new Response(body, { headers: { 'Content-Length': String(Buffer.byteLength(body)) } }),
{
...REPOSITORY_PARAMS,
pipelineUuid: '{pipeline-1}',
stepUuid: '{step-1}',
maxCharacters: 10,
}
)
expect(result.output).toEqual({
log: 'FAILED\n',
truncated: true,
totalBytes: Buffer.byteLength(body),
})
})
it('rejects log caps outside the supported range', async () => {
await expect(
runStepLog(new Response('log'), {
...REPOSITORY_PARAMS,
pipelineUuid: '{pipeline-1}',
stepUuid: '{step-1}',
maxCharacters: 0,
})
).rejects.toThrow(/maxCharacters must be an integer between 1 and 200000/)
})
})
describe('Bitbucket pipeline step log transfer boundary', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const LOG_PARAMS = {
...REPOSITORY_PARAMS,
pipelineUuid: '{pipeline-1}',
stepUuid: '{step-1}',
maxCharacters: 10,
}
it('reads through the capped server path instead of the buffered tool request', async () => {
serverMocks.secureBitbucketRead.mockResolvedValue(new Response('done\n'))
await bitbucketGetPipelineStepLogTool.directExecution!(LOG_PARAMS)
const [url, headers, maxBytes, options] = serverMocks.secureBitbucketRead.mock.calls[0]
expect(url).toContain('/pipelines/%7Bpipeline-1%7D/steps/%7Bstep-1%7D/log')
expect(headers.Range).toBe('bytes=-4096')
expect(maxBytes).toBe(16 * 1024 * 1024)
expect(options.stripAuthOnRedirect).toBe(true)
})
it('treats an unsatisfiable range over an empty log as an empty log, not a failure', async () => {
serverMocks.secureBitbucketRead.mockResolvedValue(
new Response('', { status: 416, headers: { 'Content-Range': 'bytes */0' } })
)
const result = await bitbucketGetPipelineStepLogTool.directExecution!(LOG_PARAMS)
expect(result).toEqual({
success: true,
output: { log: '', truncated: false, totalBytes: 0 },
})
})
it('still surfaces genuine step-log failures', async () => {
serverMocks.secureBitbucketRead.mockResolvedValue(
Response.json({ error: { message: 'No such step' } }, { status: 404 })
)
await expect(bitbucketGetPipelineStepLogTool.directExecution!(LOG_PARAMS)).rejects.toThrow(
/No such step/
)
})
it('does not report an empty log when a 416 came from something other than an empty log', async () => {
serverMocks.secureBitbucketRead.mockResolvedValue(
Response.json(
{ error: { message: 'Range rejected', detail: 'proxy does not support ranges' } },
{ status: 416, headers: { 'Content-Range': 'bytes */12345' } }
)
)
await expect(bitbucketGetPipelineStepLogTool.directExecution!(LOG_PARAMS)).rejects.toThrow(
/Range rejected: proxy does not support ranges/
)
})
})
@@ -0,0 +1,788 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { bitbucketApprovePullRequestTool } from '@/tools/bitbucket/approve_pull_request'
import { bitbucketCreatePullRequestTool } from '@/tools/bitbucket/create_pull_request'
import { bitbucketCreatePullRequestCommentTool } from '@/tools/bitbucket/create_pull_request_comment'
import { bitbucketDeclinePullRequestTool } from '@/tools/bitbucket/decline_pull_request'
import { bitbucketGetMergeTaskStatusTool } from '@/tools/bitbucket/get_merge_task_status'
import { bitbucketGetPullRequestTool } from '@/tools/bitbucket/get_pull_request'
import { bitbucketGetPullRequestDiffTool } from '@/tools/bitbucket/get_pull_request_diff'
import { bitbucketGetPullRequestDiffstatTool } from '@/tools/bitbucket/get_pull_request_diffstat'
import { bitbucketListPullRequestCommentsTool } from '@/tools/bitbucket/list_pull_request_comments'
import { bitbucketListPullRequestCommitStatusesTool } from '@/tools/bitbucket/list_pull_request_commit_statuses'
import { bitbucketListPullRequestsTool } from '@/tools/bitbucket/list_pull_requests'
import { bitbucketMergePullRequestTool } from '@/tools/bitbucket/merge_pull_request'
import { bitbucketRequestPullRequestChangesTool } from '@/tools/bitbucket/request_pull_request_changes'
import type {
BitbucketCreatePullRequestCommentParams,
BitbucketCreatePullRequestParams,
BitbucketGetMergeTaskStatusParams,
BitbucketGetPullRequestDiffParams,
BitbucketListPullRequestCommentsParams,
BitbucketListPullRequestCommitStatusesParams,
BitbucketListPullRequestsParams,
BitbucketMergePullRequestParams,
BitbucketPaginatedPullRequestParams,
} from '@/tools/bitbucket/types'
import { assertBitbucketResponseOk } from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
const serverMocks = vi.hoisted(() => ({
resolveBitbucketPullRequestRedirect: vi.fn(),
secureBitbucketRead: vi.fn(),
secureBitbucketPullRequestRedirect: vi.fn(),
}))
vi.mock('@/tools/bitbucket/utils.server', () => serverMocks)
const PULL_REQUEST_PARAMS = {
accessToken: 'oauth-token',
workspaceSlug: 'acme team',
repoSlug: 'sdk/core',
prId: 7,
} as const
const RAW_USER = {
uuid: '{user-1}',
account_id: 'account-1',
type: 'user',
display_name: 'Ada Lovelace',
created_on: '2026-01-01T00:00:00Z',
links: {
self: { href: 'https://api.bitbucket.org/2.0/users/ada' },
html: { href: 'https://bitbucket.org/ada' },
avatar: { href: 'https://avatar.test/ada' },
},
}
const RAW_PARTICIPANT = {
type: 'participant',
user: RAW_USER,
role: 'REVIEWER',
approved: true,
state: 'approved',
participated_on: '2026-01-02T00:00:00Z',
}
const RAW_PULL_REQUEST = {
type: 'pullrequest',
id: 7,
title: 'Ship the SDK',
description: 'Top-level description',
rendered: { description: { raw: 'Rendered fallback' } },
summary: { raw: 'Summary fallback' },
state: 'OPEN',
draft: false,
queued: false,
author: RAW_USER,
closed_by: null,
source: {
branch: { name: 'feature/sdk' },
commit: { hash: 'source123' },
repository: { uuid: '{repo-1}', full_name: 'acme/demo' },
},
destination: {
branch: { name: 'main' },
commit: { hash: 'main123' },
repository: { uuid: '{repo-1}', full_name: 'acme/demo' },
},
merge_commit: null,
comment_count: 3,
task_count: 1,
close_source_branch: true,
reason: null,
created_on: '2026-01-01T00:00:00Z',
updated_on: '2026-01-02T00:00:00Z',
reviewers: [RAW_USER],
participants: [RAW_PARTICIPANT],
links: {
self: { href: 'https://api.bitbucket.org/2.0/repositories/acme/demo/pullrequests/7' },
html: { href: 'https://bitbucket.org/acme/demo/pull-requests/7' },
},
}
const RAW_COMMENT = {
type: 'pullrequest_comment',
id: 10,
created_on: '2026-01-02T00:00:00Z',
updated_on: '2026-01-03T00:00:00Z',
content: { raw: 'Looks good' },
user: RAW_USER,
deleted: false,
parent: { id: 9 },
inline: { path: 'src/index.ts', from: 4, to: 5, start_from: 2, start_to: 3 },
pending: false,
resolution: { user: RAW_USER, created_on: '2026-01-04T00:00:00Z' },
links: {
self: { href: 'https://api.bitbucket.org/comment/10' },
html: { href: 'https://bitbucket.org/comment/10' },
},
}
const RAW_COMMIT_STATUS = {
type: 'build',
key: 'ci/test',
refname: 'feature/sdk',
url: 'https://ci.example.test/build/1',
state: 'SUCCESSFUL',
name: 'CI',
description: 'All checks passed',
created_on: '2026-01-02T00:00:00Z',
updated_on: '2026-01-03T00:00:00Z',
links: {
self: { href: 'https://api.bitbucket.org/status/1' },
commit: { href: 'https://api.bitbucket.org/commit/source123' },
},
}
const RAW_DIFFSTAT = {
type: 'diffstat',
status: 'modified',
lines_added: 12,
lines_removed: 4,
old: { path: 'src/old.ts', commit: { hash: 'main123' } },
new: { path: 'src/new.ts', commit: { hash: 'source123' } },
}
function requestUrl<P, R>(tool: ToolConfig<P, R>, params: P): string {
return typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url
}
function requestBody<P, R>(tool: ToolConfig<P, R>, params: P): unknown {
return tool.request.body?.(params)
}
afterEach(() => {
vi.resetAllMocks()
})
describe('Bitbucket pull request request builders', () => {
it('builds list and detail URLs with encoded coordinates and filters', () => {
const list = new URL(
requestUrl(bitbucketListPullRequestsTool, {
...PULL_REQUEST_PARAMS,
state: 'OPEN',
q: 'draft = false',
sort: '-updated_on',
pageLen: 25,
} satisfies BitbucketListPullRequestsParams)
)
expect(list.pathname).toBe('/2.0/repositories/acme%20team/sdk%2Fcore/pullrequests')
expect(Object.fromEntries(list.searchParams)).toEqual({
state: 'OPEN',
q: 'draft = false',
sort: '-updated_on',
pagelen: '25',
})
expect(requestUrl(bitbucketGetPullRequestTool, PULL_REQUEST_PARAMS)).toBe(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pullrequests/7'
)
expect(() =>
requestUrl(bitbucketListPullRequestsTool, {
...PULL_REQUEST_PARAMS,
state: 'CLOSED',
} as unknown as BitbucketListPullRequestsParams)
).toThrow(/state must be one of/)
expect(() =>
requestUrl(bitbucketListPullRequestsTool, {
...PULL_REQUEST_PARAMS,
q: false,
} as unknown as BitbucketListPullRequestsParams)
).toThrow(/q must be a non-empty string/)
})
it('builds the complete documented create-pull-request body', () => {
const params = {
...PULL_REQUEST_PARAMS,
title: ' Ship the SDK ',
sourceBranch: ' feature/sdk ',
destinationBranch: ' main ',
description: 'Ready for review',
closeSourceBranch: false,
draft: true,
reviewerUuids: [' {reviewer-1} ', '{reviewer-2}'],
} satisfies BitbucketCreatePullRequestParams
expect(requestBody(bitbucketCreatePullRequestTool, params)).toEqual({
title: 'Ship the SDK',
source: { branch: { name: 'feature/sdk' } },
destination: { branch: { name: 'main' } },
description: 'Ready for review',
close_source_branch: false,
draft: true,
reviewers: [{ uuid: '{reviewer-1}' }, { uuid: '{reviewer-2}' }],
})
expect(() =>
requestBody(bitbucketCreatePullRequestTool, {
...params,
reviewerUuids: [' '],
})
).toThrow(/reviewer UUID must be a non-empty string/)
expect(() =>
requestBody(bitbucketCreatePullRequestTool, {
...params,
closeSourceBranch: 'false',
} as unknown as BitbucketCreatePullRequestParams)
).toThrow(/closeSourceBranch must be a boolean/)
expect(() =>
requestBody(bitbucketCreatePullRequestTool, {
...params,
draft: 0,
} as unknown as BitbucketCreatePullRequestParams)
).toThrow(/draft must be a boolean/)
expect(() =>
requestBody(bitbucketCreatePullRequestTool, {
...params,
reviewerUuids: ['{reviewer-1}', true],
} as unknown as BitbucketCreatePullRequestParams)
).toThrow(/reviewer UUID must be a non-empty string/)
expect(() =>
requestBody(bitbucketCreatePullRequestTool, {
...params,
reviewerUuids: '{reviewer-1}',
} as unknown as BitbucketCreatePullRequestParams)
).toThrow(/reviewerUuids must be an array of strings/)
})
it('builds every pull request action endpoint', () => {
expect(requestUrl(bitbucketDeclinePullRequestTool, PULL_REQUEST_PARAMS)).toMatch(
/\/pullrequests\/7\/decline$/
)
expect(requestUrl(bitbucketApprovePullRequestTool, PULL_REQUEST_PARAMS)).toMatch(
/\/pullrequests\/7\/approve$/
)
expect(requestUrl(bitbucketRequestPullRequestChangesTool, PULL_REQUEST_PARAMS)).toMatch(
/\/pullrequests\/7\/request-changes$/
)
expect(
requestUrl(bitbucketGetMergeTaskStatusTool, {
...PULL_REQUEST_PARAMS,
taskId: 'task/with ?#',
} satisfies BitbucketGetMergeTaskStatusParams)
).toMatch(/\/merge\/task-status\/task%2Fwith%20%3F%23$/)
})
it('builds comment and status pagination plus the reply body', () => {
const comments = new URL(
requestUrl(bitbucketListPullRequestCommentsTool, {
...PULL_REQUEST_PARAMS,
q: 'deleted = false',
sort: '-created_on',
pageLen: 50,
} satisfies BitbucketListPullRequestCommentsParams)
)
expect(comments.pathname).toMatch(/\/pullrequests\/7\/comments$/)
expect(Object.fromEntries(comments.searchParams)).toEqual({
q: 'deleted = false',
sort: '-created_on',
pagelen: '50',
})
const statuses = new URL(
requestUrl(bitbucketListPullRequestCommitStatusesTool, {
...PULL_REQUEST_PARAMS,
q: 'state = "FAILED"',
sort: '-created_on',
pageLen: 10,
} satisfies BitbucketListPullRequestCommitStatusesParams)
)
expect(statuses.pathname).toMatch(/\/pullrequests\/7\/statuses$/)
expect(Object.fromEntries(statuses.searchParams)).toEqual({
q: 'state = "FAILED"',
sort: '-created_on',
pagelen: '10',
})
const commentParams = {
...PULL_REQUEST_PARAMS,
content: 'Please add a regression test.',
parentId: 9,
} satisfies BitbucketCreatePullRequestCommentParams
expect(requestBody(bitbucketCreatePullRequestCommentTool, commentParams)).toEqual({
content: { raw: 'Please add a regression test.' },
parent: { id: 9 },
})
expect(() =>
requestBody(bitbucketCreatePullRequestCommentTool, { ...commentParams, content: ' ' })
).toThrow(/content must be a non-empty string/)
expect(() =>
requestBody(bitbucketCreatePullRequestCommentTool, { ...commentParams, parentId: 0 })
).toThrow(/parentId must be a positive integer/)
expect(() =>
requestBody(bitbucketCreatePullRequestCommentTool, {
...commentParams,
parentId: true,
} as unknown as BitbucketCreatePullRequestCommentParams)
).toThrow(/parentId must be a positive integer/)
})
})
describe('Bitbucket pull request response normalization', () => {
it('normalizes list, get, create, decline, and synchronous merge responses consistently', async () => {
const listed = await bitbucketListPullRequestsTool.transformResponse!(
Response.json({ values: [RAW_PULL_REQUEST], size: 1, page: 1, pagelen: 20 })
)
const fetched = await bitbucketGetPullRequestTool.transformResponse!(
Response.json(RAW_PULL_REQUEST)
)
const created = await bitbucketCreatePullRequestTool.transformResponse!(
Response.json(RAW_PULL_REQUEST)
)
const declined = await bitbucketDeclinePullRequestTool.transformResponse!(
Response.json(RAW_PULL_REQUEST)
)
const merged = await bitbucketMergePullRequestTool.transformResponse!(
Response.json({ ...RAW_PULL_REQUEST, state: 'MERGED' }),
PULL_REQUEST_PARAMS
)
expect(listed.output.items[0]).toMatchObject({
id: 7,
description: 'Top-level description',
source: {
branchName: 'feature/sdk',
commitHash: 'source123',
repositoryUuid: '{repo-1}',
repositoryFullName: 'acme/demo',
},
reviewers: [{ accountId: 'account-1' }],
participants: [{ approved: true, user: { accountId: 'account-1' } }],
})
expect(fetched.output.pullRequest).toEqual(listed.output.items[0])
expect(created.output.pullRequest).toEqual(listed.output.items[0])
expect(declined.output.pullRequest).toEqual(listed.output.items[0])
expect(merged.output).toMatchObject({
status: 'completed',
taskId: null,
taskUrl: null,
pullRequest: { state: 'MERGED' },
})
})
it('preserves null reviewers and participants when Bitbucket omits those expansions', async () => {
const result = await bitbucketGetPullRequestTool.transformResponse!(
Response.json({ ...RAW_PULL_REQUEST, reviewers: undefined, participants: undefined })
)
expect(result.output.pullRequest).toMatchObject({ reviewers: null, participants: null })
})
it('normalizes approval and change-request participants', async () => {
const approved = await bitbucketApprovePullRequestTool.transformResponse!(
Response.json(RAW_PARTICIPANT)
)
const changes = await bitbucketRequestPullRequestChangesTool.transformResponse!(
Response.json({ ...RAW_PARTICIPANT, approved: false, state: 'changes_requested' })
)
expect(approved.output.participant).toMatchObject({
role: 'REVIEWER',
approved: true,
user: { uuid: '{user-1}', accountId: 'account-1' },
})
expect(changes.output.participant).toMatchObject({
approved: false,
state: 'changes_requested',
})
})
it('normalizes comments, replies, inline coordinates, resolutions, and statuses', async () => {
const comments = await bitbucketListPullRequestCommentsTool.transformResponse!(
Response.json({ values: [RAW_COMMENT] })
)
const created = await bitbucketCreatePullRequestCommentTool.transformResponse!(
Response.json(RAW_COMMENT)
)
expect(comments.output.items[0]).toMatchObject({
id: 10,
content: 'Looks good',
parentId: 9,
inline: { path: 'src/index.ts', from: 4, to: 5, startFrom: 2, startTo: 3 },
resolution: { resolver: { accountId: 'account-1' }, resolvedOn: '2026-01-04T00:00:00Z' },
})
expect(created.output.comment).toEqual(comments.output.items[0])
const statuses = await bitbucketListPullRequestCommitStatusesTool.transformResponse!(
Response.json({ values: [RAW_COMMIT_STATUS] })
)
expect(statuses.output.items[0]).toEqual({
type: 'build',
key: 'ci/test',
refName: 'feature/sdk',
url: 'https://ci.example.test/build/1',
state: 'SUCCESSFUL',
name: 'CI',
description: 'All checks passed',
createdOn: '2026-01-02T00:00:00Z',
updatedOn: '2026-01-03T00:00:00Z',
selfUrl: 'https://api.bitbucket.org/status/1',
commitUrl: 'https://api.bitbucket.org/commit/source123',
})
})
it('rejects resources missing required type and commit-status invariants', async () => {
await expect(
bitbucketGetPullRequestTool.transformResponse!(
Response.json({ ...RAW_PULL_REQUEST, type: undefined })
)
).rejects.toThrow(/pull request\.type must be a non-empty string/)
await expect(
bitbucketListPullRequestCommitStatusesTool.transformResponse!(
Response.json({ values: [{ ...RAW_COMMIT_STATUS, type: undefined }] })
)
).rejects.toThrow(/commit status\.type must be a non-empty string/)
const partial = await bitbucketListPullRequestCommitStatusesTool.transformResponse!(
Response.json({
values: [{ ...RAW_COMMIT_STATUS, key: undefined, state: undefined }, RAW_COMMIT_STATUS],
})
)
expect(partial.output.items).toHaveLength(2)
expect(partial.output.items[0]).toMatchObject({ key: null, state: null })
})
})
describe('Bitbucket merge lifecycle', () => {
it('sends optional merge fields and forces asynchronous task creation support', () => {
const params = {
...PULL_REQUEST_PARAMS,
mergeStrategy: 'squash_fast_forward',
message: 'Ship it',
closeSourceBranch: false,
} satisfies BitbucketMergePullRequestParams
expect(requestUrl(bitbucketMergePullRequestTool, params)).toBe(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pullrequests/7/merge?async=true'
)
expect(requestBody(bitbucketMergePullRequestTool, params)).toEqual({
type: 'pullrequest',
merge_strategy: 'squash_fast_forward',
message: 'Ship it',
close_source_branch: false,
})
expect(() =>
requestBody(bitbucketMergePullRequestTool, {
...params,
mergeStrategy: 'octopus',
} as unknown as BitbucketMergePullRequestParams)
).toThrow(/mergeStrategy must be one of/)
expect(() =>
requestBody(bitbucketMergePullRequestTool, {
...params,
closeSourceBranch: 'false',
} as unknown as BitbucketMergePullRequestParams)
).toThrow(/closeSourceBranch must be a boolean/)
expect(() =>
requestBody(bitbucketMergePullRequestTool, {
...params,
message: 'é'.repeat(65_537),
})
).toThrow(/message must not exceed 131072 UTF-8 bytes/)
})
it('returns a validated task for a 202 merge response', async () => {
const response = new Response(null, {
status: 202,
headers: {
Location:
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pullrequests/7/merge/task-status/task%20one',
},
})
const result = await bitbucketMergePullRequestTool.transformResponse!(
response,
PULL_REQUEST_PARAMS
)
expect(result).toEqual({
success: true,
output: {
status: 'pending',
taskId: 'task one',
taskUrl:
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pullrequests/7/merge/task-status/task%20one',
pullRequest: null,
},
})
})
it('rejects missing, cross-origin, and wrong-pull-request task Locations', async () => {
await expect(
bitbucketMergePullRequestTool.transformResponse!(
new Response(null, { status: 202 }),
PULL_REQUEST_PARAMS
)
).rejects.toThrow(/omitted the Location/)
await expect(
bitbucketMergePullRequestTool.transformResponse!(
new Response(null, {
status: 202,
headers: { Location: 'https://evil.test/task/1' },
}),
PULL_REQUEST_PARAMS
)
).rejects.toThrow(/Bitbucket Cloud API 2.0 URL/)
await expect(
bitbucketMergePullRequestTool.transformResponse!(
new Response(null, {
status: 202,
headers: {
Location:
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pullrequests/8/merge/task-status/task-1',
},
}),
PULL_REQUEST_PARAMS
)
).rejects.toThrow(/did not match the requested pull request/)
})
it('normalizes pending and successful task-status responses', async () => {
const pending = await bitbucketGetMergeTaskStatusTool.transformResponse!(
Response.json({
task_status: 'PENDING',
links: { self: { href: 'https://api.bitbucket.org/task/task-1' } },
})
)
expect(pending.output).toEqual({
taskStatus: 'PENDING',
selfUrl: 'https://api.bitbucket.org/task/task-1',
mergeResult: null,
})
const success = await bitbucketGetMergeTaskStatusTool.transformResponse!(
Response.json({
task_status: 'SUCCESS',
merge_result: { ...RAW_PULL_REQUEST, state: 'MERGED' },
})
)
expect(success.output).toMatchObject({
taskStatus: 'SUCCESS',
mergeResult: { id: 7, state: 'MERGED' },
})
})
it('rejects merge-task errors, unknown statuses, and missing success results', async () => {
await expect(
bitbucketGetMergeTaskStatusTool.transformResponse!(
Response.json({
type: 'error',
error: { message: 'The destination changed while the merge was queued' },
})
)
).rejects.toThrow('The destination changed while the merge was queued')
await expect(
bitbucketGetMergeTaskStatusTool.transformResponse!(Response.json({ task_status: 'FAILED' }))
).rejects.toThrow(/must be PENDING or SUCCESS/)
await expect(
bitbucketGetMergeTaskStatusTool.transformResponse!(Response.json({ task_status: 'SUCCESS' }))
).rejects.toThrow(/omitted merge_result/)
await expect(
bitbucketGetMergeTaskStatusTool.transformResponse!(
Response.json({ task_status: 'PENDING', merge_result: RAW_PULL_REQUEST })
)
).resolves.toMatchObject({
output: { taskStatus: 'PENDING', mergeResult: null },
})
})
it('surfaces merge conflicts and failed merge checks from structured Bitbucket errors', async () => {
await expect(
assertBitbucketResponseOk(
Response.json(
{ error: { message: 'Merge conflict: destination changed' } },
{ status: 409 }
)
)
).rejects.toThrow('Merge conflict: destination changed')
await expect(
assertBitbucketResponseOk(
Response.json(
{ error: { message: 'Required merge checks have not passed' } },
{ status: 409 }
)
)
).rejects.toThrow('Required merge checks have not passed')
})
})
describe('Bitbucket pull request diff safety', () => {
it('requires a file path and applies it only to the validated repository diff target', async () => {
serverMocks.secureBitbucketPullRequestRedirect.mockResolvedValueOnce(
new Response('@@ -1 +1 @@\n-old\n+new\n', {
headers: { 'Content-Length': '24', 'Content-Type': 'text/plain' },
})
)
const params = {
...PULL_REQUEST_PARAMS,
path: '/src/my file.ts/',
maxCharacters: 100,
} satisfies BitbucketGetPullRequestDiffParams
const result = await bitbucketGetPullRequestDiffTool.directExecution!(params)
expect(result).toMatchObject({
success: true,
output: {
diff: '@@ -1 +1 @@\n-old\n+new\n',
decodingLossy: false,
truncated: false,
},
})
expect(serverMocks.secureBitbucketPullRequestRedirect).toHaveBeenCalledWith(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pullrequests/7/diff',
'acme team',
'sdk/core',
'diff',
expect.objectContaining({
Accept: '*/*',
Authorization: 'Bearer oauth-token',
Range: 'bytes=0-399',
}),
10 * 1024 * 1024,
{ signal: undefined, targetQuery: { path: 'src/my file.ts', binary: 'false' } }
)
expect(requestUrl(bitbucketGetPullRequestDiffTool, params)).not.toContain('path=')
})
it('rejects hostile repository-relative paths before making a redirect request', async () => {
await expect(
bitbucketGetPullRequestDiffTool.directExecution!({
...PULL_REQUEST_PARAMS,
path: '../secret',
})
).rejects.toThrow(/dot segment/)
expect(serverMocks.secureBitbucketPullRequestRedirect).not.toHaveBeenCalled()
})
it('locally caps raw diff text when a Range response is ignored', async () => {
const result = await bitbucketGetPullRequestDiffTool.transformResponse!(
new Response('0123456789', { headers: { 'Content-Length': '10' } }),
{ ...PULL_REQUEST_PARAMS, path: 'src/index.ts', maxCharacters: 4 }
)
expect(result.output).toEqual({
diff: '0123',
decodingLossy: false,
truncated: true,
returnedBytes: 10,
fullBytes: 10,
})
})
it('lossily decodes invalid UTF-8 only for pull request diffs', async () => {
const result = await bitbucketGetPullRequestDiffTool.transformResponse!(
new Response(new Uint8Array([0x41, 0x80]), {
headers: { 'Content-Length': '2', 'Content-Type': 'text/plain' },
}),
{ ...PULL_REQUEST_PARAMS, path: 'src/index.ts', maxCharacters: 100 }
)
expect(result.output).toEqual({
diff: 'A�',
decodingLossy: true,
truncated: false,
returnedBytes: 2,
fullBytes: 2,
})
})
it('uses the PR redirect only for the first diffstat page and puts pagelen on the target', async () => {
serverMocks.secureBitbucketPullRequestRedirect.mockResolvedValueOnce(
Response.json({ values: [RAW_DIFFSTAT] })
)
const params = {
...PULL_REQUEST_PARAMS,
pageLen: 25,
} satisfies BitbucketPaginatedPullRequestParams
const result = await bitbucketGetPullRequestDiffstatTool.directExecution!(params)
expect(result.output.items[0]).toEqual({
type: 'diffstat',
status: 'modified',
linesAdded: 12,
linesRemoved: 4,
oldPath: 'src/old.ts',
newPath: 'src/new.ts',
oldCommitHash: 'main123',
newCommitHash: 'source123',
})
expect(serverMocks.secureBitbucketPullRequestRedirect).toHaveBeenCalledWith(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pullrequests/7/diffstat',
'acme team',
'sdk/core',
'diffstat',
expect.objectContaining({ Authorization: 'Bearer oauth-token' }),
2 * 1024 * 1024,
{ signal: undefined, targetQuery: { pagelen: '25' } }
)
expect(serverMocks.secureBitbucketRead).not.toHaveBeenCalled()
})
it('fetches an already-validated repository diffstat cursor directly', async () => {
const nextUrl =
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/diffstat/source-team/source-repo:6315b3bac849%0Decdc2efc4f27?page=2'
serverMocks.resolveBitbucketPullRequestRedirect.mockResolvedValueOnce(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/diffstat/source-team/source-repo:6315b3bac849%0Decdc2efc4f27'
)
serverMocks.secureBitbucketRead.mockResolvedValueOnce(
Response.json({ values: [RAW_DIFFSTAT], page: 2 })
)
const result = await bitbucketGetPullRequestDiffstatTool.directExecution!({
...PULL_REQUEST_PARAMS,
nextUrl,
pageLen: 99,
})
expect(result.output.page.page).toBe(2)
expect(serverMocks.secureBitbucketRead).toHaveBeenCalledWith(
nextUrl,
expect.objectContaining({ Authorization: 'Bearer oauth-token' }),
2 * 1024 * 1024,
{ maxRedirects: 0, signal: undefined }
)
expect(serverMocks.secureBitbucketPullRequestRedirect).not.toHaveBeenCalled()
})
it('rejects a diffstat cursor for a different pull request revspec', async () => {
serverMocks.resolveBitbucketPullRequestRedirect.mockResolvedValueOnce(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/diffstat/source-team/source-repo:6315b3bac849%0Decdc2efc4f27'
)
await expect(
bitbucketGetPullRequestDiffstatTool.directExecution!({
...PULL_REQUEST_PARAMS,
nextUrl:
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/diffstat/source-team/source-repo:6315b3bac849%0Dunrelated?page=2',
})
).rejects.toThrow(/does not belong to this Bitbucket pull request diffstat/)
expect(serverMocks.secureBitbucketRead).not.toHaveBeenCalled()
})
it('rejects hostile or cross-repository diffstat cursors before fetching', async () => {
const invalid = [
'https://evil.test/2.0/repositories/acme%20team/sdk%2Fcore/diffstat/a..b?page=2',
'https://api.bitbucket.org/2.0/repositories/acme%20team/other/diffstat/a..b?page=2',
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/diff/a..b?page=2',
]
for (const nextUrl of invalid) {
await expect(
bitbucketGetPullRequestDiffstatTool.directExecution!({
...PULL_REQUEST_PARAMS,
nextUrl,
})
).rejects.toThrow()
}
expect(serverMocks.secureBitbucketRead).not.toHaveBeenCalled()
expect(serverMocks.secureBitbucketPullRequestRedirect).not.toHaveBeenCalled()
})
it('normalizes executor-provided diffstat JSON through the same transform', async () => {
const result = await bitbucketGetPullRequestDiffstatTool.transformResponse!(
Response.json({ values: [RAW_DIFFSTAT], page: 3, pagelen: 20 })
)
expect(result.output).toMatchObject({
items: [{ newPath: 'src/new.ts', linesAdded: 12 }],
page: { page: 3, pageLen: 20 },
})
})
})
@@ -0,0 +1,875 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { bitbucketCreateBranchTool } from '@/tools/bitbucket/create_branch'
import { bitbucketDeleteBranchTool } from '@/tools/bitbucket/delete_branch'
import { bitbucketGetCommitTool } from '@/tools/bitbucket/get_commit'
import { bitbucketGetFileTool } from '@/tools/bitbucket/get_file'
import { bitbucketGetFileMetadataTool } from '@/tools/bitbucket/get_file_metadata'
import { bitbucketGetRepositoryTool } from '@/tools/bitbucket/get_repository'
import { bitbucketTools } from '@/tools/bitbucket/index'
import { bitbucketListBranchesTool } from '@/tools/bitbucket/list_branches'
import { bitbucketListCommitsTool } from '@/tools/bitbucket/list_commits'
import { bitbucketListDirectoryTool } from '@/tools/bitbucket/list_directory'
import { bitbucketListRepositoriesTool } from '@/tools/bitbucket/list_repositories'
import { bitbucketListWorkspacesTool } from '@/tools/bitbucket/list_workspaces'
import type {
BitbucketCreateBranchParams,
BitbucketFileParams,
BitbucketGetCommitParams,
BitbucketGetFileParams,
BitbucketListBranchesParams,
BitbucketListCommitsParams,
BitbucketListDirectoryParams,
BitbucketListRepositoriesParams,
BitbucketListWorkspacesParams,
BitbucketRepositoryParams,
} from '@/tools/bitbucket/types'
import type { ToolConfig } from '@/tools/types'
const serverMocks = vi.hoisted(() => ({
secureBitbucketRead: vi.fn(),
secureBitbucketPullRequestRedirect: vi.fn(),
}))
vi.mock('@/tools/bitbucket/utils.server', () => serverMocks)
const REPOSITORY_PARAMS = {
accessToken: 'oauth-token',
workspaceSlug: 'acme team',
repoSlug: 'sdk/core',
} as const
const COMMIT_SHA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const FEATURE_SHA = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
const RAW_USER = {
uuid: '{user-1}',
account_id: 'account-1',
type: 'user',
display_name: 'Ada Lovelace',
created_on: '2026-01-01T00:00:00Z',
links: {
self: { href: 'https://api.bitbucket.org/2.0/users/ada' },
html: { href: 'https://bitbucket.org/ada' },
avatar: { href: 'https://avatar.test/ada' },
},
}
const RAW_REPOSITORY = {
type: 'repository',
uuid: '{repo-1}',
slug: 'demo',
name: 'Demo repository',
full_name: 'acme/demo',
description: 'SDK repository',
is_private: true,
scm: 'git',
language: 'typescript',
size: 1234,
created_on: '2026-01-01T00:00:00Z',
updated_on: '2026-01-02T00:00:00Z',
mainbranch: { name: 'main' },
owner: RAW_USER,
project: { uuid: '{project-1}', key: 'SDK', name: 'SDK' },
links: {
self: { href: 'https://api.bitbucket.org/2.0/repositories/acme/demo' },
html: { href: 'https://bitbucket.org/acme/demo' },
},
}
const RAW_COMMIT = {
type: 'commit',
hash: 'abc123',
date: '2026-01-02T00:00:00Z',
message: 'Ship it',
summary: { raw: 'Ship it' },
author: { raw: 'Ada <ada@example.test>', user: RAW_USER },
committer: { raw: 'Ada <ada@example.test>', user: RAW_USER },
parents: [{ type: 'commit', hash: 'parent123' }],
links: {
self: { href: 'https://api.bitbucket.org/2.0/repositories/acme/demo/commit/abc123' },
html: { href: 'https://bitbucket.org/acme/demo/commits/abc123' },
},
}
const RAW_BRANCH = {
name: 'feature/demo',
type: 'branch',
target: RAW_COMMIT,
merge_strategies: ['merge_commit', 'squash'],
default_merge_strategy: 'merge_commit',
links: {
self: { href: 'https://api.bitbucket.org/2.0/repositories/acme/demo/refs/branches/feature' },
html: { href: 'https://bitbucket.org/acme/demo/branch/feature' },
},
}
function requestUrl<P, R>(tool: ToolConfig<P, R>, params: P): string {
return typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url
}
function requestBody<P, R>(tool: ToolConfig<P, R>, params: P): unknown {
return tool.request.body?.(params)
}
afterEach(() => {
vi.clearAllMocks()
})
describe('Bitbucket action tool contracts', () => {
it('exports the complete 30-tool action-only surface', () => {
expect(bitbucketTools.map((tool) => tool.id).sort()).toEqual(
[
'bitbucket_approve_pull_request',
'bitbucket_create_branch',
'bitbucket_create_pull_request',
'bitbucket_create_pull_request_comment',
'bitbucket_decline_pull_request',
'bitbucket_delete_branch',
'bitbucket_get_commit',
'bitbucket_get_file',
'bitbucket_get_file_metadata',
'bitbucket_get_pipeline',
'bitbucket_get_pipeline_step_log',
'bitbucket_get_pull_request',
'bitbucket_get_pull_request_diff',
'bitbucket_get_pull_request_diffstat',
'bitbucket_get_pull_request_merge_task_status',
'bitbucket_get_repository',
'bitbucket_list_branches',
'bitbucket_list_commits',
'bitbucket_list_directory',
'bitbucket_list_pipeline_steps',
'bitbucket_list_pipelines',
'bitbucket_list_pull_request_comments',
'bitbucket_list_pull_request_commit_statuses',
'bitbucket_list_pull_requests',
'bitbucket_list_repositories',
'bitbucket_list_workspaces',
'bitbucket_merge_pull_request',
'bitbucket_request_pull_request_changes',
'bitbucket_stop_pipeline',
'bitbucket_trigger_pipeline',
].sort()
)
})
it('uses hidden OAuth and the fixed Bitbucket provider on every action', () => {
for (const tool of bitbucketTools) {
expect(tool.oauth, tool.id).toMatchObject({ required: true, provider: 'bitbucket' })
expect(tool.oauth?.requiredScopes?.length, tool.id).toBeGreaterThan(0)
expect(tool.params.accessToken, tool.id).toMatchObject({
type: 'string',
required: true,
visibility: 'hidden',
})
}
})
it('enables bounded retry only on safe reads, never on mutations', () => {
for (const tool of bitbucketTools) {
const method = typeof tool.request.method === 'function' ? null : tool.request.method
if (method !== 'GET') {
expect(tool.request.retry, tool.id).toBeUndefined()
} else {
expect(tool.request.retry, tool.id).toMatchObject({
enabled: true,
maxRetries: 2,
retryIdempotentOnly: true,
})
}
}
})
})
describe('Bitbucket workspace and repository tools', () => {
it('builds workspace and repository list filters with bounded pagination', () => {
const workspaces = new URL(
requestUrl(bitbucketListWorkspacesTool, {
accessToken: 'oauth-token',
sort: 'slug',
administrator: false,
pageLen: 10,
} satisfies BitbucketListWorkspacesParams)
)
expect(workspaces.pathname).toBe('/2.0/user/workspaces')
expect(Object.fromEntries(workspaces.searchParams)).toEqual({
sort: 'slug',
administrator: 'false',
pagelen: '10',
})
const repositories = new URL(
requestUrl(bitbucketListRepositoriesTool, {
accessToken: 'oauth-token',
workspaceSlug: 'team / blue',
role: 'owner',
q: 'name ~ "sdk"',
sort: '-updated_on',
pageLen: 40,
} satisfies BitbucketListRepositoriesParams)
)
expect(repositories.pathname).toBe('/2.0/repositories/team%20%2F%20blue')
expect(Object.fromEntries(repositories.searchParams)).toEqual({
role: 'owner',
q: 'name ~ "sdk"',
sort: '-updated_on',
pagelen: '40',
})
expect(() =>
requestUrl(bitbucketListWorkspacesTool, {
accessToken: 'oauth-token',
administrator: 'false',
} as unknown as BitbucketListWorkspacesParams)
).toThrow(/administrator must be a boolean/)
expect(() =>
requestUrl(bitbucketListRepositoriesTool, {
accessToken: 'oauth-token',
workspaceSlug: 'team',
role: 'reader',
} as unknown as BitbucketListRepositoriesParams)
).toThrow(/role must be one of/)
expect(() =>
requestUrl(bitbucketListRepositoriesTool, {
accessToken: 'oauth-token',
workspaceSlug: 'team',
q: 0,
} as unknown as BitbucketListRepositoriesParams)
).toThrow(/q must be a non-empty string/)
})
it('rejects a repository cursor from another workspace', () => {
expect(() =>
requestUrl(bitbucketListRepositoriesTool, {
accessToken: 'oauth-token',
workspaceSlug: 'acme',
nextUrl: 'https://api.bitbucket.org/2.0/repositories/other?page=2',
} satisfies BitbucketListRepositoriesParams)
).toThrow(/does not belong/)
})
it('encodes repository coordinates in detail URLs', () => {
expect(requestUrl(bitbucketGetRepositoryTool, REPOSITORY_PARAMS)).toBe(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore'
)
})
it('normalizes workspace and repository responses', async () => {
const workspaceResult = await bitbucketListWorkspacesTool.transformResponse!(
Response.json({
values: [
{
type: 'workspace_access',
administrator: true,
workspace: {
slug: 'acme',
uuid: '{workspace-1}',
links: {
self: { href: 'https://api.bitbucket.org/2.0/workspaces/acme' },
avatar: { href: 'https://avatar.test/acme' },
},
},
},
],
size: 1,
page: 1,
pagelen: 20,
})
)
expect(workspaceResult.output).toEqual({
items: [
{
type: 'workspace_access',
slug: 'acme',
uuid: '{workspace-1}',
administrator: true,
selfUrl: 'https://api.bitbucket.org/2.0/workspaces/acme',
avatarUrl: 'https://avatar.test/acme',
},
],
page: { size: 1, page: 1, pageLen: 20, nextUrl: null, previousUrl: null },
})
const listResult = await bitbucketListRepositoriesTool.transformResponse!(
Response.json({ values: [RAW_REPOSITORY], size: 1, page: 1, pagelen: 20 })
)
const getResult = await bitbucketGetRepositoryTool.transformResponse!(
Response.json(RAW_REPOSITORY)
)
expect(listResult.output.items[0]).toMatchObject({
type: 'repository',
uuid: '{repo-1}',
slug: 'demo',
fullName: 'acme/demo',
mainBranch: 'main',
owner: { accountId: 'account-1', displayName: 'Ada Lovelace' },
project: { key: 'SDK' },
})
expect(getResult.output.repository).toEqual(listResult.output.items[0])
})
it('requires a non-empty resource type while preserving future type values', async () => {
await expect(
bitbucketGetRepositoryTool.transformResponse!(
Response.json({ ...RAW_REPOSITORY, type: undefined })
)
).rejects.toThrow(/repository\.type must be a non-empty string/)
await expect(
bitbucketGetRepositoryTool.transformResponse!(Response.json({ ...RAW_REPOSITORY, type: ' ' }))
).rejects.toThrow(/repository\.type must be a non-empty string/)
const future = await bitbucketGetRepositoryTool.transformResponse!(
Response.json({ ...RAW_REPOSITORY, type: 'future_repository_variant' })
)
expect(future.output.repository.type).toBe('future_repository_variant')
})
})
describe('Bitbucket source tools', () => {
it('builds encoded branch, commit, directory, and file URLs', () => {
expect(
requestUrl(bitbucketListBranchesTool, {
...REPOSITORY_PARAMS,
q: 'name ~ "feature"',
sort: '-name',
pageLen: 5,
} satisfies BitbucketListBranchesParams)
).toBe(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/refs/branches?q=name+%7E+%22feature%22&sort=-name&pagelen=5'
)
expect(
requestUrl(bitbucketDeleteBranchTool, {
...REPOSITORY_PARAMS,
name: 'feature/space ?#',
})
).toBe(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/refs/branches/feature%2Fspace%20%3F%23'
)
expect(
requestUrl(bitbucketGetCommitTool, {
...REPOSITORY_PARAMS,
commit: COMMIT_SHA.toUpperCase(),
} satisfies BitbucketGetCommitParams)
).toBe(`https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/commit/${COMMIT_SHA}`)
expect(
requestUrl(bitbucketGetFileMetadataTool, {
...REPOSITORY_PARAMS,
commit: FEATURE_SHA,
path: '/src/my file?#.ts/',
} satisfies BitbucketFileParams)
).toBe(
`https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/src/${FEATURE_SHA}/src/my%20file%3F%23.ts?format=meta`
)
expect(() =>
requestUrl(bitbucketGetCommitTool, {
...REPOSITORY_PARAMS,
commit: 'main',
} satisfies BitbucketGetCommitParams)
).toThrow(/commit must be a full 40-character SHA-1/)
expect(() =>
requestUrl(bitbucketListDirectoryTool, {
...REPOSITORY_PARAMS,
commit: 'abc123',
} satisfies BitbucketListDirectoryParams)
).toThrow(/commit must be a full 40-character SHA-1/)
expect(() =>
requestUrl(bitbucketGetFileMetadataTool, {
...REPOSITORY_PARAMS,
commit: 'feature/demo',
path: 'README.md',
} satisfies BitbucketFileParams)
).toThrow(/commit must be a full 40-character SHA-1/)
expect(() =>
requestUrl(bitbucketGetFileTool, {
...REPOSITORY_PARAMS,
commit: true,
path: 'README.md',
} as unknown as BitbucketGetFileParams)
).toThrow(/commit must be a full 40-character SHA-1/)
})
it('keeps directory listing shallow and binds its cursor to the selected path', () => {
const first = new URL(
requestUrl(bitbucketListDirectoryTool, {
...REPOSITORY_PARAMS,
commit: FEATURE_SHA,
path: 'src/my dir',
q: 'type = "commit_file"',
sort: 'path',
pageLen: 15,
} satisfies BitbucketListDirectoryParams)
)
expect(first.pathname).toBe(
`/2.0/repositories/acme%20team/sdk%2Fcore/src/${FEATURE_SHA}/src/my%20dir`
)
expect(Object.fromEntries(first.searchParams)).toEqual({
q: 'type = "commit_file"',
sort: 'path',
pagelen: '15',
})
expect(bitbucketListDirectoryTool.params).not.toHaveProperty('maxDepth')
const next = `https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/src/${FEATURE_SHA}/src/my%20dir?page=2`
expect(
requestUrl(bitbucketListDirectoryTool, {
...REPOSITORY_PARAMS,
commit: FEATURE_SHA,
path: 'src/my dir',
nextUrl: next,
} satisfies BitbucketListDirectoryParams)
).toBe(next)
expect(() =>
requestUrl(bitbucketListDirectoryTool, {
...REPOSITORY_PARAMS,
commit: FEATURE_SHA,
path: 'src/my dir',
nextUrl: `https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/src/${FEATURE_SHA}/src/other?page=2`,
} satisfies BitbucketListDirectoryParams)
).toThrow(/does not preserve/)
})
it('builds the documented create-branch body and never retries the mutation', () => {
const params = {
...REPOSITORY_PARAMS,
name: ' feature/demo ',
target: ' abc123 ',
} satisfies BitbucketCreateBranchParams
expect(requestUrl(bitbucketCreateBranchTool, params)).toBe(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/refs/branches'
)
expect(requestBody(bitbucketCreateBranchTool, params)).toEqual({
name: 'feature/demo',
target: { hash: 'abc123' },
})
expect(bitbucketCreateBranchTool.request.retry).toBeUndefined()
})
it('normalizes branch, commit, directory, and metadata responses', async () => {
const branches = await bitbucketListBranchesTool.transformResponse!(
Response.json({ values: [RAW_BRANCH] })
)
const created = await bitbucketCreateBranchTool.transformResponse!(Response.json(RAW_BRANCH))
expect(branches.output.items[0]).toMatchObject({
type: 'branch',
name: 'feature/demo',
target: { type: 'commit', hash: 'abc123', author: { accountId: 'account-1' } },
mergeStrategies: ['merge_commit', 'squash'],
})
expect(created.output.branch).toEqual(branches.output.items[0])
const commits = await bitbucketListCommitsTool.transformResponse!(
Response.json({ values: [RAW_COMMIT] })
)
const commit = await bitbucketGetCommitTool.transformResponse!(Response.json(RAW_COMMIT))
expect(commits.output.items[0]).toMatchObject({
type: 'commit',
hash: 'abc123',
summary: 'Ship it',
authorRaw: 'Ada <ada@example.test>',
parents: [{ hash: 'parent123' }],
})
expect(commit.output.commit).toEqual(commits.output.items[0])
const directory = await bitbucketListDirectoryTool.transformResponse!(
Response.json({
values: [
{
type: 'commit_file',
path: 'src/index.ts',
commit: { hash: 'abc123' },
size: 44,
attributes: ['binary'],
links: {
self: { href: 'https://api.bitbucket.org/file' },
meta: { href: 'https://api.bitbucket.org/file?format=meta' },
},
},
],
})
)
expect(directory.output.items[0]).toEqual({
type: 'commit_file',
path: 'src/index.ts',
commitHash: 'abc123',
size: 44,
attributes: ['binary'],
isBinary: true,
selfUrl: 'https://api.bitbucket.org/file',
metadataUrl: 'https://api.bitbucket.org/file?format=meta',
})
const metadata = await bitbucketGetFileMetadataTool.transformResponse!(
Response.json({
type: 'commit_file',
path: 'src/index.ts',
commit: { hash: 'abc123' },
escaped_path: 'src/index.ts',
size: 44,
})
)
expect(metadata.output.file).toMatchObject({ attributes: null, isBinary: null, size: 44 })
})
it('distinguishes absent, empty, and malformed optional source collections', async () => {
const commitWithoutParents = await bitbucketGetCommitTool.transformResponse!(
Response.json({ ...RAW_COMMIT, parents: undefined })
)
const commitWithNoParents = await bitbucketGetCommitTool.transformResponse!(
Response.json({ ...RAW_COMMIT, parents: [] })
)
expect(commitWithoutParents.output.commit.parents).toBeNull()
expect(commitWithNoParents.output.commit.parents).toEqual([])
await expect(
bitbucketGetCommitTool.transformResponse!(Response.json({ ...RAW_COMMIT, parents: null }))
).rejects.toThrow(/commit\.parents must be an array when present/)
await expect(
bitbucketGetCommitTool.transformResponse!(
Response.json({ ...RAW_COMMIT, parents: [{ hash: 'missing-type' }] })
)
).rejects.toThrow(/commit\.parents\[0\]\.type must be a non-empty string/)
const branchWithoutStrategies = await bitbucketCreateBranchTool.transformResponse!(
Response.json({ ...RAW_BRANCH, merge_strategies: undefined })
)
const branchWithNoStrategies = await bitbucketCreateBranchTool.transformResponse!(
Response.json({ ...RAW_BRANCH, merge_strategies: [] })
)
expect(branchWithoutStrategies.output.branch.mergeStrategies).toBeNull()
expect(branchWithNoStrategies.output.branch.mergeStrategies).toEqual([])
await expect(
bitbucketCreateBranchTool.transformResponse!(
Response.json({ ...RAW_BRANCH, merge_strategies: ['future_strategy', 7] })
)
).rejects.toThrow(/merge_strategies\[1\] must be a string/)
const futureStrategy = await bitbucketCreateBranchTool.transformResponse!(
Response.json({ ...RAW_BRANCH, merge_strategies: ['future_strategy'] })
)
expect(futureStrategy.output.branch.mergeStrategies).toEqual(['future_strategy'])
})
it('normalizes the exact commit_file metadata shape and rejects other source objects', async () => {
const futureAttribute = await bitbucketGetFileMetadataTool.transformResponse!(
Response.json({
type: 'commit_file',
path: 'src/index.ts',
commit: { hash: 'abc123' },
escaped_path: 'src/index.ts',
size: null,
attributes: ['future_attribute'],
})
)
expect(futureAttribute.output.file).toEqual({
type: 'commit_file',
path: 'src/index.ts',
commitHash: 'abc123',
escapedPath: 'src/index.ts',
size: null,
attributes: ['future_attribute'],
isBinary: false,
})
await expect(
bitbucketGetFileMetadataTool.transformResponse!(
Response.json({ type: 'commit_directory', path: 'src' })
)
).rejects.toThrow(/directory; use list_directory/)
await expect(
bitbucketGetFileMetadataTool.transformResponse!(
Response.json({ type: 'commit_file', path: 'src/index.ts', attributes: 'binary' })
)
).resolves.toMatchObject({
output: { file: { attributes: ['binary'], isBinary: true } },
})
await expect(
bitbucketGetFileMetadataTool.transformResponse!(
Response.json({ type: 'commit_file', path: 'src/index.ts', attributes: ['binary', 7] })
)
).rejects.toThrow(/metadata\.attributes\[1\] must be a string/)
await expect(
bitbucketGetFileMetadataTool.transformResponse!(
Response.json({ type: 'commit_file', path: 'src/index.ts', size: '44' })
)
).rejects.toThrow(/metadata\.size must be a finite number or null/)
})
it('preserves future directory-entry types and attributes without enum gating', async () => {
const result = await bitbucketListDirectoryTool.transformResponse!(
Response.json({
values: [
{
type: 'future_source_entry',
path: 'src/new-kind',
attributes: ['future_attribute'],
},
{ type: 'commit_file', path: 'empty.txt', attributes: [] },
{ type: 'commit_directory', path: 'src' },
],
})
)
expect(result.output.items[0]).toMatchObject({
type: 'future_source_entry',
attributes: ['future_attribute'],
isBinary: false,
})
expect(result.output.items[1]).toMatchObject({ attributes: [], isBinary: false })
expect(result.output.items[2]).toMatchObject({ attributes: null, isBinary: null })
await expect(
bitbucketListDirectoryTool.transformResponse!(
Response.json({
values: [{ type: 'commit_file', path: 'bad.txt', attributes: ['executable', 7] }],
})
)
).rejects.toThrow(/directory entry\.attributes\[1\] must be a string/)
})
it('treats successful destructive 204 responses as completion', async () => {
const result = await bitbucketDeleteBranchTool.transformResponse!(
new Response(null, { status: 204 })
)
expect(result).toEqual({ success: true, output: { deleted: true } })
for (const status of [200, 202, 205]) {
await expect(
bitbucketDeleteBranchTool.transformResponse!(new Response(null, { status }))
).rejects.toThrow(`unexpected HTTP ${status}`)
}
})
it('preflights file metadata and returns no raw bytes for a documented binary file', async () => {
serverMocks.secureBitbucketRead.mockResolvedValueOnce(
Response.json({
type: 'commit_file',
path: 'assets/logo.png',
commit: { hash: 'abc123' },
size: 1_024,
attributes: ['binary'],
})
)
const params = {
...REPOSITORY_PARAMS,
commit: FEATURE_SHA,
path: 'assets/logo.png',
} satisfies BitbucketGetFileParams
const result = await bitbucketGetFileTool.directExecution!(params)
expect(result).toEqual({
success: true,
output: {
content: null,
binary: true,
truncated: true,
returnedBytes: 0,
fullBytes: 1_024,
contentType: null,
},
})
expect(serverMocks.secureBitbucketRead).toHaveBeenCalledTimes(1)
expect(serverMocks.secureBitbucketRead).toHaveBeenCalledWith(
`https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/src/${FEATURE_SHA}/assets/logo.png?format=meta`,
expect.objectContaining({ Authorization: 'Bearer oauth-token' }),
256 * 1024,
{ signal: undefined }
)
})
it.each([
{ size: 0, truncated: false, fullBytes: 0 },
{ size: undefined, truncated: null, fullBytes: null },
])(
'reports binary size $size as truncated=$truncated',
async ({ size, truncated, fullBytes }) => {
serverMocks.secureBitbucketRead.mockResolvedValueOnce(
Response.json({
type: 'commit_file',
path: 'assets/logo.png',
commit: { hash: 'abc123' },
...(size !== undefined ? { size } : {}),
attributes: ['binary'],
})
)
const result = await bitbucketGetFileTool.directExecution!({
...REPOSITORY_PARAMS,
commit: COMMIT_SHA,
path: 'assets/logo.png',
})
expect(result.output).toEqual({
content: null,
binary: true,
truncated,
returnedBytes: 0,
fullBytes,
contentType: null,
})
expect(serverMocks.secureBitbucketRead).toHaveBeenCalledTimes(1)
}
)
it('rejects directory metadata without making a raw-content request', async () => {
serverMocks.secureBitbucketRead.mockResolvedValueOnce(
Response.json({ type: 'commit_directory', path: 'src' })
)
await expect(
bitbucketGetFileTool.directExecution!({
...REPOSITORY_PARAMS,
commit: COMMIT_SHA,
path: 'src',
})
).rejects.toThrow(/directory; use list_directory/)
expect(serverMocks.secureBitbucketRead).toHaveBeenCalledTimes(1)
})
it('validates maxCharacters before the metadata preflight', async () => {
await expect(
bitbucketGetFileTool.directExecution!({
...REPOSITORY_PARAMS,
commit: COMMIT_SHA,
path: 'README.md',
maxCharacters: 0,
})
).rejects.toThrow(/maxCharacters must be an integer between 1 and 500000/)
expect(serverMocks.secureBitbucketRead).not.toHaveBeenCalled()
await expect(
bitbucketGetFileTool.directExecution!({
...REPOSITORY_PARAMS,
commit: 'main',
path: 'README.md',
})
).rejects.toThrow(/commit must be a full 40-character SHA-1/)
expect(serverMocks.secureBitbucketRead).not.toHaveBeenCalled()
expect(bitbucketGetFileTool.outputs?.truncated).toMatchObject({
type: 'boolean',
nullable: true,
})
})
it('reads a bounded raw file when metadata says text or remains unknown', async () => {
serverMocks.secureBitbucketRead
.mockResolvedValueOnce(
Response.json({
type: 'commit_file',
path: 'src/my file.ts',
commit: { hash: 'abc123' },
size: 5,
})
)
.mockResolvedValueOnce(
new Response('hello', {
headers: { 'Content-Length': '5', 'Content-Type': 'text/plain' },
})
)
const params = {
...REPOSITORY_PARAMS,
commit: COMMIT_SHA,
path: 'src/my file.ts',
maxCharacters: 4,
} satisfies BitbucketGetFileParams
const result = await bitbucketGetFileTool.directExecution!(params)
expect(result).toEqual({
success: true,
output: {
content: 'hell',
binary: null,
truncated: true,
returnedBytes: 5,
fullBytes: 5,
contentType: 'text/plain',
},
})
expect(serverMocks.secureBitbucketRead).toHaveBeenNthCalledWith(
2,
`https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/src/${COMMIT_SHA}/src/my%20file.ts`,
expect.objectContaining({
Accept: '*/*',
Authorization: 'Bearer oauth-token',
Range: 'bytes=0-15',
}),
10 * 1024 * 1024,
{ stripAuthOnRedirect: true, signal: undefined }
)
})
it('uses metadata size when raw bytes reveal binary content with an unknown range total', async () => {
serverMocks.secureBitbucketRead
.mockResolvedValueOnce(
Response.json({
type: 'commit_file',
path: 'unknown.bin',
commit: { hash: COMMIT_SHA },
size: 3,
})
)
.mockResolvedValueOnce(
new Response(new Uint8Array([65, 0, 66]), {
status: 206,
headers: { 'Content-Range': 'bytes 0-2/*' },
})
)
const result = await bitbucketGetFileTool.directExecution!({
...REPOSITORY_PARAMS,
commit: COMMIT_SHA,
path: 'unknown.bin',
})
expect(result.output).toMatchObject({
content: null,
binary: true,
truncated: true,
returnedBytes: 3,
fullBytes: 3,
})
})
it('uses the normal HTTP path only as a guarded executor fallback', async () => {
expect(bitbucketGetFileTool.request.stripAuthOnRedirect).toBe(true)
await expect(
bitbucketGetFileTool.transformResponse!(new Response('content'), {
...REPOSITORY_PARAMS,
commit: COMMIT_SHA,
path: 'README.md',
})
).rejects.toThrow(/metadata preflight direct execution path/)
})
it('builds the list-commits endpoint with its opaque cursor bound', () => {
const first = requestUrl(bitbucketListCommitsTool, {
...REPOSITORY_PARAMS,
pageLen: 30,
} satisfies BitbucketListCommitsParams)
expect(first).toBe(
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/commits?pagelen=30'
)
expect(() =>
requestUrl(bitbucketListCommitsTool, {
...REPOSITORY_PARAMS,
nextUrl:
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pipelines?page=2',
} satisfies BitbucketListCommitsParams)
).toThrow(/does not belong/)
})
it('sets JSON content headers only on JSON mutations', () => {
const createHeaders = bitbucketCreateBranchTool.request.headers({
...REPOSITORY_PARAMS,
name: 'feature',
target: 'main',
})
const getHeaders = bitbucketGetRepositoryTool.request.headers(
REPOSITORY_PARAMS satisfies BitbucketRepositoryParams
)
expect(createHeaders).toMatchObject({
Accept: 'application/json',
Authorization: 'Bearer oauth-token',
'Content-Type': 'application/json',
})
expect(getHeaders).not.toHaveProperty('Content-Type')
})
})
@@ -0,0 +1,46 @@
import {
BITBUCKET_PARTICIPANT_OUTPUT_PROPERTIES,
type BitbucketParticipant,
type BitbucketPullRequestParams,
type BitbucketToolResponse,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_PULL_REQUEST_PARAMS,
bitbucketHeaders,
bitbucketJson,
bitbucketPullRequestPath,
normalizeBitbucketParticipant,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
export const bitbucketRequestPullRequestChangesTool: ToolConfig<
BitbucketPullRequestParams,
BitbucketToolResponse<{ participant: BitbucketParticipant }>
> = {
id: 'bitbucket_request_pull_request_changes',
name: 'Bitbucket Request Pull Request Changes',
description: 'Request changes on a pull request as the authenticated account',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pullrequest:write'] },
params: { ...BITBUCKET_PULL_REQUEST_PARAMS },
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/request-changes`,
method: 'POST',
headers: (params) => bitbucketHeaders(params.accessToken),
},
transformResponse: async (response) => ({
success: true,
output: { participant: normalizeBitbucketParticipant(await bitbucketJson(response)) },
}),
outputs: {
participant: {
type: 'object',
description: 'Change-request participant record',
properties: BITBUCKET_PARTICIPANT_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
+44
View File
@@ -0,0 +1,44 @@
import type { BitbucketPipelineParams, BitbucketToolResponse } from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketHeaders,
bitbucketRepositoryPath,
encodeBitbucketSegment,
} from '@/tools/bitbucket/utils'
import type { ToolConfig } from '@/tools/types'
export const bitbucketStopPipelineTool: ToolConfig<
BitbucketPipelineParams,
BitbucketToolResponse<{ stopped: boolean }>
> = {
id: 'bitbucket_stop_pipeline',
name: 'Bitbucket Stop Pipeline',
description: 'Stop a running pipeline',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pipeline:write'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
pipelineUuid: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Pipeline UUID',
},
},
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/pipelines/${encodeBitbucketSegment(params.pipelineUuid, 'pipelineUuid')}/stopPipeline`,
method: 'POST',
headers: (params) => bitbucketHeaders(params.accessToken),
},
transformResponse: async (response) => {
if (response.status !== 204) {
throw new Error(`Bitbucket pipeline stop returned unexpected HTTP ${response.status}`)
}
return { success: true, output: { stopped: true } }
},
outputs: { stopped: { type: 'boolean', description: 'Whether the stop request succeeded' } },
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
@@ -0,0 +1,81 @@
import {
BITBUCKET_PIPELINE_OUTPUT_PROPERTIES,
type BitbucketPipeline,
type BitbucketToolResponse,
type BitbucketTriggerPipelineParams,
} from '@/tools/bitbucket/types'
import {
BITBUCKET_API_BASE,
BITBUCKET_ERROR_EXTRACTOR,
BITBUCKET_REPOSITORY_PARAMS,
bitbucketHeaders,
bitbucketJson,
bitbucketRepositoryPath,
normalizeBitbucketPipeline,
requireBitbucketString,
} from '@/tools/bitbucket/utils'
import { optionalBitbucketSha1, requireBitbucketEnum } from '@/tools/bitbucket/validation'
import type { ToolConfig } from '@/tools/types'
const BITBUCKET_PIPELINE_REF_TYPES = ['branch', 'tag', 'named_branch', 'bookmark'] as const
export const bitbucketTriggerPipelineTool: ToolConfig<
BitbucketTriggerPipelineParams,
BitbucketToolResponse<{ pipeline: BitbucketPipeline }>
> = {
id: 'bitbucket_trigger_pipeline',
name: 'Bitbucket Trigger Pipeline',
description: 'Run the repository pipeline selected by a branch or ref target',
version: '1.0.0',
oauth: { required: true, provider: 'bitbucket', requiredScopes: ['pipeline'] },
params: {
...BITBUCKET_REPOSITORY_PARAMS,
refType: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Reference type: branch, tag, named_branch, or bookmark',
},
refName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Reference name',
},
commitHash: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Full 40-character commit SHA-1 to run in the reference context',
},
},
request: {
url: (params) =>
`${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/pipelines`,
method: 'POST',
headers: (params) => bitbucketHeaders(params.accessToken, { json: true }),
body: (params) => {
const commitHash = optionalBitbucketSha1(params.commitHash, 'commitHash')
return {
target: {
type: 'pipeline_ref_target',
ref_type: requireBitbucketEnum(params.refType, 'refType', BITBUCKET_PIPELINE_REF_TYPES),
ref_name: requireBitbucketString(params.refName, 'refName'),
...(commitHash !== undefined ? { commit: { type: 'commit', hash: commitHash } } : {}),
},
}
},
},
transformResponse: async (response) => ({
success: true,
output: { pipeline: normalizeBitbucketPipeline(await bitbucketJson(response)) },
}),
outputs: {
pipeline: {
type: 'object',
description: 'Triggered pipeline',
properties: BITBUCKET_PIPELINE_OUTPUT_PROPERTIES,
},
},
errorExtractor: BITBUCKET_ERROR_EXTRACTOR,
}
+864
View File
@@ -0,0 +1,864 @@
import type { ToolOutputProperty } from '@/tools/types'
export interface BitbucketAuthParams {
accessToken: string
}
export interface BitbucketPaginationParams {
nextUrl?: string
pageLen?: number
}
export interface BitbucketRepositoryParams extends BitbucketAuthParams {
workspaceSlug: string
repoSlug: string
}
export interface BitbucketPullRequestParams extends BitbucketRepositoryParams {
prId: number
}
export interface BitbucketListWorkspacesParams
extends BitbucketAuthParams,
BitbucketPaginationParams {
sort?: string
administrator?: boolean
}
export interface BitbucketListRepositoriesParams
extends BitbucketAuthParams,
BitbucketPaginationParams {
workspaceSlug: string
role?: 'admin' | 'contributor' | 'member' | 'owner'
q?: string
sort?: string
}
export interface BitbucketListBranchesParams
extends BitbucketRepositoryParams,
BitbucketPaginationParams {
q?: string
sort?: string
}
export interface BitbucketCreateBranchParams extends BitbucketRepositoryParams {
name: string
target: string
}
export interface BitbucketDeleteBranchParams extends BitbucketRepositoryParams {
name: string
}
export interface BitbucketListCommitsParams
extends BitbucketRepositoryParams,
BitbucketPaginationParams {}
export interface BitbucketGetCommitParams extends BitbucketRepositoryParams {
commit: string
}
export interface BitbucketListDirectoryParams
extends BitbucketRepositoryParams,
BitbucketPaginationParams {
commit: string
path?: string
q?: string
sort?: string
}
export interface BitbucketFileParams extends BitbucketRepositoryParams {
commit: string
path: string
}
export interface BitbucketGetFileParams extends BitbucketFileParams {
maxCharacters?: number
}
export interface BitbucketListPullRequestsParams
extends BitbucketRepositoryParams,
BitbucketPaginationParams {
state?: 'OPEN' | 'MERGED' | 'DECLINED' | 'SUPERSEDED'
q?: string
sort?: string
}
export interface BitbucketCreatePullRequestParams extends BitbucketRepositoryParams {
title: string
sourceBranch: string
destinationBranch: string
description?: string
closeSourceBranch?: boolean
draft?: boolean
reviewerUuids?: string[]
}
export type BitbucketMergeStrategy =
| 'merge_commit'
| 'squash'
| 'fast_forward'
| 'squash_fast_forward'
| 'rebase_fast_forward'
| 'rebase_merge'
export interface BitbucketMergePullRequestParams extends BitbucketPullRequestParams {
mergeStrategy?: BitbucketMergeStrategy
message?: string
closeSourceBranch?: boolean
}
export interface BitbucketGetMergeTaskStatusParams extends BitbucketPullRequestParams {
taskId: string
}
export interface BitbucketGetPullRequestDiffParams extends BitbucketPullRequestParams {
path: string
maxCharacters?: number
}
export interface BitbucketPaginatedPullRequestParams
extends BitbucketPullRequestParams,
BitbucketPaginationParams {}
export interface BitbucketListPullRequestCommentsParams
extends BitbucketPaginatedPullRequestParams {
q?: string
sort?: string
}
export interface BitbucketCreatePullRequestCommentParams extends BitbucketPullRequestParams {
content: string
parentId?: number
}
export interface BitbucketListPullRequestCommitStatusesParams
extends BitbucketPaginatedPullRequestParams {
q?: string
sort?: string
}
export type BitbucketPipelineListRefType = 'BRANCH' | 'TAG' | 'ANNOTATED_TAG'
export type BitbucketPipelineListSelectorType =
| 'BRANCH'
| 'TAG'
| 'CUSTOM'
| 'PULLREQUESTS'
| 'DEFAULT'
export type BitbucketPipelineTriggerType = 'PUSH' | 'MANUAL' | 'SCHEDULED' | 'PARENT_STEP'
export type BitbucketPipelineStatus =
| 'PARSING'
| 'PENDING'
| 'PAUSED'
| 'HALTED'
| 'BUILDING'
| 'ERROR'
| 'PASSED'
| 'FAILED'
| 'STOPPED'
| 'UNKNOWN'
export interface BitbucketListPipelinesParams
extends BitbucketRepositoryParams,
BitbucketPaginationParams {
refType?: BitbucketPipelineListRefType
refName?: string
commitHash?: string
selectorType?: BitbucketPipelineListSelectorType
selectorPattern?: string
triggerType?: BitbucketPipelineTriggerType
status?: BitbucketPipelineStatus
sort?: 'creator.uuid' | 'created_on' | 'run_creation_date'
}
export interface BitbucketPipelineParams extends BitbucketRepositoryParams {
pipelineUuid: string
}
export interface BitbucketTriggerPipelineParams extends BitbucketRepositoryParams {
refType: 'branch' | 'tag' | 'named_branch' | 'bookmark'
refName: string
commitHash?: string
}
export interface BitbucketListPipelineStepsParams
extends BitbucketPipelineParams,
BitbucketPaginationParams {}
export interface BitbucketGetPipelineStepLogParams extends BitbucketPipelineParams {
stepUuid: string
maxCharacters?: number
}
export interface BitbucketPage {
size: number | null
page: number | null
pageLen: number | null
nextUrl: string | null
previousUrl: string | null
}
export interface BitbucketUser {
type: string
uuid: string | null
accountId: string | null
displayName: string | null
createdOn: string | null
selfUrl: string | null
htmlUrl: string | null
avatarUrl: string | null
}
export interface BitbucketWorkspaceAccess {
type: string
slug: string | null
uuid: string | null
administrator: boolean | null
selfUrl: string | null
avatarUrl: string | null
}
export interface BitbucketRepository {
type: string
uuid: string | null
slug: string | null
name: string | null
fullName: string | null
description: string | null
isPrivate: boolean | null
scm: string | null
language: string | null
size: number | null
createdOn: string | null
updatedOn: string | null
mainBranch: string | null
owner: BitbucketUser | null
project: {
uuid: string | null
key: string | null
name: string | null
} | null
selfUrl: string | null
htmlUrl: string | null
}
export interface BitbucketCommit {
type: string
hash: string | null
date: string | null
message: string | null
summary: string | null
authorRaw: string | null
author: BitbucketUser | null
committerRaw: string | null
committer: BitbucketUser | null
parents: Array<{ hash: string | null }> | null
selfUrl: string | null
htmlUrl: string | null
}
export interface BitbucketBranch {
type: string
name: string | null
target: BitbucketCommit | null
mergeStrategies: string[] | null
defaultMergeStrategy: string | null
selfUrl: string | null
htmlUrl: string | null
}
export interface BitbucketDirectoryEntry {
type: string
path: string | null
commitHash: string | null
size: number | null
attributes: string[] | null
isBinary: boolean | null
selfUrl: string | null
metadataUrl: string | null
}
export interface BitbucketFileMetadata {
type: 'commit_file'
path: string | null
commitHash: string | null
escapedPath: string | null
size: number | null
attributes: string[] | null
isBinary: boolean | null
}
export interface BitbucketPullRequestEndpoint {
branchName: string | null
commitHash: string | null
repositoryUuid: string | null
repositoryFullName: string | null
}
export interface BitbucketParticipant {
type: string
user: BitbucketUser | null
role: string | null
approved: boolean | null
state: string | null
participatedOn: string | null
}
export interface BitbucketPullRequest {
type: string
id: number | null
title: string | null
description: string | null
state: string | null
draft: boolean | null
queued: boolean | null
author: BitbucketUser | null
closedBy: BitbucketUser | null
source: BitbucketPullRequestEndpoint | null
destination: BitbucketPullRequestEndpoint | null
mergeCommitHash: string | null
commentCount: number | null
taskCount: number | null
closeSourceBranch: boolean | null
reason: string | null
createdOn: string | null
updatedOn: string | null
reviewers: BitbucketUser[] | null
participants: BitbucketParticipant[] | null
selfUrl: string | null
htmlUrl: string | null
}
export interface BitbucketComment {
type: string
id: number | null
createdOn: string | null
updatedOn: string | null
content: string | null
user: BitbucketUser | null
deleted: boolean | null
parentId: number | null
inline: {
path: string | null
from: number | null
to: number | null
startFrom: number | null
startTo: number | null
} | null
pending: boolean | null
resolution: {
resolver: BitbucketUser | null
resolvedOn: string | null
} | null
selfUrl: string | null
htmlUrl: string | null
}
export interface BitbucketCommitStatus {
type: string
key: string | null
refName: string | null
url: string | null
state: string | null
name: string | null
description: string | null
createdOn: string | null
updatedOn: string | null
selfUrl: string | null
commitUrl: string | null
}
export interface BitbucketDiffstat {
type: string
status: string | null
linesAdded: number | null
linesRemoved: number | null
oldPath: string | null
newPath: string | null
oldCommitHash: string | null
newCommitHash: string | null
}
export interface BitbucketPipeline {
type: string
uuid: string | null
buildNumber: number | null
creator: BitbucketUser | null
repositoryFullName: string | null
target: {
type: string | null
refType: string | null
refName: string | null
commitHash: string | null
selectorType: string | null
selectorPattern: string | null
} | null
triggerType: string | null
state: {
name: string | null
stage: string | null
result: string | null
errorKey: string | null
errorMessage: string | null
} | null
createdOn: string | null
completedOn: string | null
buildSecondsUsed: number | null
selfUrl: string | null
stepsUrl: string | null
}
export interface BitbucketPipelineStep {
type: string
uuid: string | null
startedOn: string | null
completedOn: string | null
state: {
name: string | null
result: string | null
errorKey: string | null
errorMessage: string | null
} | null
imageName: string | null
setupCommands: Array<{ name: string | null; command: string | null }> | null
scriptCommands: Array<{ name: string | null; command: string | null }> | null
}
export interface BitbucketListOutput<T> {
items: T[]
page: BitbucketPage
}
export interface BitbucketToolResponse<T> {
success: true
output: T
}
export const BITBUCKET_USER_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Bitbucket account object type' },
uuid: { type: 'string', description: 'Bitbucket account UUID', nullable: true },
accountId: { type: 'string', description: 'Atlassian account ID', nullable: true },
displayName: { type: 'string', description: 'Account display name', nullable: true },
createdOn: { type: 'string', description: 'Account creation timestamp', nullable: true },
selfUrl: { type: 'string', description: 'Account API URL', nullable: true },
htmlUrl: { type: 'string', description: 'Account web URL', nullable: true },
avatarUrl: { type: 'string', description: 'Account avatar URL', nullable: true },
}
export const BITBUCKET_WORKSPACE_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Bitbucket workspace-access object type' },
slug: { type: 'string', description: 'Workspace slug', nullable: true },
uuid: { type: 'string', description: 'Workspace UUID', nullable: true },
administrator: {
type: 'boolean',
description: 'Whether the caller administers the workspace',
nullable: true,
},
selfUrl: { type: 'string', description: 'Workspace API URL', nullable: true },
avatarUrl: { type: 'string', description: 'Workspace avatar URL', nullable: true },
}
export const BITBUCKET_REPOSITORY_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Bitbucket repository object type' },
uuid: { type: 'string', description: 'Repository UUID', nullable: true },
slug: { type: 'string', description: 'Repository slug', nullable: true },
name: { type: 'string', description: 'Repository name', nullable: true },
fullName: { type: 'string', description: 'Workspace and repository full name', nullable: true },
description: { type: 'string', description: 'Repository description', nullable: true },
isPrivate: { type: 'boolean', description: 'Whether the repository is private', nullable: true },
scm: { type: 'string', description: 'Source control system', nullable: true },
language: { type: 'string', description: 'Primary repository language', nullable: true },
size: { type: 'number', description: 'Repository size in bytes', nullable: true },
createdOn: { type: 'string', description: 'Repository creation timestamp', nullable: true },
updatedOn: { type: 'string', description: 'Repository update timestamp', nullable: true },
mainBranch: { type: 'string', description: 'Main branch name', nullable: true },
owner: {
type: 'object',
description: 'Repository owner',
nullable: true,
properties: BITBUCKET_USER_OUTPUT_PROPERTIES,
},
project: {
type: 'object',
description: 'Containing Bitbucket project',
nullable: true,
properties: {
uuid: { type: 'string', description: 'Project UUID', nullable: true },
key: { type: 'string', description: 'Project key', nullable: true },
name: { type: 'string', description: 'Project name', nullable: true },
},
},
selfUrl: { type: 'string', description: 'Repository API URL', nullable: true },
htmlUrl: { type: 'string', description: 'Repository web URL', nullable: true },
}
export const BITBUCKET_COMMIT_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Bitbucket commit object type' },
hash: { type: 'string', description: 'Commit hash', nullable: true },
date: { type: 'string', description: 'Commit timestamp', nullable: true },
message: { type: 'string', description: 'Full commit message', nullable: true },
summary: { type: 'string', description: 'Raw commit summary', nullable: true },
authorRaw: { type: 'string', description: 'Raw author value stored by Git', nullable: true },
author: {
type: 'object',
description: 'Matched Bitbucket account, when available',
nullable: true,
properties: BITBUCKET_USER_OUTPUT_PROPERTIES,
},
committerRaw: {
type: 'string',
description: 'Raw committer value stored by Git',
nullable: true,
},
committer: {
type: 'object',
description: 'Matched Bitbucket committer account, when available',
nullable: true,
properties: BITBUCKET_USER_OUTPUT_PROPERTIES,
},
parents: {
type: 'array',
description: 'Parent commits',
nullable: true,
items: {
type: 'object',
properties: {
hash: { type: 'string', description: 'Parent commit hash', nullable: true },
},
},
},
selfUrl: { type: 'string', description: 'Commit API URL', nullable: true },
htmlUrl: { type: 'string', description: 'Commit web URL', nullable: true },
}
export const BITBUCKET_BRANCH_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Bitbucket branch object type' },
name: { type: 'string', description: 'Branch name', nullable: true },
target: {
type: 'object',
description: 'Commit targeted by the branch',
nullable: true,
properties: BITBUCKET_COMMIT_OUTPUT_PROPERTIES,
},
mergeStrategies: {
type: 'array',
description: 'Merge strategies available for the branch',
nullable: true,
items: { type: 'string' },
},
defaultMergeStrategy: { type: 'string', description: 'Default merge strategy', nullable: true },
selfUrl: { type: 'string', description: 'Branch API URL', nullable: true },
htmlUrl: { type: 'string', description: 'Branch web URL', nullable: true },
}
export const BITBUCKET_DIRECTORY_ENTRY_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: {
type: 'string',
description: 'Entry type, such as commit_file or commit_directory',
},
path: { type: 'string', description: 'Repository-relative path', nullable: true },
commitHash: { type: 'string', description: 'Resolved commit hash', nullable: true },
size: {
type: 'number',
description: 'File size in bytes when the entry is a file',
nullable: true,
},
attributes: {
type: 'array',
description: 'File attributes when the entry is a file',
nullable: true,
items: { type: 'string' },
},
isBinary: {
type: 'boolean',
description: 'Whether file attributes include the binary marker',
nullable: true,
},
selfUrl: { type: 'string', description: 'Source API URL', nullable: true },
metadataUrl: { type: 'string', description: 'Source metadata API URL', nullable: true },
}
export const BITBUCKET_FILE_METADATA_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Entry type (commit_file)' },
path: { type: 'string', description: 'Repository-relative path', nullable: true },
commitHash: { type: 'string', description: 'Resolved commit hash', nullable: true },
escapedPath: { type: 'string', description: 'Escaped display path', nullable: true },
size: { type: 'number', description: 'File size in bytes', nullable: true },
attributes: {
type: 'array',
description: 'File attributes reported by Bitbucket',
nullable: true,
items: { type: 'string' },
},
isBinary: {
type: 'boolean',
description: 'Whether the documented attributes include the binary marker',
nullable: true,
},
}
export const BITBUCKET_PARTICIPANT_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Bitbucket participant object type' },
user: {
type: 'object',
description: 'Participating account',
nullable: true,
properties: BITBUCKET_USER_OUTPUT_PROPERTIES,
},
role: { type: 'string', description: 'Participant role', nullable: true },
approved: { type: 'boolean', description: 'Whether the participant approved', nullable: true },
state: { type: 'string', description: 'Review state', nullable: true },
participatedOn: {
type: 'string',
description: 'Timestamp of the participant action',
nullable: true,
},
}
export const BITBUCKET_PR_ENDPOINT_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
branchName: { type: 'string', description: 'Branch name', nullable: true },
commitHash: { type: 'string', description: 'Commit hash', nullable: true },
repositoryUuid: { type: 'string', description: 'Repository UUID', nullable: true },
repositoryFullName: { type: 'string', description: 'Repository full name', nullable: true },
}
export const BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Bitbucket pull request object type' },
id: { type: 'number', description: 'Repository-scoped pull request ID', nullable: true },
title: { type: 'string', description: 'Pull request title', nullable: true },
description: { type: 'string', description: 'Pull request description', nullable: true },
state: { type: 'string', description: 'Pull request state', nullable: true },
draft: { type: 'boolean', description: 'Whether the pull request is a draft', nullable: true },
queued: { type: 'boolean', description: 'Whether the pull request is queued', nullable: true },
author: {
type: 'object',
description: 'Pull request author',
nullable: true,
properties: BITBUCKET_USER_OUTPUT_PROPERTIES,
},
closedBy: {
type: 'object',
description: 'Account that closed the pull request',
nullable: true,
properties: BITBUCKET_USER_OUTPUT_PROPERTIES,
},
source: {
type: 'object',
description: 'Source endpoint',
nullable: true,
properties: BITBUCKET_PR_ENDPOINT_OUTPUT_PROPERTIES,
},
destination: {
type: 'object',
description: 'Destination endpoint',
nullable: true,
properties: BITBUCKET_PR_ENDPOINT_OUTPUT_PROPERTIES,
},
mergeCommitHash: { type: 'string', description: 'Merge commit hash', nullable: true },
commentCount: { type: 'number', description: 'Comment count', nullable: true },
taskCount: { type: 'number', description: 'Open task count', nullable: true },
closeSourceBranch: {
type: 'boolean',
description: 'Whether merging closes the source branch',
nullable: true,
},
reason: { type: 'string', description: 'Reason the pull request was declined', nullable: true },
createdOn: { type: 'string', description: 'Creation timestamp', nullable: true },
updatedOn: { type: 'string', description: 'Update timestamp', nullable: true },
reviewers: {
type: 'array',
description: 'Explicit reviewers',
nullable: true,
items: { type: 'object', properties: BITBUCKET_USER_OUTPUT_PROPERTIES },
},
participants: {
type: 'array',
description: 'Pull request participants',
nullable: true,
items: { type: 'object', properties: BITBUCKET_PARTICIPANT_OUTPUT_PROPERTIES },
},
selfUrl: { type: 'string', description: 'Pull request API URL', nullable: true },
htmlUrl: { type: 'string', description: 'Pull request web URL', nullable: true },
}
export const BITBUCKET_COMMENT_RESOLUTION_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
resolver: {
type: 'object',
description: 'Account that resolved the comment',
nullable: true,
properties: BITBUCKET_USER_OUTPUT_PROPERTIES,
},
resolvedOn: { type: 'string', description: 'Resolution timestamp', nullable: true },
}
export const BITBUCKET_COMMENT_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Bitbucket comment object type' },
id: { type: 'number', description: 'Comment ID', nullable: true },
createdOn: { type: 'string', description: 'Creation timestamp', nullable: true },
updatedOn: { type: 'string', description: 'Update timestamp', nullable: true },
content: { type: 'string', description: 'Raw comment content', nullable: true },
user: {
type: 'object',
description: 'Comment author',
nullable: true,
properties: BITBUCKET_USER_OUTPUT_PROPERTIES,
},
deleted: { type: 'boolean', description: 'Whether the comment was deleted', nullable: true },
parentId: { type: 'number', description: 'Parent comment ID', nullable: true },
inline: {
type: 'object',
description: 'Inline comment anchor',
nullable: true,
properties: {
path: { type: 'string', description: 'Anchored file path', nullable: true },
from: { type: 'number', description: 'Ending line in the old file', nullable: true },
to: { type: 'number', description: 'Ending line in the new file', nullable: true },
startFrom: { type: 'number', description: 'Starting line in the old file', nullable: true },
startTo: { type: 'number', description: 'Starting line in the new file', nullable: true },
},
},
pending: { type: 'boolean', description: 'Whether the comment is pending', nullable: true },
resolution: {
type: 'object',
description: 'Comment resolution details',
nullable: true,
properties: BITBUCKET_COMMENT_RESOLUTION_OUTPUT_PROPERTIES,
},
selfUrl: { type: 'string', description: 'Comment API URL', nullable: true },
htmlUrl: { type: 'string', description: 'Comment web URL', nullable: true },
}
export const BITBUCKET_COMMIT_STATUS_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Bitbucket commit-status object type' },
key: { type: 'string', description: 'Vendor-unique status key', nullable: true },
refName: {
type: 'string',
description: 'Reference name at status creation time',
nullable: true,
},
url: { type: 'string', description: 'External build URL', nullable: true },
state: { type: 'string', description: 'Commit status state', nullable: true },
name: { type: 'string', description: 'Build name', nullable: true },
description: { type: 'string', description: 'Build description', nullable: true },
createdOn: { type: 'string', description: 'Creation timestamp', nullable: true },
updatedOn: { type: 'string', description: 'Update timestamp', nullable: true },
selfUrl: { type: 'string', description: 'Status API URL', nullable: true },
commitUrl: { type: 'string', description: 'Commit API URL', nullable: true },
}
export const BITBUCKET_DIFFSTAT_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Diffstat object type' },
status: { type: 'string', description: 'File change status', nullable: true },
linesAdded: { type: 'number', description: 'Lines added', nullable: true },
linesRemoved: { type: 'number', description: 'Lines removed', nullable: true },
oldPath: { type: 'string', description: 'Old file path', nullable: true },
newPath: { type: 'string', description: 'New file path', nullable: true },
oldCommitHash: { type: 'string', description: 'Old file commit hash', nullable: true },
newCommitHash: { type: 'string', description: 'New file commit hash', nullable: true },
}
export const BITBUCKET_PIPELINE_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Bitbucket pipeline object type' },
uuid: { type: 'string', description: 'Pipeline UUID', nullable: true },
buildNumber: { type: 'number', description: 'Pipeline build number', nullable: true },
creator: {
type: 'object',
description: 'Pipeline creator',
nullable: true,
properties: BITBUCKET_USER_OUTPUT_PROPERTIES,
},
repositoryFullName: { type: 'string', description: 'Repository full name', nullable: true },
target: {
type: 'object',
description: 'Pipeline target',
nullable: true,
properties: {
type: { type: 'string', description: 'Target object type', nullable: true },
refType: { type: 'string', description: 'Reference type', nullable: true },
refName: { type: 'string', description: 'Reference name', nullable: true },
commitHash: { type: 'string', description: 'Target commit hash', nullable: true },
selectorType: { type: 'string', description: 'Pipeline selector type', nullable: true },
selectorPattern: { type: 'string', description: 'Pipeline selector pattern', nullable: true },
},
},
triggerType: { type: 'string', description: 'Pipeline trigger object type', nullable: true },
state: {
type: 'object',
description: 'Pipeline state',
nullable: true,
properties: {
name: { type: 'string', description: 'State name', nullable: true },
stage: { type: 'string', description: 'In-progress stage name', nullable: true },
result: { type: 'string', description: 'Completed result name', nullable: true },
errorKey: { type: 'string', description: 'Completed-error key', nullable: true },
errorMessage: { type: 'string', description: 'Completed-error message', nullable: true },
},
},
createdOn: { type: 'string', description: 'Creation timestamp', nullable: true },
completedOn: { type: 'string', description: 'Completion timestamp', nullable: true },
buildSecondsUsed: { type: 'number', description: 'Build seconds used', nullable: true },
selfUrl: { type: 'string', description: 'Pipeline API URL', nullable: true },
stepsUrl: { type: 'string', description: 'Pipeline steps API URL', nullable: true },
}
export const BITBUCKET_PIPELINE_COMMAND_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
name: { type: 'string', description: 'Command name', nullable: true },
command: { type: 'string', description: 'Executable command', nullable: true },
}
export const BITBUCKET_PIPELINE_STEP_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
type: { type: 'string', description: 'Bitbucket pipeline-step object type' },
uuid: { type: 'string', description: 'Pipeline step UUID', nullable: true },
startedOn: { type: 'string', description: 'Step start timestamp', nullable: true },
completedOn: { type: 'string', description: 'Step completion timestamp', nullable: true },
state: {
type: 'object',
description: 'Pipeline step state',
nullable: true,
properties: {
name: { type: 'string', description: 'State name', nullable: true },
result: { type: 'string', description: 'Completed result name', nullable: true },
errorKey: { type: 'string', description: 'Completed-error key', nullable: true },
errorMessage: { type: 'string', description: 'Completed-error message', nullable: true },
},
},
imageName: { type: 'string', description: 'Build container image name', nullable: true },
setupCommands: {
type: 'array',
description: 'Setup commands',
nullable: true,
items: { type: 'object', properties: BITBUCKET_PIPELINE_COMMAND_OUTPUT_PROPERTIES },
},
scriptCommands: {
type: 'array',
description: 'Build script commands',
nullable: true,
items: { type: 'object', properties: BITBUCKET_PIPELINE_COMMAND_OUTPUT_PROPERTIES },
},
}
export const BITBUCKET_PAGE_OUTPUT_PROPERTIES: Record<string, ToolOutputProperty> = {
size: {
type: 'number',
description: 'Total result count reported by Bitbucket',
nullable: true,
},
page: { type: 'number', description: 'Current page number', nullable: true },
pageLen: {
type: 'number',
description: 'Number of results requested per page',
nullable: true,
},
nextUrl: { type: 'string', description: 'Validated URL for the next page', nullable: true },
previousUrl: {
type: 'string',
description: 'Validated URL for the previous page',
nullable: true,
},
}
export const BITBUCKET_PAGE_OUTPUT: ToolOutputProperty = {
type: 'object',
description: 'Pagination information',
properties: BITBUCKET_PAGE_OUTPUT_PROPERTIES,
}
@@ -0,0 +1,218 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
const {
mockBackoffWithJitter,
mockCreatePinnedFetchWithDispatcher,
mockParseRetryAfter,
mockSecureFetchWithPinnedIP,
mockValidateUrlWithDNS,
} = vi.hoisted(() => ({
mockBackoffWithJitter: vi.fn(() => 0),
mockCreatePinnedFetchWithDispatcher: vi.fn(),
mockParseRetryAfter: vi.fn((header: string | null) =>
header === null ? null : Number(header) * 1000
),
mockSecureFetchWithPinnedIP: vi.fn(),
mockValidateUrlWithDNS: vi.fn(),
}))
vi.mock('@sim/utils/retry', () => ({
backoffWithJitter: mockBackoffWithJitter,
parseRetryAfter: mockParseRetryAfter,
}))
vi.mock('@/lib/core/security/input-validation.server', () => ({
createPinnedFetchWithDispatcher: mockCreatePinnedFetchWithDispatcher,
secureFetchWithPinnedIP: mockSecureFetchWithPinnedIP,
validateUrlWithDNS: mockValidateUrlWithDNS,
}))
import {
resolveBitbucketPullRequestRedirect,
secureBitbucketRead,
} from '@/tools/bitbucket/utils.server'
function secureResponse(
status: number,
options: { retryAfter?: string; cancel?: ReturnType<typeof vi.fn> } = {}
) {
const headers = {
get: (name: string) =>
name.toLowerCase() === 'retry-after' ? (options.retryAfter ?? null) : null,
toRecord: () => (options.retryAfter ? { 'retry-after': options.retryAfter } : {}),
}
return {
ok: status >= 200 && status < 300,
status,
statusText: '',
headers,
body: options.cancel ? { cancel: options.cancel } : null,
}
}
beforeEach(() => {
vi.clearAllMocks()
mockBackoffWithJitter.mockReturnValue(0)
mockParseRetryAfter.mockImplementation((header: string | null) =>
header === null ? null : Number(header) * 1000
)
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' })
})
describe('secureBitbucketRead', () => {
it('honors bounded Retry-After pacing for a retryable response', async () => {
const cancel = vi.fn().mockResolvedValue(undefined)
mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(secureResponse(429, { retryAfter: '2', cancel }))
.mockResolvedValueOnce(secureResponse(200))
await expect(
secureBitbucketRead('https://api.bitbucket.org/2.0/repositories/acme/demo', {}, 1024)
).resolves.toMatchObject({ status: 200 })
expect(mockParseRetryAfter).toHaveBeenCalledWith('2', Number.POSITIVE_INFINITY)
expect(mockBackoffWithJitter).toHaveBeenCalledWith(1, 2000, {
baseMs: 500,
maxMs: 30_000,
})
expect(cancel).toHaveBeenCalledOnce()
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(2)
})
it('retries a narrowly classified transport failure', async () => {
const timeout = Object.assign(new Error('socket timeout'), { code: 'ETIMEDOUT' })
mockSecureFetchWithPinnedIP
.mockRejectedValueOnce(timeout)
.mockResolvedValueOnce(secureResponse(200))
await expect(
secureBitbucketRead('https://api.bitbucket.org/2.0/repositories/acme/demo', {}, 1024)
).resolves.toMatchObject({ status: 200 })
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(2)
})
it.each([
['ordinary client response', secureResponse(403)],
['aborted request', new DOMException('Aborted', 'AbortError')],
[
'bounded response failure',
new PayloadSizeLimitError({ label: 'response body', maxBytes: 1024, observedBytes: 2048 }),
],
])('does not retry a %s', async (_name, result) => {
if (result instanceof Error) mockSecureFetchWithPinnedIP.mockRejectedValueOnce(result)
else mockSecureFetchWithPinnedIP.mockResolvedValueOnce(result)
const execution = secureBitbucketRead(
'https://api.bitbucket.org/2.0/repositories/acme/demo',
{},
1024
)
if (result instanceof Error) await expect(execution).rejects.toBe(result)
else await expect(execution).resolves.toMatchObject({ status: 403 })
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledOnce()
})
it('stops after the three-attempt safe-read budget', async () => {
mockSecureFetchWithPinnedIP.mockResolvedValue(secureResponse(503))
await expect(
secureBitbucketRead('https://api.bitbucket.org/2.0/repositories/acme/demo', {}, 1024)
).resolves.toMatchObject({ status: 503 })
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(3)
})
it('forwards redirect and response-cap safety options on every attempt', async () => {
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(secureResponse(200))
await secureBitbucketRead(
'https://api.bitbucket.org/2.0/repositories/acme/demo/src/hash/file',
{ Authorization: 'Bearer placeholder' },
10 * 1024 * 1024,
{ stripAuthOnRedirect: true, maxRedirects: 0 }
)
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
'https://api.bitbucket.org/2.0/repositories/acme/demo/src/hash/file',
'203.0.113.10',
expect.objectContaining({
maxResponseBytes: 10 * 1024 * 1024,
maxRedirects: 0,
stripAuthOnRedirect: true,
})
)
})
})
describe('resolveBitbucketPullRequestRedirect', () => {
it('cancels the manual redirect body before closing its pinned dispatcher', async () => {
const order: string[] = []
const cancel = vi.fn(async () => {
order.push('cancel')
})
const close = vi.fn(async () => {
order.push('close')
})
const pinnedFetch = vi.fn().mockResolvedValue({
status: 302,
headers: {
get: (name: string) =>
name.toLowerCase() === 'location'
? 'https://api.bitbucket.org/2.0/repositories/acme/demo/diff/source-team/source-repo:6315b3bac849%0Decdc2efc4f27?from_pullrequest_id=7&topic=true'
: null,
},
body: { cancel },
})
mockCreatePinnedFetchWithDispatcher.mockReturnValue({
fetch: pinnedFetch,
dispatcher: { close },
})
await expect(
resolveBitbucketPullRequestRedirect(
'https://api.bitbucket.org/2.0/repositories/acme/demo/pullrequests/7/diff',
'acme',
'demo',
'diff',
{ Authorization: 'Bearer placeholder' },
{ targetQuery: { path: 'src/index.ts', binary: 'false' } }
)
).resolves.toBe(
'https://api.bitbucket.org/2.0/repositories/acme/demo/diff/source-team/source-repo:6315b3bac849%0Decdc2efc4f27?from_pullrequest_id=7&topic=true&path=src%2Findex.ts&binary=false'
)
expect(order).toEqual(['cancel', 'close'])
})
it('rejects redirect statuses other than the documented 302', async () => {
const cancel = vi.fn().mockResolvedValue(undefined)
const close = vi.fn().mockResolvedValue(undefined)
mockCreatePinnedFetchWithDispatcher.mockReturnValue({
fetch: vi.fn().mockResolvedValue({
status: 307,
headers: {
get: (name: string) =>
name.toLowerCase() === 'location'
? 'https://api.bitbucket.org/2.0/repositories/acme/demo/diff/main..feature'
: null,
},
body: { cancel },
}),
dispatcher: { close },
})
await expect(
resolveBitbucketPullRequestRedirect(
'https://api.bitbucket.org/2.0/repositories/acme/demo/pullrequests/7/diff',
'acme',
'demo',
'diff',
{ Authorization: 'Bearer placeholder' }
)
).rejects.toThrow(/documented redirect/)
expect(cancel).toHaveBeenCalledOnce()
expect(close).toHaveBeenCalledOnce()
})
})
+238
View File
@@ -0,0 +1,238 @@
import { sleep } from '@sim/utils/helpers'
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
import {
createPinnedFetchWithDispatcher,
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import {
type BitbucketPullRequestRedirectKind,
validateBitbucketPullRequestRedirect,
} from '@/tools/bitbucket/utils'
function retryableStatus(status: number): boolean {
return status === 429 || (status >= 500 && status <= 599)
}
const BITBUCKET_READ_MAX_ATTEMPTS = 3
const BITBUCKET_RETRY_BASE_MS = 500
const BITBUCKET_RETRY_MAX_MS = 30_000
function abortError(signal: AbortSignal): unknown {
return signal.reason ?? new DOMException('Aborted', 'AbortError')
}
function retryableTransportError(error: unknown): boolean {
if (isPayloadSizeLimitError(error)) return false
if (!(error instanceof Error) || error.name === 'AbortError') return false
const code = (error as NodeJS.ErrnoException).code
if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') return true
const message = error.message.toLowerCase()
return message.includes('timeout') || message.includes('timed out')
}
async function waitForRetry(delayMs: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await sleep(delayMs)
return
}
if (signal.aborted) throw abortError(signal)
let onAbort: (() => void) | undefined
const aborted = new Promise<never>((_, reject) => {
onAbort = () => reject(abortError(signal))
signal.addEventListener('abort', onAbort, { once: true })
})
try {
await Promise.race([sleep(delayMs), aborted])
} finally {
if (onAbort) signal.removeEventListener('abort', onAbort)
}
}
function retryDelayMs(attempt: number, retryAfter: string | null): number | null {
const retryAfterMs = parseRetryAfter(retryAfter, Number.POSITIVE_INFINITY)
if (retryAfterMs !== null && retryAfterMs > BITBUCKET_RETRY_MAX_MS) return null
return backoffWithJitter(attempt, retryAfterMs, {
baseMs: BITBUCKET_RETRY_BASE_MS,
maxMs: BITBUCKET_RETRY_MAX_MS,
})
}
/**
* Executes a bounded, DNS-pinned Bitbucket read. Redirect targets are validated
* and pinned by the shared transport; callers can drop authorization for media
* redirects without trusting a destination hostname.
*/
export async function secureBitbucketRead(
url: string,
headers: Record<string, string>,
maxResponseBytes: number,
options: {
stripAuthOnRedirect?: boolean
maxRedirects?: number
signal?: AbortSignal
} = {}
): Promise<Response> {
const validation = await validateUrlWithDNS(url, 'bitbucketUrl')
if (!validation.isValid || !validation.resolvedIP) {
throw new Error(`Invalid Bitbucket URL: ${validation.error ?? 'DNS resolution failed'}`)
}
for (let attempt = 1; attempt <= BITBUCKET_READ_MAX_ATTEMPTS; attempt++) {
try {
const response = await secureFetchWithPinnedIP(url, validation.resolvedIP, {
method: 'GET',
headers,
maxResponseBytes,
stripAuthOnRedirect: options.stripAuthOnRedirect,
maxRedirects: options.maxRedirects,
signal: options.signal,
})
if (retryableStatus(response.status) && attempt < BITBUCKET_READ_MAX_ATTEMPTS) {
const delayMs = retryDelayMs(attempt, response.headers.get('retry-after'))
if (delayMs === null) {
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers.toRecord(),
})
}
await response.body?.cancel()
await waitForRetry(delayMs, options.signal)
continue
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers.toRecord(),
})
} catch (error) {
if (
options.signal?.aborted ||
attempt === BITBUCKET_READ_MAX_ATTEMPTS ||
!retryableTransportError(error)
) {
throw error
}
await waitForRetry(retryDelayMs(attempt, null)!, options.signal)
}
}
throw new Error('Bitbucket read failed')
}
/**
* Resolves the documented PR-to-repository redirect without allowing the
* bearer-authenticated request to follow an unvalidated Location.
*/
export async function resolveBitbucketPullRequestRedirect(
initialUrl: string,
workspaceSlug: string,
repoSlug: string,
kind: BitbucketPullRequestRedirectKind,
headers: Record<string, string>,
options: {
signal?: AbortSignal
targetQuery?: Record<string, string>
} = {}
): Promise<string> {
const initialValidation = await validateUrlWithDNS(initialUrl, 'bitbucketPullRequestUrl')
if (!initialValidation.isValid || !initialValidation.resolvedIP) {
throw new Error(
`Invalid Bitbucket pull request URL: ${initialValidation.error ?? 'DNS resolution failed'}`
)
}
const { fetch: pinnedFetch, dispatcher } = createPinnedFetchWithDispatcher(
initialValidation.resolvedIP,
{ maxResponseSize: 64 * 1024 }
)
let initial: Response | null = null
let initialStatus: number | null = null
let location: string | null = null
try {
for (let attempt = 1; attempt <= BITBUCKET_READ_MAX_ATTEMPTS; attempt++) {
try {
initial = await pinnedFetch(initialUrl, {
method: 'GET',
headers,
redirect: 'manual',
signal: options.signal,
})
} catch (error) {
if (
options.signal?.aborted ||
attempt === BITBUCKET_READ_MAX_ATTEMPTS ||
!retryableTransportError(error)
) {
throw error
}
await waitForRetry(retryDelayMs(attempt, null)!, options.signal)
continue
}
if (!retryableStatus(initial.status) || attempt === BITBUCKET_READ_MAX_ATTEMPTS) break
const delayMs = retryDelayMs(attempt, initial.headers.get('retry-after'))
if (delayMs === null) break
await initial.body?.cancel()
await waitForRetry(delayMs, options.signal)
}
if (initial) {
initialStatus = initial.status
location = initial.headers.get('location')
}
} finally {
await initial?.body?.cancel().catch(() => undefined)
await dispatcher.close()
}
if (initialStatus === null) throw new Error(`Bitbucket ${kind} redirect request failed`)
if (initialStatus !== 302) {
throw new Error(`Bitbucket ${kind} endpoint did not return its documented redirect`)
}
if (!location) throw new Error(`Bitbucket ${kind} redirect omitted the Location header`)
const target = new URL(
validateBitbucketPullRequestRedirect(
new URL(location, initialUrl).toString(),
workspaceSlug,
repoSlug,
kind
)
)
for (const [key, value] of Object.entries(options.targetQuery ?? {})) {
target.searchParams.set(key, value)
}
return target.toString()
}
/**
* Performs the documented PR-to-repository redirect without allowing the
* bearer-authenticated request to follow an unvalidated Location.
*/
export async function secureBitbucketPullRequestRedirect(
initialUrl: string,
workspaceSlug: string,
repoSlug: string,
kind: BitbucketPullRequestRedirectKind,
headers: Record<string, string>,
maxResponseBytes: number,
options: {
signal?: AbortSignal
targetQuery?: Record<string, string>
} = {}
): Promise<Response> {
const target = await resolveBitbucketPullRequestRedirect(
initialUrl,
workspaceSlug,
repoSlug,
kind,
headers,
options
)
return secureBitbucketRead(target, headers, maxResponseBytes, {
maxRedirects: 0,
signal: options.signal,
})
}
+523
View File
@@ -0,0 +1,523 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
assertBitbucketResponseOk,
bitbucketApiUrl,
bitbucketJson,
bitbucketRawHead,
bitbucketRawTail,
bitbucketRepositoryPath,
encodeBitbucketRepositoryPath,
encodeBitbucketSegment,
normalizeBitbucketFileMetadata,
normalizeBitbucketPage,
validateBitbucketOpaqueUrl,
validateBitbucketPullRequestRedirect,
} from '@/tools/bitbucket/utils'
describe('Bitbucket path and pagination safety', () => {
it('encodes every identifier as one path segment', () => {
expect(encodeBitbucketSegment(' team/blue?admin=true#x ', 'workspaceSlug')).toBe(
'team%2Fblue%3Fadmin%3Dtrue%23x'
)
expect(bitbucketRepositoryPath('team / blue', 'repo/../secret?x=1')).toBe(
'/repositories/team%20%2F%20blue/repo%2F..%2Fsecret%3Fx%3D1'
)
})
it('encodes repository paths segment-by-segment, including spaces and hostile characters', () => {
expect(encodeBitbucketRepositoryPath('/src/my file?#.ts/')).toBe('src/my%20file%3F%23.ts')
expect(encodeBitbucketRepositoryPath('', true)).toBe('')
expect(() => encodeBitbucketRepositoryPath('src/../secret')).toThrow(/dot segment/)
expect(() => encodeBitbucketRepositoryPath('src/./file')).toThrow(/dot segment/)
expect(() => encodeBitbucketSegment('..', 'repoSlug')).toThrow(/dot path segment/)
})
it('builds bounded list queries and does not add pagination to non-list calls', () => {
const list = new URL(
bitbucketApiUrl('/repositories/acme', {
pageLen: 25,
query: { role: 'owner', q: 'name ~ "sdk"', ignored: undefined },
})
)
expect(list.origin).toBe('https://api.bitbucket.org')
expect(list.pathname).toBe('/2.0/repositories/acme')
expect(Object.fromEntries(list.searchParams)).toEqual({
role: 'owner',
q: 'name ~ "sdk"',
pagelen: '25',
})
expect(bitbucketApiUrl('/repositories/acme/demo')).toBe(
'https://api.bitbucket.org/2.0/repositories/acme/demo'
)
expect(() => bitbucketApiUrl('/repositories/acme', { pageLen: 101 })).toThrow(
/between 1 and 100/
)
})
it('accepts only exact HTTPS Bitbucket Cloud API 2.0 cursor authorities', () => {
const valid = 'https://api.bitbucket.org/2.0/repositories/acme?page=2'
expect(validateBitbucketOpaqueUrl(valid)).toBe(valid)
const hostile = [
'http://api.bitbucket.org/2.0/repositories/acme?page=2',
'https://api.bitbucket.org.evil.test/2.0/repositories/acme?page=2',
'https://api.bitbucket.org:444/2.0/repositories/acme?page=2',
'https://user:pass@api.bitbucket.org/2.0/repositories/acme?page=2',
'https://api.bitbucket.org/1.0/repositories/acme?page=2',
'https://api.bitbucket.org/2.0/repositories/acme?page=2#fragment',
'not a url',
]
for (const candidate of hostile) {
expect(() => validateBitbucketOpaqueUrl(candidate), candidate).toThrow()
}
})
it('binds opaque cursors to the exact list endpoint', () => {
const expected = 'https://api.bitbucket.org/2.0/repositories/acme/demo/commits?page=2'
expect(
bitbucketApiUrl('/repositories/acme/demo/commits', {
nextUrl: expected,
pageLen: 100,
query: { q: 'ignored for opaque cursors' },
})
).toBe(expected)
expect(() =>
bitbucketApiUrl('/repositories/acme/demo/commits', {
nextUrl: 'https://api.bitbucket.org/2.0/repositories/acme/demo/pipelines?page=2',
})
).toThrow(/does not belong/)
for (const nextUrl of [false, 0, null, {}]) {
expect(() =>
bitbucketApiUrl('/repositories/acme/demo/commits', {
nextUrl: nextUrl as never,
})
).toThrow(/nextUrl must be a non-empty string/)
}
})
it('binds directory cursors to the selected repository path', () => {
const revision = '0123456789abcdef0123456789abcdef01234567'
const next = `https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/my%20dir?page=2`
expect(
bitbucketApiUrl(`/repositories/acme/demo/src/${revision}/src/my%20dir`, {
nextUrl: next,
nextPathPrefix: '/repositories/acme/demo/src',
nextPathSuffix: 'src/my%20dir',
nextRevision: revision,
})
).toBe(next)
const equivalentEncoding = `https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/my%20%64ir?page=2`
expect(
bitbucketApiUrl(`/repositories/acme/demo/src/${revision}/src/my%20dir`, {
nextUrl: equivalentEncoding,
nextPathPrefix: '/repositories/acme/demo/src',
nextPathSuffix: 'src/my%20dir',
nextRevision: revision,
})
).toBe(equivalentEncoding)
expect(() =>
bitbucketApiUrl(`/repositories/acme/demo/src/${revision}/src/my%20dir`, {
nextUrl: `https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/other?page=2`,
nextPathPrefix: '/repositories/acme/demo/src',
nextPathSuffix: 'src/my%20dir',
nextRevision: revision,
})
).toThrow(/does not preserve/)
expect(() =>
bitbucketApiUrl(`/repositories/acme/demo/src/${revision}/src/my%20dir`, {
nextUrl: `https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src//my%20dir?page=2`,
nextPathPrefix: '/repositories/acme/demo/src',
nextPathSuffix: 'src/my%20dir',
nextRevision: revision,
})
).toThrow(/empty path segments/)
const otherRevision = 'fedcba9876543210fedcba9876543210fedcba98'
expect(() =>
bitbucketApiUrl(`/repositories/acme/demo/src/${revision}/src/my%20dir`, {
nextUrl: `https://api.bitbucket.org/2.0/repositories/acme/demo/src/${otherRevision}/src/my%20dir?page=2`,
nextPathPrefix: '/repositories/acme/demo/src',
nextPathSuffix: 'src/my%20dir',
nextRevision: revision,
})
).toThrow(/requested Bitbucket revision/)
})
it('rejects non-primitive runtime query values', () => {
expect(() =>
bitbucketApiUrl('/repositories/acme', {
query: { role: { unexpected: true } } as never,
})
).toThrow(/query parameter role must be a string, number, or boolean/)
})
it('accepts PR redirects only for the requested repository and endpoint kind', () => {
const diff = 'https://api.bitbucket.org/2.0/repositories/acme/demo/diff/main..feature'
expect(validateBitbucketPullRequestRedirect(diff, 'acme', 'demo', 'diff')).toBe(diff)
const providerQualified =
'https://api.bitbucket.org/2.0/repositories/acme/demo/diff/source-team/source-repo:6315b3bac849%0Decdc2efc4f27?from_pullrequest_id=7&topic=true'
expect(validateBitbucketPullRequestRedirect(providerQualified, 'acme', 'demo', 'diff')).toBe(
providerQualified
)
expect(() =>
validateBitbucketPullRequestRedirect(
'https://api.bitbucket.org/2.0/repositories/acme/other/diff/main..feature',
'acme',
'demo',
'diff'
)
).toThrow(/did not target/)
expect(() =>
validateBitbucketPullRequestRedirect(
'https://api.bitbucket.org/2.0/repositories/acme/demo/diff/main..feature//extra',
'acme',
'demo',
'diff'
)
).toThrow(/empty spec path segment/)
expect(() =>
validateBitbucketPullRequestRedirect(
'https://api.bitbucket.org/2.0/repositories/acme/demo/diffstat/main..feature',
'acme',
'demo',
'diff'
)
).toThrow(/did not target/)
})
})
describe('Bitbucket envelopes and errors', () => {
it('normalizes lists to items plus stable page metadata', () => {
const output = normalizeBitbucketPage(
{
values: [{ id: 1 }, { id: 2 }],
size: 9,
page: 2,
pagelen: 2,
next: 'https://api.bitbucket.org/2.0/repositories/acme?page=3',
previous: 'https://api.bitbucket.org/2.0/repositories/acme?page=1',
},
(value) => value
)
expect(output).toEqual({
items: [{ id: 1 }, { id: 2 }],
page: {
size: 9,
page: 2,
pageLen: 2,
nextUrl: 'https://api.bitbucket.org/2.0/repositories/acme?page=3',
previousUrl: 'https://api.bitbucket.org/2.0/repositories/acme?page=1',
},
})
})
it('rejects malformed pagination envelopes and pagination links', () => {
expect(() => normalizeBitbucketPage({}, (value) => value)).toThrow(/values array/)
expect(() =>
normalizeBitbucketPage({ values: [], next: { href: 'not-supported' } }, (value) => value)
).toThrow(/pagination next must be a URL/)
expect(() =>
normalizeBitbucketPage(
{ values: [], next: 'https://evil.test/2.0/repositories/acme?page=2' },
(value) => value
)
).toThrow(/Bitbucket Cloud API 2.0 URL/)
})
it('rejects non-object JSON responses', async () => {
await expect(bitbucketJson(Response.json([]))).rejects.toThrow(/non-object JSON/)
await expect(bitbucketJson(Response.json(null))).rejects.toThrow(/non-object JSON/)
})
it('extracts Bitbucket structured errors and preserves plain-text errors', async () => {
await expect(
assertBitbucketResponseOk(
Response.json({ error: { message: 'Merge checks failed' } }, { status: 409 })
)
).rejects.toThrow('Merge checks failed')
await expect(
assertBitbucketResponseOk(new Response('Service unavailable', { status: 503 }))
).rejects.toThrow('Service unavailable')
await expect(assertBitbucketResponseOk(new Response(null, { status: 429 }))).rejects.toThrow(
/Bitbucket API error: 429/
)
})
it('distinguishes documented binary metadata from unknown metadata', () => {
expect(
normalizeBitbucketFileMetadata({
type: 'commit_file',
path: 'assets/logo.png',
commit: { hash: 'abc' },
escaped_path: 'assets/logo.png',
size: 42,
attributes: ['binary', 'lfs'],
})
).toMatchObject({ attributes: ['binary', 'lfs'], isBinary: true })
expect(
normalizeBitbucketFileMetadata({ type: 'commit_file', path: 'README.md' })
).toMatchObject({
attributes: null,
isBinary: null,
})
expect(
normalizeBitbucketFileMetadata({
type: 'commit_file',
path: 'README.md',
attributes: [],
})
).toMatchObject({ attributes: [], isBinary: false })
expect(
normalizeBitbucketFileMetadata({
type: 'commit_file',
path: 'binary.dat',
attributes: 'binary',
})
).toMatchObject({ attributes: ['binary'], isBinary: true })
expect(() =>
normalizeBitbucketFileMetadata({
type: 'commit_file',
path: 'README.md',
attributes: ['future_attribute', 1],
})
).toThrow(/metadata\.attributes\[1\] must be a string/)
})
})
describe('Bitbucket bounded raw content', () => {
it('caps UTF-8 file content without emitting an incomplete trailing character', async () => {
const bytes = new TextEncoder().encode('ab🙂cd')
const partial = bytes.slice(0, 5)
const result = await bitbucketRawHead(
new Response(partial, {
status: 206,
headers: {
'Content-Range': `bytes 0-${partial.byteLength - 1}/${bytes.byteLength}`,
'Content-Type': 'text/plain; charset=utf-8',
},
}),
5,
false
)
expect(result).toEqual({
content: 'ab',
binary: false,
truncated: true,
returnedBytes: partial.byteLength,
fullBytes: bytes.byteLength,
contentType: 'text/plain; charset=utf-8',
})
})
it('locally caps a full response when Range is ignored', async () => {
const text = 'abcdefghijklmnopqrstuvwxyz'
const result = await bitbucketRawHead(
new Response(text, { headers: { 'Content-Length': String(text.length) } }),
3,
false
)
expect(result).toEqual({
content: 'abc',
binary: false,
truncated: true,
returnedBytes: 12,
fullBytes: text.length,
contentType: 'text/plain;charset=UTF-8',
})
})
it('does not split an astral character at a retained prefix boundary', async () => {
const text = 'ab😀cd'
const result = await bitbucketRawHead(
new Response(text, { headers: { 'Content-Length': String(Buffer.byteLength(text)) } }),
3,
false
)
expect(result.content).toBe('ab')
expect(result.content).not.toMatch(/[\ud800-\udfff]/)
expect(result.truncated).toBe(true)
})
it.each([
['missing range', {}, 'abcde'],
['impossible total', { 'Content-Range': 'bytes 0-4/4' }, 'abcde'],
[
'inconsistent length header',
{ 'Content-Range': 'bytes 0-4/10', 'Content-Length': '4' },
'abcde',
],
['inconsistent body length', { 'Content-Range': 'bytes 0-4/10' }, 'abcd'],
])('rejects a 206 prefix with %s', async (_name, headers, body) => {
await expect(
bitbucketRawHead(new Response(body, { status: 206, headers }), 100, false)
).rejects.toThrow(/Content-(?:Range|Length)|body does not match/)
})
it('detects NUL bytes when metadata cannot determine whether a file is binary', async () => {
const result = await bitbucketRawHead(
new Response(new Uint8Array([65, 0, 66]), {
headers: { 'Content-Type': 'application/octet-stream' },
}),
100,
null
)
expect(result).toMatchObject({
content: null,
binary: true,
truncated: true,
returnedBytes: 3,
fullBytes: 3,
})
})
it('reports nullable truncation when raw binary size remains unknown', async () => {
const result = await bitbucketRawHead(
new Response(new Uint8Array([65, 0, 66]), {
status: 206,
headers: { 'Content-Range': 'bytes 0-2/*' },
}),
100,
null
)
expect(result).toMatchObject({
content: null,
binary: true,
truncated: null,
returnedBytes: 3,
fullBytes: null,
})
})
it('trims a partial leading line from a ranged log tail', async () => {
const body = 'ise\nFAILED: expected 1 to be 2\n'
const result = await bitbucketRawTail(
new Response(body, {
status: 206,
headers: { 'Content-Range': `bytes 969-999/1000` },
}),
100
)
expect(result).toEqual({
log: 'FAILED: expected 1 to be 2\n',
truncated: true,
totalBytes: 1000,
})
})
it('keeps the first line when a 206 response contains the entire log', async () => {
const body = 'first\nsecond\n'
const result = await bitbucketRawTail(
new Response(body, {
status: 206,
headers: { 'Content-Range': `bytes 0-${body.length - 1}/${body.length}` },
}),
100
)
expect(result).toEqual({ log: body, truncated: false, totalBytes: body.length })
})
it.each([
['missing Content-Range', {}, 'abc'],
['non-suffix range', { 'Content-Range': 'bytes 0-2/10' }, 'abc'],
['body mismatch', { 'Content-Range': 'bytes 7-9/10' }, 'ab'],
])('rejects a 206 log tail with %s', async (_name, headers, body) => {
await expect(
bitbucketRawTail(new Response(body, { status: 206, headers }), 100)
).rejects.toThrow(/Content-Range|suffix|body does not match/)
})
it('accepts an internally consistent unknown-total log suffix', async () => {
const result = await bitbucketRawTail(
new Response('abc', {
status: 206,
headers: { 'Content-Range': 'bytes 7-9/*' },
}),
100
)
expect(result).toEqual({ log: 'abc', truncated: true, totalBytes: null })
})
it('accepts the minimum 4 KiB suffix response before trimming a small requested tail', async () => {
const body = `${'x'.repeat(4_080)}\nDONE😀\n`
const bytes = Buffer.byteLength(body)
const result = await bitbucketRawTail(
new Response(body, {
status: 206,
headers: { 'Content-Range': `bytes 1000-${999 + bytes}/${1000 + bytes}` },
}),
100
)
expect(result.log.endsWith('DONE😀\n')).toBe(true)
expect(result.log.length).toBeLessThanOrEqual(100)
expect(result.truncated).toBe(true)
})
it('does not split an astral character at a retained suffix boundary', async () => {
const body = 'ab😀cd'
const bytes = Buffer.byteLength(body)
const result = await bitbucketRawTail(
new Response(body, {
status: 206,
headers: { 'Content-Range': `bytes 0-${bytes - 1}/${bytes}` },
}),
3
)
expect(result.log).toBe('cd')
expect(result.log).not.toMatch(/[\ud800-\udfff]/)
})
it('never empties the log when the retained window holds one long line', async () => {
const body = `${'x'.repeat(400)}\n`
const result = await bitbucketRawTail(
new Response(body, { headers: { 'Content-Length': String(Buffer.byteLength(body)) } }),
100
)
expect(result.log).toBe(`${'x'.repeat(99)}\n`)
expect(result.truncated).toBe(true)
})
it('returns the fragment when the retained window contains no line break at all', async () => {
const body = 'y'.repeat(400)
const result = await bitbucketRawTail(
new Response(body, { headers: { 'Content-Length': String(Buffer.byteLength(body)) } }),
100
)
expect(result.log).toBe('y'.repeat(100))
expect(result.truncated).toBe(true)
})
it('keeps a bounded tail when the server ignores Range', async () => {
const body = `${'noise line\n'.repeat(20)}FAILED\n`
const result = await bitbucketRawTail(
new Response(body, { headers: { 'Content-Length': String(Buffer.byteLength(body)) } }),
10
)
expect(result.log).toBe('FAILED\n')
expect(result.log.startsWith('ne')).toBe(false)
expect(result.log.length).toBeLessThanOrEqual(10)
expect(result).toMatchObject({ truncated: true, totalBytes: Buffer.byteLength(body) })
})
})
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
const BITBUCKET_SHA1_PATTERN = /^[0-9a-f]{40}$/i
export function optionalBitbucketString(value: unknown, name: string): string | undefined {
if (value === undefined) return undefined
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`${name} must be a non-empty string`)
}
return value.trim()
}
export function optionalBitbucketBoolean(value: unknown, name: string): boolean | undefined {
if (value === undefined) return undefined
if (typeof value !== 'boolean') throw new Error(`${name} must be a boolean`)
return value
}
export function optionalBitbucketEnum<T extends string>(
value: unknown,
name: string,
allowed: readonly T[]
): T | undefined {
if (value === undefined) return undefined
if (typeof value !== 'string' || !allowed.includes(value as T)) {
throw new Error(`${name} must be one of: ${allowed.join(', ')}`)
}
return value as T
}
export function requireBitbucketEnum<T extends string>(
value: unknown,
name: string,
allowed: readonly T[]
): T {
const validated = optionalBitbucketEnum(value, name, allowed)
if (validated === undefined) throw new Error(`${name} is required`)
return validated
}
export function optionalBitbucketStringArray(
value: unknown,
name: string,
itemName: string
): string[] | undefined {
if (value === undefined) return undefined
if (!Array.isArray(value)) throw new Error(`${name} must be an array of strings`)
return value.map((item) => {
if (typeof item !== 'string' || item.trim().length === 0) {
throw new Error(`${itemName} must be a non-empty string`)
}
return item.trim()
})
}
export function requireBitbucketPositiveInteger(value: unknown, name: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
throw new Error(`${name} must be a positive integer`)
}
return value
}
export function requireBitbucketSha1(value: unknown, name: string): string {
if (typeof value !== 'string') throw new Error(`${name} must be a full 40-character SHA-1`)
const normalized = value.trim()
if (!BITBUCKET_SHA1_PATTERN.test(normalized)) {
throw new Error(`${name} must be a full 40-character SHA-1`)
}
return normalized.toLowerCase()
}
export function optionalBitbucketSha1(value: unknown, name: string): string | undefined {
if (value === undefined) return undefined
return requireBitbucketSha1(value, name)
}
export function optionalBitbucketUtf8String(
value: unknown,
name: string,
maxBytes: number
): string | undefined {
if (value === undefined) return undefined
if (typeof value !== 'string') throw new Error(`${name} must be a string`)
if (new TextEncoder().encode(value).byteLength > maxBytes) {
throw new Error(`${name} must not exceed ${maxBytes} UTF-8 bytes`)
}
return value
}
+16
View File
@@ -243,6 +243,21 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
return undefined
},
},
{
id: 'bitbucket-errors',
description:
'Bitbucket error envelope: {type:"error", error:{message, detail}}. `message` is the class of failure and `detail` names the offending branch, file, or property, which the bare message does not',
examples: ['Bitbucket Cloud REST API v2'],
extract: (errorInfo) => {
const error = errorInfo?.data?.error
if (!error || typeof error !== 'object') return undefined
const message = typeof error.message === 'string' ? error.message.trim() : ''
const detail = typeof error.detail === 'string' ? error.detail.trim() : ''
if (!message) return detail || undefined
if (!detail || detail === message) return message
return `${message}: ${detail}`
},
},
{
id: 'dynatrace-errors',
description:
@@ -418,6 +433,7 @@ export const ErrorExtractorId = {
SOAP_FAULT: 'soap-fault',
OAUTH_ERROR_DESCRIPTION: 'oauth-error-description',
NESTED_ERROR_OBJECT: 'nested-error-object',
BITBUCKET_ERRORS: 'bitbucket-errors',
DYNATRACE_ERRORS: 'dynatrace-errors',
SMARTLEAD_ERRORS: 'smartlead-errors',
POSTHOG_ERRORS: 'posthog-errors',
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+52
View File
@@ -35,6 +35,7 @@ import {
ANONYMOUS_SECRET_TRACE_REPLACEMENT,
ResolvedSecretTraceRegistry,
} from '@/executor/utils/resolved-secret-trace-registry'
import { bitbucketGetPipelineStepLogTool } from '@/tools/bitbucket/get_pipeline_step_log'
import { fileGetContentTool } from '@/tools/file/get'
import { memoryAddTool } from '@/tools/memory/add'
import { tableBatchInsertRowsTool } from '@/tools/table/batch_insert_rows'
@@ -143,6 +144,7 @@ vi.mock('@/executor/handlers/workflow/custom-block-tool-runner', () => ({
// Mock the tools registry to avoid loading the full 4500+ line registry file.
// Only the tools actually exercised in tests are provided.
const mockRegistryTools: Record<string, any> = {
bitbucket_get_pipeline_step_log: bitbucketGetPipelineStepLogTool,
deployed_block_executor: customBlockExecutorTool,
workflow_executor: workflowExecutorTool,
file_get_content: fileGetContentTool,
@@ -704,6 +706,56 @@ describe('executeTool Function', () => {
tools.function_execute = originalFunctionTool
})
it('bounds ignored Bitbucket pipeline log ranges through the execution path', async () => {
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '93.184.216.34' })
const log = 'line 1\nDONE\n'
const response = new Response(log, {
status: 200,
headers: {
'content-length': String(Buffer.byteLength(log)),
'content-type': 'text/plain',
},
})
mockSecureFetchWithPinnedIP.mockResolvedValueOnce({
ok: true,
status: response.status,
statusText: response.statusText,
headers: {
get: (name: string) => response.headers.get(name),
toRecord: () => Object.fromEntries(response.headers.entries()),
},
body: response.body,
})
const params = {
accessToken: 'oauth-token',
workspaceSlug: 'acme',
repoSlug: 'demo',
pipelineUuid: '{pipeline}',
stepUuid: '{step}',
maxCharacters: 5,
}
const accepted = await executeTool('bitbucket_get_pipeline_step_log', params, {
skipPostProcess: true,
})
expect(accepted).toMatchObject({
success: true,
output: {
log: 'DONE\n',
truncated: true,
totalBytes: Buffer.byteLength(log),
},
})
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
expect.stringContaining('/pipelines/%7Bpipeline%7D/steps/%7Bstep%7D/log'),
'93.184.216.34',
expect.objectContaining({ maxResponseBytes: 16 * 1024 * 1024 })
)
})
it('retries transient database failures during permission preflight', async () => {
const driverError = Object.assign(new Error('read ECONNRESET'), {
code: 'ECONNRESET',
+62
View File
@@ -350,6 +350,38 @@ import {
queryWorkItemsTool as azureDevopsQueryWorkItemsTool,
updateWorkItemTool as azureDevopsUpdateWorkItemTool,
} from '@/tools/azure_devops'
import {
bitbucketApprovePullRequestTool,
bitbucketCreateBranchTool,
bitbucketCreatePullRequestCommentTool,
bitbucketCreatePullRequestTool,
bitbucketDeclinePullRequestTool,
bitbucketDeleteBranchTool,
bitbucketGetCommitTool,
bitbucketGetFileMetadataTool,
bitbucketGetFileTool,
bitbucketGetMergeTaskStatusTool,
bitbucketGetPipelineStepLogTool,
bitbucketGetPipelineTool,
bitbucketGetPullRequestDiffstatTool,
bitbucketGetPullRequestDiffTool,
bitbucketGetPullRequestTool,
bitbucketGetRepositoryTool,
bitbucketListBranchesTool,
bitbucketListCommitsTool,
bitbucketListDirectoryTool,
bitbucketListPipelineStepsTool,
bitbucketListPipelinesTool,
bitbucketListPullRequestCommentsTool,
bitbucketListPullRequestCommitStatusesTool,
bitbucketListPullRequestsTool,
bitbucketListRepositoriesTool,
bitbucketListWorkspacesTool,
bitbucketMergePullRequestTool,
bitbucketRequestPullRequestChangesTool,
bitbucketStopPipelineTool,
bitbucketTriggerPipelineTool,
} from '@/tools/bitbucket'
import {
boxCopyFileTool,
boxCreateFolderTool,
@@ -5635,6 +5667,36 @@ export const tools: Record<string, ToolConfig> = {
azure_devops_update_work_item: azureDevopsUpdateWorkItemTool,
azure_devops_add_comment: azureDevopsAddCommentTool,
azure_devops_get_comments: azureDevopsGetCommentsTool,
bitbucket_approve_pull_request: bitbucketApprovePullRequestTool,
bitbucket_create_branch: bitbucketCreateBranchTool,
bitbucket_create_pull_request: bitbucketCreatePullRequestTool,
bitbucket_create_pull_request_comment: bitbucketCreatePullRequestCommentTool,
bitbucket_decline_pull_request: bitbucketDeclinePullRequestTool,
bitbucket_delete_branch: bitbucketDeleteBranchTool,
bitbucket_get_commit: bitbucketGetCommitTool,
bitbucket_get_file: bitbucketGetFileTool,
bitbucket_get_file_metadata: bitbucketGetFileMetadataTool,
bitbucket_get_pipeline: bitbucketGetPipelineTool,
bitbucket_get_pipeline_step_log: bitbucketGetPipelineStepLogTool,
bitbucket_get_pull_request: bitbucketGetPullRequestTool,
bitbucket_get_pull_request_diff: bitbucketGetPullRequestDiffTool,
bitbucket_get_pull_request_diffstat: bitbucketGetPullRequestDiffstatTool,
bitbucket_get_pull_request_merge_task_status: bitbucketGetMergeTaskStatusTool,
bitbucket_get_repository: bitbucketGetRepositoryTool,
bitbucket_list_branches: bitbucketListBranchesTool,
bitbucket_list_commits: bitbucketListCommitsTool,
bitbucket_list_directory: bitbucketListDirectoryTool,
bitbucket_list_pipeline_steps: bitbucketListPipelineStepsTool,
bitbucket_list_pipelines: bitbucketListPipelinesTool,
bitbucket_list_pull_request_comments: bitbucketListPullRequestCommentsTool,
bitbucket_list_pull_request_commit_statuses: bitbucketListPullRequestCommitStatusesTool,
bitbucket_list_pull_requests: bitbucketListPullRequestsTool,
bitbucket_list_repositories: bitbucketListRepositoriesTool,
bitbucket_list_workspaces: bitbucketListWorkspacesTool,
bitbucket_merge_pull_request: bitbucketMergePullRequestTool,
bitbucket_request_pull_request_changes: bitbucketRequestPullRequestChangesTool,
bitbucket_stop_pipeline: bitbucketStopPipelineTool,
bitbucket_trigger_pipeline: bitbucketTriggerPipelineTool,
box_copy_file: boxCopyFileTool,
box_create_folder: boxCreateFolderTool,
box_delete_file: boxDeleteFileTool,
@@ -1266,6 +1266,7 @@ export const OAUTH_CLIENT_CAPABILITIES = {
jira: ['JIRA_CLIENT_ID', 'JIRA_CLIENT_SECRET'],
calcom: ['CALCOM_CLIENT_ID'],
airtable: ['AIRTABLE_CLIENT_ID', 'AIRTABLE_CLIENT_SECRET'],
bitbucket: ['BITBUCKET_CLIENT_ID', 'BITBUCKET_CLIENT_SECRET'],
notion: ['NOTION_CLIENT_ID', 'NOTION_CLIENT_SECRET'],
microsoft: ['MICROSOFT_CLIENT_ID', 'MICROSOFT_CLIENT_SECRET'],
clickup: ['CLICKUP_CLIENT_ID', 'CLICKUP_CLIENT_SECRET'],
@@ -2503,6 +2503,146 @@
"integrationType": "devops",
"tags": ["version-control", "ci-cd", "project-management"]
},
{
"type": "bitbucket",
"slug": "bitbucket",
"name": "Bitbucket",
"description": "Work with Bitbucket Cloud repositories, pull requests, and pipelines",
"longDescription": "Connect Bitbucket Cloud to inspect repositories and source, collaborate on pull requests, and diagnose or control pipelines. This action integration uses OAuth and does not create webhooks or triggers.",
"bgColor": "#FFFFFF",
"iconName": "BitbucketIcon",
"docsUrl": "https://docs.sim.ai/integrations/bitbucket",
"operations": [
{
"name": "List Workspaces",
"description": "List Bitbucket Cloud workspaces available to the authenticated account"
},
{
"name": "List Repositories",
"description": "List repositories in a Bitbucket Cloud workspace"
},
{
"name": "Get Repository",
"description": "Get a Bitbucket Cloud repository"
},
{
"name": "List Branches",
"description": "List branches in a Bitbucket Cloud repository"
},
{
"name": "Create Branch",
"description": "Create a branch at a commit hash or existing ref"
},
{
"name": "Delete Branch",
"description": "Delete a branch from a Bitbucket Cloud repository"
},
{
"name": "List Commits",
"description": "List repository commits in reverse chronological order"
},
{
"name": "Get Commit",
"description": "Get a repository commit by its full SHA-1"
},
{
"name": "List Directory",
"description": "List one shallow repository directory at a full commit SHA-1"
},
{
"name": "Get File Metadata",
"description": "Inspect file size and attributes at a full repository commit SHA-1"
},
{
"name": "Get File",
"description": "Read bounded UTF-8 text from a file at a full repository commit SHA-1"
},
{
"name": "List Pull Requests",
"description": "List pull requests in a Bitbucket Cloud repository"
},
{
"name": "Get Pull Request",
"description": "Get a pull request by repository-scoped ID"
},
{
"name": "Create Pull Request",
"description": "Create a pull request between repository branches"
},
{
"name": "Merge Pull Request",
"description": "Start an asynchronous pull request merge and return a task to poll when needed"
},
{
"name": "Get Pull Request Merge Task Status",
"description": "Poll the status of an asynchronous pull request merge task"
},
{
"name": "Decline Pull Request",
"description": "Decline an open pull request"
},
{
"name": "Approve Pull Request",
"description": "Approve a pull request as the authenticated account"
},
{
"name": "Request Pull Request Changes",
"description": "Request changes on a pull request as the authenticated account"
},
{
"name": "Get Pull Request Diff",
"description": "Read a bounded UTF-8 unified diff for one pull request file"
},
{
"name": "Get Pull Request Diffstat",
"description": "List per-file change statistics for a pull request"
},
{
"name": "List Pull Request Comments",
"description": "List global, inline, and reply comments on a pull request"
},
{
"name": "Create Pull Request Comment",
"description": "Create a global comment or reply on a pull request"
},
{
"name": "List Pull Request Commit Statuses",
"description": "List commit statuses associated with a pull request"
},
{
"name": "List Pipelines",
"description": "List pipelines for a Bitbucket Cloud repository"
},
{
"name": "Get Pipeline",
"description": "Get a pipeline by UUID"
},
{
"name": "Trigger Pipeline",
"description": "Run the repository pipeline selected by a branch or ref target"
},
{
"name": "Stop Pipeline",
"description": "Stop a running pipeline"
},
{
"name": "List Pipeline Steps",
"description": "List the steps in a pipeline"
},
{
"name": "Get Pipeline Step Log",
"description": "Read a bounded UTF-8 tail of a pipeline step log"
}
],
"operationCount": 30,
"triggers": [],
"triggerCount": 0,
"authType": "oauth",
"oauthServiceId": "bitbucket",
"category": "tools",
"integrationType": "devops",
"tags": ["version-control", "ci-cd", "automation"]
},
{
"type": "box",
"slug": "box",
@@ -896,6 +896,10 @@ export const OAUTH_CLIENT_SETUP_FIELDS = {
AIRTABLE_CLIENT_ID: { input: 'text' },
AIRTABLE_CLIENT_SECRET: { input: 'secret' },
},
bitbucket: {
BITBUCKET_CLIENT_ID: { input: 'text' },
BITBUCKET_CLIENT_SECRET: { input: 'secret' },
},
notion: {
NOTION_CLIENT_ID: { input: 'text' },
NOTION_CLIENT_SECRET: { input: 'secret' },
+2 -2
View File
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 1128,
zodRoutes: 1128,
totalRoutes: 1130,
zodRoutes: 1130,
nonZodRoutes: 0,
} as const