From aaffb9cb0743ac8600b1febc5f18791425df0ee5 Mon Sep 17 00:00:00 2001
From: kiranfin <4finjas@online.de>
Date: Wed, 30 Sep 2026 15:28:10 +0200
Subject: [PATCH 1/9] Add Bitbucket service layer, input parser, icon and
source type
- Add bitbucketService and bitbucketRepositoryInput with tests, including defensive discover mapping and hasMore
- Add BitbucketIcon and a wider IconComponent type so SOURCE_META and DrawerCard accept non-lucide icons
- Add BITBUCKET to the source unions and Record maps, shown as "Soon" in the add-source grid
---
src/components/icons/BitbucketIcon.tsx | 24 ++
src/components/icons/types.ts | 16 +
src/features/admin/components/DrawerCard.tsx | 6 +-
.../wizard/sources/AddSourceFlow.tsx | 1 +
.../wizard/steps/WizardReviewStep.tsx | 3 +
src/features/admin/projectSourcesDraft.ts | 2 +-
.../components/AddSourceModal.tsx | 10 +-
src/features/data-ingestion/data.ts | 16 +-
src/features/data-ingestion/types.ts | 7 +-
.../components/ArtifactFilters.tsx | 2 +
src/features/knowledge-base/tabs.ts | 9 +-
src/features/knowledge-base/types.ts | 2 +-
src/services/projectService.ts | 3 +-
.../sources/bitbucketRepositoryInput.ts | 89 ++++
src/services/sources/bitbucketService.ts | 292 +++++++++++++
.../a11y/SourceConnectModal.a11y.test.tsx | 6 +
.../components/SourceDetailsPanel.test.tsx | 10 +-
.../unit/features/data-ingestion/data.test.ts | 2 +-
.../services/bitbucketRepositoryInput.test.ts | 87 ++++
tests/unit/services/bitbucketService.test.ts | 382 ++++++++++++++++++
20 files changed, 951 insertions(+), 18 deletions(-)
create mode 100644 src/components/icons/BitbucketIcon.tsx
create mode 100644 src/components/icons/types.ts
create mode 100644 src/services/sources/bitbucketRepositoryInput.ts
create mode 100644 src/services/sources/bitbucketService.ts
create mode 100644 tests/unit/services/bitbucketRepositoryInput.test.ts
create mode 100644 tests/unit/services/bitbucketService.test.ts
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/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/wizard/sources/AddSourceFlow.tsx b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx
index 44394294e..26b7e6eea 100644
--- a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx
+++ b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx
@@ -568,6 +568,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.",
};
/**
diff --git a/src/features/admin/components/wizard/steps/WizardReviewStep.tsx b/src/features/admin/components/wizard/steps/WizardReviewStep.tsx
index f8cbdf86f..b4ddba6c2 100644
--- a/src/features/admin/components/wizard/steps/WizardReviewStep.tsx
+++ b/src/features/admin/components/wizard/steps/WizardReviewStep.tsx
@@ -1,5 +1,6 @@
import { GitBranch, Ticket, FileText, BookOpen } from "lucide-react";
import type { ComponentType, ReactNode } from "react";
+import { BitbucketIcon } from "../../../../../components/icons/BitbucketIcon";
import { UserAvatar } from "../../../../../components/common/UserAvatar";
import { SourceTypeBadge } from "../../../../data-ingestion/components/SourceTypeBadge";
import type { DraftSource, DraftSourceType } from "../../../projectSourcesDraft";
@@ -30,6 +31,7 @@ const typeIcons: Record>
JIRA: Ticket,
UPLOAD: FileText,
CONFLUENCE: BookOpen,
+ BITBUCKET: BitbucketIcon,
};
const typeLabels: Record = {
@@ -37,6 +39,7 @@ const typeLabels: Record = {
JIRA: "Jira",
UPLOAD: "Upload",
CONFLUENCE: "Confluence",
+ BITBUCKET: "Bitbucket",
};
function sourceTitle(source: DraftSource): string {
diff --git a/src/features/admin/projectSourcesDraft.ts b/src/features/admin/projectSourcesDraft.ts
index c95f739cf..86ff52f46 100644
--- a/src/features/admin/projectSourcesDraft.ts
+++ b/src/features/admin/projectSourcesDraft.ts
@@ -25,7 +25,7 @@ import type { DiscoverySelection } from "../data-ingestion/components/GithubRepo
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 = {
diff --git a/src/features/data-ingestion/components/AddSourceModal.tsx b/src/features/data-ingestion/components/AddSourceModal.tsx
index 3541bf20e..fdffb90d3 100644
--- a/src/features/data-ingestion/components/AddSourceModal.tsx
+++ b/src/features/data-ingestion/components/AddSourceModal.tsx
@@ -36,6 +36,11 @@ import type { SourceSystem } from "../types.ts";
import type { DiscoverySelection } from "./GithubRepositoryDiscovery.tsx";
import type { AtlassianCredentialDto } from "../../../services/sources/atlassianService.ts";
+// Bitbucket shows up in the type grid as "Soon" until its add-source flow exists.
+const AVAILABLE_SOURCE_TYPES: SourceSystem[] = SOURCE_SYSTEMS.filter(
+ (system) => system !== "BITBUCKET",
+);
+
type AddSourceModalProps = {
projectId: string | null;
projectName?: string;
@@ -230,6 +235,9 @@ export function AddSourceModal({
};
const handleSelectAddType = (type: SourceSystem) => {
+ // A "Soon" type (not yet wired) is inert until its phase lands.
+ if (!AVAILABLE_SOURCE_TYPES.includes(type)) return;
+
setAddType(type);
setAddStep("detail");
};
@@ -558,7 +566,7 @@ export function AddSourceModal({
key={addFlowKey}
step={addStep}
selectedType={addType}
- availableTypes={SOURCE_SYSTEMS}
+ availableTypes={AVAILABLE_SOURCE_TYPES}
onSelectType={handleSelectAddType}
onBack={backToTypeGrid}
isBusy={isSubmitting}
diff --git a/src/features/data-ingestion/data.ts b/src/features/data-ingestion/data.ts
index d0d0b9578..3257d720d 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;
diff --git a/src/features/data-ingestion/types.ts b/src/features/data-ingestion/types.ts
index 38c24bcab..fab9ae34e 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"
@@ -255,7 +256,7 @@ export type SourceStatusPresentation = {
export type SourceMeta = {
name: string;
type: string;
- icon: LucideIcon;
+ icon: IconComponent;
description: string;
};
@@ -285,7 +286,7 @@ export type SourceDetailsSource = {
};
export type DataSource = SourceDetailsSource & {
- icon: LucideIcon;
+ icon: IconComponent;
statusLabel: string;
ingestionStatus: SourceStatus;
ingestionStatusLabel: string;
diff --git a/src/features/knowledge-base/components/ArtifactFilters.tsx b/src/features/knowledge-base/components/ArtifactFilters.tsx
index eea194cf6..28d86e47e 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";
@@ -124,6 +125,7 @@ const SOURCE_ICONS: Record = {
GITHUB: ,
JIRA: ,
CONFLUENCE: ,
+ BITBUCKET: ,
UPLOAD: ,
};
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/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/bitbucketRepositoryInput.ts b/src/services/sources/bitbucketRepositoryInput.ts
new file mode 100644
index 000000000..b511ba1d8
--- /dev/null
+++ b/src/services/sources/bitbucketRepositoryInput.ts
@@ -0,0 +1,89 @@
+/**
+ * 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`.
+ */
+
+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 path segments.
+ */
+function toPathSegments(value: string): string[] {
+ return value
+ .trim()
+ .replace(/^(?:https?|ssh):\/\/(?:[^@/]+@)?bitbucket\.org\//i, "")
+ .replace(/^bitbucket\.org\//i, "")
+ .replace(/^git@bitbucket\.org:/i, "")
+ .replace(/^\/+/, "")
+ .split("/")
+ .filter((segment) => segment.length > 0);
+}
+
+/**
+ * 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,
+ slug: trimmedSlugInput,
+ };
+ }
+
+ return null;
+}
diff --git a/src/services/sources/bitbucketService.ts b/src/services/sources/bitbucketService.ts
new file mode 100644
index 000000000..73a07df2a
--- /dev/null
+++ b/src/services/sources/bitbucketService.ts
@@ -0,0 +1,292 @@
+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. An
+ * unreachable repository currently surfaces as a 500 with no dedicated handler.
+ */
+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/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/features/data-ingestion/components/SourceDetailsPanel.test.tsx b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
index e3037e3ec..dfa4fbc1c 100644
--- a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
+++ b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
@@ -510,7 +510,7 @@ describe("SourceDetailsPanel", () => {
statusView: {
state: "syncing",
label: "Syncing",
- icon: mockSource.icon,
+ icon: GitBranch,
tone: "brand",
spinning: true,
},
@@ -530,7 +530,7 @@ describe("SourceDetailsPanel", () => {
statusView: {
state: "syncing",
label: "Syncing",
- icon: mockSource.icon,
+ icon: GitBranch,
tone: "brand",
spinning: true,
},
@@ -551,7 +551,7 @@ describe("SourceDetailsPanel", () => {
statusView: {
state: "syncing",
label: "Syncing",
- icon: mockSource.icon,
+ icon: GitBranch,
tone: "brand",
spinning: true,
},
@@ -613,7 +613,7 @@ describe("SourceDetailsPanel", () => {
statusView: {
state: "syncing",
label: "Syncing",
- icon: mockSource.icon,
+ icon: GitBranch,
tone: "brand",
spinning: true,
},
@@ -642,7 +642,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/data.test.ts b/tests/unit/features/data-ingestion/data.test.ts
index 4f7b2b1c4..9948f07c5 100644
--- a/tests/unit/features/data-ingestion/data.test.ts
+++ b/tests/unit/features/data-ingestion/data.test.ts
@@ -33,7 +33,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", () => {
diff --git a/tests/unit/services/bitbucketRepositoryInput.test.ts b/tests/unit/services/bitbucketRepositoryInput.test.ts
new file mode 100644
index 000000000..4d4b9151b
--- /dev/null
+++ b/tests/unit/services/bitbucketRepositoryInput.test.ts
@@ -0,0 +1,87 @@
+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();
+ });
+});
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",
+ });
+ });
+});
From a973761cc62af557551fd5a09c045fafaed3ee8c Mon Sep 17 00:00:00 2001
From: kiranfin <4finjas@online.de>
Date: Wed, 30 Sep 2026 15:37:06 +0200
Subject: [PATCH 2/9] Add Bitbucket source cards, run labels and run-history
filter - Add BitbucketRepositoryDetails and
createBitbucketSourceFromInstance, built from the status row alone - Append
Bitbucket cards on the data ingestion page, skip BITBUCKET project sources,
filter runs by repositoryId - Show the workspace on cards and in the run
details, and extend run source labels for workspace/slug
---
.../components/RunDetailsPanel.tsx | 13 +-
.../data-ingestion/components/SourceList.tsx | 6 +
src/features/data-ingestion/data.ts | 79 +++++++++-
src/features/data-ingestion/types.ts | 29 +++-
src/pages/DataIngestionPage.tsx | 52 +++++--
.../components/RunDetailsPanel.test.tsx | 32 ++++
.../components/SourceList.test.tsx | 25 +++
.../unit/features/data-ingestion/data.test.ts | 146 ++++++++++++++++++
tests/unit/pages/DataIngestionPage.test.tsx | 123 +++++++++++++++
9 files changed, 486 insertions(+), 19 deletions(-)
diff --git a/src/features/data-ingestion/components/RunDetailsPanel.tsx b/src/features/data-ingestion/components/RunDetailsPanel.tsx
index ed693cc76..75224ccbf 100644
--- a/src/features/data-ingestion/components/RunDetailsPanel.tsx
+++ b/src/features/data-ingestion/components/RunDetailsPanel.tsx
@@ -174,10 +174,10 @@ export function RunDetailsPanel({ run, sourceLabel, onClose }: RunDetailsPanelPr
/**
* The connector-specific origin of a run, shown next to the repository in the
- * details panel: the GitHub owner (from `run.owner`, falling back to the first
- * segment of `"owner/name"`) or the Jira instance domain (from the instance URL
- * in `run.sourceId`). Both are already carried on the run, so no extra fetch is
- * needed. Returns null for uploads and runs with no attributable origin.
+ * details panel: the GitHub owner or Bitbucket workspace (from `run.owner`,
+ * falling back to the first segment of `"owner/name"`) or the Jira instance
+ * domain (from the instance URL in `run.sourceId`). All are already carried on
+ * the run, so no extra fetch is needed. Returns null for uploads and runs with no attributable origin.
*/
function buildOriginRow(run: IngestionRun): { label: string; value: string } | null {
if (run.sourceSystem === "JIRA") {
@@ -189,6 +189,11 @@ function buildOriginRow(run: IngestionRun): { label: string; value: string } | n
return owner ? { label: "Owner", value: owner } : null;
}
+ if (run.sourceSystem === "BITBUCKET") {
+ const workspace = run.owner ?? run.sourceId?.split("/")[0] ?? null;
+ return workspace ? { label: "Workspace", value: workspace } : null;
+ }
+
if (run.sourceSystem === "CONFLUENCE") {
return run.sourceId ? { label: "Space", value: run.sourceId } : null;
}
diff --git a/src/features/data-ingestion/components/SourceList.tsx b/src/features/data-ingestion/components/SourceList.tsx
index 773beee54..3dd2bd4bb 100644
--- a/src/features/data-ingestion/components/SourceList.tsx
+++ b/src/features/data-ingestion/components/SourceList.tsx
@@ -105,6 +105,12 @@ export function SourceList({
)}
+ {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 3257d720d..62cd09d55 100644
--- a/src/features/data-ingestion/data.ts
+++ b/src/features/data-ingestion/data.ts
@@ -200,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.
@@ -712,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 {
@@ -727,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 fab9ae34e..45187df0a 100644
--- a/src/features/data-ingestion/types.ts
+++ b/src/features/data-ingestion/types.ts
@@ -98,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;
@@ -177,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
@@ -281,6 +300,8 @@ 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;
};
diff --git a/src/pages/DataIngestionPage.tsx b/src/pages/DataIngestionPage.tsx
index 206ab7cfb..3424ba5d8 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,
@@ -145,8 +146,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 +162,8 @@ function toSourceSystem(value: string): SourceSystem | null {
normalized === "GITHUB" ||
normalized === "JIRA" ||
normalized === "UPLOAD" ||
- normalized === "CONFLUENCE"
+ normalized === "CONFLUENCE" ||
+ normalized === "BITBUCKET"
) {
return normalized;
}
@@ -268,8 +270,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")) {
@@ -634,11 +639,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 +828,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,
@@ -926,7 +948,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 +963,16 @@ export function DataIngestionPage() {
];
}
+ if (source.bitbucketRepository?.repositoryId) {
+ return [
+ {
+ value: source.bitbucketRepository.repositoryId,
+ label: source.name,
+ sourceSystem: "BITBUCKET",
+ },
+ ];
+ }
+
if (source.sourceSystem === "JIRA") {
return [
{
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/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 9948f07c5..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,
@@ -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/pages/DataIngestionPage.test.tsx b/tests/unit/pages/DataIngestionPage.test.tsx
index 939ec2b7b..61df3746f 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
From 8b2bab96ed52f29eef95ee209dc134ec87b6f056 Mon Sep 17 00:00:00 2001
From: kiranfin <4finjas@online.de>
Date: Wed, 30 Sep 2026 15:45:20 +0200
Subject: [PATCH 3/9] Add Bitbucket details drawer actions and global sync
settings - Add a Bitbucket repository card with enable toggle, update, remove
from project and per-repo sync schedule to the source drawer - Wire update,
enable/disable via patchConnectorSources("bitbucket"), unlink and config
load/save into DataIngestionPage - Add a Bitbucket tab to the global sync
settings modal, saved through configureAllBitbucketRepositories
---
.../components/SourceDetailsPanel.tsx | 131 +++++++++-
src/pages/DataIngestionPage.tsx | 107 ++++++++-
.../a11y/SourceDetailsPanel.a11y.test.tsx | 48 ++++
.../components/SourceDetailsPanel.test.tsx | 226 ++++++++++++++++++
tests/unit/pages/DataIngestionPage.test.tsx | 165 +++++++++++++
5 files changed, 665 insertions(+), 12 deletions(-)
diff --git a/src/features/data-ingestion/components/SourceDetailsPanel.tsx b/src/features/data-ingestion/components/SourceDetailsPanel.tsx
index 8d050c3b3..9c76dc748 100644
--- a/src/features/data-ingestion/components/SourceDetailsPanel.tsx
+++ b/src/features/data-ingestion/components/SourceDetailsPanel.tsx
@@ -12,6 +12,7 @@ import {
type LucideIcon,
} from "lucide-react";
import { useCallback, useMemo, useState, type ReactNode } from "react";
+import { BitbucketIcon } from "../../../components/icons/BitbucketIcon";
import { Button } from "../../../components/ui/Button";
import { Spinner } from "../../../components/ui/Spinner";
import { useToast } from "../../../context/useToast";
@@ -56,6 +57,15 @@ type SourceDetailsPanelProps = {
repository: NonNullable,
request: ConfigureGithubRepositoryRequest,
) => Promise;
+ /** Loads the sync schedule of a Bitbucket repository (by workspace and slug). */
+ onLoadBitbucketConfig?: (
+ repository: NonNullable,
+ ) => Promise;
+ /** Saves the sync schedule of a Bitbucket repository (by workspace and slug). */
+ onSaveBitbucketConfig?: (
+ repository: NonNullable,
+ request: ConfigureGithubRepositoryRequest,
+ ) => Promise;
/** Loads the sync schedule of a Jira instance (by URL) for the schedule form. */
onLoadJiraConfig?: (instanceUrl: string) => Promise;
/** Saves the sync schedule of a Jira instance (by URL). */
@@ -75,6 +85,11 @@ type SourceDetailsPanelProps = {
repository: NonNullable,
enabled: boolean,
) => Promise;
+ /** Enables/disables a Bitbucket repository as an ingestion source (by `workspace/slug`). */
+ onSetBitbucketSourceEnabled?: (
+ repository: NonNullable,
+ enabled: boolean,
+ ) => Promise;
/** Enables/disables a Jira instance as an ingestion source (by instance URL). */
onSetJiraSourceEnabled?: (instanceUrl: string, enabled: boolean) => Promise;
/**
@@ -96,11 +111,14 @@ export function SourceDetailsPanel({
canManageSyncSettings = false,
onLoadRepositoryConfig,
onSaveRepositoryConfig,
+ onLoadBitbucketConfig,
+ onSaveBitbucketConfig,
onLoadJiraConfig,
onSaveJiraConfig,
onLoadConfluenceConfig,
onSaveConfluenceConfig,
onSetSourceEnabled,
+ onSetBitbucketSourceEnabled,
onSetJiraSourceEnabled,
onUnlinkSource,
onClose,
@@ -113,8 +131,10 @@ export function SourceDetailsPanel({
const toast = useToast();
const Icon = SOURCE_META[source.sourceSystem].icon;
const repository = source.githubRepository;
+ const bitbucket = source.bitbucketRepository ?? null;
const jira = source.jiraInstance ?? null;
const confluence = source.confluenceSpace ?? null;
+ const isBitbucket = source.sourceSystem === "BITBUCKET";
const isJira = source.sourceSystem === "JIRA";
const isConfluence = source.sourceSystem === "CONFLUENCE";
const isUpdating = updateState === "loading";
@@ -125,11 +145,13 @@ export function SourceDetailsPanel({
const [dinoActive, closeDino] = useSpaceOpensDino(isSyncing, dinoUnlocked, {
keepActiveUntilExit: true,
});
- // Update is available for a GitHub repo (needs owner/name), a Jira instance
- // (needs its URL), or a Confluence space (needs its ID).
+ // Update is available for a GitHub repo (needs owner/name), a Bitbucket repo
+ // (needs its connection id), a Jira instance (needs its URL), or a Confluence
+ // space (needs its ID).
const canUpdate =
onUpdateSource !== undefined &&
((source.sourceSystem === "GITHUB" && repository !== null) ||
+ (isBitbucket && Boolean(bitbucket?.repositoryId)) ||
(isJira && jira !== null) ||
(isConfluence && Boolean(confluence?.connectionId)));
// GitHub and Jira start an asynchronous run, so "Update started" is the whole
@@ -143,6 +165,17 @@ export function SourceDetailsPanel({
repository !== null &&
onLoadRepositoryConfig !== undefined &&
onSaveRepositoryConfig !== undefined;
+ const canManageBitbucketConfig =
+ canManageSyncSettings &&
+ isBitbucket &&
+ bitbucket !== null &&
+ onLoadBitbucketConfig !== undefined &&
+ onSaveBitbucketConfig !== undefined;
+ const canToggleBitbucketEnabled =
+ canManageSyncSettings &&
+ isBitbucket &&
+ bitbucket !== null &&
+ onSetBitbucketSourceEnabled !== undefined;
const canManageJiraConfig =
canManageSyncSettings &&
isJira &&
@@ -168,13 +201,14 @@ export function SourceDetailsPanel({
const isTogglingEnabled = enabledState === "loading";
// Authorization is presence-based — the parent only passes onUnlinkSource when
// the caller may manage the project's sources. GitHub needs the connection's
- // repositoryId; Jira is identified by its instance URL, Confluence by its
- // connection id.
+ // repositoryId, as does Bitbucket; Jira is identified by its instance URL,
+ // Confluence by its connection id.
const canUnlinkSource =
onUnlinkSource !== undefined &&
((source.sourceSystem === "GITHUB" &&
repository !== null &&
repository.repositoryId !== null) ||
+ (isBitbucket && Boolean(bitbucket?.repositoryId)) ||
(isJira && jira !== null) ||
(isConfluence && Boolean(confluence?.connectionId)));
const isUnlinking = unlinkState === "loading";
@@ -217,6 +251,26 @@ export function SourceDetailsPanel({
[onSetSourceEnabled, repository, toast],
);
+ const handleToggleBitbucketEnabled = useCallback(
+ async (enabled: boolean) => {
+ if (!bitbucket || !onSetBitbucketSourceEnabled) return;
+
+ setEnabledState("loading");
+
+ try {
+ await onSetBitbucketSourceEnabled(bitbucket, enabled);
+ setEnabledState("success");
+ toast.success(enabled ? "Source enabled" : "Source disabled", {
+ description: enabled ? "Included in ingestion again." : "Excluded from ingestion.",
+ });
+ } catch (error) {
+ setEnabledState("error");
+ toast.error(error instanceof Error ? error.message : "Couldn't update the source.");
+ }
+ },
+ [bitbucket, onSetBitbucketSourceEnabled, toast],
+ );
+
const handleToggleJiraEnabled = useCallback(
async (enabled: boolean) => {
if (!jira || !onSetJiraSourceEnabled) return;
@@ -362,6 +416,8 @@ export function SourceDetailsPanel({
icon={
isJira ? (
+ ) : isBitbucket ? (
+
) : isConfluence ? (
) : (
@@ -373,9 +429,11 @@ export function SourceDetailsPanel({
? undefined
: isJira
? "Instance updates need the Jira instance URL."
- : isConfluence
- ? "Space updates need the Confluence space ID."
- : "Repository updates need GitHub owner and repository name."
+ : isBitbucket
+ ? "Repository updates need the Bitbucket connection."
+ : isConfluence
+ ? "Space updates need the Confluence space ID."
+ : "Repository updates need GitHub owner and repository name."
}
>
{isJira ? "Update instance" : isConfluence ? "Update space" : "Update repo"}
@@ -500,6 +558,45 @@ export function SourceDetailsPanel({
)}
+ {isBitbucket && (
+
+
+
+
+
+
+
+ {canToggleBitbucketEnabled && bitbucket ? (
+
+
- Source
+
-
+
+ Include in ingestion
+
+ · sync this repository into the knowledge base
+
+
+ {
+ void handleToggleBitbucketEnabled(next);
+ }}
+ />
+
+
+ ) : (
+
+ )}
+
+
+ )}
+
{source.sourceSystem === "GITHUB" && (
@@ -543,6 +640,11 @@ export function SourceDetailsPanel({
{isJira ? (
+ ) : isBitbucket ? (
+
) : (
<>
@@ -567,6 +669,21 @@ export function SourceDetailsPanel({
)}
+ {canManageBitbucketConfig && bitbucket && onLoadBitbucketConfig && onSaveBitbucketConfig && (
+
+ {/* Same control as GitHub: the Bitbucket repository schedule shares the
+ identical schedule contract, only the load/save endpoints differ. */}
+ onLoadBitbucketConfig(bitbucket)}
+ onSave={(request) => onSaveBitbucketConfig(bitbucket, request)}
+ autoUpdateOnText="Due checks update this Bitbucket repository."
+ autoUpdateOffText="Due checks only mark this Bitbucket repository out of date."
+ toggleAriaLabel="Toggle Bitbucket repository auto update"
+ />
+
+ )}
+
{canManageJiraConfig && jira && onLoadJiraConfig && onSaveJiraConfig && (
{/* Same control as GitHub: the Jira instance sync schedule shares the
diff --git a/src/pages/DataIngestionPage.tsx b/src/pages/DataIngestionPage.tsx
index 3424ba5d8..98fe42425 100644
--- a/src/pages/DataIngestionPage.tsx
+++ b/src/pages/DataIngestionPage.tsx
@@ -46,6 +46,7 @@ import {
} from "../features/data-ingestion/data.ts";
import type {
BackendProjectSourceStatus,
+ BitbucketRepositoryDetails,
DataSource,
GithubRepositoryDetails,
IngestionRun,
@@ -63,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,
@@ -104,7 +112,12 @@ 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`
@@ -116,11 +129,21 @@ const SYNC_SETTINGS_COPY: Record<
{ label: string; one: string; many: string }
> = {
github: { label: "GitHub", one: "GitHub repository", many: "GitHub repositories" },
+ bitbucket: {
+ label: "Bitbucket",
+ one: "Bitbucket repository",
+ many: "Bitbucket repositories",
+ },
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.
@@ -486,6 +509,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] =
@@ -899,6 +924,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
@@ -906,6 +932,7 @@ export function DataIngestionPage() {
const syncSettingsProviders = useMemo[]>(() => {
const hasSources: Record = {
github: hasGithubSources,
+ bitbucket: hasBitbucketSources,
jira: hasJiraSources,
confluence: hasConfluenceSources,
};
@@ -913,7 +940,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(() => {
@@ -1163,6 +1190,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.");
}
@@ -1187,6 +1225,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);
@@ -1234,10 +1281,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<
@@ -1245,6 +1298,7 @@ export function DataIngestionPage() {
(request: ConfigureGithubRepositoryRequest) => Promise
> = {
github: handleSaveGlobalGithubConfig,
+ bitbucket: handleSaveGlobalBitbucketConfig,
jira: handleSaveGlobalJiraConfig,
confluence: handleSaveGlobalConfluenceConfig,
};
@@ -1267,6 +1321,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),
[],
@@ -1340,6 +1408,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.
@@ -1353,7 +1433,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(
@@ -1384,6 +1464,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) {
@@ -1637,11 +1731,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}
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/data-ingestion/components/SourceDetailsPanel.test.tsx b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
index dfa4fbc1c..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(
{
});
});
+ 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(
From c51299c151efaa009aff1d42929e1f98b487b0bc Mon Sep 17 00:00:00 2001
From: kiranfin <4finjas@online.de>
Date: Wed, 30 Sep 2026 15:55:15 +0200
Subject: [PATCH 4/9] Extract provider-neutral RepositoryDiscovery from the
GitHub discovery
- Move search, paging, multi-select and link-state handling into RepositoryDiscovery, driven by an adapter and a credential picker slot
- Filter the already-ingested and in-project lookups by source system so a Bitbucket repo never counts as a linkable GitHub repo
- Reduce GithubRepositoryDiscovery to a thin wrapper with the same props and re-exported types
---
.../components/GithubRepositoryDiscovery.tsx | 582 ++--------------
.../components/RepositoryDiscovery.tsx | 624 ++++++++++++++++++
.../components/RepositoryDiscovery.test.tsx | 348 ++++++++++
3 files changed, 1043 insertions(+), 511 deletions(-)
create mode 100644 src/features/data-ingestion/components/RepositoryDiscovery.tsx
create mode 100644 tests/unit/features/data-ingestion/components/RepositoryDiscovery.test.tsx
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
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/ArtifactViewerDrawer.tsx b/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx
index a6c2ff24f..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";
@@ -837,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
*
@@ -1182,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" && (
@@ -1304,7 +1433,11 @@ export function ArtifactViewerDrawer({
) : viewMode === "raw" && artifact?.artifactType === "ORG_METADATA" ? (
-
+ {isBitbucketWorkspace ? (
+
+ ) : (
+
+ )}
) : viewMode === "raw" ? (
diff --git a/src/pages/DataIngestionPage.tsx b/src/pages/DataIngestionPage.tsx
index 98fe42425..0f61f9e45 100644
--- a/src/pages/DataIngestionPage.tsx
+++ b/src/pages/DataIngestionPage.tsx
@@ -120,19 +120,25 @@ const DEFAULT_GLOBAL_BITBUCKET_SYNC_CONFIG: ConfigureGithubRepositoryRequest = {
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" },
@@ -1815,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/sources/bitbucketRepositoryInput.ts b/src/services/sources/bitbucketRepositoryInput.ts
index b511ba1d8..1dfb89cc0 100644
--- a/src/services/sources/bitbucketRepositoryInput.ts
+++ b/src/services/sources/bitbucketRepositoryInput.ts
@@ -4,6 +4,11 @@
* 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 = {
@@ -13,7 +18,8 @@ export type BitbucketRepositoryReference = {
/**
* 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 path segments.
+ * `user@`) or an SSH remote, and the leading slashes, leaving the lowercased
+ * path segments.
*/
function toPathSegments(value: string): string[] {
return value
@@ -23,7 +29,8 @@ function toPathSegments(value: string): string[] {
.replace(/^git@bitbucket\.org:/i, "")
.replace(/^\/+/, "")
.split("/")
- .filter((segment) => segment.length > 0);
+ .filter((segment) => segment.length > 0)
+ .map((segment) => segment.toLowerCase());
}
/**
@@ -80,8 +87,8 @@ export function parseBitbucketRepositoryInput(
if (trimmedWorkspaceInput && trimmedSlugInput) {
return {
- workspace: trimmedWorkspaceInput,
- slug: trimmedSlugInput,
+ workspace: trimmedWorkspaceInput.toLowerCase(),
+ slug: trimmedSlugInput.toLowerCase(),
};
}
diff --git a/src/services/sources/bitbucketService.ts b/src/services/sources/bitbucketService.ts
index 73a07df2a..1d000fc46 100644
--- a/src/services/sources/bitbucketService.ts
+++ b/src/services/sources/bitbucketService.ts
@@ -127,8 +127,9 @@ function repositoryPath({ workspace, slug }: BitbucketRepositoryReference): stri
* 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. An
- * unreachable repository currently surfaces as a 500 with no dedicated handler.
+ * @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,
diff --git a/tests/unit/a11y/BitbucketRepositoryDiscovery.a11y.test.tsx b/tests/unit/a11y/BitbucketRepositoryDiscovery.a11y.test.tsx
index 2e69acec1..9fd9a7dac 100644
--- a/tests/unit/a11y/BitbucketRepositoryDiscovery.a11y.test.tsx
+++ b/tests/unit/a11y/BitbucketRepositoryDiscovery.a11y.test.tsx
@@ -48,7 +48,7 @@ describe("BitbucketRepositoryDiscovery Accessibility", () => {
{
workspace: "acme",
slug: "widgets",
- name: "Widgets",
+ name: "Widget Shop",
isPrivate: true,
url: "https://bitbucket.org/acme/widgets",
alreadyConnected: false,
@@ -72,7 +72,7 @@ describe("BitbucketRepositoryDiscovery Accessibility", () => {
await user.type(screen.getByLabelText("Workspace or bitbucket.org URL"), "acme");
await user.click(screen.getByRole("button", { name: "Discover" }));
- await screen.findByText("Widgets (widgets)");
+ await screen.findByText("Widget Shop (widgets)");
expect(await axe(container)).toHaveNoViolations();
});
diff --git a/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx b/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx
index 9796bed9d..388de664e 100644
--- a/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx
+++ b/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx
@@ -553,7 +553,7 @@ describe("AddSourceModal Bitbucket", () => {
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 (widgets)");
+ await screen.findByText("Widgets");
}
const rowOf = (text: string) => screen.getByText(text).closest("label") as HTMLElement;
@@ -608,7 +608,7 @@ describe("AddSourceModal Bitbucket", () => {
workspace: "acme",
query: { credentialName: "default", page: "0", pageSize: "20" },
});
- expect(within(rowOf("Widgets (widgets)")).getByText("Private")).toBeInTheDocument();
+ expect(within(rowOf("Widgets")).getByText("Private")).toBeInTheDocument();
expect(within(rowOf("gadgets")).getByText("Public")).toBeInTheDocument();
});
@@ -632,7 +632,7 @@ describe("AddSourceModal Bitbucket", () => {
expect(screen.getByRole("button", { name: /add to list/i })).toBeDisabled();
await discoverAcme(user);
- await user.click(within(rowOf("Widgets (widgets)")).getByRole("checkbox"));
+ await user.click(within(rowOf("Widgets")).getByRole("checkbox"));
expect(screen.getByRole("button", { name: /add to list/i })).toBeEnabled();
});
@@ -652,7 +652,7 @@ describe("AddSourceModal Bitbucket", () => {
await openBitbucketDetail(user);
await discoverAcme(user);
- await user.click(within(rowOf("Widgets (widgets)")).getByRole("checkbox"));
+ 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.
@@ -767,7 +767,7 @@ describe("AddSourceModal Bitbucket", () => {
await openBitbucketDetail(user);
await discoverAcme(user);
- await user.click(within(rowOf("Widgets (widgets)")).getByRole("checkbox"));
+ 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();
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/ArtifactViewerDrawer.test.tsx b/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx
index 38c092be5..0c9be73ed 100644
--- a/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx
+++ b/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx
@@ -967,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/services/bitbucketRepositoryInput.test.ts b/tests/unit/services/bitbucketRepositoryInput.test.ts
index 4d4b9151b..eec387fac 100644
--- a/tests/unit/services/bitbucketRepositoryInput.test.ts
+++ b/tests/unit/services/bitbucketRepositoryInput.test.ts
@@ -84,4 +84,18 @@ describe("parseBitbucketRepositoryInput", () => {
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");
+ });
});
From 72b72154ad85fb821a1dcf9821f1c430d756e71e Mon Sep 17 00:00:00 2001
From: kiranfin <4finjas@online.de>
Date: Thu, 1 Oct 2026 21:34:58 +0200
Subject: [PATCH 9/9] Bind repository discovery pagination to the results and
strip URL query from Bitbucket input
- Load more continues with the owner and credential that produced the results, a credential change resets results and selection, and the form is locked while a page loads
- Drop query string and fragment before splitting Bitbucket URLs so the slug stays clean
- Add tests for pagination binding, credential reset, locking and URL suffixes
---
.../components/RepositoryDiscovery.tsx | 82 +++++++++++++------
.../sources/bitbucketRepositoryInput.ts | 4 +-
.../components/RepositoryDiscovery.test.tsx | 78 +++++++++++++++++-
.../services/bitbucketRepositoryInput.test.ts | 19 +++++
4 files changed, 156 insertions(+), 27 deletions(-)
diff --git a/src/features/data-ingestion/components/RepositoryDiscovery.tsx b/src/features/data-ingestion/components/RepositoryDiscovery.tsx
index db88b933c..140d71724 100644
--- a/src/features/data-ingestion/components/RepositoryDiscovery.tsx
+++ b/src/features/data-ingestion/components/RepositoryDiscovery.tsx
@@ -172,9 +172,12 @@ export function RepositoryDiscovery({
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).
+ // The owner and credential that actually produced the current results. The
+ // owner is used at connect time (discovered repos carry only their name, not
+ // their owner); both are what "Load more" continues with, so editing the
+ // inputs afterwards cannot mix another owner's pages into these results.
const [resolvedOwner, setResolvedOwner] = useState("");
+ const [resolvedCredential, setResolvedCredential] = useState("");
const [repositories, setRepositories] = useState([]);
const [selected, setSelected] = useState>(new Set());
const [page, setPage] = useState(0);
@@ -191,7 +194,22 @@ export function RepositoryDiscovery({
>("idle");
const [discoverError, setDiscoverError] = useState(null);
- const isBusy = discoverState === "loading" || isConnecting;
+ const isDiscovering = discoverState === "loading" || discoverState === "loadingMore";
+ const isBusy = isDiscovering || isConnecting;
+
+ // Results belong to the credential that produced them: it is also what the
+ // parent connects with. Switching it drops the results and the selection, so a
+ // fresh discovery is needed before anything can be paged or connected.
+ if (resolvedCredential && resolvedCredential !== credentialName.trim()) {
+ setResolvedOwner("");
+ setResolvedCredential("");
+ setRepositories([]);
+ setSelected(new Set());
+ setPage(0);
+ setHasMore(false);
+ setFilter("");
+ setDiscoverState("idle");
+ }
// Discovery only reports *that* a repo is already a source, not its id or which
// projects it belongs to. The per-repo status endpoint supplies both, so an
@@ -236,34 +254,45 @@ export function RepositoryDiscovery({
const runDiscovery = useCallback(
async (nextPage: number) => {
- // The single field accepts an owner handle, a bare "owner/name", or a full
- // URL to either. When it carries a repository name we still discover the
- // owner but isolate that one repository in the results; a bare owner lists
- // all of them.
- const parsedInput = adapter.parseInput(ownerInput);
-
- if (!parsedInput) {
- setDiscoverState("error");
- setDiscoverError(adapter.invalidInputMessage);
- return;
- }
+ const loadingMore = nextPage > 0;
+ let owner = resolvedOwner;
+ let credential = resolvedCredential;
+ let repositoryName: string | null = null;
+
+ // "Load more" continues the discovery that produced the current results; it
+ // never re-reads the editable fields. Only a fresh discovery does that.
+ if (!loadingMore) {
+ // The single field accepts an owner handle, a bare "owner/name", or a
+ // full URL to either. When it carries a repository name we still
+ // discover the owner but isolate that one repository in the results; a
+ // bare owner lists all of them.
+ const parsedInput = adapter.parseInput(ownerInput);
+
+ if (!parsedInput) {
+ setDiscoverState("error");
+ setDiscoverError(adapter.invalidInputMessage);
+ return;
+ }
- const { owner, name: repositoryName } = parsedInput;
+ owner = parsedInput.owner;
+ repositoryName = parsedInput.name;
+ credential = credentialName.trim();
- if (!credentialName.trim()) {
- setDiscoverState("error");
- setDiscoverError(adapter.credentialRequiredMessage);
- return;
+ if (!credential) {
+ setDiscoverState("error");
+ setDiscoverError(adapter.credentialRequiredMessage);
+ return;
+ }
}
- const loadingMore = nextPage > 0;
setDiscoverState(loadingMore ? "loadingMore" : "loading");
setDiscoverError(null);
try {
- const result = await adapter.discover(owner, credentialName.trim(), nextPage, PAGE_SIZE);
+ const result = await adapter.discover(owner, credential, nextPage, PAGE_SIZE);
setResolvedOwner(owner);
+ setResolvedCredential(credential);
setHasMore(result.hasMore);
setPage(nextPage);
setRepositories((current) =>
@@ -288,7 +317,14 @@ export function RepositoryDiscovery({
);
}
},
- [adapter, credentialName, loadConnectedRepositories, ownerInput],
+ [
+ adapter,
+ credentialName,
+ loadConnectedRepositories,
+ ownerInput,
+ resolvedCredential,
+ resolvedOwner,
+ ],
);
const filteredRepositories = useMemo(() => {
@@ -605,7 +641,7 @@ export function RepositoryDiscovery({