diff --git a/src/components/icons/BitbucketIcon.tsx b/src/components/icons/BitbucketIcon.tsx
new file mode 100644
index 000000000..fefa5f4aa
--- /dev/null
+++ b/src/components/icons/BitbucketIcon.tsx
@@ -0,0 +1,24 @@
+import type { IconProps } from "./types.ts";
+
+/**
+ * The Bitbucket logo as an inline SVG. lucide has no Bitbucket icon, so this
+ * takes the same props as one and fills with `currentColor`, which lets it sit
+ * next to the other source icons. The logo is a filled shape, so `strokeWidth`
+ * is accepted and ignored.
+ */
+export function BitbucketIcon({ className, "aria-hidden": ariaHidden, size }: IconProps) {
+ return (
+
+ );
+}
diff --git a/src/components/icons/types.ts b/src/components/icons/types.ts
new file mode 100644
index 000000000..cdb976627
--- /dev/null
+++ b/src/components/icons/types.ts
@@ -0,0 +1,16 @@
+import type { ComponentType } from "react";
+
+/** The props every icon in the app is drawn with, lucide or hand-made. */
+export type IconProps = {
+ className?: string;
+ "aria-hidden"?: boolean | "true" | "false";
+ size?: number | string;
+ strokeWidth?: number | string;
+};
+
+/**
+ * An icon component. Wider than `LucideIcon` so a brand logo that lucide does
+ * not ship (Bitbucket) can be used wherever a lucide icon is; every lucide icon
+ * still satisfies it.
+ */
+export type IconComponent = ComponentType;
diff --git a/src/features/access/registry.ts b/src/features/access/registry.ts
index 5501ca9a3..507a0df59 100644
--- a/src/features/access/registry.ts
+++ b/src/features/access/registry.ts
@@ -59,7 +59,8 @@ export const atlassianConnector = defineAccessConnector(
noun: { one: "credential", many: "credentials" },
addLabel: "Add credential",
emptyTitle: "No credentials yet",
- emptyDescription: "Add an Atlassian API token to connect Jira instances and Confluence spaces.",
+ emptyDescription:
+ "Add an Atlassian API token to connect Jira instances, Confluence spaces and Bitbucket repositories.",
useEntries: () => {
const { credentials, loaded, error, isRefreshing, reload } = useAtlassianCredentials();
diff --git a/src/features/admin/components/CreateProjectWizard.tsx b/src/features/admin/components/CreateProjectWizard.tsx
index a1d8f8f3a..04c61c343 100644
--- a/src/features/admin/components/CreateProjectWizard.tsx
+++ b/src/features/admin/components/CreateProjectWizard.tsx
@@ -14,6 +14,7 @@ import {
addDraftSource,
connectDraftSources,
connectOutcomeDescription,
+ createBitbucketDraftFromDiscovery,
createConfluenceDraft,
createDraftSourceFromDiscovery,
createJiraDraft,
@@ -82,8 +83,14 @@ const STEP_INDEX: Record, number> = {
review: 3,
};
-// All four connectors can now be staged from the add-source sub-flow.
-const AVAILABLE_SOURCE_TYPES: SourceSystem[] = ["GITHUB", "JIRA", "UPLOAD", "CONFLUENCE"];
+// All five connectors can now be staged from the add-source sub-flow.
+const AVAILABLE_SOURCE_TYPES: SourceSystem[] = [
+ "GITHUB",
+ "JIRA",
+ "UPLOAD",
+ "CONFLUENCE",
+ "BITBUCKET",
+];
/**
* Transactional create-project wizard: everything is drafted locally across the
@@ -149,6 +156,9 @@ export function CreateProjectWizard({
} = useGithubTokens();
const effectiveTokenNames = tokensLoaded ? loadedTokenNames : tokenNames;
+ const [bitbucketSelection, setBitbucketSelection] = useState([]);
+ const [bitbucketCredentialName, setBitbucketCredentialName] = useState("");
+
const [jiraDisplayName, setJiraDisplayName] = useState("");
const [jiraUrl, setJiraUrl] = useState("");
const [jiraCredentialName, setJiraCredentialName] = useState("");
@@ -198,8 +208,9 @@ export function CreateProjectWizard({
const isJiraDetail = isAddingSource && addStep === "detail" && addType === "JIRA";
const isConfluenceDetail = isAddingSource && addStep === "detail" && addType === "CONFLUENCE";
- // Jira and Confluence share the same Atlassian credential store, so one
- // instance of the hook backs both detail screens' pickers.
+ const isBitbucketDetail = isAddingSource && addStep === "detail" && addType === "BITBUCKET";
+ // Jira, Confluence and Bitbucket share the same Atlassian credential store, so
+ // one instance of the hook backs all three detail screens' pickers.
const {
credentials: jiraCredentials,
loaded: jiraCredentialsLoaded,
@@ -207,7 +218,7 @@ export function CreateProjectWizard({
isRefreshing: jiraCredentialsLoading,
reload: reloadJiraCredentials,
addCredentialLocally,
- } = useAtlassianCredentials(isOpen && (isJiraDetail || isConfluenceDetail));
+ } = useAtlassianCredentials(isOpen && (isJiraDetail || isConfluenceDetail || isBitbucketDetail));
// The token list arrives asynchronously; adopt the first token as soon as it
// does (and heal a stale selection) so discovery is usable on the first open.
@@ -221,10 +232,10 @@ export function CreateProjectWizard({
});
}, [effectiveTokenNames]);
- // Same adoption pattern for the Jira and Confluence credential pickers:
- // select the first stored credential once the list arrives, keeping a
- // still-valid choice. Both fields share the list, so a credential just added
- // from either detail screen is adopted here too.
+ // Same adoption pattern for the Jira, Confluence and Bitbucket credential
+ // pickers: select the first stored credential once the list arrives, keeping a
+ // still-valid choice. All fields share the list, so a credential just added
+ // from any detail screen is adopted here too.
useEffect(() => {
if (!jiraCredentialsLoaded || jiraCredentialsLoading) return;
@@ -241,6 +252,12 @@ export function CreateProjectWizard({
? current
: jiraCredentials[0].displayName;
});
+ setBitbucketCredentialName((current) => {
+ if (jiraCredentials.length === 0) return "";
+ return current && jiraCredentials.some((credential) => credential.displayName === current)
+ ? current
+ : jiraCredentials[0].displayName;
+ });
});
}, [jiraCredentials, jiraCredentialsLoaded, jiraCredentialsLoading]);
@@ -267,6 +284,11 @@ export function CreateProjectWizard({
void Promise.resolve().then(loadManagerCandidates);
}, [isOpen, loadManagerCandidates]);
+ const resetBitbucketDraftFields = () => {
+ setBitbucketSelection([]);
+ setBitbucketCredentialName("");
+ };
+
const resetConfluenceDraftFields = () => {
setConfluenceBaseUrl("");
setConfluenceSpaceId("");
@@ -288,6 +310,7 @@ export function CreateProjectWizard({
setAddStep("type");
setAddType("GITHUB");
setGithubSelection([]);
+ resetBitbucketDraftFields();
resetJiraDraftFields();
resetConfluenceDraftFields();
setUploadFiles([]);
@@ -302,6 +325,7 @@ export function CreateProjectWizard({
const resetSourceDraftFields = () => {
setGithubSelection([]);
+ resetBitbucketDraftFields();
resetJiraDraftFields();
resetConfluenceDraftFields();
setUploadFiles([]);
@@ -451,6 +475,12 @@ export function CreateProjectWizard({
await reloadJiraCredentials();
};
+ const handleBitbucketCredentialSaved = async (credential: AtlassianCredentialDto) => {
+ addCredentialLocally(credential);
+ setBitbucketCredentialName(credential.displayName);
+ await reloadJiraCredentials();
+ };
+
const handleConfluenceCredentialSaved = async (credential: AtlassianCredentialDto) => {
addCredentialLocally(credential);
setConfluenceCredentialName(credential.displayName);
@@ -468,17 +498,19 @@ export function CreateProjectWizard({
const canAddSource =
addType === "GITHUB"
? githubSelection.length > 0
- : addType === "JIRA"
- ? Boolean(jiraDisplayName.trim() && jiraUrl.trim() && selectedJiraCredential)
- : addType === "UPLOAD"
- ? uploadFiles.length > 0
- : addType === "CONFLUENCE"
- ? Boolean(
- confluenceBaseUrl.trim() &&
- isValidConfluenceSpaceId(confluenceSpaceId) &&
- selectedConfluenceCredential,
- )
- : false;
+ : addType === "BITBUCKET"
+ ? bitbucketSelection.length > 0 && Boolean(bitbucketCredentialName)
+ : addType === "JIRA"
+ ? Boolean(jiraDisplayName.trim() && jiraUrl.trim() && selectedJiraCredential)
+ : addType === "UPLOAD"
+ ? uploadFiles.length > 0
+ : addType === "CONFLUENCE"
+ ? Boolean(
+ confluenceBaseUrl.trim() &&
+ isValidConfluenceSpaceId(confluenceSpaceId) &&
+ selectedConfluenceCredential,
+ )
+ : false;
const commitAddSource = () => {
if (!canAddSource) return;
@@ -491,6 +523,17 @@ export function CreateProjectWizard({
current,
),
);
+ } else if (addType === "BITBUCKET") {
+ setSources((current) =>
+ bitbucketSelection.reduce(
+ (accumulated, selection) =>
+ addDraftSource(
+ accumulated,
+ createBitbucketDraftFromDiscovery(selection, bitbucketCredentialName),
+ ),
+ current,
+ ),
+ );
} else if (addType === "JIRA" && selectedJiraCredential) {
setSources((current) =>
addDraftSource(
@@ -865,6 +908,17 @@ export function CreateProjectWizard({
onSelectionChange: setGithubSelection,
onTokenSaved: handleTokenSaved,
}}
+ bitbucket={{
+ credentialName: bitbucketCredentialName,
+ credentials: jiraCredentials,
+ credentialsLoaded: jiraCredentialsLoaded,
+ credentialsLoading: jiraCredentialsLoading,
+ credentialsError: jiraCredentialsError,
+ defaultUserEmail: null,
+ onCredentialNameChange: setBitbucketCredentialName,
+ onSelectionChange: setBitbucketSelection,
+ onCredentialSaved: handleBitbucketCredentialSaved,
+ }}
jira={{
displayName: jiraDisplayName,
url: jiraUrl,
diff --git a/src/features/admin/components/DrawerCard.tsx b/src/features/admin/components/DrawerCard.tsx
index 7ed3463c3..56d34391c 100644
--- a/src/features/admin/components/DrawerCard.tsx
+++ b/src/features/admin/components/DrawerCard.tsx
@@ -1,12 +1,12 @@
import { motion, useReducedMotion } from "framer-motion";
-import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
+import type { IconComponent } from "../../../components/icons/types.ts";
type DrawerCardProps = {
/** Uppercase section label shown in the card header. Omit for a headerless card. */
label?: string;
- /** Optional lucide icon rendered in a soft tile beside the label. */
- icon?: LucideIcon;
+ /** Optional icon (lucide or a brand logo) rendered in a soft tile beside the label. */
+ icon?: IconComponent;
/** Position in the body stack, so cards reveal in sequence when the drawer opens. */
index?: number;
/** Optional trailing content in the header row (counts, actions). */
diff --git a/src/features/admin/components/StagedSourceList.tsx b/src/features/admin/components/StagedSourceList.tsx
index 8c7ad8705..9ef82994b 100644
--- a/src/features/admin/components/StagedSourceList.tsx
+++ b/src/features/admin/components/StagedSourceList.tsx
@@ -9,6 +9,7 @@ import {
Trash2,
Ticket,
} from "lucide-react";
+import { BitbucketIcon } from "../../../components/icons/BitbucketIcon";
import { Button } from "../../../components/ui/Button";
import { FilterSelect } from "../../../components/ui/FilterSelect";
import { NO_OWNER_OPTION, type SourceOwnerOption } from "../sourceOwners";
@@ -60,6 +61,10 @@ function TypeIcon({ source }: { source: DraftSource }) {
return ;
}
+ if (source.type === "BITBUCKET") {
+ return ;
+ }
+
return ;
}
@@ -82,6 +87,7 @@ function StatusIcon({ source }: { source: DraftSource }) {
/** Primary line: the human name of the source, by type. */
function sourceTitle(source: DraftSource): string {
if (source.type === "GITHUB") return `${source.owner}/${source.name}`;
+ if (source.type === "BITBUCKET") return `${source.workspace}/${source.slug}`;
return source.displayName;
}
@@ -89,7 +95,7 @@ function sourceTitle(source: DraftSource): string {
/**
* Secondary line shown when the source is not in a failed state: the instance
* URL for Jira, the staged file count for an upload, or the credential for a
- * GitHub repository.
+ * GitHub or Bitbucket repository.
*/
function sourceDetail(source: DraftSource): string {
if (source.type === "UPLOAD") {
@@ -104,12 +110,16 @@ function sourceDetail(source: DraftSource): string {
return `${source.baseUrl} (${source.spaceId})`;
}
+ if (source.type === "BITBUCKET") {
+ return source.credentialName;
+ }
+
return source.tokenName;
}
/**
- * The status line under the title. A staged GitHub repository that is already
- * ingested elsewhere is linked rather than fetched, so "Not connected yet" would
+ * The status line under the title. A staged GitHub or Bitbucket repository that
+ * is already ingested elsewhere is linked rather than fetched, so "Not connected yet" would
* misdescribe it — it says so instead, before and after the run.
*
* Saying so afterwards matters as much as before: a linked source finishes
@@ -118,7 +128,11 @@ function sourceDetail(source: DraftSource): string {
* worked at all.
*/
function statusDescription(source: DraftSource): string {
- if (source.status === "pending" && source.type === "GITHUB" && source.repositoryId) {
+ if (
+ source.status === "pending" &&
+ (source.type === "GITHUB" || source.type === "BITBUCKET") &&
+ source.repositoryId
+ ) {
return "Already ingested, will be linked";
}
diff --git a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx
index 44394294e..0f16d3f95 100644
--- a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx
+++ b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx
@@ -19,6 +19,7 @@ import {
GithubRepositoryDiscovery,
type DiscoverySelection,
} from "../../../../data-ingestion/components/GithubRepositoryDiscovery";
+import { BitbucketRepositoryDiscovery } from "../../../../data-ingestion/components/BitbucketRepositoryDiscovery";
import { JiraConnectStep } from "../../../../data-ingestion/components/JiraConnectStep";
import { ConfluenceConnectStep } from "../../../../data-ingestion/components/ConfluenceConnectStep";
import { SourceTypeStep } from "../../../../data-ingestion/components/SourceTypeStep";
@@ -57,6 +58,28 @@ type GithubDetailProps = {
projectName?: string;
};
+/** Bitbucket detail is fully controlled so the footer's "Add to list" can read it. */
+type BitbucketDetailProps = {
+ credentialName: string;
+ credentials: AtlassianCredentialDto[];
+ credentialsLoaded: boolean;
+ credentialsLoading: boolean;
+ credentialsError: string | null;
+ /** Prefill for the inline "add credential" form's account-email field. */
+ defaultUserEmail: string | null;
+ onCredentialNameChange: (value: string) => void;
+ /** Must be stable (a state setter) — the picker only re-reports on change. */
+ onSelectionChange: (selection: DiscoverySelection[]) => void;
+ /** Adds the new credential to the list, selects it, and reconciles with the server. */
+ onCredentialSaved: (credential: AtlassianCredentialDto) => Promise;
+ /**
+ * Scopes discovery to an existing project so repositories already connected to
+ * it are flagged and cannot be staged twice. `null`/omitted (the create-project
+ * wizard, where no project exists yet) discovers without that scope.
+ */
+ projectId?: string | null;
+};
+
/** Jira detail — a staged form; nothing connects until provisioning. */
type JiraDetailProps = {
displayName: string;
@@ -119,6 +142,12 @@ type AddSourceFlowProps = {
onBack: () => void;
isBusy?: boolean;
github: GithubDetailProps;
+ /**
+ * The Bitbucket detail's state. Omitted by a flow that does not offer
+ * Bitbucket; selecting the type there renders nothing rather than falling
+ * through to another connector's screen.
+ */
+ bitbucket?: BitbucketDetailProps;
jira: JiraDetailProps;
upload: UploadDetailProps;
confluence: ConfluenceDetailProps;
@@ -428,6 +457,53 @@ function GithubDetail({
);
}
+/** Bitbucket detail with an "add credential" trigger above the discovery. */
+function BitbucketDetail({
+ isBusy,
+ bitbucket,
+ onCompanionOpenChange,
+}: {
+ isBusy: boolean;
+ bitbucket: BitbucketDetailProps;
+ onCompanionOpenChange?: (open: boolean) => void;
+}) {
+ // Only hint "nothing stored" once the list has loaded, so the chip does not
+ // flash while credentials are still being fetched.
+ const missingCredential = bitbucket.credentialsLoaded && bitbucket.credentials.length === 0;
+
+ return (
+
+ );
+}
+
/** Jira detail with an "add credential" trigger above the form. */
function JiraDetail({
isBusy,
@@ -568,6 +644,7 @@ const DETAIL_SUBTITLE: Record = {
JIRA: "Point to your Jira instance and pick a credential, then add it to the list.",
UPLOAD: "Files are staged now and uploaded right after the project is created.",
CONFLUENCE: "Point to your Confluence space and pick a credential, then add it to the list.",
+ BITBUCKET: "Pick a credential and a workspace, then choose the repositories to index.",
};
/**
@@ -623,6 +700,7 @@ export function AddSourceFlow({
onBack,
isBusy = false,
github,
+ bitbucket,
jira,
upload,
confluence,
@@ -631,7 +709,15 @@ export function AddSourceFlow({
const prefersReducedMotion = useReducedMotion();
const detail =
- selectedType === "JIRA" ? (
+ selectedType === "BITBUCKET" ? (
+ bitbucket ? (
+
+ ) : null
+ ) : selectedType === "JIRA" ? (
) : selectedType === "UPLOAD" ? (
>
JIRA: Ticket,
UPLOAD: FileText,
CONFLUENCE: BookOpen,
+ BITBUCKET: BitbucketIcon,
};
const typeLabels: Record = {
@@ -42,10 +44,14 @@ const typeLabels: Record = {
JIRA: "Jira",
UPLOAD: "Upload",
CONFLUENCE: "Confluence",
+ BITBUCKET: "Bitbucket",
};
function sourceTitle(source: DraftSource): string {
- return source.type === "GITHUB" ? `${source.owner}/${source.name}` : source.displayName;
+ if (source.type === "GITHUB") return `${source.owner}/${source.name}`;
+ if (source.type === "BITBUCKET") return `${source.workspace}/${source.slug}`;
+
+ return source.displayName;
}
function PersonChip({ person, suffix }: { person: ReviewPerson; suffix?: string }) {
diff --git a/src/features/admin/projectSourcesDraft.ts b/src/features/admin/projectSourcesDraft.ts
index c95f739cf..5bde156c8 100644
--- a/src/features/admin/projectSourcesDraft.ts
+++ b/src/features/admin/projectSourcesDraft.ts
@@ -2,6 +2,11 @@ import {
addRepositoryToProject,
connectGithubRepository,
} from "../../services/sources/githubService";
+import { ApiError } from "../../services/apiClient";
+import {
+ addBitbucketRepositoryToProject,
+ connectBitbucketRepository,
+} from "../../services/sources/bitbucketService";
import { connectJiraInstance } from "../../services/sources/jiraService";
import { confluenceService } from "../../services/sources/confluenceService";
import { knowledgeGapService } from "../../services/knowledgeGapService";
@@ -16,16 +21,17 @@ import type { DiscoverySelection } from "../data-ingestion/components/GithubRepo
* outcome so a partial failure can be shown and retried per source instead of
* failing the whole batch.
*
- * A source can be one of four kinds — a GitHub repository, a Jira instance,
- * an in-memory file upload, or a Confluence space — modelled as a discriminated
- * union on `type` so a single list can hold a mix of all four. Nothing here
+ * A source can be one of five kinds — a GitHub repository, a Bitbucket
+ * repository, a Jira instance, an in-memory file upload, or a Confluence space —
+ * modelled as a discriminated union on `type` so a single list can hold a mix of
+ * all of them. Nothing here
* touches the backend until {@link connectDraftSources} runs during
* provisioning; uploads in particular hold their `File[]` in memory until then.
*/
export type DraftSourceStatus = "pending" | "connecting" | "connected" | "failed";
-export type DraftSourceType = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE";
+export type DraftSourceType = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE" | "BITBUCKET";
/** Fields every staged source carries regardless of its type. */
type DraftSourceBase = {
@@ -88,6 +94,20 @@ export type GithubDraftSource = DraftSourceBase & {
repositoryId?: string;
};
+export type BitbucketDraftSource = DraftSourceBase & {
+ type: "BITBUCKET";
+ workspace: string;
+ slug: string;
+ /** Name of a stored Atlassian credential, shared with Jira and Confluence. */
+ credentialName: string;
+ /**
+ * Set when the repository is already ingested elsewhere: connecting then only
+ * links it to the project (reusing its artifacts) instead of fetching and
+ * ingesting it again. Absent for genuinely new repositories.
+ */
+ repositoryId?: string;
+};
+
export type JiraDraftSource = DraftSourceBase & {
type: "JIRA";
displayName: string;
@@ -114,7 +134,11 @@ export type ConfluenceDraftSource = DraftSourceBase & {
};
export type DraftSource =
- GithubDraftSource | JiraDraftSource | UploadDraftSource | ConfluenceDraftSource;
+ | GithubDraftSource
+ | BitbucketDraftSource
+ | JiraDraftSource
+ | UploadDraftSource
+ | ConfluenceDraftSource;
let draftSourceCounter = 0;
@@ -161,6 +185,44 @@ export function createDraftSourceFromDiscovery(
);
}
+export function createBitbucketDraft(
+ workspace: string,
+ slug: string,
+ credentialName: string,
+ repositoryId?: string,
+): BitbucketDraftSource {
+ return {
+ id: nextDraftSourceId(),
+ type: "BITBUCKET",
+ workspace,
+ slug,
+ credentialName,
+ status: "pending",
+ errorMessage: "",
+ ownerAssignmentFailed: false,
+ wasReused: false,
+ repositoryId,
+ };
+}
+
+/**
+ * Stages a repository picked in the Bitbucket discovery flow. A `linkable`
+ * selection carries the repository id so it can be linked without re-ingesting;
+ * everything else is staged as a new repository to fetch and ingest. The
+ * selection's `owner` is the workspace and its `name` the repository slug.
+ */
+export function createBitbucketDraftFromDiscovery(
+ selection: DiscoverySelection,
+ credentialName: string,
+): BitbucketDraftSource {
+ return createBitbucketDraft(
+ selection.owner,
+ selection.name,
+ credentialName,
+ selection.linkState === "linkable" ? selection.repositoryId : undefined,
+ );
+}
+
export function createJiraDraft(params: {
displayName: string;
url: string;
@@ -226,7 +288,8 @@ export function createConfluenceDraft(params: {
/**
* Whether two drafts point at the same underlying source, used to dedupe on
- * add. Identity is per type: GitHub by `owner/name`, Jira by instance URL; two
+ * add. Identity is per type: GitHub by `owner/name`, Bitbucket by
+ * `workspace/slug`, Jira by instance URL; two
* uploads are always distinct (the same file can legitimately be staged twice).
* Drafts of different types are never the same source.
*/
@@ -240,6 +303,13 @@ export function isSameSource(left: DraftSource, right: DraftSource): boolean {
);
}
+ if (left.type === "BITBUCKET" && right.type === "BITBUCKET") {
+ return (
+ left.workspace.toLowerCase() === right.workspace.toLowerCase() &&
+ left.slug.toLowerCase() === right.slug.toLowerCase()
+ );
+ }
+
if (left.type === "JIRA" && right.type === "JIRA") {
return left.url.trim().toLowerCase() === right.url.trim().toLowerCase();
}
@@ -431,6 +501,40 @@ async function connectOneDraftSource(
};
}
+ if (source.type === "BITBUCKET") {
+ if (source.repositoryId) {
+ // Already ingested elsewhere: link it to this project, reusing its artifacts.
+ await addBitbucketRepositoryToProject(source.repositoryId, projectId);
+
+ return { wasReused: true, ownerAssignmentFailed: false };
+ }
+
+ try {
+ await connectBitbucketRepository({
+ workspace: source.workspace,
+ slug: source.slug,
+ credentialName: source.credentialName,
+ projectId,
+ });
+ } catch (error) {
+ // A repository Bitbucket cannot find arrives as a 404 whose message names
+ // it. A failed Bitbucket call during the check (a rejected token, an
+ // outage) arrives as a bare 500, so say what the likely cause is instead
+ // of showing the status text.
+ if (error instanceof ApiError && error.status >= 500) {
+ throw new Error(
+ `Couldn't connect ${source.workspace}/${source.slug}. Check that the repository exists and that the selected credential can read it.`,
+ );
+ }
+
+ throw error;
+ }
+
+ // The backend reports no `wasReused` for Bitbucket, so a connect that found
+ // an existing connection cannot be told apart from a fresh one.
+ return NOTHING_EXTRA;
+ }
+
if (source.type === "JIRA") {
await connectJiraInstance({
displayName: source.displayName,
diff --git a/src/features/chatbot/hooks/useAvailableSources.ts b/src/features/chatbot/hooks/useAvailableSources.ts
index 0f22df66a..2fc3e05c5 100644
--- a/src/features/chatbot/hooks/useAvailableSources.ts
+++ b/src/features/chatbot/hooks/useAvailableSources.ts
@@ -10,7 +10,7 @@ const ALWAYS_AVAILABLE: readonly SourceSystem[] = ["UPLOAD"];
/**
* Maps a backend connector id onto the source system the chat filters by. Connector ids are
- * lowercase (`github`, `jira`, `confluence`); the filter values are the uppercase enum constants.
+ * lowercase (`github`, `jira`, `confluence`, `bitbucket`); the filter values are the uppercase enum constants.
*/
function toSourceSystem(connectorId: string): SourceSystem | null {
const candidate = connectorId.toUpperCase();
diff --git a/src/features/chatbot/types.ts b/src/features/chatbot/types.ts
index 77145675c..96b1d8eca 100644
--- a/src/features/chatbot/types.ts
+++ b/src/features/chatbot/types.ts
@@ -184,6 +184,6 @@ export type StreamHandlers = {
onError?: (message: string) => void;
};
-export const SOURCE_SYSTEMS = ["GITHUB", "UPLOAD", "JIRA", "CONFLUENCE"] as const;
+export const SOURCE_SYSTEMS = ["GITHUB", "UPLOAD", "JIRA", "CONFLUENCE", "BITBUCKET"] as const;
export type SourceSystem = (typeof SOURCE_SYSTEMS)[number];
diff --git a/src/features/connectors/data.ts b/src/features/connectors/data.ts
index 8e0ccfcf5..75532aa40 100644
--- a/src/features/connectors/data.ts
+++ b/src/features/connectors/data.ts
@@ -1,4 +1,5 @@
import { BookOpen, GitBranch, Plug } from "lucide-react";
+import { BitbucketIcon } from "../../components/icons/BitbucketIcon.tsx";
import type { ConnectorDto } from "../../services/connectorService.ts";
import type { ConnectorListItem, ConnectorMeta } from "./types.ts";
@@ -12,6 +13,11 @@ const CONNECTOR_META: Record = {
"Commits, files, issues and pull request metadata from connected GitHub repositories.",
icon: GitBranch,
},
+ bitbucket: {
+ label: "Bitbucket Repository Connector",
+ description: "Pull requests, files and metadata from connected Bitbucket Cloud repositories.",
+ icon: BitbucketIcon,
+ },
confluence: {
label: "Confluence Cloud Connector",
description: "Pages and spaces from connected Confluence Cloud tenants.",
diff --git a/src/features/connectors/types.ts b/src/features/connectors/types.ts
index e460eb014..f73b9501f 100644
--- a/src/features/connectors/types.ts
+++ b/src/features/connectors/types.ts
@@ -1,4 +1,4 @@
-import type { LucideIcon } from "lucide-react";
+import type { IconComponent } from "../../components/icons/types.ts";
import type { ConnectorDto, ConnectorSource } from "../../services/connectorService.ts";
export type { ConnectorDto, ConnectorSource } from "../../services/connectorService.ts";
@@ -14,7 +14,7 @@ export type LoadingState = "idle" | "loading" | "success" | "error";
export type ConnectorMeta = {
label: string;
description: string;
- icon: LucideIcon;
+ icon: IconComponent;
};
/**
diff --git a/src/features/data-ingestion/components/AddSourceModal.tsx b/src/features/data-ingestion/components/AddSourceModal.tsx
index 3541bf20e..c1ed4239a 100644
--- a/src/features/data-ingestion/components/AddSourceModal.tsx
+++ b/src/features/data-ingestion/components/AddSourceModal.tsx
@@ -11,6 +11,7 @@ import {
addDraftSource,
connectDraftSources,
connectOutcomeDescription,
+ createBitbucketDraftFromDiscovery,
createConfluenceDraft,
createDraftSourceFromDiscovery,
createJiraDraft,
@@ -144,6 +145,10 @@ export function AddSourceModal({
} = useGithubTokens();
const effectiveTokenNames = tokensLoaded ? loadedTokenNames : tokenNames;
+ // Bitbucket detail state.
+ const [bitbucketSelection, setBitbucketSelection] = useState([]);
+ const [bitbucketCredentialName, setBitbucketCredentialName] = useState("");
+
// Jira detail state.
const [jiraDisplayName, setJiraDisplayName] = useState("");
const [jiraUrl, setJiraUrl] = useState("");
@@ -159,8 +164,9 @@ export function AddSourceModal({
const isJiraDetail = isAddingSource && addStep === "detail" && addType === "JIRA";
const isConfluenceDetail = isAddingSource && addStep === "detail" && addType === "CONFLUENCE";
- // Jira and Confluence share the same Atlassian credential store, so one
- // instance of the hook backs both detail screens' pickers.
+ const isBitbucketDetail = isAddingSource && addStep === "detail" && addType === "BITBUCKET";
+ // Jira, Confluence and Bitbucket share the same Atlassian credential store, so
+ // one instance of the hook backs all three detail screens' pickers.
const {
credentials: jiraCredentials,
loaded: jiraCredentialsLoaded,
@@ -168,7 +174,7 @@ export function AddSourceModal({
isRefreshing: jiraCredentialsLoading,
reload: reloadJiraCredentials,
addCredentialLocally,
- } = useAtlassianCredentials(isJiraDetail || isConfluenceDetail);
+ } = useAtlassianCredentials(isJiraDetail || isConfluenceDetail || isBitbucketDetail);
// Adopt the first token as soon as the list arrives (and heal a stale
// selection) so discovery is usable on the first open.
@@ -183,7 +189,7 @@ export function AddSourceModal({
}, [effectiveTokenNames]);
// Adopt the first stored Atlassian credential once the list arrives, keeping
- // a still-valid choice — shared by the Jira and Confluence pickers.
+ // a still-valid choice — shared by the Jira, Confluence and Bitbucket pickers.
useEffect(() => {
if (!jiraCredentialsLoaded || jiraCredentialsLoading) return;
@@ -200,11 +206,18 @@ export function AddSourceModal({
? current
: jiraCredentials[0].displayName;
});
+ setBitbucketCredentialName((current) => {
+ if (jiraCredentials.length === 0) return "";
+ return current && jiraCredentials.some((credential) => credential.displayName === current)
+ ? current
+ : jiraCredentials[0].displayName;
+ });
});
}, [jiraCredentials, jiraCredentialsLoaded, jiraCredentialsLoading]);
const resetSourceDraftFields = () => {
setGithubSelection([]);
+ setBitbucketSelection([]);
setJiraDisplayName("");
setJiraUrl("");
setJiraCredentialName("");
@@ -260,6 +273,12 @@ export function AddSourceModal({
await reloadJiraCredentials();
};
+ const handleBitbucketCredentialSaved = async (credential: AtlassianCredentialDto) => {
+ addCredentialLocally(credential);
+ setBitbucketCredentialName(credential.displayName);
+ await reloadJiraCredentials();
+ };
+
const selectedJiraCredential = jiraCredentials.find(
(credential) => credential.displayName === jiraCredentialName,
);
@@ -271,21 +290,23 @@ export function AddSourceModal({
const canAddSource =
addType === "GITHUB"
? githubSelection.length > 0
- : addType === "JIRA"
- ? Boolean(jiraDisplayName.trim() && jiraUrl.trim() && selectedJiraCredential)
- : addType === "UPLOAD"
- ? uploadFiles.length > 0
- : addType === "CONFLUENCE"
- ? Boolean(
- confluenceBaseUrl.trim() &&
- isValidConfluenceSpaceId(confluenceSpaceId) &&
- selectedConfluenceCredential,
- )
- : false;
+ : addType === "BITBUCKET"
+ ? bitbucketSelection.length > 0 && Boolean(bitbucketCredentialName)
+ : addType === "JIRA"
+ ? Boolean(jiraDisplayName.trim() && jiraUrl.trim() && selectedJiraCredential)
+ : addType === "UPLOAD"
+ ? uploadFiles.length > 0
+ : addType === "CONFLUENCE"
+ ? Boolean(
+ confluenceBaseUrl.trim() &&
+ isValidConfluenceSpaceId(confluenceSpaceId) &&
+ selectedConfluenceCredential,
+ )
+ : false;
/**
* The draft(s) captured on the current detail screen — several at once for the
- * GitHub multi-select, one for Jira/Upload/Confluence. Empty when the detail isn't
+ * GitHub and Bitbucket multi-selects, one for Jira/Upload/Confluence. Empty when the detail isn't
* complete enough to stage.
*/
const buildDetailDrafts = (): DraftSource[] => {
@@ -297,6 +318,12 @@ export function AddSourceModal({
);
}
+ if (addType === "BITBUCKET") {
+ return bitbucketSelection.map((selection) =>
+ createBitbucketDraftFromDiscovery(selection, bitbucketCredentialName),
+ );
+ }
+
if (addType === "JIRA" && selectedJiraCredential) {
return [
createJiraDraft({
@@ -572,6 +599,18 @@ export function AddSourceModal({
projectId,
projectName,
}}
+ bitbucket={{
+ credentialName: bitbucketCredentialName,
+ credentials: jiraCredentials,
+ credentialsLoaded: jiraCredentialsLoaded,
+ credentialsLoading: jiraCredentialsLoading,
+ credentialsError: jiraCredentialsError,
+ defaultUserEmail: null,
+ onCredentialNameChange: setBitbucketCredentialName,
+ onSelectionChange: setBitbucketSelection,
+ onCredentialSaved: handleBitbucketCredentialSaved,
+ projectId,
+ }}
jira={{
displayName: jiraDisplayName,
url: jiraUrl,
diff --git a/src/features/data-ingestion/components/BitbucketRepositoryDiscovery.tsx b/src/features/data-ingestion/components/BitbucketRepositoryDiscovery.tsx
new file mode 100644
index 000000000..80c25b76b
--- /dev/null
+++ b/src/features/data-ingestion/components/BitbucketRepositoryDiscovery.tsx
@@ -0,0 +1,184 @@
+import { AlertTriangle } from "lucide-react";
+import { DropdownSelect } from "../../../components/ui/DropdownSelect";
+import { ApiError } from "../../../services/apiClient.ts";
+import type { AtlassianCredentialDto } from "../../../services/sources/atlassianService.ts";
+import { discoverBitbucketRepositories } from "../../../services/sources/bitbucketService.ts";
+import {
+ parseBitbucketRepositoryReference,
+ parseBitbucketWorkspaceInput,
+} from "../../../services/sources/bitbucketRepositoryInput.ts";
+import {
+ RepositoryDiscovery,
+ type DiscoverySelection,
+ type RepositoryDiscoveryAdapter,
+} from "./RepositoryDiscovery.tsx";
+
+type BitbucketRepositoryDiscoveryProps = {
+ /** The caller's stored Atlassian credentials, shared with Jira and Confluence. */
+ credentials: AtlassianCredentialDto[];
+ /** Whether the credential list has finished loading, so "none stored" is not shown early. */
+ credentialsLoaded: boolean;
+ credentialsLoading: boolean;
+ credentialsError: string | null;
+ /** Controlled credential selection (the parent needs it at connect time). */
+ credentialName: string;
+ onCredentialNameChange: (name: string) => void;
+ /**
+ * Project the repositories would be connected to, or `null` when there is no
+ * project yet (the create-project wizard). See {@link RepositoryDiscovery}.
+ */
+ projectId: string | null;
+ /** Reports the resolved selection whenever it changes. Must be stable. */
+ onSelectionChange: (selection: DiscoverySelection[]) => void;
+ /** True while the parent runs its connect batch; locks the inputs. */
+ isConnecting?: boolean;
+ /** Connect error from the parent, shown below any discovery error. */
+ connectError?: string | null;
+ /**
+ * Hides the built-in "no stored credential" banner. Set when the parent shows
+ * its own hint (e.g. the wizard's chip next to its inline "Add credential"
+ * button) so the message is not duplicated.
+ */
+ suppressMissingCredentialNotice?: boolean;
+};
+
+/**
+ * How {@link RepositoryDiscovery} talks to Bitbucket Cloud. Discovery is always
+ * scoped to one workspace (Bitbucket has no cross-workspace listing), so the
+ * owner field takes a workspace id, `bitbucket.org/` or a repository
+ * URL, which narrows the list to that repository. The selection's `name` is the
+ * repository slug; the display name only labels the row.
+ */
+const BITBUCKET_DISCOVERY_ADAPTER: RepositoryDiscoveryAdapter = {
+ sourceSystem: "BITBUCKET",
+ providerName: "Bitbucket",
+ discover: async (workspace, credentialName, page, pageSize) => {
+ const result = await discoverBitbucketRepositories(workspace, credentialName, page, pageSize);
+
+ return {
+ repositories: result.repositories.map((repository) => ({
+ name: repository.slug,
+ // The slug only adds information when it is more than the lowercased
+ // display name ("Widget Shop (widget-shop)", but just "Widgets").
+ label:
+ repository.name === repository.slug
+ ? undefined
+ : repository.name.toLowerCase() === repository.slug.toLowerCase()
+ ? repository.name
+ : `${repository.name} (${repository.slug})`,
+ isPrivate: repository.isPrivate,
+ url: repository.url,
+ alreadyConnected: repository.alreadyConnected,
+ isEnabled: repository.isEnabled,
+ })),
+ hasMore: result.hasMore,
+ };
+ },
+ parseInput: (value) => {
+ const reference = parseBitbucketRepositoryReference(value);
+ if (reference) return { owner: reference.workspace, name: reference.slug };
+
+ const workspace = parseBitbucketWorkspaceInput(value);
+ return workspace ? { owner: workspace, name: null } : null;
+ },
+ ownerLabel: "Workspace or bitbucket.org URL",
+ ownerPlaceholder: "acme, bitbucket.org/acme, or a repo URL",
+ invalidInputMessage:
+ "Enter a Bitbucket workspace or repository URL (e.g. acme or bitbucket.org/acme/widgets).",
+ credentialRequiredMessage: "Choose a stored Atlassian credential.",
+ missingCredentialNotice:
+ "No Atlassian credentials are stored for your account. Add one under Settings, Access Tokens, Atlassian first, then come back to discover repositories.",
+ emptyResultHint:
+ "The credential may not be allowed to read this workspace's repositories. Its token needs Bitbucket scopes for repositories, pull requests and the workspace.",
+ describeError: (error, workspace) => {
+ if (error instanceof ApiError && error.status === 404) {
+ return `The selected credential was not found, or no workspace "${workspace}" is visible to it.`;
+ }
+
+ if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
+ return `The selected credential can't read workspace "${workspace}". Check that its token has Bitbucket scopes.`;
+ }
+
+ if (error instanceof ApiError && error.status === 429) {
+ return "Bitbucket rate limit reached. Please wait a moment and try again.";
+ }
+
+ return null;
+ },
+};
+
+/**
+ * Bitbucket workspace repository discovery: {@link RepositoryDiscovery} with the
+ * Bitbucket adapter and an Atlassian credential picker. Used by the add-source
+ * modal and the create-project wizard.
+ */
+export function BitbucketRepositoryDiscovery({
+ credentials,
+ credentialsLoaded,
+ credentialsLoading,
+ credentialsError,
+ credentialName,
+ onCredentialNameChange,
+ projectId,
+ onSelectionChange,
+ isConnecting = false,
+ connectError,
+ suppressMissingCredentialNotice = false,
+}: BitbucketRepositoryDiscoveryProps) {
+ const hasCredentials = credentials.length > 0;
+
+ return (
+
+
+ The credential’s token needs Bitbucket scopes: read access to repositories, pull
+ requests and the workspace.
+
+
+ {credentialsError && (
+
+ )}
+
+
(
+
+ Credential
+ ({
+ value: credential.displayName,
+ label: `${credential.displayName} - ${credential.userEmail}`,
+ }))
+ : [
+ {
+ value: "",
+ label: credentialsLoading ? "Loading credentials..." : "No credentials",
+ },
+ ]
+ }
+ onChange={onCredentialNameChange}
+ disabled={disabled}
+ className="mt-2 sm:w-64"
+ />
+
+ )}
+ projectId={projectId}
+ onSelectionChange={onSelectionChange}
+ isConnecting={isConnecting}
+ connectError={connectError}
+ // The banner only means something once the list has loaded.
+ suppressMissingCredentialNotice={
+ suppressMissingCredentialNotice || !credentialsLoaded || credentialsLoading
+ }
+ />
+
+ );
+}
diff --git a/src/features/data-ingestion/components/GithubRepositoryDiscovery.tsx b/src/features/data-ingestion/components/GithubRepositoryDiscovery.tsx
index 16db2a0a7..2eb9983dd 100644
--- a/src/features/data-ingestion/components/GithubRepositoryDiscovery.tsx
+++ b/src/features/data-ingestion/components/GithubRepositoryDiscovery.tsx
@@ -1,59 +1,19 @@
-import {
- AlertTriangle,
- Check,
- CheckCircle2,
- ExternalLink,
- GitBranch,
- Loader2,
- Lock,
- RefreshCw,
- Search,
-} from "lucide-react";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { Badge } from "../../../components/ui/Badge";
-import { Button } from "../../../components/ui/Button";
-import { DropdownSelect } from "../../../components/ui/DropdownSelect";
-import { EmptyState } from "../../../components/ui/EmptyState";
-import { Input } from "../../../components/ui/Input";
import { ApiError } from "../../../services/apiClient.ts";
-import {
- discoverRepositories,
- type DiscoveredRepository,
-} from "../../../services/sources/githubService.ts";
-import { getIngestionSourceStatuses } from "../../../services/ingestionService.ts";
+import { DropdownSelect } from "../../../components/ui/DropdownSelect";
+import { discoverRepositories } from "../../../services/sources/githubService.ts";
import {
parseGithubOwnerInput,
parseGithubRepositoryReference,
} from "../../../services/sources/githubRepositoryInput.ts";
+import {
+ RepositoryDiscovery,
+ type DiscoverySelection,
+ type RepositoryDiscoveryAdapter,
+} from "./RepositoryDiscovery.tsx";
-/**
- * What connecting a discovered repository to the current project would mean.
- *
- * `alreadyConnected` from discovery is global: it says the repo is a SprintStart
- * source *somewhere*, not that it belongs to this project. Such a repo can be
- * linked to the current project without fetching or ingesting it again, which is
- * why it stays selectable instead of being greyed out.
- */
-export type RepositoryLinkState =
- /** Not a source yet: connecting fetches and ingests it. */
- | "new"
- /** Ingested elsewhere: connecting only links it, reusing its artifacts. */
- | "linkable"
- /** Already a source of this project: nothing left to do. */
- | "in-project"
- /** Ingested elsewhere, but its repository id could not be resolved. */
- | "unresolved";
-
-/** A resolved, selected repository handed to the parent's connect flow. */
-export type DiscoverySelection = {
- /** The owner that actually produced the results (repos carry only a name). */
- owner: string;
- name: string;
- isPrivate: boolean;
- linkState: RepositoryLinkState;
- /** Present for `linkable` repos, so they can be linked instead of re-ingested. */
- repositoryId?: string;
-};
+// Re-exported so the wizard, the add-source modal and the draft helpers keep
+// importing the selection types from here.
+export type { DiscoverySelection, RepositoryLinkState } from "./RepositoryDiscovery.tsx";
type GithubRepositoryDiscoveryProps = {
tokenNames: string[];
@@ -82,16 +42,57 @@ type GithubRepositoryDiscoveryProps = {
suppressMissingTokenNotice?: boolean;
};
-const PAGE_SIZE = 20;
+/**
+ * How {@link RepositoryDiscovery} talks to GitHub: an owner is an organization or
+ * a user, resolved on the service side ("auto" tries the org endpoint first and
+ * falls back to the user endpoint), so the user never has to know or pick which
+ * kind they typed.
+ */
+const GITHUB_DISCOVERY_ADAPTER: RepositoryDiscoveryAdapter = {
+ sourceSystem: "GITHUB",
+ providerName: "GitHub",
+ discover: async (owner, tokenName, page, pageSize) => {
+ const result = await discoverRepositories(owner, tokenName, "auto", page, pageSize);
+
+ return { repositories: result.repositories, hasMore: result.hasMore };
+ },
+ parseInput: (value) => {
+ const reference = parseGithubRepositoryReference(value);
+ if (reference) return { owner: reference.owner, name: reference.name };
+
+ const owner = parseGithubOwnerInput(value);
+ return owner ? { owner, name: null } : null;
+ },
+ ownerLabel: "Organization, user, or URL",
+ ownerPlaceholder: "octocat, github.com/octocat, or a repo URL",
+ invalidInputMessage:
+ "Enter a GitHub organization, user, or repository URL (e.g. octocat or github.com/octocat/hello-world).",
+ credentialRequiredMessage: "Choose a stored GitHub access token.",
+ missingCredentialNotice:
+ "Add a GitHub personal access token in Settings first, then come back to discover repositories.",
+ emptyResultHint: (
+ <>
+ The token may only see public repositories. Private repositories require a token with broader
+ scope (e.g. read:org / repo access).
+ >
+ ),
+ describeError: (error, owner) => {
+ if (error instanceof ApiError && error.status === 404) {
+ return `No GitHub organization or user "${owner}" was found for the selected token.`;
+ }
+
+ if (error instanceof ApiError && error.status === 429) {
+ return "GitHub rate limit reached. Please wait a moment and try again.";
+ }
+
+ return null;
+ },
+};
/**
- * GitHub org/user repository discovery: searchable, paginated, multi-select, with
- * an "already ingested elsewhere → link instead of re-ingest" classification.
- *
- * Owns discovery and the selection set; the resolved selection is reported up via
- * {@link GithubRepositoryDiscoveryProps.onSelectionChange} so the parent keeps its
- * own connect button and decides what connecting means (immediate connect on the
- * Data Ingestion page, staged connect in the create-project wizard).
+ * GitHub org/user repository discovery: {@link RepositoryDiscovery} with the
+ * GitHub adapter and a stored-token picker. Keeps the props the create-project
+ * wizard and the add-source modal already use.
*/
export function GithubRepositoryDiscovery({
tokenNames,
@@ -105,257 +106,12 @@ export function GithubRepositoryDiscovery({
}: GithubRepositoryDiscoveryProps) {
const hasTokens = tokenNames.length > 0;
- const [ownerInput, setOwnerInput] = useState("");
- const [filter, setFilter] = useState("");
-
- // The owner that actually produced the current results, used at connect time
- // (discovered repos carry only their name, not their owner).
- const [resolvedOwner, setResolvedOwner] = useState("");
- const [repositories, setRepositories] = useState([]);
- const [selected, setSelected] = useState>(new Set());
- const [page, setPage] = useState(0);
- const [hasMore, setHasMore] = useState(false);
- // "owner/name" (lowercased) -> repository id, for every repo connected anywhere,
- // plus the subset already belonging to the current project.
- const [repositoryIdsByFullName, setRepositoryIdsByFullName] = useState
)}
+ {source.bitbucketRepository?.workspace && (
+
+ {source.bitbucketRepository.workspace}
+
+ )}
+
{source.jiraInstance?.instanceUrl && (
{formatInstanceDomain(source.jiraInstance.instanceUrl)}
diff --git a/src/features/data-ingestion/data.ts b/src/features/data-ingestion/data.ts
index d0d0b9578..62cd09d55 100644
--- a/src/features/data-ingestion/data.ts
+++ b/src/features/data-ingestion/data.ts
@@ -21,10 +21,17 @@ import type {
SourceStatusPresentation,
SourceSystem,
} from "./types.ts";
+import { BitbucketIcon } from "../../components/icons/BitbucketIcon.tsx";
import type { JiraInstanceDto } from "../../services/sources/jiraService.ts";
import type { ConfluenceConnectionDto } from "../../services/sources/confluenceService.ts";
-export const SOURCE_SYSTEMS: SourceSystem[] = ["GITHUB", "JIRA", "UPLOAD", "CONFLUENCE"];
+export const SOURCE_SYSTEMS: SourceSystem[] = [
+ "GITHUB",
+ "JIRA",
+ "UPLOAD",
+ "CONFLUENCE",
+ "BITBUCKET",
+];
export const SOURCE_META: Record = {
GITHUB: {
@@ -51,6 +58,13 @@ export const SOURCE_META: Record = {
icon: BookOpen,
description: "Indexes pages, hierarchical documents and spaces from Confluence Cloud.",
},
+ BITBUCKET: {
+ name: "Bitbucket Repository",
+ type: "Bitbucket",
+ icon: BitbucketIcon,
+ description:
+ "Indexes pull requests, README files and source files from Bitbucket Cloud repositories.",
+ },
};
export const INGESTION_RUN_LIMIT = 50;
@@ -186,6 +200,78 @@ export function createJiraSourceFromInstance(
};
}
+/**
+ * Maps a BITBUCKET status row from `/api/v1/ingestion-sources/status` into a
+ * {@link DataSource}, the Bitbucket counterpart to
+ * {@link createJiraSourceFromInstance}.
+ *
+ * The status row is authoritative for health, counters, total artifact count
+ * and last-sync time. `sourceId`/`displayName` are `"workspace/slug"`, `owner`
+ * is the workspace and `name` the slug. Bitbucket only reports a pull-request
+ * sync time; commits and issues are never synced, so those stay null.
+ * `githubRepository` is always null; identity lives in `bitbucketRepository`.
+ */
+export function createBitbucketSourceFromInstance(
+ status: SourceInstanceIngestionStatus,
+ connectorEnabled?: boolean,
+): DataSource {
+ const meta = SOURCE_META.BITBUCKET;
+ const backendStatus: BackendProjectSourceStatus =
+ status.enabled === false ? "DISABLED" : status.connectionStatus;
+ const hasErrors = status.failedCount > 0;
+ const hasNeverSynced = status.lastRunTime === null;
+ const [fallbackWorkspace = "", fallbackSlug = ""] = status.sourceId.split("/");
+
+ return {
+ sourceId: status.repositoryId ?? status.sourceId,
+ sourceSystem: "BITBUCKET",
+ name: status.displayName,
+ type: meta.type,
+ icon: meta.icon,
+ status: getSourceStatusFromBackend(backendStatus),
+ backendStatus,
+ statusLabel: getBackendSourceStatusLabel(backendStatus),
+ ingestionStatus: getSourceStatus(hasNeverSynced, hasErrors, null),
+ ingestionStatusLabel:
+ !hasNeverSynced && !hasErrors
+ ? "Synced"
+ : getSourceStatusLabel(hasNeverSynced, hasErrors, null),
+ statusView: deriveSourceStatus({
+ backendStatus,
+ hasErrors,
+ hasNeverSynced,
+ connectorEnabled,
+ }),
+ artifacts: status.artifactCount,
+ lastSync: formatDateTime(status.lastRunTime),
+ nextSync: "Not available",
+ errors: status.failedCount,
+ description: meta.description,
+ lastRunAt: status.lastRunTime,
+ latestIngestedCount: status.ingestedCount,
+ latestUpdatedCount: status.updatedCount,
+ deletedCount: status.deletedCount,
+ totalArtifactCount: status.artifactCount,
+ runIds: [],
+ sharesSourceSystem: false,
+ failedItems: status.failedItems,
+ githubRepository: null,
+ jiraInstance: null,
+ confluenceSpace: null,
+ bitbucketRepository: {
+ repositoryId: status.repositoryId,
+ workspace: status.owner ?? fallbackWorkspace,
+ slug: status.name ?? fallbackSlug,
+ fullName: status.sourceId,
+ url: status.sourceUrl,
+ enabled: status.enabled,
+ },
+ lastCommitsSyncAt: null,
+ lastIssuesSyncAt: null,
+ lastPullRequestsSyncAt: status.lastPullRequestsSyncAt,
+ };
+}
+
/**
* Maps an UPLOAD status row from `/api/v1/ingestion-sources/status` into the
* full {@link DataSource} model rendered on the ingestion page.
@@ -698,7 +784,8 @@ export function formatInstanceDomain(instanceUrl: string): string {
* reference — most visibly for Jira, whose `sourceId` is the instance URL.
*
* Keyed by the same value the run carries in `sourceId`: GitHub `"owner/name"`
- * (the repository's full name) and Jira the instance URL. Runs whose source is
+ * and Bitbucket `"workspace/slug"` (the repository's full name) and Jira the
+ * instance URL. Runs whose source is
* no longer connected won't be in the map and fall back to the raw reference.
*/
export function buildRunSourceLabels(sources: DataSource[]): Map {
@@ -713,6 +800,10 @@ export function buildRunSourceLabels(sources: DataSource[]): Map
labels.set(source.githubRepository.fullName, source.name);
}
+ if (source.bitbucketRepository?.fullName) {
+ labels.set(source.bitbucketRepository.fullName, source.name);
+ }
+
if (source.confluenceSpace) {
const { connectionId, baseUrl, spaceId, spaceKey } = source.confluenceSpace;
if (connectionId) labels.set(connectionId, source.name);
diff --git a/src/features/data-ingestion/types.ts b/src/features/data-ingestion/types.ts
index 38c24bcab..45187df0a 100644
--- a/src/features/data-ingestion/types.ts
+++ b/src/features/data-ingestion/types.ts
@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
+import type { IconComponent } from "../../components/icons/types.ts";
-export type SourceSystem = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE";
+export type SourceSystem = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE" | "BITBUCKET";
export type BackendProjectSourceStatus =
| "CONNECTED"
@@ -97,14 +98,19 @@ export type IngestionRun = {
export type SourceInstanceIngestionStatus = {
sourceSystem: SourceSystem;
/**
- * Stable, connector-neutral key: GitHub `"owner/name"`, Jira the instance URL.
+ * Stable, connector-neutral key: GitHub `"owner/name"`, Bitbucket
+ * `"workspace/slug"`, Jira the instance URL.
*/
sourceId: string;
- /** Display name: GitHub `"owner/name"`, Jira the instance's display name. */
+ /**
+ * Display name: GitHub `"owner/name"`, Bitbucket `"workspace/slug"`, Jira the
+ * instance's display name.
+ */
displayName: string;
/**
- * GitHub-only repository identity. Null for connector-neutral rows such as
- * Jira, which are identified by {@link sourceId} (the instance URL) instead.
+ * Repository connection id (GitHub and Bitbucket). Null for connector-neutral
+ * rows such as Jira, which are identified by {@link sourceId} (the instance
+ * URL) instead.
*/
repositoryId: string | null;
owner: string | null;
@@ -176,6 +182,20 @@ export type GithubRepositoryDetails = GithubRepositoryReference & {
enabled: boolean | null;
};
+/**
+ * Bitbucket-specific identity for a source card. A repository is addressed as
+ * `workspace/slug`; `repositoryId` is the connection's UUID, used to update the
+ * repository and to link or unlink it from a project.
+ */
+export type BitbucketRepositoryDetails = {
+ repositoryId: string | null;
+ workspace: string;
+ slug: string;
+ fullName: string;
+ url: string;
+ enabled: boolean | null;
+};
+
/**
* Jira-specific identity for a source card, mirroring
* {@link GithubRepositoryDetails} for the Jira connector. A connected Jira
@@ -255,7 +275,7 @@ export type SourceStatusPresentation = {
export type SourceMeta = {
name: string;
type: string;
- icon: LucideIcon;
+ icon: IconComponent;
description: string;
};
@@ -280,12 +300,14 @@ export type SourceDetailsSource = {
jiraInstance?: JiraInstanceSourceDetails | null;
/** Confluence space identity; null/absent for non-Confluence sources. */
confluenceSpace?: ConfluenceSpaceSourceDetails | null;
+ /** Bitbucket repository identity; null/absent for non-Bitbucket sources. */
+ bitbucketRepository?: BitbucketRepositoryDetails | null;
description?: string;
nextSync?: string;
};
export type DataSource = SourceDetailsSource & {
- icon: LucideIcon;
+ icon: IconComponent;
statusLabel: string;
ingestionStatus: SourceStatus;
ingestionStatusLabel: string;
diff --git a/src/features/knowledge-base/bitbucketWorkspaceMetadata.ts b/src/features/knowledge-base/bitbucketWorkspaceMetadata.ts
new file mode 100644
index 000000000..a24fb6829
--- /dev/null
+++ b/src/features/knowledge-base/bitbucketWorkspaceMetadata.ts
@@ -0,0 +1,89 @@
+/**
+ * Type mapping + tolerant parser for the metadata JSON string the backend ships on
+ * Bitbucket `ORG_METADATA` artifacts.
+ *
+ * The backend DTO (`BitbucketWorkspaceMetadataArtifactMetadata` in sprintstart-backend)
+ * serializes the workspace profile and its members into the artifact's `metadata`
+ * field. It is the Bitbucket counterpart of the GitHub org profile in
+ * [`orgMetadata`](./orgMetadata), with a different shape: a workspace has no teams,
+ * no blog or location, and its members are named by display name and nickname.
+ */
+
+export interface BitbucketWorkspaceMember {
+ accountId: string | null;
+ nickname: string | null;
+ displayName: string | null;
+}
+
+export interface BitbucketWorkspaceMetadata {
+ /** The workspace id, as used in `bitbucket.org/`. */
+ workspace: string;
+ /** The workspace's display name (also the artifact `title`). */
+ name: string;
+ /** Null when the backend reported no visibility. */
+ isPrivate: boolean | null;
+ /** ISO 8601 creation time, or null when Bitbucket reported none. */
+ createdOn: string | null;
+ /** Browser URL of the workspace, or null when Bitbucket reported none. */
+ url: string | null;
+ members: BitbucketWorkspaceMember[];
+}
+
+function optionalString(value: unknown): string | null {
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
+}
+
+/**
+ * Parses a Bitbucket `ORG_METADATA` artifact's `metadata` JSON string.
+ *
+ * Tolerant like `parseOrgMetadata`: returns `null` for anything that names no
+ * workspace or carries no member list, so the viewer can show its quiet empty
+ * state. Members that are not objects are dropped rather than failing the whole
+ * profile. The Kotlin `isPrivate` property is read under both spellings Jackson
+ * may serialize it as (`isPrivate` and `private`).
+ *
+ * @param json The raw `artifact.metadata` string (may be omitted).
+ * @returns The parsed workspace metadata, or `null` when the input is not usable.
+ */
+export function parseBitbucketWorkspaceMetadata(
+ json: string | null | undefined,
+): BitbucketWorkspaceMetadata | null {
+ if (typeof json !== "string" || json.trim() === "") return null;
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(json);
+ } catch {
+ console.warn("Ignoring unparseable Bitbucket workspace metadata", json);
+ return null;
+ }
+
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ return null;
+ }
+
+ const payload = parsed as Record;
+ const workspace = optionalString(payload["workspace"]);
+ const members = payload["members"];
+ if (workspace === null || !Array.isArray(members)) return null;
+
+ const isPrivate = payload["isPrivate"] ?? payload["private"];
+
+ return {
+ workspace,
+ name: optionalString(payload["name"]) ?? workspace,
+ isPrivate: typeof isPrivate === "boolean" ? isPrivate : null,
+ createdOn: optionalString(payload["createdOn"]),
+ url: optionalString(payload["url"]),
+ members: members
+ .filter(
+ (member): member is Record =>
+ typeof member === "object" && member !== null && !Array.isArray(member),
+ )
+ .map((member) => ({
+ accountId: optionalString(member["accountId"]),
+ nickname: optionalString(member["nickname"]),
+ displayName: optionalString(member["displayName"]),
+ })),
+ };
+}
diff --git a/src/features/knowledge-base/components/ArtifactFilters.tsx b/src/features/knowledge-base/components/ArtifactFilters.tsx
index eea194cf6..e107375dc 100644
--- a/src/features/knowledge-base/components/ArtifactFilters.tsx
+++ b/src/features/knowledge-base/components/ArtifactFilters.tsx
@@ -15,6 +15,7 @@ import {
Ticket,
Upload,
} from "lucide-react";
+import { BitbucketIcon } from "../../../components/icons/BitbucketIcon";
import { Button } from "../../../components/ui/Button";
import { Input } from "../../../components/ui/Input";
import { Select } from "../../../components/ui/Select.tsx";
@@ -66,10 +67,10 @@ export interface ArtifactFiltersProps {
*/
formatOptions: FacetOption[];
/**
- * Repositories behind the project's GitHub artifacts, with counts. Empty
- * unless GitHub is part of the source selection — the facet describes GitHub
- * only, so the section disappears with it and the hook owns that rule rather
- * than the toolbar re-deriving it.
+ * Repositories behind the project's GitHub and Bitbucket artifacts, with
+ * counts. Empty unless one of them is part of the source selection — the facet
+ * describes the git providers only, so the section disappears with them and the
+ * hook owns that rule rather than the toolbar re-deriving it.
*/
repositoryOptions: FacetOption[];
selectedSources: ReadonlySet;
@@ -124,6 +125,7 @@ const SOURCE_ICONS: Record = {
GITHUB: ,
JIRA: ,
CONFLUENCE: ,
+ BITBUCKET: ,
UPLOAD: ,
};
diff --git a/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx b/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx
index 20ec752da..ff9e76b61 100644
--- a/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx
+++ b/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx
@@ -16,6 +16,8 @@ import {
Link2,
ExternalLink,
MessageSquare,
+ Lock,
+ CalendarDays,
} from "lucide-react";
import ReactMarkdown, { type Options as ReactMarkdownOptions } from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -36,7 +38,12 @@ import {
type OrgMetadataArtifactMetadata,
type OrgMetadataTeam,
} from "../orgMetadata";
+import {
+ parseBitbucketWorkspaceMetadata,
+ type BitbucketWorkspaceMetadata,
+} from "../bitbucketWorkspaceMetadata";
import { getArtifactRepository } from "../githubMetadata";
+import { BitbucketIcon } from "../../../components/icons/BitbucketIcon";
import { knowledgeService } from "../../../services/knowledgeService";
import { RepositoryBadge } from "./RepositoryBadge";
import { SourceLinkBadge } from "./SourceLinkBadge";
@@ -206,6 +213,7 @@ const shouldRenderAsMarkdown = (
title.startsWith("issue #") ||
title.startsWith("jira #") ||
sourceUrl.includes("/pull/") ||
+ sourceUrl.includes("/pull-requests/") ||
sourceUrl.includes("/issues/") ||
sourceUrl.includes("/browse/");
@@ -836,6 +844,117 @@ function OrgMetadataView({
);
}
+/** Formats the workspace's ISO creation time as a date, or null when it does not parse. */
+function formatWorkspaceDate(iso: string | null): string | null {
+ if (!iso) return null;
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return null;
+ return date.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
+}
+
+/**
+ * Bitbucket workspace profile, the counterpart of {@link OrgMetadataView} for a
+ * Bitbucket `ORG_METADATA` artifact. Rendered purely from the artifact's
+ * `metadata` JSON; a workspace has no teams, so only its members are listed.
+ */
+function BitbucketWorkspaceView({
+ metadata,
+ title,
+}: {
+ metadata: BitbucketWorkspaceMetadata | null;
+ title: string | null;
+}) {
+ if (!metadata) {
+ return (
+
+
+
Workspace profile unavailable.
+
+ );
+ }
+
+ const workspaceUrl =
+ metadata.url ?? `https://bitbucket.org/${encodeURIComponent(metadata.workspace)}`;
+ const createdOn = formatWorkspaceDate(metadata.createdOn);
+
+ return (
+
+
+
+
+
+
+ {metadata.isPrivate !== null && (
+ } label="Visibility">
+ {metadata.isPrivate ? "Private" : "Public"}
+
+ )}
+ {createdOn && (
+ } label="Created">
+ {createdOn}
+
+ )}
+
+
+
+
+
+ Members
+ {metadata.members.length > 0 && (
+
+ {metadata.members.length}
+
+ )}
+
+
+ {metadata.members.length > 0 ? (
+
+ {metadata.members.map((member, index) => (
+ -
+
+ {member.displayName ?? member.nickname ?? "Unknown member"}
+
+ ))}
+
+ ) : (
+ No members visible in this workspace.
+ )}
+
+
+ );
+}
+
/**
* ArtifactViewerDrawer
*
@@ -1181,10 +1300,21 @@ export function ArtifactViewerDrawer({
const summariseBlockedReason = summariseBlockReason(content);
+ // A Bitbucket workspace profile shares the ORG_METADATA type with the GitHub
+ // org profile but has its own metadata shape, so it gets its own view.
+ const isBitbucketWorkspace =
+ artifact?.artifactType === "ORG_METADATA" && artifact.sourceSystem === "BITBUCKET";
const orgMetadata = useMemo(
- () => (artifact?.artifactType === "ORG_METADATA" ? parseOrgMetadata(artifact.metadata) : null),
+ () =>
+ artifact?.artifactType === "ORG_METADATA" && artifact.sourceSystem !== "BITBUCKET"
+ ? parseOrgMetadata(artifact.metadata)
+ : null,
[artifact],
);
+ const workspaceMetadata = useMemo(
+ () => (isBitbucketWorkspace ? parseBitbucketWorkspaceMetadata(artifact?.metadata) : null),
+ [artifact, isBitbucketWorkspace],
+ );
const actionsContent = viewMode === "raw" && artifact?.artifactType !== "ORG_METADATA" && (
@@ -1303,7 +1433,11 @@ export function ArtifactViewerDrawer({
) : viewMode === "raw" && artifact?.artifactType === "ORG_METADATA" ? (
-
+ {isBitbucketWorkspace ? (
+
+ ) : (
+
+ )}
) : viewMode === "raw" ? (
diff --git a/src/features/knowledge-base/components/SourceLinkBadge.tsx b/src/features/knowledge-base/components/SourceLinkBadge.tsx
index da5885a3d..eb77a6e6b 100644
--- a/src/features/knowledge-base/components/SourceLinkBadge.tsx
+++ b/src/features/knowledge-base/components/SourceLinkBadge.tsx
@@ -8,6 +8,8 @@ function getSourceSystemLabel(sourceSystem: SourceSystem): string {
switch (sourceSystem) {
case "GITHUB":
return "Open in GitHub";
+ case "BITBUCKET":
+ return "Open in Bitbucket";
case "JIRA":
return "Open in Jira";
case "CONFLUENCE":
diff --git a/src/features/knowledge-base/githubMetadata.ts b/src/features/knowledge-base/githubMetadata.ts
index 22077af76..078de1013 100644
--- a/src/features/knowledge-base/githubMetadata.ts
+++ b/src/features/knowledge-base/githubMetadata.ts
@@ -8,6 +8,11 @@
* the parse keeps consumers (the artifact list card and the viewer drawer) free
* of ad-hoc `JSON.parse` + shape handling, and guarantees malformed metadata can
* never crash the UI or leak raw JSON into it.
+ *
+ * Bitbucket artifacts carry `{ repositoryId, workspace, slug }` instead (see
+ * {@link parseBitbucketMetadata}); both shapes resolve to the same
+ * `owner/repository` display string, so the card badge and the repository facet
+ * treat the two providers alike.
*/
import type { Artifact } from "./types";
@@ -25,21 +30,14 @@ export interface GithubArtifactMetadata {
}
/**
- * Parses a GitHub artifact's `metadata` JSON string.
- *
- * Tolerant by design, mirroring {@link parseOrgMetadata}: returns `null` for
- * anything that cannot be turned into a usable repository reference —
- * `null`/`undefined` input, empty or whitespace-only string, malformed JSON,
- * JSON that is not a plain object, or a missing/blank `repositoryFullName`.
- * Only fields the UI actually renders are validated; the backend always sends
- * `repositoryId` alongside, but nothing consumes it.
- *
- * @param json The raw `artifact.metadata` string (may be omitted).
- * @returns The parsed metadata, or `null` when the input is not usable.
+ * Reads a metadata string as a plain JSON object, or `null` when it is missing,
+ * blank, malformed or not an object. `source` only names the provider in the
+ * warning logged for a malformed blob.
*/
-export function parseGithubMetadata(
+function readMetadataObject(
json: string | null | undefined,
-): GithubArtifactMetadata | null {
+ source: string,
+): Record
| null {
if (typeof json !== "string" || json.trim() === "") return null;
let parsed: unknown;
@@ -49,7 +47,7 @@ export function parseGithubMetadata(
// A malformed blob (never expected from the backend, but cheap to guard
// against) must not crash the UI — degrade to the same "no repository"
// state as missing metadata.
- console.warn("Ignoring unparseable GitHub artifact metadata", json);
+ console.warn(`Ignoring unparseable ${source} artifact metadata`, json);
return null;
}
@@ -57,7 +55,28 @@ export function parseGithubMetadata(
return null;
}
- const payload = parsed as Record;
+ return parsed as Record;
+}
+
+/**
+ * Parses a GitHub artifact's `metadata` JSON string.
+ *
+ * Tolerant by design, mirroring {@link parseOrgMetadata}: returns `null` for
+ * anything that cannot be turned into a usable repository reference —
+ * `null`/`undefined` input, empty or whitespace-only string, malformed JSON,
+ * JSON that is not a plain object, or a missing/blank `repositoryFullName`.
+ * Only fields the UI actually renders are validated; the backend always sends
+ * `repositoryId` alongside, but nothing consumes it.
+ *
+ * @param json The raw `artifact.metadata` string (may be omitted).
+ * @returns The parsed metadata, or `null` when the input is not usable.
+ */
+export function parseGithubMetadata(
+ json: string | null | undefined,
+): GithubArtifactMetadata | null {
+ const payload = readMetadataObject(json, "GitHub");
+ if (payload === null) return null;
+
const repositoryFullName = payload["repositoryFullName"];
if (typeof repositoryFullName !== "string" || repositoryFullName.trim() === "") {
return null;
@@ -74,15 +93,57 @@ export function parseGithubMetadata(
};
}
+export interface BitbucketArtifactMetadata {
+ /** The connected repository's id; declared-but-unconsumed, like GitHub's. */
+ repositoryId?: string;
+ /** The Bitbucket workspace owning the repository (or described, for a workspace profile). */
+ workspace: string;
+ /**
+ * The repository's slug. Absent on a workspace's own metadata artifact, which
+ * names the workspace but no repository.
+ */
+ slug?: string;
+}
+
+/**
+ * Parses a Bitbucket artifact's `metadata` JSON string: `{ repositoryId,
+ * workspace, slug }` on repository artifacts, `{ workspace, ... }` on the
+ * workspace's profile artifact.
+ *
+ * Tolerant like {@link parseGithubMetadata}: returns `null` for anything that
+ * names no workspace. Only the fields the UI uses are validated and trimmed.
+ *
+ * @param json The raw `artifact.metadata` string (may be omitted).
+ * @returns The parsed metadata, or `null` when the input is not usable.
+ */
+export function parseBitbucketMetadata(
+ json: string | null | undefined,
+): BitbucketArtifactMetadata | null {
+ const payload = readMetadataObject(json, "Bitbucket");
+ if (payload === null) return null;
+
+ const workspace = payload["workspace"];
+ if (typeof workspace !== "string" || workspace.trim() === "") return null;
+
+ const slug = payload["slug"];
+
+ return {
+ repositoryId: typeof payload["repositoryId"] === "string" ? payload["repositoryId"] : undefined,
+ workspace: workspace.trim(),
+ slug: typeof slug === "string" && slug.trim() !== "" ? slug.trim() : undefined,
+ };
+}
+
/**
- * Resolves the display repository (`owner/repository`) for an artifact, or
- * `null` when the artifact has no repository to show.
+ * Resolves the display repository (`owner/repository`, or `workspace/slug` for
+ * Bitbucket) for an artifact, or `null` when the artifact has no repository to
+ * show.
*
- * Only GitHub's repo-scoped artifact types carry repository metadata. GitHub's
- * `ORG_METADATA` artifacts are also `sourceSystem === "GITHUB"`, but their
- * metadata is the org profile (no repository fields), so they are excluded
- * explicitly — letting them through would run the org JSON through the repo
- * parser and always fail. Non-GitHub source systems (`UPLOAD`, `JIRA`,
+ * Only the repo-scoped artifact types of GitHub and Bitbucket carry repository
+ * metadata. Their `ORG_METADATA` artifacts share the source system, but the
+ * metadata is the org or workspace profile (no repository fields), so they are
+ * excluded explicitly — letting them through would run the profile JSON through
+ * the repo parser and always fail. Other source systems (`UPLOAD`, `JIRA`,
* `CONFLUENCE`, including their `PAGE` artifacts) carry different metadata
* shapes and are excluded by the source-system check.
*
@@ -110,6 +171,7 @@ export function getArtifactRepository(
*/
interface RepositoryInfo {
repository: string | null;
+ /** The owning GitHub org login, or the Bitbucket workspace, of an owner-profile artifact. */
orgLogin: string | null;
}
@@ -117,6 +179,17 @@ interface RepositoryInfo {
function computeRepositoryInfo(
artifact: Pick,
): RepositoryInfo {
+ if (artifact.sourceSystem === "BITBUCKET") {
+ const bitbucket = parseBitbucketMetadata(artifact.metadata);
+
+ if (bitbucket?.slug) {
+ return { repository: `${bitbucket.workspace}/${bitbucket.slug}`, orgLogin: null };
+ }
+
+ // The workspace's own artifact names no repository, only its workspace.
+ return { repository: null, orgLogin: bitbucket?.workspace ?? null };
+ }
+
const repository =
artifact.sourceSystem === "GITHUB" && artifact.artifactType !== "ORG_METADATA"
? (parseGithubMetadata(artifact.metadata)?.repositoryFullName ?? null)
@@ -150,7 +223,7 @@ function repositoryInfoOf(
*
* Mirrors `matchesFormat`'s scoping rule where it can: artifacts from other
* sources are outside the facet's reach and always match, so "GitHub + Uploads
- * + repo X" does not hide the uploads. Within GitHub a selection narrows
+ * + repo X" does not hide the uploads. Within GitHub (and Bitbucket) a selection narrows
* strictly, with one exception: the org profile names no repository, but it
* describes the org that owns the chosen ones — so it stays visible exactly
* when its login is the owner half of a checked `owner/repo`. An unrelated
@@ -174,13 +247,13 @@ export function matchesRepository(
selected: ReadonlySet,
): boolean {
if (selected.size === 0) return true;
- if (artifact.sourceSystem !== "GITHUB") return true;
+ if (artifact.sourceSystem !== "GITHUB" && artifact.sourceSystem !== "BITBUCKET") return true;
const { repository, orgLogin } = repositoryInfoOf(artifact);
if (repository !== null) return selected.has(repository);
- // A GitHub artifact that names no repository: only the org profile can still
- // belong to the selection — namely when its org owns one of the chosen repos.
+ // An artifact that names no repository: only the org (or workspace) profile can
+ // still belong to the selection — namely when its owner owns one of the chosen repos.
if (orgLogin === null) return false;
const login = orgLogin.toLowerCase();
return [...selected].some((repo) => {
diff --git a/src/features/knowledge-base/hooks/useKnowledgeBase.ts b/src/features/knowledge-base/hooks/useKnowledgeBase.ts
index 806c993e4..4d77c2796 100644
--- a/src/features/knowledge-base/hooks/useKnowledgeBase.ts
+++ b/src/features/knowledge-base/hooks/useKnowledgeBase.ts
@@ -362,7 +362,8 @@ export function useKnowledgeBase(
}, [facetsData?.repositories]);
const repositoryOptions = useMemo[]>(() => {
- if (!selectedSources.has("GITHUB")) return [];
+ // Repositories belong to the git providers; the facet shows while either is selected.
+ if (!selectedSources.has("GITHUB") && !selectedSources.has("BITBUCKET")) return [];
const repos = (facetsData?.repositories ?? []).map((r) => r.value);
const offered = Array.from(new Set([...repos, ...selectedRepositories])).sort((a, b) =>
a.localeCompare(b),
diff --git a/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts b/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts
index f24b2dfdb..74998bd97 100644
--- a/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts
+++ b/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts
@@ -96,7 +96,7 @@ export interface KnowledgeBaseUrlState {
/** Search text exactly as written to `?q=`; trimming is the request builder's job. */
search: string;
sources: ReadonlySet;
- /** Only ever non-empty while GitHub is among `sources` — see {@link parseKnowledgeBaseSearch}. */
+ /** Only ever non-empty while GitHub or Bitbucket is among `sources` — see {@link parseKnowledgeBaseSearch}. */
repositories: ReadonlySet;
/** Only ever non-null while Uploads is among `sources` — see {@link parseKnowledgeBaseSearch}. */
format: UploadFormat | null;
@@ -166,7 +166,7 @@ function readPositiveInt(raw: string | null, fallback: number, max: number): num
* because people type URLs by hand.
*
* The two dependent facets keep the invariant the toggles enforce: a `format` only counts while
- * Uploads is selected and `repos` only while GitHub is. Otherwise a shared link could carry a
+ * Uploads is selected and `repos` only while GitHub or Bitbucket is. Otherwise a shared link could carry a
* filter the panel does not even show — narrowing the list with no visible way to undo it.
*
* @param params The current `location.search`, parsed.
@@ -186,7 +186,10 @@ export function parseKnowledgeBaseSearch(params: URLSearchParams): KnowledgeBase
const format =
sources.has("UPLOAD") && FORMAT_VALUES.has(rawFormat) ? (rawFormat as UploadFormat) : null;
- const repositoryList = sources.has("GITHUB") ? readList(params, KB_URL_PARAM.repositories) : [];
+ const repositoryList =
+ sources.has("GITHUB") || sources.has("BITBUCKET")
+ ? readList(params, KB_URL_PARAM.repositories)
+ : [];
const repositories: ReadonlySet =
repositoryList.length > 0 ? new Set(repositoryList) : NO_STRINGS;
@@ -415,7 +418,15 @@ export function useKnowledgeBaseUrlState(
const isRemoving = toggleInList(params, KB_URL_PARAM.sources, source);
// The dependent facets describe one source each; they go when their source goes.
if (isRemoving && source === "UPLOAD") params.delete(KB_URL_PARAM.format);
- if (isRemoving && source === "GITHUB") params.delete(KB_URL_PARAM.repositories);
+ // Repositories belong to GitHub and Bitbucket together, so they go once neither is left.
+ if (isRemoving && (source === "GITHUB" || source === "BITBUCKET")) {
+ const remaining = readList(params, KB_URL_PARAM.sources).map((value) =>
+ value.toUpperCase(),
+ );
+ if (!remaining.includes("GITHUB") && !remaining.includes("BITBUCKET")) {
+ params.delete(KB_URL_PARAM.repositories);
+ }
+ }
params.delete(KB_URL_PARAM.page);
}, "push"),
[commit],
diff --git a/src/features/knowledge-base/tabs.ts b/src/features/knowledge-base/tabs.ts
index b9b141f30..b977b678d 100644
--- a/src/features/knowledge-base/tabs.ts
+++ b/src/features/knowledge-base/tabs.ts
@@ -14,13 +14,20 @@ export const SOURCE_LABELS: Record = {
GITHUB: "GitHub",
JIRA: "Jira",
CONFLUENCE: "Confluence",
+ BITBUCKET: "Bitbucket",
UPLOAD: "Uploads",
};
/**
* Standard left-to-right order for the source facet.
*/
-export const DEFAULT_SOURCE_ORDER: SourceSystem[] = ["GITHUB", "JIRA", "CONFLUENCE", "UPLOAD"];
+export const DEFAULT_SOURCE_ORDER: SourceSystem[] = [
+ "GITHUB",
+ "BITBUCKET",
+ "JIRA",
+ "CONFLUENCE",
+ "UPLOAD",
+];
/**
* Human-readable display names for artifact types.
diff --git a/src/features/knowledge-base/types.ts b/src/features/knowledge-base/types.ts
index c73836d85..233402842 100644
--- a/src/features/knowledge-base/types.ts
+++ b/src/features/knowledge-base/types.ts
@@ -8,7 +8,7 @@ export type ArtifactType = "COMMIT" | "FILE" | "ISSUE" | "PULL_REQUEST" | "PAGE"
* Origin source of the artifact data.
* Used to route API calls (e.g., Github vs internal Uploads).
*/
-export type SourceSystem = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE";
+export type SourceSystem = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE" | "BITBUCKET";
/**
* Core business entity representing any indexed piece of knowledge.
diff --git a/src/features/settings/components/atlassian/AtlassianCredentialAddForm.tsx b/src/features/settings/components/atlassian/AtlassianCredentialAddForm.tsx
index 40d9ac02e..b10ee1953 100644
--- a/src/features/settings/components/atlassian/AtlassianCredentialAddForm.tsx
+++ b/src/features/settings/components/atlassian/AtlassianCredentialAddForm.tsx
@@ -28,7 +28,7 @@ const ADD_FALLBACK = "Failed to add Atlassian credential.";
/**
* Inline form for storing an Atlassian account email and API token for the
- * authenticated user, shared by the Jira and Confluence connectors. The login
+ * authenticated user, shared by the Jira, Confluence and Bitbucket connectors. The login
* email is only a convenience default because the Atlassian account may use a
* different address.
*/
diff --git a/src/features/settings/hooks/useAtlassianCredentials.ts b/src/features/settings/hooks/useAtlassianCredentials.ts
index 8d83cf6ed..24c739adb 100644
--- a/src/features/settings/hooks/useAtlassianCredentials.ts
+++ b/src/features/settings/hooks/useAtlassianCredentials.ts
@@ -20,7 +20,7 @@ type UseAtlassianCredentialsResult = {
/**
* Loads the Atlassian credentials owned by the authenticated user, shared by
- * the Jira and Confluence connectors.
+ * the Jira, Confluence and Bitbucket connectors.
*
* When disabled, the hook settles into a loaded-empty state without fetching.
* Out-of-order responses (an explicit `reload` outrunning a slower, earlier
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index 5fc12d1f7..88146ff0d 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -48,7 +48,12 @@ function deriveArtifactFromCitation(citation: CitationArtifactOpen): Artifact {
const name = citation.filename.toLowerCase();
let artifactType: ArtifactType = "FILE";
- if (url.includes("/pull/") || name.startsWith("pr #") || name.startsWith("pull request")) {
+ if (
+ url.includes("/pull/") ||
+ url.includes("/pull-requests/") ||
+ name.startsWith("pr #") ||
+ name.startsWith("pull request")
+ ) {
artifactType = "PULL_REQUEST";
} else if (
url.includes("/issues/") ||
@@ -60,7 +65,13 @@ function deriveArtifactFromCitation(citation: CitationArtifactOpen): Artifact {
}
let sourceSystem: SourceSystem = "GITHUB";
- if (url.includes("atlassian.net") || url.includes("/browse/") || name.startsWith("jira #")) {
+ if (url.includes("bitbucket.org")) {
+ sourceSystem = "BITBUCKET";
+ } else if (
+ url.includes("atlassian.net") ||
+ url.includes("/browse/") ||
+ name.startsWith("jira #")
+ ) {
sourceSystem = "JIRA";
}
diff --git a/src/pages/DataIngestionPage.tsx b/src/pages/DataIngestionPage.tsx
index 206ab7cfb..0f61f9e45 100644
--- a/src/pages/DataIngestionPage.tsx
+++ b/src/pages/DataIngestionPage.tsx
@@ -29,6 +29,7 @@ import type { ConnectorListItem } from "../features/connectors/types.ts";
import { connectorService } from "../services/connectorService.ts";
import {
buildRunSourceLabels,
+ createBitbucketSourceFromInstance,
createConfluenceSourceFromConnection,
createConfluenceSourceFromInstance,
createJiraSourceFromInstance,
@@ -45,6 +46,7 @@ import {
} from "../features/data-ingestion/data.ts";
import type {
BackendProjectSourceStatus,
+ BitbucketRepositoryDetails,
DataSource,
GithubRepositoryDetails,
IngestionRun,
@@ -62,6 +64,13 @@ import { getIngestionRunsPage, getIngestionSourceStatuses } from "../services/in
import { useAuth } from "../context/useAuth";
import { useToast } from "../context/useToast";
import { useProjectContext } from "../features/projects/useProjectContext.ts";
+import {
+ configureAllBitbucketRepositories,
+ configureBitbucketRepository,
+ getBitbucketRepositoryConfig,
+ removeBitbucketRepositoryFromProject,
+ updateBitbucketRepository,
+} from "../services/sources/bitbucketService.ts";
import {
configureAllGithubRepositories,
configureGithubRepository,
@@ -103,23 +112,44 @@ const DEFAULT_GLOBAL_CONFLUENCE_SYNC_CONFIG: ConfigureGithubRepositoryRequest =
schedule: { type: "INTERVAL", everyMinutes: 60 },
};
-type SyncSettingsProvider = "github" | "jira" | "confluence";
+const DEFAULT_GLOBAL_BITBUCKET_SYNC_CONFIG: ConfigureGithubRepositoryRequest = {
+ autoUpdate: true,
+ schedule: { type: "INTERVAL", everyMinutes: 60 },
+};
+
+type SyncSettingsProvider = "github" | "bitbucket" | "jira" | "confluence";
/**
- * Wording for the global sync-settings modal, per connector. `one` and `many`
+ * Wording for the global sync-settings modal, per connector. `autoUpdateOff`
+ * overrides the default "only mark out of date" line for a connector whose
+ * scheduler does nothing at all while auto update is off. `one` and `many`
* name the connector's sources so the copy reads naturally in both the
* "overwrites every connected …" and the "updates all connected …" sentences.
*/
const SYNC_SETTINGS_COPY: Record<
SyncSettingsProvider,
- { label: string; one: string; many: string }
+ { label: string; one: string; many: string; autoUpdateOff?: string }
> = {
github: { label: "GitHub", one: "GitHub repository", many: "GitHub repositories" },
+ bitbucket: {
+ label: "Bitbucket",
+ one: "Bitbucket repository",
+ many: "Bitbucket repositories",
+ // The Bitbucket scheduler skips a repository with auto update off instead of
+ // marking it out of date.
+ autoUpdateOff:
+ "Due checks skip connected Bitbucket repositories. They only update when started manually.",
+ },
jira: { label: "Jira", one: "Jira instance", many: "Jira instances" },
confluence: { label: "Confluence", one: "Confluence space", many: "Confluence spaces" },
};
-const SYNC_SETTINGS_PROVIDER_ORDER: SyncSettingsProvider[] = ["github", "jira", "confluence"];
+const SYNC_SETTINGS_PROVIDER_ORDER: SyncSettingsProvider[] = [
+ "github",
+ "bitbucket",
+ "jira",
+ "confluence",
+];
// Small enough that the run table stays scannable and pagination is actually
// reachable rather than a single page of rows.
@@ -145,8 +175,8 @@ const DEFAULT_RUN_FILTER: RunFilterState = {
/**
* A source offered in the run-history filter. `value` is the GitHub repository
- * id, Jira instance URL, or Confluence space id; `sourceSystem` decides which
- * query param it maps to (repositoryId vs. sourceRef).
+ * id (also used for Bitbucket and Confluence), or Jira instance URL;
+ * `sourceSystem` decides which query param it maps to (repositoryId vs. sourceRef).
*/
type RunSourceFilterOption = {
value: string;
@@ -161,7 +191,8 @@ function toSourceSystem(value: string): SourceSystem | null {
normalized === "GITHUB" ||
normalized === "JIRA" ||
normalized === "UPLOAD" ||
- normalized === "CONFLUENCE"
+ normalized === "CONFLUENCE" ||
+ normalized === "BITBUCKET"
) {
return normalized;
}
@@ -268,8 +299,11 @@ function buildProjectDataSources(
const sourceSystem = toSourceSystem(projectSource.type);
if (!sourceSystem) return [];
- // Jira and Confluence cards are built solely from the connector-neutral status rows.
- if (sourceSystem === "JIRA" || sourceSystem === "CONFLUENCE") return [];
+ // Jira, Confluence and Bitbucket cards are built solely from the connector-neutral
+ // status rows; a project source of these types must not add a second, fallback card.
+ if (sourceSystem === "JIRA" || sourceSystem === "CONFLUENCE" || sourceSystem === "BITBUCKET") {
+ return [];
+ }
// Skip UPLOAD only when an authoritative status row already exists so the card
// does not vanish when artifact count is 0 or when run status fallback is needed.
if (sourceSystem === "UPLOAD" && sourceInstances.some((s) => s.sourceSystem === "UPLOAD")) {
@@ -481,6 +515,8 @@ export function DataIngestionPage() {
const [isSyncSettingsModalOpen, setIsSyncSettingsModalOpen] = useState(false);
const [globalGithubSyncConfig, setGlobalGithubSyncConfig] =
useState(DEFAULT_GLOBAL_GITHUB_SYNC_CONFIG);
+ const [globalBitbucketSyncConfig, setGlobalBitbucketSyncConfig] =
+ useState(DEFAULT_GLOBAL_BITBUCKET_SYNC_CONFIG);
const [globalJiraSyncConfig, setGlobalJiraSyncConfig] =
useState(DEFAULT_GLOBAL_JIRA_SYNC_CONFIG);
const [globalConfluenceSyncConfig, setGlobalConfluenceSyncConfig] =
@@ -634,11 +670,14 @@ export function DataIngestionPage() {
page,
size: RUN_PAGE_SIZE,
projectId: selectedProjectId || undefined,
- // GitHub and Confluence scope by repositoryId (filtering on backend sourceInstanceId UUID);
- // Jira scopes by the run's sourceInstanceRef via sourceRef (instance URL).
+ // GitHub, Bitbucket and Confluence scope by repositoryId (filtering on backend
+ // sourceInstanceId UUID); Jira scopes by the run's sourceInstanceRef via
+ // sourceRef (instance URL).
repositoryId:
hasSource &&
- (runFilter.sourceSystem === "GITHUB" || runFilter.sourceSystem === "CONFLUENCE")
+ (runFilter.sourceSystem === "GITHUB" ||
+ runFilter.sourceSystem === "BITBUCKET" ||
+ runFilter.sourceSystem === "CONFLUENCE")
? runFilter.sourceValue
: undefined,
sourceRef:
@@ -820,11 +859,25 @@ export function DataIngestionPage() {
);
});
+ // Bitbucket cards come from the status rows alone, like Jira: every field the
+ // card and drawer show is on the row.
+ const bitbucketSources = sourceInstances
+ .filter((status) => status.sourceSystem === "BITBUCKET")
+ .map((status) =>
+ createBitbucketSourceFromInstance(status, connectorEnabledById.get("bitbucket")),
+ );
+
const uploadSources = sourceInstances
.filter((status) => status.sourceSystem === "UPLOAD")
.map((status) => createUploadSourceFromInstance(status));
- return [...githubAndUpload, ...jiraSources, ...confluenceSources, ...uploadSources];
+ return [
+ ...githubAndUpload,
+ ...bitbucketSources,
+ ...jiraSources,
+ ...confluenceSources,
+ ...uploadSources,
+ ];
}, [
confluenceConnections,
connectorEnabledById,
@@ -877,6 +930,7 @@ export function DataIngestionPage() {
[sources],
);
const hasGithubSources = visibleSourceSystems.has("GITHUB");
+ const hasBitbucketSources = visibleSourceSystems.has("BITBUCKET");
const hasJiraSources = visibleSourceSystems.has("JIRA");
const hasConfluenceSources = visibleSourceSystems.has("CONFLUENCE");
// The connectors whose global sync policy can be edited right now: one tab per
@@ -884,6 +938,7 @@ export function DataIngestionPage() {
const syncSettingsProviders = useMemo[]>(() => {
const hasSources: Record = {
github: hasGithubSources,
+ bitbucket: hasBitbucketSources,
jira: hasJiraSources,
confluence: hasConfluenceSources,
};
@@ -891,7 +946,7 @@ export function DataIngestionPage() {
return SYNC_SETTINGS_PROVIDER_ORDER.filter((provider) => hasSources[provider]).map(
(provider) => ({ value: provider, label: SYNC_SETTINGS_COPY[provider].label }),
);
- }, [hasConfluenceSources, hasGithubSources, hasJiraSources]);
+ }, [hasBitbucketSources, hasConfluenceSources, hasGithubSources, hasJiraSources]);
const syncSettingsCopy = SYNC_SETTINGS_COPY[syncSettingsProvider];
const sourceHealth = useMemo(() => {
@@ -926,7 +981,7 @@ export function DataIngestionPage() {
const runSourceLabels = useMemo(() => buildRunSourceLabels(sources), [sources]);
// Sources offered in the run filter, from the project's connected sources: a
- // GitHub repo filters by its repositoryId, a Jira instance by its URL
+ // GitHub or Bitbucket repo filters by its repositoryId, a Jira instance by its URL
// (sourceId), which the query maps to sourceRef.
const runSourceOptions = useMemo(
() =>
@@ -941,6 +996,16 @@ export function DataIngestionPage() {
];
}
+ if (source.bitbucketRepository?.repositoryId) {
+ return [
+ {
+ value: source.bitbucketRepository.repositoryId,
+ label: source.name,
+ sourceSystem: "BITBUCKET",
+ },
+ ];
+ }
+
if (source.sourceSystem === "JIRA") {
return [
{
@@ -1131,6 +1196,17 @@ export function DataIngestionPage() {
return;
}
+ if (source.sourceSystem === "BITBUCKET") {
+ const repositoryId = source.bitbucketRepository?.repositoryId;
+ if (!repositoryId) {
+ throw new Error("Repository details are not available for this source.");
+ }
+
+ await updateBitbucketRepository(repositoryId);
+ refreshAfterUpdate();
+ return;
+ }
+
if (source.sourceSystem !== "GITHUB" || !source.githubRepository) {
throw new Error("Repository details are not available for this source.");
}
@@ -1155,6 +1231,15 @@ export function DataIngestionPage() {
[loadData, reloadSourceStatuses],
);
+ const handleSaveGlobalBitbucketConfig = useCallback(
+ async (request: ConfigureGithubRepositoryRequest) => {
+ await configureAllBitbucketRepositories(request);
+ setGlobalBitbucketSyncConfig(request);
+ await Promise.all([loadData(false), reloadSourceStatuses()]);
+ },
+ [loadData, reloadSourceStatuses],
+ );
+
const handleSaveGlobalJiraConfig = useCallback(
async (request: ConfigureGithubRepositoryRequest) => {
await configureAllJiraInstances(request);
@@ -1202,10 +1287,16 @@ export function DataIngestionPage() {
const globalSyncConfigs = useMemo>(
() => ({
github: globalGithubSyncConfig,
+ bitbucket: globalBitbucketSyncConfig,
jira: globalJiraSyncConfig,
confluence: globalConfluenceSyncConfig,
}),
- [globalConfluenceSyncConfig, globalGithubSyncConfig, globalJiraSyncConfig],
+ [
+ globalBitbucketSyncConfig,
+ globalConfluenceSyncConfig,
+ globalGithubSyncConfig,
+ globalJiraSyncConfig,
+ ],
);
const globalSyncSavers: Record<
@@ -1213,6 +1304,7 @@ export function DataIngestionPage() {
(request: ConfigureGithubRepositoryRequest) => Promise
> = {
github: handleSaveGlobalGithubConfig,
+ bitbucket: handleSaveGlobalBitbucketConfig,
jira: handleSaveGlobalJiraConfig,
confluence: handleSaveGlobalConfluenceConfig,
};
@@ -1235,6 +1327,20 @@ export function DataIngestionPage() {
[loadData, reloadSourceStatuses],
);
+ const handleLoadBitbucketRepositoryConfig = useCallback(
+ async (repository: BitbucketRepositoryDetails) =>
+ getBitbucketRepositoryConfig(repository.workspace, repository.slug),
+ [],
+ );
+
+ const handleSaveBitbucketRepositoryConfig = useCallback(
+ async (repository: BitbucketRepositoryDetails, request: ConfigureGithubRepositoryRequest) => {
+ await configureBitbucketRepository(repository, request);
+ await Promise.all([loadData(false), reloadSourceStatuses()]);
+ },
+ [loadData, reloadSourceStatuses],
+ );
+
const handleLoadJiraConfig = useCallback(
async (instanceUrl: string) => getJiraConfig(instanceUrl),
[],
@@ -1308,6 +1414,18 @@ export function DataIngestionPage() {
[refreshSourceDetails],
);
+ // Bitbucket repositories go through the same generic connector endpoint, keyed
+ // by `workspace/slug`.
+ const handleSetBitbucketSourceEnabled = useCallback(
+ async (repository: BitbucketRepositoryDetails, enabled: boolean) => {
+ await connectorService.patchConnectorSources("bitbucket", [
+ { sourceId: repository.fullName, enabled },
+ ]);
+ await refreshSourceDetails();
+ },
+ [refreshSourceDetails],
+ );
+
// Jira instances are gated through the same generic connector endpoint as
// GitHub: the JiraConnector's patchSource flips `sourceEnabled` and the AI
// service is notified, keyed by the instance URL.
@@ -1321,7 +1439,7 @@ export function DataIngestionPage() {
// Removes a source's link to the selected project (the DELETE counterpart to
// linking it via the Add Source flow), keyed per connector: a repository id
- // for GitHub, the instance URL for Jira, the connection id for Confluence. The
+ // for GitHub and Bitbucket, the instance URL for Jira, the connection id for Confluence. The
// source and its artifacts are kept; only this project stops using it. Closes
// the drawer and refreshes the project-scoped lists so the card disappears.
const handleUnlinkSource = useCallback(
@@ -1352,6 +1470,20 @@ export function DataIngestionPage() {
return;
}
+ if (source.sourceSystem === "BITBUCKET") {
+ const bitbucketRepositoryId = source.bitbucketRepository?.repositoryId;
+
+ if (!bitbucketRepositoryId || !selectedProjectId) {
+ throw new Error("This repository cannot be removed from the project.");
+ }
+
+ await removeBitbucketRepositoryFromProject(bitbucketRepositoryId, selectedProjectId);
+
+ setSelectedSourceId(null);
+ await refreshSourceDetails();
+ return;
+ }
+
const repositoryId = source.githubRepository?.repositoryId;
if (source.sourceSystem !== "GITHUB" || !repositoryId || !selectedProjectId) {
@@ -1605,11 +1737,14 @@ export function DataIngestionPage() {
canManageSyncSettings={canManageGithubSyncSettings}
onLoadRepositoryConfig={handleLoadGithubRepositoryConfig}
onSaveRepositoryConfig={handleSaveGithubRepositoryConfig}
+ onLoadBitbucketConfig={handleLoadBitbucketRepositoryConfig}
+ onSaveBitbucketConfig={handleSaveBitbucketRepositoryConfig}
onLoadJiraConfig={handleLoadJiraConfig}
onSaveJiraConfig={handleSaveJiraConfig}
onLoadConfluenceConfig={handleLoadConfluenceConfig}
onSaveConfluenceConfig={handleSaveConfluenceConfig}
onSetSourceEnabled={handleSetSourceEnabled}
+ onSetBitbucketSourceEnabled={handleSetBitbucketSourceEnabled}
onSetJiraSourceEnabled={handleSetJiraSourceEnabled}
onUnlinkSource={canIngestIntoSelectedProject ? handleUnlinkSource : undefined}
onClose={closeSourceDetails}
@@ -1686,7 +1821,10 @@ export function DataIngestionPage() {
showNextSync={false}
disclaimer={`Applying global settings overwrites the sync settings of every connected ${syncSettingsCopy.one}.`}
autoUpdateOnText={`Due checks update all connected ${syncSettingsCopy.many}.`}
- autoUpdateOffText={`Due checks only mark connected ${syncSettingsCopy.many} out of date.`}
+ autoUpdateOffText={
+ syncSettingsCopy.autoUpdateOff ??
+ `Due checks only mark connected ${syncSettingsCopy.many} out of date.`
+ }
toggleAriaLabel={`Toggle global ${syncSettingsCopy.label} auto update`}
saveLabel="Apply globally"
/>
diff --git a/src/services/projectService.ts b/src/services/projectService.ts
index ac06e95de..c4ffb0972 100644
--- a/src/services/projectService.ts
+++ b/src/services/projectService.ts
@@ -16,7 +16,8 @@ export type GlobalUserRole = "ADMIN" | "HR" | "PM" | "USER" | (string & {});
export type ProjectRole = "MEMBER" | "MANAGER" | "TEAMLEAD" | (string & {});
-export type ProjectSourceType = "GITHUB" | "JIRA" | "SONARQUBE" | "UPLOAD" | (string & {});
+export type ProjectSourceType =
+ "GITHUB" | "JIRA" | "BITBUCKET" | "SONARQUBE" | "UPLOAD" | (string & {});
export type ProjectSourceStatus =
| "CONNECTED"
diff --git a/src/services/sources/atlassianService.ts b/src/services/sources/atlassianService.ts
index 7f00099c0..6f98e3d2e 100644
--- a/src/services/sources/atlassianService.ts
+++ b/src/services/sources/atlassianService.ts
@@ -29,7 +29,7 @@ export type ChangeAtlassianCredentialTokenRequest = {
/**
* A stored credential as returned by the backend: only `userEmail` plus the
* credential/token name (`displayName`). The token secret is never returned.
- * Shared by the Jira and Confluence connectors.
+ * Shared by the Jira, Confluence and Bitbucket connectors.
*/
export type AtlassianCredentialDto = {
userEmail: string;
diff --git a/src/services/sources/bitbucketRepositoryInput.ts b/src/services/sources/bitbucketRepositoryInput.ts
new file mode 100644
index 000000000..29022c9b0
--- /dev/null
+++ b/src/services/sources/bitbucketRepositoryInput.ts
@@ -0,0 +1,98 @@
+/**
+ * Parsing for the free-form Bitbucket workspace and repository fields.
+ *
+ * The Bitbucket counterpart of `githubRepositoryInput.ts`: the connect flows let
+ * the user paste a URL into the workspace field instead of filling workspace and
+ * slug separately. A Bitbucket repository is addressed as `workspace/slug`.
+ *
+ * Bitbucket workspace ids and repository slugs are always lowercase, while the
+ * backend matches connections by exact coordinates. Every parser here lowercases
+ * what it returns, so `Acme/Widgets` resolves to the existing `acme/widgets`
+ * connection instead of becoming a second one.
+ */
+
+export type BitbucketRepositoryReference = {
+ workspace: string;
+ slug: string;
+};
+
+/**
+ * Strips the host part of a browser URL, an HTTPS clone URL (which may carry a
+ * `user@`) or an SSH remote, and the leading slashes, leaving the lowercased
+ * path segments. A query string or fragment (`?foo=bar`, `#readme`) is dropped
+ * first so it never ends up inside the last segment.
+ */
+function toPathSegments(value: string): string[] {
+ return value
+ .trim()
+ .replace(/[?#].*$/, "")
+ .replace(/^(?:https?|ssh):\/\/(?:[^@/]+@)?bitbucket\.org\//i, "")
+ .replace(/^bitbucket\.org\//i, "")
+ .replace(/^git@bitbucket\.org:/i, "")
+ .replace(/^\/+/, "")
+ .split("/")
+ .filter((segment) => segment.length > 0)
+ .map((segment) => segment.toLowerCase());
+}
+
+/**
+ * Normalizes a single free-form reference such as `workspace/slug`, a browser
+ * URL, an HTTPS clone URL or an SSH remote into its workspace and slug. A URL
+ * that points deeper into the repository (`/src/main`, `/pull-requests/3`) still
+ * resolves to the repository. Returns `null` when the value does not carry both
+ * halves.
+ */
+export function parseBitbucketRepositoryReference(
+ value: string,
+): BitbucketRepositoryReference | null {
+ const [workspace, rawSlug] = toPathSegments(value);
+ const slug = rawSlug?.replace(/\.git$/i, "");
+
+ if (workspace && slug) {
+ return { workspace, slug };
+ }
+
+ return null;
+}
+
+/**
+ * Extracts a bare workspace id from a free-form value such as `acme`,
+ * `bitbucket.org/acme` or a full `https://bitbucket.org/acme/` URL. Unlike
+ * {@link parseBitbucketRepositoryReference} it does not require a slug: it is
+ * used by the discovery flow, where only the workspace is entered. Returns
+ * `null` when no workspace segment can be found.
+ */
+export function parseBitbucketWorkspaceInput(value: string): string | null {
+ const [workspace] = toPathSegments(value);
+
+ return workspace ?? null;
+}
+
+/**
+ * Resolves the workspace and slug inputs of a connect form.
+ *
+ * A combined reference in the workspace field wins, so pasting `workspace/slug`
+ * or a URL works without touching the second field. Otherwise both fields have
+ * to be filled. Returns `null` when neither path yields a complete reference.
+ */
+export function parseBitbucketRepositoryInput(
+ workspaceInput: string,
+ slugInput: string,
+): BitbucketRepositoryReference | null {
+ const trimmedWorkspaceInput = workspaceInput.trim();
+ const trimmedSlugInput = slugInput.trim();
+ const parsedWorkspaceInput = parseBitbucketRepositoryReference(trimmedWorkspaceInput);
+
+ if (parsedWorkspaceInput) {
+ return parsedWorkspaceInput;
+ }
+
+ if (trimmedWorkspaceInput && trimmedSlugInput) {
+ return {
+ workspace: trimmedWorkspaceInput.toLowerCase(),
+ slug: trimmedSlugInput.toLowerCase(),
+ };
+ }
+
+ return null;
+}
diff --git a/src/services/sources/bitbucketService.ts b/src/services/sources/bitbucketService.ts
new file mode 100644
index 000000000..1d000fc46
--- /dev/null
+++ b/src/services/sources/bitbucketService.ts
@@ -0,0 +1,293 @@
+import { apiClient } from "../apiClient.ts";
+import type { BitbucketRepositoryReference } from "./bitbucketRepositoryInput.ts";
+import type {
+ ConfigureGithubRepositoryRequest,
+ GithubScheduleSpec,
+ RepositoryProjectAssignmentResponse,
+} from "./githubService.ts";
+
+/**
+ * Bitbucket repositories are scheduled through the same shared spec as GitHub
+ * repositories, Jira instances and Confluence spaces, so the schedule form is
+ * shared with them as well.
+ */
+export type BitbucketScheduleSpec = GithubScheduleSpec;
+
+export type ConfigureBitbucketRepositoryRequest = ConfigureGithubRepositoryRequest;
+
+export type ConnectBitbucketRepositoryRequest = BitbucketRepositoryReference & {
+ /** Name of a stored Atlassian credential, shared with Jira and Confluence. */
+ credentialName: string;
+ projectId: string;
+};
+
+/**
+ * Unlike GitHub, the backend reports no `wasReused` flag, so a connect that
+ * linked an existing connection looks the same as a fresh one.
+ */
+export type ConnectBitbucketRepositoryResponse = {
+ transactionId: string;
+};
+
+export type ConnectBitbucketRepositoriesResult = {
+ /** Maps `"workspace/slug"` to the accepted ingestion transaction id. */
+ transactionIdsByRepository: Record;
+};
+
+/**
+ * One repository returned by workspace discovery, normalized for the UI. The
+ * shape mirrors the GitHub `DiscoveredRepository` (plus the coordinates a
+ * connect needs) so the discovery list can treat both alike.
+ */
+export type DiscoveredBitbucketRepository = BitbucketRepositoryReference & {
+ name: string;
+ isPrivate: boolean;
+ /** The repository's browser URL; null when Bitbucket reported no link. */
+ url: string | null;
+ alreadyConnected: boolean;
+ isEnabled: boolean | null;
+};
+
+/**
+ * The discovery row as the backend sends it. The Kotlin properties `isPrivate`
+ * and `enabled` carry no explicit JSON name, and Jackson may serialize a
+ * boolean `isPrivate` as `private`, so both spellings are read.
+ */
+type BackendDiscoveredBitbucketRepository = {
+ workspace: string;
+ slug: string;
+ name: string;
+ isPrivate?: boolean;
+ private?: boolean;
+ url?: string | null;
+ alreadyConnected?: boolean;
+ enabled?: boolean | null;
+ isEnabled?: boolean | null;
+};
+
+type BackendDiscoverBitbucketRepositoriesResponse = {
+ repositories: BackendDiscoveredBitbucketRepository[];
+};
+
+export type DiscoverBitbucketRepositoriesResult = {
+ repositories: DiscoveredBitbucketRepository[];
+ /**
+ * Best-effort "there may be another page" flag. The backend returns no total
+ * count, so we infer it from a full page being returned (`length >= pageSize`).
+ */
+ hasMore: boolean;
+};
+
+export type UpdateBitbucketRepositoryResponse = {
+ transactionId: string;
+};
+
+export type UpdateAllBitbucketRepositoriesResponse = {
+ /** Maps `"workspace/slug"` to the update's transaction id; disabled repositories are absent. */
+ transactionIdsByRepository: Record;
+};
+
+/**
+ * The schedule and auto-update policy of one connected repository. The backend
+ * names the coordinates `workspace` and `slug` here, where the GitHub config
+ * has `repositoryOwner` and `repositoryName`.
+ */
+export type BitbucketRepositoryConfig = {
+ id: string;
+ workspace: string;
+ slug: string;
+ autoUpdate: boolean;
+ spec: BitbucketScheduleSpec | null;
+ schedule: string;
+ nextSyncAt: string | null;
+};
+
+const DEFAULT_DISCOVER_PAGE_SIZE = 20;
+
+function mapDiscoveredRepository(
+ repository: BackendDiscoveredBitbucketRepository,
+): DiscoveredBitbucketRepository {
+ return {
+ workspace: repository.workspace,
+ slug: repository.slug,
+ name: repository.name,
+ isPrivate: repository.isPrivate ?? repository.private ?? false,
+ url: repository.url ?? null,
+ alreadyConnected: repository.alreadyConnected ?? false,
+ isEnabled: repository.enabled ?? repository.isEnabled ?? null,
+ };
+}
+
+function repositoryPath({ workspace, slug }: BitbucketRepositoryReference): string {
+ return `${encodeURIComponent(workspace)}/${encodeURIComponent(slug)}`;
+}
+
+/**
+ * Connects a Bitbucket repository to a project by notifying the backend. The
+ * backend handles the actual ingestion asynchronously.
+ *
+ * @returns The backend transaction identifier for the accepted connection job.
+ * @throws ApiError — 404 when the named credential does not exist, or when the
+ * repository does not exist or the credential cannot read it; 403 when the
+ * caller cannot access the project.
+ */
+export async function connectBitbucketRepository(
+ request: ConnectBitbucketRepositoryRequest,
+): Promise {
+ return apiClient.fetch("/api/v1/bitbucket", {
+ method: "POST",
+ body: JSON.stringify(request),
+ });
+}
+
+/**
+ * Connects several repositories to one project in a single call. Each
+ * repository still becomes its own source. Requires the PM or ADMIN role and
+ * access to the target project.
+ *
+ * @returns The accepted ingestion transaction ids keyed by `"workspace/slug"`.
+ */
+export async function connectBitbucketRepositories(
+ repositories: BitbucketRepositoryReference[],
+ credentialName: string,
+ projectId: string,
+): Promise {
+ return apiClient.fetch("/api/v1/bitbucket/connect/all", {
+ method: "POST",
+ body: JSON.stringify({
+ repositories: repositories.map(({ workspace, slug }) => ({
+ workspace,
+ slug,
+ credentialName,
+ projectId,
+ })),
+ }),
+ });
+}
+
+/**
+ * Discovers the repositories of one Bitbucket workspace the named Atlassian
+ * credential can read. `page` is 0-based (matching the backend). Bitbucket has
+ * no cross-workspace listing, so discovery is always scoped to a workspace.
+ * Requires the PM or ADMIN role.
+ *
+ * @throws ApiError — 404 when the credential does not exist, 403 for insufficient role.
+ */
+export async function discoverBitbucketRepositories(
+ workspace: string,
+ credentialName: string,
+ page = 0,
+ pageSize = DEFAULT_DISCOVER_PAGE_SIZE,
+): Promise {
+ const query = new URLSearchParams({
+ credentialName,
+ page: String(page),
+ pageSize: String(pageSize),
+ }).toString();
+ const response = await apiClient.fetch(
+ `/api/v1/bitbucket/discover/workspace/${encodeURIComponent(workspace)}?${query}`,
+ );
+ const repositories = response.repositories.map(mapDiscoveredRepository);
+
+ return {
+ repositories,
+ // No total is returned, so a full page is our only "there might be more" signal.
+ hasMore: repositories.length >= pageSize,
+ };
+}
+
+/**
+ * Assigns an already-ingested repository to an additional project without
+ * re-fetching or re-ingesting it. Idempotent. Requires the PM or ADMIN role and
+ * access to the target project.
+ *
+ * @throws ApiError — 403 when the caller cannot access the project, 404 when the
+ * repository connection is unknown.
+ */
+export async function addBitbucketRepositoryToProject(
+ repositoryId: string,
+ projectId: string,
+): Promise {
+ return apiClient.fetch(
+ `/api/v1/bitbucket/connections/${encodeURIComponent(repositoryId)}/projects/${encodeURIComponent(projectId)}`,
+ { method: "POST" },
+ );
+}
+
+/**
+ * Removes the link between an already-ingested repository and a project, the
+ * counterpart to {@link addBitbucketRepositoryToProject}. The repository and its
+ * artifacts are kept; only the project assignment is dropped. Idempotent.
+ *
+ * @returns The repository connection's remaining project ids after the unlink.
+ * @throws ApiError — 403 when the caller cannot access the project, 404 when the
+ * repository connection is unknown.
+ */
+export async function removeBitbucketRepositoryFromProject(
+ repositoryId: string,
+ projectId: string,
+): Promise {
+ return apiClient.fetch(
+ `/api/v1/bitbucket/connections/${encodeURIComponent(repositoryId)}/projects/${encodeURIComponent(projectId)}`,
+ { method: "DELETE" },
+ );
+}
+
+/**
+ * Starts an update of one connected repository. A paused (disabled) repository
+ * is refused by the backend rather than quietly updated.
+ */
+export async function updateBitbucketRepository(
+ repositoryId: string,
+): Promise {
+ return apiClient.fetch(
+ `/api/v1/bitbucket/connections/${encodeURIComponent(repositoryId)}/update`,
+ { method: "POST" },
+ );
+}
+
+/** Updates every enabled connected repository; disabled ones are skipped. */
+export async function updateAllBitbucketRepositories(): Promise {
+ return apiClient.fetch("/api/v1/bitbucket/update-all", {
+ method: "POST",
+ });
+}
+
+/**
+ * Applies one schedule and auto-update policy to all connected Bitbucket
+ * repositories. The backend converts the typed schedule into the stored cron
+ * expression.
+ */
+export async function configureAllBitbucketRepositories(
+ request: ConfigureBitbucketRepositoryRequest,
+): Promise {
+ await apiClient.fetch("/api/v1/bitbucket/config", {
+ method: "PUT",
+ body: JSON.stringify(request),
+ });
+}
+
+/**
+ * Loads the current schedule and auto-update policy for one connected repository.
+ */
+export async function getBitbucketRepositoryConfig(
+ workspace: string,
+ slug: string,
+): Promise {
+ return apiClient.fetch(
+ `/api/v1/bitbucket/config/${repositoryPath({ workspace, slug })}`,
+ );
+}
+
+/**
+ * Updates the schedule and auto-update policy for one connected repository.
+ */
+export async function configureBitbucketRepository(
+ repository: BitbucketRepositoryReference,
+ request: ConfigureBitbucketRepositoryRequest,
+): Promise {
+ await apiClient.fetch(`/api/v1/bitbucket/config/${repositoryPath(repository)}`, {
+ method: "PUT",
+ body: JSON.stringify(request),
+ });
+}
diff --git a/tests/unit/a11y/BitbucketRepositoryDiscovery.a11y.test.tsx b/tests/unit/a11y/BitbucketRepositoryDiscovery.a11y.test.tsx
new file mode 100644
index 000000000..9fd9a7dac
--- /dev/null
+++ b/tests/unit/a11y/BitbucketRepositoryDiscovery.a11y.test.tsx
@@ -0,0 +1,79 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { http, HttpResponse } from "msw";
+import { describe, it, expect, vi } from "vitest";
+import { axe } from "vitest-axe";
+import { BitbucketRepositoryDiscovery } from "../../../src/features/data-ingestion/components/BitbucketRepositoryDiscovery";
+import { server } from "../setup/vitest.setup";
+
+const credentials = [{ displayName: "default", userEmail: "me@corp.com" }];
+
+function renderDiscovery(
+ overrides: Partial[0]> = {},
+) {
+ return render(
+ ,
+ );
+}
+
+describe("BitbucketRepositoryDiscovery Accessibility", () => {
+ it("has no violations before discovery", async () => {
+ const { container } = renderDiscovery();
+
+ expect(await axe(container)).toHaveNoViolations();
+ });
+
+ it("has no violations without stored credentials", async () => {
+ const { container } = renderDiscovery({ credentials: [], credentialName: "" });
+
+ expect(await screen.findByText(/No Atlassian credentials are stored/)).toBeInTheDocument();
+ expect(await axe(container)).toHaveNoViolations();
+ });
+
+ it("has no violations with discovered repositories", async () => {
+ server.use(
+ http.get("/api/v1/bitbucket/discover/workspace/:workspace", () =>
+ HttpResponse.json({
+ repositories: [
+ {
+ workspace: "acme",
+ slug: "widgets",
+ name: "Widget Shop",
+ isPrivate: true,
+ url: "https://bitbucket.org/acme/widgets",
+ alreadyConnected: false,
+ enabled: null,
+ },
+ {
+ workspace: "acme",
+ slug: "gadgets",
+ name: "gadgets",
+ isPrivate: false,
+ url: "https://bitbucket.org/acme/gadgets",
+ alreadyConnected: true,
+ enabled: false,
+ },
+ ],
+ }),
+ ),
+ );
+ const user = userEvent.setup();
+ const { container } = renderDiscovery();
+
+ await user.type(screen.getByLabelText("Workspace or bitbucket.org URL"), "acme");
+ await user.click(screen.getByRole("button", { name: "Discover" }));
+ await screen.findByText("Widget Shop (widgets)");
+
+ expect(await axe(container)).toHaveNoViolations();
+ });
+});
diff --git a/tests/unit/a11y/CreateProjectWizard.a11y.test.tsx b/tests/unit/a11y/CreateProjectWizard.a11y.test.tsx
index 92c4ccfe2..2f20e3e32 100644
--- a/tests/unit/a11y/CreateProjectWizard.a11y.test.tsx
+++ b/tests/unit/a11y/CreateProjectWizard.a11y.test.tsx
@@ -24,6 +24,25 @@ vi.mock("../../../src/services/sources/githubService", () => ({
addGithubPat: vi.fn(),
}));
+vi.mock("../../../src/services/sources/bitbucketService", () => ({
+ discoverBitbucketRepositories: vi.fn().mockResolvedValue({
+ repositories: [
+ {
+ workspace: "acme",
+ slug: "widgets",
+ name: "Widgets",
+ isPrivate: true,
+ url: "https://bitbucket.org/acme/widgets",
+ alreadyConnected: false,
+ isEnabled: null,
+ },
+ ],
+ hasMore: false,
+ }),
+ connectBitbucketRepository: vi.fn(),
+ addBitbucketRepositoryToProject: vi.fn(),
+}));
+
vi.mock("../../../src/services/ingestionService", () => ({
getIngestionSourceStatuses: vi.fn().mockResolvedValue([]),
}));
@@ -67,6 +86,12 @@ async function openGithubDetail(user: ReturnType) {
await user.click(screen.getByRole("button", { name: /indexes repositories/i }));
}
+/** From the sources step, open the add-source flow and pick the Bitbucket type. */
+async function openBitbucketDetail(user: ReturnType) {
+ await user.click(screen.getByRole("button", { name: /add source/i }));
+ await user.click(screen.getByRole("button", { name: /indexes pull requests, readme/i }));
+}
+
describe("CreateProjectWizard Accessibility", () => {
beforeEach(() => {
// Narrowest layout by default — the credential form opens inline. The
@@ -151,6 +176,36 @@ describe("CreateProjectWizard Accessibility", () => {
expect(await axe(baseElement)).toHaveNoViolations();
});
+ it("has no axe violations on the Bitbucket detail", async () => {
+ const user = userEvent.setup();
+ const { baseElement } = renderWizard();
+
+ await screen.findByRole("dialog", { name: "New Project" });
+ await waitFor(() => expect(screen.getByLabelText(/^Name/)).toBeInTheDocument());
+ await goToSources(user);
+ await openBitbucketDetail(user);
+
+ // No credential is stored in this suite, so the form is locked and the chip says why.
+ expect(await screen.findByText("No credential yet")).toBeInTheDocument();
+
+ expect(await axe(baseElement)).toHaveNoViolations();
+ });
+
+ it("has no axe violations with the inline Atlassian credential form open on the Bitbucket detail", async () => {
+ const user = userEvent.setup();
+ const { baseElement } = renderWizard();
+
+ await screen.findByRole("dialog", { name: "New Project" });
+ await waitFor(() => expect(screen.getByLabelText(/^Name/)).toBeInTheDocument());
+ await goToSources(user);
+ await openBitbucketDetail(user);
+
+ await user.click(screen.getByRole("button", { name: /add atlassian credential/i }));
+
+ expect(await screen.findByTestId("settings-atlassian-add-email")).toBeInTheDocument();
+ expect(await axe(baseElement)).toHaveNoViolations();
+ });
+
it("has no axe violations on the review step with a member who would be moved", async () => {
const user = userEvent.setup();
const mover: AdminUser = {
diff --git a/tests/unit/a11y/SourceConnectModal.a11y.test.tsx b/tests/unit/a11y/SourceConnectModal.a11y.test.tsx
index 136f75d79..fd8e6192d 100644
--- a/tests/unit/a11y/SourceConnectModal.a11y.test.tsx
+++ b/tests/unit/a11y/SourceConnectModal.a11y.test.tsx
@@ -23,6 +23,12 @@ describe("SourceConnectModal Accessibility", () => {
type: "Space",
icon: Activity,
},
+ BITBUCKET: {
+ name: "Bitbucket",
+ description: "Connect to Bitbucket",
+ type: "Repository",
+ icon: Activity,
+ },
};
const { baseElement } = render(
diff --git a/tests/unit/a11y/SourceDetailsPanel.a11y.test.tsx b/tests/unit/a11y/SourceDetailsPanel.a11y.test.tsx
index daa890c5a..d8312d829 100644
--- a/tests/unit/a11y/SourceDetailsPanel.a11y.test.tsx
+++ b/tests/unit/a11y/SourceDetailsPanel.a11y.test.tsx
@@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest";
import { axe } from "vitest-axe";
import { MemoryRouter } from "react-router-dom";
import { GitBranch } from "lucide-react";
+import { BitbucketIcon } from "../../../src/components/icons/BitbucketIcon";
import { SourceDetailsPanel } from "../../../src/features/data-ingestion/components/SourceDetailsPanel";
import type { DataSource } from "../../../src/features/data-ingestion/types";
import { deriveSourceStatus } from "../../../src/features/data-ingestion/data";
@@ -55,4 +56,51 @@ describe("SourceDetailsPanel Accessibility", () => {
expect(await axe(baseElement)).toHaveNoViolations();
});
+
+ it("has no a11y violations for a Bitbucket repository with all actions", async () => {
+ const bitbucketRepository = {
+ repositoryId: "bb-repo-1",
+ workspace: "acme",
+ slug: "widgets",
+ fullName: "acme/widgets",
+ url: "https://bitbucket.org/acme/widgets",
+ enabled: true,
+ };
+ const bitbucketSource: DataSource = {
+ ...source,
+ sourceId: "bb-repo-1",
+ sourceSystem: "BITBUCKET",
+ name: "acme/widgets",
+ type: "Bitbucket",
+ icon: BitbucketIcon,
+ lastPullRequestsSyncAt: "2026-07-01T00:00:00.000Z",
+ bitbucketRepository,
+ };
+
+ const { baseElement } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getAllByText("acme/widgets").length).toBeGreaterThan(0);
+ });
+ await screen.findByLabelText("Minutes");
+
+ expect(await axe(baseElement)).toHaveNoViolations();
+ });
});
diff --git a/tests/unit/features/admin/components/CreateProjectWizard.test.tsx b/tests/unit/features/admin/components/CreateProjectWizard.test.tsx
index 8ffd5c4ac..64be1f95c 100644
--- a/tests/unit/features/admin/components/CreateProjectWizard.test.tsx
+++ b/tests/unit/features/admin/components/CreateProjectWizard.test.tsx
@@ -24,6 +24,12 @@ vi.mock("../../../../../src/services/sources/githubService", () => ({
addGithubPat: vi.fn(),
}));
+vi.mock("../../../../../src/services/sources/bitbucketService", () => ({
+ discoverBitbucketRepositories: vi.fn(),
+ connectBitbucketRepository: vi.fn(),
+ addBitbucketRepositoryToProject: vi.fn(),
+}));
+
vi.mock("../../../../../src/services/ingestionService", () => ({
getIngestionSourceStatuses: vi.fn(),
}));
@@ -55,6 +61,11 @@ import {
discoverRepositories,
getGithubPatNames,
} from "../../../../../src/services/sources/githubService";
+import {
+ addBitbucketRepositoryToProject,
+ connectBitbucketRepository,
+ discoverBitbucketRepositories,
+} from "../../../../../src/services/sources/bitbucketService";
import { connectJiraInstance } from "../../../../../src/services/sources/jiraService";
import {
addAtlassianCredential,
@@ -201,6 +212,34 @@ describe("CreateProjectWizard", () => {
resolvedOwnerType: "org",
});
vi.mocked(getIngestionSourceStatuses).mockResolvedValue([]);
+ vi.mocked(connectBitbucketRepository).mockResolvedValue({ transactionId: "bb-tx" });
+ vi.mocked(addBitbucketRepositoryToProject).mockResolvedValue({
+ repositoryId: "bb-42",
+ projectIds: ["proj-new"],
+ });
+ vi.mocked(discoverBitbucketRepositories).mockResolvedValue({
+ repositories: [
+ {
+ workspace: "acme",
+ slug: "widgets",
+ name: "widgets",
+ isPrivate: false,
+ url: "https://bitbucket.org/acme/widgets",
+ alreadyConnected: false,
+ isEnabled: null,
+ },
+ {
+ workspace: "acme",
+ slug: "gadgets",
+ name: "gadgets",
+ isPrivate: true,
+ url: "https://bitbucket.org/acme/gadgets",
+ alreadyConnected: false,
+ isEnabled: null,
+ },
+ ],
+ hasMore: false,
+ });
vi.mocked(getGithubPatNames).mockResolvedValue(["team-pat"]);
vi.mocked(addGithubPat).mockResolvedValue(undefined);
vi.mocked(addAtlassianCredential).mockResolvedValue(undefined);
@@ -262,6 +301,198 @@ describe("CreateProjectWizard", () => {
await user.click(screen.getByRole("button", { name: /add to list/i }));
}
+ /** From the sources step, open the add-source flow and pick the Bitbucket type. */
+ async function openBitbucketDetail(user: ReturnType) {
+ await user.click(screen.getByRole("button", { name: /add source/i }));
+ await user.click(screen.getByRole("button", { name: /indexes pull requests, readme/i }));
+
+ // The stored credential is shared with Jira and Confluence and adopted automatically.
+ await screen.findByText(/Team token - me@example.com/i);
+ }
+
+ /** Discovers the `acme` workspace and ticks the given repositories. */
+ async function discoverBitbucket(user: ReturnType, ...slugs: string[]) {
+ await user.type(screen.getByLabelText("Workspace or bitbucket.org URL"), "acme");
+ await user.click(screen.getByRole("button", { name: /discover/i }));
+
+ for (const slug of slugs) {
+ await user.click(await screen.findByRole("checkbox", { name: new RegExp(slug, "i") }));
+ }
+ }
+
+ /** Stages `widgets` as a Bitbucket source and returns to the sources list. */
+ async function stageBitbucketWidgets(user: ReturnType) {
+ await openBitbucketDetail(user);
+ await discoverBitbucket(user, "widgets");
+ await user.click(screen.getByRole("button", { name: /add to list/i }));
+ }
+
+ it("offers Bitbucket as a connectable type in the add-source grid", async () => {
+ const user = userEvent.setup();
+ renderWizard();
+ await goToSources(user);
+ await user.click(screen.getByRole("button", { name: /add source/i }));
+
+ expect(screen.getByRole("button", { name: /bitbucket/i })).not.toHaveTextContent(/soon/i);
+ });
+
+ it("stages Bitbucket repositories and connects them against the new project on Create", async () => {
+ const user = userEvent.setup();
+ renderWizard();
+ await goToSources(user);
+
+ await openBitbucketDetail(user);
+ await discoverBitbucket(user, "widgets", "gadgets");
+ await user.click(screen.getByRole("button", { name: /add to list/i }));
+
+ // Back on the sources list, both are staged under workspace/slug.
+ expect(screen.getByText("acme/widgets")).toBeInTheDocument();
+ expect(screen.getByText("acme/gadgets")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: /continue/i }));
+ await user.click(screen.getByRole("button", { name: /create project/i }));
+
+ await waitFor(() => expect(vi.mocked(connectBitbucketRepository)).toHaveBeenCalledTimes(2));
+ expect(vi.mocked(connectBitbucketRepository)).toHaveBeenCalledWith({
+ workspace: "acme",
+ slug: "widgets",
+ credentialName: "Team token",
+ projectId: "proj-new",
+ });
+ expect(vi.mocked(connectGithubRepository)).not.toHaveBeenCalled();
+ });
+
+ it("shows the staged Bitbucket repository on the review step", async () => {
+ const user = userEvent.setup();
+ renderWizard();
+ await goToSources(user);
+ await stageBitbucketWidgets(user);
+
+ await user.click(screen.getByRole("button", { name: /continue/i }));
+
+ expect(screen.getByText("acme/widgets")).toBeInTheDocument();
+ expect(screen.getByText("Bitbucket")).toBeInTheDocument();
+ });
+
+ it("connects a mixed batch of a GitHub repo and a Bitbucket repo", async () => {
+ const user = userEvent.setup();
+ renderWizard();
+ await goToSources(user);
+ // Same owner/name on both providers: they are two different sources.
+ await stageWidgets(user);
+ await stageBitbucketWidgets(user);
+
+ expect(screen.getAllByText("acme/widgets")).toHaveLength(2);
+
+ await user.click(screen.getByRole("button", { name: /continue/i }));
+ await user.click(screen.getByRole("button", { name: /create project/i }));
+
+ await waitFor(() => expect(vi.mocked(connectBitbucketRepository)).toHaveBeenCalledTimes(1));
+ expect(vi.mocked(connectGithubRepository)).toHaveBeenCalledWith(
+ expect.objectContaining({ owner: "acme", name: "widgets", projectId: "proj-new" }),
+ );
+ expect(vi.mocked(connectBitbucketRepository)).toHaveBeenCalledWith(
+ expect.objectContaining({ workspace: "acme", slug: "widgets", projectId: "proj-new" }),
+ );
+ });
+
+ it("links an already-ingested Bitbucket repository instead of re-ingesting it", async () => {
+ vi.mocked(discoverBitbucketRepositories).mockResolvedValue({
+ repositories: [
+ {
+ workspace: "acme",
+ slug: "linked",
+ name: "linked",
+ isPrivate: false,
+ url: "https://bitbucket.org/acme/linked",
+ alreadyConnected: true,
+ isEnabled: true,
+ },
+ ],
+ hasMore: false,
+ });
+ vi.mocked(getIngestionSourceStatuses).mockResolvedValue([
+ {
+ ...statusRow("acme/linked", "bb-42"),
+ sourceSystem: "BITBUCKET",
+ },
+ ]);
+ const user = userEvent.setup();
+ renderWizard();
+ await goToSources(user);
+
+ await openBitbucketDetail(user);
+ await user.type(screen.getByLabelText("Workspace or bitbucket.org URL"), "acme");
+ await user.click(screen.getByRole("button", { name: /discover/i }));
+ const linkedCheckbox = await screen.findByRole("checkbox", { name: /linked/i });
+ await waitFor(() => expect(linkedCheckbox).toBeEnabled());
+ await user.click(linkedCheckbox);
+ await user.click(screen.getByRole("button", { name: /add to list/i }));
+
+ expect(screen.getByText(/Already ingested, will be linked/)).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: /continue/i }));
+ await user.click(screen.getByRole("button", { name: /create project/i }));
+
+ await waitFor(() =>
+ expect(vi.mocked(addBitbucketRepositoryToProject)).toHaveBeenCalledWith("bb-42", "proj-new"),
+ );
+ expect(vi.mocked(connectBitbucketRepository)).not.toHaveBeenCalled();
+ });
+
+ it("keeps the project and offers a retry when a Bitbucket repository fails to connect", async () => {
+ vi.mocked(connectBitbucketRepository).mockRejectedValueOnce(new Error("no access"));
+ const user = userEvent.setup();
+ renderWizard();
+ await goToSources(user);
+ await stageBitbucketWidgets(user);
+
+ await user.click(screen.getByRole("button", { name: /continue/i }));
+ await user.click(screen.getByRole("button", { name: /create project/i }));
+
+ expect(await screen.findByRole("button", { name: /retry/i })).toBeInTheDocument();
+ expect(screen.getByText("no access")).toBeInTheDocument();
+ expect(vi.mocked(projectService.createProject)).toHaveBeenCalledTimes(1);
+ });
+
+ it("adds an Atlassian credential inline while staging a Bitbucket repository and selects the new one", async () => {
+ vi.mocked(getMyAtlassianCredentials)
+ .mockResolvedValueOnce([]) // initial load: none stored
+ .mockResolvedValue([{ userEmail: "new@example.com", displayName: "Fresh cred" }]);
+ const user = userEvent.setup();
+ renderWizard();
+ await goToSources(user);
+ await user.click(screen.getByRole("button", { name: /add source/i }));
+ await user.click(screen.getByRole("button", { name: /indexes pull requests, readme/i }));
+
+ await user.click(screen.getByRole("button", { name: /add atlassian credential/i }));
+ await user.type(screen.getByTestId("settings-atlassian-add-email"), "new@example.com");
+ await user.type(screen.getByTestId("settings-atlassian-add-name"), "Fresh cred");
+ await user.type(screen.getByTestId("settings-atlassian-add-token"), "bitbucket-token");
+ await user.click(screen.getByTestId("settings-atlassian-add-submit"));
+
+ await waitFor(() =>
+ expect(vi.mocked(addAtlassianCredential)).toHaveBeenCalledWith({
+ userEmail: "new@example.com",
+ tokenName: "Fresh cred",
+ authToken: "bitbucket-token",
+ }),
+ );
+ // The refreshed credential is adopted and drives discovery.
+ await screen.findByText(/Fresh cred - new@example.com/i);
+ expect(screen.getByLabelText("Credential")).toHaveTextContent("Fresh cred");
+
+ await user.type(screen.getByLabelText("Workspace or bitbucket.org URL"), "acme");
+ await user.click(screen.getByRole("button", { name: /discover/i }));
+ await screen.findByRole("checkbox", { name: /widgets/i });
+ expect(vi.mocked(discoverBitbucketRepositories)).toHaveBeenCalledWith(
+ "acme",
+ "Fresh cred",
+ 0,
+ 20,
+ );
+ });
+
it("keeps details on a blank name and explains why instead of a dead button", async () => {
const user = userEvent.setup();
renderWizard();
diff --git a/tests/unit/features/admin/projectSourcesDraft.test.ts b/tests/unit/features/admin/projectSourcesDraft.test.ts
index 21ae84fe8..57e15a219 100644
--- a/tests/unit/features/admin/projectSourcesDraft.test.ts
+++ b/tests/unit/features/admin/projectSourcesDraft.test.ts
@@ -5,6 +5,8 @@ import {
connectOutcome,
connectOutcomeDescription,
countUnconnectedSources,
+ createBitbucketDraft,
+ createBitbucketDraftFromDiscovery,
createDraftSource,
createDraftSourceFromDiscovery,
createJiraDraft,
@@ -16,7 +18,12 @@ import {
setDraftSourceOwner,
type DraftSource,
} from "../../../../src/features/admin/projectSourcesDraft";
+import { ApiError } from "../../../../src/services/apiClient";
import { knowledgeGapService } from "../../../../src/services/knowledgeGapService";
+import {
+ addBitbucketRepositoryToProject,
+ connectBitbucketRepository,
+} from "../../../../src/services/sources/bitbucketService";
import {
addRepositoryToProject,
connectGithubRepository,
@@ -30,6 +37,11 @@ vi.mock("../../../../src/services/sources/githubService", () => ({
addRepositoryToProject: vi.fn(),
}));
+vi.mock("../../../../src/services/sources/bitbucketService", () => ({
+ connectBitbucketRepository: vi.fn(),
+ addBitbucketRepositoryToProject: vi.fn(),
+}));
+
vi.mock("../../../../src/services/sources/jiraService", () => ({
connectJiraInstance: vi.fn(),
}));
@@ -44,6 +56,8 @@ vi.mock("../../../../src/services/knowledgeGapService", () => ({
const connectGithubRepositoryMock = vi.mocked(connectGithubRepository);
const addRepositoryToProjectMock = vi.mocked(addRepositoryToProject);
+const connectBitbucketRepositoryMock = vi.mocked(connectBitbucketRepository);
+const addBitbucketRepositoryToProjectMock = vi.mocked(addBitbucketRepositoryToProject);
const connectJiraInstanceMock = vi.mocked(connectJiraInstance);
const uploadDocumentsMock = vi.mocked(knowledgeService.uploadDocuments);
const setComponentOwnersMock = vi.mocked(knowledgeGapService.setComponentOwners);
@@ -60,6 +74,13 @@ beforeEach(() => {
connectGithubRepositoryMock.mockResolvedValue({ transactionId: "tx" });
addRepositoryToProjectMock.mockReset();
addRepositoryToProjectMock.mockResolvedValue({ repositoryId: "r1", projectIds: ["p1"] });
+ connectBitbucketRepositoryMock.mockReset();
+ connectBitbucketRepositoryMock.mockResolvedValue({ transactionId: "bb-tx" });
+ addBitbucketRepositoryToProjectMock.mockReset();
+ addBitbucketRepositoryToProjectMock.mockResolvedValue({
+ repositoryId: "bb-r1",
+ projectIds: ["p1"],
+ });
connectJiraInstanceMock.mockReset();
connectJiraInstanceMock.mockResolvedValue(undefined);
uploadDocumentsMock.mockReset();
@@ -166,6 +187,24 @@ describe("isSameSource", () => {
).toBe(true);
});
+ it("matches Bitbucket repositories by workspace/slug regardless of casing", () => {
+ expect(
+ isSameSource(
+ createBitbucketDraft("acme", "widgets", "a"),
+ createBitbucketDraft("ACME", "Widgets", "b"),
+ ),
+ ).toBe(true);
+ });
+
+ it("does not match a Bitbucket repository with a GitHub one of the same name", () => {
+ expect(
+ isSameSource(
+ createBitbucketDraft("acme", "widgets", "cred"),
+ createDraftSource("acme", "widgets", "pat"),
+ ),
+ ).toBe(false);
+ });
+
it("never treats two uploads as the same source", () => {
expect(isSameSource(createUploadDraft("A", []), createUploadDraft("A", []))).toBe(false);
});
@@ -204,6 +243,53 @@ describe("createDraftSourceFromDiscovery", () => {
});
});
+describe("createBitbucketDraft / createBitbucketDraftFromDiscovery", () => {
+ const baseSelection: DiscoverySelection = {
+ owner: "acme",
+ name: "widgets",
+ isPrivate: false,
+ linkState: "new",
+ };
+
+ it("stages a pending Bitbucket repository with a unique id", () => {
+ const first = createBitbucketDraft("acme", "widgets", "atlassian-main");
+ const second = createBitbucketDraft("acme", "widgets", "atlassian-main");
+
+ expect(first).toMatchObject({
+ type: "BITBUCKET",
+ workspace: "acme",
+ slug: "widgets",
+ credentialName: "atlassian-main",
+ status: "pending",
+ wasReused: false,
+ });
+ expect(first.repositoryId).toBeUndefined();
+ expect(first.id).not.toBe(second.id);
+ });
+
+ it("reads the workspace from the selection's owner and the slug from its name", () => {
+ const draft = createBitbucketDraftFromDiscovery(baseSelection, "atlassian-main");
+
+ expect(draft).toMatchObject({ workspace: "acme", slug: "widgets" });
+ expect(draft.repositoryId).toBeUndefined();
+ });
+
+ it("keeps the repository id for a linkable repository only", () => {
+ expect(
+ createBitbucketDraftFromDiscovery(
+ { ...baseSelection, linkState: "linkable", repositoryId: "bb-9" },
+ "cred",
+ ).repositoryId,
+ ).toBe("bb-9");
+ expect(
+ createBitbucketDraftFromDiscovery(
+ { ...baseSelection, linkState: "new", repositoryId: "bb-9" },
+ "cred",
+ ).repositoryId,
+ ).toBeUndefined();
+ });
+});
+
describe("addDraftSource", () => {
it("appends a new repository", () => {
const sources = addDraftSource([], createDraftSource("acme", "widgets", "pat"));
@@ -443,6 +529,54 @@ describe("connectDraftSources", () => {
expect(result[0].ownerAssignmentFailed).toBe(true);
});
+ it("connects a new Bitbucket repository through the Bitbucket connector", async () => {
+ const result = await connectDraftSources("p1", [
+ createBitbucketDraft("acme", "widgets", "atlassian-main"),
+ ]);
+
+ expect(connectBitbucketRepositoryMock).toHaveBeenCalledWith({
+ workspace: "acme",
+ slug: "widgets",
+ credentialName: "atlassian-main",
+ projectId: "p1",
+ });
+ expect(addBitbucketRepositoryToProjectMock).not.toHaveBeenCalled();
+ expect(result[0]).toMatchObject({ status: "connected", wasReused: false });
+ });
+
+ it("links an already-ingested Bitbucket repository instead of re-ingesting it", async () => {
+ const result = await connectDraftSources("p1", [
+ createBitbucketDraft("acme", "linked", "cred", "bb-42"),
+ ]);
+
+ expect(addBitbucketRepositoryToProjectMock).toHaveBeenCalledWith("bb-42", "p1");
+ expect(connectBitbucketRepositoryMock).not.toHaveBeenCalled();
+ expect(result[0]).toMatchObject({ status: "connected", wasReused: true });
+ });
+
+ it("says what to check when the backend answers a bare 500 for a Bitbucket repository", async () => {
+ connectBitbucketRepositoryMock.mockRejectedValue(new ApiError(500, "Internal Server Error"));
+
+ const result = await connectDraftSources("p1", [
+ createBitbucketDraft("acme", "missing", "cred"),
+ ]);
+
+ expect(result[0].status).toBe("failed");
+ expect(result[0].errorMessage).toMatch(/acme\/missing/);
+ expect(result[0].errorMessage).toMatch(/exists/);
+ expect(result[0].errorMessage).not.toMatch(/Internal Server Error/);
+ });
+
+ it("keeps the backend's own message for a Bitbucket failure that is not a 500", async () => {
+ connectBitbucketRepositoryMock.mockRejectedValue(
+ new ApiError(404, "Atlassian credential 'gone' was not found"),
+ );
+
+ const result = await connectDraftSources("p1", [createBitbucketDraft("acme", "w", "gone")]);
+
+ expect(result[0].errorMessage).toBe("Atlassian credential 'gone' was not found");
+ });
+
it("dispatches each source type in one mixed batch", async () => {
const result = await connectDraftSources("p1", [
createDraftSource("acme", "widgets", "pat"),
@@ -455,6 +589,17 @@ describe("connectDraftSources", () => {
expect(uploadDocumentsMock).toHaveBeenCalledTimes(1);
expect(result.every((source) => source.status === "connected")).toBe(true);
});
+
+ it("connects a Bitbucket repository alongside a GitHub one in a mixed batch", async () => {
+ const result = await connectDraftSources("p1", [
+ createDraftSource("acme", "widgets", "pat"),
+ createBitbucketDraft("acme", "widgets", "cred"),
+ ]);
+
+ expect(connectGithubRepositoryMock).toHaveBeenCalledTimes(1);
+ expect(connectBitbucketRepositoryMock).toHaveBeenCalledTimes(1);
+ expect(result.every((source) => source.status === "connected")).toBe(true);
+ });
});
describe("connectOutcome / connectOutcomeDescription", () => {
diff --git a/tests/unit/features/chatbot/useAvailableSources.test.tsx b/tests/unit/features/chatbot/useAvailableSources.test.tsx
index 77e55a63c..271a6e010 100644
--- a/tests/unit/features/chatbot/useAvailableSources.test.tsx
+++ b/tests/unit/features/chatbot/useAvailableSources.test.tsx
@@ -36,6 +36,15 @@ describe("useAvailableSources", () => {
expect([...result.current.sources].sort()).toEqual(["CONFLUENCE", "GITHUB", "JIRA", "UPLOAD"]);
});
+ it("offers Bitbucket once its connector is enabled", async () => {
+ mockListConnectors.mockResolvedValue([connector("github"), connector("bitbucket")]);
+
+ const { result } = renderHook(() => useAvailableSources());
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect([...result.current.sources].sort()).toEqual(["BITBUCKET", "GITHUB", "UPLOAD"]);
+ });
+
it("leaves out a disabled connector and any id the chat has no filter for", async () => {
mockListConnectors.mockResolvedValue([
connector("github"),
diff --git a/tests/unit/features/connectors/connectorMeta.test.ts b/tests/unit/features/connectors/connectorMeta.test.ts
new file mode 100644
index 000000000..89514925b
--- /dev/null
+++ b/tests/unit/features/connectors/connectorMeta.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { BitbucketIcon } from "../../../../src/components/icons/BitbucketIcon";
+import { getConnectorMeta, toConnectorListItems } from "../../../../src/features/connectors/data";
+
+const connector = (id: string, name = `${id} connector`) => ({
+ id,
+ name,
+ enabled: true,
+ firstConfiguredAt: null,
+ lastConfiguredAt: null,
+});
+
+describe("getConnectorMeta", () => {
+ it("knows the Bitbucket connector and gives it the Bitbucket logo", () => {
+ const meta = getConnectorMeta(connector("bitbucket"));
+
+ expect(meta.label).toBe("Bitbucket Repository Connector");
+ expect(meta.description).toMatch(/Bitbucket Cloud/);
+ expect(meta.icon).toBe(BitbucketIcon);
+ });
+
+ it("still knows GitHub and Confluence", () => {
+ expect(getConnectorMeta(connector("github")).label).toBe("GitHub Repository Connector");
+ expect(getConnectorMeta(connector("confluence")).label).toBe("Confluence Cloud Connector");
+ });
+
+ it("falls back to the backend name for an unknown connector", () => {
+ expect(getConnectorMeta(connector("sonarqube", "SonarQube")).label).toBe("SonarQube");
+ });
+});
+
+describe("toConnectorListItems", () => {
+ it("attaches the resolved meta to each connector", () => {
+ const [item] = toConnectorListItems([connector("bitbucket")]);
+
+ expect(item.id).toBe("bitbucket");
+ expect(item.meta.label).toBe("Bitbucket Repository Connector");
+ });
+});
diff --git a/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx b/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx
index f9701efd5..388de664e 100644
--- a/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx
+++ b/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx
@@ -489,3 +489,316 @@ describe("AddSourceModal", () => {
expect(screen.getByRole("button", { name: /connect now/i })).toBeDisabled();
});
});
+
+describe("AddSourceModal Bitbucket", () => {
+ const WORKSPACE_LABEL = "Workspace or bitbucket.org URL";
+
+ const bitbucketDiscoveryBody = {
+ repositories: [
+ {
+ workspace: "acme",
+ slug: "widgets",
+ name: "Widgets",
+ isPrivate: true,
+ url: "https://bitbucket.org/acme/widgets",
+ alreadyConnected: false,
+ enabled: null,
+ },
+ {
+ workspace: "acme",
+ slug: "gadgets",
+ name: "gadgets",
+ isPrivate: false,
+ url: "https://bitbucket.org/acme/gadgets",
+ alreadyConnected: true,
+ enabled: true,
+ },
+ ],
+ };
+
+ const bitbucketDiscoveryHandler = http.get(
+ "/api/v1/bitbucket/discover/workspace/:workspace",
+ () => HttpResponse.json(bitbucketDiscoveryBody),
+ );
+
+ /** Per-repo status rows; `gadgets` is ingested elsewhere (and optionally in this project). */
+ function bitbucketStatusHandler({ inThisProject = false } = {}) {
+ const row = {
+ sourceSystem: "BITBUCKET",
+ sourceId: "acme/gadgets",
+ displayName: "acme/gadgets",
+ repositoryId: "bb-gadgets-id",
+ owner: "acme",
+ name: "gadgets",
+ sourceUrl: "https://bitbucket.org/acme/gadgets",
+ connectionStatus: "CONNECTED",
+ enabled: true,
+ artifactCount: 3,
+ };
+
+ return http.get("/api/v1/ingestion-sources/status", ({ request }) => {
+ const projectId = new URL(request.url).searchParams.get("projectId");
+
+ return HttpResponse.json(projectId && !inThisProject ? [] : [row]);
+ });
+ }
+
+ async function openBitbucketDetail(user: ReturnType) {
+ await user.click(screen.getByRole("button", { name: /bitbucket/i }));
+ await waitFor(() =>
+ expect(screen.getByLabelText("Credential")).toHaveTextContent("default - me@corp.com"),
+ );
+ }
+
+ async function discoverAcme(user: ReturnType) {
+ await user.type(screen.getByLabelText(WORKSPACE_LABEL), "acme");
+ await user.click(screen.getByRole("button", { name: "Discover" }));
+ await screen.findByText("Widgets");
+ }
+
+ const rowOf = (text: string) => screen.getByText(text).closest("label") as HTMLElement;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ server.use(
+ http.get("/api/v1/github/pat", () => HttpResponse.json(["default"])),
+ atlassianCredentialsHandler(["default"]),
+ );
+ });
+
+ it("offers Bitbucket as a connectable type instead of a Soon card", () => {
+ renderModal();
+
+ const card = screen.getByRole("button", { name: /bitbucket/i });
+ expect(card).not.toHaveTextContent(/soon/i);
+ });
+
+ it("opens the Bitbucket detail with a credential picker, the workspace field and the scope hint", async () => {
+ const user = userEvent.setup();
+ renderModal();
+
+ await openBitbucketDetail(user);
+
+ expect(screen.getByLabelText(WORKSPACE_LABEL)).toBeInTheDocument();
+ expect(screen.getByText(/needs Bitbucket scopes/i)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /add atlassian credential/i })).toBeInTheDocument();
+ // The GitHub screen is not what renders.
+ expect(screen.queryByLabelText("Organization, user, or URL")).not.toBeInTheDocument();
+ });
+
+ it("discovers a workspace with the selected credential and shows slug and visibility", async () => {
+ let discoverRequest: { workspace?: string; query: Record } | null = null;
+ server.use(
+ http.get("/api/v1/bitbucket/discover/workspace/:workspace", ({ params, request }) => {
+ discoverRequest = {
+ workspace: String(params.workspace),
+ query: Object.fromEntries(new URL(request.url).searchParams),
+ };
+ return HttpResponse.json(bitbucketDiscoveryBody);
+ }),
+ bitbucketStatusHandler(),
+ );
+ const user = userEvent.setup();
+ renderModal();
+ await openBitbucketDetail(user);
+
+ await discoverAcme(user);
+
+ expect(discoverRequest).toEqual({
+ workspace: "acme",
+ query: { credentialName: "default", page: "0", pageSize: "20" },
+ });
+ expect(within(rowOf("Widgets")).getByText("Private")).toBeInTheDocument();
+ expect(within(rowOf("gadgets")).getByText("Public")).toBeInTheDocument();
+ });
+
+ it("shows a missing-credential notice once the list has loaded empty", async () => {
+ server.use(atlassianCredentialsHandler([]));
+ const user = userEvent.setup();
+ renderModal();
+
+ await user.click(screen.getByRole("button", { name: /bitbucket/i }));
+
+ expect(await screen.findByText("No credential yet")).toBeInTheDocument();
+ expect(screen.getByLabelText(WORKSPACE_LABEL)).toBeDisabled();
+ });
+
+ it("keeps 'Add to list' disabled until a repository is selected", async () => {
+ server.use(bitbucketDiscoveryHandler, bitbucketStatusHandler());
+ const user = userEvent.setup();
+ renderModal();
+ await openBitbucketDetail(user);
+
+ expect(screen.getByRole("button", { name: /add to list/i })).toBeDisabled();
+
+ await discoverAcme(user);
+ await user.click(within(rowOf("Widgets")).getByRole("checkbox"));
+
+ expect(screen.getByRole("button", { name: /add to list/i })).toBeEnabled();
+ });
+
+ it("stages the selection and connects each repository with the credential and project", async () => {
+ const bodies: unknown[] = [];
+ server.use(
+ bitbucketDiscoveryHandler,
+ bitbucketStatusHandler(),
+ http.post("/api/v1/bitbucket", async ({ request }) => {
+ bodies.push(await request.json());
+ return HttpResponse.json({ transactionId: "tx" }, { status: 202 });
+ }),
+ );
+ const user = userEvent.setup();
+ const props = renderModal();
+ await openBitbucketDetail(user);
+ await discoverAcme(user);
+
+ await user.click(within(rowOf("Widgets")).getByRole("checkbox"));
+ await user.click(screen.getByRole("button", { name: /add to list/i }));
+
+ // Back on the staged list, titled workspace/slug with the credential as detail.
+ expect(await screen.findByText("acme/widgets")).toBeInTheDocument();
+ expect(screen.getByText(/Not connected yet · default/)).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: /connect 1 source/i }));
+
+ await waitFor(() => expect(props.onConnected).toHaveBeenCalledTimes(1));
+ expect(props.onClose).toHaveBeenCalled();
+ expect(bodies).toEqual([
+ { workspace: "acme", slug: "widgets", credentialName: "default", projectId: "project-1" },
+ ]);
+ });
+
+ it("links an already-ingested repository instead of re-ingesting it, with no reuse text from the backend", async () => {
+ let linkedPath: string | null = null;
+ let connectCalled = false;
+ server.use(
+ bitbucketDiscoveryHandler,
+ bitbucketStatusHandler(),
+ http.post(
+ "/api/v1/bitbucket/connections/:repositoryId/projects/:projectId",
+ ({ request }) => {
+ linkedPath = new URL(request.url).pathname;
+ return HttpResponse.json({ repositoryId: "bb-gadgets-id", projectIds: ["project-1"] });
+ },
+ ),
+ http.post("/api/v1/bitbucket", () => {
+ connectCalled = true;
+ return HttpResponse.json({ transactionId: "tx" }, { status: 202 });
+ }),
+ );
+ const user = userEvent.setup();
+ const props = renderModal();
+ await openBitbucketDetail(user);
+ await discoverAcme(user);
+
+ const gadgetsRow = rowOf("gadgets");
+ await waitFor(() =>
+ expect(within(gadgetsRow).getByText("Already ingested")).toBeInTheDocument(),
+ );
+ await user.click(within(gadgetsRow).getByRole("checkbox"));
+ await user.click(screen.getByRole("button", { name: /add to list/i }));
+
+ expect(await screen.findByText(/Already ingested, will be linked/)).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: /connect 1 source/i }));
+
+ await waitFor(() => expect(props.onConnected).toHaveBeenCalledTimes(1));
+ expect(linkedPath).toBe("/api/v1/bitbucket/connections/bb-gadgets-id/projects/project-1");
+ expect(connectCalled).toBe(false);
+ expect(await screen.findByText(/already available, nothing re-ingested/i)).toBeInTheDocument();
+ });
+
+ it("keeps a repository that is already in this project unselectable", async () => {
+ server.use(bitbucketDiscoveryHandler, bitbucketStatusHandler({ inThisProject: true }));
+ const user = userEvent.setup();
+ renderModal();
+ await openBitbucketDetail(user);
+ await discoverAcme(user);
+
+ const gadgetsRow = rowOf("gadgets");
+ await waitFor(() =>
+ expect(within(gadgetsRow).getByText("In this project")).toBeInTheDocument(),
+ );
+ expect(within(gadgetsRow).getByRole("checkbox")).toBeDisabled();
+ });
+
+ it("does not treat a GitHub repository with the same owner/name as ingested", async () => {
+ server.use(
+ bitbucketDiscoveryHandler,
+ http.get("/api/v1/ingestion-sources/status", () =>
+ HttpResponse.json([
+ {
+ sourceSystem: "GITHUB",
+ sourceId: "acme/gadgets",
+ displayName: "acme/gadgets",
+ repositoryId: "gh-gadgets-id",
+ owner: "acme",
+ name: "gadgets",
+ sourceUrl: "https://github.com/acme/gadgets",
+ connectionStatus: "CONNECTED",
+ enabled: true,
+ artifactCount: 3,
+ },
+ ]),
+ ),
+ );
+ const user = userEvent.setup();
+ renderModal();
+ await openBitbucketDetail(user);
+ await discoverAcme(user);
+
+ const gadgetsRow = rowOf("gadgets");
+ // Ingested as a Bitbucket repo elsewhere, but the only row that matches is GitHub's.
+ await waitFor(() => expect(within(gadgetsRow).getByText("Connected")).toBeInTheDocument());
+ expect(within(gadgetsRow).queryByText("Already ingested")).not.toBeInTheDocument();
+ expect(within(gadgetsRow).getByRole("checkbox")).toBeDisabled();
+ });
+
+ it("explains a failed connect of an unreachable repository instead of showing the status text", async () => {
+ server.use(
+ bitbucketDiscoveryHandler,
+ bitbucketStatusHandler(),
+ http.post("/api/v1/bitbucket", () =>
+ HttpResponse.json({ message: "Internal Server Error" }, { status: 500 }),
+ ),
+ );
+ const user = userEvent.setup();
+ renderModal();
+ await openBitbucketDetail(user);
+ await discoverAcme(user);
+
+ await user.click(within(rowOf("Widgets")).getByRole("checkbox"));
+ await user.click(screen.getByRole("button", { name: /connect now/i }));
+
+ expect(await screen.findByRole("button", { name: /retry/i })).toBeInTheDocument();
+ expect(screen.getByText(/Couldn't connect acme\/widgets/)).toBeInTheDocument();
+ expect(screen.queryByText(/Internal Server Error/)).not.toBeInTheDocument();
+ });
+
+ it("reports a missing credential on discovery in the adapter's words", async () => {
+ server.use(
+ http.get("/api/v1/bitbucket/discover/workspace/:workspace", () =>
+ HttpResponse.json({ message: "Credential not found" }, { status: 404 }),
+ ),
+ );
+ const user = userEvent.setup();
+ renderModal();
+ await openBitbucketDetail(user);
+
+ await user.type(screen.getByLabelText(WORKSPACE_LABEL), "acme");
+ await user.click(screen.getByRole("button", { name: "Discover" }));
+
+ expect(await screen.findByText(/selected credential was not found/i)).toBeInTheDocument();
+ });
+
+ it("can go back from the Bitbucket detail to the type grid", async () => {
+ const user = userEvent.setup();
+ renderModal();
+ await openBitbucketDetail(user);
+
+ await user.click(screen.getByRole("button", { name: /back to source types/i }));
+
+ expect(screen.getByRole("button", { name: /jira/i })).toBeInTheDocument();
+ expect(screen.queryByLabelText(WORKSPACE_LABEL)).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/features/data-ingestion/components/RepositoryDiscovery.test.tsx b/tests/unit/features/data-ingestion/components/RepositoryDiscovery.test.tsx
new file mode 100644
index 000000000..4168c0303
--- /dev/null
+++ b/tests/unit/features/data-ingestion/components/RepositoryDiscovery.test.tsx
@@ -0,0 +1,420 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { http, HttpResponse } from "msw";
+import {
+ RepositoryDiscovery,
+ type DiscoveredRepositoryItem,
+ type DiscoverySelection,
+ type RepositoryDiscoveryAdapter,
+} from "../../../../../src/features/data-ingestion/components/RepositoryDiscovery";
+import { server } from "../../../setup/vitest.setup";
+
+function repo(
+ name: string,
+ overrides: Partial = {},
+): DiscoveredRepositoryItem {
+ return {
+ name,
+ isPrivate: false,
+ url: `https://example.test/acme/${name}`,
+ alreadyConnected: false,
+ isEnabled: null,
+ ...overrides,
+ };
+}
+
+function makeAdapter(
+ overrides: Partial = {},
+): RepositoryDiscoveryAdapter {
+ return {
+ sourceSystem: "BITBUCKET",
+ providerName: "Bitbucket",
+ discover: vi.fn().mockResolvedValue({ repositories: [], hasMore: false }),
+ parseInput: (value) => {
+ const [owner, name] = value.trim().split("/");
+ return owner ? { owner, name: name ?? null } : null;
+ },
+ ownerLabel: "Workspace",
+ ownerPlaceholder: "acme",
+ invalidInputMessage: "Enter a workspace.",
+ credentialRequiredMessage: "Choose a credential.",
+ missingCredentialNotice: "Add a credential first.",
+ emptyResultHint: "Nothing here.",
+ ...overrides,
+ };
+}
+
+const statusRow = (sourceSystem: string, fullName: string, repositoryId: string) => ({
+ sourceSystem,
+ sourceId: fullName,
+ repositoryId,
+ owner: fullName.split("/")[0],
+ name: fullName.split("/")[1],
+ sourceUrl: `https://example.test/${fullName}`,
+ connectionStatus: "CONNECTED",
+ enabled: true,
+ artifactCount: 1,
+});
+
+function renderDiscovery(
+ adapter: RepositoryDiscoveryAdapter,
+ props: Partial[0]> = {},
+) {
+ const onSelectionChange = vi.fn<(selection: DiscoverySelection[]) => void>();
+
+ const tree = (overrides: Partial[0]> = {}) => (
+ (
+
+ )}
+ projectId="project-1"
+ onSelectionChange={onSelectionChange}
+ {...props}
+ {...overrides}
+ />
+ );
+ const view = render(tree());
+
+ return {
+ onSelectionChange,
+ rerenderWith: (overrides: Partial[0]>) =>
+ view.rerender(tree(overrides)),
+ };
+}
+
+async function discover(user: ReturnType, value = "acme") {
+ await user.type(screen.getByLabelText(/workspace/i), value);
+ await user.click(screen.getByRole("button", { name: /discover/i }));
+}
+
+function lastSelection(onSelectionChange: ReturnType): DiscoverySelection[] {
+ const calls = onSelectionChange.mock.calls;
+ return calls[calls.length - 1][0] as DiscoverySelection[];
+}
+
+describe("RepositoryDiscovery", () => {
+ it("shows the adapter's labels and the credential picker slot", () => {
+ renderDiscovery(makeAdapter());
+
+ expect(screen.getByLabelText("Workspace")).toHaveAttribute("placeholder", "acme");
+ expect(screen.getByRole("button", { name: "picker" })).toBeEnabled();
+ });
+
+ it("shows the missing-credential notice and locks the form without credentials", () => {
+ renderDiscovery(makeAdapter(), { hasCredentials: false });
+
+ expect(screen.getByText("Add a credential first.")).toBeInTheDocument();
+ expect(screen.getByLabelText("Workspace")).toBeDisabled();
+ expect(screen.getByRole("button", { name: "picker" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /discover/i })).toBeDisabled();
+ });
+
+ it("hides the notice when the parent shows its own", () => {
+ renderDiscovery(makeAdapter(), {
+ hasCredentials: false,
+ suppressMissingCredentialNotice: true,
+ });
+
+ expect(screen.queryByText("Add a credential first.")).not.toBeInTheDocument();
+ });
+
+ it("reports the adapter's message when the input has no owner", async () => {
+ const adapter = makeAdapter({ parseInput: () => null });
+ renderDiscovery(adapter);
+ const user = userEvent.setup();
+
+ await user.type(screen.getByLabelText("Workspace"), "x");
+ await user.click(screen.getByRole("button", { name: /discover/i }));
+
+ expect(await screen.findByText("Enter a workspace.")).toBeInTheDocument();
+ expect(adapter.discover).not.toHaveBeenCalled();
+ });
+
+ it("asks for a credential before discovering", async () => {
+ const adapter = makeAdapter();
+ renderDiscovery(adapter, { credentialName: " " });
+
+ await discover(userEvent.setup());
+
+ expect(await screen.findByText("Choose a credential.")).toBeInTheDocument();
+ expect(adapter.discover).not.toHaveBeenCalled();
+ });
+
+ it("discovers with the parsed owner, credential and a 0-based first page", async () => {
+ const adapter = makeAdapter({
+ discover: vi.fn().mockResolvedValue({ repositories: [repo("widgets")], hasMore: false }),
+ });
+ renderDiscovery(adapter);
+
+ await discover(userEvent.setup(), "acme");
+
+ expect(await screen.findByText("widgets")).toBeInTheDocument();
+ expect(adapter.discover).toHaveBeenCalledWith("acme", "cred", 0, 20);
+ });
+
+ it("isolates the named repository when the input carries one", async () => {
+ const adapter = makeAdapter({
+ discover: vi
+ .fn()
+ .mockResolvedValue({ repositories: [repo("widgets"), repo("gadgets")], hasMore: false }),
+ });
+ renderDiscovery(adapter);
+
+ await discover(userEvent.setup(), "acme/widgets");
+
+ expect(await screen.findByText("widgets")).toBeInTheDocument();
+ expect(screen.queryByText("gadgets")).not.toBeInTheDocument();
+ expect(screen.getByLabelText("Filter repositories")).toHaveValue("widgets");
+ });
+
+ it("loads the next page and appends it when there may be more", async () => {
+ const discoverPage = vi
+ .fn()
+ .mockResolvedValueOnce({ repositories: [repo("a")], hasMore: true })
+ .mockResolvedValueOnce({ repositories: [repo("b")], hasMore: false });
+ renderDiscovery(makeAdapter({ discover: discoverPage }));
+ const user = userEvent.setup();
+
+ await discover(user);
+ await user.click(await screen.findByRole("button", { name: /load more/i }));
+
+ expect(await screen.findByText("b")).toBeInTheDocument();
+ expect(screen.getByText("a")).toBeInTheDocument();
+ expect(discoverPage).toHaveBeenLastCalledWith("acme", "cred", 1, 20);
+ expect(screen.queryByRole("button", { name: /load more/i })).not.toBeInTheDocument();
+ });
+
+ it("keeps paging the discovery that produced the results when the owner field is edited", async () => {
+ const discoverPage = vi
+ .fn()
+ .mockResolvedValueOnce({ repositories: [repo("widgets")], hasMore: true })
+ .mockResolvedValueOnce({ repositories: [repo("gadgets")], hasMore: false });
+ const { onSelectionChange } = renderDiscovery(makeAdapter({ discover: discoverPage }));
+ const user = userEvent.setup();
+
+ await discover(user, "alpha");
+ await user.click(await screen.findByRole("checkbox"));
+ await user.clear(screen.getByLabelText("Workspace"));
+ await user.type(screen.getByLabelText("Workspace"), "beta");
+ await user.click(screen.getByRole("button", { name: /load more/i }));
+
+ expect(await screen.findByText("gadgets")).toBeInTheDocument();
+ expect(discoverPage).toHaveBeenLastCalledWith("alpha", "cred", 1, 20);
+ await waitFor(() => {
+ expect(lastSelection(onSelectionChange)).toEqual([
+ expect.objectContaining({ owner: "alpha", name: "widgets" }),
+ ]);
+ });
+ });
+
+ it("drops the results and the selection when the credential changes", async () => {
+ const adapter = makeAdapter({
+ discover: vi.fn().mockResolvedValue({ repositories: [repo("widgets")], hasMore: true }),
+ });
+ const { onSelectionChange, rerenderWith } = renderDiscovery(adapter);
+ const user = userEvent.setup();
+
+ await discover(user);
+ await user.click(await screen.findByRole("checkbox"));
+
+ rerenderWith({ credentialName: "other" });
+
+ await waitFor(() => {
+ expect(screen.queryByText("widgets")).not.toBeInTheDocument();
+ });
+ expect(screen.queryByRole("button", { name: /load more/i })).not.toBeInTheDocument();
+ expect(lastSelection(onSelectionChange)).toEqual([]);
+ });
+
+ it("locks the form and the pager while a page is loading", async () => {
+ let resolvePage: (page: {
+ repositories: DiscoveredRepositoryItem[];
+ hasMore: boolean;
+ }) => void = () => undefined;
+ const discoverPage = vi
+ .fn()
+ .mockResolvedValueOnce({ repositories: [repo("a")], hasMore: true })
+ .mockReturnValueOnce(new Promise((resolve) => (resolvePage = resolve)));
+ renderDiscovery(makeAdapter({ discover: discoverPage }));
+ const user = userEvent.setup();
+
+ await discover(user);
+ await user.click(await screen.findByRole("button", { name: /load more/i }));
+
+ expect(screen.getByLabelText("Workspace")).toBeDisabled();
+ expect(screen.getByRole("button", { name: /discover/i })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /load more/i })).toBeDisabled();
+
+ resolvePage({ repositories: [repo("b")], hasMore: false });
+ expect(await screen.findByText("b")).toBeInTheDocument();
+ expect(screen.getByLabelText("Workspace")).toBeEnabled();
+ });
+
+ it("shows the label instead of the name and filters by either", async () => {
+ const adapter = makeAdapter({
+ discover: vi.fn().mockResolvedValue({
+ repositories: [
+ repo("widgets-repo", { label: "Widgets" }),
+ repo("gadgets-repo", { label: "Gadgets" }),
+ ],
+ hasMore: false,
+ }),
+ });
+ renderDiscovery(adapter);
+ const user = userEvent.setup();
+
+ await discover(user);
+ expect(await screen.findByText("Widgets")).toBeInTheDocument();
+ expect(screen.queryByText("widgets-repo")).not.toBeInTheDocument();
+
+ await user.type(screen.getByLabelText("Filter repositories"), "gadgets-r");
+ expect(screen.queryByText("Widgets")).not.toBeInTheDocument();
+ expect(screen.getByText("Gadgets")).toBeInTheDocument();
+ });
+
+ it("omits the external link when a repository has no url", async () => {
+ const adapter = makeAdapter({
+ discover: vi.fn().mockResolvedValue({
+ repositories: [repo("linked"), repo("unlinked", { url: null })],
+ hasMore: false,
+ }),
+ });
+ renderDiscovery(adapter);
+
+ await discover(userEvent.setup());
+
+ await screen.findByText("unlinked");
+ expect(screen.getByRole("link", { name: "Open linked on Bitbucket" })).toBeInTheDocument();
+ expect(screen.queryByRole("link", { name: /open unlinked/i })).not.toBeInTheDocument();
+ });
+
+ it("uses the adapter's error wording and falls back to the error message", async () => {
+ const describeError = vi
+ .fn<(error: unknown, owner: string) => string | null>()
+ .mockReturnValueOnce("No workspace acme.")
+ .mockReturnValueOnce(null);
+ const adapter = makeAdapter({
+ discover: vi.fn().mockRejectedValue(new Error("boom")),
+ describeError,
+ });
+ renderDiscovery(adapter);
+ const user = userEvent.setup();
+
+ await user.type(screen.getByLabelText("Workspace"), "acme");
+ await user.click(screen.getByRole("button", { name: /discover/i }));
+ expect(await screen.findByText("No workspace acme.")).toBeInTheDocument();
+ expect(describeError).toHaveBeenCalledWith(expect.any(Error), "acme");
+
+ await user.click(screen.getByRole("button", { name: /discover/i }));
+ expect(await screen.findByText("boom")).toBeInTheDocument();
+ });
+
+ it("shows the adapter's empty-state hint when nothing is found", async () => {
+ renderDiscovery(makeAdapter());
+
+ await discover(userEvent.setup());
+
+ expect(await screen.findByText("No repositories found")).toBeInTheDocument();
+ expect(screen.getByText("Nothing here.")).toBeInTheDocument();
+ });
+
+ describe("link state", () => {
+ const discoverAlreadyConnected = () =>
+ vi.fn().mockResolvedValue({
+ repositories: [repo("widgets", { alreadyConnected: true, isEnabled: true })],
+ hasMore: false,
+ });
+
+ it("makes a repository ingested elsewhere linkable and hands its id to the selection", async () => {
+ server.use(
+ http.get("/api/v1/ingestion-sources/status", ({ request }) =>
+ HttpResponse.json(
+ new URL(request.url).searchParams.get("projectId")
+ ? []
+ : [statusRow("BITBUCKET", "acme/widgets", "bb-1")],
+ ),
+ ),
+ );
+ const { onSelectionChange } = renderDiscovery(
+ makeAdapter({ discover: discoverAlreadyConnected() }),
+ );
+ const user = userEvent.setup();
+
+ await discover(user);
+ expect(await screen.findByText("Already ingested")).toBeInTheDocument();
+ await user.click(screen.getByRole("checkbox"));
+
+ await waitFor(() => {
+ expect(lastSelection(onSelectionChange)).toEqual([
+ {
+ owner: "acme",
+ name: "widgets",
+ isPrivate: false,
+ linkState: "linkable",
+ repositoryId: "bb-1",
+ },
+ ]);
+ });
+ });
+
+ it("ignores ingestion rows of another source system with the same owner/name", async () => {
+ // A GitHub repository acme/widgets must not make the Bitbucket acme/widgets linkable.
+ server.use(
+ // Answers both the global and the project-scoped request.
+ http.get("/api/v1/ingestion-sources/status", () =>
+ HttpResponse.json([statusRow("GITHUB", "acme/widgets", "gh-1")]),
+ ),
+ );
+ renderDiscovery(makeAdapter({ discover: discoverAlreadyConnected() }));
+
+ await discover(userEvent.setup());
+
+ // Ingested elsewhere, but no Bitbucket row resolves it, and the GitHub row
+ // does not count as "in this project" either.
+ expect(await screen.findByText("Connected")).toBeInTheDocument();
+ expect(screen.queryByText("Already ingested")).not.toBeInTheDocument();
+ expect(screen.queryByText("In this project")).not.toBeInTheDocument();
+ expect(screen.getByRole("checkbox")).toBeDisabled();
+ });
+
+ it("marks a repository already in this project and keeps it unselectable", async () => {
+ server.use(
+ http.get("/api/v1/ingestion-sources/status", () =>
+ HttpResponse.json([statusRow("BITBUCKET", "acme/widgets", "bb-1")]),
+ ),
+ );
+ renderDiscovery(makeAdapter({ discover: discoverAlreadyConnected() }));
+
+ await discover(userEvent.setup());
+
+ expect(await screen.findByText("In this project")).toBeInTheDocument();
+ expect(screen.getByRole("checkbox")).toBeDisabled();
+ });
+
+ it("treats a row without a system label as GitHub", async () => {
+ server.use(
+ http.get("/api/v1/ingestion-sources/status", () =>
+ HttpResponse.json([
+ { sourceId: "acme/widgets", repositoryId: "gh-1", connectionStatus: "CONNECTED" },
+ ]),
+ ),
+ );
+ const github = makeAdapter({
+ sourceSystem: "GITHUB",
+ providerName: "GitHub",
+ discover: discoverAlreadyConnected(),
+ });
+ renderDiscovery(github, { projectId: null });
+
+ await discover(userEvent.setup());
+
+ expect(await screen.findByText("Already ingested")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/tests/unit/features/data-ingestion/components/RunDetailsPanel.test.tsx b/tests/unit/features/data-ingestion/components/RunDetailsPanel.test.tsx
index 43dfd9a31..246b83d0b 100644
--- a/tests/unit/features/data-ingestion/components/RunDetailsPanel.test.tsx
+++ b/tests/unit/features/data-ingestion/components/RunDetailsPanel.test.tsx
@@ -91,6 +91,38 @@ describe("RunDetailsPanel", () => {
expect(screen.getByText("acme.atlassian.net")).toBeInTheDocument();
});
+ it("shows the Bitbucket workspace in the timing section", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Fetched from Bitbucket")).toBeInTheDocument();
+ expect(screen.getByText("Workspace")).toBeInTheDocument();
+ expect(screen.getByText("acme")).toBeInTheDocument();
+ });
+
+ it("falls back to the workspace/slug prefix when a Bitbucket run has no owner", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Workspace")).toBeInTheDocument();
+ expect(screen.getByText("acme")).toBeInTheDocument();
+ });
+
it("lists failed items", () => {
render();
diff --git a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
index e3037e3ec..dca3754fc 100644
--- a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
+++ b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
@@ -88,6 +88,25 @@ const confluenceSource: DataSource = {
},
};
+const bitbucketRepository = {
+ repositoryId: "bb-repo-1",
+ workspace: "acme",
+ slug: "widgets",
+ fullName: "acme/widgets",
+ url: "https://bitbucket.org/acme/widgets",
+ enabled: true,
+};
+
+const bitbucketSource: DataSource = {
+ ...mockSource,
+ sourceId: "bb-repo-1",
+ sourceSystem: "BITBUCKET",
+ name: "acme/widgets",
+ type: "Bitbucket",
+ githubRepository: null,
+ bitbucketRepository,
+};
+
describe("SourceDetailsPanel", () => {
beforeEach(() => {
window.localStorage.clear();
@@ -448,6 +467,213 @@ describe("SourceDetailsPanel", () => {
expect(screen.queryByRole("button", { name: /Remove from project/ })).not.toBeInTheDocument();
});
+ it("renders the Bitbucket repository section with workspace, slug and link", () => {
+ render();
+
+ expect(screen.getByText("Repository")).toBeInTheDocument();
+ expect(screen.getByText("Workspace")).toBeInTheDocument();
+ expect(screen.getByText("Slug")).toBeInTheDocument();
+ expect(screen.getByText("widgets")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /bitbucket\.org\/acme\/widgets/ })).toHaveAttribute(
+ "href",
+ "https://bitbucket.org/acme/widgets",
+ );
+ expect(screen.getByText("bb-repo-1")).toBeInTheDocument();
+ // The GitHub-only "Owner" row does not appear.
+ expect(screen.queryByText("Owner")).not.toBeInTheDocument();
+ });
+
+ it('updates a Bitbucket repository via the "Update repo" button', async () => {
+ const user = userEvent.setup();
+ const onUpdateSource = vi.fn().mockResolvedValue(undefined);
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /Update repo/ }));
+
+ expect(onUpdateSource).toHaveBeenCalledWith(bitbucketSource);
+ });
+
+ it("disables the Bitbucket update when the connection id is unknown", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: /Update repo/ })).toBeDisabled();
+ });
+
+ it("toggles a Bitbucket repository's ingestion via onSetBitbucketSourceEnabled", async () => {
+ const user = userEvent.setup();
+ const onSetBitbucketSourceEnabled = vi.fn().mockResolvedValue(undefined);
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("switch", { name: /Toggle ingestion for acme\/widgets/ }));
+
+ expect(onSetBitbucketSourceEnabled).toHaveBeenCalledWith(bitbucketRepository, false);
+ });
+
+ it("shows a read-only source state without the toggle handler", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument();
+ expect(screen.getByText("Disabled")).toBeInTheDocument();
+ });
+
+ it("renders the Bitbucket sync schedule and loads it for the repository", async () => {
+ const onLoadBitbucketConfig = vi.fn().mockResolvedValue({
+ autoUpdate: true,
+ spec: { type: "INTERVAL", everyMinutes: 30 },
+ nextSyncAt: null,
+ });
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Sync Schedule")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(onLoadBitbucketConfig).toHaveBeenCalledWith(bitbucketRepository);
+ });
+ });
+
+ it("saves the Bitbucket sync schedule via onSaveBitbucketConfig", async () => {
+ const user = userEvent.setup();
+ const onLoadBitbucketConfig = vi.fn().mockResolvedValue({
+ autoUpdate: true,
+ spec: { type: "INTERVAL", everyMinutes: 30 },
+ nextSyncAt: null,
+ });
+ const onSaveBitbucketConfig = vi.fn().mockResolvedValue(undefined);
+
+ render(
+ ,
+ );
+
+ await waitFor(() => expect(onLoadBitbucketConfig).toHaveBeenCalled());
+
+ const minutes = screen.getByLabelText("Minutes");
+ await user.clear(minutes);
+ await user.type(minutes, "45");
+ await user.click(screen.getByRole("button", { name: /save/i }));
+
+ await waitFor(() => {
+ expect(onSaveBitbucketConfig).toHaveBeenCalledWith(
+ bitbucketRepository,
+ expect.objectContaining({
+ autoUpdate: true,
+ schedule: { type: "INTERVAL", everyMinutes: 45 },
+ }),
+ );
+ });
+ });
+
+ it("hides the Bitbucket schedule without the sync-settings permission", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByText("Sync Schedule")).not.toBeInTheDocument();
+ });
+
+ it("unlinks a Bitbucket repository after confirming the dialog", async () => {
+ const user = userEvent.setup();
+ const onUnlinkSource = vi.fn().mockResolvedValue(undefined);
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /Remove from project/ }));
+
+ // Bitbucket sources are repositories, so the copy says so.
+ expect(
+ screen.getByRole("alertdialog", { name: /Remove repository from project/ }),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: /^Remove$/ }));
+
+ expect(onUnlinkSource).toHaveBeenCalledWith(bitbucketSource);
+ });
+
+ it("hides the remove-from-project action when the Bitbucket repository has no id", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByRole("button", { name: /Remove from project/ })).not.toBeInTheDocument();
+ });
+
+ it("shows only the pull-request sync time for Bitbucket", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Last Synced")).toBeInTheDocument();
+ expect(screen.getByText("Pull requests")).toBeInTheDocument();
+ expect(screen.queryByText("Commits")).not.toBeInTheDocument();
+ expect(screen.queryByText("Issues")).not.toBeInTheDocument();
+ });
+
it("renders failed items from the source", () => {
render(
{
statusView: {
state: "syncing",
label: "Syncing",
- icon: mockSource.icon,
+ icon: GitBranch,
tone: "brand",
spinning: true,
},
@@ -530,7 +756,7 @@ describe("SourceDetailsPanel", () => {
statusView: {
state: "syncing",
label: "Syncing",
- icon: mockSource.icon,
+ icon: GitBranch,
tone: "brand",
spinning: true,
},
@@ -551,7 +777,7 @@ describe("SourceDetailsPanel", () => {
statusView: {
state: "syncing",
label: "Syncing",
- icon: mockSource.icon,
+ icon: GitBranch,
tone: "brand",
spinning: true,
},
@@ -613,7 +839,7 @@ describe("SourceDetailsPanel", () => {
statusView: {
state: "syncing",
label: "Syncing",
- icon: mockSource.icon,
+ icon: GitBranch,
tone: "brand",
spinning: true,
},
@@ -642,7 +868,7 @@ describe("SourceDetailsPanel", () => {
statusView: {
state: "syncing",
label: "Syncing",
- icon: mockSource.icon,
+ icon: GitBranch,
tone: "brand",
spinning: true,
},
diff --git a/tests/unit/features/data-ingestion/components/SourceList.test.tsx b/tests/unit/features/data-ingestion/components/SourceList.test.tsx
index 1d956f6be..c1629e523 100644
--- a/tests/unit/features/data-ingestion/components/SourceList.test.tsx
+++ b/tests/unit/features/data-ingestion/components/SourceList.test.tsx
@@ -91,6 +91,31 @@ describe("SourceList", () => {
expect(screen.queryAllByText("https://acme.atlassian.net")).toHaveLength(0);
});
+ it("shows the Bitbucket workspace under the name", () => {
+ const sources: DataSource[] = [
+ createMockSource({
+ sourceId: "repo-uuid-1",
+ sourceSystem: "BITBUCKET",
+ name: "acme/widgets",
+ type: "Bitbucket",
+ bitbucketRepository: {
+ repositoryId: "repo-uuid-1",
+ workspace: "acme",
+ slug: "widgets",
+ fullName: "acme/widgets",
+ url: "https://bitbucket.org/acme/widgets",
+ enabled: true,
+ },
+ }),
+ ];
+
+ render();
+
+ expect(screen.getAllByText("acme/widgets").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("acme").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Bitbucket").length).toBeGreaterThan(0);
+ });
+
it("renders info blocks with formatted values", () => {
const sources: DataSource[] = [
createMockSource({
diff --git a/tests/unit/features/data-ingestion/data.test.ts b/tests/unit/features/data-ingestion/data.test.ts
index 4f7b2b1c4..b17738657 100644
--- a/tests/unit/features/data-ingestion/data.test.ts
+++ b/tests/unit/features/data-ingestion/data.test.ts
@@ -4,6 +4,7 @@ import {
SOURCE_META,
INGESTION_RUN_LIMIT,
DETAILS_RUN_LIMIT,
+ createBitbucketSourceFromInstance,
createJiraSourceFromInstance,
createConfluenceSourceFromConnection,
createConfluenceSourceFromInstance,
@@ -33,7 +34,7 @@ import type { ConfluenceConnectionDto } from "../../../../src/services/sources/c
describe("data-ingestion data helpers", () => {
describe("SOURCE_SYSTEMS / SOURCE_META", () => {
it("lists all known source systems", () => {
- expect(SOURCE_SYSTEMS).toEqual(["GITHUB", "JIRA", "UPLOAD", "CONFLUENCE"]);
+ expect(SOURCE_SYSTEMS).toEqual(["GITHUB", "JIRA", "UPLOAD", "CONFLUENCE", "BITBUCKET"]);
});
it("provides meta for every source system", () => {
@@ -543,4 +544,149 @@ describe("data-ingestion data helpers", () => {
expect(source.confluenceSpace?.credentialName).toBe("team-cred");
});
});
+
+ describe("createBitbucketSourceFromInstance", () => {
+ const status = (
+ overrides: Partial = {},
+ ): SourceInstanceIngestionStatus => ({
+ sourceSystem: "BITBUCKET",
+ sourceId: "acme/widgets",
+ displayName: "acme/widgets",
+ repositoryId: "repo-uuid-1",
+ owner: "acme",
+ name: "widgets",
+ sourceUrl: "https://bitbucket.org/acme/widgets",
+ connectionStatus: "CONNECTED",
+ enabled: true,
+ lastRunTime: "2026-09-28T10:00:00Z",
+ ingestedCount: 12,
+ updatedCount: 2,
+ deletedCount: 1,
+ failedCount: 0,
+ failedItems: [],
+ artifactCount: 64,
+ lastCommitsSyncAt: null,
+ lastIssuesSyncAt: null,
+ lastPullRequestsSyncAt: "2026-09-28T10:00:00Z",
+ ...overrides,
+ });
+
+ it("carries the repository identity in bitbucketRepository, not githubRepository", () => {
+ const source = createBitbucketSourceFromInstance(status());
+
+ expect(source.sourceSystem).toBe("BITBUCKET");
+ expect(source.type).toBe("Bitbucket");
+ expect(source.name).toBe("acme/widgets");
+ expect(source.githubRepository).toBeNull();
+ expect(source.bitbucketRepository).toEqual({
+ repositoryId: "repo-uuid-1",
+ workspace: "acme",
+ slug: "widgets",
+ fullName: "acme/widgets",
+ url: "https://bitbucket.org/acme/widgets",
+ enabled: true,
+ });
+ });
+
+ it("keys the card by the repository id, falling back to workspace/slug", () => {
+ expect(createBitbucketSourceFromInstance(status()).sourceId).toBe("repo-uuid-1");
+ expect(createBitbucketSourceFromInstance(status({ repositoryId: null })).sourceId).toBe(
+ "acme/widgets",
+ );
+ });
+
+ it("derives workspace and slug from the source id when the row carries no owner or name", () => {
+ const source = createBitbucketSourceFromInstance(status({ owner: null, name: null }));
+
+ expect(source.bitbucketRepository).toMatchObject({ workspace: "acme", slug: "widgets" });
+ });
+
+ it("takes counters and the artifact total from the status row", () => {
+ const source = createBitbucketSourceFromInstance(status());
+
+ expect(source.artifacts).toBe(64);
+ expect(source.totalArtifactCount).toBe(64);
+ expect(source.latestIngestedCount).toBe(12);
+ expect(source.latestUpdatedCount).toBe(2);
+ expect(source.deletedCount).toBe(1);
+ expect(source.errors).toBe(0);
+ });
+
+ it("only reports the pull-request sync time", () => {
+ const source = createBitbucketSourceFromInstance(status());
+
+ expect(source.lastPullRequestsSyncAt).toBe("2026-09-28T10:00:00Z");
+ expect(source.lastCommitsSyncAt).toBeNull();
+ expect(source.lastIssuesSyncAt).toBeNull();
+ });
+
+ const cases: ConnectionStatus[] = ["CONNECTED", "UPDATING", "OUT_OF_DATE", "FAILED"];
+
+ for (const connectionStatus of cases) {
+ it(`carries the ${connectionStatus} connection status`, () => {
+ const source = createBitbucketSourceFromInstance(status({ connectionStatus }));
+
+ expect(source.backendStatus).toBe(connectionStatus);
+ });
+ }
+
+ it("shows the syncing state while the repository is updating", () => {
+ const source = createBitbucketSourceFromInstance(status({ connectionStatus: "UPDATING" }));
+
+ expect(source.statusView.state).toBe("syncing");
+ });
+
+ it("overrides the status with DISABLED when the source is disabled", () => {
+ const source = createBitbucketSourceFromInstance(status({ enabled: false }));
+
+ expect(source.backendStatus).toBe("DISABLED");
+ expect(source.statusView.state).toBe("disabled");
+ expect(source.bitbucketRepository?.enabled).toBe(false);
+ });
+
+ it("reports never-synced when the status row has no last run", () => {
+ const source = createBitbucketSourceFromInstance(status({ lastRunTime: null }));
+
+ expect(source.statusView.state).toBe("attention");
+ expect(source.lastRunAt).toBeNull();
+ });
+
+ it("shows a synced badge after a clean sync", () => {
+ expect(createBitbucketSourceFromInstance(status()).ingestionStatusLabel).toBe("Synced");
+ });
+
+ it("surfaces a disabled Bitbucket connector", () => {
+ const source = createBitbucketSourceFromInstance(status(), false);
+
+ expect(source.ingestionStatusLabel).toBe("Synced");
+ expect(source.statusView.state).toBe("disabled");
+ });
+
+ it("resolves runs by workspace/slug to the card name", () => {
+ const source = createBitbucketSourceFromInstance(status());
+ const labels = buildRunSourceLabels([source]);
+ const run: IngestionRun = {
+ runId: "run-1",
+ sourceSystem: "BITBUCKET",
+ sourceId: "acme/widgets",
+ owner: "acme",
+ name: "widgets",
+ repositoryId: "repo-uuid-1",
+ startedAt: "2026-09-28T10:00:00Z",
+ finishedAt: "2026-09-28T10:05:00Z",
+ ingestedCount: 12,
+ updatedCount: 2,
+ deletedCount: 1,
+ failedCount: 0,
+ status: "COMPLETED",
+ failedItems: [],
+ failureReason: null,
+ aiSyncStatus: "SUCCEEDED",
+ aiSyncFailureReason: null,
+ };
+
+ expect(labels.get("acme/widgets")).toBe("acme/widgets");
+ expect(getRunSourceLabel(run, labels)).toBe("acme/widgets");
+ });
+ });
});
diff --git a/tests/unit/features/knowledge-base/bitbucketWorkspaceMetadata.test.ts b/tests/unit/features/knowledge-base/bitbucketWorkspaceMetadata.test.ts
new file mode 100644
index 000000000..08d4c494f
--- /dev/null
+++ b/tests/unit/features/knowledge-base/bitbucketWorkspaceMetadata.test.ts
@@ -0,0 +1,77 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { parseBitbucketWorkspaceMetadata } from "../../../../src/features/knowledge-base/bitbucketWorkspaceMetadata";
+
+describe("parseBitbucketWorkspaceMetadata", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("parses a fully-populated workspace profile", () => {
+ const parsed = parseBitbucketWorkspaceMetadata(
+ JSON.stringify({
+ workspace: "acme",
+ uuid: "{1234}",
+ name: "Acme",
+ isPrivate: true,
+ createdOn: "2020-01-01T00:00:00.000Z",
+ url: "https://bitbucket.org/acme",
+ members: [{ accountId: "1", nickname: "ada", displayName: "Ada Lovelace" }],
+ }),
+ );
+
+ expect(parsed).toEqual({
+ workspace: "acme",
+ name: "Acme",
+ isPrivate: true,
+ createdOn: "2020-01-01T00:00:00.000Z",
+ url: "https://bitbucket.org/acme",
+ members: [{ accountId: "1", nickname: "ada", displayName: "Ada Lovelace" }],
+ });
+ });
+
+ it("reads the visibility under Jackson's `private` spelling as well", () => {
+ const parsed = parseBitbucketWorkspaceMetadata(
+ JSON.stringify({ workspace: "acme", name: "Acme", private: false, members: [] }),
+ );
+
+ expect(parsed?.isPrivate).toBe(false);
+ });
+
+ it("falls back to the workspace id and nulls for missing optional fields", () => {
+ const parsed = parseBitbucketWorkspaceMetadata(
+ JSON.stringify({
+ workspace: " acme ",
+ members: [{ accountId: null, nickname: " ", displayName: "Grace" }, "not a member"],
+ }),
+ );
+
+ expect(parsed).toEqual({
+ workspace: "acme",
+ name: "acme",
+ isPrivate: null,
+ createdOn: null,
+ url: null,
+ members: [{ accountId: null, nickname: null, displayName: "Grace" }],
+ });
+ });
+
+ it.each([
+ ["undefined", undefined],
+ ["null", null],
+ ["a blank string", " "],
+ ["a JSON array", "[]"],
+ ["a payload without workspace", JSON.stringify({ name: "Acme", members: [] })],
+ ["a payload without members", JSON.stringify({ workspace: "acme" })],
+ // The GitHub org shape names no workspace.
+ ["a GitHub org profile", JSON.stringify({ login: "acme", members: [] })],
+ ])("returns null for %s", (_label, input) => {
+ expect(parseBitbucketWorkspaceMetadata(input)).toBeNull();
+ });
+
+ it("returns null and warns for malformed JSON", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ expect(parseBitbucketWorkspaceMetadata("{not json")).toBeNull();
+ expect(warn).toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/features/knowledge-base/components/ArtifactFilters.test.tsx b/tests/unit/features/knowledge-base/components/ArtifactFilters.test.tsx
index 7a25b4c7e..e0112f423 100644
--- a/tests/unit/features/knowledge-base/components/ArtifactFilters.test.tsx
+++ b/tests/unit/features/knowledge-base/components/ArtifactFilters.test.tsx
@@ -117,6 +117,42 @@ describe("ArtifactFilters", () => {
expect(screen.getByTestId("kb-filter-option-markdown")).toBeInTheDocument();
});
+ it("offers Bitbucket as a source with its own icon and reports its toggle", () => {
+ const onToggleSource = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId("kb-filter-trigger"));
+
+ const bitbucket = screen.getByTestId("kb-filter-option-bitbucket");
+ // The Bitbucket logo is an inline SVG next to the option's label.
+ expect(bitbucket.closest("label")?.querySelector("svg")).not.toBeNull();
+
+ fireEvent.click(bitbucket);
+ expect(onToggleSource).toHaveBeenCalledWith("BITBUCKET");
+ });
+
+ it("shows the repository facet while Bitbucket repositories are offered", () => {
+ render(
+ (["BITBUCKET"]),
+ repositoryOptions: [{ value: "acme/widgets", label: "acme/widgets", count: 2 }],
+ })}
+ />,
+ );
+
+ fireEvent.click(screen.getByTestId("kb-filter-trigger"));
+
+ expect(screen.getByText("acme/widgets")).toBeInTheDocument();
+ });
+
it("summarises the source selection and shows active filter count", () => {
const { rerender } = render(
{
expect(await screen.findByText("PR Description")).toBeInTheDocument();
});
+ it("renders a Bitbucket pull request as markdown from its /pull-requests/ link alone", async () => {
+ const { knowledgeService } = await import("../../../../../src/services/knowledgeService");
+ vi.mocked(knowledgeService.getArtifactContent).mockResolvedValueOnce({
+ content: "## Bitbucket PR\n- Reworked the widgets",
+ mimeType: "text/plain",
+ isObjectUrl: false,
+ });
+
+ renderDrawer(
+ createArtifact({
+ title: "Rework widgets",
+ artifactType: "FILE",
+ sourceSystem: "BITBUCKET",
+ sourceUrl: "https://bitbucket.org/acme/widgets/pull-requests/12",
+ }),
+ );
+
+ const rawContent = await screen.findByTestId("raw-content");
+ expect(rawContent.querySelector(".prose")).toBeInTheDocument();
+ expect(await screen.findByText("Bitbucket PR")).toBeInTheDocument();
+ });
+
it("renders Jira / GitHub issues as markdown even if artifactType is FILE", async () => {
const { knowledgeService } = await import("../../../../../src/services/knowledgeService");
vi.mocked(knowledgeService.getArtifactContent).mockResolvedValueOnce({
@@ -945,6 +967,64 @@ describe("ArtifactViewerDrawer", () => {
);
});
});
+
+ describe("Bitbucket workspace artifacts", () => {
+ const workspaceMetadata = JSON.stringify({
+ workspace: "acme",
+ uuid: "{1234}",
+ name: "Acme Corp",
+ isPrivate: true,
+ createdOn: "2020-01-15T00:00:00.000Z",
+ url: "https://bitbucket.org/acme/",
+ members: [
+ { accountId: "1", nickname: "ada", displayName: "Ada Lovelace" },
+ { accountId: "2", nickname: "grace", displayName: null },
+ ],
+ });
+
+ it("renders the workspace profile from metadata and never fetches content", async () => {
+ const { knowledgeService } = await import("../../../../../src/services/knowledgeService");
+ renderDrawer(
+ createArtifact({
+ artifactType: "ORG_METADATA",
+ title: "Acme Corp",
+ sourceSystem: "BITBUCKET",
+ metadata: workspaceMetadata,
+ }),
+ );
+
+ expect(await screen.findByTestId("bitbucket-workspace-view")).toBeInTheDocument();
+ expect(screen.queryByTestId("org-metadata-view")).not.toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "acme" })).toHaveAttribute(
+ "href",
+ "https://bitbucket.org/acme/",
+ );
+ expect(screen.getByRole("link", { name: /repositories/i })).toHaveAttribute(
+ "href",
+ "https://bitbucket.org/acme/workspace/repositories",
+ );
+ expect(screen.getByText("Private")).toBeInTheDocument();
+ expect(screen.getByText("15 Jan 2020")).toBeInTheDocument();
+ // A member without a display name falls back to the nickname.
+ expect(screen.getByText("Ada Lovelace")).toBeInTheDocument();
+ expect(screen.getByText("grace")).toBeInTheDocument();
+ expect(knowledgeService.getArtifactContent).not.toHaveBeenCalled();
+ });
+
+ it("shows a quiet empty state when the workspace metadata is unusable", async () => {
+ renderDrawer(
+ createArtifact({
+ artifactType: "ORG_METADATA",
+ title: "Acme Corp",
+ sourceSystem: "BITBUCKET",
+ metadata: JSON.stringify({ name: "Acme Corp" }),
+ }),
+ );
+
+ expect(await screen.findByText("Workspace profile unavailable.")).toBeInTheDocument();
+ expect(screen.queryByText("Organization profile unavailable.")).not.toBeInTheDocument();
+ });
+ });
it("refuses an empty artifact instead of asking the AI", async () => {
const { knowledgeService } = await import("../../../../../src/services/knowledgeService");
diff --git a/tests/unit/features/knowledge-base/components/SourceLinkBadge.test.tsx b/tests/unit/features/knowledge-base/components/SourceLinkBadge.test.tsx
index 9bd61755c..86c8d8d7a 100644
--- a/tests/unit/features/knowledge-base/components/SourceLinkBadge.test.tsx
+++ b/tests/unit/features/knowledge-base/components/SourceLinkBadge.test.tsx
@@ -21,6 +21,20 @@ describe("SourceLinkBadge", () => {
expect(link).toHaveAttribute("title", "https://github.com/org/repo/blob/main/doc.md");
});
+ it("renders Bitbucket link badge", () => {
+ render(
+ ,
+ );
+
+ const link = screen.getByTestId("artifact-drawer-source-link");
+ expect(link).toHaveTextContent("Open in Bitbucket");
+ expect(link).toHaveAttribute("aria-label", "Open in Bitbucket (opens in a new tab)");
+ expect(link).toHaveAttribute("href", "https://bitbucket.org/acme/widgets/pull-requests/12");
+ });
+
it("renders Jira link badge", () => {
render(
,
diff --git a/tests/unit/features/knowledge-base/githubMetadata.test.ts b/tests/unit/features/knowledge-base/githubMetadata.test.ts
index 2769435f9..d9e780df7 100644
--- a/tests/unit/features/knowledge-base/githubMetadata.test.ts
+++ b/tests/unit/features/knowledge-base/githubMetadata.test.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
getArtifactRepository,
matchesRepository,
+ parseBitbucketMetadata,
parseGithubMetadata,
} from "../../../../src/features/knowledge-base/githubMetadata";
import type { Artifact } from "../../../../src/features/knowledge-base/types";
@@ -203,3 +204,133 @@ describe("matchesRepository", () => {
).toBe(true);
});
});
+
+describe("parseBitbucketMetadata", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("parses the backend shape with repositoryId, workspace and slug", () => {
+ const parsed = parseBitbucketMetadata(
+ JSON.stringify({ repositoryId: "bb-1", workspace: "acme", slug: "widgets" }),
+ );
+
+ expect(parsed).toEqual({ repositoryId: "bb-1", workspace: "acme", slug: "widgets" });
+ });
+
+ it("accepts a workspace profile that names no slug", () => {
+ const parsed = parseBitbucketMetadata(
+ JSON.stringify({ workspace: "acme", uuid: "{1}", name: "Acme", members: [] }),
+ );
+
+ expect(parsed?.workspace).toBe("acme");
+ expect(parsed?.slug).toBeUndefined();
+ });
+
+ it("trims the names and drops a blank slug or non-string repositoryId", () => {
+ const parsed = parseBitbucketMetadata(
+ JSON.stringify({ repositoryId: 7, workspace: " acme ", slug: " " }),
+ );
+
+ expect(parsed).toEqual({ repositoryId: undefined, workspace: "acme", slug: undefined });
+ });
+
+ it("returns null for missing, malformed or workspace-less metadata", () => {
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
+
+ expect(parseBitbucketMetadata(undefined)).toBeNull();
+ expect(parseBitbucketMetadata("")).toBeNull();
+ expect(parseBitbucketMetadata("{not json")).toBeNull();
+ expect(parseBitbucketMetadata("[1]")).toBeNull();
+ expect(parseBitbucketMetadata(JSON.stringify({ slug: "widgets" }))).toBeNull();
+ expect(parseBitbucketMetadata(JSON.stringify({ workspace: " " }))).toBeNull();
+ });
+
+ it("names Bitbucket in the malformed-metadata warning", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+
+ parseBitbucketMetadata("{not json");
+
+ expect(warn).toHaveBeenCalledWith(
+ "Ignoring unparseable Bitbucket artifact metadata",
+ "{not json",
+ );
+ });
+});
+
+describe("getArtifactRepository for Bitbucket", () => {
+ const repoMetadata = JSON.stringify({ repositoryId: "bb-1", workspace: "acme", slug: "widgets" });
+
+ it("resolves workspace/slug for a repository artifact", () => {
+ expect(
+ getArtifactRepository({
+ sourceSystem: "BITBUCKET",
+ artifactType: "PULL_REQUEST",
+ metadata: repoMetadata,
+ }),
+ ).toBe("acme/widgets");
+ });
+
+ it("returns null for the workspace profile and for unusable metadata", () => {
+ expect(
+ getArtifactRepository({
+ sourceSystem: "BITBUCKET",
+ artifactType: "ORG_METADATA",
+ metadata: JSON.stringify({ workspace: "acme", members: [] }),
+ }),
+ ).toBeNull();
+ expect(
+ getArtifactRepository({ sourceSystem: "BITBUCKET", artifactType: "FILE", metadata: "" }),
+ ).toBeNull();
+ });
+
+ it("does not read GitHub's repositoryFullName from a Bitbucket artifact", () => {
+ expect(
+ getArtifactRepository({
+ sourceSystem: "BITBUCKET",
+ artifactType: "FILE",
+ metadata: JSON.stringify({ repositoryFullName: "acme/widgets" }),
+ }),
+ ).toBeNull();
+ });
+});
+
+describe("matchesRepository for Bitbucket", () => {
+ const artifact = (
+ slug: string,
+ ): Pick => ({
+ sourceSystem: "BITBUCKET",
+ artifactType: "FILE",
+ metadata: JSON.stringify({ repositoryId: "bb-1", workspace: "acme", slug }),
+ });
+ const workspaceProfile = (
+ workspace: string,
+ ): Pick => ({
+ sourceSystem: "BITBUCKET",
+ artifactType: "ORG_METADATA",
+ metadata: JSON.stringify({ workspace, members: [] }),
+ });
+
+ it("narrows Bitbucket repository artifacts to the selected repositories", () => {
+ expect(matchesRepository(artifact("widgets"), new Set(["acme/widgets"]))).toBe(true);
+ expect(matchesRepository(artifact("gadgets"), new Set(["acme/widgets"]))).toBe(false);
+ });
+
+ it("matches everything while nothing is selected", () => {
+ expect(matchesRepository(artifact("widgets"), new Set())).toBe(true);
+ });
+
+ it("shows the workspace profile when its workspace owns a chosen repository, case-insensitively", () => {
+ expect(matchesRepository(workspaceProfile("ACME"), new Set(["acme/widgets"]))).toBe(true);
+ expect(matchesRepository(workspaceProfile("other"), new Set(["acme/widgets"]))).toBe(false);
+ });
+
+ it("still lets other sources through", () => {
+ expect(
+ matchesRepository(
+ { sourceSystem: "UPLOAD", artifactType: "FILE", metadata: "" },
+ new Set(["acme/widgets"]),
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts b/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts
index c131679a6..43f499c3e 100644
--- a/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts
+++ b/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts
@@ -264,7 +264,11 @@ function mockArtifactQuery(artifacts: Artifact[]) {
}));
const repoCandidates = artifacts.filter(
- (a) => matchesAllExcept(a, "repositories") && a.sourceSystem === "GITHUB",
+ (a) =>
+ matchesAllExcept(a, "repositories") &&
+ // This stands in for the backend's facet: it lists the repositories of both git
+ // providers, so the real backend has to do the same for Bitbucket.
+ (a.sourceSystem === "GITHUB" || a.sourceSystem === "BITBUCKET"),
);
const distinctRepos = new Set();
for (const a of repoCandidates) {
@@ -591,6 +595,52 @@ describe("useKnowledgeBase", () => {
});
});
+ it("offers the repository facet while Bitbucket is selected", async () => {
+ const bitbucketMetadata = (slug: string) =>
+ JSON.stringify({ repositoryId: "bb-1", workspace: "acme", slug });
+ const result = await renderWith([
+ {
+ ...makeArtifact("bb-1", "Widgets PR", "PULL_REQUEST", bitbucketMetadata("widgets")),
+ sourceSystem: "BITBUCKET",
+ },
+ {
+ ...makeArtifact("bb-2", "Widgets file", "FILE", bitbucketMetadata("widgets")),
+ sourceSystem: "BITBUCKET",
+ },
+ {
+ ...makeArtifact("bb-3", "Gadgets file", "FILE", bitbucketMetadata("gadgets")),
+ sourceSystem: "BITBUCKET",
+ },
+ ]);
+
+ expect(result.current.repositoryOptions).toHaveLength(0);
+
+ act(() => {
+ result.current.toggleSource("BITBUCKET");
+ });
+
+ await waitFor(() => {
+ expect(result.current.repositoryOptions.map((option) => option.value)).toEqual([
+ "acme/gadgets",
+ "acme/widgets",
+ ]);
+ expect(
+ result.current.repositoryOptions.find((option) => option.value === "acme/widgets")?.count,
+ ).toBe(2);
+ });
+
+ act(() => {
+ result.current.toggleRepository("acme/widgets");
+ });
+
+ await waitFor(() => {
+ expect(result.current.artifacts.map((artifact) => artifact.id).sort()).toEqual([
+ "bb-1",
+ "bb-2",
+ ]);
+ });
+ });
+
it("promises with its count exactly what choosing the repository delivers", async () => {
const result = await renderWith(makeRepoFixture());
diff --git a/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts b/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts
index e3c308d73..e34355ae8 100644
--- a/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts
+++ b/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts
@@ -58,6 +58,15 @@ describe("parseKnowledgeBaseSearch", () => {
expect(state.format).toBe("PDF");
});
+ it("keeps repositories while Bitbucket is selected, with or without GitHub", () => {
+ expect([...parse("?sources=BITBUCKET&repos=acme/widgets").repositories]).toEqual([
+ "acme/widgets",
+ ]);
+ expect([...parse("?sources=GITHUB,BITBUCKET&repos=acme/widgets").repositories]).toEqual([
+ "acme/widgets",
+ ]);
+ });
+
it("ignores a format without Uploads and repositories without GitHub", () => {
const state = parse("?sources=JIRA&format=PDF&repos=acme/api");
expect(state.format).toBeNull();
@@ -187,6 +196,18 @@ describe("useKnowledgeBaseUrlState", () => {
expect(result.current.location.search).toBe("");
});
+ it("keeps the repositories until neither git provider is left selected", () => {
+ const { result } = renderUrlState(["/kb?sources=GITHUB,BITBUCKET&repos=acme/widgets"], {
+ projectId: "p1",
+ });
+
+ act(() => result.current.api.toggleSource("GITHUB"));
+ expect(result.current.location.search).toBe("?sources=BITBUCKET&repos=acme/widgets");
+
+ act(() => result.current.api.toggleSource("BITBUCKET"));
+ expect(result.current.location.search).toBe("");
+ });
+
it("resets the page on every filter change and keeps other params", () => {
const { result } = renderUrlState(["/kb?page=4&size=50&projectId=x"], { projectId: "p1" });
act(() => result.current.api.toggleSource("JIRA"));
diff --git a/tests/unit/pages/DataIngestionPage.test.tsx b/tests/unit/pages/DataIngestionPage.test.tsx
index 939ec2b7b..f88a20d9f 100644
--- a/tests/unit/pages/DataIngestionPage.test.tsx
+++ b/tests/unit/pages/DataIngestionPage.test.tsx
@@ -443,6 +443,129 @@ describe("DataIngestionPage", () => {
});
});
+ const bitbucketStatusRow = (overrides: Record = {}) => ({
+ sourceSystem: "BITBUCKET",
+ sourceId: "acme/widgets",
+ displayName: "acme/widgets",
+ repositoryId: "bb-repo-uuid",
+ owner: "acme",
+ name: "widgets",
+ sourceUrl: "https://bitbucket.org/acme/widgets",
+ connectionStatus: "CONNECTED",
+ enabled: true,
+ lastRunTime: "2026-09-28T10:00:00Z",
+ ingestedCount: 9,
+ updatedCount: 1,
+ deletedCount: 0,
+ failedCount: 0,
+ failedItems: [],
+ artifactCount: 77,
+ lastCommitsSyncAt: null,
+ lastIssuesSyncAt: null,
+ lastPullRequestsSyncAt: "2026-09-28T10:00:00Z",
+ ...overrides,
+ });
+
+ it("builds a Bitbucket source card from the connector-neutral status row", async () => {
+ mockGetAccessibleProject.mockResolvedValue({
+ id: "proj1",
+ name: "Project Alpha",
+ description: "",
+ manager: null,
+ sources: [],
+ users: [],
+ });
+ mockGetIngestionSourceStatuses.mockResolvedValue([bitbucketStatusRow()]);
+
+ render(
+
+
+ ,
+ );
+
+ expect((await screen.findAllByText("acme/widgets")).length).toBeGreaterThan(0);
+ // The workspace is the card's subtitle and the row's artifact total drives the count.
+ expect(screen.getAllByText("acme").length).toBeGreaterThan(0);
+ await waitFor(() => {
+ expect(screen.getAllByText("77").length).toBeGreaterThan(0);
+ });
+ });
+
+ it("does not double a Bitbucket repository that is also exposed as a project source", async () => {
+ // `project.sources` carries Bitbucket entries too. The card is built from the
+ // status row alone, so the project source must not add a fallback card.
+ mockGetAccessibleProject.mockResolvedValue({
+ id: "proj1",
+ name: "Project Alpha",
+ description: "",
+ manager: null,
+ sources: [
+ { id: "acme/widgets", name: "acme/widgets", type: "BITBUCKET", status: "CONNECTED" },
+ ],
+ users: [],
+ });
+ mockGetIngestionSourceStatuses.mockResolvedValue([bitbucketStatusRow()]);
+
+ render(
+
+
+ ,
+ );
+
+ const connectedSourcesKpi = await screen.findByRole("button", {
+ name: /connected sources/i,
+ });
+ expect(within(connectedSourcesKpi).getByText("1")).toBeInTheDocument();
+ });
+
+ it("filters the run history to a Bitbucket repository via repositoryId", async () => {
+ mockGetIngestionSourceStatuses.mockResolvedValue([
+ {
+ sourceSystem: "GITHUB",
+ sourceId: "octocat/hello-world",
+ displayName: "octocat/hello-world",
+ repositoryId: "repo-uuid",
+ owner: "octocat",
+ name: "hello-world",
+ sourceUrl: "https://github.com/octocat/hello-world",
+ connectionStatus: "CONNECTED",
+ enabled: true,
+ lastRunTime: "2026-07-01T00:00:00Z",
+ ingestedCount: 1,
+ updatedCount: 0,
+ deletedCount: 0,
+ failedCount: 0,
+ failedItems: [],
+ artifactCount: 10,
+ lastCommitsSyncAt: null,
+ lastIssuesSyncAt: null,
+ lastPullRequestsSyncAt: null,
+ },
+ bitbucketStatusRow(),
+ ]);
+
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole("combobox", { name: "Filter runs by source" })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("combobox", { name: "Filter runs by source" }));
+ await user.click(await screen.findByRole("option", { name: "acme/widgets" }));
+
+ // A Bitbucket repository is scoped by its connection id, not by sourceRef.
+ await waitFor(() => {
+ expect(mockGetIngestionRunsPage).toHaveBeenLastCalledWith(
+ expect.objectContaining({ repositoryId: "bb-repo-uuid", sourceRef: undefined, page: 1 }),
+ );
+ });
+ });
+
/*
The knowledge-gap detail page's "Update data source" button links here from a gap, and a gap
knows itself by component — `owner/repo` — not by the project-source id these cards select
@@ -1064,6 +1187,171 @@ describe("DataIngestionPage", () => {
});
});
+ describe("Bitbucket source actions", () => {
+ beforeEach(() => {
+ mockGetAccessibleProject.mockResolvedValue({
+ id: "proj1",
+ name: "Project Alpha",
+ description: "",
+ manager: null,
+ sources: [],
+ users: [],
+ });
+ mockGetIngestionSourceStatuses.mockResolvedValue([bitbucketStatusRow()]);
+ });
+
+ async function openBitbucketDrawer() {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ const panel = await screen.findByRole("dialog");
+ expect(within(panel).getByText("Workspace")).toBeInTheDocument();
+
+ return { user, panel };
+ }
+
+ it("updates the repository through its connection id", async () => {
+ const updated: string[] = [];
+ server.use(
+ http.post("/api/v1/bitbucket/connections/:repositoryId/update", ({ params }) => {
+ updated.push(String(params.repositoryId));
+ return HttpResponse.json({ transactionId: "tx-1" }, { status: 202 });
+ }),
+ );
+
+ const { user, panel } = await openBitbucketDrawer();
+ await user.click(within(panel).getByRole("button", { name: /update repo/i }));
+
+ await waitFor(() => expect(updated).toEqual(["bb-repo-uuid"]));
+ });
+
+ it("disables the repository through the connector endpoint by workspace/slug", async () => {
+ const patches: unknown[] = [];
+ server.use(
+ http.patch(
+ "/api/v1/connectors/:connectorId/sources/status",
+ async ({ params, request }) => {
+ patches.push({ connectorId: params.connectorId, body: await request.json() });
+ return HttpResponse.json({});
+ },
+ ),
+ );
+
+ const { user, panel } = await openBitbucketDrawer();
+ await user.click(
+ within(panel).getByRole("switch", { name: /toggle ingestion for acme\/widgets/i }),
+ );
+
+ await waitFor(() => {
+ expect(patches).toEqual([
+ {
+ connectorId: "bitbucket",
+ body: { sources: [{ sourceId: "acme/widgets", enabled: false }] },
+ },
+ ]);
+ });
+ });
+
+ it("removes the repository from the selected project", async () => {
+ const removed: unknown[] = [];
+ server.use(
+ http.delete(
+ "/api/v1/bitbucket/connections/:repositoryId/projects/:projectId",
+ ({ params }) => {
+ removed.push({ repositoryId: params.repositoryId, projectId: params.projectId });
+ return HttpResponse.json({ repositoryId: params.repositoryId, projectIds: [] });
+ },
+ ),
+ );
+
+ const { user, panel } = await openBitbucketDrawer();
+ await user.click(within(panel).getByRole("button", { name: /remove from project/i }));
+ await user.click(await screen.findByRole("button", { name: /^remove$/i }));
+
+ await waitFor(() => {
+ expect(removed).toEqual([{ repositoryId: "bb-repo-uuid", projectId: "proj1" }]);
+ });
+ });
+
+ it("loads and saves the repository's own sync schedule by workspace and slug", async () => {
+ const saved: unknown[] = [];
+ server.use(
+ http.get("/api/v1/bitbucket/config/:workspace/:slug", () =>
+ HttpResponse.json({
+ id: "cfg-1",
+ workspace: "acme",
+ slug: "widgets",
+ autoUpdate: true,
+ spec: { type: "INTERVAL", everyMinutes: 30 },
+ schedule: "every 30m",
+ nextSyncAt: null,
+ }),
+ ),
+ http.put("/api/v1/bitbucket/config/:workspace/:slug", async ({ params, request }) => {
+ saved.push({ params, body: await request.json() });
+ return new HttpResponse(null, { status: 204 });
+ }),
+ );
+
+ const { user, panel } = await openBitbucketDrawer();
+ const minutes = await within(panel).findByLabelText("Minutes");
+ await waitFor(() => expect(minutes).toHaveValue(30));
+
+ await user.clear(minutes);
+ await user.type(minutes, "45");
+ await user.click(within(panel).getByRole("button", { name: /save/i }));
+
+ await waitFor(() => {
+ expect(saved).toEqual([
+ {
+ params: { workspace: "acme", slug: "widgets" },
+ body: { autoUpdate: true, schedule: { type: "INTERVAL", everyMinutes: 45 } },
+ },
+ ]);
+ });
+ });
+
+ it("applies the global sync schedule to every Bitbucket repository", async () => {
+ const configured: unknown[] = [];
+ server.use(
+ http.put("/api/v1/bitbucket/config", async ({ request }) => {
+ configured.push(await request.json());
+ return new HttpResponse(null, { status: 204 });
+ }),
+ );
+
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ await user.click(await screen.findByRole("button", { name: /manage sync settings/i }));
+
+ expect(await screen.findByText("Bitbucket Sync Settings")).toBeInTheDocument();
+ expect(
+ screen.queryByRole("tablist", { name: /sync settings connector/i }),
+ ).not.toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole("switch", { name: /toggle global bitbucket auto update/i }),
+ );
+ await user.click(screen.getByRole("button", { name: /apply globally/i }));
+
+ await waitFor(() => {
+ expect(configured).toEqual([
+ { autoUpdate: false, schedule: { type: "INTERVAL", everyMinutes: 60 } },
+ ]);
+ });
+ expect(mockConfigureAllGithubRepositories).not.toHaveBeenCalled();
+ });
+ });
+
it("opens the connectors modal from Manage connectors", async () => {
const user = userEvent.setup();
render(
diff --git a/tests/unit/services/bitbucketRepositoryInput.test.ts b/tests/unit/services/bitbucketRepositoryInput.test.ts
new file mode 100644
index 000000000..43c0db375
--- /dev/null
+++ b/tests/unit/services/bitbucketRepositoryInput.test.ts
@@ -0,0 +1,120 @@
+import { describe, it, expect } from "vitest";
+import {
+ parseBitbucketRepositoryInput,
+ parseBitbucketRepositoryReference,
+ parseBitbucketWorkspaceInput,
+} from "../../../src/services/sources/bitbucketRepositoryInput";
+
+describe("parseBitbucketRepositoryReference", () => {
+ it("parses a plain workspace/slug reference", () => {
+ expect(parseBitbucketRepositoryReference("acme/widgets")).toEqual({
+ workspace: "acme",
+ slug: "widgets",
+ });
+ });
+
+ it.each([
+ "https://bitbucket.org/acme/widgets",
+ "http://bitbucket.org/acme/widgets",
+ "bitbucket.org/acme/widgets",
+ "git@bitbucket.org:acme/widgets.git",
+ "ssh://git@bitbucket.org/acme/widgets.git",
+ "https://someone@bitbucket.org/acme/widgets.git",
+ "https://bitbucket.org/acme/widgets/src/main/",
+ "https://bitbucket.org/acme/widgets/pull-requests/12",
+ "/acme/widgets/",
+ ])("strips the surrounding syntax of %s", (value) => {
+ expect(parseBitbucketRepositoryReference(value)).toEqual({
+ workspace: "acme",
+ slug: "widgets",
+ });
+ });
+
+ it("returns null when the slug half is missing", () => {
+ expect(parseBitbucketRepositoryReference("acme")).toBeNull();
+ expect(parseBitbucketRepositoryReference("https://bitbucket.org/acme/")).toBeNull();
+ });
+
+ it("returns null for an empty value", () => {
+ expect(parseBitbucketRepositoryReference(" ")).toBeNull();
+ });
+});
+
+describe("parseBitbucketWorkspaceInput", () => {
+ it.each([
+ "acme",
+ " acme ",
+ "bitbucket.org/acme",
+ "https://bitbucket.org/acme/",
+ "https://bitbucket.org/acme/widgets",
+ "git@bitbucket.org:acme/widgets.git",
+ ])("extracts the workspace from %s", (value) => {
+ expect(parseBitbucketWorkspaceInput(value)).toBe("acme");
+ });
+
+ it("returns null for an empty value", () => {
+ expect(parseBitbucketWorkspaceInput("")).toBeNull();
+ expect(parseBitbucketWorkspaceInput("https://bitbucket.org/")).toBeNull();
+ });
+});
+
+describe("parseBitbucketRepositoryInput", () => {
+ it("combines the two fields", () => {
+ expect(parseBitbucketRepositoryInput("acme", "widgets")).toEqual({
+ workspace: "acme",
+ slug: "widgets",
+ });
+ });
+
+ it("trims the two fields", () => {
+ expect(parseBitbucketRepositoryInput(" acme ", " widgets ")).toEqual({
+ workspace: "acme",
+ slug: "widgets",
+ });
+ });
+
+ it("prefers a combined reference in the workspace field", () => {
+ expect(parseBitbucketRepositoryInput("https://bitbucket.org/acme/widgets", "ignored")).toEqual({
+ workspace: "acme",
+ slug: "widgets",
+ });
+ });
+
+ it("returns null when only one half is given", () => {
+ expect(parseBitbucketRepositoryInput("acme", "")).toBeNull();
+ expect(parseBitbucketRepositoryInput("", "widgets")).toBeNull();
+ });
+
+ // The backend matches connections by exact coordinates, and Bitbucket ids are
+ // lowercase, so a capitalized entry must not address a second connection.
+ it("lowercases the workspace and slug", () => {
+ expect(parseBitbucketRepositoryInput("Acme", "Widgets")).toEqual({
+ workspace: "acme",
+ slug: "widgets",
+ });
+ expect(parseBitbucketRepositoryReference("https://bitbucket.org/Acme/Widgets.git")).toEqual({
+ workspace: "acme",
+ slug: "widgets",
+ });
+ expect(parseBitbucketWorkspaceInput("bitbucket.org/ACME")).toBe("acme");
+ });
+
+ // A pasted browser URL often carries a query string or a fragment; neither may
+ // end up in the slug, or discovery filters by a name that matches nothing.
+ it("ignores a query string and a fragment", () => {
+ expect(parseBitbucketRepositoryReference("https://bitbucket.org/acme/widgets?foo=bar")).toEqual(
+ {
+ workspace: "acme",
+ slug: "widgets",
+ },
+ );
+ expect(
+ parseBitbucketRepositoryReference("https://bitbucket.org/acme/widgets/src/main#readme"),
+ ).toEqual({ workspace: "acme", slug: "widgets" });
+ expect(parseBitbucketWorkspaceInput("https://bitbucket.org/acme?tab=repos")).toBe("acme");
+ expect(parseBitbucketRepositoryReference("git@bitbucket.org:acme/widgets.git")).toEqual({
+ workspace: "acme",
+ slug: "widgets",
+ });
+ });
+});
diff --git a/tests/unit/services/bitbucketService.test.ts b/tests/unit/services/bitbucketService.test.ts
new file mode 100644
index 000000000..eb6c1f6e6
--- /dev/null
+++ b/tests/unit/services/bitbucketService.test.ts
@@ -0,0 +1,382 @@
+import { describe, expect, it } from "vitest";
+import { http, HttpResponse } from "msw";
+import { server } from "../setup/vitest.setup";
+import {
+ addBitbucketRepositoryToProject,
+ configureAllBitbucketRepositories,
+ configureBitbucketRepository,
+ connectBitbucketRepositories,
+ connectBitbucketRepository,
+ discoverBitbucketRepositories,
+ getBitbucketRepositoryConfig,
+ removeBitbucketRepositoryFromProject,
+ updateAllBitbucketRepositories,
+ updateBitbucketRepository,
+} from "../../../src/services/sources/bitbucketService";
+
+const discoverEndpoint = "/api/v1/bitbucket/discover/workspace/:workspace";
+
+const backendRepository = (slug: string, overrides: Record = {}) => ({
+ workspace: "acme",
+ slug,
+ name: slug,
+ isPrivate: false,
+ url: `https://bitbucket.org/acme/${slug}`,
+ alreadyConnected: false,
+ enabled: null,
+ ...overrides,
+});
+
+describe("connectBitbucketRepository", () => {
+ it("posts the workspace, slug, credential and project", async () => {
+ expect.assertions(2);
+
+ server.use(
+ http.post("/api/v1/bitbucket", async ({ request }) => {
+ expect(await request.json()).toEqual({
+ workspace: "acme",
+ slug: "widgets",
+ credentialName: "atlassian-main",
+ projectId: "project-1",
+ });
+
+ return HttpResponse.json({ transactionId: "tx-1" }, { status: 202 });
+ }),
+ );
+
+ const response = await connectBitbucketRepository({
+ workspace: "acme",
+ slug: "widgets",
+ credentialName: "atlassian-main",
+ projectId: "project-1",
+ });
+
+ expect(response).toEqual({ transactionId: "tx-1" });
+ });
+});
+
+describe("connectBitbucketRepositories", () => {
+ it("sends one entry per repository, each with the shared credential and project", async () => {
+ expect.assertions(2);
+
+ server.use(
+ http.post("/api/v1/bitbucket/connect/all", async ({ request }) => {
+ expect(await request.json()).toEqual({
+ repositories: [
+ {
+ workspace: "acme",
+ slug: "widgets",
+ credentialName: "atlassian-main",
+ projectId: "project-1",
+ },
+ {
+ workspace: "acme",
+ slug: "gadgets",
+ credentialName: "atlassian-main",
+ projectId: "project-1",
+ },
+ ],
+ });
+
+ return HttpResponse.json(
+ { transactionIdsByRepository: { "acme/widgets": "tx-1", "acme/gadgets": "tx-2" } },
+ { status: 202 },
+ );
+ }),
+ );
+
+ const result = await connectBitbucketRepositories(
+ [
+ { workspace: "acme", slug: "widgets" },
+ { workspace: "acme", slug: "gadgets" },
+ ],
+ "atlassian-main",
+ "project-1",
+ );
+
+ expect(result.transactionIdsByRepository).toEqual({
+ "acme/widgets": "tx-1",
+ "acme/gadgets": "tx-2",
+ });
+ });
+});
+
+describe("discoverBitbucketRepositories", () => {
+ it("passes the credential and the 0-based page in the query and the workspace in the path", async () => {
+ expect.assertions(3);
+
+ server.use(
+ http.get(discoverEndpoint, ({ params, request }) => {
+ const url = new URL(request.url);
+
+ expect(params.workspace).toBe("acme team");
+ expect(url.searchParams.get("credentialName")).toBe("atlassian-main");
+ expect(Object.fromEntries(url.searchParams)).toMatchObject({ page: "2", pageSize: "10" });
+
+ return HttpResponse.json({ repositories: [] });
+ }),
+ );
+
+ await discoverBitbucketRepositories("acme team", "atlassian-main", 2, 10);
+ });
+
+ it("maps the repositories to the shape the UI consumes", async () => {
+ server.use(
+ http.get(discoverEndpoint, () =>
+ HttpResponse.json({
+ repositories: [
+ backendRepository("widgets", { isPrivate: true }),
+ backendRepository("gadgets", { alreadyConnected: true, enabled: false }),
+ ],
+ }),
+ ),
+ );
+
+ const result = await discoverBitbucketRepositories("acme", "atlassian-main");
+
+ expect(result.repositories).toEqual([
+ {
+ workspace: "acme",
+ slug: "widgets",
+ name: "widgets",
+ isPrivate: true,
+ url: "https://bitbucket.org/acme/widgets",
+ alreadyConnected: false,
+ isEnabled: null,
+ },
+ {
+ workspace: "acme",
+ slug: "gadgets",
+ name: "gadgets",
+ isPrivate: false,
+ url: "https://bitbucket.org/acme/gadgets",
+ alreadyConnected: true,
+ isEnabled: false,
+ },
+ ]);
+ });
+
+ it("reads the privacy flag whether the backend names it isPrivate or private", async () => {
+ server.use(
+ http.get(discoverEndpoint, () =>
+ HttpResponse.json({
+ repositories: [
+ { workspace: "acme", slug: "kotlin-style", name: "a", isPrivate: true },
+ { workspace: "acme", slug: "jackson-style", name: "b", private: true },
+ { workspace: "acme", slug: "missing", name: "c" },
+ ],
+ }),
+ ),
+ );
+
+ const result = await discoverBitbucketRepositories("acme", "atlassian-main");
+
+ expect(result.repositories.map((repository) => repository.isPrivate)).toEqual([
+ true,
+ true,
+ false,
+ ]);
+ });
+
+ it("defaults the optional fields when the backend omits them", async () => {
+ server.use(
+ http.get(discoverEndpoint, () =>
+ HttpResponse.json({
+ repositories: [{ workspace: "acme", slug: "bare", name: "bare", url: null }],
+ }),
+ ),
+ );
+
+ const result = await discoverBitbucketRepositories("acme", "atlassian-main");
+
+ expect(result.repositories[0]).toMatchObject({
+ url: null,
+ alreadyConnected: false,
+ isEnabled: null,
+ });
+ });
+
+ it("reports hasMore when a full page came back", async () => {
+ server.use(
+ http.get(discoverEndpoint, () =>
+ HttpResponse.json({ repositories: [backendRepository("a"), backendRepository("b")] }),
+ ),
+ );
+
+ const result = await discoverBitbucketRepositories("acme", "atlassian-main", 0, 2);
+
+ expect(result.hasMore).toBe(true);
+ });
+
+ it("reports no more pages when the page is short", async () => {
+ server.use(
+ http.get(discoverEndpoint, () =>
+ HttpResponse.json({ repositories: [backendRepository("a")] }),
+ ),
+ );
+
+ const result = await discoverBitbucketRepositories("acme", "atlassian-main", 0, 2);
+
+ expect(result.hasMore).toBe(false);
+ });
+
+ it("propagates a missing credential as a 404", async () => {
+ server.use(
+ http.get(discoverEndpoint, () =>
+ HttpResponse.json({ message: "Credential not found" }, { status: 404 }),
+ ),
+ );
+
+ await expect(discoverBitbucketRepositories("acme", "gone")).rejects.toMatchObject({
+ status: 404,
+ });
+ });
+});
+
+describe("bitbucketService project links", () => {
+ it("addBitbucketRepositoryToProject posts to the connection's project path", async () => {
+ server.use(
+ http.post("/api/v1/bitbucket/connections/:repositoryId/projects/:projectId", ({ params }) =>
+ HttpResponse.json({
+ repositoryId: params.repositoryId,
+ projectIds: ["project-1", params.projectId],
+ }),
+ ),
+ );
+
+ const response = await addBitbucketRepositoryToProject("repo-1", "project-2");
+
+ expect(response).toEqual({ repositoryId: "repo-1", projectIds: ["project-1", "project-2"] });
+ });
+
+ it("removeBitbucketRepositoryFromProject deletes the link and returns the remaining projects", async () => {
+ expect.assertions(2);
+
+ server.use(
+ http.delete(
+ "/api/v1/bitbucket/connections/:repositoryId/projects/:projectId",
+ ({ params }) => {
+ expect(params).toMatchObject({ repositoryId: "repo-1", projectId: "project-1" });
+
+ return HttpResponse.json({ repositoryId: "repo-1", projectIds: ["project-2"] });
+ },
+ ),
+ );
+
+ const response = await removeBitbucketRepositoryFromProject("repo-1", "project-1");
+
+ expect(response.projectIds).toEqual(["project-2"]);
+ });
+});
+
+describe("bitbucketService updates", () => {
+ it("updateBitbucketRepository posts to the connection's update path", async () => {
+ server.use(
+ http.post("/api/v1/bitbucket/connections/:repositoryId/update", ({ params }) =>
+ HttpResponse.json({ transactionId: `tx-${String(params.repositoryId)}` }, { status: 202 }),
+ ),
+ );
+
+ await expect(updateBitbucketRepository("repo-1")).resolves.toEqual({
+ transactionId: "tx-repo-1",
+ });
+ });
+
+ it("updateAllBitbucketRepositories returns the transaction ids keyed by workspace/slug", async () => {
+ server.use(
+ http.post("/api/v1/bitbucket/update-all", () =>
+ HttpResponse.json(
+ { transactionIdsByRepository: { "acme/widgets": "tx-1" } },
+ { status: 202 },
+ ),
+ ),
+ );
+
+ await expect(updateAllBitbucketRepositories()).resolves.toEqual({
+ transactionIdsByRepository: { "acme/widgets": "tx-1" },
+ });
+ });
+});
+
+describe("bitbucketService config endpoints", () => {
+ it("configureAllBitbucketRepositories sends the typed global schedule payload", async () => {
+ expect.assertions(1);
+
+ server.use(
+ http.put("/api/v1/bitbucket/config", async ({ request }) => {
+ expect(await request.json()).toEqual({
+ autoUpdate: true,
+ schedule: { type: "WEEKLY", time: "09:00:00", daysOfWeek: ["MONDAY", "THURSDAY"] },
+ });
+
+ return new HttpResponse(null, { status: 204 });
+ }),
+ );
+
+ await configureAllBitbucketRepositories({
+ autoUpdate: true,
+ schedule: { type: "WEEKLY", time: "09:00:00", daysOfWeek: ["MONDAY", "THURSDAY"] },
+ });
+ });
+
+ it("configureBitbucketRepository targets one repository config", async () => {
+ expect.assertions(2);
+
+ server.use(
+ http.put("/api/v1/bitbucket/config/:workspace/:slug", async ({ params, request }) => {
+ expect(params).toMatchObject({ workspace: "sprint", slug: "frontend" });
+ expect(await request.json()).toEqual({
+ autoUpdate: false,
+ schedule: { type: "INTERVAL", everyMinutes: 60 },
+ });
+
+ return new HttpResponse(null, { status: 204 });
+ }),
+ );
+
+ await configureBitbucketRepository(
+ { workspace: "sprint", slug: "frontend" },
+ { autoUpdate: false, schedule: { type: "INTERVAL", everyMinutes: 60 } },
+ );
+ });
+
+ it("encodes the workspace and slug into the config path", async () => {
+ expect.assertions(1);
+
+ server.use(
+ http.get("/api/v1/bitbucket/config/:workspace/:slug", ({ request }) => {
+ expect(new URL(request.url).pathname).toBe("/api/v1/bitbucket/config/a%20b/c%2Fd");
+
+ return HttpResponse.json({});
+ }),
+ );
+
+ await getBitbucketRepositoryConfig("a b", "c/d");
+ });
+
+ it("getBitbucketRepositoryConfig returns repository sync settings", async () => {
+ server.use(
+ http.get("/api/v1/bitbucket/config/sprint/frontend", () =>
+ HttpResponse.json({
+ id: "config-1",
+ workspace: "sprint",
+ slug: "frontend",
+ autoUpdate: true,
+ spec: { type: "DAILY", time: "02:00:00" },
+ schedule: "0 0 2 * * *",
+ nextSyncAt: "2026-01-01T02:00:00Z",
+ }),
+ ),
+ );
+
+ const config = await getBitbucketRepositoryConfig("sprint", "frontend");
+
+ expect(config).toMatchObject({
+ workspace: "sprint",
+ slug: "frontend",
+ autoUpdate: true,
+ spec: { type: "DAILY", time: "02:00:00" },
+ nextSyncAt: "2026-01-01T02:00:00Z",
+ });
+ });
+});
diff --git a/tests/unit/setup/msw-handlers.ts b/tests/unit/setup/msw-handlers.ts
index 7ddc66d57..cb79f365c 100644
--- a/tests/unit/setup/msw-handlers.ts
+++ b/tests/unit/setup/msw-handlers.ts
@@ -676,6 +676,25 @@ export const handlers = [
http.get("/api/v1/connectors/confluence/sources", () =>
HttpResponse.json({ connectorId: "confluence", sources: [] }),
),
+ // Bitbucket: nothing discovered and every connect accepted. Tests that care override these.
+ http.get("/api/v1/bitbucket/discover/workspace/:workspace", () =>
+ HttpResponse.json({ repositories: [] }),
+ ),
+ http.post("/api/v1/bitbucket", () =>
+ HttpResponse.json({ transactionId: "bb-tx-default" }, { status: 202 }),
+ ),
+ http.post("/api/v1/bitbucket/connect/all", () =>
+ HttpResponse.json({ transactionIdsByRepository: {} }, { status: 202 }),
+ ),
+ http.post("/api/v1/bitbucket/connections/:repositoryId/projects/:projectId", ({ params }) =>
+ HttpResponse.json({ repositoryId: params.repositoryId, projectIds: [params.projectId] }),
+ ),
+ http.delete("/api/v1/bitbucket/connections/:repositoryId/projects/:projectId", ({ params }) =>
+ HttpResponse.json({ repositoryId: params.repositoryId, projectIds: [] }),
+ ),
+ http.post("/api/v1/bitbucket/connections/:repositoryId/update", () =>
+ HttpResponse.json({ transactionId: "bb-update-default" }, { status: 202 }),
+ ),
// Every surface that opens the buddy dock asks for its suggestion chips. A default empty list
// keeps that request handled for the many tests that open the dock without being about the
// chips; `useBuddySuggestions`' own suite mocks the service directly and never sees this.