diff --git a/src/components/icons/BitbucketIcon.tsx b/src/components/icons/BitbucketIcon.tsx new file mode 100644 index 000000000..fefa5f4aa --- /dev/null +++ b/src/components/icons/BitbucketIcon.tsx @@ -0,0 +1,24 @@ +import type { IconProps } from "./types.ts"; + +/** + * The Bitbucket logo as an inline SVG. lucide has no Bitbucket icon, so this + * takes the same props as one and fills with `currentColor`, which lets it sit + * next to the other source icons. The logo is a filled shape, so `strokeWidth` + * is accepted and ignored. + */ +export function BitbucketIcon({ className, "aria-hidden": ariaHidden, size }: IconProps) { + return ( + + + + ); +} diff --git a/src/components/icons/types.ts b/src/components/icons/types.ts new file mode 100644 index 000000000..cdb976627 --- /dev/null +++ b/src/components/icons/types.ts @@ -0,0 +1,16 @@ +import type { ComponentType } from "react"; + +/** The props every icon in the app is drawn with, lucide or hand-made. */ +export type IconProps = { + className?: string; + "aria-hidden"?: boolean | "true" | "false"; + size?: number | string; + strokeWidth?: number | string; +}; + +/** + * An icon component. Wider than `LucideIcon` so a brand logo that lucide does + * not ship (Bitbucket) can be used wherever a lucide icon is; every lucide icon + * still satisfies it. + */ +export type IconComponent = ComponentType; diff --git a/src/features/access/registry.ts b/src/features/access/registry.ts index 5501ca9a3..507a0df59 100644 --- a/src/features/access/registry.ts +++ b/src/features/access/registry.ts @@ -59,7 +59,8 @@ export const atlassianConnector = defineAccessConnector( noun: { one: "credential", many: "credentials" }, addLabel: "Add credential", emptyTitle: "No credentials yet", - emptyDescription: "Add an Atlassian API token to connect Jira instances and Confluence spaces.", + emptyDescription: + "Add an Atlassian API token to connect Jira instances, Confluence spaces and Bitbucket repositories.", useEntries: () => { const { credentials, loaded, error, isRefreshing, reload } = useAtlassianCredentials(); diff --git a/src/features/admin/components/CreateProjectWizard.tsx b/src/features/admin/components/CreateProjectWizard.tsx index a1d8f8f3a..04c61c343 100644 --- a/src/features/admin/components/CreateProjectWizard.tsx +++ b/src/features/admin/components/CreateProjectWizard.tsx @@ -14,6 +14,7 @@ import { addDraftSource, connectDraftSources, connectOutcomeDescription, + createBitbucketDraftFromDiscovery, createConfluenceDraft, createDraftSourceFromDiscovery, createJiraDraft, @@ -82,8 +83,14 @@ const STEP_INDEX: Record, number> = { review: 3, }; -// All four connectors can now be staged from the add-source sub-flow. -const AVAILABLE_SOURCE_TYPES: SourceSystem[] = ["GITHUB", "JIRA", "UPLOAD", "CONFLUENCE"]; +// All five connectors can now be staged from the add-source sub-flow. +const AVAILABLE_SOURCE_TYPES: SourceSystem[] = [ + "GITHUB", + "JIRA", + "UPLOAD", + "CONFLUENCE", + "BITBUCKET", +]; /** * Transactional create-project wizard: everything is drafted locally across the @@ -149,6 +156,9 @@ export function CreateProjectWizard({ } = useGithubTokens(); const effectiveTokenNames = tokensLoaded ? loadedTokenNames : tokenNames; + const [bitbucketSelection, setBitbucketSelection] = useState([]); + const [bitbucketCredentialName, setBitbucketCredentialName] = useState(""); + const [jiraDisplayName, setJiraDisplayName] = useState(""); const [jiraUrl, setJiraUrl] = useState(""); const [jiraCredentialName, setJiraCredentialName] = useState(""); @@ -198,8 +208,9 @@ export function CreateProjectWizard({ const isJiraDetail = isAddingSource && addStep === "detail" && addType === "JIRA"; const isConfluenceDetail = isAddingSource && addStep === "detail" && addType === "CONFLUENCE"; - // Jira and Confluence share the same Atlassian credential store, so one - // instance of the hook backs both detail screens' pickers. + const isBitbucketDetail = isAddingSource && addStep === "detail" && addType === "BITBUCKET"; + // Jira, Confluence and Bitbucket share the same Atlassian credential store, so + // one instance of the hook backs all three detail screens' pickers. const { credentials: jiraCredentials, loaded: jiraCredentialsLoaded, @@ -207,7 +218,7 @@ export function CreateProjectWizard({ isRefreshing: jiraCredentialsLoading, reload: reloadJiraCredentials, addCredentialLocally, - } = useAtlassianCredentials(isOpen && (isJiraDetail || isConfluenceDetail)); + } = useAtlassianCredentials(isOpen && (isJiraDetail || isConfluenceDetail || isBitbucketDetail)); // The token list arrives asynchronously; adopt the first token as soon as it // does (and heal a stale selection) so discovery is usable on the first open. @@ -221,10 +232,10 @@ export function CreateProjectWizard({ }); }, [effectiveTokenNames]); - // Same adoption pattern for the Jira and Confluence credential pickers: - // select the first stored credential once the list arrives, keeping a - // still-valid choice. Both fields share the list, so a credential just added - // from either detail screen is adopted here too. + // Same adoption pattern for the Jira, Confluence and Bitbucket credential + // pickers: select the first stored credential once the list arrives, keeping a + // still-valid choice. All fields share the list, so a credential just added + // from any detail screen is adopted here too. useEffect(() => { if (!jiraCredentialsLoaded || jiraCredentialsLoading) return; @@ -241,6 +252,12 @@ export function CreateProjectWizard({ ? current : jiraCredentials[0].displayName; }); + setBitbucketCredentialName((current) => { + if (jiraCredentials.length === 0) return ""; + return current && jiraCredentials.some((credential) => credential.displayName === current) + ? current + : jiraCredentials[0].displayName; + }); }); }, [jiraCredentials, jiraCredentialsLoaded, jiraCredentialsLoading]); @@ -267,6 +284,11 @@ export function CreateProjectWizard({ void Promise.resolve().then(loadManagerCandidates); }, [isOpen, loadManagerCandidates]); + const resetBitbucketDraftFields = () => { + setBitbucketSelection([]); + setBitbucketCredentialName(""); + }; + const resetConfluenceDraftFields = () => { setConfluenceBaseUrl(""); setConfluenceSpaceId(""); @@ -288,6 +310,7 @@ export function CreateProjectWizard({ setAddStep("type"); setAddType("GITHUB"); setGithubSelection([]); + resetBitbucketDraftFields(); resetJiraDraftFields(); resetConfluenceDraftFields(); setUploadFiles([]); @@ -302,6 +325,7 @@ export function CreateProjectWizard({ const resetSourceDraftFields = () => { setGithubSelection([]); + resetBitbucketDraftFields(); resetJiraDraftFields(); resetConfluenceDraftFields(); setUploadFiles([]); @@ -451,6 +475,12 @@ export function CreateProjectWizard({ await reloadJiraCredentials(); }; + const handleBitbucketCredentialSaved = async (credential: AtlassianCredentialDto) => { + addCredentialLocally(credential); + setBitbucketCredentialName(credential.displayName); + await reloadJiraCredentials(); + }; + const handleConfluenceCredentialSaved = async (credential: AtlassianCredentialDto) => { addCredentialLocally(credential); setConfluenceCredentialName(credential.displayName); @@ -468,17 +498,19 @@ export function CreateProjectWizard({ const canAddSource = addType === "GITHUB" ? githubSelection.length > 0 - : addType === "JIRA" - ? Boolean(jiraDisplayName.trim() && jiraUrl.trim() && selectedJiraCredential) - : addType === "UPLOAD" - ? uploadFiles.length > 0 - : addType === "CONFLUENCE" - ? Boolean( - confluenceBaseUrl.trim() && - isValidConfluenceSpaceId(confluenceSpaceId) && - selectedConfluenceCredential, - ) - : false; + : addType === "BITBUCKET" + ? bitbucketSelection.length > 0 && Boolean(bitbucketCredentialName) + : addType === "JIRA" + ? Boolean(jiraDisplayName.trim() && jiraUrl.trim() && selectedJiraCredential) + : addType === "UPLOAD" + ? uploadFiles.length > 0 + : addType === "CONFLUENCE" + ? Boolean( + confluenceBaseUrl.trim() && + isValidConfluenceSpaceId(confluenceSpaceId) && + selectedConfluenceCredential, + ) + : false; const commitAddSource = () => { if (!canAddSource) return; @@ -491,6 +523,17 @@ export function CreateProjectWizard({ current, ), ); + } else if (addType === "BITBUCKET") { + setSources((current) => + bitbucketSelection.reduce( + (accumulated, selection) => + addDraftSource( + accumulated, + createBitbucketDraftFromDiscovery(selection, bitbucketCredentialName), + ), + current, + ), + ); } else if (addType === "JIRA" && selectedJiraCredential) { setSources((current) => addDraftSource( @@ -865,6 +908,17 @@ export function CreateProjectWizard({ onSelectionChange: setGithubSelection, onTokenSaved: handleTokenSaved, }} + bitbucket={{ + credentialName: bitbucketCredentialName, + credentials: jiraCredentials, + credentialsLoaded: jiraCredentialsLoaded, + credentialsLoading: jiraCredentialsLoading, + credentialsError: jiraCredentialsError, + defaultUserEmail: null, + onCredentialNameChange: setBitbucketCredentialName, + onSelectionChange: setBitbucketSelection, + onCredentialSaved: handleBitbucketCredentialSaved, + }} jira={{ displayName: jiraDisplayName, url: jiraUrl, diff --git a/src/features/admin/components/DrawerCard.tsx b/src/features/admin/components/DrawerCard.tsx index 7ed3463c3..56d34391c 100644 --- a/src/features/admin/components/DrawerCard.tsx +++ b/src/features/admin/components/DrawerCard.tsx @@ -1,12 +1,12 @@ import { motion, useReducedMotion } from "framer-motion"; -import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; +import type { IconComponent } from "../../../components/icons/types.ts"; type DrawerCardProps = { /** Uppercase section label shown in the card header. Omit for a headerless card. */ label?: string; - /** Optional lucide icon rendered in a soft tile beside the label. */ - icon?: LucideIcon; + /** Optional icon (lucide or a brand logo) rendered in a soft tile beside the label. */ + icon?: IconComponent; /** Position in the body stack, so cards reveal in sequence when the drawer opens. */ index?: number; /** Optional trailing content in the header row (counts, actions). */ diff --git a/src/features/admin/components/StagedSourceList.tsx b/src/features/admin/components/StagedSourceList.tsx index 8c7ad8705..9ef82994b 100644 --- a/src/features/admin/components/StagedSourceList.tsx +++ b/src/features/admin/components/StagedSourceList.tsx @@ -9,6 +9,7 @@ import { Trash2, Ticket, } from "lucide-react"; +import { BitbucketIcon } from "../../../components/icons/BitbucketIcon"; import { Button } from "../../../components/ui/Button"; import { FilterSelect } from "../../../components/ui/FilterSelect"; import { NO_OWNER_OPTION, type SourceOwnerOption } from "../sourceOwners"; @@ -60,6 +61,10 @@ function TypeIcon({ source }: { source: DraftSource }) { return ; } + if (source.type === "BITBUCKET") { + return ; + } + return ; } @@ -82,6 +87,7 @@ function StatusIcon({ source }: { source: DraftSource }) { /** Primary line: the human name of the source, by type. */ function sourceTitle(source: DraftSource): string { if (source.type === "GITHUB") return `${source.owner}/${source.name}`; + if (source.type === "BITBUCKET") return `${source.workspace}/${source.slug}`; return source.displayName; } @@ -89,7 +95,7 @@ function sourceTitle(source: DraftSource): string { /** * Secondary line shown when the source is not in a failed state: the instance * URL for Jira, the staged file count for an upload, or the credential for a - * GitHub repository. + * GitHub or Bitbucket repository. */ function sourceDetail(source: DraftSource): string { if (source.type === "UPLOAD") { @@ -104,12 +110,16 @@ function sourceDetail(source: DraftSource): string { return `${source.baseUrl} (${source.spaceId})`; } + if (source.type === "BITBUCKET") { + return source.credentialName; + } + return source.tokenName; } /** - * The status line under the title. A staged GitHub repository that is already - * ingested elsewhere is linked rather than fetched, so "Not connected yet" would + * The status line under the title. A staged GitHub or Bitbucket repository that + * is already ingested elsewhere is linked rather than fetched, so "Not connected yet" would * misdescribe it — it says so instead, before and after the run. * * Saying so afterwards matters as much as before: a linked source finishes @@ -118,7 +128,11 @@ function sourceDetail(source: DraftSource): string { * worked at all. */ function statusDescription(source: DraftSource): string { - if (source.status === "pending" && source.type === "GITHUB" && source.repositoryId) { + if ( + source.status === "pending" && + (source.type === "GITHUB" || source.type === "BITBUCKET") && + source.repositoryId + ) { return "Already ingested, will be linked"; } diff --git a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx index 44394294e..0f16d3f95 100644 --- a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx +++ b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx @@ -19,6 +19,7 @@ import { GithubRepositoryDiscovery, type DiscoverySelection, } from "../../../../data-ingestion/components/GithubRepositoryDiscovery"; +import { BitbucketRepositoryDiscovery } from "../../../../data-ingestion/components/BitbucketRepositoryDiscovery"; import { JiraConnectStep } from "../../../../data-ingestion/components/JiraConnectStep"; import { ConfluenceConnectStep } from "../../../../data-ingestion/components/ConfluenceConnectStep"; import { SourceTypeStep } from "../../../../data-ingestion/components/SourceTypeStep"; @@ -57,6 +58,28 @@ type GithubDetailProps = { projectName?: string; }; +/** Bitbucket detail is fully controlled so the footer's "Add to list" can read it. */ +type BitbucketDetailProps = { + credentialName: string; + credentials: AtlassianCredentialDto[]; + credentialsLoaded: boolean; + credentialsLoading: boolean; + credentialsError: string | null; + /** Prefill for the inline "add credential" form's account-email field. */ + defaultUserEmail: string | null; + onCredentialNameChange: (value: string) => void; + /** Must be stable (a state setter) — the picker only re-reports on change. */ + onSelectionChange: (selection: DiscoverySelection[]) => void; + /** Adds the new credential to the list, selects it, and reconciles with the server. */ + onCredentialSaved: (credential: AtlassianCredentialDto) => Promise; + /** + * Scopes discovery to an existing project so repositories already connected to + * it are flagged and cannot be staged twice. `null`/omitted (the create-project + * wizard, where no project exists yet) discovers without that scope. + */ + projectId?: string | null; +}; + /** Jira detail — a staged form; nothing connects until provisioning. */ type JiraDetailProps = { displayName: string; @@ -119,6 +142,12 @@ type AddSourceFlowProps = { onBack: () => void; isBusy?: boolean; github: GithubDetailProps; + /** + * The Bitbucket detail's state. Omitted by a flow that does not offer + * Bitbucket; selecting the type there renders nothing rather than falling + * through to another connector's screen. + */ + bitbucket?: BitbucketDetailProps; jira: JiraDetailProps; upload: UploadDetailProps; confluence: ConfluenceDetailProps; @@ -428,6 +457,53 @@ function GithubDetail({ ); } +/** Bitbucket detail with an "add credential" trigger above the discovery. */ +function BitbucketDetail({ + isBusy, + bitbucket, + onCompanionOpenChange, +}: { + isBusy: boolean; + bitbucket: BitbucketDetailProps; + onCompanionOpenChange?: (open: boolean) => void; +}) { + // Only hint "nothing stored" once the list has loaded, so the chip does not + // flash while credentials are still being fetched. + const missingCredential = bitbucket.credentialsLoaded && bitbucket.credentials.length === 0; + + return ( +
+ ( + + )} + /> + + +
+ ); +} + /** Jira detail with an "add credential" trigger above the form. */ function JiraDetail({ isBusy, @@ -568,6 +644,7 @@ const DETAIL_SUBTITLE: Record = { JIRA: "Point to your Jira instance and pick a credential, then add it to the list.", UPLOAD: "Files are staged now and uploaded right after the project is created.", CONFLUENCE: "Point to your Confluence space and pick a credential, then add it to the list.", + BITBUCKET: "Pick a credential and a workspace, then choose the repositories to index.", }; /** @@ -623,6 +700,7 @@ export function AddSourceFlow({ onBack, isBusy = false, github, + bitbucket, jira, upload, confluence, @@ -631,7 +709,15 @@ export function AddSourceFlow({ const prefersReducedMotion = useReducedMotion(); const detail = - selectedType === "JIRA" ? ( + selectedType === "BITBUCKET" ? ( + bitbucket ? ( + + ) : null + ) : selectedType === "JIRA" ? ( ) : selectedType === "UPLOAD" ? ( > JIRA: Ticket, UPLOAD: FileText, CONFLUENCE: BookOpen, + BITBUCKET: BitbucketIcon, }; const typeLabels: Record = { @@ -42,10 +44,14 @@ const typeLabels: Record = { JIRA: "Jira", UPLOAD: "Upload", CONFLUENCE: "Confluence", + BITBUCKET: "Bitbucket", }; function sourceTitle(source: DraftSource): string { - return source.type === "GITHUB" ? `${source.owner}/${source.name}` : source.displayName; + if (source.type === "GITHUB") return `${source.owner}/${source.name}`; + if (source.type === "BITBUCKET") return `${source.workspace}/${source.slug}`; + + return source.displayName; } function PersonChip({ person, suffix }: { person: ReviewPerson; suffix?: string }) { diff --git a/src/features/admin/projectSourcesDraft.ts b/src/features/admin/projectSourcesDraft.ts index c95f739cf..5bde156c8 100644 --- a/src/features/admin/projectSourcesDraft.ts +++ b/src/features/admin/projectSourcesDraft.ts @@ -2,6 +2,11 @@ import { addRepositoryToProject, connectGithubRepository, } from "../../services/sources/githubService"; +import { ApiError } from "../../services/apiClient"; +import { + addBitbucketRepositoryToProject, + connectBitbucketRepository, +} from "../../services/sources/bitbucketService"; import { connectJiraInstance } from "../../services/sources/jiraService"; import { confluenceService } from "../../services/sources/confluenceService"; import { knowledgeGapService } from "../../services/knowledgeGapService"; @@ -16,16 +21,17 @@ import type { DiscoverySelection } from "../data-ingestion/components/GithubRepo * outcome so a partial failure can be shown and retried per source instead of * failing the whole batch. * - * A source can be one of four kinds — a GitHub repository, a Jira instance, - * an in-memory file upload, or a Confluence space — modelled as a discriminated - * union on `type` so a single list can hold a mix of all four. Nothing here + * A source can be one of five kinds — a GitHub repository, a Bitbucket + * repository, a Jira instance, an in-memory file upload, or a Confluence space — + * modelled as a discriminated union on `type` so a single list can hold a mix of + * all of them. Nothing here * touches the backend until {@link connectDraftSources} runs during * provisioning; uploads in particular hold their `File[]` in memory until then. */ export type DraftSourceStatus = "pending" | "connecting" | "connected" | "failed"; -export type DraftSourceType = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE"; +export type DraftSourceType = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE" | "BITBUCKET"; /** Fields every staged source carries regardless of its type. */ type DraftSourceBase = { @@ -88,6 +94,20 @@ export type GithubDraftSource = DraftSourceBase & { repositoryId?: string; }; +export type BitbucketDraftSource = DraftSourceBase & { + type: "BITBUCKET"; + workspace: string; + slug: string; + /** Name of a stored Atlassian credential, shared with Jira and Confluence. */ + credentialName: string; + /** + * Set when the repository is already ingested elsewhere: connecting then only + * links it to the project (reusing its artifacts) instead of fetching and + * ingesting it again. Absent for genuinely new repositories. + */ + repositoryId?: string; +}; + export type JiraDraftSource = DraftSourceBase & { type: "JIRA"; displayName: string; @@ -114,7 +134,11 @@ export type ConfluenceDraftSource = DraftSourceBase & { }; export type DraftSource = - GithubDraftSource | JiraDraftSource | UploadDraftSource | ConfluenceDraftSource; + | GithubDraftSource + | BitbucketDraftSource + | JiraDraftSource + | UploadDraftSource + | ConfluenceDraftSource; let draftSourceCounter = 0; @@ -161,6 +185,44 @@ export function createDraftSourceFromDiscovery( ); } +export function createBitbucketDraft( + workspace: string, + slug: string, + credentialName: string, + repositoryId?: string, +): BitbucketDraftSource { + return { + id: nextDraftSourceId(), + type: "BITBUCKET", + workspace, + slug, + credentialName, + status: "pending", + errorMessage: "", + ownerAssignmentFailed: false, + wasReused: false, + repositoryId, + }; +} + +/** + * Stages a repository picked in the Bitbucket discovery flow. A `linkable` + * selection carries the repository id so it can be linked without re-ingesting; + * everything else is staged as a new repository to fetch and ingest. The + * selection's `owner` is the workspace and its `name` the repository slug. + */ +export function createBitbucketDraftFromDiscovery( + selection: DiscoverySelection, + credentialName: string, +): BitbucketDraftSource { + return createBitbucketDraft( + selection.owner, + selection.name, + credentialName, + selection.linkState === "linkable" ? selection.repositoryId : undefined, + ); +} + export function createJiraDraft(params: { displayName: string; url: string; @@ -226,7 +288,8 @@ export function createConfluenceDraft(params: { /** * Whether two drafts point at the same underlying source, used to dedupe on - * add. Identity is per type: GitHub by `owner/name`, Jira by instance URL; two + * add. Identity is per type: GitHub by `owner/name`, Bitbucket by + * `workspace/slug`, Jira by instance URL; two * uploads are always distinct (the same file can legitimately be staged twice). * Drafts of different types are never the same source. */ @@ -240,6 +303,13 @@ export function isSameSource(left: DraftSource, right: DraftSource): boolean { ); } + if (left.type === "BITBUCKET" && right.type === "BITBUCKET") { + return ( + left.workspace.toLowerCase() === right.workspace.toLowerCase() && + left.slug.toLowerCase() === right.slug.toLowerCase() + ); + } + if (left.type === "JIRA" && right.type === "JIRA") { return left.url.trim().toLowerCase() === right.url.trim().toLowerCase(); } @@ -431,6 +501,40 @@ async function connectOneDraftSource( }; } + if (source.type === "BITBUCKET") { + if (source.repositoryId) { + // Already ingested elsewhere: link it to this project, reusing its artifacts. + await addBitbucketRepositoryToProject(source.repositoryId, projectId); + + return { wasReused: true, ownerAssignmentFailed: false }; + } + + try { + await connectBitbucketRepository({ + workspace: source.workspace, + slug: source.slug, + credentialName: source.credentialName, + projectId, + }); + } catch (error) { + // A repository Bitbucket cannot find arrives as a 404 whose message names + // it. A failed Bitbucket call during the check (a rejected token, an + // outage) arrives as a bare 500, so say what the likely cause is instead + // of showing the status text. + if (error instanceof ApiError && error.status >= 500) { + throw new Error( + `Couldn't connect ${source.workspace}/${source.slug}. Check that the repository exists and that the selected credential can read it.`, + ); + } + + throw error; + } + + // The backend reports no `wasReused` for Bitbucket, so a connect that found + // an existing connection cannot be told apart from a fresh one. + return NOTHING_EXTRA; + } + if (source.type === "JIRA") { await connectJiraInstance({ displayName: source.displayName, diff --git a/src/features/chatbot/hooks/useAvailableSources.ts b/src/features/chatbot/hooks/useAvailableSources.ts index 0f22df66a..2fc3e05c5 100644 --- a/src/features/chatbot/hooks/useAvailableSources.ts +++ b/src/features/chatbot/hooks/useAvailableSources.ts @@ -10,7 +10,7 @@ const ALWAYS_AVAILABLE: readonly SourceSystem[] = ["UPLOAD"]; /** * Maps a backend connector id onto the source system the chat filters by. Connector ids are - * lowercase (`github`, `jira`, `confluence`); the filter values are the uppercase enum constants. + * lowercase (`github`, `jira`, `confluence`, `bitbucket`); the filter values are the uppercase enum constants. */ function toSourceSystem(connectorId: string): SourceSystem | null { const candidate = connectorId.toUpperCase(); diff --git a/src/features/chatbot/types.ts b/src/features/chatbot/types.ts index 77145675c..96b1d8eca 100644 --- a/src/features/chatbot/types.ts +++ b/src/features/chatbot/types.ts @@ -184,6 +184,6 @@ export type StreamHandlers = { onError?: (message: string) => void; }; -export const SOURCE_SYSTEMS = ["GITHUB", "UPLOAD", "JIRA", "CONFLUENCE"] as const; +export const SOURCE_SYSTEMS = ["GITHUB", "UPLOAD", "JIRA", "CONFLUENCE", "BITBUCKET"] as const; export type SourceSystem = (typeof SOURCE_SYSTEMS)[number]; diff --git a/src/features/connectors/data.ts b/src/features/connectors/data.ts index 8e0ccfcf5..75532aa40 100644 --- a/src/features/connectors/data.ts +++ b/src/features/connectors/data.ts @@ -1,4 +1,5 @@ import { BookOpen, GitBranch, Plug } from "lucide-react"; +import { BitbucketIcon } from "../../components/icons/BitbucketIcon.tsx"; import type { ConnectorDto } from "../../services/connectorService.ts"; import type { ConnectorListItem, ConnectorMeta } from "./types.ts"; @@ -12,6 +13,11 @@ const CONNECTOR_META: Record = { "Commits, files, issues and pull request metadata from connected GitHub repositories.", icon: GitBranch, }, + bitbucket: { + label: "Bitbucket Repository Connector", + description: "Pull requests, files and metadata from connected Bitbucket Cloud repositories.", + icon: BitbucketIcon, + }, confluence: { label: "Confluence Cloud Connector", description: "Pages and spaces from connected Confluence Cloud tenants.", diff --git a/src/features/connectors/types.ts b/src/features/connectors/types.ts index e460eb014..f73b9501f 100644 --- a/src/features/connectors/types.ts +++ b/src/features/connectors/types.ts @@ -1,4 +1,4 @@ -import type { LucideIcon } from "lucide-react"; +import type { IconComponent } from "../../components/icons/types.ts"; import type { ConnectorDto, ConnectorSource } from "../../services/connectorService.ts"; export type { ConnectorDto, ConnectorSource } from "../../services/connectorService.ts"; @@ -14,7 +14,7 @@ export type LoadingState = "idle" | "loading" | "success" | "error"; export type ConnectorMeta = { label: string; description: string; - icon: LucideIcon; + icon: IconComponent; }; /** diff --git a/src/features/data-ingestion/components/AddSourceModal.tsx b/src/features/data-ingestion/components/AddSourceModal.tsx index 3541bf20e..c1ed4239a 100644 --- a/src/features/data-ingestion/components/AddSourceModal.tsx +++ b/src/features/data-ingestion/components/AddSourceModal.tsx @@ -11,6 +11,7 @@ import { addDraftSource, connectDraftSources, connectOutcomeDescription, + createBitbucketDraftFromDiscovery, createConfluenceDraft, createDraftSourceFromDiscovery, createJiraDraft, @@ -144,6 +145,10 @@ export function AddSourceModal({ } = useGithubTokens(); const effectiveTokenNames = tokensLoaded ? loadedTokenNames : tokenNames; + // Bitbucket detail state. + const [bitbucketSelection, setBitbucketSelection] = useState([]); + const [bitbucketCredentialName, setBitbucketCredentialName] = useState(""); + // Jira detail state. const [jiraDisplayName, setJiraDisplayName] = useState(""); const [jiraUrl, setJiraUrl] = useState(""); @@ -159,8 +164,9 @@ export function AddSourceModal({ const isJiraDetail = isAddingSource && addStep === "detail" && addType === "JIRA"; const isConfluenceDetail = isAddingSource && addStep === "detail" && addType === "CONFLUENCE"; - // Jira and Confluence share the same Atlassian credential store, so one - // instance of the hook backs both detail screens' pickers. + const isBitbucketDetail = isAddingSource && addStep === "detail" && addType === "BITBUCKET"; + // Jira, Confluence and Bitbucket share the same Atlassian credential store, so + // one instance of the hook backs all three detail screens' pickers. const { credentials: jiraCredentials, loaded: jiraCredentialsLoaded, @@ -168,7 +174,7 @@ export function AddSourceModal({ isRefreshing: jiraCredentialsLoading, reload: reloadJiraCredentials, addCredentialLocally, - } = useAtlassianCredentials(isJiraDetail || isConfluenceDetail); + } = useAtlassianCredentials(isJiraDetail || isConfluenceDetail || isBitbucketDetail); // Adopt the first token as soon as the list arrives (and heal a stale // selection) so discovery is usable on the first open. @@ -183,7 +189,7 @@ export function AddSourceModal({ }, [effectiveTokenNames]); // Adopt the first stored Atlassian credential once the list arrives, keeping - // a still-valid choice — shared by the Jira and Confluence pickers. + // a still-valid choice — shared by the Jira, Confluence and Bitbucket pickers. useEffect(() => { if (!jiraCredentialsLoaded || jiraCredentialsLoading) return; @@ -200,11 +206,18 @@ export function AddSourceModal({ ? current : jiraCredentials[0].displayName; }); + setBitbucketCredentialName((current) => { + if (jiraCredentials.length === 0) return ""; + return current && jiraCredentials.some((credential) => credential.displayName === current) + ? current + : jiraCredentials[0].displayName; + }); }); }, [jiraCredentials, jiraCredentialsLoaded, jiraCredentialsLoading]); const resetSourceDraftFields = () => { setGithubSelection([]); + setBitbucketSelection([]); setJiraDisplayName(""); setJiraUrl(""); setJiraCredentialName(""); @@ -260,6 +273,12 @@ export function AddSourceModal({ await reloadJiraCredentials(); }; + const handleBitbucketCredentialSaved = async (credential: AtlassianCredentialDto) => { + addCredentialLocally(credential); + setBitbucketCredentialName(credential.displayName); + await reloadJiraCredentials(); + }; + const selectedJiraCredential = jiraCredentials.find( (credential) => credential.displayName === jiraCredentialName, ); @@ -271,21 +290,23 @@ export function AddSourceModal({ const canAddSource = addType === "GITHUB" ? githubSelection.length > 0 - : addType === "JIRA" - ? Boolean(jiraDisplayName.trim() && jiraUrl.trim() && selectedJiraCredential) - : addType === "UPLOAD" - ? uploadFiles.length > 0 - : addType === "CONFLUENCE" - ? Boolean( - confluenceBaseUrl.trim() && - isValidConfluenceSpaceId(confluenceSpaceId) && - selectedConfluenceCredential, - ) - : false; + : addType === "BITBUCKET" + ? bitbucketSelection.length > 0 && Boolean(bitbucketCredentialName) + : addType === "JIRA" + ? Boolean(jiraDisplayName.trim() && jiraUrl.trim() && selectedJiraCredential) + : addType === "UPLOAD" + ? uploadFiles.length > 0 + : addType === "CONFLUENCE" + ? Boolean( + confluenceBaseUrl.trim() && + isValidConfluenceSpaceId(confluenceSpaceId) && + selectedConfluenceCredential, + ) + : false; /** * The draft(s) captured on the current detail screen — several at once for the - * GitHub multi-select, one for Jira/Upload/Confluence. Empty when the detail isn't + * GitHub and Bitbucket multi-selects, one for Jira/Upload/Confluence. Empty when the detail isn't * complete enough to stage. */ const buildDetailDrafts = (): DraftSource[] => { @@ -297,6 +318,12 @@ export function AddSourceModal({ ); } + if (addType === "BITBUCKET") { + return bitbucketSelection.map((selection) => + createBitbucketDraftFromDiscovery(selection, bitbucketCredentialName), + ); + } + if (addType === "JIRA" && selectedJiraCredential) { return [ createJiraDraft({ @@ -572,6 +599,18 @@ export function AddSourceModal({ projectId, projectName, }} + bitbucket={{ + credentialName: bitbucketCredentialName, + credentials: jiraCredentials, + credentialsLoaded: jiraCredentialsLoaded, + credentialsLoading: jiraCredentialsLoading, + credentialsError: jiraCredentialsError, + defaultUserEmail: null, + onCredentialNameChange: setBitbucketCredentialName, + onSelectionChange: setBitbucketSelection, + onCredentialSaved: handleBitbucketCredentialSaved, + projectId, + }} jira={{ displayName: jiraDisplayName, url: jiraUrl, diff --git a/src/features/data-ingestion/components/BitbucketRepositoryDiscovery.tsx b/src/features/data-ingestion/components/BitbucketRepositoryDiscovery.tsx new file mode 100644 index 000000000..80c25b76b --- /dev/null +++ b/src/features/data-ingestion/components/BitbucketRepositoryDiscovery.tsx @@ -0,0 +1,184 @@ +import { AlertTriangle } from "lucide-react"; +import { DropdownSelect } from "../../../components/ui/DropdownSelect"; +import { ApiError } from "../../../services/apiClient.ts"; +import type { AtlassianCredentialDto } from "../../../services/sources/atlassianService.ts"; +import { discoverBitbucketRepositories } from "../../../services/sources/bitbucketService.ts"; +import { + parseBitbucketRepositoryReference, + parseBitbucketWorkspaceInput, +} from "../../../services/sources/bitbucketRepositoryInput.ts"; +import { + RepositoryDiscovery, + type DiscoverySelection, + type RepositoryDiscoveryAdapter, +} from "./RepositoryDiscovery.tsx"; + +type BitbucketRepositoryDiscoveryProps = { + /** The caller's stored Atlassian credentials, shared with Jira and Confluence. */ + credentials: AtlassianCredentialDto[]; + /** Whether the credential list has finished loading, so "none stored" is not shown early. */ + credentialsLoaded: boolean; + credentialsLoading: boolean; + credentialsError: string | null; + /** Controlled credential selection (the parent needs it at connect time). */ + credentialName: string; + onCredentialNameChange: (name: string) => void; + /** + * Project the repositories would be connected to, or `null` when there is no + * project yet (the create-project wizard). See {@link RepositoryDiscovery}. + */ + projectId: string | null; + /** Reports the resolved selection whenever it changes. Must be stable. */ + onSelectionChange: (selection: DiscoverySelection[]) => void; + /** True while the parent runs its connect batch; locks the inputs. */ + isConnecting?: boolean; + /** Connect error from the parent, shown below any discovery error. */ + connectError?: string | null; + /** + * Hides the built-in "no stored credential" banner. Set when the parent shows + * its own hint (e.g. the wizard's chip next to its inline "Add credential" + * button) so the message is not duplicated. + */ + suppressMissingCredentialNotice?: boolean; +}; + +/** + * How {@link RepositoryDiscovery} talks to Bitbucket Cloud. Discovery is always + * scoped to one workspace (Bitbucket has no cross-workspace listing), so the + * owner field takes a workspace id, `bitbucket.org/` or a repository + * URL, which narrows the list to that repository. The selection's `name` is the + * repository slug; the display name only labels the row. + */ +const BITBUCKET_DISCOVERY_ADAPTER: RepositoryDiscoveryAdapter = { + sourceSystem: "BITBUCKET", + providerName: "Bitbucket", + discover: async (workspace, credentialName, page, pageSize) => { + const result = await discoverBitbucketRepositories(workspace, credentialName, page, pageSize); + + return { + repositories: result.repositories.map((repository) => ({ + name: repository.slug, + // The slug only adds information when it is more than the lowercased + // display name ("Widget Shop (widget-shop)", but just "Widgets"). + label: + repository.name === repository.slug + ? undefined + : repository.name.toLowerCase() === repository.slug.toLowerCase() + ? repository.name + : `${repository.name} (${repository.slug})`, + isPrivate: repository.isPrivate, + url: repository.url, + alreadyConnected: repository.alreadyConnected, + isEnabled: repository.isEnabled, + })), + hasMore: result.hasMore, + }; + }, + parseInput: (value) => { + const reference = parseBitbucketRepositoryReference(value); + if (reference) return { owner: reference.workspace, name: reference.slug }; + + const workspace = parseBitbucketWorkspaceInput(value); + return workspace ? { owner: workspace, name: null } : null; + }, + ownerLabel: "Workspace or bitbucket.org URL", + ownerPlaceholder: "acme, bitbucket.org/acme, or a repo URL", + invalidInputMessage: + "Enter a Bitbucket workspace or repository URL (e.g. acme or bitbucket.org/acme/widgets).", + credentialRequiredMessage: "Choose a stored Atlassian credential.", + missingCredentialNotice: + "No Atlassian credentials are stored for your account. Add one under Settings, Access Tokens, Atlassian first, then come back to discover repositories.", + emptyResultHint: + "The credential may not be allowed to read this workspace's repositories. Its token needs Bitbucket scopes for repositories, pull requests and the workspace.", + describeError: (error, workspace) => { + if (error instanceof ApiError && error.status === 404) { + return `The selected credential was not found, or no workspace "${workspace}" is visible to it.`; + } + + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + return `The selected credential can't read workspace "${workspace}". Check that its token has Bitbucket scopes.`; + } + + if (error instanceof ApiError && error.status === 429) { + return "Bitbucket rate limit reached. Please wait a moment and try again."; + } + + return null; + }, +}; + +/** + * Bitbucket workspace repository discovery: {@link RepositoryDiscovery} with the + * Bitbucket adapter and an Atlassian credential picker. Used by the add-source + * modal and the create-project wizard. + */ +export function BitbucketRepositoryDiscovery({ + credentials, + credentialsLoaded, + credentialsLoading, + credentialsError, + credentialName, + onCredentialNameChange, + projectId, + onSelectionChange, + isConnecting = false, + connectError, + suppressMissingCredentialNotice = false, +}: BitbucketRepositoryDiscoveryProps) { + const hasCredentials = credentials.length > 0; + + return ( +
+

+ The credential’s token needs Bitbucket scopes: read access to repositories, pull + requests and the workspace. +

+ + {credentialsError && ( +
+ + {credentialsError} +
+ )} + + ( +
+ Credential + ({ + value: credential.displayName, + label: `${credential.displayName} - ${credential.userEmail}`, + })) + : [ + { + value: "", + label: credentialsLoading ? "Loading credentials..." : "No credentials", + }, + ] + } + onChange={onCredentialNameChange} + disabled={disabled} + className="mt-2 sm:w-64" + /> +
+ )} + projectId={projectId} + onSelectionChange={onSelectionChange} + isConnecting={isConnecting} + connectError={connectError} + // The banner only means something once the list has loaded. + suppressMissingCredentialNotice={ + suppressMissingCredentialNotice || !credentialsLoaded || credentialsLoading + } + /> +
+ ); +} diff --git a/src/features/data-ingestion/components/GithubRepositoryDiscovery.tsx b/src/features/data-ingestion/components/GithubRepositoryDiscovery.tsx index 16db2a0a7..2eb9983dd 100644 --- a/src/features/data-ingestion/components/GithubRepositoryDiscovery.tsx +++ b/src/features/data-ingestion/components/GithubRepositoryDiscovery.tsx @@ -1,59 +1,19 @@ -import { - AlertTriangle, - Check, - CheckCircle2, - ExternalLink, - GitBranch, - Loader2, - Lock, - RefreshCw, - Search, -} from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Badge } from "../../../components/ui/Badge"; -import { Button } from "../../../components/ui/Button"; -import { DropdownSelect } from "../../../components/ui/DropdownSelect"; -import { EmptyState } from "../../../components/ui/EmptyState"; -import { Input } from "../../../components/ui/Input"; import { ApiError } from "../../../services/apiClient.ts"; -import { - discoverRepositories, - type DiscoveredRepository, -} from "../../../services/sources/githubService.ts"; -import { getIngestionSourceStatuses } from "../../../services/ingestionService.ts"; +import { DropdownSelect } from "../../../components/ui/DropdownSelect"; +import { discoverRepositories } from "../../../services/sources/githubService.ts"; import { parseGithubOwnerInput, parseGithubRepositoryReference, } from "../../../services/sources/githubRepositoryInput.ts"; +import { + RepositoryDiscovery, + type DiscoverySelection, + type RepositoryDiscoveryAdapter, +} from "./RepositoryDiscovery.tsx"; -/** - * What connecting a discovered repository to the current project would mean. - * - * `alreadyConnected` from discovery is global: it says the repo is a SprintStart - * source *somewhere*, not that it belongs to this project. Such a repo can be - * linked to the current project without fetching or ingesting it again, which is - * why it stays selectable instead of being greyed out. - */ -export type RepositoryLinkState = - /** Not a source yet: connecting fetches and ingests it. */ - | "new" - /** Ingested elsewhere: connecting only links it, reusing its artifacts. */ - | "linkable" - /** Already a source of this project: nothing left to do. */ - | "in-project" - /** Ingested elsewhere, but its repository id could not be resolved. */ - | "unresolved"; - -/** A resolved, selected repository handed to the parent's connect flow. */ -export type DiscoverySelection = { - /** The owner that actually produced the results (repos carry only a name). */ - owner: string; - name: string; - isPrivate: boolean; - linkState: RepositoryLinkState; - /** Present for `linkable` repos, so they can be linked instead of re-ingested. */ - repositoryId?: string; -}; +// Re-exported so the wizard, the add-source modal and the draft helpers keep +// importing the selection types from here. +export type { DiscoverySelection, RepositoryLinkState } from "./RepositoryDiscovery.tsx"; type GithubRepositoryDiscoveryProps = { tokenNames: string[]; @@ -82,16 +42,57 @@ type GithubRepositoryDiscoveryProps = { suppressMissingTokenNotice?: boolean; }; -const PAGE_SIZE = 20; +/** + * How {@link RepositoryDiscovery} talks to GitHub: an owner is an organization or + * a user, resolved on the service side ("auto" tries the org endpoint first and + * falls back to the user endpoint), so the user never has to know or pick which + * kind they typed. + */ +const GITHUB_DISCOVERY_ADAPTER: RepositoryDiscoveryAdapter = { + sourceSystem: "GITHUB", + providerName: "GitHub", + discover: async (owner, tokenName, page, pageSize) => { + const result = await discoverRepositories(owner, tokenName, "auto", page, pageSize); + + return { repositories: result.repositories, hasMore: result.hasMore }; + }, + parseInput: (value) => { + const reference = parseGithubRepositoryReference(value); + if (reference) return { owner: reference.owner, name: reference.name }; + + const owner = parseGithubOwnerInput(value); + return owner ? { owner, name: null } : null; + }, + ownerLabel: "Organization, user, or URL", + ownerPlaceholder: "octocat, github.com/octocat, or a repo URL", + invalidInputMessage: + "Enter a GitHub organization, user, or repository URL (e.g. octocat or github.com/octocat/hello-world).", + credentialRequiredMessage: "Choose a stored GitHub access token.", + missingCredentialNotice: + "Add a GitHub personal access token in Settings first, then come back to discover repositories.", + emptyResultHint: ( + <> + The token may only see public repositories. Private repositories require a token with broader + scope (e.g. read:org / repo access). + + ), + describeError: (error, owner) => { + if (error instanceof ApiError && error.status === 404) { + return `No GitHub organization or user "${owner}" was found for the selected token.`; + } + + if (error instanceof ApiError && error.status === 429) { + return "GitHub rate limit reached. Please wait a moment and try again."; + } + + return null; + }, +}; /** - * GitHub org/user repository discovery: searchable, paginated, multi-select, with - * an "already ingested elsewhere → link instead of re-ingest" classification. - * - * Owns discovery and the selection set; the resolved selection is reported up via - * {@link GithubRepositoryDiscoveryProps.onSelectionChange} so the parent keeps its - * own connect button and decides what connecting means (immediate connect on the - * Data Ingestion page, staged connect in the create-project wizard). + * GitHub org/user repository discovery: {@link RepositoryDiscovery} with the + * GitHub adapter and a stored-token picker. Keeps the props the create-project + * wizard and the add-source modal already use. */ export function GithubRepositoryDiscovery({ tokenNames, @@ -105,257 +106,12 @@ export function GithubRepositoryDiscovery({ }: GithubRepositoryDiscoveryProps) { const hasTokens = tokenNames.length > 0; - const [ownerInput, setOwnerInput] = useState(""); - const [filter, setFilter] = useState(""); - - // The owner that actually produced the current results, used at connect time - // (discovered repos carry only their name, not their owner). - const [resolvedOwner, setResolvedOwner] = useState(""); - const [repositories, setRepositories] = useState([]); - const [selected, setSelected] = useState>(new Set()); - const [page, setPage] = useState(0); - const [hasMore, setHasMore] = useState(false); - // "owner/name" (lowercased) -> repository id, for every repo connected anywhere, - // plus the subset already belonging to the current project. - const [repositoryIdsByFullName, setRepositoryIdsByFullName] = useState>( - new Map(), - ); - const [projectFullNames, setProjectFullNames] = useState>(new Set()); - - const [discoverState, setDiscoverState] = useState< - "idle" | "loading" | "loadingMore" | "loaded" | "error" - >("idle"); - const [discoverError, setDiscoverError] = useState(null); - - const isBusy = discoverState === "loading" || isConnecting; - - // 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 - // already-ingested repo can be linked to this project instead of being blocked. - const loadConnectedRepositories = useCallback(async () => { - try { - const [allConnected, connectedToProject] = await Promise.all([ - getIngestionSourceStatuses(), - projectId ? getIngestionSourceStatuses(projectId) : Promise.resolve([]), - ]); - - setRepositoryIdsByFullName( - new Map( - // Only GitHub rows carry a repositoryId; connector-neutral rows (Jira) - // have none and are not link-by-repository candidates here. - allConnected.flatMap((status) => - status.repositoryId - ? ([[status.sourceId.toLowerCase(), status.repositoryId]] as [string, string][]) - : [], - ), - ), - ); - setProjectFullNames( - new Set(connectedToProject.map((status) => status.sourceId.toLowerCase())), - ); - } catch { - // Degrades gracefully: without ids, already-connected repos stay - // unselectable rather than offering an action that would fail. - setRepositoryIdsByFullName(new Map()); - setProjectFullNames(new Set()); - } - }, [projectId]); - - const runDiscovery = useCallback( - async (nextPage: number) => { - // The single field accepts an org/user handle, a bare "owner/name", or a - // full GitHub 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 repoReference = parseGithubRepositoryReference(ownerInput); - const owner = repoReference?.owner ?? parseGithubOwnerInput(ownerInput); - - if (!owner) { - setDiscoverState("error"); - setDiscoverError( - "Enter a GitHub organization, user, or repository URL (e.g. octocat or github.com/octocat/hello-world).", - ); - return; - } - - if (!tokenName.trim()) { - setDiscoverState("error"); - setDiscoverError("Choose a stored GitHub access token."); - return; - } - - const loadingMore = nextPage > 0; - setDiscoverState(loadingMore ? "loadingMore" : "loading"); - setDiscoverError(null); - - try { - // "auto" resolves org-vs-user on the service side (org first, then user - // on a 404), so the user never has to know or pick which kind they typed. - const result = await discoverRepositories( - owner, - tokenName.trim(), - "auto", - nextPage, - PAGE_SIZE, - ); - - setResolvedOwner(owner); - setHasMore(result.hasMore); - setPage(nextPage); - setRepositories((current) => - loadingMore ? [...current, ...result.repositories] : result.repositories, - ); - setDiscoverState("loaded"); - - if (!loadingMore) { - // A fresh discovery starts with a clean slate: clear selections so a - // repository sharing a name with one picked under a previous owner is - // not silently re-selected. A pasted "owner/name" pre-filters to that - // repository; a bare owner clears any leftover filter. - setSelected(new Set()); - setFilter(repoReference ? repoReference.name : ""); - await loadConnectedRepositories(); - } - } catch (error) { - setDiscoverState("error"); - - if (error instanceof ApiError && error.status === 404) { - setDiscoverError( - `No GitHub organization or user "${owner}" was found for the selected token.`, - ); - } else if (error instanceof ApiError && error.status === 429) { - setDiscoverError("GitHub rate limit reached. Please wait a moment and try again."); - } else { - setDiscoverError( - error instanceof Error ? error.message : "Repositories could not be discovered.", - ); - } - } - }, - [loadConnectedRepositories, ownerInput, tokenName], - ); - - const filteredRepositories = useMemo(() => { - const normalized = filter.trim().toLowerCase(); - if (!normalized) return repositories; - - return repositories.filter((repository) => repository.name.toLowerCase().includes(normalized)); - }, [filter, repositories]); - - const linkStateByName = useMemo(() => { - const states = new Map(); - - repositories.forEach((repository) => { - const fullName = `${resolvedOwner}/${repository.name}`.toLowerCase(); - - if (projectFullNames.has(fullName)) { - states.set(repository.name, "in-project"); - } else if (!repository.alreadyConnected) { - states.set(repository.name, "new"); - } else if (repositoryIdsByFullName.has(fullName)) { - states.set(repository.name, "linkable"); - } else { - states.set(repository.name, "unresolved"); - } - }); - - return states; - }, [projectFullNames, repositories, repositoryIdsByFullName, resolvedOwner]); - - const isSelectable = useCallback( - (name: string) => { - const state = linkStateByName.get(name) ?? "new"; - return state === "new" || state === "linkable"; - }, - [linkStateByName], - ); - - const selectableVisible = filteredRepositories.filter((repository) => - isSelectable(repository.name), - ); - const allVisibleSelected = - selectableVisible.length > 0 && - selectableVisible.every((repository) => selected.has(repository.name)); - - const toggleRepository = (name: string) => { - setSelected((current) => { - const next = new Set(current); - if (next.has(name)) { - next.delete(name); - } else { - next.add(name); - } - return next; - }); - }; - - const toggleAllVisible = () => { - setSelected((current) => { - const next = new Set(current); - if (allVisibleSelected) { - selectableVisible.forEach((repository) => next.delete(repository.name)); - } else { - selectableVisible.forEach((repository) => next.add(repository.name)); - } - return next; - }); - }; - - const selection = useMemo(() => { - return repositories - .filter((repository) => selected.has(repository.name)) - .map((repository) => { - const fullName = `${resolvedOwner}/${repository.name}`.toLowerCase(); - - return { - owner: resolvedOwner, - name: repository.name, - isPrivate: repository.isPrivate, - linkState: linkStateByName.get(repository.name) ?? "new", - repositoryId: repositoryIdsByFullName.get(fullName), - }; - }); - }, [linkStateByName, repositories, repositoryIdsByFullName, resolvedOwner, selected]); - - // Report the resolved selection up. `onSelectionChange` must be stable (a - // useState setter or a memoised callback) so this only fires when the - // selection actually changes. - useEffect(() => { - onSelectionChange(selection); - }, [onSelectionChange, selection]); - - const selectedCount = selection.length; - return ( -
- {!hasTokens && !suppressMissingTokenNotice && ( -
- Add a GitHub personal access token in Settings first, then come back to discover - repositories. -
- )} - -
{ - event.preventDefault(); - void runDiscovery(0); - }} - > -
- - setOwnerInput(event.target.value)} - disabled={isBusy || !hasTokens} - placeholder="octocat, github.com/octocat, or a repo URL" - className="mt-2" - /> -
- + (
Access token
- - - - - {discoverError && ( -
- - {discoverError} -
- )} - - {connectError && ( -
- - {connectError} -
- )} - - {discoverState === "loading" && ( -
- - Discovering repositories… -
- )} - - {discoverState === "loaded" && repositories.length === 0 && ( - } title="No repositories found"> - The token may only see public repositories. Private repositories require a token with - broader scope (e.g. read:org / repo access). - - )} - - {repositories.length > 0 && ( -
-
-
- setFilter(event.target.value)} - placeholder="Filter repositories" - aria-label="Filter repositories" - icon={} - /> -
- -
- {selectedCount} selected - -
-
- -
    - {filteredRepositories.map((repository) => { - const isSelected = selected.has(repository.name); - const linkState = linkStateByName.get(repository.name) ?? "new"; - const disabledRow = !isSelectable(repository.name); - - return ( -
  • - -
  • - ); - })} -
- - {hasMore && ( -
- -
- )} -
)} -
+ projectId={projectId} + onSelectionChange={onSelectionChange} + isConnecting={isConnecting} + connectError={connectError} + suppressMissingCredentialNotice={suppressMissingTokenNotice} + /> ); } diff --git a/src/features/data-ingestion/components/RepositoryDiscovery.tsx b/src/features/data-ingestion/components/RepositoryDiscovery.tsx new file mode 100644 index 000000000..140d71724 --- /dev/null +++ b/src/features/data-ingestion/components/RepositoryDiscovery.tsx @@ -0,0 +1,660 @@ +import { + AlertTriangle, + Check, + CheckCircle2, + ExternalLink, + GitBranch, + Loader2, + Lock, + RefreshCw, + Search, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { Input } from "../../../components/ui/Input"; +import { getIngestionSourceStatuses } from "../../../services/ingestionService.ts"; +import type { SourceSystem } from "../types.ts"; + +/** + * 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. `owner` + * is the GitHub owner or the Bitbucket workspace, `name` the repository name or + * slug. + */ +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; +}; + +/** One repository of a discovery page, normalized across providers. */ +export type DiscoveredRepositoryItem = { + /** Repository name (GitHub) or slug (Bitbucket): the key and what a selection carries. */ + name: string; + /** Text shown in the row when it differs from `name` (a Bitbucket display name). */ + label?: string; + isPrivate: boolean; + /** Browser URL of the repository; the row shows no link when null. */ + url: string | null; + alreadyConnected: boolean; + isEnabled: boolean | null; +}; + +export type RepositoryDiscoveryPage = { + repositories: DiscoveredRepositoryItem[]; + hasMore: boolean; +}; + +/** + * What differs between the providers: how a page is fetched, how the owner field + * is read, which ingestion rows count as "the same provider", and the wording. + * Must be a stable reference (a module-level constant) so discovery callbacks are + * not rebuilt on every render. + */ +export type RepositoryDiscoveryAdapter = { + /** + * The source system a repository belongs to. Only ingestion rows of this + * system count as "already ingested" or "in this project", so a Bitbucket + * repository sharing `owner/name` with a GitHub one is not seen as linkable. + */ + sourceSystem: SourceSystem; + /** Provider name for the link labels, e.g. "GitHub". */ + providerName: string; + /** Loads one 0-based page of `owner`'s repositories with the named credential. */ + discover: ( + owner: string, + credentialName: string, + page: number, + pageSize: number, + ) => Promise; + /** + * Reads the free-form owner field: the owner (organization, user or + * workspace) and, when the input names a single repository, its name. Returns + * null when no owner can be found. + */ + parseInput: (value: string) => { owner: string; name: string | null } | null; + ownerLabel: string; + ownerPlaceholder: string; + /** Shown when {@link parseInput} finds no owner. */ + invalidInputMessage: string; + /** Shown when no credential is selected. */ + credentialRequiredMessage: string; + /** Banner shown while the user has no stored credential at all. */ + missingCredentialNotice: string; + /** Empty-state body when discovery returns no repositories. */ + emptyResultHint: ReactNode; + /** + * Provider-specific wording for a failed discovery (e.g. an unknown owner or a + * rate limit). Return null to fall back to the error's own message. + */ + describeError?: (error: unknown, owner: string) => string | null; +}; + +type RepositoryDiscoveryProps = { + adapter: RepositoryDiscoveryAdapter; + /** Whether the user has any stored credential; disables the form without one. */ + hasCredentials: boolean; + /** Selected credential (the parent needs it at connect time). */ + credentialName: string; + /** + * Renders the credential picker (a GitHub token or an Atlassian credential + * dropdown, with its label). `disabled` is true while discovery or the + * parent's connect batch runs, or without any stored credential. + */ + renderCredentialPicker: (state: { disabled: boolean }) => ReactNode; + /** + * Project the repositories would be connected to, or `null` when there is no + * project yet (e.g. the create-project wizard stages the selection first). With + * no project the "already in this project" classification is skipped, but the + * global "ingested elsewhere" check still runs. + */ + projectId: string | null; + /** Reports the resolved selection whenever it changes. Must be stable. */ + onSelectionChange: (selection: DiscoverySelection[]) => void; + /** True while the parent runs its connect batch; locks the inputs. */ + isConnecting?: boolean; + /** Connect error from the parent, shown below any discovery error. */ + connectError?: string | null; + /** + * Hides the built-in "no stored credential" banner. Set when the parent shows + * its own hint (e.g. the wizard's compact notice next to its inline "Add" + * button) so the message is not duplicated. + */ + suppressMissingCredentialNotice?: boolean; +}; + +const PAGE_SIZE = 20; + +/** + * Provider-neutral 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 RepositoryDiscoveryProps.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). The provider + * differences come in through a {@link RepositoryDiscoveryAdapter}. + */ +export function RepositoryDiscovery({ + adapter, + hasCredentials, + credentialName, + renderCredentialPicker, + projectId, + onSelectionChange, + isConnecting = false, + connectError, + suppressMissingCredentialNotice = false, +}: RepositoryDiscoveryProps) { + const [ownerInput, setOwnerInput] = useState(""); + const [filter, setFilter] = useState(""); + + // 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); + 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>( + new Map(), + ); + const [projectFullNames, setProjectFullNames] = useState>(new Set()); + + const [discoverState, setDiscoverState] = useState< + "idle" | "loading" | "loadingMore" | "loaded" | "error" + >("idle"); + const [discoverError, setDiscoverError] = useState(null); + + 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 + // already-ingested repo can be linked to this project instead of being blocked. + const loadConnectedRepositories = useCallback(async () => { + // A row without a system label predates the field and is a GitHub row, the + // same fallback the ingestion service applies when it maps the response. + const isOfProvider = (status: { sourceSystem?: SourceSystem }) => + (status.sourceSystem ?? "GITHUB") === adapter.sourceSystem; + + try { + const [allConnected, connectedToProject] = await Promise.all([ + getIngestionSourceStatuses(), + projectId ? getIngestionSourceStatuses(projectId) : Promise.resolve([]), + ]); + + setRepositoryIdsByFullName( + new Map( + // Only repository rows carry a repositoryId; connector-neutral rows + // (Jira) have none and are not link-by-repository candidates here. + allConnected + .filter(isOfProvider) + .flatMap((status) => + status.repositoryId + ? ([[status.sourceId.toLowerCase(), status.repositoryId]] as [string, string][]) + : [], + ), + ), + ); + setProjectFullNames( + new Set( + connectedToProject.filter(isOfProvider).map((status) => status.sourceId.toLowerCase()), + ), + ); + } catch { + // Degrades gracefully: without ids, already-connected repos stay + // unselectable rather than offering an action that would fail. + setRepositoryIdsByFullName(new Map()); + setProjectFullNames(new Set()); + } + }, [adapter.sourceSystem, projectId]); + + const runDiscovery = useCallback( + async (nextPage: number) => { + 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; + } + + owner = parsedInput.owner; + repositoryName = parsedInput.name; + credential = credentialName.trim(); + + if (!credential) { + setDiscoverState("error"); + setDiscoverError(adapter.credentialRequiredMessage); + return; + } + } + + setDiscoverState(loadingMore ? "loadingMore" : "loading"); + setDiscoverError(null); + + try { + const result = await adapter.discover(owner, credential, nextPage, PAGE_SIZE); + + setResolvedOwner(owner); + setResolvedCredential(credential); + setHasMore(result.hasMore); + setPage(nextPage); + setRepositories((current) => + loadingMore ? [...current, ...result.repositories] : result.repositories, + ); + setDiscoverState("loaded"); + + if (!loadingMore) { + // A fresh discovery starts with a clean slate: clear selections so a + // repository sharing a name with one picked under a previous owner is + // not silently re-selected. A pasted "owner/name" pre-filters to that + // repository; a bare owner clears any leftover filter. + setSelected(new Set()); + setFilter(repositoryName ?? ""); + await loadConnectedRepositories(); + } + } catch (error) { + setDiscoverState("error"); + setDiscoverError( + adapter.describeError?.(error, owner) ?? + (error instanceof Error ? error.message : "Repositories could not be discovered."), + ); + } + }, + [ + adapter, + credentialName, + loadConnectedRepositories, + ownerInput, + resolvedCredential, + resolvedOwner, + ], + ); + + const filteredRepositories = useMemo(() => { + const normalized = filter.trim().toLowerCase(); + if (!normalized) return repositories; + + return repositories.filter( + (repository) => + repository.name.toLowerCase().includes(normalized) || + (repository.label?.toLowerCase().includes(normalized) ?? false), + ); + }, [filter, repositories]); + + const linkStateByName = useMemo(() => { + const states = new Map(); + + repositories.forEach((repository) => { + const fullName = `${resolvedOwner}/${repository.name}`.toLowerCase(); + + if (projectFullNames.has(fullName)) { + states.set(repository.name, "in-project"); + } else if (!repository.alreadyConnected) { + states.set(repository.name, "new"); + } else if (repositoryIdsByFullName.has(fullName)) { + states.set(repository.name, "linkable"); + } else { + states.set(repository.name, "unresolved"); + } + }); + + return states; + }, [projectFullNames, repositories, repositoryIdsByFullName, resolvedOwner]); + + const isSelectable = useCallback( + (name: string) => { + const state = linkStateByName.get(name) ?? "new"; + return state === "new" || state === "linkable"; + }, + [linkStateByName], + ); + + const selectableVisible = filteredRepositories.filter((repository) => + isSelectable(repository.name), + ); + const allVisibleSelected = + selectableVisible.length > 0 && + selectableVisible.every((repository) => selected.has(repository.name)); + + const toggleRepository = (name: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + }; + + const toggleAllVisible = () => { + setSelected((current) => { + const next = new Set(current); + if (allVisibleSelected) { + selectableVisible.forEach((repository) => next.delete(repository.name)); + } else { + selectableVisible.forEach((repository) => next.add(repository.name)); + } + return next; + }); + }; + + const selection = useMemo(() => { + return repositories + .filter((repository) => selected.has(repository.name)) + .map((repository) => { + const fullName = `${resolvedOwner}/${repository.name}`.toLowerCase(); + + return { + owner: resolvedOwner, + name: repository.name, + isPrivate: repository.isPrivate, + linkState: linkStateByName.get(repository.name) ?? "new", + repositoryId: repositoryIdsByFullName.get(fullName), + }; + }); + }, [linkStateByName, repositories, repositoryIdsByFullName, resolvedOwner, selected]); + + // Report the resolved selection up. `onSelectionChange` must be stable (a + // useState setter or a memoised callback) so this only fires when the + // selection actually changes. + useEffect(() => { + onSelectionChange(selection); + }, [onSelectionChange, selection]); + + const selectedCount = selection.length; + + return ( +
+ {!hasCredentials && !suppressMissingCredentialNotice && ( +
+ {adapter.missingCredentialNotice} +
+ )} + +
{ + event.preventDefault(); + void runDiscovery(0); + }} + > +
+ + setOwnerInput(event.target.value)} + disabled={isBusy || !hasCredentials} + placeholder={adapter.ownerPlaceholder} + className="mt-2" + /> +
+ + {renderCredentialPicker({ disabled: isBusy || !hasCredentials })} + + +
+ + {discoverError && ( +
+ + {discoverError} +
+ )} + + {connectError && ( +
+ + {connectError} +
+ )} + + {discoverState === "loading" && ( +
+ + Discovering repositories… +
+ )} + + {discoverState === "loaded" && repositories.length === 0 && ( + } title="No repositories found"> + {adapter.emptyResultHint} + + )} + + {repositories.length > 0 && ( +
+
+
+ setFilter(event.target.value)} + placeholder="Filter repositories" + aria-label="Filter repositories" + icon={} + /> +
+ +
+ {selectedCount} selected + +
+
+ +
    + {filteredRepositories.map((repository) => { + const isSelected = selected.has(repository.name); + const linkState = linkStateByName.get(repository.name) ?? "new"; + const disabledRow = !isSelectable(repository.name); + + return ( +
  • + +
  • + ); + })} +
+ + {hasMore && ( +
+ +
+ )} +
+ )} +
+ ); +} 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/SourceDetailsPanel.tsx b/src/features/data-ingestion/components/SourceDetailsPanel.tsx index 8d050c3b3..1b99026a1 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 skip this Bitbucket repository. It only updates when started manually." + 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/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 d0d0b9578..62cd09d55 100644 --- a/src/features/data-ingestion/data.ts +++ b/src/features/data-ingestion/data.ts @@ -21,10 +21,17 @@ import type { SourceStatusPresentation, SourceSystem, } from "./types.ts"; +import { BitbucketIcon } from "../../components/icons/BitbucketIcon.tsx"; import type { JiraInstanceDto } from "../../services/sources/jiraService.ts"; import type { ConfluenceConnectionDto } from "../../services/sources/confluenceService.ts"; -export const SOURCE_SYSTEMS: SourceSystem[] = ["GITHUB", "JIRA", "UPLOAD", "CONFLUENCE"]; +export const SOURCE_SYSTEMS: SourceSystem[] = [ + "GITHUB", + "JIRA", + "UPLOAD", + "CONFLUENCE", + "BITBUCKET", +]; export const SOURCE_META: Record = { GITHUB: { @@ -51,6 +58,13 @@ export const SOURCE_META: Record = { icon: BookOpen, description: "Indexes pages, hierarchical documents and spaces from Confluence Cloud.", }, + BITBUCKET: { + name: "Bitbucket Repository", + type: "Bitbucket", + icon: BitbucketIcon, + description: + "Indexes pull requests, README files and source files from Bitbucket Cloud repositories.", + }, }; export const INGESTION_RUN_LIMIT = 50; @@ -186,6 +200,78 @@ export function createJiraSourceFromInstance( }; } +/** + * Maps a BITBUCKET status row from `/api/v1/ingestion-sources/status` into a + * {@link DataSource}, the Bitbucket counterpart to + * {@link createJiraSourceFromInstance}. + * + * The status row is authoritative for health, counters, total artifact count + * and last-sync time. `sourceId`/`displayName` are `"workspace/slug"`, `owner` + * is the workspace and `name` the slug. Bitbucket only reports a pull-request + * sync time; commits and issues are never synced, so those stay null. + * `githubRepository` is always null; identity lives in `bitbucketRepository`. + */ +export function createBitbucketSourceFromInstance( + status: SourceInstanceIngestionStatus, + connectorEnabled?: boolean, +): DataSource { + const meta = SOURCE_META.BITBUCKET; + const backendStatus: BackendProjectSourceStatus = + status.enabled === false ? "DISABLED" : status.connectionStatus; + const hasErrors = status.failedCount > 0; + const hasNeverSynced = status.lastRunTime === null; + const [fallbackWorkspace = "", fallbackSlug = ""] = status.sourceId.split("/"); + + return { + sourceId: status.repositoryId ?? status.sourceId, + sourceSystem: "BITBUCKET", + name: status.displayName, + type: meta.type, + icon: meta.icon, + status: getSourceStatusFromBackend(backendStatus), + backendStatus, + statusLabel: getBackendSourceStatusLabel(backendStatus), + ingestionStatus: getSourceStatus(hasNeverSynced, hasErrors, null), + ingestionStatusLabel: + !hasNeverSynced && !hasErrors + ? "Synced" + : getSourceStatusLabel(hasNeverSynced, hasErrors, null), + statusView: deriveSourceStatus({ + backendStatus, + hasErrors, + hasNeverSynced, + connectorEnabled, + }), + artifacts: status.artifactCount, + lastSync: formatDateTime(status.lastRunTime), + nextSync: "Not available", + errors: status.failedCount, + description: meta.description, + lastRunAt: status.lastRunTime, + latestIngestedCount: status.ingestedCount, + latestUpdatedCount: status.updatedCount, + deletedCount: status.deletedCount, + totalArtifactCount: status.artifactCount, + runIds: [], + sharesSourceSystem: false, + failedItems: status.failedItems, + githubRepository: null, + jiraInstance: null, + confluenceSpace: null, + bitbucketRepository: { + repositoryId: status.repositoryId, + workspace: status.owner ?? fallbackWorkspace, + slug: status.name ?? fallbackSlug, + fullName: status.sourceId, + url: status.sourceUrl, + enabled: status.enabled, + }, + lastCommitsSyncAt: null, + lastIssuesSyncAt: null, + lastPullRequestsSyncAt: status.lastPullRequestsSyncAt, + }; +} + /** * Maps an UPLOAD status row from `/api/v1/ingestion-sources/status` into the * full {@link DataSource} model rendered on the ingestion page. @@ -698,7 +784,8 @@ export function formatInstanceDomain(instanceUrl: string): string { * reference — most visibly for Jira, whose `sourceId` is the instance URL. * * Keyed by the same value the run carries in `sourceId`: GitHub `"owner/name"` - * (the repository's full name) and Jira the instance URL. Runs whose source is + * and Bitbucket `"workspace/slug"` (the repository's full name) and Jira the + * instance URL. Runs whose source is * no longer connected won't be in the map and fall back to the raw reference. */ export function buildRunSourceLabels(sources: DataSource[]): Map { @@ -713,6 +800,10 @@ export function buildRunSourceLabels(sources: DataSource[]): Map labels.set(source.githubRepository.fullName, source.name); } + if (source.bitbucketRepository?.fullName) { + labels.set(source.bitbucketRepository.fullName, source.name); + } + if (source.confluenceSpace) { const { connectionId, baseUrl, spaceId, spaceKey } = source.confluenceSpace; if (connectionId) labels.set(connectionId, source.name); diff --git a/src/features/data-ingestion/types.ts b/src/features/data-ingestion/types.ts index 38c24bcab..45187df0a 100644 --- a/src/features/data-ingestion/types.ts +++ b/src/features/data-ingestion/types.ts @@ -1,6 +1,7 @@ import type { LucideIcon } from "lucide-react"; +import type { IconComponent } from "../../components/icons/types.ts"; -export type SourceSystem = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE"; +export type SourceSystem = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE" | "BITBUCKET"; export type BackendProjectSourceStatus = | "CONNECTED" @@ -97,14 +98,19 @@ export type IngestionRun = { export type SourceInstanceIngestionStatus = { sourceSystem: SourceSystem; /** - * Stable, connector-neutral key: GitHub `"owner/name"`, Jira the instance URL. + * Stable, connector-neutral key: GitHub `"owner/name"`, Bitbucket + * `"workspace/slug"`, Jira the instance URL. */ sourceId: string; - /** Display name: GitHub `"owner/name"`, Jira the instance's display name. */ + /** + * Display name: GitHub `"owner/name"`, Bitbucket `"workspace/slug"`, Jira the + * instance's display name. + */ displayName: string; /** - * GitHub-only repository identity. Null for connector-neutral rows such as - * Jira, which are identified by {@link sourceId} (the instance URL) instead. + * Repository connection id (GitHub and Bitbucket). Null for connector-neutral + * rows such as Jira, which are identified by {@link sourceId} (the instance + * URL) instead. */ repositoryId: string | null; owner: string | null; @@ -176,6 +182,20 @@ export type GithubRepositoryDetails = GithubRepositoryReference & { enabled: boolean | null; }; +/** + * Bitbucket-specific identity for a source card. A repository is addressed as + * `workspace/slug`; `repositoryId` is the connection's UUID, used to update the + * repository and to link or unlink it from a project. + */ +export type BitbucketRepositoryDetails = { + repositoryId: string | null; + workspace: string; + slug: string; + fullName: string; + url: string; + enabled: boolean | null; +}; + /** * Jira-specific identity for a source card, mirroring * {@link GithubRepositoryDetails} for the Jira connector. A connected Jira @@ -255,7 +275,7 @@ export type SourceStatusPresentation = { export type SourceMeta = { name: string; type: string; - icon: LucideIcon; + icon: IconComponent; description: string; }; @@ -280,12 +300,14 @@ export type SourceDetailsSource = { jiraInstance?: JiraInstanceSourceDetails | null; /** Confluence space identity; null/absent for non-Confluence sources. */ confluenceSpace?: ConfluenceSpaceSourceDetails | null; + /** Bitbucket repository identity; null/absent for non-Bitbucket sources. */ + bitbucketRepository?: BitbucketRepositoryDetails | null; description?: string; nextSync?: string; }; export type DataSource = SourceDetailsSource & { - icon: LucideIcon; + icon: IconComponent; statusLabel: string; ingestionStatus: SourceStatus; ingestionStatusLabel: string; diff --git a/src/features/knowledge-base/bitbucketWorkspaceMetadata.ts b/src/features/knowledge-base/bitbucketWorkspaceMetadata.ts new file mode 100644 index 000000000..a24fb6829 --- /dev/null +++ b/src/features/knowledge-base/bitbucketWorkspaceMetadata.ts @@ -0,0 +1,89 @@ +/** + * Type mapping + tolerant parser for the metadata JSON string the backend ships on + * Bitbucket `ORG_METADATA` artifacts. + * + * The backend DTO (`BitbucketWorkspaceMetadataArtifactMetadata` in sprintstart-backend) + * serializes the workspace profile and its members into the artifact's `metadata` + * field. It is the Bitbucket counterpart of the GitHub org profile in + * [`orgMetadata`](./orgMetadata), with a different shape: a workspace has no teams, + * no blog or location, and its members are named by display name and nickname. + */ + +export interface BitbucketWorkspaceMember { + accountId: string | null; + nickname: string | null; + displayName: string | null; +} + +export interface BitbucketWorkspaceMetadata { + /** The workspace id, as used in `bitbucket.org/`. */ + workspace: string; + /** The workspace's display name (also the artifact `title`). */ + name: string; + /** Null when the backend reported no visibility. */ + isPrivate: boolean | null; + /** ISO 8601 creation time, or null when Bitbucket reported none. */ + createdOn: string | null; + /** Browser URL of the workspace, or null when Bitbucket reported none. */ + url: string | null; + members: BitbucketWorkspaceMember[]; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value.trim() : null; +} + +/** + * Parses a Bitbucket `ORG_METADATA` artifact's `metadata` JSON string. + * + * Tolerant like `parseOrgMetadata`: returns `null` for anything that names no + * workspace or carries no member list, so the viewer can show its quiet empty + * state. Members that are not objects are dropped rather than failing the whole + * profile. The Kotlin `isPrivate` property is read under both spellings Jackson + * may serialize it as (`isPrivate` and `private`). + * + * @param json The raw `artifact.metadata` string (may be omitted). + * @returns The parsed workspace metadata, or `null` when the input is not usable. + */ +export function parseBitbucketWorkspaceMetadata( + json: string | null | undefined, +): BitbucketWorkspaceMetadata | null { + if (typeof json !== "string" || json.trim() === "") return null; + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + console.warn("Ignoring unparseable Bitbucket workspace metadata", json); + return null; + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + + const payload = parsed as Record; + const workspace = optionalString(payload["workspace"]); + const members = payload["members"]; + if (workspace === null || !Array.isArray(members)) return null; + + const isPrivate = payload["isPrivate"] ?? payload["private"]; + + return { + workspace, + name: optionalString(payload["name"]) ?? workspace, + isPrivate: typeof isPrivate === "boolean" ? isPrivate : null, + createdOn: optionalString(payload["createdOn"]), + url: optionalString(payload["url"]), + members: members + .filter( + (member): member is Record => + typeof member === "object" && member !== null && !Array.isArray(member), + ) + .map((member) => ({ + accountId: optionalString(member["accountId"]), + nickname: optionalString(member["nickname"]), + displayName: optionalString(member["displayName"]), + })), + }; +} diff --git a/src/features/knowledge-base/components/ArtifactFilters.tsx b/src/features/knowledge-base/components/ArtifactFilters.tsx index eea194cf6..e107375dc 100644 --- a/src/features/knowledge-base/components/ArtifactFilters.tsx +++ b/src/features/knowledge-base/components/ArtifactFilters.tsx @@ -15,6 +15,7 @@ import { Ticket, Upload, } from "lucide-react"; +import { BitbucketIcon } from "../../../components/icons/BitbucketIcon"; import { Button } from "../../../components/ui/Button"; import { Input } from "../../../components/ui/Input"; import { Select } from "../../../components/ui/Select.tsx"; @@ -66,10 +67,10 @@ export interface ArtifactFiltersProps { */ formatOptions: FacetOption[]; /** - * Repositories behind the project's GitHub artifacts, with counts. Empty - * unless GitHub is part of the source selection — the facet describes GitHub - * only, so the section disappears with it and the hook owns that rule rather - * than the toolbar re-deriving it. + * Repositories behind the project's GitHub and Bitbucket artifacts, with + * counts. Empty unless one of them is part of the source selection — the facet + * describes the git providers only, so the section disappears with them and the + * hook owns that rule rather than the toolbar re-deriving it. */ repositoryOptions: FacetOption[]; selectedSources: ReadonlySet; @@ -124,6 +125,7 @@ const SOURCE_ICONS: Record = { GITHUB:

+ +

Workspace profile unavailable.

+
+ ); + } + + const workspaceUrl = + metadata.url ?? `https://bitbucket.org/${encodeURIComponent(metadata.workspace)}`; + const createdOn = formatWorkspaceDate(metadata.createdOn); + + return ( +
+
+
+ +
+
+

+ {metadata.name || title || "Workspace"} +

+ + {metadata.workspace} + +
+
+ + + +
+ {metadata.isPrivate !== null && ( + } label="Visibility"> + {metadata.isPrivate ? "Private" : "Public"} + + )} + {createdOn && ( + } label="Created"> + {createdOn} + + )} +
+ +
+

+ + Members + {metadata.members.length > 0 && ( + + {metadata.members.length} + + )} +

+ + {metadata.members.length > 0 ? ( +
    + {metadata.members.map((member, index) => ( +
  • + + {member.displayName ?? member.nickname ?? "Unknown member"} +
  • + ))} +
+ ) : ( +

No members visible in this workspace.

+ )} +
+
+ ); +} + /** * ArtifactViewerDrawer * @@ -1181,10 +1300,21 @@ export function ArtifactViewerDrawer({ const summariseBlockedReason = summariseBlockReason(content); + // A Bitbucket workspace profile shares the ORG_METADATA type with the GitHub + // org profile but has its own metadata shape, so it gets its own view. + const isBitbucketWorkspace = + artifact?.artifactType === "ORG_METADATA" && artifact.sourceSystem === "BITBUCKET"; const orgMetadata = useMemo( - () => (artifact?.artifactType === "ORG_METADATA" ? parseOrgMetadata(artifact.metadata) : null), + () => + artifact?.artifactType === "ORG_METADATA" && artifact.sourceSystem !== "BITBUCKET" + ? parseOrgMetadata(artifact.metadata) + : null, [artifact], ); + const workspaceMetadata = useMemo( + () => (isBitbucketWorkspace ? parseBitbucketWorkspaceMetadata(artifact?.metadata) : null), + [artifact, isBitbucketWorkspace], + ); const actionsContent = viewMode === "raw" && artifact?.artifactType !== "ORG_METADATA" && (
@@ -1303,7 +1433,11 @@ export function ArtifactViewerDrawer({
) : viewMode === "raw" && artifact?.artifactType === "ORG_METADATA" ? (
- + {isBitbucketWorkspace ? ( + + ) : ( + + )}
) : viewMode === "raw" ? (
diff --git a/src/features/knowledge-base/components/SourceLinkBadge.tsx b/src/features/knowledge-base/components/SourceLinkBadge.tsx index da5885a3d..eb77a6e6b 100644 --- a/src/features/knowledge-base/components/SourceLinkBadge.tsx +++ b/src/features/knowledge-base/components/SourceLinkBadge.tsx @@ -8,6 +8,8 @@ function getSourceSystemLabel(sourceSystem: SourceSystem): string { switch (sourceSystem) { case "GITHUB": return "Open in GitHub"; + case "BITBUCKET": + return "Open in Bitbucket"; case "JIRA": return "Open in Jira"; case "CONFLUENCE": diff --git a/src/features/knowledge-base/githubMetadata.ts b/src/features/knowledge-base/githubMetadata.ts index 22077af76..078de1013 100644 --- a/src/features/knowledge-base/githubMetadata.ts +++ b/src/features/knowledge-base/githubMetadata.ts @@ -8,6 +8,11 @@ * the parse keeps consumers (the artifact list card and the viewer drawer) free * of ad-hoc `JSON.parse` + shape handling, and guarantees malformed metadata can * never crash the UI or leak raw JSON into it. + * + * Bitbucket artifacts carry `{ repositoryId, workspace, slug }` instead (see + * {@link parseBitbucketMetadata}); both shapes resolve to the same + * `owner/repository` display string, so the card badge and the repository facet + * treat the two providers alike. */ import type { Artifact } from "./types"; @@ -25,21 +30,14 @@ export interface GithubArtifactMetadata { } /** - * Parses a GitHub artifact's `metadata` JSON string. - * - * Tolerant by design, mirroring {@link parseOrgMetadata}: returns `null` for - * anything that cannot be turned into a usable repository reference — - * `null`/`undefined` input, empty or whitespace-only string, malformed JSON, - * JSON that is not a plain object, or a missing/blank `repositoryFullName`. - * Only fields the UI actually renders are validated; the backend always sends - * `repositoryId` alongside, but nothing consumes it. - * - * @param json The raw `artifact.metadata` string (may be omitted). - * @returns The parsed metadata, or `null` when the input is not usable. + * Reads a metadata string as a plain JSON object, or `null` when it is missing, + * blank, malformed or not an object. `source` only names the provider in the + * warning logged for a malformed blob. */ -export function parseGithubMetadata( +function readMetadataObject( json: string | null | undefined, -): GithubArtifactMetadata | null { + source: string, +): Record | null { if (typeof json !== "string" || json.trim() === "") return null; let parsed: unknown; @@ -49,7 +47,7 @@ export function parseGithubMetadata( // A malformed blob (never expected from the backend, but cheap to guard // against) must not crash the UI — degrade to the same "no repository" // state as missing metadata. - console.warn("Ignoring unparseable GitHub artifact metadata", json); + console.warn(`Ignoring unparseable ${source} artifact metadata`, json); return null; } @@ -57,7 +55,28 @@ export function parseGithubMetadata( return null; } - const payload = parsed as Record; + return parsed as Record; +} + +/** + * Parses a GitHub artifact's `metadata` JSON string. + * + * Tolerant by design, mirroring {@link parseOrgMetadata}: returns `null` for + * anything that cannot be turned into a usable repository reference — + * `null`/`undefined` input, empty or whitespace-only string, malformed JSON, + * JSON that is not a plain object, or a missing/blank `repositoryFullName`. + * Only fields the UI actually renders are validated; the backend always sends + * `repositoryId` alongside, but nothing consumes it. + * + * @param json The raw `artifact.metadata` string (may be omitted). + * @returns The parsed metadata, or `null` when the input is not usable. + */ +export function parseGithubMetadata( + json: string | null | undefined, +): GithubArtifactMetadata | null { + const payload = readMetadataObject(json, "GitHub"); + if (payload === null) return null; + const repositoryFullName = payload["repositoryFullName"]; if (typeof repositoryFullName !== "string" || repositoryFullName.trim() === "") { return null; @@ -74,15 +93,57 @@ export function parseGithubMetadata( }; } +export interface BitbucketArtifactMetadata { + /** The connected repository's id; declared-but-unconsumed, like GitHub's. */ + repositoryId?: string; + /** The Bitbucket workspace owning the repository (or described, for a workspace profile). */ + workspace: string; + /** + * The repository's slug. Absent on a workspace's own metadata artifact, which + * names the workspace but no repository. + */ + slug?: string; +} + +/** + * Parses a Bitbucket artifact's `metadata` JSON string: `{ repositoryId, + * workspace, slug }` on repository artifacts, `{ workspace, ... }` on the + * workspace's profile artifact. + * + * Tolerant like {@link parseGithubMetadata}: returns `null` for anything that + * names no workspace. Only the fields the UI uses are validated and trimmed. + * + * @param json The raw `artifact.metadata` string (may be omitted). + * @returns The parsed metadata, or `null` when the input is not usable. + */ +export function parseBitbucketMetadata( + json: string | null | undefined, +): BitbucketArtifactMetadata | null { + const payload = readMetadataObject(json, "Bitbucket"); + if (payload === null) return null; + + const workspace = payload["workspace"]; + if (typeof workspace !== "string" || workspace.trim() === "") return null; + + const slug = payload["slug"]; + + return { + repositoryId: typeof payload["repositoryId"] === "string" ? payload["repositoryId"] : undefined, + workspace: workspace.trim(), + slug: typeof slug === "string" && slug.trim() !== "" ? slug.trim() : undefined, + }; +} + /** - * Resolves the display repository (`owner/repository`) for an artifact, or - * `null` when the artifact has no repository to show. + * Resolves the display repository (`owner/repository`, or `workspace/slug` for + * Bitbucket) for an artifact, or `null` when the artifact has no repository to + * show. * - * Only GitHub's repo-scoped artifact types carry repository metadata. GitHub's - * `ORG_METADATA` artifacts are also `sourceSystem === "GITHUB"`, but their - * metadata is the org profile (no repository fields), so they are excluded - * explicitly — letting them through would run the org JSON through the repo - * parser and always fail. Non-GitHub source systems (`UPLOAD`, `JIRA`, + * Only the repo-scoped artifact types of GitHub and Bitbucket carry repository + * metadata. Their `ORG_METADATA` artifacts share the source system, but the + * metadata is the org or workspace profile (no repository fields), so they are + * excluded explicitly — letting them through would run the profile JSON through + * the repo parser and always fail. Other source systems (`UPLOAD`, `JIRA`, * `CONFLUENCE`, including their `PAGE` artifacts) carry different metadata * shapes and are excluded by the source-system check. * @@ -110,6 +171,7 @@ export function getArtifactRepository( */ interface RepositoryInfo { repository: string | null; + /** The owning GitHub org login, or the Bitbucket workspace, of an owner-profile artifact. */ orgLogin: string | null; } @@ -117,6 +179,17 @@ interface RepositoryInfo { function computeRepositoryInfo( artifact: Pick, ): RepositoryInfo { + if (artifact.sourceSystem === "BITBUCKET") { + const bitbucket = parseBitbucketMetadata(artifact.metadata); + + if (bitbucket?.slug) { + return { repository: `${bitbucket.workspace}/${bitbucket.slug}`, orgLogin: null }; + } + + // The workspace's own artifact names no repository, only its workspace. + return { repository: null, orgLogin: bitbucket?.workspace ?? null }; + } + const repository = artifact.sourceSystem === "GITHUB" && artifact.artifactType !== "ORG_METADATA" ? (parseGithubMetadata(artifact.metadata)?.repositoryFullName ?? null) @@ -150,7 +223,7 @@ function repositoryInfoOf( * * Mirrors `matchesFormat`'s scoping rule where it can: artifacts from other * sources are outside the facet's reach and always match, so "GitHub + Uploads - * + repo X" does not hide the uploads. Within GitHub a selection narrows + * + repo X" does not hide the uploads. Within GitHub (and Bitbucket) a selection narrows * strictly, with one exception: the org profile names no repository, but it * describes the org that owns the chosen ones — so it stays visible exactly * when its login is the owner half of a checked `owner/repo`. An unrelated @@ -174,13 +247,13 @@ export function matchesRepository( selected: ReadonlySet, ): boolean { if (selected.size === 0) return true; - if (artifact.sourceSystem !== "GITHUB") return true; + if (artifact.sourceSystem !== "GITHUB" && artifact.sourceSystem !== "BITBUCKET") return true; const { repository, orgLogin } = repositoryInfoOf(artifact); if (repository !== null) return selected.has(repository); - // A GitHub artifact that names no repository: only the org profile can still - // belong to the selection — namely when its org owns one of the chosen repos. + // An artifact that names no repository: only the org (or workspace) profile can + // still belong to the selection — namely when its owner owns one of the chosen repos. if (orgLogin === null) return false; const login = orgLogin.toLowerCase(); return [...selected].some((repo) => { diff --git a/src/features/knowledge-base/hooks/useKnowledgeBase.ts b/src/features/knowledge-base/hooks/useKnowledgeBase.ts index 806c993e4..4d77c2796 100644 --- a/src/features/knowledge-base/hooks/useKnowledgeBase.ts +++ b/src/features/knowledge-base/hooks/useKnowledgeBase.ts @@ -362,7 +362,8 @@ export function useKnowledgeBase( }, [facetsData?.repositories]); const repositoryOptions = useMemo[]>(() => { - if (!selectedSources.has("GITHUB")) return []; + // Repositories belong to the git providers; the facet shows while either is selected. + if (!selectedSources.has("GITHUB") && !selectedSources.has("BITBUCKET")) return []; const repos = (facetsData?.repositories ?? []).map((r) => r.value); const offered = Array.from(new Set([...repos, ...selectedRepositories])).sort((a, b) => a.localeCompare(b), diff --git a/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts b/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts index f24b2dfdb..74998bd97 100644 --- a/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts +++ b/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts @@ -96,7 +96,7 @@ export interface KnowledgeBaseUrlState { /** Search text exactly as written to `?q=`; trimming is the request builder's job. */ search: string; sources: ReadonlySet; - /** Only ever non-empty while GitHub is among `sources` — see {@link parseKnowledgeBaseSearch}. */ + /** Only ever non-empty while GitHub or Bitbucket is among `sources` — see {@link parseKnowledgeBaseSearch}. */ repositories: ReadonlySet; /** Only ever non-null while Uploads is among `sources` — see {@link parseKnowledgeBaseSearch}. */ format: UploadFormat | null; @@ -166,7 +166,7 @@ function readPositiveInt(raw: string | null, fallback: number, max: number): num * because people type URLs by hand. * * The two dependent facets keep the invariant the toggles enforce: a `format` only counts while - * Uploads is selected and `repos` only while GitHub is. Otherwise a shared link could carry a + * Uploads is selected and `repos` only while GitHub or Bitbucket is. Otherwise a shared link could carry a * filter the panel does not even show — narrowing the list with no visible way to undo it. * * @param params The current `location.search`, parsed. @@ -186,7 +186,10 @@ export function parseKnowledgeBaseSearch(params: URLSearchParams): KnowledgeBase const format = sources.has("UPLOAD") && FORMAT_VALUES.has(rawFormat) ? (rawFormat as UploadFormat) : null; - const repositoryList = sources.has("GITHUB") ? readList(params, KB_URL_PARAM.repositories) : []; + const repositoryList = + sources.has("GITHUB") || sources.has("BITBUCKET") + ? readList(params, KB_URL_PARAM.repositories) + : []; const repositories: ReadonlySet = repositoryList.length > 0 ? new Set(repositoryList) : NO_STRINGS; @@ -415,7 +418,15 @@ export function useKnowledgeBaseUrlState( const isRemoving = toggleInList(params, KB_URL_PARAM.sources, source); // The dependent facets describe one source each; they go when their source goes. if (isRemoving && source === "UPLOAD") params.delete(KB_URL_PARAM.format); - if (isRemoving && source === "GITHUB") params.delete(KB_URL_PARAM.repositories); + // Repositories belong to GitHub and Bitbucket together, so they go once neither is left. + if (isRemoving && (source === "GITHUB" || source === "BITBUCKET")) { + const remaining = readList(params, KB_URL_PARAM.sources).map((value) => + value.toUpperCase(), + ); + if (!remaining.includes("GITHUB") && !remaining.includes("BITBUCKET")) { + params.delete(KB_URL_PARAM.repositories); + } + } params.delete(KB_URL_PARAM.page); }, "push"), [commit], diff --git a/src/features/knowledge-base/tabs.ts b/src/features/knowledge-base/tabs.ts index b9b141f30..b977b678d 100644 --- a/src/features/knowledge-base/tabs.ts +++ b/src/features/knowledge-base/tabs.ts @@ -14,13 +14,20 @@ export const SOURCE_LABELS: Record = { GITHUB: "GitHub", JIRA: "Jira", CONFLUENCE: "Confluence", + BITBUCKET: "Bitbucket", UPLOAD: "Uploads", }; /** * Standard left-to-right order for the source facet. */ -export const DEFAULT_SOURCE_ORDER: SourceSystem[] = ["GITHUB", "JIRA", "CONFLUENCE", "UPLOAD"]; +export const DEFAULT_SOURCE_ORDER: SourceSystem[] = [ + "GITHUB", + "BITBUCKET", + "JIRA", + "CONFLUENCE", + "UPLOAD", +]; /** * Human-readable display names for artifact types. diff --git a/src/features/knowledge-base/types.ts b/src/features/knowledge-base/types.ts index c73836d85..233402842 100644 --- a/src/features/knowledge-base/types.ts +++ b/src/features/knowledge-base/types.ts @@ -8,7 +8,7 @@ export type ArtifactType = "COMMIT" | "FILE" | "ISSUE" | "PULL_REQUEST" | "PAGE" * Origin source of the artifact data. * Used to route API calls (e.g., Github vs internal Uploads). */ -export type SourceSystem = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE"; +export type SourceSystem = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE" | "BITBUCKET"; /** * Core business entity representing any indexed piece of knowledge. diff --git a/src/features/settings/components/atlassian/AtlassianCredentialAddForm.tsx b/src/features/settings/components/atlassian/AtlassianCredentialAddForm.tsx index 40d9ac02e..b10ee1953 100644 --- a/src/features/settings/components/atlassian/AtlassianCredentialAddForm.tsx +++ b/src/features/settings/components/atlassian/AtlassianCredentialAddForm.tsx @@ -28,7 +28,7 @@ const ADD_FALLBACK = "Failed to add Atlassian credential."; /** * Inline form for storing an Atlassian account email and API token for the - * authenticated user, shared by the Jira and Confluence connectors. The login + * authenticated user, shared by the Jira, Confluence and Bitbucket connectors. The login * email is only a convenience default because the Atlassian account may use a * different address. */ diff --git a/src/features/settings/hooks/useAtlassianCredentials.ts b/src/features/settings/hooks/useAtlassianCredentials.ts index 8d83cf6ed..24c739adb 100644 --- a/src/features/settings/hooks/useAtlassianCredentials.ts +++ b/src/features/settings/hooks/useAtlassianCredentials.ts @@ -20,7 +20,7 @@ type UseAtlassianCredentialsResult = { /** * Loads the Atlassian credentials owned by the authenticated user, shared by - * the Jira and Confluence connectors. + * the Jira, Confluence and Bitbucket connectors. * * When disabled, the hook settles into a loaded-empty state without fetching. * Out-of-order responses (an explicit `reload` outrunning a slower, earlier diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 5fc12d1f7..88146ff0d 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -48,7 +48,12 @@ function deriveArtifactFromCitation(citation: CitationArtifactOpen): Artifact { const name = citation.filename.toLowerCase(); let artifactType: ArtifactType = "FILE"; - if (url.includes("/pull/") || name.startsWith("pr #") || name.startsWith("pull request")) { + if ( + url.includes("/pull/") || + url.includes("/pull-requests/") || + name.startsWith("pr #") || + name.startsWith("pull request") + ) { artifactType = "PULL_REQUEST"; } else if ( url.includes("/issues/") || @@ -60,7 +65,13 @@ function deriveArtifactFromCitation(citation: CitationArtifactOpen): Artifact { } let sourceSystem: SourceSystem = "GITHUB"; - if (url.includes("atlassian.net") || url.includes("/browse/") || name.startsWith("jira #")) { + if (url.includes("bitbucket.org")) { + sourceSystem = "BITBUCKET"; + } else if ( + url.includes("atlassian.net") || + url.includes("/browse/") || + name.startsWith("jira #") + ) { sourceSystem = "JIRA"; } diff --git a/src/pages/DataIngestionPage.tsx b/src/pages/DataIngestionPage.tsx index 206ab7cfb..0f61f9e45 100644 --- a/src/pages/DataIngestionPage.tsx +++ b/src/pages/DataIngestionPage.tsx @@ -29,6 +29,7 @@ import type { ConnectorListItem } from "../features/connectors/types.ts"; import { connectorService } from "../services/connectorService.ts"; import { buildRunSourceLabels, + createBitbucketSourceFromInstance, createConfluenceSourceFromConnection, createConfluenceSourceFromInstance, createJiraSourceFromInstance, @@ -45,6 +46,7 @@ import { } from "../features/data-ingestion/data.ts"; import type { BackendProjectSourceStatus, + BitbucketRepositoryDetails, DataSource, GithubRepositoryDetails, IngestionRun, @@ -62,6 +64,13 @@ import { getIngestionRunsPage, getIngestionSourceStatuses } from "../services/in import { useAuth } from "../context/useAuth"; import { useToast } from "../context/useToast"; import { useProjectContext } from "../features/projects/useProjectContext.ts"; +import { + configureAllBitbucketRepositories, + configureBitbucketRepository, + getBitbucketRepositoryConfig, + removeBitbucketRepositoryFromProject, + updateBitbucketRepository, +} from "../services/sources/bitbucketService.ts"; import { configureAllGithubRepositories, configureGithubRepository, @@ -103,23 +112,44 @@ const DEFAULT_GLOBAL_CONFLUENCE_SYNC_CONFIG: ConfigureGithubRepositoryRequest = schedule: { type: "INTERVAL", everyMinutes: 60 }, }; -type SyncSettingsProvider = "github" | "jira" | "confluence"; +const DEFAULT_GLOBAL_BITBUCKET_SYNC_CONFIG: ConfigureGithubRepositoryRequest = { + autoUpdate: true, + schedule: { type: "INTERVAL", everyMinutes: 60 }, +}; + +type SyncSettingsProvider = "github" | "bitbucket" | "jira" | "confluence"; /** - * Wording for the global sync-settings modal, per connector. `one` and `many` + * Wording for the global sync-settings modal, per connector. `autoUpdateOff` + * overrides the default "only mark out of date" line for a connector whose + * scheduler does nothing at all while auto update is off. `one` and `many` * name the connector's sources so the copy reads naturally in both the * "overwrites every connected …" and the "updates all connected …" sentences. */ const SYNC_SETTINGS_COPY: Record< SyncSettingsProvider, - { label: string; one: string; many: string } + { label: string; one: string; many: string; autoUpdateOff?: string } > = { github: { label: "GitHub", one: "GitHub repository", many: "GitHub repositories" }, + bitbucket: { + label: "Bitbucket", + one: "Bitbucket repository", + many: "Bitbucket repositories", + // The Bitbucket scheduler skips a repository with auto update off instead of + // marking it out of date. + autoUpdateOff: + "Due checks skip connected Bitbucket repositories. They only update when started manually.", + }, jira: { label: "Jira", one: "Jira instance", many: "Jira instances" }, confluence: { label: "Confluence", one: "Confluence space", many: "Confluence spaces" }, }; -const SYNC_SETTINGS_PROVIDER_ORDER: SyncSettingsProvider[] = ["github", "jira", "confluence"]; +const SYNC_SETTINGS_PROVIDER_ORDER: SyncSettingsProvider[] = [ + "github", + "bitbucket", + "jira", + "confluence", +]; // Small enough that the run table stays scannable and pagination is actually // reachable rather than a single page of rows. @@ -145,8 +175,8 @@ const DEFAULT_RUN_FILTER: RunFilterState = { /** * A source offered in the run-history filter. `value` is the GitHub repository - * id, Jira instance URL, or Confluence space id; `sourceSystem` decides which - * query param it maps to (repositoryId vs. sourceRef). + * id (also used for Bitbucket and Confluence), or Jira instance URL; + * `sourceSystem` decides which query param it maps to (repositoryId vs. sourceRef). */ type RunSourceFilterOption = { value: string; @@ -161,7 +191,8 @@ function toSourceSystem(value: string): SourceSystem | null { normalized === "GITHUB" || normalized === "JIRA" || normalized === "UPLOAD" || - normalized === "CONFLUENCE" + normalized === "CONFLUENCE" || + normalized === "BITBUCKET" ) { return normalized; } @@ -268,8 +299,11 @@ function buildProjectDataSources( const sourceSystem = toSourceSystem(projectSource.type); if (!sourceSystem) return []; - // Jira and Confluence cards are built solely from the connector-neutral status rows. - if (sourceSystem === "JIRA" || sourceSystem === "CONFLUENCE") return []; + // Jira, Confluence and Bitbucket cards are built solely from the connector-neutral + // status rows; a project source of these types must not add a second, fallback card. + if (sourceSystem === "JIRA" || sourceSystem === "CONFLUENCE" || sourceSystem === "BITBUCKET") { + return []; + } // Skip UPLOAD only when an authoritative status row already exists so the card // does not vanish when artifact count is 0 or when run status fallback is needed. if (sourceSystem === "UPLOAD" && sourceInstances.some((s) => s.sourceSystem === "UPLOAD")) { @@ -481,6 +515,8 @@ export function DataIngestionPage() { const [isSyncSettingsModalOpen, setIsSyncSettingsModalOpen] = useState(false); const [globalGithubSyncConfig, setGlobalGithubSyncConfig] = useState(DEFAULT_GLOBAL_GITHUB_SYNC_CONFIG); + const [globalBitbucketSyncConfig, setGlobalBitbucketSyncConfig] = + useState(DEFAULT_GLOBAL_BITBUCKET_SYNC_CONFIG); const [globalJiraSyncConfig, setGlobalJiraSyncConfig] = useState(DEFAULT_GLOBAL_JIRA_SYNC_CONFIG); const [globalConfluenceSyncConfig, setGlobalConfluenceSyncConfig] = @@ -634,11 +670,14 @@ export function DataIngestionPage() { page, size: RUN_PAGE_SIZE, projectId: selectedProjectId || undefined, - // GitHub and Confluence scope by repositoryId (filtering on backend sourceInstanceId UUID); - // Jira scopes by the run's sourceInstanceRef via sourceRef (instance URL). + // GitHub, Bitbucket and Confluence scope by repositoryId (filtering on backend + // sourceInstanceId UUID); Jira scopes by the run's sourceInstanceRef via + // sourceRef (instance URL). repositoryId: hasSource && - (runFilter.sourceSystem === "GITHUB" || runFilter.sourceSystem === "CONFLUENCE") + (runFilter.sourceSystem === "GITHUB" || + runFilter.sourceSystem === "BITBUCKET" || + runFilter.sourceSystem === "CONFLUENCE") ? runFilter.sourceValue : undefined, sourceRef: @@ -820,11 +859,25 @@ export function DataIngestionPage() { ); }); + // Bitbucket cards come from the status rows alone, like Jira: every field the + // card and drawer show is on the row. + const bitbucketSources = sourceInstances + .filter((status) => status.sourceSystem === "BITBUCKET") + .map((status) => + createBitbucketSourceFromInstance(status, connectorEnabledById.get("bitbucket")), + ); + const uploadSources = sourceInstances .filter((status) => status.sourceSystem === "UPLOAD") .map((status) => createUploadSourceFromInstance(status)); - return [...githubAndUpload, ...jiraSources, ...confluenceSources, ...uploadSources]; + return [ + ...githubAndUpload, + ...bitbucketSources, + ...jiraSources, + ...confluenceSources, + ...uploadSources, + ]; }, [ confluenceConnections, connectorEnabledById, @@ -877,6 +930,7 @@ export function DataIngestionPage() { [sources], ); const hasGithubSources = visibleSourceSystems.has("GITHUB"); + const hasBitbucketSources = visibleSourceSystems.has("BITBUCKET"); const hasJiraSources = visibleSourceSystems.has("JIRA"); const hasConfluenceSources = visibleSourceSystems.has("CONFLUENCE"); // The connectors whose global sync policy can be edited right now: one tab per @@ -884,6 +938,7 @@ export function DataIngestionPage() { const syncSettingsProviders = useMemo[]>(() => { const hasSources: Record = { github: hasGithubSources, + bitbucket: hasBitbucketSources, jira: hasJiraSources, confluence: hasConfluenceSources, }; @@ -891,7 +946,7 @@ export function DataIngestionPage() { return SYNC_SETTINGS_PROVIDER_ORDER.filter((provider) => hasSources[provider]).map( (provider) => ({ value: provider, label: SYNC_SETTINGS_COPY[provider].label }), ); - }, [hasConfluenceSources, hasGithubSources, hasJiraSources]); + }, [hasBitbucketSources, hasConfluenceSources, hasGithubSources, hasJiraSources]); const syncSettingsCopy = SYNC_SETTINGS_COPY[syncSettingsProvider]; const sourceHealth = useMemo(() => { @@ -926,7 +981,7 @@ export function DataIngestionPage() { const runSourceLabels = useMemo(() => buildRunSourceLabels(sources), [sources]); // Sources offered in the run filter, from the project's connected sources: a - // GitHub repo filters by its repositoryId, a Jira instance by its URL + // GitHub or Bitbucket repo filters by its repositoryId, a Jira instance by its URL // (sourceId), which the query maps to sourceRef. const runSourceOptions = useMemo( () => @@ -941,6 +996,16 @@ export function DataIngestionPage() { ]; } + if (source.bitbucketRepository?.repositoryId) { + return [ + { + value: source.bitbucketRepository.repositoryId, + label: source.name, + sourceSystem: "BITBUCKET", + }, + ]; + } + if (source.sourceSystem === "JIRA") { return [ { @@ -1131,6 +1196,17 @@ export function DataIngestionPage() { return; } + if (source.sourceSystem === "BITBUCKET") { + const repositoryId = source.bitbucketRepository?.repositoryId; + if (!repositoryId) { + throw new Error("Repository details are not available for this source."); + } + + await updateBitbucketRepository(repositoryId); + refreshAfterUpdate(); + return; + } + if (source.sourceSystem !== "GITHUB" || !source.githubRepository) { throw new Error("Repository details are not available for this source."); } @@ -1155,6 +1231,15 @@ export function DataIngestionPage() { [loadData, reloadSourceStatuses], ); + const handleSaveGlobalBitbucketConfig = useCallback( + async (request: ConfigureGithubRepositoryRequest) => { + await configureAllBitbucketRepositories(request); + setGlobalBitbucketSyncConfig(request); + await Promise.all([loadData(false), reloadSourceStatuses()]); + }, + [loadData, reloadSourceStatuses], + ); + const handleSaveGlobalJiraConfig = useCallback( async (request: ConfigureGithubRepositoryRequest) => { await configureAllJiraInstances(request); @@ -1202,10 +1287,16 @@ export function DataIngestionPage() { const globalSyncConfigs = useMemo>( () => ({ github: globalGithubSyncConfig, + bitbucket: globalBitbucketSyncConfig, jira: globalJiraSyncConfig, confluence: globalConfluenceSyncConfig, }), - [globalConfluenceSyncConfig, globalGithubSyncConfig, globalJiraSyncConfig], + [ + globalBitbucketSyncConfig, + globalConfluenceSyncConfig, + globalGithubSyncConfig, + globalJiraSyncConfig, + ], ); const globalSyncSavers: Record< @@ -1213,6 +1304,7 @@ export function DataIngestionPage() { (request: ConfigureGithubRepositoryRequest) => Promise > = { github: handleSaveGlobalGithubConfig, + bitbucket: handleSaveGlobalBitbucketConfig, jira: handleSaveGlobalJiraConfig, confluence: handleSaveGlobalConfluenceConfig, }; @@ -1235,6 +1327,20 @@ export function DataIngestionPage() { [loadData, reloadSourceStatuses], ); + const handleLoadBitbucketRepositoryConfig = useCallback( + async (repository: BitbucketRepositoryDetails) => + getBitbucketRepositoryConfig(repository.workspace, repository.slug), + [], + ); + + const handleSaveBitbucketRepositoryConfig = useCallback( + async (repository: BitbucketRepositoryDetails, request: ConfigureGithubRepositoryRequest) => { + await configureBitbucketRepository(repository, request); + await Promise.all([loadData(false), reloadSourceStatuses()]); + }, + [loadData, reloadSourceStatuses], + ); + const handleLoadJiraConfig = useCallback( async (instanceUrl: string) => getJiraConfig(instanceUrl), [], @@ -1308,6 +1414,18 @@ export function DataIngestionPage() { [refreshSourceDetails], ); + // Bitbucket repositories go through the same generic connector endpoint, keyed + // by `workspace/slug`. + const handleSetBitbucketSourceEnabled = useCallback( + async (repository: BitbucketRepositoryDetails, enabled: boolean) => { + await connectorService.patchConnectorSources("bitbucket", [ + { sourceId: repository.fullName, enabled }, + ]); + await refreshSourceDetails(); + }, + [refreshSourceDetails], + ); + // Jira instances are gated through the same generic connector endpoint as // GitHub: the JiraConnector's patchSource flips `sourceEnabled` and the AI // service is notified, keyed by the instance URL. @@ -1321,7 +1439,7 @@ export function DataIngestionPage() { // Removes a source's link to the selected project (the DELETE counterpart to // linking it via the Add Source flow), keyed per connector: a repository id - // for GitHub, the instance URL for Jira, the connection id for Confluence. The + // for GitHub and Bitbucket, the instance URL for Jira, the connection id for Confluence. The // source and its artifacts are kept; only this project stops using it. Closes // the drawer and refreshes the project-scoped lists so the card disappears. const handleUnlinkSource = useCallback( @@ -1352,6 +1470,20 @@ export function DataIngestionPage() { return; } + if (source.sourceSystem === "BITBUCKET") { + const bitbucketRepositoryId = source.bitbucketRepository?.repositoryId; + + if (!bitbucketRepositoryId || !selectedProjectId) { + throw new Error("This repository cannot be removed from the project."); + } + + await removeBitbucketRepositoryFromProject(bitbucketRepositoryId, selectedProjectId); + + setSelectedSourceId(null); + await refreshSourceDetails(); + return; + } + const repositoryId = source.githubRepository?.repositoryId; if (source.sourceSystem !== "GITHUB" || !repositoryId || !selectedProjectId) { @@ -1605,11 +1737,14 @@ export function DataIngestionPage() { canManageSyncSettings={canManageGithubSyncSettings} onLoadRepositoryConfig={handleLoadGithubRepositoryConfig} onSaveRepositoryConfig={handleSaveGithubRepositoryConfig} + onLoadBitbucketConfig={handleLoadBitbucketRepositoryConfig} + onSaveBitbucketConfig={handleSaveBitbucketRepositoryConfig} onLoadJiraConfig={handleLoadJiraConfig} onSaveJiraConfig={handleSaveJiraConfig} onLoadConfluenceConfig={handleLoadConfluenceConfig} onSaveConfluenceConfig={handleSaveConfluenceConfig} onSetSourceEnabled={handleSetSourceEnabled} + onSetBitbucketSourceEnabled={handleSetBitbucketSourceEnabled} onSetJiraSourceEnabled={handleSetJiraSourceEnabled} onUnlinkSource={canIngestIntoSelectedProject ? handleUnlinkSource : undefined} onClose={closeSourceDetails} @@ -1686,7 +1821,10 @@ export function DataIngestionPage() { showNextSync={false} disclaimer={`Applying global settings overwrites the sync settings of every connected ${syncSettingsCopy.one}.`} autoUpdateOnText={`Due checks update all connected ${syncSettingsCopy.many}.`} - autoUpdateOffText={`Due checks only mark connected ${syncSettingsCopy.many} out of date.`} + autoUpdateOffText={ + syncSettingsCopy.autoUpdateOff ?? + `Due checks only mark connected ${syncSettingsCopy.many} out of date.` + } toggleAriaLabel={`Toggle global ${syncSettingsCopy.label} auto update`} saveLabel="Apply globally" /> diff --git a/src/services/projectService.ts b/src/services/projectService.ts index ac06e95de..c4ffb0972 100644 --- a/src/services/projectService.ts +++ b/src/services/projectService.ts @@ -16,7 +16,8 @@ export type GlobalUserRole = "ADMIN" | "HR" | "PM" | "USER" | (string & {}); export type ProjectRole = "MEMBER" | "MANAGER" | "TEAMLEAD" | (string & {}); -export type ProjectSourceType = "GITHUB" | "JIRA" | "SONARQUBE" | "UPLOAD" | (string & {}); +export type ProjectSourceType = + "GITHUB" | "JIRA" | "BITBUCKET" | "SONARQUBE" | "UPLOAD" | (string & {}); export type ProjectSourceStatus = | "CONNECTED" diff --git a/src/services/sources/atlassianService.ts b/src/services/sources/atlassianService.ts index 7f00099c0..6f98e3d2e 100644 --- a/src/services/sources/atlassianService.ts +++ b/src/services/sources/atlassianService.ts @@ -29,7 +29,7 @@ export type ChangeAtlassianCredentialTokenRequest = { /** * A stored credential as returned by the backend: only `userEmail` plus the * credential/token name (`displayName`). The token secret is never returned. - * Shared by the Jira and Confluence connectors. + * Shared by the Jira, Confluence and Bitbucket connectors. */ export type AtlassianCredentialDto = { userEmail: string; diff --git a/src/services/sources/bitbucketRepositoryInput.ts b/src/services/sources/bitbucketRepositoryInput.ts new file mode 100644 index 000000000..29022c9b0 --- /dev/null +++ b/src/services/sources/bitbucketRepositoryInput.ts @@ -0,0 +1,98 @@ +/** + * Parsing for the free-form Bitbucket workspace and repository fields. + * + * The Bitbucket counterpart of `githubRepositoryInput.ts`: the connect flows let + * the user paste a URL into the workspace field instead of filling workspace and + * slug separately. A Bitbucket repository is addressed as `workspace/slug`. + * + * Bitbucket workspace ids and repository slugs are always lowercase, while the + * backend matches connections by exact coordinates. Every parser here lowercases + * what it returns, so `Acme/Widgets` resolves to the existing `acme/widgets` + * connection instead of becoming a second one. + */ + +export type BitbucketRepositoryReference = { + workspace: string; + slug: string; +}; + +/** + * Strips the host part of a browser URL, an HTTPS clone URL (which may carry a + * `user@`) or an SSH remote, and the leading slashes, leaving the lowercased + * path segments. A query string or fragment (`?foo=bar`, `#readme`) is dropped + * first so it never ends up inside the last segment. + */ +function toPathSegments(value: string): string[] { + return value + .trim() + .replace(/[?#].*$/, "") + .replace(/^(?:https?|ssh):\/\/(?:[^@/]+@)?bitbucket\.org\//i, "") + .replace(/^bitbucket\.org\//i, "") + .replace(/^git@bitbucket\.org:/i, "") + .replace(/^\/+/, "") + .split("/") + .filter((segment) => segment.length > 0) + .map((segment) => segment.toLowerCase()); +} + +/** + * Normalizes a single free-form reference such as `workspace/slug`, a browser + * URL, an HTTPS clone URL or an SSH remote into its workspace and slug. A URL + * that points deeper into the repository (`/src/main`, `/pull-requests/3`) still + * resolves to the repository. Returns `null` when the value does not carry both + * halves. + */ +export function parseBitbucketRepositoryReference( + value: string, +): BitbucketRepositoryReference | null { + const [workspace, rawSlug] = toPathSegments(value); + const slug = rawSlug?.replace(/\.git$/i, ""); + + if (workspace && slug) { + return { workspace, slug }; + } + + return null; +} + +/** + * Extracts a bare workspace id from a free-form value such as `acme`, + * `bitbucket.org/acme` or a full `https://bitbucket.org/acme/` URL. Unlike + * {@link parseBitbucketRepositoryReference} it does not require a slug: it is + * used by the discovery flow, where only the workspace is entered. Returns + * `null` when no workspace segment can be found. + */ +export function parseBitbucketWorkspaceInput(value: string): string | null { + const [workspace] = toPathSegments(value); + + return workspace ?? null; +} + +/** + * Resolves the workspace and slug inputs of a connect form. + * + * A combined reference in the workspace field wins, so pasting `workspace/slug` + * or a URL works without touching the second field. Otherwise both fields have + * to be filled. Returns `null` when neither path yields a complete reference. + */ +export function parseBitbucketRepositoryInput( + workspaceInput: string, + slugInput: string, +): BitbucketRepositoryReference | null { + const trimmedWorkspaceInput = workspaceInput.trim(); + const trimmedSlugInput = slugInput.trim(); + const parsedWorkspaceInput = parseBitbucketRepositoryReference(trimmedWorkspaceInput); + + if (parsedWorkspaceInput) { + return parsedWorkspaceInput; + } + + if (trimmedWorkspaceInput && trimmedSlugInput) { + return { + workspace: trimmedWorkspaceInput.toLowerCase(), + slug: trimmedSlugInput.toLowerCase(), + }; + } + + return null; +} diff --git a/src/services/sources/bitbucketService.ts b/src/services/sources/bitbucketService.ts new file mode 100644 index 000000000..1d000fc46 --- /dev/null +++ b/src/services/sources/bitbucketService.ts @@ -0,0 +1,293 @@ +import { apiClient } from "../apiClient.ts"; +import type { BitbucketRepositoryReference } from "./bitbucketRepositoryInput.ts"; +import type { + ConfigureGithubRepositoryRequest, + GithubScheduleSpec, + RepositoryProjectAssignmentResponse, +} from "./githubService.ts"; + +/** + * Bitbucket repositories are scheduled through the same shared spec as GitHub + * repositories, Jira instances and Confluence spaces, so the schedule form is + * shared with them as well. + */ +export type BitbucketScheduleSpec = GithubScheduleSpec; + +export type ConfigureBitbucketRepositoryRequest = ConfigureGithubRepositoryRequest; + +export type ConnectBitbucketRepositoryRequest = BitbucketRepositoryReference & { + /** Name of a stored Atlassian credential, shared with Jira and Confluence. */ + credentialName: string; + projectId: string; +}; + +/** + * Unlike GitHub, the backend reports no `wasReused` flag, so a connect that + * linked an existing connection looks the same as a fresh one. + */ +export type ConnectBitbucketRepositoryResponse = { + transactionId: string; +}; + +export type ConnectBitbucketRepositoriesResult = { + /** Maps `"workspace/slug"` to the accepted ingestion transaction id. */ + transactionIdsByRepository: Record; +}; + +/** + * One repository returned by workspace discovery, normalized for the UI. The + * shape mirrors the GitHub `DiscoveredRepository` (plus the coordinates a + * connect needs) so the discovery list can treat both alike. + */ +export type DiscoveredBitbucketRepository = BitbucketRepositoryReference & { + name: string; + isPrivate: boolean; + /** The repository's browser URL; null when Bitbucket reported no link. */ + url: string | null; + alreadyConnected: boolean; + isEnabled: boolean | null; +}; + +/** + * The discovery row as the backend sends it. The Kotlin properties `isPrivate` + * and `enabled` carry no explicit JSON name, and Jackson may serialize a + * boolean `isPrivate` as `private`, so both spellings are read. + */ +type BackendDiscoveredBitbucketRepository = { + workspace: string; + slug: string; + name: string; + isPrivate?: boolean; + private?: boolean; + url?: string | null; + alreadyConnected?: boolean; + enabled?: boolean | null; + isEnabled?: boolean | null; +}; + +type BackendDiscoverBitbucketRepositoriesResponse = { + repositories: BackendDiscoveredBitbucketRepository[]; +}; + +export type DiscoverBitbucketRepositoriesResult = { + repositories: DiscoveredBitbucketRepository[]; + /** + * Best-effort "there may be another page" flag. The backend returns no total + * count, so we infer it from a full page being returned (`length >= pageSize`). + */ + hasMore: boolean; +}; + +export type UpdateBitbucketRepositoryResponse = { + transactionId: string; +}; + +export type UpdateAllBitbucketRepositoriesResponse = { + /** Maps `"workspace/slug"` to the update's transaction id; disabled repositories are absent. */ + transactionIdsByRepository: Record; +}; + +/** + * The schedule and auto-update policy of one connected repository. The backend + * names the coordinates `workspace` and `slug` here, where the GitHub config + * has `repositoryOwner` and `repositoryName`. + */ +export type BitbucketRepositoryConfig = { + id: string; + workspace: string; + slug: string; + autoUpdate: boolean; + spec: BitbucketScheduleSpec | null; + schedule: string; + nextSyncAt: string | null; +}; + +const DEFAULT_DISCOVER_PAGE_SIZE = 20; + +function mapDiscoveredRepository( + repository: BackendDiscoveredBitbucketRepository, +): DiscoveredBitbucketRepository { + return { + workspace: repository.workspace, + slug: repository.slug, + name: repository.name, + isPrivate: repository.isPrivate ?? repository.private ?? false, + url: repository.url ?? null, + alreadyConnected: repository.alreadyConnected ?? false, + isEnabled: repository.enabled ?? repository.isEnabled ?? null, + }; +} + +function repositoryPath({ workspace, slug }: BitbucketRepositoryReference): string { + return `${encodeURIComponent(workspace)}/${encodeURIComponent(slug)}`; +} + +/** + * Connects a Bitbucket repository to a project by notifying the backend. The + * backend handles the actual ingestion asynchronously. + * + * @returns The backend transaction identifier for the accepted connection job. + * @throws ApiError — 404 when the named credential does not exist, or when the + * repository does not exist or the credential cannot read it; 403 when the + * caller cannot access the project. + */ +export async function connectBitbucketRepository( + request: ConnectBitbucketRepositoryRequest, +): Promise { + return apiClient.fetch("/api/v1/bitbucket", { + method: "POST", + body: JSON.stringify(request), + }); +} + +/** + * Connects several repositories to one project in a single call. Each + * repository still becomes its own source. Requires the PM or ADMIN role and + * access to the target project. + * + * @returns The accepted ingestion transaction ids keyed by `"workspace/slug"`. + */ +export async function connectBitbucketRepositories( + repositories: BitbucketRepositoryReference[], + credentialName: string, + projectId: string, +): Promise { + return apiClient.fetch("/api/v1/bitbucket/connect/all", { + method: "POST", + body: JSON.stringify({ + repositories: repositories.map(({ workspace, slug }) => ({ + workspace, + slug, + credentialName, + projectId, + })), + }), + }); +} + +/** + * Discovers the repositories of one Bitbucket workspace the named Atlassian + * credential can read. `page` is 0-based (matching the backend). Bitbucket has + * no cross-workspace listing, so discovery is always scoped to a workspace. + * Requires the PM or ADMIN role. + * + * @throws ApiError — 404 when the credential does not exist, 403 for insufficient role. + */ +export async function discoverBitbucketRepositories( + workspace: string, + credentialName: string, + page = 0, + pageSize = DEFAULT_DISCOVER_PAGE_SIZE, +): Promise { + const query = new URLSearchParams({ + credentialName, + page: String(page), + pageSize: String(pageSize), + }).toString(); + const response = await apiClient.fetch( + `/api/v1/bitbucket/discover/workspace/${encodeURIComponent(workspace)}?${query}`, + ); + const repositories = response.repositories.map(mapDiscoveredRepository); + + return { + repositories, + // No total is returned, so a full page is our only "there might be more" signal. + hasMore: repositories.length >= pageSize, + }; +} + +/** + * Assigns an already-ingested repository to an additional project without + * re-fetching or re-ingesting it. Idempotent. Requires the PM or ADMIN role and + * access to the target project. + * + * @throws ApiError — 403 when the caller cannot access the project, 404 when the + * repository connection is unknown. + */ +export async function addBitbucketRepositoryToProject( + repositoryId: string, + projectId: string, +): Promise { + return apiClient.fetch( + `/api/v1/bitbucket/connections/${encodeURIComponent(repositoryId)}/projects/${encodeURIComponent(projectId)}`, + { method: "POST" }, + ); +} + +/** + * Removes the link between an already-ingested repository and a project, the + * counterpart to {@link addBitbucketRepositoryToProject}. The repository and its + * artifacts are kept; only the project assignment is dropped. Idempotent. + * + * @returns The repository connection's remaining project ids after the unlink. + * @throws ApiError — 403 when the caller cannot access the project, 404 when the + * repository connection is unknown. + */ +export async function removeBitbucketRepositoryFromProject( + repositoryId: string, + projectId: string, +): Promise { + return apiClient.fetch( + `/api/v1/bitbucket/connections/${encodeURIComponent(repositoryId)}/projects/${encodeURIComponent(projectId)}`, + { method: "DELETE" }, + ); +} + +/** + * Starts an update of one connected repository. A paused (disabled) repository + * is refused by the backend rather than quietly updated. + */ +export async function updateBitbucketRepository( + repositoryId: string, +): Promise { + return apiClient.fetch( + `/api/v1/bitbucket/connections/${encodeURIComponent(repositoryId)}/update`, + { method: "POST" }, + ); +} + +/** Updates every enabled connected repository; disabled ones are skipped. */ +export async function updateAllBitbucketRepositories(): Promise { + return apiClient.fetch("/api/v1/bitbucket/update-all", { + method: "POST", + }); +} + +/** + * Applies one schedule and auto-update policy to all connected Bitbucket + * repositories. The backend converts the typed schedule into the stored cron + * expression. + */ +export async function configureAllBitbucketRepositories( + request: ConfigureBitbucketRepositoryRequest, +): Promise { + await apiClient.fetch("/api/v1/bitbucket/config", { + method: "PUT", + body: JSON.stringify(request), + }); +} + +/** + * Loads the current schedule and auto-update policy for one connected repository. + */ +export async function getBitbucketRepositoryConfig( + workspace: string, + slug: string, +): Promise { + return apiClient.fetch( + `/api/v1/bitbucket/config/${repositoryPath({ workspace, slug })}`, + ); +} + +/** + * Updates the schedule and auto-update policy for one connected repository. + */ +export async function configureBitbucketRepository( + repository: BitbucketRepositoryReference, + request: ConfigureBitbucketRepositoryRequest, +): Promise { + await apiClient.fetch(`/api/v1/bitbucket/config/${repositoryPath(repository)}`, { + method: "PUT", + body: JSON.stringify(request), + }); +} diff --git a/tests/unit/a11y/BitbucketRepositoryDiscovery.a11y.test.tsx b/tests/unit/a11y/BitbucketRepositoryDiscovery.a11y.test.tsx new file mode 100644 index 000000000..9fd9a7dac --- /dev/null +++ b/tests/unit/a11y/BitbucketRepositoryDiscovery.a11y.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { describe, it, expect, vi } from "vitest"; +import { axe } from "vitest-axe"; +import { BitbucketRepositoryDiscovery } from "../../../src/features/data-ingestion/components/BitbucketRepositoryDiscovery"; +import { server } from "../setup/vitest.setup"; + +const credentials = [{ displayName: "default", userEmail: "me@corp.com" }]; + +function renderDiscovery( + overrides: Partial[0]> = {}, +) { + return render( + , + ); +} + +describe("BitbucketRepositoryDiscovery Accessibility", () => { + it("has no violations before discovery", async () => { + const { container } = renderDiscovery(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("has no violations without stored credentials", async () => { + const { container } = renderDiscovery({ credentials: [], credentialName: "" }); + + expect(await screen.findByText(/No Atlassian credentials are stored/)).toBeInTheDocument(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("has no violations with discovered repositories", async () => { + server.use( + http.get("/api/v1/bitbucket/discover/workspace/:workspace", () => + HttpResponse.json({ + repositories: [ + { + workspace: "acme", + slug: "widgets", + name: "Widget Shop", + isPrivate: true, + url: "https://bitbucket.org/acme/widgets", + alreadyConnected: false, + enabled: null, + }, + { + workspace: "acme", + slug: "gadgets", + name: "gadgets", + isPrivate: false, + url: "https://bitbucket.org/acme/gadgets", + alreadyConnected: true, + enabled: false, + }, + ], + }), + ), + ); + const user = userEvent.setup(); + const { container } = renderDiscovery(); + + await user.type(screen.getByLabelText("Workspace or bitbucket.org URL"), "acme"); + await user.click(screen.getByRole("button", { name: "Discover" })); + await screen.findByText("Widget Shop (widgets)"); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/tests/unit/a11y/CreateProjectWizard.a11y.test.tsx b/tests/unit/a11y/CreateProjectWizard.a11y.test.tsx index 92c4ccfe2..2f20e3e32 100644 --- a/tests/unit/a11y/CreateProjectWizard.a11y.test.tsx +++ b/tests/unit/a11y/CreateProjectWizard.a11y.test.tsx @@ -24,6 +24,25 @@ vi.mock("../../../src/services/sources/githubService", () => ({ addGithubPat: vi.fn(), })); +vi.mock("../../../src/services/sources/bitbucketService", () => ({ + discoverBitbucketRepositories: vi.fn().mockResolvedValue({ + repositories: [ + { + workspace: "acme", + slug: "widgets", + name: "Widgets", + isPrivate: true, + url: "https://bitbucket.org/acme/widgets", + alreadyConnected: false, + isEnabled: null, + }, + ], + hasMore: false, + }), + connectBitbucketRepository: vi.fn(), + addBitbucketRepositoryToProject: vi.fn(), +})); + vi.mock("../../../src/services/ingestionService", () => ({ getIngestionSourceStatuses: vi.fn().mockResolvedValue([]), })); @@ -67,6 +86,12 @@ async function openGithubDetail(user: ReturnType) { await user.click(screen.getByRole("button", { name: /indexes repositories/i })); } +/** From the sources step, open the add-source flow and pick the Bitbucket type. */ +async function openBitbucketDetail(user: ReturnType) { + await user.click(screen.getByRole("button", { name: /add source/i })); + await user.click(screen.getByRole("button", { name: /indexes pull requests, readme/i })); +} + describe("CreateProjectWizard Accessibility", () => { beforeEach(() => { // Narrowest layout by default — the credential form opens inline. The @@ -151,6 +176,36 @@ describe("CreateProjectWizard Accessibility", () => { expect(await axe(baseElement)).toHaveNoViolations(); }); + it("has no axe violations on the Bitbucket detail", async () => { + const user = userEvent.setup(); + const { baseElement } = renderWizard(); + + await screen.findByRole("dialog", { name: "New Project" }); + await waitFor(() => expect(screen.getByLabelText(/^Name/)).toBeInTheDocument()); + await goToSources(user); + await openBitbucketDetail(user); + + // No credential is stored in this suite, so the form is locked and the chip says why. + expect(await screen.findByText("No credential yet")).toBeInTheDocument(); + + expect(await axe(baseElement)).toHaveNoViolations(); + }); + + it("has no axe violations with the inline Atlassian credential form open on the Bitbucket detail", async () => { + const user = userEvent.setup(); + const { baseElement } = renderWizard(); + + await screen.findByRole("dialog", { name: "New Project" }); + await waitFor(() => expect(screen.getByLabelText(/^Name/)).toBeInTheDocument()); + await goToSources(user); + await openBitbucketDetail(user); + + await user.click(screen.getByRole("button", { name: /add atlassian credential/i })); + + expect(await screen.findByTestId("settings-atlassian-add-email")).toBeInTheDocument(); + expect(await axe(baseElement)).toHaveNoViolations(); + }); + it("has no axe violations on the review step with a member who would be moved", async () => { const user = userEvent.setup(); const mover: AdminUser = { diff --git a/tests/unit/a11y/SourceConnectModal.a11y.test.tsx b/tests/unit/a11y/SourceConnectModal.a11y.test.tsx index 136f75d79..fd8e6192d 100644 --- a/tests/unit/a11y/SourceConnectModal.a11y.test.tsx +++ b/tests/unit/a11y/SourceConnectModal.a11y.test.tsx @@ -23,6 +23,12 @@ describe("SourceConnectModal Accessibility", () => { type: "Space", icon: Activity, }, + BITBUCKET: { + name: "Bitbucket", + description: "Connect to Bitbucket", + type: "Repository", + icon: Activity, + }, }; const { baseElement } = render( diff --git a/tests/unit/a11y/SourceDetailsPanel.a11y.test.tsx b/tests/unit/a11y/SourceDetailsPanel.a11y.test.tsx index daa890c5a..d8312d829 100644 --- a/tests/unit/a11y/SourceDetailsPanel.a11y.test.tsx +++ b/tests/unit/a11y/SourceDetailsPanel.a11y.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest"; import { axe } from "vitest-axe"; import { MemoryRouter } from "react-router-dom"; import { GitBranch } from "lucide-react"; +import { BitbucketIcon } from "../../../src/components/icons/BitbucketIcon"; import { SourceDetailsPanel } from "../../../src/features/data-ingestion/components/SourceDetailsPanel"; import type { DataSource } from "../../../src/features/data-ingestion/types"; import { deriveSourceStatus } from "../../../src/features/data-ingestion/data"; @@ -55,4 +56,51 @@ describe("SourceDetailsPanel Accessibility", () => { expect(await axe(baseElement)).toHaveNoViolations(); }); + + it("has no a11y violations for a Bitbucket repository with all actions", async () => { + const bitbucketRepository = { + repositoryId: "bb-repo-1", + workspace: "acme", + slug: "widgets", + fullName: "acme/widgets", + url: "https://bitbucket.org/acme/widgets", + enabled: true, + }; + const bitbucketSource: DataSource = { + ...source, + sourceId: "bb-repo-1", + sourceSystem: "BITBUCKET", + name: "acme/widgets", + type: "Bitbucket", + icon: BitbucketIcon, + lastPullRequestsSyncAt: "2026-07-01T00:00:00.000Z", + bitbucketRepository, + }; + + const { baseElement } = render( + + + , + ); + + await waitFor(() => { + expect(screen.getAllByText("acme/widgets").length).toBeGreaterThan(0); + }); + await screen.findByLabelText("Minutes"); + + expect(await axe(baseElement)).toHaveNoViolations(); + }); }); diff --git a/tests/unit/features/admin/components/CreateProjectWizard.test.tsx b/tests/unit/features/admin/components/CreateProjectWizard.test.tsx index 8ffd5c4ac..64be1f95c 100644 --- a/tests/unit/features/admin/components/CreateProjectWizard.test.tsx +++ b/tests/unit/features/admin/components/CreateProjectWizard.test.tsx @@ -24,6 +24,12 @@ vi.mock("../../../../../src/services/sources/githubService", () => ({ addGithubPat: vi.fn(), })); +vi.mock("../../../../../src/services/sources/bitbucketService", () => ({ + discoverBitbucketRepositories: vi.fn(), + connectBitbucketRepository: vi.fn(), + addBitbucketRepositoryToProject: vi.fn(), +})); + vi.mock("../../../../../src/services/ingestionService", () => ({ getIngestionSourceStatuses: vi.fn(), })); @@ -55,6 +61,11 @@ import { discoverRepositories, getGithubPatNames, } from "../../../../../src/services/sources/githubService"; +import { + addBitbucketRepositoryToProject, + connectBitbucketRepository, + discoverBitbucketRepositories, +} from "../../../../../src/services/sources/bitbucketService"; import { connectJiraInstance } from "../../../../../src/services/sources/jiraService"; import { addAtlassianCredential, @@ -201,6 +212,34 @@ describe("CreateProjectWizard", () => { resolvedOwnerType: "org", }); vi.mocked(getIngestionSourceStatuses).mockResolvedValue([]); + vi.mocked(connectBitbucketRepository).mockResolvedValue({ transactionId: "bb-tx" }); + vi.mocked(addBitbucketRepositoryToProject).mockResolvedValue({ + repositoryId: "bb-42", + projectIds: ["proj-new"], + }); + vi.mocked(discoverBitbucketRepositories).mockResolvedValue({ + repositories: [ + { + workspace: "acme", + slug: "widgets", + name: "widgets", + isPrivate: false, + url: "https://bitbucket.org/acme/widgets", + alreadyConnected: false, + isEnabled: null, + }, + { + workspace: "acme", + slug: "gadgets", + name: "gadgets", + isPrivate: true, + url: "https://bitbucket.org/acme/gadgets", + alreadyConnected: false, + isEnabled: null, + }, + ], + hasMore: false, + }); vi.mocked(getGithubPatNames).mockResolvedValue(["team-pat"]); vi.mocked(addGithubPat).mockResolvedValue(undefined); vi.mocked(addAtlassianCredential).mockResolvedValue(undefined); @@ -262,6 +301,198 @@ describe("CreateProjectWizard", () => { await user.click(screen.getByRole("button", { name: /add to list/i })); } + /** From the sources step, open the add-source flow and pick the Bitbucket type. */ + async function openBitbucketDetail(user: ReturnType) { + await user.click(screen.getByRole("button", { name: /add source/i })); + await user.click(screen.getByRole("button", { name: /indexes pull requests, readme/i })); + + // The stored credential is shared with Jira and Confluence and adopted automatically. + await screen.findByText(/Team token - me@example.com/i); + } + + /** Discovers the `acme` workspace and ticks the given repositories. */ + async function discoverBitbucket(user: ReturnType, ...slugs: string[]) { + await user.type(screen.getByLabelText("Workspace or bitbucket.org URL"), "acme"); + await user.click(screen.getByRole("button", { name: /discover/i })); + + for (const slug of slugs) { + await user.click(await screen.findByRole("checkbox", { name: new RegExp(slug, "i") })); + } + } + + /** Stages `widgets` as a Bitbucket source and returns to the sources list. */ + async function stageBitbucketWidgets(user: ReturnType) { + await openBitbucketDetail(user); + await discoverBitbucket(user, "widgets"); + await user.click(screen.getByRole("button", { name: /add to list/i })); + } + + it("offers Bitbucket as a connectable type in the add-source grid", async () => { + const user = userEvent.setup(); + renderWizard(); + await goToSources(user); + await user.click(screen.getByRole("button", { name: /add source/i })); + + expect(screen.getByRole("button", { name: /bitbucket/i })).not.toHaveTextContent(/soon/i); + }); + + it("stages Bitbucket repositories and connects them against the new project on Create", async () => { + const user = userEvent.setup(); + renderWizard(); + await goToSources(user); + + await openBitbucketDetail(user); + await discoverBitbucket(user, "widgets", "gadgets"); + await user.click(screen.getByRole("button", { name: /add to list/i })); + + // Back on the sources list, both are staged under workspace/slug. + expect(screen.getByText("acme/widgets")).toBeInTheDocument(); + expect(screen.getByText("acme/gadgets")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /continue/i })); + await user.click(screen.getByRole("button", { name: /create project/i })); + + await waitFor(() => expect(vi.mocked(connectBitbucketRepository)).toHaveBeenCalledTimes(2)); + expect(vi.mocked(connectBitbucketRepository)).toHaveBeenCalledWith({ + workspace: "acme", + slug: "widgets", + credentialName: "Team token", + projectId: "proj-new", + }); + expect(vi.mocked(connectGithubRepository)).not.toHaveBeenCalled(); + }); + + it("shows the staged Bitbucket repository on the review step", async () => { + const user = userEvent.setup(); + renderWizard(); + await goToSources(user); + await stageBitbucketWidgets(user); + + await user.click(screen.getByRole("button", { name: /continue/i })); + + expect(screen.getByText("acme/widgets")).toBeInTheDocument(); + expect(screen.getByText("Bitbucket")).toBeInTheDocument(); + }); + + it("connects a mixed batch of a GitHub repo and a Bitbucket repo", async () => { + const user = userEvent.setup(); + renderWizard(); + await goToSources(user); + // Same owner/name on both providers: they are two different sources. + await stageWidgets(user); + await stageBitbucketWidgets(user); + + expect(screen.getAllByText("acme/widgets")).toHaveLength(2); + + await user.click(screen.getByRole("button", { name: /continue/i })); + await user.click(screen.getByRole("button", { name: /create project/i })); + + await waitFor(() => expect(vi.mocked(connectBitbucketRepository)).toHaveBeenCalledTimes(1)); + expect(vi.mocked(connectGithubRepository)).toHaveBeenCalledWith( + expect.objectContaining({ owner: "acme", name: "widgets", projectId: "proj-new" }), + ); + expect(vi.mocked(connectBitbucketRepository)).toHaveBeenCalledWith( + expect.objectContaining({ workspace: "acme", slug: "widgets", projectId: "proj-new" }), + ); + }); + + it("links an already-ingested Bitbucket repository instead of re-ingesting it", async () => { + vi.mocked(discoverBitbucketRepositories).mockResolvedValue({ + repositories: [ + { + workspace: "acme", + slug: "linked", + name: "linked", + isPrivate: false, + url: "https://bitbucket.org/acme/linked", + alreadyConnected: true, + isEnabled: true, + }, + ], + hasMore: false, + }); + vi.mocked(getIngestionSourceStatuses).mockResolvedValue([ + { + ...statusRow("acme/linked", "bb-42"), + sourceSystem: "BITBUCKET", + }, + ]); + const user = userEvent.setup(); + renderWizard(); + await goToSources(user); + + await openBitbucketDetail(user); + await user.type(screen.getByLabelText("Workspace or bitbucket.org URL"), "acme"); + await user.click(screen.getByRole("button", { name: /discover/i })); + const linkedCheckbox = await screen.findByRole("checkbox", { name: /linked/i }); + await waitFor(() => expect(linkedCheckbox).toBeEnabled()); + await user.click(linkedCheckbox); + await user.click(screen.getByRole("button", { name: /add to list/i })); + + expect(screen.getByText(/Already ingested, will be linked/)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /continue/i })); + await user.click(screen.getByRole("button", { name: /create project/i })); + + await waitFor(() => + expect(vi.mocked(addBitbucketRepositoryToProject)).toHaveBeenCalledWith("bb-42", "proj-new"), + ); + expect(vi.mocked(connectBitbucketRepository)).not.toHaveBeenCalled(); + }); + + it("keeps the project and offers a retry when a Bitbucket repository fails to connect", async () => { + vi.mocked(connectBitbucketRepository).mockRejectedValueOnce(new Error("no access")); + const user = userEvent.setup(); + renderWizard(); + await goToSources(user); + await stageBitbucketWidgets(user); + + await user.click(screen.getByRole("button", { name: /continue/i })); + await user.click(screen.getByRole("button", { name: /create project/i })); + + expect(await screen.findByRole("button", { name: /retry/i })).toBeInTheDocument(); + expect(screen.getByText("no access")).toBeInTheDocument(); + expect(vi.mocked(projectService.createProject)).toHaveBeenCalledTimes(1); + }); + + it("adds an Atlassian credential inline while staging a Bitbucket repository and selects the new one", async () => { + vi.mocked(getMyAtlassianCredentials) + .mockResolvedValueOnce([]) // initial load: none stored + .mockResolvedValue([{ userEmail: "new@example.com", displayName: "Fresh cred" }]); + const user = userEvent.setup(); + renderWizard(); + await goToSources(user); + await user.click(screen.getByRole("button", { name: /add source/i })); + await user.click(screen.getByRole("button", { name: /indexes pull requests, readme/i })); + + await user.click(screen.getByRole("button", { name: /add atlassian credential/i })); + await user.type(screen.getByTestId("settings-atlassian-add-email"), "new@example.com"); + await user.type(screen.getByTestId("settings-atlassian-add-name"), "Fresh cred"); + await user.type(screen.getByTestId("settings-atlassian-add-token"), "bitbucket-token"); + await user.click(screen.getByTestId("settings-atlassian-add-submit")); + + await waitFor(() => + expect(vi.mocked(addAtlassianCredential)).toHaveBeenCalledWith({ + userEmail: "new@example.com", + tokenName: "Fresh cred", + authToken: "bitbucket-token", + }), + ); + // The refreshed credential is adopted and drives discovery. + await screen.findByText(/Fresh cred - new@example.com/i); + expect(screen.getByLabelText("Credential")).toHaveTextContent("Fresh cred"); + + await user.type(screen.getByLabelText("Workspace or bitbucket.org URL"), "acme"); + await user.click(screen.getByRole("button", { name: /discover/i })); + await screen.findByRole("checkbox", { name: /widgets/i }); + expect(vi.mocked(discoverBitbucketRepositories)).toHaveBeenCalledWith( + "acme", + "Fresh cred", + 0, + 20, + ); + }); + it("keeps details on a blank name and explains why instead of a dead button", async () => { const user = userEvent.setup(); renderWizard(); diff --git a/tests/unit/features/admin/projectSourcesDraft.test.ts b/tests/unit/features/admin/projectSourcesDraft.test.ts index 21ae84fe8..57e15a219 100644 --- a/tests/unit/features/admin/projectSourcesDraft.test.ts +++ b/tests/unit/features/admin/projectSourcesDraft.test.ts @@ -5,6 +5,8 @@ import { connectOutcome, connectOutcomeDescription, countUnconnectedSources, + createBitbucketDraft, + createBitbucketDraftFromDiscovery, createDraftSource, createDraftSourceFromDiscovery, createJiraDraft, @@ -16,7 +18,12 @@ import { setDraftSourceOwner, type DraftSource, } from "../../../../src/features/admin/projectSourcesDraft"; +import { ApiError } from "../../../../src/services/apiClient"; import { knowledgeGapService } from "../../../../src/services/knowledgeGapService"; +import { + addBitbucketRepositoryToProject, + connectBitbucketRepository, +} from "../../../../src/services/sources/bitbucketService"; import { addRepositoryToProject, connectGithubRepository, @@ -30,6 +37,11 @@ vi.mock("../../../../src/services/sources/githubService", () => ({ addRepositoryToProject: vi.fn(), })); +vi.mock("../../../../src/services/sources/bitbucketService", () => ({ + connectBitbucketRepository: vi.fn(), + addBitbucketRepositoryToProject: vi.fn(), +})); + vi.mock("../../../../src/services/sources/jiraService", () => ({ connectJiraInstance: vi.fn(), })); @@ -44,6 +56,8 @@ vi.mock("../../../../src/services/knowledgeGapService", () => ({ const connectGithubRepositoryMock = vi.mocked(connectGithubRepository); const addRepositoryToProjectMock = vi.mocked(addRepositoryToProject); +const connectBitbucketRepositoryMock = vi.mocked(connectBitbucketRepository); +const addBitbucketRepositoryToProjectMock = vi.mocked(addBitbucketRepositoryToProject); const connectJiraInstanceMock = vi.mocked(connectJiraInstance); const uploadDocumentsMock = vi.mocked(knowledgeService.uploadDocuments); const setComponentOwnersMock = vi.mocked(knowledgeGapService.setComponentOwners); @@ -60,6 +74,13 @@ beforeEach(() => { connectGithubRepositoryMock.mockResolvedValue({ transactionId: "tx" }); addRepositoryToProjectMock.mockReset(); addRepositoryToProjectMock.mockResolvedValue({ repositoryId: "r1", projectIds: ["p1"] }); + connectBitbucketRepositoryMock.mockReset(); + connectBitbucketRepositoryMock.mockResolvedValue({ transactionId: "bb-tx" }); + addBitbucketRepositoryToProjectMock.mockReset(); + addBitbucketRepositoryToProjectMock.mockResolvedValue({ + repositoryId: "bb-r1", + projectIds: ["p1"], + }); connectJiraInstanceMock.mockReset(); connectJiraInstanceMock.mockResolvedValue(undefined); uploadDocumentsMock.mockReset(); @@ -166,6 +187,24 @@ describe("isSameSource", () => { ).toBe(true); }); + it("matches Bitbucket repositories by workspace/slug regardless of casing", () => { + expect( + isSameSource( + createBitbucketDraft("acme", "widgets", "a"), + createBitbucketDraft("ACME", "Widgets", "b"), + ), + ).toBe(true); + }); + + it("does not match a Bitbucket repository with a GitHub one of the same name", () => { + expect( + isSameSource( + createBitbucketDraft("acme", "widgets", "cred"), + createDraftSource("acme", "widgets", "pat"), + ), + ).toBe(false); + }); + it("never treats two uploads as the same source", () => { expect(isSameSource(createUploadDraft("A", []), createUploadDraft("A", []))).toBe(false); }); @@ -204,6 +243,53 @@ describe("createDraftSourceFromDiscovery", () => { }); }); +describe("createBitbucketDraft / createBitbucketDraftFromDiscovery", () => { + const baseSelection: DiscoverySelection = { + owner: "acme", + name: "widgets", + isPrivate: false, + linkState: "new", + }; + + it("stages a pending Bitbucket repository with a unique id", () => { + const first = createBitbucketDraft("acme", "widgets", "atlassian-main"); + const second = createBitbucketDraft("acme", "widgets", "atlassian-main"); + + expect(first).toMatchObject({ + type: "BITBUCKET", + workspace: "acme", + slug: "widgets", + credentialName: "atlassian-main", + status: "pending", + wasReused: false, + }); + expect(first.repositoryId).toBeUndefined(); + expect(first.id).not.toBe(second.id); + }); + + it("reads the workspace from the selection's owner and the slug from its name", () => { + const draft = createBitbucketDraftFromDiscovery(baseSelection, "atlassian-main"); + + expect(draft).toMatchObject({ workspace: "acme", slug: "widgets" }); + expect(draft.repositoryId).toBeUndefined(); + }); + + it("keeps the repository id for a linkable repository only", () => { + expect( + createBitbucketDraftFromDiscovery( + { ...baseSelection, linkState: "linkable", repositoryId: "bb-9" }, + "cred", + ).repositoryId, + ).toBe("bb-9"); + expect( + createBitbucketDraftFromDiscovery( + { ...baseSelection, linkState: "new", repositoryId: "bb-9" }, + "cred", + ).repositoryId, + ).toBeUndefined(); + }); +}); + describe("addDraftSource", () => { it("appends a new repository", () => { const sources = addDraftSource([], createDraftSource("acme", "widgets", "pat")); @@ -443,6 +529,54 @@ describe("connectDraftSources", () => { expect(result[0].ownerAssignmentFailed).toBe(true); }); + it("connects a new Bitbucket repository through the Bitbucket connector", async () => { + const result = await connectDraftSources("p1", [ + createBitbucketDraft("acme", "widgets", "atlassian-main"), + ]); + + expect(connectBitbucketRepositoryMock).toHaveBeenCalledWith({ + workspace: "acme", + slug: "widgets", + credentialName: "atlassian-main", + projectId: "p1", + }); + expect(addBitbucketRepositoryToProjectMock).not.toHaveBeenCalled(); + expect(result[0]).toMatchObject({ status: "connected", wasReused: false }); + }); + + it("links an already-ingested Bitbucket repository instead of re-ingesting it", async () => { + const result = await connectDraftSources("p1", [ + createBitbucketDraft("acme", "linked", "cred", "bb-42"), + ]); + + expect(addBitbucketRepositoryToProjectMock).toHaveBeenCalledWith("bb-42", "p1"); + expect(connectBitbucketRepositoryMock).not.toHaveBeenCalled(); + expect(result[0]).toMatchObject({ status: "connected", wasReused: true }); + }); + + it("says what to check when the backend answers a bare 500 for a Bitbucket repository", async () => { + connectBitbucketRepositoryMock.mockRejectedValue(new ApiError(500, "Internal Server Error")); + + const result = await connectDraftSources("p1", [ + createBitbucketDraft("acme", "missing", "cred"), + ]); + + expect(result[0].status).toBe("failed"); + expect(result[0].errorMessage).toMatch(/acme\/missing/); + expect(result[0].errorMessage).toMatch(/exists/); + expect(result[0].errorMessage).not.toMatch(/Internal Server Error/); + }); + + it("keeps the backend's own message for a Bitbucket failure that is not a 500", async () => { + connectBitbucketRepositoryMock.mockRejectedValue( + new ApiError(404, "Atlassian credential 'gone' was not found"), + ); + + const result = await connectDraftSources("p1", [createBitbucketDraft("acme", "w", "gone")]); + + expect(result[0].errorMessage).toBe("Atlassian credential 'gone' was not found"); + }); + it("dispatches each source type in one mixed batch", async () => { const result = await connectDraftSources("p1", [ createDraftSource("acme", "widgets", "pat"), @@ -455,6 +589,17 @@ describe("connectDraftSources", () => { expect(uploadDocumentsMock).toHaveBeenCalledTimes(1); expect(result.every((source) => source.status === "connected")).toBe(true); }); + + it("connects a Bitbucket repository alongside a GitHub one in a mixed batch", async () => { + const result = await connectDraftSources("p1", [ + createDraftSource("acme", "widgets", "pat"), + createBitbucketDraft("acme", "widgets", "cred"), + ]); + + expect(connectGithubRepositoryMock).toHaveBeenCalledTimes(1); + expect(connectBitbucketRepositoryMock).toHaveBeenCalledTimes(1); + expect(result.every((source) => source.status === "connected")).toBe(true); + }); }); describe("connectOutcome / connectOutcomeDescription", () => { diff --git a/tests/unit/features/chatbot/useAvailableSources.test.tsx b/tests/unit/features/chatbot/useAvailableSources.test.tsx index 77e55a63c..271a6e010 100644 --- a/tests/unit/features/chatbot/useAvailableSources.test.tsx +++ b/tests/unit/features/chatbot/useAvailableSources.test.tsx @@ -36,6 +36,15 @@ describe("useAvailableSources", () => { expect([...result.current.sources].sort()).toEqual(["CONFLUENCE", "GITHUB", "JIRA", "UPLOAD"]); }); + it("offers Bitbucket once its connector is enabled", async () => { + mockListConnectors.mockResolvedValue([connector("github"), connector("bitbucket")]); + + const { result } = renderHook(() => useAvailableSources()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect([...result.current.sources].sort()).toEqual(["BITBUCKET", "GITHUB", "UPLOAD"]); + }); + it("leaves out a disabled connector and any id the chat has no filter for", async () => { mockListConnectors.mockResolvedValue([ connector("github"), diff --git a/tests/unit/features/connectors/connectorMeta.test.ts b/tests/unit/features/connectors/connectorMeta.test.ts new file mode 100644 index 000000000..89514925b --- /dev/null +++ b/tests/unit/features/connectors/connectorMeta.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { BitbucketIcon } from "../../../../src/components/icons/BitbucketIcon"; +import { getConnectorMeta, toConnectorListItems } from "../../../../src/features/connectors/data"; + +const connector = (id: string, name = `${id} connector`) => ({ + id, + name, + enabled: true, + firstConfiguredAt: null, + lastConfiguredAt: null, +}); + +describe("getConnectorMeta", () => { + it("knows the Bitbucket connector and gives it the Bitbucket logo", () => { + const meta = getConnectorMeta(connector("bitbucket")); + + expect(meta.label).toBe("Bitbucket Repository Connector"); + expect(meta.description).toMatch(/Bitbucket Cloud/); + expect(meta.icon).toBe(BitbucketIcon); + }); + + it("still knows GitHub and Confluence", () => { + expect(getConnectorMeta(connector("github")).label).toBe("GitHub Repository Connector"); + expect(getConnectorMeta(connector("confluence")).label).toBe("Confluence Cloud Connector"); + }); + + it("falls back to the backend name for an unknown connector", () => { + expect(getConnectorMeta(connector("sonarqube", "SonarQube")).label).toBe("SonarQube"); + }); +}); + +describe("toConnectorListItems", () => { + it("attaches the resolved meta to each connector", () => { + const [item] = toConnectorListItems([connector("bitbucket")]); + + expect(item.id).toBe("bitbucket"); + expect(item.meta.label).toBe("Bitbucket Repository Connector"); + }); +}); diff --git a/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx b/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx index f9701efd5..388de664e 100644 --- a/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx +++ b/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx @@ -489,3 +489,316 @@ describe("AddSourceModal", () => { expect(screen.getByRole("button", { name: /connect now/i })).toBeDisabled(); }); }); + +describe("AddSourceModal Bitbucket", () => { + const WORKSPACE_LABEL = "Workspace or bitbucket.org URL"; + + const bitbucketDiscoveryBody = { + repositories: [ + { + workspace: "acme", + slug: "widgets", + name: "Widgets", + isPrivate: true, + url: "https://bitbucket.org/acme/widgets", + alreadyConnected: false, + enabled: null, + }, + { + workspace: "acme", + slug: "gadgets", + name: "gadgets", + isPrivate: false, + url: "https://bitbucket.org/acme/gadgets", + alreadyConnected: true, + enabled: true, + }, + ], + }; + + const bitbucketDiscoveryHandler = http.get( + "/api/v1/bitbucket/discover/workspace/:workspace", + () => HttpResponse.json(bitbucketDiscoveryBody), + ); + + /** Per-repo status rows; `gadgets` is ingested elsewhere (and optionally in this project). */ + function bitbucketStatusHandler({ inThisProject = false } = {}) { + const row = { + sourceSystem: "BITBUCKET", + sourceId: "acme/gadgets", + displayName: "acme/gadgets", + repositoryId: "bb-gadgets-id", + owner: "acme", + name: "gadgets", + sourceUrl: "https://bitbucket.org/acme/gadgets", + connectionStatus: "CONNECTED", + enabled: true, + artifactCount: 3, + }; + + return http.get("/api/v1/ingestion-sources/status", ({ request }) => { + const projectId = new URL(request.url).searchParams.get("projectId"); + + return HttpResponse.json(projectId && !inThisProject ? [] : [row]); + }); + } + + async function openBitbucketDetail(user: ReturnType) { + await user.click(screen.getByRole("button", { name: /bitbucket/i })); + await waitFor(() => + expect(screen.getByLabelText("Credential")).toHaveTextContent("default - me@corp.com"), + ); + } + + async function discoverAcme(user: ReturnType) { + await user.type(screen.getByLabelText(WORKSPACE_LABEL), "acme"); + await user.click(screen.getByRole("button", { name: "Discover" })); + await screen.findByText("Widgets"); + } + + const rowOf = (text: string) => screen.getByText(text).closest("label") as HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + server.use( + http.get("/api/v1/github/pat", () => HttpResponse.json(["default"])), + atlassianCredentialsHandler(["default"]), + ); + }); + + it("offers Bitbucket as a connectable type instead of a Soon card", () => { + renderModal(); + + const card = screen.getByRole("button", { name: /bitbucket/i }); + expect(card).not.toHaveTextContent(/soon/i); + }); + + it("opens the Bitbucket detail with a credential picker, the workspace field and the scope hint", async () => { + const user = userEvent.setup(); + renderModal(); + + await openBitbucketDetail(user); + + expect(screen.getByLabelText(WORKSPACE_LABEL)).toBeInTheDocument(); + expect(screen.getByText(/needs Bitbucket scopes/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add atlassian credential/i })).toBeInTheDocument(); + // The GitHub screen is not what renders. + expect(screen.queryByLabelText("Organization, user, or URL")).not.toBeInTheDocument(); + }); + + it("discovers a workspace with the selected credential and shows slug and visibility", async () => { + let discoverRequest: { workspace?: string; query: Record } | null = null; + server.use( + http.get("/api/v1/bitbucket/discover/workspace/:workspace", ({ params, request }) => { + discoverRequest = { + workspace: String(params.workspace), + query: Object.fromEntries(new URL(request.url).searchParams), + }; + return HttpResponse.json(bitbucketDiscoveryBody); + }), + bitbucketStatusHandler(), + ); + const user = userEvent.setup(); + renderModal(); + await openBitbucketDetail(user); + + await discoverAcme(user); + + expect(discoverRequest).toEqual({ + workspace: "acme", + query: { credentialName: "default", page: "0", pageSize: "20" }, + }); + expect(within(rowOf("Widgets")).getByText("Private")).toBeInTheDocument(); + expect(within(rowOf("gadgets")).getByText("Public")).toBeInTheDocument(); + }); + + it("shows a missing-credential notice once the list has loaded empty", async () => { + server.use(atlassianCredentialsHandler([])); + const user = userEvent.setup(); + renderModal(); + + await user.click(screen.getByRole("button", { name: /bitbucket/i })); + + expect(await screen.findByText("No credential yet")).toBeInTheDocument(); + expect(screen.getByLabelText(WORKSPACE_LABEL)).toBeDisabled(); + }); + + it("keeps 'Add to list' disabled until a repository is selected", async () => { + server.use(bitbucketDiscoveryHandler, bitbucketStatusHandler()); + const user = userEvent.setup(); + renderModal(); + await openBitbucketDetail(user); + + expect(screen.getByRole("button", { name: /add to list/i })).toBeDisabled(); + + await discoverAcme(user); + await user.click(within(rowOf("Widgets")).getByRole("checkbox")); + + expect(screen.getByRole("button", { name: /add to list/i })).toBeEnabled(); + }); + + it("stages the selection and connects each repository with the credential and project", async () => { + const bodies: unknown[] = []; + server.use( + bitbucketDiscoveryHandler, + bitbucketStatusHandler(), + http.post("/api/v1/bitbucket", async ({ request }) => { + bodies.push(await request.json()); + return HttpResponse.json({ transactionId: "tx" }, { status: 202 }); + }), + ); + const user = userEvent.setup(); + const props = renderModal(); + await openBitbucketDetail(user); + await discoverAcme(user); + + await user.click(within(rowOf("Widgets")).getByRole("checkbox")); + await user.click(screen.getByRole("button", { name: /add to list/i })); + + // Back on the staged list, titled workspace/slug with the credential as detail. + expect(await screen.findByText("acme/widgets")).toBeInTheDocument(); + expect(screen.getByText(/Not connected yet · default/)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /connect 1 source/i })); + + await waitFor(() => expect(props.onConnected).toHaveBeenCalledTimes(1)); + expect(props.onClose).toHaveBeenCalled(); + expect(bodies).toEqual([ + { workspace: "acme", slug: "widgets", credentialName: "default", projectId: "project-1" }, + ]); + }); + + it("links an already-ingested repository instead of re-ingesting it, with no reuse text from the backend", async () => { + let linkedPath: string | null = null; + let connectCalled = false; + server.use( + bitbucketDiscoveryHandler, + bitbucketStatusHandler(), + http.post( + "/api/v1/bitbucket/connections/:repositoryId/projects/:projectId", + ({ request }) => { + linkedPath = new URL(request.url).pathname; + return HttpResponse.json({ repositoryId: "bb-gadgets-id", projectIds: ["project-1"] }); + }, + ), + http.post("/api/v1/bitbucket", () => { + connectCalled = true; + return HttpResponse.json({ transactionId: "tx" }, { status: 202 }); + }), + ); + const user = userEvent.setup(); + const props = renderModal(); + await openBitbucketDetail(user); + await discoverAcme(user); + + const gadgetsRow = rowOf("gadgets"); + await waitFor(() => + expect(within(gadgetsRow).getByText("Already ingested")).toBeInTheDocument(), + ); + await user.click(within(gadgetsRow).getByRole("checkbox")); + await user.click(screen.getByRole("button", { name: /add to list/i })); + + expect(await screen.findByText(/Already ingested, will be linked/)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /connect 1 source/i })); + + await waitFor(() => expect(props.onConnected).toHaveBeenCalledTimes(1)); + expect(linkedPath).toBe("/api/v1/bitbucket/connections/bb-gadgets-id/projects/project-1"); + expect(connectCalled).toBe(false); + expect(await screen.findByText(/already available, nothing re-ingested/i)).toBeInTheDocument(); + }); + + it("keeps a repository that is already in this project unselectable", async () => { + server.use(bitbucketDiscoveryHandler, bitbucketStatusHandler({ inThisProject: true })); + const user = userEvent.setup(); + renderModal(); + await openBitbucketDetail(user); + await discoverAcme(user); + + const gadgetsRow = rowOf("gadgets"); + await waitFor(() => + expect(within(gadgetsRow).getByText("In this project")).toBeInTheDocument(), + ); + expect(within(gadgetsRow).getByRole("checkbox")).toBeDisabled(); + }); + + it("does not treat a GitHub repository with the same owner/name as ingested", async () => { + server.use( + bitbucketDiscoveryHandler, + http.get("/api/v1/ingestion-sources/status", () => + HttpResponse.json([ + { + sourceSystem: "GITHUB", + sourceId: "acme/gadgets", + displayName: "acme/gadgets", + repositoryId: "gh-gadgets-id", + owner: "acme", + name: "gadgets", + sourceUrl: "https://github.com/acme/gadgets", + connectionStatus: "CONNECTED", + enabled: true, + artifactCount: 3, + }, + ]), + ), + ); + const user = userEvent.setup(); + renderModal(); + await openBitbucketDetail(user); + await discoverAcme(user); + + const gadgetsRow = rowOf("gadgets"); + // Ingested as a Bitbucket repo elsewhere, but the only row that matches is GitHub's. + await waitFor(() => expect(within(gadgetsRow).getByText("Connected")).toBeInTheDocument()); + expect(within(gadgetsRow).queryByText("Already ingested")).not.toBeInTheDocument(); + expect(within(gadgetsRow).getByRole("checkbox")).toBeDisabled(); + }); + + it("explains a failed connect of an unreachable repository instead of showing the status text", async () => { + server.use( + bitbucketDiscoveryHandler, + bitbucketStatusHandler(), + http.post("/api/v1/bitbucket", () => + HttpResponse.json({ message: "Internal Server Error" }, { status: 500 }), + ), + ); + const user = userEvent.setup(); + renderModal(); + await openBitbucketDetail(user); + await discoverAcme(user); + + await user.click(within(rowOf("Widgets")).getByRole("checkbox")); + await user.click(screen.getByRole("button", { name: /connect now/i })); + + expect(await screen.findByRole("button", { name: /retry/i })).toBeInTheDocument(); + expect(screen.getByText(/Couldn't connect acme\/widgets/)).toBeInTheDocument(); + expect(screen.queryByText(/Internal Server Error/)).not.toBeInTheDocument(); + }); + + it("reports a missing credential on discovery in the adapter's words", async () => { + server.use( + http.get("/api/v1/bitbucket/discover/workspace/:workspace", () => + HttpResponse.json({ message: "Credential not found" }, { status: 404 }), + ), + ); + const user = userEvent.setup(); + renderModal(); + await openBitbucketDetail(user); + + await user.type(screen.getByLabelText(WORKSPACE_LABEL), "acme"); + await user.click(screen.getByRole("button", { name: "Discover" })); + + expect(await screen.findByText(/selected credential was not found/i)).toBeInTheDocument(); + }); + + it("can go back from the Bitbucket detail to the type grid", async () => { + const user = userEvent.setup(); + renderModal(); + await openBitbucketDetail(user); + + await user.click(screen.getByRole("button", { name: /back to source types/i })); + + expect(screen.getByRole("button", { name: /jira/i })).toBeInTheDocument(); + expect(screen.queryByLabelText(WORKSPACE_LABEL)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/features/data-ingestion/components/RepositoryDiscovery.test.tsx b/tests/unit/features/data-ingestion/components/RepositoryDiscovery.test.tsx new file mode 100644 index 000000000..4168c0303 --- /dev/null +++ b/tests/unit/features/data-ingestion/components/RepositoryDiscovery.test.tsx @@ -0,0 +1,420 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { + RepositoryDiscovery, + type DiscoveredRepositoryItem, + type DiscoverySelection, + type RepositoryDiscoveryAdapter, +} from "../../../../../src/features/data-ingestion/components/RepositoryDiscovery"; +import { server } from "../../../setup/vitest.setup"; + +function repo( + name: string, + overrides: Partial = {}, +): DiscoveredRepositoryItem { + return { + name, + isPrivate: false, + url: `https://example.test/acme/${name}`, + alreadyConnected: false, + isEnabled: null, + ...overrides, + }; +} + +function makeAdapter( + overrides: Partial = {}, +): RepositoryDiscoveryAdapter { + return { + sourceSystem: "BITBUCKET", + providerName: "Bitbucket", + discover: vi.fn().mockResolvedValue({ repositories: [], hasMore: false }), + parseInput: (value) => { + const [owner, name] = value.trim().split("/"); + return owner ? { owner, name: name ?? null } : null; + }, + ownerLabel: "Workspace", + ownerPlaceholder: "acme", + invalidInputMessage: "Enter a workspace.", + credentialRequiredMessage: "Choose a credential.", + missingCredentialNotice: "Add a credential first.", + emptyResultHint: "Nothing here.", + ...overrides, + }; +} + +const statusRow = (sourceSystem: string, fullName: string, repositoryId: string) => ({ + sourceSystem, + sourceId: fullName, + repositoryId, + owner: fullName.split("/")[0], + name: fullName.split("/")[1], + sourceUrl: `https://example.test/${fullName}`, + connectionStatus: "CONNECTED", + enabled: true, + artifactCount: 1, +}); + +function renderDiscovery( + adapter: RepositoryDiscoveryAdapter, + props: Partial[0]> = {}, +) { + const onSelectionChange = vi.fn<(selection: DiscoverySelection[]) => void>(); + + const tree = (overrides: Partial[0]> = {}) => ( + ( + + )} + projectId="project-1" + onSelectionChange={onSelectionChange} + {...props} + {...overrides} + /> + ); + const view = render(tree()); + + return { + onSelectionChange, + rerenderWith: (overrides: Partial[0]>) => + view.rerender(tree(overrides)), + }; +} + +async function discover(user: ReturnType, value = "acme") { + await user.type(screen.getByLabelText(/workspace/i), value); + await user.click(screen.getByRole("button", { name: /discover/i })); +} + +function lastSelection(onSelectionChange: ReturnType): DiscoverySelection[] { + const calls = onSelectionChange.mock.calls; + return calls[calls.length - 1][0] as DiscoverySelection[]; +} + +describe("RepositoryDiscovery", () => { + it("shows the adapter's labels and the credential picker slot", () => { + renderDiscovery(makeAdapter()); + + expect(screen.getByLabelText("Workspace")).toHaveAttribute("placeholder", "acme"); + expect(screen.getByRole("button", { name: "picker" })).toBeEnabled(); + }); + + it("shows the missing-credential notice and locks the form without credentials", () => { + renderDiscovery(makeAdapter(), { hasCredentials: false }); + + expect(screen.getByText("Add a credential first.")).toBeInTheDocument(); + expect(screen.getByLabelText("Workspace")).toBeDisabled(); + expect(screen.getByRole("button", { name: "picker" })).toBeDisabled(); + expect(screen.getByRole("button", { name: /discover/i })).toBeDisabled(); + }); + + it("hides the notice when the parent shows its own", () => { + renderDiscovery(makeAdapter(), { + hasCredentials: false, + suppressMissingCredentialNotice: true, + }); + + expect(screen.queryByText("Add a credential first.")).not.toBeInTheDocument(); + }); + + it("reports the adapter's message when the input has no owner", async () => { + const adapter = makeAdapter({ parseInput: () => null }); + renderDiscovery(adapter); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Workspace"), "x"); + await user.click(screen.getByRole("button", { name: /discover/i })); + + expect(await screen.findByText("Enter a workspace.")).toBeInTheDocument(); + expect(adapter.discover).not.toHaveBeenCalled(); + }); + + it("asks for a credential before discovering", async () => { + const adapter = makeAdapter(); + renderDiscovery(adapter, { credentialName: " " }); + + await discover(userEvent.setup()); + + expect(await screen.findByText("Choose a credential.")).toBeInTheDocument(); + expect(adapter.discover).not.toHaveBeenCalled(); + }); + + it("discovers with the parsed owner, credential and a 0-based first page", async () => { + const adapter = makeAdapter({ + discover: vi.fn().mockResolvedValue({ repositories: [repo("widgets")], hasMore: false }), + }); + renderDiscovery(adapter); + + await discover(userEvent.setup(), "acme"); + + expect(await screen.findByText("widgets")).toBeInTheDocument(); + expect(adapter.discover).toHaveBeenCalledWith("acme", "cred", 0, 20); + }); + + it("isolates the named repository when the input carries one", async () => { + const adapter = makeAdapter({ + discover: vi + .fn() + .mockResolvedValue({ repositories: [repo("widgets"), repo("gadgets")], hasMore: false }), + }); + renderDiscovery(adapter); + + await discover(userEvent.setup(), "acme/widgets"); + + expect(await screen.findByText("widgets")).toBeInTheDocument(); + expect(screen.queryByText("gadgets")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Filter repositories")).toHaveValue("widgets"); + }); + + it("loads the next page and appends it when there may be more", async () => { + const discoverPage = vi + .fn() + .mockResolvedValueOnce({ repositories: [repo("a")], hasMore: true }) + .mockResolvedValueOnce({ repositories: [repo("b")], hasMore: false }); + renderDiscovery(makeAdapter({ discover: discoverPage })); + const user = userEvent.setup(); + + await discover(user); + await user.click(await screen.findByRole("button", { name: /load more/i })); + + expect(await screen.findByText("b")).toBeInTheDocument(); + expect(screen.getByText("a")).toBeInTheDocument(); + expect(discoverPage).toHaveBeenLastCalledWith("acme", "cred", 1, 20); + expect(screen.queryByRole("button", { name: /load more/i })).not.toBeInTheDocument(); + }); + + it("keeps paging the discovery that produced the results when the owner field is edited", async () => { + const discoverPage = vi + .fn() + .mockResolvedValueOnce({ repositories: [repo("widgets")], hasMore: true }) + .mockResolvedValueOnce({ repositories: [repo("gadgets")], hasMore: false }); + const { onSelectionChange } = renderDiscovery(makeAdapter({ discover: discoverPage })); + const user = userEvent.setup(); + + await discover(user, "alpha"); + await user.click(await screen.findByRole("checkbox")); + await user.clear(screen.getByLabelText("Workspace")); + await user.type(screen.getByLabelText("Workspace"), "beta"); + await user.click(screen.getByRole("button", { name: /load more/i })); + + expect(await screen.findByText("gadgets")).toBeInTheDocument(); + expect(discoverPage).toHaveBeenLastCalledWith("alpha", "cred", 1, 20); + await waitFor(() => { + expect(lastSelection(onSelectionChange)).toEqual([ + expect.objectContaining({ owner: "alpha", name: "widgets" }), + ]); + }); + }); + + it("drops the results and the selection when the credential changes", async () => { + const adapter = makeAdapter({ + discover: vi.fn().mockResolvedValue({ repositories: [repo("widgets")], hasMore: true }), + }); + const { onSelectionChange, rerenderWith } = renderDiscovery(adapter); + const user = userEvent.setup(); + + await discover(user); + await user.click(await screen.findByRole("checkbox")); + + rerenderWith({ credentialName: "other" }); + + await waitFor(() => { + expect(screen.queryByText("widgets")).not.toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: /load more/i })).not.toBeInTheDocument(); + expect(lastSelection(onSelectionChange)).toEqual([]); + }); + + it("locks the form and the pager while a page is loading", async () => { + let resolvePage: (page: { + repositories: DiscoveredRepositoryItem[]; + hasMore: boolean; + }) => void = () => undefined; + const discoverPage = vi + .fn() + .mockResolvedValueOnce({ repositories: [repo("a")], hasMore: true }) + .mockReturnValueOnce(new Promise((resolve) => (resolvePage = resolve))); + renderDiscovery(makeAdapter({ discover: discoverPage })); + const user = userEvent.setup(); + + await discover(user); + await user.click(await screen.findByRole("button", { name: /load more/i })); + + expect(screen.getByLabelText("Workspace")).toBeDisabled(); + expect(screen.getByRole("button", { name: /discover/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /load more/i })).toBeDisabled(); + + resolvePage({ repositories: [repo("b")], hasMore: false }); + expect(await screen.findByText("b")).toBeInTheDocument(); + expect(screen.getByLabelText("Workspace")).toBeEnabled(); + }); + + it("shows the label instead of the name and filters by either", async () => { + const adapter = makeAdapter({ + discover: vi.fn().mockResolvedValue({ + repositories: [ + repo("widgets-repo", { label: "Widgets" }), + repo("gadgets-repo", { label: "Gadgets" }), + ], + hasMore: false, + }), + }); + renderDiscovery(adapter); + const user = userEvent.setup(); + + await discover(user); + expect(await screen.findByText("Widgets")).toBeInTheDocument(); + expect(screen.queryByText("widgets-repo")).not.toBeInTheDocument(); + + await user.type(screen.getByLabelText("Filter repositories"), "gadgets-r"); + expect(screen.queryByText("Widgets")).not.toBeInTheDocument(); + expect(screen.getByText("Gadgets")).toBeInTheDocument(); + }); + + it("omits the external link when a repository has no url", async () => { + const adapter = makeAdapter({ + discover: vi.fn().mockResolvedValue({ + repositories: [repo("linked"), repo("unlinked", { url: null })], + hasMore: false, + }), + }); + renderDiscovery(adapter); + + await discover(userEvent.setup()); + + await screen.findByText("unlinked"); + expect(screen.getByRole("link", { name: "Open linked on Bitbucket" })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /open unlinked/i })).not.toBeInTheDocument(); + }); + + it("uses the adapter's error wording and falls back to the error message", async () => { + const describeError = vi + .fn<(error: unknown, owner: string) => string | null>() + .mockReturnValueOnce("No workspace acme.") + .mockReturnValueOnce(null); + const adapter = makeAdapter({ + discover: vi.fn().mockRejectedValue(new Error("boom")), + describeError, + }); + renderDiscovery(adapter); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Workspace"), "acme"); + await user.click(screen.getByRole("button", { name: /discover/i })); + expect(await screen.findByText("No workspace acme.")).toBeInTheDocument(); + expect(describeError).toHaveBeenCalledWith(expect.any(Error), "acme"); + + await user.click(screen.getByRole("button", { name: /discover/i })); + expect(await screen.findByText("boom")).toBeInTheDocument(); + }); + + it("shows the adapter's empty-state hint when nothing is found", async () => { + renderDiscovery(makeAdapter()); + + await discover(userEvent.setup()); + + expect(await screen.findByText("No repositories found")).toBeInTheDocument(); + expect(screen.getByText("Nothing here.")).toBeInTheDocument(); + }); + + describe("link state", () => { + const discoverAlreadyConnected = () => + vi.fn().mockResolvedValue({ + repositories: [repo("widgets", { alreadyConnected: true, isEnabled: true })], + hasMore: false, + }); + + it("makes a repository ingested elsewhere linkable and hands its id to the selection", async () => { + server.use( + http.get("/api/v1/ingestion-sources/status", ({ request }) => + HttpResponse.json( + new URL(request.url).searchParams.get("projectId") + ? [] + : [statusRow("BITBUCKET", "acme/widgets", "bb-1")], + ), + ), + ); + const { onSelectionChange } = renderDiscovery( + makeAdapter({ discover: discoverAlreadyConnected() }), + ); + const user = userEvent.setup(); + + await discover(user); + expect(await screen.findByText("Already ingested")).toBeInTheDocument(); + await user.click(screen.getByRole("checkbox")); + + await waitFor(() => { + expect(lastSelection(onSelectionChange)).toEqual([ + { + owner: "acme", + name: "widgets", + isPrivate: false, + linkState: "linkable", + repositoryId: "bb-1", + }, + ]); + }); + }); + + it("ignores ingestion rows of another source system with the same owner/name", async () => { + // A GitHub repository acme/widgets must not make the Bitbucket acme/widgets linkable. + server.use( + // Answers both the global and the project-scoped request. + http.get("/api/v1/ingestion-sources/status", () => + HttpResponse.json([statusRow("GITHUB", "acme/widgets", "gh-1")]), + ), + ); + renderDiscovery(makeAdapter({ discover: discoverAlreadyConnected() })); + + await discover(userEvent.setup()); + + // Ingested elsewhere, but no Bitbucket row resolves it, and the GitHub row + // does not count as "in this project" either. + expect(await screen.findByText("Connected")).toBeInTheDocument(); + expect(screen.queryByText("Already ingested")).not.toBeInTheDocument(); + expect(screen.queryByText("In this project")).not.toBeInTheDocument(); + expect(screen.getByRole("checkbox")).toBeDisabled(); + }); + + it("marks a repository already in this project and keeps it unselectable", async () => { + server.use( + http.get("/api/v1/ingestion-sources/status", () => + HttpResponse.json([statusRow("BITBUCKET", "acme/widgets", "bb-1")]), + ), + ); + renderDiscovery(makeAdapter({ discover: discoverAlreadyConnected() })); + + await discover(userEvent.setup()); + + expect(await screen.findByText("In this project")).toBeInTheDocument(); + expect(screen.getByRole("checkbox")).toBeDisabled(); + }); + + it("treats a row without a system label as GitHub", async () => { + server.use( + http.get("/api/v1/ingestion-sources/status", () => + HttpResponse.json([ + { sourceId: "acme/widgets", repositoryId: "gh-1", connectionStatus: "CONNECTED" }, + ]), + ), + ); + const github = makeAdapter({ + sourceSystem: "GITHUB", + providerName: "GitHub", + discover: discoverAlreadyConnected(), + }); + renderDiscovery(github, { projectId: null }); + + await discover(userEvent.setup()); + + expect(await screen.findByText("Already ingested")).toBeInTheDocument(); + }); + }); +}); diff --git a/tests/unit/features/data-ingestion/components/RunDetailsPanel.test.tsx b/tests/unit/features/data-ingestion/components/RunDetailsPanel.test.tsx index 43dfd9a31..246b83d0b 100644 --- a/tests/unit/features/data-ingestion/components/RunDetailsPanel.test.tsx +++ b/tests/unit/features/data-ingestion/components/RunDetailsPanel.test.tsx @@ -91,6 +91,38 @@ describe("RunDetailsPanel", () => { expect(screen.getByText("acme.atlassian.net")).toBeInTheDocument(); }); + it("shows the Bitbucket workspace in the timing section", () => { + render( + , + ); + + expect(screen.getByText("Fetched from Bitbucket")).toBeInTheDocument(); + expect(screen.getByText("Workspace")).toBeInTheDocument(); + expect(screen.getByText("acme")).toBeInTheDocument(); + }); + + it("falls back to the workspace/slug prefix when a Bitbucket run has no owner", () => { + render( + , + ); + + expect(screen.getByText("Workspace")).toBeInTheDocument(); + expect(screen.getByText("acme")).toBeInTheDocument(); + }); + it("lists failed items", () => { render(); diff --git a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx index e3037e3ec..dca3754fc 100644 --- a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx +++ b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx @@ -88,6 +88,25 @@ const confluenceSource: DataSource = { }, }; +const bitbucketRepository = { + repositoryId: "bb-repo-1", + workspace: "acme", + slug: "widgets", + fullName: "acme/widgets", + url: "https://bitbucket.org/acme/widgets", + enabled: true, +}; + +const bitbucketSource: DataSource = { + ...mockSource, + sourceId: "bb-repo-1", + sourceSystem: "BITBUCKET", + name: "acme/widgets", + type: "Bitbucket", + githubRepository: null, + bitbucketRepository, +}; + describe("SourceDetailsPanel", () => { beforeEach(() => { window.localStorage.clear(); @@ -448,6 +467,213 @@ describe("SourceDetailsPanel", () => { expect(screen.queryByRole("button", { name: /Remove from project/ })).not.toBeInTheDocument(); }); + it("renders the Bitbucket repository section with workspace, slug and link", () => { + render(); + + expect(screen.getByText("Repository")).toBeInTheDocument(); + expect(screen.getByText("Workspace")).toBeInTheDocument(); + expect(screen.getByText("Slug")).toBeInTheDocument(); + expect(screen.getByText("widgets")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /bitbucket\.org\/acme\/widgets/ })).toHaveAttribute( + "href", + "https://bitbucket.org/acme/widgets", + ); + expect(screen.getByText("bb-repo-1")).toBeInTheDocument(); + // The GitHub-only "Owner" row does not appear. + expect(screen.queryByText("Owner")).not.toBeInTheDocument(); + }); + + it('updates a Bitbucket repository via the "Update repo" button', async () => { + const user = userEvent.setup(); + const onUpdateSource = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /Update repo/ })); + + expect(onUpdateSource).toHaveBeenCalledWith(bitbucketSource); + }); + + it("disables the Bitbucket update when the connection id is unknown", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: /Update repo/ })).toBeDisabled(); + }); + + it("toggles a Bitbucket repository's ingestion via onSetBitbucketSourceEnabled", async () => { + const user = userEvent.setup(); + const onSetBitbucketSourceEnabled = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await user.click(screen.getByRole("switch", { name: /Toggle ingestion for acme\/widgets/ })); + + expect(onSetBitbucketSourceEnabled).toHaveBeenCalledWith(bitbucketRepository, false); + }); + + it("shows a read-only source state without the toggle handler", () => { + render( + , + ); + + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + expect(screen.getByText("Disabled")).toBeInTheDocument(); + }); + + it("renders the Bitbucket sync schedule and loads it for the repository", async () => { + const onLoadBitbucketConfig = vi.fn().mockResolvedValue({ + autoUpdate: true, + spec: { type: "INTERVAL", everyMinutes: 30 }, + nextSyncAt: null, + }); + + render( + , + ); + + expect(screen.getByText("Sync Schedule")).toBeInTheDocument(); + await waitFor(() => { + expect(onLoadBitbucketConfig).toHaveBeenCalledWith(bitbucketRepository); + }); + }); + + it("saves the Bitbucket sync schedule via onSaveBitbucketConfig", async () => { + const user = userEvent.setup(); + const onLoadBitbucketConfig = vi.fn().mockResolvedValue({ + autoUpdate: true, + spec: { type: "INTERVAL", everyMinutes: 30 }, + nextSyncAt: null, + }); + const onSaveBitbucketConfig = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await waitFor(() => expect(onLoadBitbucketConfig).toHaveBeenCalled()); + + const minutes = screen.getByLabelText("Minutes"); + await user.clear(minutes); + await user.type(minutes, "45"); + await user.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => { + expect(onSaveBitbucketConfig).toHaveBeenCalledWith( + bitbucketRepository, + expect.objectContaining({ + autoUpdate: true, + schedule: { type: "INTERVAL", everyMinutes: 45 }, + }), + ); + }); + }); + + it("hides the Bitbucket schedule without the sync-settings permission", () => { + render( + , + ); + + expect(screen.queryByText("Sync Schedule")).not.toBeInTheDocument(); + }); + + it("unlinks a Bitbucket repository after confirming the dialog", async () => { + const user = userEvent.setup(); + const onUnlinkSource = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /Remove from project/ })); + + // Bitbucket sources are repositories, so the copy says so. + expect( + screen.getByRole("alertdialog", { name: /Remove repository from project/ }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /^Remove$/ })); + + expect(onUnlinkSource).toHaveBeenCalledWith(bitbucketSource); + }); + + it("hides the remove-from-project action when the Bitbucket repository has no id", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: /Remove from project/ })).not.toBeInTheDocument(); + }); + + it("shows only the pull-request sync time for Bitbucket", () => { + render( + , + ); + + expect(screen.getByText("Last Synced")).toBeInTheDocument(); + expect(screen.getByText("Pull requests")).toBeInTheDocument(); + expect(screen.queryByText("Commits")).not.toBeInTheDocument(); + expect(screen.queryByText("Issues")).not.toBeInTheDocument(); + }); + it("renders failed items from the source", () => { render( { statusView: { state: "syncing", label: "Syncing", - icon: mockSource.icon, + icon: GitBranch, tone: "brand", spinning: true, }, @@ -530,7 +756,7 @@ describe("SourceDetailsPanel", () => { statusView: { state: "syncing", label: "Syncing", - icon: mockSource.icon, + icon: GitBranch, tone: "brand", spinning: true, }, @@ -551,7 +777,7 @@ describe("SourceDetailsPanel", () => { statusView: { state: "syncing", label: "Syncing", - icon: mockSource.icon, + icon: GitBranch, tone: "brand", spinning: true, }, @@ -613,7 +839,7 @@ describe("SourceDetailsPanel", () => { statusView: { state: "syncing", label: "Syncing", - icon: mockSource.icon, + icon: GitBranch, tone: "brand", spinning: true, }, @@ -642,7 +868,7 @@ describe("SourceDetailsPanel", () => { statusView: { state: "syncing", label: "Syncing", - icon: mockSource.icon, + icon: GitBranch, tone: "brand", spinning: true, }, diff --git a/tests/unit/features/data-ingestion/components/SourceList.test.tsx b/tests/unit/features/data-ingestion/components/SourceList.test.tsx index 1d956f6be..c1629e523 100644 --- a/tests/unit/features/data-ingestion/components/SourceList.test.tsx +++ b/tests/unit/features/data-ingestion/components/SourceList.test.tsx @@ -91,6 +91,31 @@ describe("SourceList", () => { expect(screen.queryAllByText("https://acme.atlassian.net")).toHaveLength(0); }); + it("shows the Bitbucket workspace under the name", () => { + const sources: DataSource[] = [ + createMockSource({ + sourceId: "repo-uuid-1", + sourceSystem: "BITBUCKET", + name: "acme/widgets", + type: "Bitbucket", + bitbucketRepository: { + repositoryId: "repo-uuid-1", + workspace: "acme", + slug: "widgets", + fullName: "acme/widgets", + url: "https://bitbucket.org/acme/widgets", + enabled: true, + }, + }), + ]; + + render(); + + expect(screen.getAllByText("acme/widgets").length).toBeGreaterThan(0); + expect(screen.getAllByText("acme").length).toBeGreaterThan(0); + expect(screen.getAllByText("Bitbucket").length).toBeGreaterThan(0); + }); + it("renders info blocks with formatted values", () => { const sources: DataSource[] = [ createMockSource({ diff --git a/tests/unit/features/data-ingestion/data.test.ts b/tests/unit/features/data-ingestion/data.test.ts index 4f7b2b1c4..b17738657 100644 --- a/tests/unit/features/data-ingestion/data.test.ts +++ b/tests/unit/features/data-ingestion/data.test.ts @@ -4,6 +4,7 @@ import { SOURCE_META, INGESTION_RUN_LIMIT, DETAILS_RUN_LIMIT, + createBitbucketSourceFromInstance, createJiraSourceFromInstance, createConfluenceSourceFromConnection, createConfluenceSourceFromInstance, @@ -33,7 +34,7 @@ import type { ConfluenceConnectionDto } from "../../../../src/services/sources/c describe("data-ingestion data helpers", () => { describe("SOURCE_SYSTEMS / SOURCE_META", () => { it("lists all known source systems", () => { - expect(SOURCE_SYSTEMS).toEqual(["GITHUB", "JIRA", "UPLOAD", "CONFLUENCE"]); + expect(SOURCE_SYSTEMS).toEqual(["GITHUB", "JIRA", "UPLOAD", "CONFLUENCE", "BITBUCKET"]); }); it("provides meta for every source system", () => { @@ -543,4 +544,149 @@ describe("data-ingestion data helpers", () => { expect(source.confluenceSpace?.credentialName).toBe("team-cred"); }); }); + + describe("createBitbucketSourceFromInstance", () => { + const status = ( + overrides: Partial = {}, + ): SourceInstanceIngestionStatus => ({ + sourceSystem: "BITBUCKET", + sourceId: "acme/widgets", + displayName: "acme/widgets", + repositoryId: "repo-uuid-1", + owner: "acme", + name: "widgets", + sourceUrl: "https://bitbucket.org/acme/widgets", + connectionStatus: "CONNECTED", + enabled: true, + lastRunTime: "2026-09-28T10:00:00Z", + ingestedCount: 12, + updatedCount: 2, + deletedCount: 1, + failedCount: 0, + failedItems: [], + artifactCount: 64, + lastCommitsSyncAt: null, + lastIssuesSyncAt: null, + lastPullRequestsSyncAt: "2026-09-28T10:00:00Z", + ...overrides, + }); + + it("carries the repository identity in bitbucketRepository, not githubRepository", () => { + const source = createBitbucketSourceFromInstance(status()); + + expect(source.sourceSystem).toBe("BITBUCKET"); + expect(source.type).toBe("Bitbucket"); + expect(source.name).toBe("acme/widgets"); + expect(source.githubRepository).toBeNull(); + expect(source.bitbucketRepository).toEqual({ + repositoryId: "repo-uuid-1", + workspace: "acme", + slug: "widgets", + fullName: "acme/widgets", + url: "https://bitbucket.org/acme/widgets", + enabled: true, + }); + }); + + it("keys the card by the repository id, falling back to workspace/slug", () => { + expect(createBitbucketSourceFromInstance(status()).sourceId).toBe("repo-uuid-1"); + expect(createBitbucketSourceFromInstance(status({ repositoryId: null })).sourceId).toBe( + "acme/widgets", + ); + }); + + it("derives workspace and slug from the source id when the row carries no owner or name", () => { + const source = createBitbucketSourceFromInstance(status({ owner: null, name: null })); + + expect(source.bitbucketRepository).toMatchObject({ workspace: "acme", slug: "widgets" }); + }); + + it("takes counters and the artifact total from the status row", () => { + const source = createBitbucketSourceFromInstance(status()); + + expect(source.artifacts).toBe(64); + expect(source.totalArtifactCount).toBe(64); + expect(source.latestIngestedCount).toBe(12); + expect(source.latestUpdatedCount).toBe(2); + expect(source.deletedCount).toBe(1); + expect(source.errors).toBe(0); + }); + + it("only reports the pull-request sync time", () => { + const source = createBitbucketSourceFromInstance(status()); + + expect(source.lastPullRequestsSyncAt).toBe("2026-09-28T10:00:00Z"); + expect(source.lastCommitsSyncAt).toBeNull(); + expect(source.lastIssuesSyncAt).toBeNull(); + }); + + const cases: ConnectionStatus[] = ["CONNECTED", "UPDATING", "OUT_OF_DATE", "FAILED"]; + + for (const connectionStatus of cases) { + it(`carries the ${connectionStatus} connection status`, () => { + const source = createBitbucketSourceFromInstance(status({ connectionStatus })); + + expect(source.backendStatus).toBe(connectionStatus); + }); + } + + it("shows the syncing state while the repository is updating", () => { + const source = createBitbucketSourceFromInstance(status({ connectionStatus: "UPDATING" })); + + expect(source.statusView.state).toBe("syncing"); + }); + + it("overrides the status with DISABLED when the source is disabled", () => { + const source = createBitbucketSourceFromInstance(status({ enabled: false })); + + expect(source.backendStatus).toBe("DISABLED"); + expect(source.statusView.state).toBe("disabled"); + expect(source.bitbucketRepository?.enabled).toBe(false); + }); + + it("reports never-synced when the status row has no last run", () => { + const source = createBitbucketSourceFromInstance(status({ lastRunTime: null })); + + expect(source.statusView.state).toBe("attention"); + expect(source.lastRunAt).toBeNull(); + }); + + it("shows a synced badge after a clean sync", () => { + expect(createBitbucketSourceFromInstance(status()).ingestionStatusLabel).toBe("Synced"); + }); + + it("surfaces a disabled Bitbucket connector", () => { + const source = createBitbucketSourceFromInstance(status(), false); + + expect(source.ingestionStatusLabel).toBe("Synced"); + expect(source.statusView.state).toBe("disabled"); + }); + + it("resolves runs by workspace/slug to the card name", () => { + const source = createBitbucketSourceFromInstance(status()); + const labels = buildRunSourceLabels([source]); + const run: IngestionRun = { + runId: "run-1", + sourceSystem: "BITBUCKET", + sourceId: "acme/widgets", + owner: "acme", + name: "widgets", + repositoryId: "repo-uuid-1", + startedAt: "2026-09-28T10:00:00Z", + finishedAt: "2026-09-28T10:05:00Z", + ingestedCount: 12, + updatedCount: 2, + deletedCount: 1, + failedCount: 0, + status: "COMPLETED", + failedItems: [], + failureReason: null, + aiSyncStatus: "SUCCEEDED", + aiSyncFailureReason: null, + }; + + expect(labels.get("acme/widgets")).toBe("acme/widgets"); + expect(getRunSourceLabel(run, labels)).toBe("acme/widgets"); + }); + }); }); diff --git a/tests/unit/features/knowledge-base/bitbucketWorkspaceMetadata.test.ts b/tests/unit/features/knowledge-base/bitbucketWorkspaceMetadata.test.ts new file mode 100644 index 000000000..08d4c494f --- /dev/null +++ b/tests/unit/features/knowledge-base/bitbucketWorkspaceMetadata.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { parseBitbucketWorkspaceMetadata } from "../../../../src/features/knowledge-base/bitbucketWorkspaceMetadata"; + +describe("parseBitbucketWorkspaceMetadata", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("parses a fully-populated workspace profile", () => { + const parsed = parseBitbucketWorkspaceMetadata( + JSON.stringify({ + workspace: "acme", + uuid: "{1234}", + name: "Acme", + isPrivate: true, + createdOn: "2020-01-01T00:00:00.000Z", + url: "https://bitbucket.org/acme", + members: [{ accountId: "1", nickname: "ada", displayName: "Ada Lovelace" }], + }), + ); + + expect(parsed).toEqual({ + workspace: "acme", + name: "Acme", + isPrivate: true, + createdOn: "2020-01-01T00:00:00.000Z", + url: "https://bitbucket.org/acme", + members: [{ accountId: "1", nickname: "ada", displayName: "Ada Lovelace" }], + }); + }); + + it("reads the visibility under Jackson's `private` spelling as well", () => { + const parsed = parseBitbucketWorkspaceMetadata( + JSON.stringify({ workspace: "acme", name: "Acme", private: false, members: [] }), + ); + + expect(parsed?.isPrivate).toBe(false); + }); + + it("falls back to the workspace id and nulls for missing optional fields", () => { + const parsed = parseBitbucketWorkspaceMetadata( + JSON.stringify({ + workspace: " acme ", + members: [{ accountId: null, nickname: " ", displayName: "Grace" }, "not a member"], + }), + ); + + expect(parsed).toEqual({ + workspace: "acme", + name: "acme", + isPrivate: null, + createdOn: null, + url: null, + members: [{ accountId: null, nickname: null, displayName: "Grace" }], + }); + }); + + it.each([ + ["undefined", undefined], + ["null", null], + ["a blank string", " "], + ["a JSON array", "[]"], + ["a payload without workspace", JSON.stringify({ name: "Acme", members: [] })], + ["a payload without members", JSON.stringify({ workspace: "acme" })], + // The GitHub org shape names no workspace. + ["a GitHub org profile", JSON.stringify({ login: "acme", members: [] })], + ])("returns null for %s", (_label, input) => { + expect(parseBitbucketWorkspaceMetadata(input)).toBeNull(); + }); + + it("returns null and warns for malformed JSON", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + expect(parseBitbucketWorkspaceMetadata("{not json")).toBeNull(); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/features/knowledge-base/components/ArtifactFilters.test.tsx b/tests/unit/features/knowledge-base/components/ArtifactFilters.test.tsx index 7a25b4c7e..e0112f423 100644 --- a/tests/unit/features/knowledge-base/components/ArtifactFilters.test.tsx +++ b/tests/unit/features/knowledge-base/components/ArtifactFilters.test.tsx @@ -117,6 +117,42 @@ describe("ArtifactFilters", () => { expect(screen.getByTestId("kb-filter-option-markdown")).toBeInTheDocument(); }); + it("offers Bitbucket as a source with its own icon and reports its toggle", () => { + const onToggleSource = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByTestId("kb-filter-trigger")); + + const bitbucket = screen.getByTestId("kb-filter-option-bitbucket"); + // The Bitbucket logo is an inline SVG next to the option's label. + expect(bitbucket.closest("label")?.querySelector("svg")).not.toBeNull(); + + fireEvent.click(bitbucket); + expect(onToggleSource).toHaveBeenCalledWith("BITBUCKET"); + }); + + it("shows the repository facet while Bitbucket repositories are offered", () => { + render( + (["BITBUCKET"]), + repositoryOptions: [{ value: "acme/widgets", label: "acme/widgets", count: 2 }], + })} + />, + ); + + fireEvent.click(screen.getByTestId("kb-filter-trigger")); + + expect(screen.getByText("acme/widgets")).toBeInTheDocument(); + }); + it("summarises the source selection and shows active filter count", () => { const { rerender } = render( { expect(await screen.findByText("PR Description")).toBeInTheDocument(); }); + it("renders a Bitbucket pull request as markdown from its /pull-requests/ link alone", async () => { + const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); + vi.mocked(knowledgeService.getArtifactContent).mockResolvedValueOnce({ + content: "## Bitbucket PR\n- Reworked the widgets", + mimeType: "text/plain", + isObjectUrl: false, + }); + + renderDrawer( + createArtifact({ + title: "Rework widgets", + artifactType: "FILE", + sourceSystem: "BITBUCKET", + sourceUrl: "https://bitbucket.org/acme/widgets/pull-requests/12", + }), + ); + + const rawContent = await screen.findByTestId("raw-content"); + expect(rawContent.querySelector(".prose")).toBeInTheDocument(); + expect(await screen.findByText("Bitbucket PR")).toBeInTheDocument(); + }); + it("renders Jira / GitHub issues as markdown even if artifactType is FILE", async () => { const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); vi.mocked(knowledgeService.getArtifactContent).mockResolvedValueOnce({ @@ -945,6 +967,64 @@ describe("ArtifactViewerDrawer", () => { ); }); }); + + describe("Bitbucket workspace artifacts", () => { + const workspaceMetadata = JSON.stringify({ + workspace: "acme", + uuid: "{1234}", + name: "Acme Corp", + isPrivate: true, + createdOn: "2020-01-15T00:00:00.000Z", + url: "https://bitbucket.org/acme/", + members: [ + { accountId: "1", nickname: "ada", displayName: "Ada Lovelace" }, + { accountId: "2", nickname: "grace", displayName: null }, + ], + }); + + it("renders the workspace profile from metadata and never fetches content", async () => { + const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); + renderDrawer( + createArtifact({ + artifactType: "ORG_METADATA", + title: "Acme Corp", + sourceSystem: "BITBUCKET", + metadata: workspaceMetadata, + }), + ); + + expect(await screen.findByTestId("bitbucket-workspace-view")).toBeInTheDocument(); + expect(screen.queryByTestId("org-metadata-view")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "acme" })).toHaveAttribute( + "href", + "https://bitbucket.org/acme/", + ); + expect(screen.getByRole("link", { name: /repositories/i })).toHaveAttribute( + "href", + "https://bitbucket.org/acme/workspace/repositories", + ); + expect(screen.getByText("Private")).toBeInTheDocument(); + expect(screen.getByText("15 Jan 2020")).toBeInTheDocument(); + // A member without a display name falls back to the nickname. + expect(screen.getByText("Ada Lovelace")).toBeInTheDocument(); + expect(screen.getByText("grace")).toBeInTheDocument(); + expect(knowledgeService.getArtifactContent).not.toHaveBeenCalled(); + }); + + it("shows a quiet empty state when the workspace metadata is unusable", async () => { + renderDrawer( + createArtifact({ + artifactType: "ORG_METADATA", + title: "Acme Corp", + sourceSystem: "BITBUCKET", + metadata: JSON.stringify({ name: "Acme Corp" }), + }), + ); + + expect(await screen.findByText("Workspace profile unavailable.")).toBeInTheDocument(); + expect(screen.queryByText("Organization profile unavailable.")).not.toBeInTheDocument(); + }); + }); it("refuses an empty artifact instead of asking the AI", async () => { const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); diff --git a/tests/unit/features/knowledge-base/components/SourceLinkBadge.test.tsx b/tests/unit/features/knowledge-base/components/SourceLinkBadge.test.tsx index 9bd61755c..86c8d8d7a 100644 --- a/tests/unit/features/knowledge-base/components/SourceLinkBadge.test.tsx +++ b/tests/unit/features/knowledge-base/components/SourceLinkBadge.test.tsx @@ -21,6 +21,20 @@ describe("SourceLinkBadge", () => { expect(link).toHaveAttribute("title", "https://github.com/org/repo/blob/main/doc.md"); }); + it("renders Bitbucket link badge", () => { + render( + , + ); + + const link = screen.getByTestId("artifact-drawer-source-link"); + expect(link).toHaveTextContent("Open in Bitbucket"); + expect(link).toHaveAttribute("aria-label", "Open in Bitbucket (opens in a new tab)"); + expect(link).toHaveAttribute("href", "https://bitbucket.org/acme/widgets/pull-requests/12"); + }); + it("renders Jira link badge", () => { render( , diff --git a/tests/unit/features/knowledge-base/githubMetadata.test.ts b/tests/unit/features/knowledge-base/githubMetadata.test.ts index 2769435f9..d9e780df7 100644 --- a/tests/unit/features/knowledge-base/githubMetadata.test.ts +++ b/tests/unit/features/knowledge-base/githubMetadata.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getArtifactRepository, matchesRepository, + parseBitbucketMetadata, parseGithubMetadata, } from "../../../../src/features/knowledge-base/githubMetadata"; import type { Artifact } from "../../../../src/features/knowledge-base/types"; @@ -203,3 +204,133 @@ describe("matchesRepository", () => { ).toBe(true); }); }); + +describe("parseBitbucketMetadata", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("parses the backend shape with repositoryId, workspace and slug", () => { + const parsed = parseBitbucketMetadata( + JSON.stringify({ repositoryId: "bb-1", workspace: "acme", slug: "widgets" }), + ); + + expect(parsed).toEqual({ repositoryId: "bb-1", workspace: "acme", slug: "widgets" }); + }); + + it("accepts a workspace profile that names no slug", () => { + const parsed = parseBitbucketMetadata( + JSON.stringify({ workspace: "acme", uuid: "{1}", name: "Acme", members: [] }), + ); + + expect(parsed?.workspace).toBe("acme"); + expect(parsed?.slug).toBeUndefined(); + }); + + it("trims the names and drops a blank slug or non-string repositoryId", () => { + const parsed = parseBitbucketMetadata( + JSON.stringify({ repositoryId: 7, workspace: " acme ", slug: " " }), + ); + + expect(parsed).toEqual({ repositoryId: undefined, workspace: "acme", slug: undefined }); + }); + + it("returns null for missing, malformed or workspace-less metadata", () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + + expect(parseBitbucketMetadata(undefined)).toBeNull(); + expect(parseBitbucketMetadata("")).toBeNull(); + expect(parseBitbucketMetadata("{not json")).toBeNull(); + expect(parseBitbucketMetadata("[1]")).toBeNull(); + expect(parseBitbucketMetadata(JSON.stringify({ slug: "widgets" }))).toBeNull(); + expect(parseBitbucketMetadata(JSON.stringify({ workspace: " " }))).toBeNull(); + }); + + it("names Bitbucket in the malformed-metadata warning", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + parseBitbucketMetadata("{not json"); + + expect(warn).toHaveBeenCalledWith( + "Ignoring unparseable Bitbucket artifact metadata", + "{not json", + ); + }); +}); + +describe("getArtifactRepository for Bitbucket", () => { + const repoMetadata = JSON.stringify({ repositoryId: "bb-1", workspace: "acme", slug: "widgets" }); + + it("resolves workspace/slug for a repository artifact", () => { + expect( + getArtifactRepository({ + sourceSystem: "BITBUCKET", + artifactType: "PULL_REQUEST", + metadata: repoMetadata, + }), + ).toBe("acme/widgets"); + }); + + it("returns null for the workspace profile and for unusable metadata", () => { + expect( + getArtifactRepository({ + sourceSystem: "BITBUCKET", + artifactType: "ORG_METADATA", + metadata: JSON.stringify({ workspace: "acme", members: [] }), + }), + ).toBeNull(); + expect( + getArtifactRepository({ sourceSystem: "BITBUCKET", artifactType: "FILE", metadata: "" }), + ).toBeNull(); + }); + + it("does not read GitHub's repositoryFullName from a Bitbucket artifact", () => { + expect( + getArtifactRepository({ + sourceSystem: "BITBUCKET", + artifactType: "FILE", + metadata: JSON.stringify({ repositoryFullName: "acme/widgets" }), + }), + ).toBeNull(); + }); +}); + +describe("matchesRepository for Bitbucket", () => { + const artifact = ( + slug: string, + ): Pick => ({ + sourceSystem: "BITBUCKET", + artifactType: "FILE", + metadata: JSON.stringify({ repositoryId: "bb-1", workspace: "acme", slug }), + }); + const workspaceProfile = ( + workspace: string, + ): Pick => ({ + sourceSystem: "BITBUCKET", + artifactType: "ORG_METADATA", + metadata: JSON.stringify({ workspace, members: [] }), + }); + + it("narrows Bitbucket repository artifacts to the selected repositories", () => { + expect(matchesRepository(artifact("widgets"), new Set(["acme/widgets"]))).toBe(true); + expect(matchesRepository(artifact("gadgets"), new Set(["acme/widgets"]))).toBe(false); + }); + + it("matches everything while nothing is selected", () => { + expect(matchesRepository(artifact("widgets"), new Set())).toBe(true); + }); + + it("shows the workspace profile when its workspace owns a chosen repository, case-insensitively", () => { + expect(matchesRepository(workspaceProfile("ACME"), new Set(["acme/widgets"]))).toBe(true); + expect(matchesRepository(workspaceProfile("other"), new Set(["acme/widgets"]))).toBe(false); + }); + + it("still lets other sources through", () => { + expect( + matchesRepository( + { sourceSystem: "UPLOAD", artifactType: "FILE", metadata: "" }, + new Set(["acme/widgets"]), + ), + ).toBe(true); + }); +}); diff --git a/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts b/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts index c131679a6..43f499c3e 100644 --- a/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts +++ b/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts @@ -264,7 +264,11 @@ function mockArtifactQuery(artifacts: Artifact[]) { })); const repoCandidates = artifacts.filter( - (a) => matchesAllExcept(a, "repositories") && a.sourceSystem === "GITHUB", + (a) => + matchesAllExcept(a, "repositories") && + // This stands in for the backend's facet: it lists the repositories of both git + // providers, so the real backend has to do the same for Bitbucket. + (a.sourceSystem === "GITHUB" || a.sourceSystem === "BITBUCKET"), ); const distinctRepos = new Set(); for (const a of repoCandidates) { @@ -591,6 +595,52 @@ describe("useKnowledgeBase", () => { }); }); + it("offers the repository facet while Bitbucket is selected", async () => { + const bitbucketMetadata = (slug: string) => + JSON.stringify({ repositoryId: "bb-1", workspace: "acme", slug }); + const result = await renderWith([ + { + ...makeArtifact("bb-1", "Widgets PR", "PULL_REQUEST", bitbucketMetadata("widgets")), + sourceSystem: "BITBUCKET", + }, + { + ...makeArtifact("bb-2", "Widgets file", "FILE", bitbucketMetadata("widgets")), + sourceSystem: "BITBUCKET", + }, + { + ...makeArtifact("bb-3", "Gadgets file", "FILE", bitbucketMetadata("gadgets")), + sourceSystem: "BITBUCKET", + }, + ]); + + expect(result.current.repositoryOptions).toHaveLength(0); + + act(() => { + result.current.toggleSource("BITBUCKET"); + }); + + await waitFor(() => { + expect(result.current.repositoryOptions.map((option) => option.value)).toEqual([ + "acme/gadgets", + "acme/widgets", + ]); + expect( + result.current.repositoryOptions.find((option) => option.value === "acme/widgets")?.count, + ).toBe(2); + }); + + act(() => { + result.current.toggleRepository("acme/widgets"); + }); + + await waitFor(() => { + expect(result.current.artifacts.map((artifact) => artifact.id).sort()).toEqual([ + "bb-1", + "bb-2", + ]); + }); + }); + it("promises with its count exactly what choosing the repository delivers", async () => { const result = await renderWith(makeRepoFixture()); diff --git a/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts b/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts index e3c308d73..e34355ae8 100644 --- a/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts +++ b/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts @@ -58,6 +58,15 @@ describe("parseKnowledgeBaseSearch", () => { expect(state.format).toBe("PDF"); }); + it("keeps repositories while Bitbucket is selected, with or without GitHub", () => { + expect([...parse("?sources=BITBUCKET&repos=acme/widgets").repositories]).toEqual([ + "acme/widgets", + ]); + expect([...parse("?sources=GITHUB,BITBUCKET&repos=acme/widgets").repositories]).toEqual([ + "acme/widgets", + ]); + }); + it("ignores a format without Uploads and repositories without GitHub", () => { const state = parse("?sources=JIRA&format=PDF&repos=acme/api"); expect(state.format).toBeNull(); @@ -187,6 +196,18 @@ describe("useKnowledgeBaseUrlState", () => { expect(result.current.location.search).toBe(""); }); + it("keeps the repositories until neither git provider is left selected", () => { + const { result } = renderUrlState(["/kb?sources=GITHUB,BITBUCKET&repos=acme/widgets"], { + projectId: "p1", + }); + + act(() => result.current.api.toggleSource("GITHUB")); + expect(result.current.location.search).toBe("?sources=BITBUCKET&repos=acme/widgets"); + + act(() => result.current.api.toggleSource("BITBUCKET")); + expect(result.current.location.search).toBe(""); + }); + it("resets the page on every filter change and keeps other params", () => { const { result } = renderUrlState(["/kb?page=4&size=50&projectId=x"], { projectId: "p1" }); act(() => result.current.api.toggleSource("JIRA")); diff --git a/tests/unit/pages/DataIngestionPage.test.tsx b/tests/unit/pages/DataIngestionPage.test.tsx index 939ec2b7b..f88a20d9f 100644 --- a/tests/unit/pages/DataIngestionPage.test.tsx +++ b/tests/unit/pages/DataIngestionPage.test.tsx @@ -443,6 +443,129 @@ describe("DataIngestionPage", () => { }); }); + const bitbucketStatusRow = (overrides: Record = {}) => ({ + sourceSystem: "BITBUCKET", + sourceId: "acme/widgets", + displayName: "acme/widgets", + repositoryId: "bb-repo-uuid", + owner: "acme", + name: "widgets", + sourceUrl: "https://bitbucket.org/acme/widgets", + connectionStatus: "CONNECTED", + enabled: true, + lastRunTime: "2026-09-28T10:00:00Z", + ingestedCount: 9, + updatedCount: 1, + deletedCount: 0, + failedCount: 0, + failedItems: [], + artifactCount: 77, + lastCommitsSyncAt: null, + lastIssuesSyncAt: null, + lastPullRequestsSyncAt: "2026-09-28T10:00:00Z", + ...overrides, + }); + + it("builds a Bitbucket source card from the connector-neutral status row", async () => { + mockGetAccessibleProject.mockResolvedValue({ + id: "proj1", + name: "Project Alpha", + description: "", + manager: null, + sources: [], + users: [], + }); + mockGetIngestionSourceStatuses.mockResolvedValue([bitbucketStatusRow()]); + + render( + + + , + ); + + expect((await screen.findAllByText("acme/widgets")).length).toBeGreaterThan(0); + // The workspace is the card's subtitle and the row's artifact total drives the count. + expect(screen.getAllByText("acme").length).toBeGreaterThan(0); + await waitFor(() => { + expect(screen.getAllByText("77").length).toBeGreaterThan(0); + }); + }); + + it("does not double a Bitbucket repository that is also exposed as a project source", async () => { + // `project.sources` carries Bitbucket entries too. The card is built from the + // status row alone, so the project source must not add a fallback card. + mockGetAccessibleProject.mockResolvedValue({ + id: "proj1", + name: "Project Alpha", + description: "", + manager: null, + sources: [ + { id: "acme/widgets", name: "acme/widgets", type: "BITBUCKET", status: "CONNECTED" }, + ], + users: [], + }); + mockGetIngestionSourceStatuses.mockResolvedValue([bitbucketStatusRow()]); + + render( + + + , + ); + + const connectedSourcesKpi = await screen.findByRole("button", { + name: /connected sources/i, + }); + expect(within(connectedSourcesKpi).getByText("1")).toBeInTheDocument(); + }); + + it("filters the run history to a Bitbucket repository via repositoryId", async () => { + mockGetIngestionSourceStatuses.mockResolvedValue([ + { + sourceSystem: "GITHUB", + sourceId: "octocat/hello-world", + displayName: "octocat/hello-world", + repositoryId: "repo-uuid", + owner: "octocat", + name: "hello-world", + sourceUrl: "https://github.com/octocat/hello-world", + connectionStatus: "CONNECTED", + enabled: true, + lastRunTime: "2026-07-01T00:00:00Z", + ingestedCount: 1, + updatedCount: 0, + deletedCount: 0, + failedCount: 0, + failedItems: [], + artifactCount: 10, + lastCommitsSyncAt: null, + lastIssuesSyncAt: null, + lastPullRequestsSyncAt: null, + }, + bitbucketStatusRow(), + ]); + + const user = userEvent.setup(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole("combobox", { name: "Filter runs by source" })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("combobox", { name: "Filter runs by source" })); + await user.click(await screen.findByRole("option", { name: "acme/widgets" })); + + // A Bitbucket repository is scoped by its connection id, not by sourceRef. + await waitFor(() => { + expect(mockGetIngestionRunsPage).toHaveBeenLastCalledWith( + expect.objectContaining({ repositoryId: "bb-repo-uuid", sourceRef: undefined, page: 1 }), + ); + }); + }); + /* The knowledge-gap detail page's "Update data source" button links here from a gap, and a gap knows itself by component — `owner/repo` — not by the project-source id these cards select @@ -1064,6 +1187,171 @@ describe("DataIngestionPage", () => { }); }); + describe("Bitbucket source actions", () => { + beforeEach(() => { + mockGetAccessibleProject.mockResolvedValue({ + id: "proj1", + name: "Project Alpha", + description: "", + manager: null, + sources: [], + users: [], + }); + mockGetIngestionSourceStatuses.mockResolvedValue([bitbucketStatusRow()]); + }); + + async function openBitbucketDrawer() { + const user = userEvent.setup(); + render( + + + , + ); + + const panel = await screen.findByRole("dialog"); + expect(within(panel).getByText("Workspace")).toBeInTheDocument(); + + return { user, panel }; + } + + it("updates the repository through its connection id", async () => { + const updated: string[] = []; + server.use( + http.post("/api/v1/bitbucket/connections/:repositoryId/update", ({ params }) => { + updated.push(String(params.repositoryId)); + return HttpResponse.json({ transactionId: "tx-1" }, { status: 202 }); + }), + ); + + const { user, panel } = await openBitbucketDrawer(); + await user.click(within(panel).getByRole("button", { name: /update repo/i })); + + await waitFor(() => expect(updated).toEqual(["bb-repo-uuid"])); + }); + + it("disables the repository through the connector endpoint by workspace/slug", async () => { + const patches: unknown[] = []; + server.use( + http.patch( + "/api/v1/connectors/:connectorId/sources/status", + async ({ params, request }) => { + patches.push({ connectorId: params.connectorId, body: await request.json() }); + return HttpResponse.json({}); + }, + ), + ); + + const { user, panel } = await openBitbucketDrawer(); + await user.click( + within(panel).getByRole("switch", { name: /toggle ingestion for acme\/widgets/i }), + ); + + await waitFor(() => { + expect(patches).toEqual([ + { + connectorId: "bitbucket", + body: { sources: [{ sourceId: "acme/widgets", enabled: false }] }, + }, + ]); + }); + }); + + it("removes the repository from the selected project", async () => { + const removed: unknown[] = []; + server.use( + http.delete( + "/api/v1/bitbucket/connections/:repositoryId/projects/:projectId", + ({ params }) => { + removed.push({ repositoryId: params.repositoryId, projectId: params.projectId }); + return HttpResponse.json({ repositoryId: params.repositoryId, projectIds: [] }); + }, + ), + ); + + const { user, panel } = await openBitbucketDrawer(); + await user.click(within(panel).getByRole("button", { name: /remove from project/i })); + await user.click(await screen.findByRole("button", { name: /^remove$/i })); + + await waitFor(() => { + expect(removed).toEqual([{ repositoryId: "bb-repo-uuid", projectId: "proj1" }]); + }); + }); + + it("loads and saves the repository's own sync schedule by workspace and slug", async () => { + const saved: unknown[] = []; + server.use( + http.get("/api/v1/bitbucket/config/:workspace/:slug", () => + HttpResponse.json({ + id: "cfg-1", + workspace: "acme", + slug: "widgets", + autoUpdate: true, + spec: { type: "INTERVAL", everyMinutes: 30 }, + schedule: "every 30m", + nextSyncAt: null, + }), + ), + http.put("/api/v1/bitbucket/config/:workspace/:slug", async ({ params, request }) => { + saved.push({ params, body: await request.json() }); + return new HttpResponse(null, { status: 204 }); + }), + ); + + const { user, panel } = await openBitbucketDrawer(); + const minutes = await within(panel).findByLabelText("Minutes"); + await waitFor(() => expect(minutes).toHaveValue(30)); + + await user.clear(minutes); + await user.type(minutes, "45"); + await user.click(within(panel).getByRole("button", { name: /save/i })); + + await waitFor(() => { + expect(saved).toEqual([ + { + params: { workspace: "acme", slug: "widgets" }, + body: { autoUpdate: true, schedule: { type: "INTERVAL", everyMinutes: 45 } }, + }, + ]); + }); + }); + + it("applies the global sync schedule to every Bitbucket repository", async () => { + const configured: unknown[] = []; + server.use( + http.put("/api/v1/bitbucket/config", async ({ request }) => { + configured.push(await request.json()); + return new HttpResponse(null, { status: 204 }); + }), + ); + + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(await screen.findByRole("button", { name: /manage sync settings/i })); + + expect(await screen.findByText("Bitbucket Sync Settings")).toBeInTheDocument(); + expect( + screen.queryByRole("tablist", { name: /sync settings connector/i }), + ).not.toBeInTheDocument(); + + await user.click( + screen.getByRole("switch", { name: /toggle global bitbucket auto update/i }), + ); + await user.click(screen.getByRole("button", { name: /apply globally/i })); + + await waitFor(() => { + expect(configured).toEqual([ + { autoUpdate: false, schedule: { type: "INTERVAL", everyMinutes: 60 } }, + ]); + }); + expect(mockConfigureAllGithubRepositories).not.toHaveBeenCalled(); + }); + }); + it("opens the connectors modal from Manage connectors", async () => { const user = userEvent.setup(); render( diff --git a/tests/unit/services/bitbucketRepositoryInput.test.ts b/tests/unit/services/bitbucketRepositoryInput.test.ts new file mode 100644 index 000000000..43c0db375 --- /dev/null +++ b/tests/unit/services/bitbucketRepositoryInput.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { + parseBitbucketRepositoryInput, + parseBitbucketRepositoryReference, + parseBitbucketWorkspaceInput, +} from "../../../src/services/sources/bitbucketRepositoryInput"; + +describe("parseBitbucketRepositoryReference", () => { + it("parses a plain workspace/slug reference", () => { + expect(parseBitbucketRepositoryReference("acme/widgets")).toEqual({ + workspace: "acme", + slug: "widgets", + }); + }); + + it.each([ + "https://bitbucket.org/acme/widgets", + "http://bitbucket.org/acme/widgets", + "bitbucket.org/acme/widgets", + "git@bitbucket.org:acme/widgets.git", + "ssh://git@bitbucket.org/acme/widgets.git", + "https://someone@bitbucket.org/acme/widgets.git", + "https://bitbucket.org/acme/widgets/src/main/", + "https://bitbucket.org/acme/widgets/pull-requests/12", + "/acme/widgets/", + ])("strips the surrounding syntax of %s", (value) => { + expect(parseBitbucketRepositoryReference(value)).toEqual({ + workspace: "acme", + slug: "widgets", + }); + }); + + it("returns null when the slug half is missing", () => { + expect(parseBitbucketRepositoryReference("acme")).toBeNull(); + expect(parseBitbucketRepositoryReference("https://bitbucket.org/acme/")).toBeNull(); + }); + + it("returns null for an empty value", () => { + expect(parseBitbucketRepositoryReference(" ")).toBeNull(); + }); +}); + +describe("parseBitbucketWorkspaceInput", () => { + it.each([ + "acme", + " acme ", + "bitbucket.org/acme", + "https://bitbucket.org/acme/", + "https://bitbucket.org/acme/widgets", + "git@bitbucket.org:acme/widgets.git", + ])("extracts the workspace from %s", (value) => { + expect(parseBitbucketWorkspaceInput(value)).toBe("acme"); + }); + + it("returns null for an empty value", () => { + expect(parseBitbucketWorkspaceInput("")).toBeNull(); + expect(parseBitbucketWorkspaceInput("https://bitbucket.org/")).toBeNull(); + }); +}); + +describe("parseBitbucketRepositoryInput", () => { + it("combines the two fields", () => { + expect(parseBitbucketRepositoryInput("acme", "widgets")).toEqual({ + workspace: "acme", + slug: "widgets", + }); + }); + + it("trims the two fields", () => { + expect(parseBitbucketRepositoryInput(" acme ", " widgets ")).toEqual({ + workspace: "acme", + slug: "widgets", + }); + }); + + it("prefers a combined reference in the workspace field", () => { + expect(parseBitbucketRepositoryInput("https://bitbucket.org/acme/widgets", "ignored")).toEqual({ + workspace: "acme", + slug: "widgets", + }); + }); + + it("returns null when only one half is given", () => { + expect(parseBitbucketRepositoryInput("acme", "")).toBeNull(); + expect(parseBitbucketRepositoryInput("", "widgets")).toBeNull(); + }); + + // The backend matches connections by exact coordinates, and Bitbucket ids are + // lowercase, so a capitalized entry must not address a second connection. + it("lowercases the workspace and slug", () => { + expect(parseBitbucketRepositoryInput("Acme", "Widgets")).toEqual({ + workspace: "acme", + slug: "widgets", + }); + expect(parseBitbucketRepositoryReference("https://bitbucket.org/Acme/Widgets.git")).toEqual({ + workspace: "acme", + slug: "widgets", + }); + expect(parseBitbucketWorkspaceInput("bitbucket.org/ACME")).toBe("acme"); + }); + + // A pasted browser URL often carries a query string or a fragment; neither may + // end up in the slug, or discovery filters by a name that matches nothing. + it("ignores a query string and a fragment", () => { + expect(parseBitbucketRepositoryReference("https://bitbucket.org/acme/widgets?foo=bar")).toEqual( + { + workspace: "acme", + slug: "widgets", + }, + ); + expect( + parseBitbucketRepositoryReference("https://bitbucket.org/acme/widgets/src/main#readme"), + ).toEqual({ workspace: "acme", slug: "widgets" }); + expect(parseBitbucketWorkspaceInput("https://bitbucket.org/acme?tab=repos")).toBe("acme"); + expect(parseBitbucketRepositoryReference("git@bitbucket.org:acme/widgets.git")).toEqual({ + workspace: "acme", + slug: "widgets", + }); + }); +}); diff --git a/tests/unit/services/bitbucketService.test.ts b/tests/unit/services/bitbucketService.test.ts new file mode 100644 index 000000000..eb6c1f6e6 --- /dev/null +++ b/tests/unit/services/bitbucketService.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, it } from "vitest"; +import { http, HttpResponse } from "msw"; +import { server } from "../setup/vitest.setup"; +import { + addBitbucketRepositoryToProject, + configureAllBitbucketRepositories, + configureBitbucketRepository, + connectBitbucketRepositories, + connectBitbucketRepository, + discoverBitbucketRepositories, + getBitbucketRepositoryConfig, + removeBitbucketRepositoryFromProject, + updateAllBitbucketRepositories, + updateBitbucketRepository, +} from "../../../src/services/sources/bitbucketService"; + +const discoverEndpoint = "/api/v1/bitbucket/discover/workspace/:workspace"; + +const backendRepository = (slug: string, overrides: Record = {}) => ({ + workspace: "acme", + slug, + name: slug, + isPrivate: false, + url: `https://bitbucket.org/acme/${slug}`, + alreadyConnected: false, + enabled: null, + ...overrides, +}); + +describe("connectBitbucketRepository", () => { + it("posts the workspace, slug, credential and project", async () => { + expect.assertions(2); + + server.use( + http.post("/api/v1/bitbucket", async ({ request }) => { + expect(await request.json()).toEqual({ + workspace: "acme", + slug: "widgets", + credentialName: "atlassian-main", + projectId: "project-1", + }); + + return HttpResponse.json({ transactionId: "tx-1" }, { status: 202 }); + }), + ); + + const response = await connectBitbucketRepository({ + workspace: "acme", + slug: "widgets", + credentialName: "atlassian-main", + projectId: "project-1", + }); + + expect(response).toEqual({ transactionId: "tx-1" }); + }); +}); + +describe("connectBitbucketRepositories", () => { + it("sends one entry per repository, each with the shared credential and project", async () => { + expect.assertions(2); + + server.use( + http.post("/api/v1/bitbucket/connect/all", async ({ request }) => { + expect(await request.json()).toEqual({ + repositories: [ + { + workspace: "acme", + slug: "widgets", + credentialName: "atlassian-main", + projectId: "project-1", + }, + { + workspace: "acme", + slug: "gadgets", + credentialName: "atlassian-main", + projectId: "project-1", + }, + ], + }); + + return HttpResponse.json( + { transactionIdsByRepository: { "acme/widgets": "tx-1", "acme/gadgets": "tx-2" } }, + { status: 202 }, + ); + }), + ); + + const result = await connectBitbucketRepositories( + [ + { workspace: "acme", slug: "widgets" }, + { workspace: "acme", slug: "gadgets" }, + ], + "atlassian-main", + "project-1", + ); + + expect(result.transactionIdsByRepository).toEqual({ + "acme/widgets": "tx-1", + "acme/gadgets": "tx-2", + }); + }); +}); + +describe("discoverBitbucketRepositories", () => { + it("passes the credential and the 0-based page in the query and the workspace in the path", async () => { + expect.assertions(3); + + server.use( + http.get(discoverEndpoint, ({ params, request }) => { + const url = new URL(request.url); + + expect(params.workspace).toBe("acme team"); + expect(url.searchParams.get("credentialName")).toBe("atlassian-main"); + expect(Object.fromEntries(url.searchParams)).toMatchObject({ page: "2", pageSize: "10" }); + + return HttpResponse.json({ repositories: [] }); + }), + ); + + await discoverBitbucketRepositories("acme team", "atlassian-main", 2, 10); + }); + + it("maps the repositories to the shape the UI consumes", async () => { + server.use( + http.get(discoverEndpoint, () => + HttpResponse.json({ + repositories: [ + backendRepository("widgets", { isPrivate: true }), + backendRepository("gadgets", { alreadyConnected: true, enabled: false }), + ], + }), + ), + ); + + const result = await discoverBitbucketRepositories("acme", "atlassian-main"); + + expect(result.repositories).toEqual([ + { + workspace: "acme", + slug: "widgets", + name: "widgets", + isPrivate: true, + url: "https://bitbucket.org/acme/widgets", + alreadyConnected: false, + isEnabled: null, + }, + { + workspace: "acme", + slug: "gadgets", + name: "gadgets", + isPrivate: false, + url: "https://bitbucket.org/acme/gadgets", + alreadyConnected: true, + isEnabled: false, + }, + ]); + }); + + it("reads the privacy flag whether the backend names it isPrivate or private", async () => { + server.use( + http.get(discoverEndpoint, () => + HttpResponse.json({ + repositories: [ + { workspace: "acme", slug: "kotlin-style", name: "a", isPrivate: true }, + { workspace: "acme", slug: "jackson-style", name: "b", private: true }, + { workspace: "acme", slug: "missing", name: "c" }, + ], + }), + ), + ); + + const result = await discoverBitbucketRepositories("acme", "atlassian-main"); + + expect(result.repositories.map((repository) => repository.isPrivate)).toEqual([ + true, + true, + false, + ]); + }); + + it("defaults the optional fields when the backend omits them", async () => { + server.use( + http.get(discoverEndpoint, () => + HttpResponse.json({ + repositories: [{ workspace: "acme", slug: "bare", name: "bare", url: null }], + }), + ), + ); + + const result = await discoverBitbucketRepositories("acme", "atlassian-main"); + + expect(result.repositories[0]).toMatchObject({ + url: null, + alreadyConnected: false, + isEnabled: null, + }); + }); + + it("reports hasMore when a full page came back", async () => { + server.use( + http.get(discoverEndpoint, () => + HttpResponse.json({ repositories: [backendRepository("a"), backendRepository("b")] }), + ), + ); + + const result = await discoverBitbucketRepositories("acme", "atlassian-main", 0, 2); + + expect(result.hasMore).toBe(true); + }); + + it("reports no more pages when the page is short", async () => { + server.use( + http.get(discoverEndpoint, () => + HttpResponse.json({ repositories: [backendRepository("a")] }), + ), + ); + + const result = await discoverBitbucketRepositories("acme", "atlassian-main", 0, 2); + + expect(result.hasMore).toBe(false); + }); + + it("propagates a missing credential as a 404", async () => { + server.use( + http.get(discoverEndpoint, () => + HttpResponse.json({ message: "Credential not found" }, { status: 404 }), + ), + ); + + await expect(discoverBitbucketRepositories("acme", "gone")).rejects.toMatchObject({ + status: 404, + }); + }); +}); + +describe("bitbucketService project links", () => { + it("addBitbucketRepositoryToProject posts to the connection's project path", async () => { + server.use( + http.post("/api/v1/bitbucket/connections/:repositoryId/projects/:projectId", ({ params }) => + HttpResponse.json({ + repositoryId: params.repositoryId, + projectIds: ["project-1", params.projectId], + }), + ), + ); + + const response = await addBitbucketRepositoryToProject("repo-1", "project-2"); + + expect(response).toEqual({ repositoryId: "repo-1", projectIds: ["project-1", "project-2"] }); + }); + + it("removeBitbucketRepositoryFromProject deletes the link and returns the remaining projects", async () => { + expect.assertions(2); + + server.use( + http.delete( + "/api/v1/bitbucket/connections/:repositoryId/projects/:projectId", + ({ params }) => { + expect(params).toMatchObject({ repositoryId: "repo-1", projectId: "project-1" }); + + return HttpResponse.json({ repositoryId: "repo-1", projectIds: ["project-2"] }); + }, + ), + ); + + const response = await removeBitbucketRepositoryFromProject("repo-1", "project-1"); + + expect(response.projectIds).toEqual(["project-2"]); + }); +}); + +describe("bitbucketService updates", () => { + it("updateBitbucketRepository posts to the connection's update path", async () => { + server.use( + http.post("/api/v1/bitbucket/connections/:repositoryId/update", ({ params }) => + HttpResponse.json({ transactionId: `tx-${String(params.repositoryId)}` }, { status: 202 }), + ), + ); + + await expect(updateBitbucketRepository("repo-1")).resolves.toEqual({ + transactionId: "tx-repo-1", + }); + }); + + it("updateAllBitbucketRepositories returns the transaction ids keyed by workspace/slug", async () => { + server.use( + http.post("/api/v1/bitbucket/update-all", () => + HttpResponse.json( + { transactionIdsByRepository: { "acme/widgets": "tx-1" } }, + { status: 202 }, + ), + ), + ); + + await expect(updateAllBitbucketRepositories()).resolves.toEqual({ + transactionIdsByRepository: { "acme/widgets": "tx-1" }, + }); + }); +}); + +describe("bitbucketService config endpoints", () => { + it("configureAllBitbucketRepositories sends the typed global schedule payload", async () => { + expect.assertions(1); + + server.use( + http.put("/api/v1/bitbucket/config", async ({ request }) => { + expect(await request.json()).toEqual({ + autoUpdate: true, + schedule: { type: "WEEKLY", time: "09:00:00", daysOfWeek: ["MONDAY", "THURSDAY"] }, + }); + + return new HttpResponse(null, { status: 204 }); + }), + ); + + await configureAllBitbucketRepositories({ + autoUpdate: true, + schedule: { type: "WEEKLY", time: "09:00:00", daysOfWeek: ["MONDAY", "THURSDAY"] }, + }); + }); + + it("configureBitbucketRepository targets one repository config", async () => { + expect.assertions(2); + + server.use( + http.put("/api/v1/bitbucket/config/:workspace/:slug", async ({ params, request }) => { + expect(params).toMatchObject({ workspace: "sprint", slug: "frontend" }); + expect(await request.json()).toEqual({ + autoUpdate: false, + schedule: { type: "INTERVAL", everyMinutes: 60 }, + }); + + return new HttpResponse(null, { status: 204 }); + }), + ); + + await configureBitbucketRepository( + { workspace: "sprint", slug: "frontend" }, + { autoUpdate: false, schedule: { type: "INTERVAL", everyMinutes: 60 } }, + ); + }); + + it("encodes the workspace and slug into the config path", async () => { + expect.assertions(1); + + server.use( + http.get("/api/v1/bitbucket/config/:workspace/:slug", ({ request }) => { + expect(new URL(request.url).pathname).toBe("/api/v1/bitbucket/config/a%20b/c%2Fd"); + + return HttpResponse.json({}); + }), + ); + + await getBitbucketRepositoryConfig("a b", "c/d"); + }); + + it("getBitbucketRepositoryConfig returns repository sync settings", async () => { + server.use( + http.get("/api/v1/bitbucket/config/sprint/frontend", () => + HttpResponse.json({ + id: "config-1", + workspace: "sprint", + slug: "frontend", + autoUpdate: true, + spec: { type: "DAILY", time: "02:00:00" }, + schedule: "0 0 2 * * *", + nextSyncAt: "2026-01-01T02:00:00Z", + }), + ), + ); + + const config = await getBitbucketRepositoryConfig("sprint", "frontend"); + + expect(config).toMatchObject({ + workspace: "sprint", + slug: "frontend", + autoUpdate: true, + spec: { type: "DAILY", time: "02:00:00" }, + nextSyncAt: "2026-01-01T02:00:00Z", + }); + }); +}); diff --git a/tests/unit/setup/msw-handlers.ts b/tests/unit/setup/msw-handlers.ts index 7ddc66d57..cb79f365c 100644 --- a/tests/unit/setup/msw-handlers.ts +++ b/tests/unit/setup/msw-handlers.ts @@ -676,6 +676,25 @@ export const handlers = [ http.get("/api/v1/connectors/confluence/sources", () => HttpResponse.json({ connectorId: "confluence", sources: [] }), ), + // Bitbucket: nothing discovered and every connect accepted. Tests that care override these. + http.get("/api/v1/bitbucket/discover/workspace/:workspace", () => + HttpResponse.json({ repositories: [] }), + ), + http.post("/api/v1/bitbucket", () => + HttpResponse.json({ transactionId: "bb-tx-default" }, { status: 202 }), + ), + http.post("/api/v1/bitbucket/connect/all", () => + HttpResponse.json({ transactionIdsByRepository: {} }, { status: 202 }), + ), + http.post("/api/v1/bitbucket/connections/:repositoryId/projects/:projectId", ({ params }) => + HttpResponse.json({ repositoryId: params.repositoryId, projectIds: [params.projectId] }), + ), + http.delete("/api/v1/bitbucket/connections/:repositoryId/projects/:projectId", ({ params }) => + HttpResponse.json({ repositoryId: params.repositoryId, projectIds: [] }), + ), + http.post("/api/v1/bitbucket/connections/:repositoryId/update", () => + HttpResponse.json({ transactionId: "bb-update-default" }, { status: 202 }), + ), // Every surface that opens the buddy dock asks for its suggestion chips. A default empty list // keeps that request handled for the many tests that open the dock without being about the // chips; `useBuddySuggestions`' own suite mocks the service directly and never sees this.