feat(plugins): add source-control extension contracts (#2117)

This commit is contained in:
José Almeida
2026-08-13 08:29:29 +01:00
committed by GitHub
parent 064b852884
commit f218880ecb
530 changed files with 39054 additions and 7518 deletions
@@ -9,6 +9,7 @@ import {
toTaskPreset,
} from "./action-presets";
import { IconEye, IconSparkles } from "@tabler/icons-react";
import { iconForIntegrationPreset } from "@/components/integrations/integration-preset-icons";
describe("interpolatePromptTemplate", () => {
it("replaces {{url}} and {{title}} placeholders", () => {
@@ -47,6 +48,10 @@ describe("iconForPresetKey", () => {
expect(iconForPresetKey("eye")).toBe(IconEye);
});
it.each(["eye", "message", "tool"])("shares the host semantic %s glyph", (key) => {
expect(iconForPresetKey(key)).toBe(iconForIntegrationPreset(key));
});
it("falls back to sparkle for unknown or missing keys", () => {
expect(iconForPresetKey(undefined)).toBe(IconSparkles);
expect(iconForPresetKey("nonsense")).toBe(IconSparkles);
@@ -1,14 +1,5 @@
import {
IconEye,
IconMessageDots,
IconTool,
IconCode,
IconSearch,
IconBug,
IconSparkles,
IconChecks,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { iconForIntegrationPreset } from "@/components/integrations/integration-preset-icons";
import type {
GitHubActionPreset,
GitHubActionPresetIcon,
@@ -24,23 +15,26 @@ export const PRESET_ICON_CHOICES: {
icon: Icon;
labelKey: string;
}[] = [
{ key: "eye", icon: IconEye, labelKey: "github:presetIconEye" },
{ key: "message", icon: IconMessageDots, labelKey: "github:presetIconMessage" },
{ key: "tool", icon: IconTool, labelKey: "github:presetIconTool" },
{ key: "code", icon: IconCode, labelKey: "github:presetIconCode" },
{ key: "search", icon: IconSearch, labelKey: "github:presetIconSearch" },
{ key: "bug", icon: IconBug, labelKey: "github:presetIconBug" },
{ key: "sparkle", icon: IconSparkles, labelKey: "github:presetIconSparkle" },
{ key: "check", icon: IconChecks, labelKey: "github:presetIconCheck" },
{ key: "eye", icon: iconForIntegrationPreset("eye"), labelKey: "github:presetIconEye" },
{
key: "message",
icon: iconForIntegrationPreset("message"),
labelKey: "github:presetIconMessage",
},
{ key: "tool", icon: iconForIntegrationPreset("tool"), labelKey: "github:presetIconTool" },
{ key: "code", icon: iconForIntegrationPreset("code"), labelKey: "github:presetIconCode" },
{ key: "search", icon: iconForIntegrationPreset("search"), labelKey: "github:presetIconSearch" },
{ key: "bug", icon: iconForIntegrationPreset("bug"), labelKey: "github:presetIconBug" },
{
key: "sparkle",
icon: iconForIntegrationPreset("sparkle"),
labelKey: "github:presetIconSparkle",
},
{ key: "check", icon: iconForIntegrationPreset("check"), labelKey: "github:presetIconCheck" },
];
const ICON_BY_KEY: Record<string, Icon> = Object.fromEntries(
PRESET_ICON_CHOICES.map((choice) => [choice.key, choice.icon]),
);
export function iconForPresetKey(key: string | undefined): Icon {
if (!key) return IconSparkles;
return ICON_BY_KEY[key] ?? IconSparkles;
return iconForIntegrationPreset(key);
}
// Interpolate `{{url}}` and `{{title}}` placeholders in a prompt template.
@@ -15,7 +15,7 @@ import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/utils";
import type { GitHubIssue, TaskIssueLink } from "@/lib/types/github";
import type { LaunchPayload, TaskPreset } from "./quick-task-launcher";
import { TaskRowIndicator } from "./task-row-indicator";
import { TaskRowIndicator } from "@/components/integrations/task-row-indicator";
import { useTranslation } from "react-i18next";
type IssueListProps = {
@@ -1,12 +1,8 @@
"use client";
import { IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/utils";
import { RepoFilterCombobox } from "./repo-filter-combobox";
import { useTranslation } from "react-i18next";
import { IntegrationListToolbar } from "@/components/integrations/integration-list-toolbar";
import { RepoFilterCombobox } from "./repo-filter-combobox";
type ListToolbarProps = {
title: string;
@@ -23,40 +19,6 @@ type ListToolbarProps = {
onRefresh: () => void;
};
function RefreshControls({
loading,
lastFetchedAt,
onRefresh,
showUpdatedPrefix,
}: {
loading: boolean;
lastFetchedAt: Date | null;
onRefresh: () => void;
showUpdatedPrefix: boolean;
}) {
const { t } = useTranslation();
return (
<>
{lastFetchedAt && !loading && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{showUpdatedPrefix ? t("github:updated") : ""}
{formatRelativeTime(lastFetchedAt.toISOString())}
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={onRefresh}
disabled={loading}
title={t("github:refresh")}
>
<IconRefresh className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</>
);
}
export function ListToolbar({
title,
count,
@@ -72,67 +34,31 @@ export function ListToolbar({
onRefresh,
}: ListToolbarProps) {
const { t } = useTranslation();
const dirty = customQuery !== committedQuery;
return (
<div className="px-4 sm:px-6 py-2.5 border-b shrink-0 flex flex-col md:flex-row md:items-center md:flex-wrap gap-2 md:gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className="flex items-baseline gap-2 min-w-0 flex-1 md:flex-initial">
<h2 className="text-sm font-semibold truncate" data-testid="github-list-toolbar-title">
{title}
</h2>
<span className="text-xs text-muted-foreground tabular-nums">
{loading ? "…" : count}
</span>
</div>
<div className="flex items-center gap-2 md:hidden">
<RefreshControls
loading={loading}
lastFetchedAt={lastFetchedAt}
onRefresh={onRefresh}
showUpdatedPrefix={false}
/>
</div>
</div>
<RepoFilterCombobox
repoFilter={repoFilter}
onRepoFilterChange={onRepoFilterChange}
repoOptions={repoOptions}
ariaLabel={t("github:filterGithubResultsByRepository")}
triggerClassName="w-full md:w-[220px] h-8 border border-input bg-background hover:bg-secondary/50 px-2 py-1.5 text-xs/relaxed"
className="md:min-w-[360px]"
testId="github-repo-filter-trigger"
dropdownTestId="github-repo-filter-dropdown"
/>
<div className="w-full md:flex-1 md:min-w-[240px] relative">
<Input
value={customQuery}
onChange={(e) => onCustomQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onCommitCustomQuery();
}
}}
onBlur={() => {
if (dirty) onCommitCustomQuery();
}}
placeholder={t("github:customQueryPressEnterEG")}
className="h-8 pr-20"
<IntegrationListToolbar
title={title}
count={count}
loading={loading}
lastFetchedAt={lastFetchedAt}
customQuery={customQuery}
committedQuery={committedQuery}
onCustomQueryChange={onCustomQueryChange}
onCommitCustomQuery={onCommitCustomQuery}
onRefresh={onRefresh}
filter={
<RepoFilterCombobox
repoFilter={repoFilter}
onRepoFilterChange={onRepoFilterChange}
repoOptions={repoOptions}
ariaLabel={t("github:filterGithubResultsByRepository")}
triggerClassName="h-8 w-full border border-input bg-background px-2 py-1.5 text-xs/relaxed hover:bg-secondary/50 md:w-[220px]"
className="md:min-w-[360px]"
testId="github-repo-filter-trigger"
dropdownTestId="github-repo-filter-dropdown"
/>
{dirty && (
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-[10px] uppercase tracking-wider text-muted-foreground hidden sm:inline">
{t("github:pressEnter")}
</span>
)}
</div>
<div className="hidden md:flex items-center gap-2 md:ml-auto">
<RefreshControls
loading={loading}
lastFetchedAt={lastFetchedAt}
onRefresh={onRefresh}
showUpdatedPrefix
/>
</div>
</div>
}
queryPlaceholder={t("github:customQueryPressEnterEG")}
titleTestId="github-list-toolbar-title"
/>
);
}
+46 -115
View File
@@ -1,29 +1,18 @@
"use client";
import Link from "@/components/routing/app-link";
import { useTranslation } from "react-i18next";
import { ChangeRequestList, ChangeRequestRow } from "@/components/integrations/change-request-list";
import {
IconGitPullRequest,
IconGitPullRequestClosed,
IconGitMerge,
IconPlus,
IconChevronDown,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Spinner } from "@kandev/ui/spinner";
IntegrationIcon,
type IntegrationIconName,
} from "@/components/integrations/integration-icon";
import { IntegrationStartTaskMenu } from "@/components/integrations/integration-start-task-menu";
import { cn, formatRelativeTime } from "@/lib/utils";
import type { GitHubPR, GitHubPRStatus, TaskPR } from "@/lib/types/github";
import type { LaunchPayload, TaskPreset } from "./quick-task-launcher";
import { PRStatusBadges } from "./pr-status-badges";
import { prStatusKey, usePRStatuses } from "./use-pr-statuses";
import { PRRowTaskIndicator } from "./pr-row-task-indicator";
import { useTranslation } from "react-i18next";
import { prStatusKey, usePRStatuses } from "./use-pr-statuses";
type PRListProps = {
workspaceId: string | null;
@@ -35,20 +24,17 @@ type PRListProps = {
prKeyToTasks?: Map<string, TaskPR[]>;
};
// Prefer the enriched PR returned by the batched status endpoint — the search
// API used to populate `items` does not include head/base branches, so the
// launcher needs the enriched copy to pre-fill the task dialog correctly.
export function pickPRForLaunch(pr: GitHubPR, status: GitHubPRStatus | null | undefined): GitHubPR {
return status?.pr ?? pr;
}
function prStateIcon(pr: GitHubPR): { Icon: Icon; className: string } {
function prStateIcon(pr: GitHubPR): { name: IntegrationIconName; className: string } {
if (pr.state === "merged")
return { Icon: IconGitMerge, className: "text-purple-600 dark:text-purple-400" };
return { name: "merged", className: "text-purple-600 dark:text-purple-400" };
if (pr.state === "closed")
return { Icon: IconGitPullRequestClosed, className: "text-red-600 dark:text-red-400" };
if (pr.draft) return { Icon: IconGitPullRequest, className: "text-muted-foreground" };
return { Icon: IconGitPullRequest, className: "text-emerald-600 dark:text-emerald-400" };
return { name: "pull-request-closed", className: "text-red-600 dark:text-red-400" };
if (pr.draft) return { name: "pull-request", className: "text-muted-foreground" };
return { name: "pull-request", className: "text-emerald-600 dark:text-emerald-400" };
}
function StartTaskMenu({
@@ -61,42 +47,15 @@ function StartTaskMenu({
onStartTask: PRListProps["onStartTask"];
}) {
const { t } = useTranslation();
const launch = (preset: TaskPreset) => onStartTask({ kind: "pr", pr, preset });
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 cursor-pointer"
data-testid="pr-start-task-trigger"
>
<IconPlus className="h-3.5 w-3.5" />
{t("github:task")}
<IconChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{presets.map((p) => {
const ItemIcon = p.icon;
return (
<DropdownMenuItem
key={p.id}
className="cursor-pointer gap-2 py-1.5"
onSelect={() => launch(p)}
data-testid="pr-start-task-preset"
data-preset-id={p.id}
>
<ItemIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-medium leading-tight">{p.label}</span>
<span className="text-[11px] text-muted-foreground leading-tight">{p.hint}</span>
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<IntegrationStartTaskMenu
presets={presets}
onSelect={(preset) => onStartTask({ kind: "pr", pr, preset })}
triggerLabel={t("github:task")}
triggerAriaLabel={t("github:task")}
triggerTestId="pr-start-task-trigger"
itemTestId="pr-start-task-preset"
/>
);
}
@@ -113,25 +72,15 @@ function PRRow({
onStartTask: PRListProps["onStartTask"];
tasks: TaskPR[] | undefined;
}) {
const { Icon: StateIcon, className: stateIconClass } = prStateIcon(pr);
const { t } = useTranslation();
const { name: stateIconName, className: stateIconClass } = prStateIcon(pr);
return (
<div
className="flex items-start gap-3 px-4 py-3 hover:bg-muted/40 transition-colors"
data-testid="pr-row"
data-pr-number={pr.number}
>
<StateIcon className={cn("h-4 w-4 mt-1 shrink-0", stateIconClass)} />
<div className="min-w-0 flex-1">
<Link
href={pr.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-semibold hover:underline block truncate cursor-pointer"
>
{pr.title}
</Link>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-0.5 text-xs text-muted-foreground">
<ChangeRequestRow
stateIcon={<IntegrationIcon name={stateIconName} className={cn("h-4 w-4", stateIconClass)} />}
title={pr.title}
href={pr.html_url}
metadata={
<>
<span className="whitespace-nowrap">
{pr.repo_owner}/{pr.repo_name}#{pr.number}
</span>
@@ -143,50 +92,26 @@ function PRRow({
})}
</span>
<PRStatusBadges pr={pr} status={status} />
<PRRowTaskIndicator tasks={tasks} />
</div>
</div>
<div className="shrink-0">
</>
}
taskIndicator={<PRRowTaskIndicator tasks={tasks} />}
action={
<StartTaskMenu
pr={pickPRForLaunch(pr, status)}
presets={presets}
onStartTask={onStartTask}
/>
</div>
</div>
}
testId="pr-row"
dataAttributes={{ "data-pr-number": pr.number }}
/>
);
}
function PRListBody({
workspaceId,
loading,
error,
items,
presets,
onStartTask,
prKeyToTasks,
}: PRListProps) {
const { t } = useTranslation();
function PRListBody({ workspaceId, items, presets, onStartTask, prKeyToTasks }: PRListProps) {
const statuses = usePRStatuses(workspaceId, items);
if (loading) {
return (
<div className="flex justify-center py-10">
<Spinner />
</div>
);
}
if (error) {
return <div className="text-center py-10 text-destructive text-sm">{error}</div>;
}
if (items.length === 0) {
return (
<div className="text-center py-10 text-muted-foreground text-sm">
{t("github:noPullRequestsMatchThisFilter")}
</div>
);
}
return (
<div className="divide-y">
<>
{items.map((pr) => {
const key = prStatusKey(pr.repo_owner, pr.repo_name, pr.number);
return (
@@ -200,14 +125,20 @@ function PRListBody({
/>
);
})}
</div>
</>
);
}
export function PRList(props: PRListProps) {
const { t } = useTranslation();
return (
<div className="rounded-md border overflow-hidden">
<ChangeRequestList
loading={props.loading}
error={props.error}
emptyMessage={t("github:noPullRequestsMatchThisFilter")}
isEmpty={props.items.length === 0}
>
<PRListBody {...props} />
</div>
</ChangeRequestList>
);
}
@@ -1,7 +1,7 @@
"use client";
import type { TaskPR } from "@/lib/types/github";
import { TaskRowIndicator } from "./task-row-indicator";
import { TaskRowIndicator } from "@/components/integrations/task-row-indicator";
import { useTranslation } from "react-i18next";
type PRRowTaskIndicatorProps = {
@@ -1,11 +1,7 @@
"use client";
import { useMemo } from "react";
import { Combobox, type ComboboxOption } from "@/components/combobox";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
const ALL_REPOS = "__all__";
import { IntegrationRepositoryFilter } from "@/components/integrations/integration-repository-filter";
type RepoFilterComboboxProps = {
repoFilter: string;
@@ -18,13 +14,6 @@ type RepoFilterComboboxProps = {
className?: string;
};
function buildRepoFilterOptions(repoOptions: string[]): ComboboxOption[] {
return [
{ value: ALL_REPOS, label: t("github:allRepos"), keywords: ["all", "repositories", "repos"] },
...repoOptions.map((repo) => ({ value: repo, label: repo, keywords: [repo] })),
];
}
export function RepoFilterCombobox({
repoFilter,
onRepoFilterChange,
@@ -36,19 +25,13 @@ export function RepoFilterCombobox({
className,
}: RepoFilterComboboxProps) {
const { t } = useTranslation();
const options = useMemo(() => buildRepoFilterOptions(repoOptions), [repoOptions]);
return (
<Combobox
value={repoFilter || ALL_REPOS}
onValueChange={(value) => {
// Combobox signals reselecting the active option with an empty value.
if (!value) return;
onRepoFilterChange(value === ALL_REPOS ? "" : value);
}}
options={options}
<IntegrationRepositoryFilter
value={repoFilter}
onValueChange={onRepoFilterChange}
options={repoOptions.map((repo) => ({ value: repo, label: repo, keywords: [repo] }))}
ariaLabel={ariaLabel}
placeholder={t("github:allRepos")}
allLabel={t("github:allRepos")}
searchPlaceholder={t("github:filterRepositories")}
emptyMessage={t("github:noRepositoriesFound")}
triggerClassName={triggerClassName}
@@ -1,151 +1,5 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "@/lib/routing/client-router";
import { IconChecklist } from "@tabler/icons-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Badge } from "@kandev/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { cn } from "@/lib/utils";
import { linkToTask } from "@/lib/links";
import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id";
import type { KanbanState } from "@/lib/state/slices";
import { useTranslation } from "react-i18next";
export type TaskRowLink = {
id: string;
taskId: string;
fallbackTitle: string;
};
type TaskRowIndicatorProps = {
tasks: TaskRowLink[] | undefined;
testIdPrefix: string;
emptyLabel?: string;
};
function useTaskStepTitle(workflowStepId: string | undefined): string | null {
return useAppStore((state) => {
if (!workflowStepId) return null;
const findIn = (steps: KanbanState["steps"]) =>
steps.find((step) => step.id === workflowStepId)?.title ?? null;
const activeTitle = findIn(state.kanban.steps);
if (activeTitle) return activeTitle;
for (const snapshot of Object.values(state.kanbanMulti.snapshots)) {
const title = findIn(snapshot.steps);
if (title) return title;
}
return null;
});
}
function TaskTitle({ task }: { task: TaskRowLink }) {
const taskData = useTaskById(task.taskId);
const title = taskData?.title ?? task.fallbackTitle;
return (
<span className="truncate text-foreground/80" title={title}>
{title}
</span>
);
}
const buttonClass = cn(
"inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs",
"hover:bg-muted/70 transition-colors cursor-pointer w-fit max-w-full",
);
const iconClass = "h-3 w-3 shrink-0 text-muted-foreground";
function SingleTaskButton({
task,
testIdPrefix,
onNavigate,
}: {
task: TaskRowLink;
testIdPrefix: string;
onNavigate: (taskId: string) => void;
}) {
const taskData = useTaskById(task.taskId);
const stepTitle = useTaskStepTitle(taskData?.workflowStepId);
const title = taskData?.title ?? task.fallbackTitle;
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-testid={`${testIdPrefix}-single`}
onClick={() => onNavigate(task.taskId)}
className={buttonClass}
>
<IconChecklist className={iconClass} />
<span className="truncate text-foreground/80">{title}</span>
</button>
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-col gap-0.5 text-xs">
<span>{t("github:taskLabelled", { title })}</span>
{stepTitle ? (
<span className="text-muted-foreground">{t("github:stepLabelled", { stepTitle })}</span>
) : null}
</div>
</TooltipContent>
</Tooltip>
);
}
export function TaskRowIndicator({ tasks, testIdPrefix, emptyLabel }: TaskRowIndicatorProps) {
const { t } = useTranslation();
const setActiveTask = useAppStore((state) => state.setActiveTask);
const router = useRouter();
const navigate = useCallback(
(taskId: string) => {
setActiveTask(taskId);
router.push(linkToTask(taskId));
},
[router, setActiveTask],
);
if (!tasks || tasks.length === 0) {
return emptyLabel ? (
<span
data-testid={`${testIdPrefix}-empty`}
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
>
{emptyLabel}
</span>
) : null;
}
if (tasks.length === 1) {
return <SingleTaskButton task={tasks[0]} testIdPrefix={testIdPrefix} onNavigate={navigate} />;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" data-testid={`${testIdPrefix}-multi`} className={buttonClass}>
<IconChecklist className={iconClass} />
<span className="text-foreground/80">{t("github:tasks")}</span>
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px] shrink-0">
{tasks.length}
</Badge>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{tasks.map((task) => (
<DropdownMenuItem
key={task.id}
className="cursor-pointer"
onSelect={() => navigate(task.taskId)}
>
<TaskTitle task={task} />
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
export {
TaskRowIndicator,
type TaskRowIndicatorProps,
type TaskRowLink,
} from "@/components/integrations/task-row-indicator";