Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/components/icons/BitbucketIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size}
height={size}
fill="currentColor"
className={className}
aria-hidden={ariaHidden}
focusable="false"
>
<path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z" />
</svg>
);
}
16 changes: 16 additions & 0 deletions src/components/icons/types.ts
Original file line number Diff line number Diff line change
@@ -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<IconProps>;
3 changes: 2 additions & 1 deletion src/features/access/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ export const atlassianConnector = defineAccessConnector<AtlassianCredentialDto>(
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();

Expand Down
94 changes: 74 additions & 20 deletions src/features/admin/components/CreateProjectWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
addDraftSource,
connectDraftSources,
connectOutcomeDescription,
createBitbucketDraftFromDiscovery,
createConfluenceDraft,
createDraftSourceFromDiscovery,
createJiraDraft,
Expand Down Expand Up @@ -82,8 +83,14 @@ const STEP_INDEX: Record<Exclude<WizardPhase, "provisioning">, 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
Expand Down Expand Up @@ -149,6 +156,9 @@ export function CreateProjectWizard({
} = useGithubTokens();
const effectiveTokenNames = tokensLoaded ? loadedTokenNames : tokenNames;

const [bitbucketSelection, setBitbucketSelection] = useState<DiscoverySelection[]>([]);
const [bitbucketCredentialName, setBitbucketCredentialName] = useState("");

const [jiraDisplayName, setJiraDisplayName] = useState("");
const [jiraUrl, setJiraUrl] = useState("");
const [jiraCredentialName, setJiraCredentialName] = useState("");
Expand Down Expand Up @@ -198,16 +208,17 @@ 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,
error: jiraCredentialsError,
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.
Expand All @@ -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;

Expand All @@ -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]);

Expand All @@ -267,6 +284,11 @@ export function CreateProjectWizard({
void Promise.resolve().then(loadManagerCandidates);
}, [isOpen, loadManagerCandidates]);

const resetBitbucketDraftFields = () => {
setBitbucketSelection([]);
setBitbucketCredentialName("");
};

const resetConfluenceDraftFields = () => {
setConfluenceBaseUrl("");
setConfluenceSpaceId("");
Expand All @@ -288,6 +310,7 @@ export function CreateProjectWizard({
setAddStep("type");
setAddType("GITHUB");
setGithubSelection([]);
resetBitbucketDraftFields();
resetJiraDraftFields();
resetConfluenceDraftFields();
setUploadFiles([]);
Expand All @@ -302,6 +325,7 @@ export function CreateProjectWizard({

const resetSourceDraftFields = () => {
setGithubSelection([]);
resetBitbucketDraftFields();
resetJiraDraftFields();
resetConfluenceDraftFields();
setUploadFiles([]);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/features/admin/components/DrawerCard.tsx
Original file line number Diff line number Diff line change
@@ -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). */
Expand Down
22 changes: 18 additions & 4 deletions src/features/admin/components/StagedSourceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -60,6 +61,10 @@ function TypeIcon({ source }: { source: DraftSource }) {
return <BookOpen className="h-4 w-4 text-app-text-muted" />;
}

if (source.type === "BITBUCKET") {
return <BitbucketIcon className="h-4 w-4 text-app-text-muted" />;
}

return <GitBranch className="h-4 w-4 text-app-text-muted" />;
}

Expand All @@ -82,14 +87,15 @@ 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;
}

/**
* 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") {
Expand All @@ -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
Expand All @@ -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";
}

Expand Down
Loading
Loading