Files
2026-09-10 18:14:05 +01:00

458 lines
17 KiB
TypeScript

"use client";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Button } from "@kandev/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { WatcherRepositoryFields } from "@/components/watcher-repository-fields";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps";
import { useWorkflows } from "@/hooks/use-workflows";
import { STEP_DEFAULT, resolveProfileId } from "@/lib/watcher-profile-default";
import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types";
import type {
CreateIssueWatchRequest,
CreateReviewWatchRequest,
UpdateIssueWatchRequest,
UpdateReviewWatchRequest,
} from "@/lib/api/domains/gitlab-api";
import type { IssueWatch, ReviewWatch } from "@/lib/types/gitlab";
import {
buildWatchPayload,
makeWatchForm,
watchFormFromWatch,
type GitLabWatchForm,
type GitLabWatchKind,
} from "./watch-form";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import { gitlabIssueWatchPlaceholders } from "./issue-watch-placeholders";
import { gitlabReviewWatchPlaceholders } from "./review-watch-placeholders";
type Watch = ReviewWatch | IssueWatch;
type CreateRequest = CreateReviewWatchRequest | CreateIssueWatchRequest;
type UpdateRequest = UpdateReviewWatchRequest | UpdateIssueWatchRequest;
export type GitLabWatchDialogProps = {
kind: GitLabWatchKind;
open: boolean;
onOpenChange: (open: boolean) => void;
watch: Watch | null;
workspaceId: string;
onCreate: (request: CreateRequest) => Promise<unknown>;
onUpdate: (id: string, request: UpdateRequest) => Promise<unknown>;
};
type SelectItemShape = { id: string; label: string };
function SelectField(props: {
label: string;
description: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
items: SelectItemShape[];
disabled?: boolean;
}) {
const id = useId();
return (
<div className="min-w-0 space-y-1.5">
<Label htmlFor={id}>{props.label}</Label>
<p className="text-xs text-muted-foreground">{props.description}</p>
<Select
value={props.value || undefined}
onValueChange={props.onChange}
disabled={props.disabled}
>
<SelectTrigger id={id} className="w-full cursor-pointer">
<SelectValue placeholder={props.placeholder} />
</SelectTrigger>
<SelectContent>
{props.items.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h3 className="border-b pb-2 text-sm font-medium">{children}</h3>;
}
function useDialogData(workspaceId: string, workflowId: string) {
useSettingsData(true);
useWorkflows(workspaceId, true);
const workflows = useAppStore((state) => state.workflows.items).filter((item) => !item.hidden);
const agentProfiles = useAppStore((state) => state.agentProfiles.items);
const dynamicRoutingEnabled = useFeature("dynamicAgentRouting");
const selectableAgentProfiles = useMemo(
() =>
agentProfiles.filter((profile) => isSelectableAgentProfile(profile, dynamicRoutingEnabled)),
[agentProfiles, dynamicRoutingEnabled],
);
const executors = useAppStore((state) => state.executors.items);
const executorProfiles = useMemo(
() =>
executors
.filter((item) => item.type !== "local" && item.type !== "local_pc")
.flatMap((item) => item.profiles ?? []),
[executors],
);
const { steps, loading } = useWorkflowSteps(workflowId);
return {
workflows,
agentProfiles: selectableAgentProfiles,
executorProfiles,
steps,
stepsLoading: loading,
};
}
function FilterFields({ kind, form, setForm }: FormFieldsProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<SectionTitle>{t("gitlab:match")}</SectionTitle>
<div className="space-y-1.5">
<Label htmlFor={`${kind}-watch-projects`}>{t("gitlab:projectPaths")}</Label>
<p className="text-xs text-muted-foreground">
{t("gitlab:optionalCommaSeparatedNamespaceProjectPaths")}
</p>
<Input
id={`${kind}-watch-projects`}
value={form.projectPaths}
onChange={(event) =>
setForm((current) => ({ ...current, projectPaths: event.target.value }))
}
// Sample namespace/project paths, i.e. the shape of the GitLab data the
// field accepts. Not copy — a translated "group" would stop being a
// usable example.
// eslint-disable-next-line i18next/no-literal-string -- example project paths
placeholder="group/api, group/web"
/>
</div>
{kind === "issue" && (
<div className="space-y-1.5">
<Label htmlFor="gitlab-watch-labels">{t("gitlab:labels")}</Label>
<p className="text-xs text-muted-foreground">
{t("gitlab:optionalCommaSeparatedGitlabLabelsThat")}
</p>
<Input
id="gitlab-watch-labels"
value={form.labels}
onChange={(event) => setForm((current) => ({ ...current, labels: event.target.value }))}
// Sample GitLab label names — user data, like the labels the user
// will actually type here.
// eslint-disable-next-line i18next/no-literal-string -- example label names
placeholder="bug, priority::high"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor={`${kind}-watch-query`}>{t("gitlab:gitlabQueryParameters")}</Label>
<p className="text-xs text-muted-foreground">
{kind === "review"
? t("gitlab:leaveEmptyToMatchMergeRequests")
: // The example is a literal GitLab API query string, so it is
// interpolated rather than written into the catalog — a translated
// (or pseudo-transliterated) copy of it would not parse.
t("gitlab:optionalGitlabApiQueryParametersSuch", {
example: "state=opened&milestone=Next",
})}
</p>
<Input
id={`${kind}-watch-query`}
value={form.customQuery}
onChange={(event) =>
setForm((current) => ({ ...current, customQuery: event.target.value }))
}
// A literal GitLab API query parameter. It is submitted to GitLab
// verbatim, so it is protocol, not copy.
// eslint-disable-next-line i18next/no-literal-string -- GitLab API query parameter
placeholder="state=opened"
className="font-mono text-xs"
/>
</div>
{kind === "review" && (
<SelectField
label={t("gitlab:reviewScope")}
description={t("gitlab:chooseWhetherToIncludeOnlyDirect")}
value={form.reviewScope}
onChange={(value) =>
setForm((current) => ({
...current,
reviewScope: value as GitLabWatchForm["reviewScope"],
}))
}
placeholder={t("gitlab:directRequests")}
items={[
{ id: "user", label: t("gitlab:directRequests") },
{ id: "user_and_teams", label: t("gitlab:directAndGroupCompatibleRequests") },
]}
/>
)}
</div>
);
}
type FormFieldsProps = {
kind: GitLabWatchKind;
form: GitLabWatchForm;
setForm: React.Dispatch<React.SetStateAction<GitLabWatchForm>>;
};
function AutomationFields({ kind, form, setForm }: FormFieldsProps) {
const { t } = useTranslation();
const stepDefaultLabel = t("common:useStepDefaultOption");
const data = useDialogData(form.workspaceId, form.workflowId);
const placeholders = useMemo(
() => (kind === "review" ? gitlabReviewWatchPlaceholders(t) : gitlabIssueWatchPlaceholders(t)),
[kind, t],
);
return (
<div className="space-y-4">
<SectionTitle>{t("gitlab:taskAutomation")}</SectionTitle>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<SelectField
label={t("gitlab:workflow")}
description={t("gitlab:workflowThatReceivesNewTasks")}
value={form.workflowId}
onChange={(workflowId) =>
setForm((current) => ({ ...current, workflowId, workflowStepId: "" }))
}
placeholder={t("gitlab:selectWorkflow")}
items={data.workflows.map((item) => ({ id: item.id, label: item.name }))}
/>
<SelectField
label={t("gitlab:workflowStep")}
description={t("gitlab:initialStepForEachNewTask")}
value={form.workflowStepId}
onChange={(workflowStepId) => setForm((current) => ({ ...current, workflowStepId }))}
placeholder={stepPlaceholder(form.workflowId, data.stepsLoading, data.steps.length)}
items={data.steps.map((item) => ({ id: item.id, label: item.name }))}
disabled={!form.workflowId || data.stepsLoading || data.steps.length === 0}
/>
</div>
<WatcherRepositoryFields
workspaceId={form.workspaceId}
repositoryId={form.repositoryId}
baseBranch={form.baseBranch}
onRepositoryChange={(repositoryId) =>
setForm((current) => ({ ...current, repositoryId, baseBranch: "" }))
}
onBaseBranchChange={(baseBranch) => setForm((current) => ({ ...current, baseBranch }))}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<SelectField
label={t("gitlab:agentProfile")}
description={t("gitlab:optionalOtherwiseUsesTheWorkflowStep")}
value={form.agentProfileId || STEP_DEFAULT}
onChange={(value) =>
setForm((current) => ({ ...current, agentProfileId: resolveProfileId(value) }))
}
placeholder={stepDefaultLabel}
items={[
{ id: STEP_DEFAULT, label: stepDefaultLabel },
...data.agentProfiles.map((item) => ({ id: item.id, label: item.label })),
]}
/>
<SelectField
label={t("gitlab:executorProfile")}
description={t("gitlab:optionalOtherwiseUsesTheWorkflowStep")}
value={form.executorProfileId || STEP_DEFAULT}
onChange={(value) =>
setForm((current) => ({ ...current, executorProfileId: resolveProfileId(value) }))
}
placeholder={stepDefaultLabel}
items={[
{ id: STEP_DEFAULT, label: stepDefaultLabel },
...data.executorProfiles.map((item) => ({ id: item.id, label: item.name })),
]}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${kind}-watch-prompt`}>{t("gitlab:taskPrompt")}</Label>
<p className="text-xs text-muted-foreground">{t("gitlab:promptSentToTheSelectedAgent")}</p>
<SettingsPromptEditor
value={form.prompt}
onChange={(value) => setForm((current) => ({ ...current, prompt: value }))}
placeholders={placeholders}
promptReferences
ariaLabel={t("gitlab:taskPrompt")}
testId={`${kind}-watch-prompt-editor`}
help={
<p className="text-xs text-muted-foreground">
{t("gitlab:promptSentToTheSelectedAgent")}
</p>
}
/>
</div>
</div>
);
}
function ScheduleFields({ kind, form, setForm }: FormFieldsProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<SectionTitle>{t("gitlab:pollingAndCleanup")}</SectionTitle>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${kind}-watch-interval`}>{t("gitlab:pollIntervalSeconds")}</Label>
<p className="text-xs text-muted-foreground">{t("gitlab:between60And3600Seconds")}</p>
<Input
id={`${kind}-watch-interval`}
type="number"
min={60}
max={3600}
value={form.pollIntervalSeconds}
onChange={(event) =>
setForm((current) => ({ ...current, pollIntervalSeconds: event.target.value }))
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${kind}-watch-inflight`}>{t("gitlab:maximumInFlightTasks")}</Label>
<p className="text-xs text-muted-foreground">
{t("gitlab:optionalCapPollingResumesWhenActive")}
</p>
<Input
id={`${kind}-watch-inflight`}
type="number"
min={1}
value={form.maxInflightTasks}
onChange={(event) =>
setForm((current) => ({ ...current, maxInflightTasks: event.target.value }))
}
placeholder={t("gitlab:noLimit")}
/>
</div>
</div>
<SelectField
label={t("gitlab:cleanupPolicy")}
description={t("gitlab:controlsTaskDeletionWhenTheGitlab")}
value={form.cleanupPolicy}
onChange={(value) =>
setForm((current) => ({
...current,
cleanupPolicy: value as GitLabWatchForm["cleanupPolicy"],
}))
}
placeholder={t("gitlab:auto")}
items={[
{ id: "auto", label: t("gitlab:autoKeepEngagedTasks") },
{ id: "always", label: t("gitlab:alwaysDelete") },
{ id: "never", label: t("gitlab:neverDelete") },
]}
/>
</div>
);
}
function dialogTitle(t: TFunction, kind: GitLabWatchKind, editing: boolean): string {
if (kind === "review") {
return editing ? t("gitlab:editReviewWatch") : t("gitlab:createReviewWatch");
}
return editing ? t("gitlab:editIssueWatch") : t("gitlab:createIssueWatch");
}
export function GitLabWatchDialog({
kind,
open,
onOpenChange,
watch,
workspaceId,
onCreate,
onUpdate,
}: GitLabWatchDialogProps) {
const { t } = useTranslation();
const [form, setForm] = useState(() => makeWatchForm(kind, workspaceId));
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
setForm(watch ? watchFormFromWatch(kind, watch) : makeWatchForm(kind, workspaceId));
setError("");
}, [kind, open, watch, workspaceId]);
const payload = buildWatchPayload(kind as "review", form, Boolean(watch)) as CreateRequest | null;
const save = useCallback(async () => {
if (!payload) return;
setSaving(true);
setError("");
try {
if (watch) {
const { workspace_id: _workspaceId, ...update } = payload;
await onUpdate(watch.id, update);
} else {
await onCreate(payload);
}
onOpenChange(false);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("gitlab:gitlabWatchCouldNotBeSaved"));
} finally {
setSaving(false);
}
}, [onCreate, onOpenChange, onUpdate, payload, t, watch]);
let saveLabel = t("gitlab:createWatch");
if (watch) saveLabel = t("gitlab:updateWatch");
if (saving) saveLabel = t("gitlab:saving");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-none overflow-y-auto sm:max-h-[90vh] sm:w-[min(900px,calc(100vw-2rem))]">
<DialogHeader>
{/* Four whole sentences rather than "Edit"/"Create" + a `kind` noun:
`kind` is the wire discriminant and must never be translated, and
splicing a translated noun into a translated stem fixes English
word order. */}
<DialogTitle>{dialogTitle(t, kind, Boolean(watch))}</DialogTitle>
<DialogDescription>
{kind === "review"
? t("gitlab:reviewWatchDialogDescription")
: t("gitlab:issueWatchDialogDescription")}
</DialogDescription>
</DialogHeader>
{error && (
<Alert variant="destructive">
<IconAlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-6">
<FilterFields kind={kind} form={form} setForm={setForm} />
<AutomationFields kind={kind} form={form} setForm={setForm} />
<ScheduleFields kind={kind} form={form} setForm={setForm} />
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
{t("common:cancel")}
</Button>
<Button
onClick={() => void save()}
disabled={!payload || saving}
className="cursor-pointer"
>
{saveLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}