From 1de5fb9316d2ad2b33c23bd5a0022a502e54ae47 Mon Sep 17 00:00:00 2001 From: dotta Date: Tue, 7 Apr 2026 16:31:14 -0500 Subject: [PATCH] Support routine variables in titles --- packages/shared/src/routine-variables.test.ts | 11 +++++++++- packages/shared/src/routine-variables.ts | 21 ++++++++++++------- server/src/__tests__/routines-service.test.ts | 6 ++++-- server/src/services/routines.ts | 10 +++++---- ui/src/components/RoutineVariablesEditor.tsx | 8 ++++--- ui/src/pages/RoutineDetail.tsx | 1 + ui/src/pages/Routines.tsx | 1 + 7 files changed, 41 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/routine-variables.test.ts b/packages/shared/src/routine-variables.test.ts index a3832d87..9169dbfa 100644 --- a/packages/shared/src/routine-variables.test.ts +++ b/packages/shared/src/routine-variables.test.ts @@ -12,9 +12,18 @@ describe("routine variable helpers", () => { ).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", () => { expect( - syncRoutineVariablesWithTemplate("Review {{repo}} and {{priority}}", [ + syncRoutineVariablesWithTemplate(["Triage {{repo}}", "Review {{repo}} and {{priority}}"], [ { name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] }, ]), ).toEqual([ diff --git a/packages/shared/src/routine-variables.ts b/packages/shared/src/routine-variables.ts index 73df368d..3c12b51c 100644 --- a/packages/shared/src/routine-variables.ts +++ b/packages/shared/src/routine-variables.ts @@ -1,18 +1,25 @@ import type { RoutineVariable } from "./types/routine.js"; const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g; +type RoutineTemplateInput = string | null | undefined | Array; export function isValidRoutineVariableName(name: string): boolean { return /^[A-Za-z][A-Za-z0-9_]*$/.test(name); } -export function extractRoutineVariableNames(template: string | null | undefined): string[] { - if (!template) return []; +function normalizeRoutineTemplateInput(input: RoutineTemplateInput): string[] { + 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(); - for (const match of template.matchAll(ROUTINE_VARIABLE_MATCHER)) { - const name = match[1]; - if (name && !found.has(name)) { - found.add(name); + for (const source of normalizeRoutineTemplateInput(template)) { + for (const match of source.matchAll(ROUTINE_VARIABLE_MATCHER)) { + const name = match[1]; + if (name && !found.has(name)) { + found.add(name); + } } } return [...found]; @@ -30,7 +37,7 @@ function defaultRoutineVariable(name: string): RoutineVariable { } export function syncRoutineVariablesWithTemplate( - template: string | null | undefined, + template: RoutineTemplateInput, existing: RoutineVariable[] | null | undefined, ): RoutineVariable[] { const names = extractRoutineVariableNames(template); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 5363fa83..13ce62e0 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -332,7 +332,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { projectId, goalId: null, parentIssueId: null, - title: "repo triage", + title: "repo triage for {{repo}}", description: "Review {{repo}} for {{priority}} bugs", assigneeAgentId: agentId, 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, { source: "manual", @@ -353,7 +354,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { }); const storedIssue = await db - .select({ description: issues.description }) + .select({ title: issues.title, description: issues.description }) .from(issues) .where(eq(issues.id, run.linkedIssueId!)) .then((rows) => rows[0] ?? null); @@ -363,6 +364,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { .where(eq(routineRuns.id, run.id)) .then((rows) => rows[0] ?? null); + expect(storedIssue?.title).toBe("repo triage for paperclip"); expect(storedIssue?.description).toBe("Review paperclip for high bugs"); expect(storedRun?.triggerPayload).toEqual({ variables: { diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 86cc69cb..8cd5ebb7 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -675,6 +675,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup executionWorkspaceSettings?: Record | null; }) { 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 triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables); const run = await db.transaction(async (tx) => { @@ -748,7 +749,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup projectId: input.routine.projectId, goalId: input.routine.goalId, parentId: input.routine.parentIssueId, - title: input.routine.title, + title, description, status: "todo", 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.parentIssueId) await assertParentIssue(companyId, input.parentIssueId); const variables = syncRoutineVariablesWithTemplate( - input.description, + [input.title, input.description], sanitizeRoutineVariableInputs(input.variables), ); assertRoutineVariableDefinitions(variables); @@ -1029,9 +1030,10 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup if (!existing) return null; const nextProjectId = patch.projectId ?? existing.projectId; const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId; + const nextTitle = patch.title ?? existing.title; const nextDescription = patch.description === undefined ? existing.description : patch.description; const nextVariables = syncRoutineVariablesWithTemplate( - nextDescription, + [nextTitle, nextDescription], patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables), ); if (patch.projectId) await assertProject(existing.companyId, nextProjectId); @@ -1060,7 +1062,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup projectId: nextProjectId, goalId: patch.goalId === undefined ? existing.goalId : patch.goalId, parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId, - title: patch.title ?? existing.title, + title: nextTitle, description: nextDescription, assigneeAgentId: nextAssigneeAgentId, priority: patch.priority ?? existing.priority, diff --git a/ui/src/components/RoutineVariablesEditor.tsx b/ui/src/components/RoutineVariablesEditor.tsx index 565589b8..f6ecd322 100644 --- a/ui/src/components/RoutineVariablesEditor.tsx +++ b/ui/src/components/RoutineVariablesEditor.tsx @@ -36,18 +36,20 @@ function updateVariableList( } export function RoutineVariablesEditor({ + title, description, value, onChange, }: { + title: string; description: string; value: RoutineVariable[]; onChange: (value: RoutineVariable[]) => void; }) { const [open, setOpen] = useState(true); const syncedVariables = useMemo( - () => syncRoutineVariablesWithTemplate(description, value), - [description, value], + () => syncRoutineVariablesWithTemplate([title, description], value), + [description, title, value], ); const syncedSignature = serializeVariables(syncedVariables); const currentSignature = serializeVariables(value); @@ -68,7 +70,7 @@ export function RoutineVariablesEditor({

Variables

- Detected from `{"{{name}}"}` placeholders in the routine instructions. + Detected from `{"{{name}}"}` placeholders in the routine title and instructions.

{open ? : } diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index aeaba763..18e4fa6f 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -860,6 +860,7 @@ export function RoutineDetail() { /> setEditDraft((current) => ({ ...current, variables }))} diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index 85e32796..3f9606f0 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -806,6 +806,7 @@ export function Routines() {
setDraft((current) => ({ ...current, variables }))}