Support routine variables in titles

This commit is contained in:
dotta 2026-04-07 16:31:14 -05:00
parent 372421ef0b
commit 1de5fb9316
7 changed files with 41 additions and 17 deletions

View file

@ -12,9 +12,18 @@ describe("routine variable helpers", () => {
).toEqual(["repo", "priority"]); ).toEqual(["repo", "priority"]);
}); });
it("deduplicates placeholder names across the routine title and description", () => {
expect(
extractRoutineVariableNames([
"Triage {{repo}}",
"Review {{repo}} for {{priority}} bugs",
]),
).toEqual(["repo", "priority"]);
});
it("preserves existing metadata when syncing variables from a template", () => { it("preserves existing metadata when syncing variables from a template", () => {
expect( expect(
syncRoutineVariablesWithTemplate("Review {{repo}} and {{priority}}", [ syncRoutineVariablesWithTemplate(["Triage {{repo}}", "Review {{repo}} and {{priority}}"], [
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] }, { name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
]), ]),
).toEqual([ ).toEqual([

View file

@ -1,18 +1,25 @@
import type { RoutineVariable } from "./types/routine.js"; import type { RoutineVariable } from "./types/routine.js";
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g; const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
type RoutineTemplateInput = string | null | undefined | Array<string | null | undefined>;
export function isValidRoutineVariableName(name: string): boolean { export function isValidRoutineVariableName(name: string): boolean {
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name); return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
} }
export function extractRoutineVariableNames(template: string | null | undefined): string[] { function normalizeRoutineTemplateInput(input: RoutineTemplateInput): string[] {
if (!template) return []; const templates = Array.isArray(input) ? input : [input];
return templates.filter((template): template is string => typeof template === "string" && template.length > 0);
}
export function extractRoutineVariableNames(template: RoutineTemplateInput): string[] {
const found = new Set<string>(); const found = new Set<string>();
for (const match of template.matchAll(ROUTINE_VARIABLE_MATCHER)) { for (const source of normalizeRoutineTemplateInput(template)) {
const name = match[1]; for (const match of source.matchAll(ROUTINE_VARIABLE_MATCHER)) {
if (name && !found.has(name)) { const name = match[1];
found.add(name); if (name && !found.has(name)) {
found.add(name);
}
} }
} }
return [...found]; return [...found];
@ -30,7 +37,7 @@ function defaultRoutineVariable(name: string): RoutineVariable {
} }
export function syncRoutineVariablesWithTemplate( export function syncRoutineVariablesWithTemplate(
template: string | null | undefined, template: RoutineTemplateInput,
existing: RoutineVariable[] | null | undefined, existing: RoutineVariable[] | null | undefined,
): RoutineVariable[] { ): RoutineVariable[] {
const names = extractRoutineVariableNames(template); const names = extractRoutineVariableNames(template);

View file

@ -332,7 +332,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
projectId, projectId,
goalId: null, goalId: null,
parentIssueId: null, parentIssueId: null,
title: "repo triage", title: "repo triage for {{repo}}",
description: "Review {{repo}} for {{priority}} bugs", description: "Review {{repo}} for {{priority}} bugs",
assigneeAgentId: agentId, assigneeAgentId: agentId,
priority: "medium", priority: "medium",
@ -346,6 +346,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
}, },
{}, {},
); );
expect(variableRoutine.variables.map((variable) => variable.name)).toEqual(["repo", "priority"]);
const run = await svc.runRoutine(variableRoutine.id, { const run = await svc.runRoutine(variableRoutine.id, {
source: "manual", source: "manual",
@ -353,7 +354,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
}); });
const storedIssue = await db const storedIssue = await db
.select({ description: issues.description }) .select({ title: issues.title, description: issues.description })
.from(issues) .from(issues)
.where(eq(issues.id, run.linkedIssueId!)) .where(eq(issues.id, run.linkedIssueId!))
.then((rows) => rows[0] ?? null); .then((rows) => rows[0] ?? null);
@ -363,6 +364,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
.where(eq(routineRuns.id, run.id)) .where(eq(routineRuns.id, run.id))
.then((rows) => rows[0] ?? null); .then((rows) => rows[0] ?? null);
expect(storedIssue?.title).toBe("repo triage for paperclip");
expect(storedIssue?.description).toBe("Review paperclip for high bugs"); expect(storedIssue?.description).toBe("Review paperclip for high bugs");
expect(storedRun?.triggerPayload).toEqual({ expect(storedRun?.triggerPayload).toEqual({
variables: { variables: {

View file

@ -675,6 +675,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
executionWorkspaceSettings?: Record<string, unknown> | null; executionWorkspaceSettings?: Record<string, unknown> | null;
}) { }) {
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input); const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
const title = interpolateRoutineTemplate(input.routine.title, resolvedVariables) ?? input.routine.title;
const description = interpolateRoutineTemplate(input.routine.description, resolvedVariables); const description = interpolateRoutineTemplate(input.routine.description, resolvedVariables);
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables); const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
const run = await db.transaction(async (tx) => { const run = await db.transaction(async (tx) => {
@ -748,7 +749,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
projectId: input.routine.projectId, projectId: input.routine.projectId,
goalId: input.routine.goalId, goalId: input.routine.goalId,
parentId: input.routine.parentIssueId, parentId: input.routine.parentIssueId,
title: input.routine.title, title,
description, description,
status: "todo", status: "todo",
priority: input.routine.priority, priority: input.routine.priority,
@ -996,7 +997,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (input.goalId) await assertGoal(companyId, input.goalId); if (input.goalId) await assertGoal(companyId, input.goalId);
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId); if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
const variables = syncRoutineVariablesWithTemplate( const variables = syncRoutineVariablesWithTemplate(
input.description, [input.title, input.description],
sanitizeRoutineVariableInputs(input.variables), sanitizeRoutineVariableInputs(input.variables),
); );
assertRoutineVariableDefinitions(variables); assertRoutineVariableDefinitions(variables);
@ -1029,9 +1030,10 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (!existing) return null; if (!existing) return null;
const nextProjectId = patch.projectId ?? existing.projectId; const nextProjectId = patch.projectId ?? existing.projectId;
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId; const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
const nextTitle = patch.title ?? existing.title;
const nextDescription = patch.description === undefined ? existing.description : patch.description; const nextDescription = patch.description === undefined ? existing.description : patch.description;
const nextVariables = syncRoutineVariablesWithTemplate( const nextVariables = syncRoutineVariablesWithTemplate(
nextDescription, [nextTitle, nextDescription],
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables), patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
); );
if (patch.projectId) await assertProject(existing.companyId, nextProjectId); if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
@ -1060,7 +1062,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
projectId: nextProjectId, projectId: nextProjectId,
goalId: patch.goalId === undefined ? existing.goalId : patch.goalId, goalId: patch.goalId === undefined ? existing.goalId : patch.goalId,
parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId, parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId,
title: patch.title ?? existing.title, title: nextTitle,
description: nextDescription, description: nextDescription,
assigneeAgentId: nextAssigneeAgentId, assigneeAgentId: nextAssigneeAgentId,
priority: patch.priority ?? existing.priority, priority: patch.priority ?? existing.priority,

View file

@ -36,18 +36,20 @@ function updateVariableList(
} }
export function RoutineVariablesEditor({ export function RoutineVariablesEditor({
title,
description, description,
value, value,
onChange, onChange,
}: { }: {
title: string;
description: string; description: string;
value: RoutineVariable[]; value: RoutineVariable[];
onChange: (value: RoutineVariable[]) => void; onChange: (value: RoutineVariable[]) => void;
}) { }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const syncedVariables = useMemo( const syncedVariables = useMemo(
() => syncRoutineVariablesWithTemplate(description, value), () => syncRoutineVariablesWithTemplate([title, description], value),
[description, value], [description, title, value],
); );
const syncedSignature = serializeVariables(syncedVariables); const syncedSignature = serializeVariables(syncedVariables);
const currentSignature = serializeVariables(value); const currentSignature = serializeVariables(value);
@ -68,7 +70,7 @@ export function RoutineVariablesEditor({
<div> <div>
<p className="text-sm font-medium">Variables</p> <p className="text-sm font-medium">Variables</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Detected from `{"{{name}}"}` placeholders in the routine instructions. Detected from `{"{{name}}"}` placeholders in the routine title and instructions.
</p> </p>
</div> </div>
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />} {open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}

View file

@ -860,6 +860,7 @@ export function RoutineDetail() {
/> />
<RoutineVariablesHint /> <RoutineVariablesHint />
<RoutineVariablesEditor <RoutineVariablesEditor
title={editDraft.title}
description={editDraft.description} description={editDraft.description}
value={editDraft.variables} value={editDraft.variables}
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))} onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}

View file

@ -806,6 +806,7 @@ export function Routines() {
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
<RoutineVariablesHint /> <RoutineVariablesHint />
<RoutineVariablesEditor <RoutineVariablesEditor
title={draft.title}
description={draft.description} description={draft.description}
value={draft.variables} value={draft.variables}
onChange={(variables) => setDraft((current) => ({ ...current, variables }))} onChange={(variables) => setDraft((current) => ({ ...current, variables }))}