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"]);
});
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([

View file

@ -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<string | null | undefined>;
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<string>();
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);

View file

@ -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: {

View file

@ -675,6 +675,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
executionWorkspaceSettings?: Record<string, unknown> | 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,

View file

@ -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({
<div>
<p className="text-sm font-medium">Variables</p>
<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>
</div>
{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 />
<RoutineVariablesEditor
title={editDraft.title}
description={editDraft.description}
value={editDraft.variables}
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}

View file

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