210 lines
6.9 KiB
TypeScript
210 lines
6.9 KiB
TypeScript
import type {
|
|
SentryIssueWatch,
|
|
SentryLevel,
|
|
SentryProject,
|
|
SentrySearchFilter,
|
|
SentryStatus,
|
|
} from "@/lib/types/sentry";
|
|
import { DEFAULT_SENTRY_ISSUE_WATCH_PROMPT } from "./sentry-issue-watch-placeholders";
|
|
|
|
export const LEVEL_OPTIONS: SentryLevel[] = ["fatal", "error", "warning", "info", "debug"];
|
|
export const STATUS_OPTIONS: SentryStatus[] = ["unresolved", "resolved", "ignored"];
|
|
|
|
export const STATS_PERIOD_OPTIONS: { value: string; label: string }[] = [
|
|
{ value: "1h", label: "Last hour" },
|
|
{ value: "24h", label: "Last 24 hours" },
|
|
{ value: "7d", label: "Last 7 days" },
|
|
{ value: "14d", label: "Last 14 days" },
|
|
{ value: "30d", label: "Last 30 days" },
|
|
];
|
|
|
|
export interface FormState {
|
|
workspaceId: string;
|
|
/** The Sentry instance to poll. Required; immutable after create. */
|
|
sentryInstanceId: string;
|
|
orgSlug: string;
|
|
projectSlugs: string[];
|
|
environment: string;
|
|
levels: SentryLevel[];
|
|
statuses: SentryStatus[];
|
|
query: string;
|
|
statsPeriod: string;
|
|
workflowId: string;
|
|
workflowStepId: string;
|
|
/** Optional repository binding; "" = unbound (repo-less task). */
|
|
repositoryId: string;
|
|
/** Base branch for the worktree; "" = the repository's default branch. */
|
|
baseBranch: string;
|
|
agentProfileId: string;
|
|
executorProfileId: string;
|
|
prompt: string;
|
|
enabled: boolean;
|
|
pollInterval: number;
|
|
maxInflightTasks: string;
|
|
}
|
|
|
|
export function makeEmptyForm(workspaceId: string): FormState {
|
|
return {
|
|
workspaceId,
|
|
sentryInstanceId: "",
|
|
orgSlug: "",
|
|
projectSlugs: [],
|
|
environment: "",
|
|
levels: ["fatal", "error"],
|
|
statuses: ["unresolved"],
|
|
query: "",
|
|
statsPeriod: "24h",
|
|
workflowId: "",
|
|
workflowStepId: "",
|
|
repositoryId: "",
|
|
baseBranch: "",
|
|
agentProfileId: "",
|
|
executorProfileId: "",
|
|
prompt: DEFAULT_SENTRY_ISSUE_WATCH_PROMPT,
|
|
enabled: true,
|
|
pollInterval: 300,
|
|
maxInflightTasks: "5",
|
|
};
|
|
}
|
|
|
|
export function formStateFromWatch(w: SentryIssueWatch): FormState {
|
|
const f: SentrySearchFilter = w.filter ?? { orgSlug: "" };
|
|
return {
|
|
workspaceId: w.workspaceId,
|
|
sentryInstanceId: w.sentryInstanceId ?? "",
|
|
orgSlug: f.orgSlug ?? "",
|
|
projectSlugs: f.projectSlugs ?? [],
|
|
environment: f.environment ?? "",
|
|
levels: f.levels ?? [],
|
|
statuses: f.statuses ?? [],
|
|
query: f.query ?? "",
|
|
statsPeriod: f.statsPeriod ?? "",
|
|
workflowId: w.workflowId,
|
|
workflowStepId: w.workflowStepId,
|
|
repositoryId: w.repositoryId ?? "",
|
|
baseBranch: w.baseBranch ?? "",
|
|
agentProfileId: w.agentProfileId,
|
|
executorProfileId: w.executorProfileId,
|
|
prompt: w.prompt || DEFAULT_SENTRY_ISSUE_WATCH_PROMPT,
|
|
enabled: w.enabled,
|
|
pollInterval: w.pollIntervalSeconds,
|
|
maxInflightTasks: maxInflightTasksString(w.maxInflightTasks),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Formats the throttle cap for the input. nil/undefined and non-positive
|
|
* (from a stale row) collapse to "" — an empty box reads as "no cap", and
|
|
* showing "0" would falsely imply a cap was enforced.
|
|
*/
|
|
export function maxInflightTasksString(v: number | null | undefined): string {
|
|
if (v === undefined || v === null) return "";
|
|
if (!Number.isFinite(v) || v <= 0) return "";
|
|
return String(v);
|
|
}
|
|
|
|
/**
|
|
* Parses the throttle-cap input back into a payload value. Returns `null` for
|
|
* blank (uncapped), the integer for a positive whole number, or "invalid" when
|
|
* the input is non-empty but unparseable / non-positive so the dialog can show
|
|
* an inline error before submit.
|
|
*/
|
|
export function parseMaxInflightTasks(raw: string): number | null | "invalid" {
|
|
const t = raw.trim();
|
|
if (t === "") return null;
|
|
const n = Number(t);
|
|
if (!Number.isInteger(n) || n <= 0) return "invalid";
|
|
return n;
|
|
}
|
|
|
|
export type SelectItemSpec = { id: string; label: string };
|
|
|
|
export function orgSelectItems(orgs: string[], current: string): SelectItemSpec[] {
|
|
const items: SelectItemSpec[] = [];
|
|
const seen = new Set<string>();
|
|
// Include the current value even if the token can no longer see it (editing an
|
|
// old watch) so the Select still shows the saved org.
|
|
for (const slug of [current, ...orgs]) {
|
|
if (!slug || seen.has(slug)) continue;
|
|
seen.add(slug);
|
|
items.push({ id: slug, label: slug });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
export function projectMultiSelectItems(
|
|
projects: SentryProject[],
|
|
current: string[],
|
|
): SelectItemSpec[] {
|
|
const items: SelectItemSpec[] = [];
|
|
const seen = new Set<string>();
|
|
for (const p of projects) {
|
|
if (seen.has(p.slug)) continue;
|
|
seen.add(p.slug);
|
|
items.push({ id: p.slug, label: `${p.name} (${p.slug})` });
|
|
}
|
|
// Include currently-selected slugs even if the token can no longer see them
|
|
// (editing an old watch, or a project since renamed/removed) so the picker
|
|
// still shows the saved selection.
|
|
for (const slug of current) {
|
|
if (!slug || seen.has(slug)) continue;
|
|
seen.add(slug);
|
|
items.push({ id: slug, label: slug });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
// isWatchFormReady aggregates the dialog's "can Save enable?" rule. Kept here
|
|
// so the rule has one named home and the dialog stays under its line limit.
|
|
export function isWatchFormReady(
|
|
form: FormState,
|
|
{ requiresInstance = true }: { requiresInstance?: boolean } = {},
|
|
): boolean {
|
|
return (
|
|
!!form.workspaceId &&
|
|
(!requiresInstance || !!form.sentryInstanceId) &&
|
|
!!form.orgSlug.trim() &&
|
|
form.projectSlugs.length > 0 &&
|
|
!!form.workflowId &&
|
|
!!form.workflowStepId &&
|
|
!!form.prompt.trim() &&
|
|
Number.isInteger(form.pollInterval) &&
|
|
form.pollInterval >= 60 &&
|
|
form.pollInterval <= 3600 &&
|
|
parseMaxInflightTasks(form.maxInflightTasks) !== "invalid"
|
|
);
|
|
}
|
|
|
|
export function buildFilterPayload(form: FormState): SentrySearchFilter {
|
|
return {
|
|
orgSlug: form.orgSlug.trim(),
|
|
projectSlugs: form.projectSlugs.length > 0 ? form.projectSlugs : undefined,
|
|
environment: form.environment.trim() || undefined,
|
|
levels: form.levels.length > 0 ? form.levels : undefined,
|
|
statuses: form.statuses.length > 0 ? form.statuses : undefined,
|
|
query: form.query.trim() || undefined,
|
|
statsPeriod: form.statsPeriod || undefined,
|
|
};
|
|
}
|
|
|
|
// buildWatchPayload assembles the create/update watch fields shared by both
|
|
// paths (everything except workspaceId + sentryInstanceId, which only create
|
|
// carries). maxInflightTasks is resolved by the caller via parseMaxInflightTasks.
|
|
export function buildWatchPayload(form: FormState, maxInflightTasks: number | null) {
|
|
return {
|
|
filter: buildFilterPayload(form),
|
|
workflowId: form.workflowId,
|
|
workflowStepId: form.workflowStepId,
|
|
// Empty repositoryId clears the binding; empty base branch is sent verbatim
|
|
// so the backend fills the repo's default at save time.
|
|
repositoryId: form.repositoryId,
|
|
baseBranch: form.repositoryId ? form.baseBranch : "",
|
|
agentProfileId: form.agentProfileId,
|
|
executorProfileId: form.executorProfileId,
|
|
prompt: form.prompt,
|
|
enabled: form.enabled,
|
|
pollIntervalSeconds: form.pollInterval,
|
|
maxInflightTasks,
|
|
};
|
|
}
|