mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
Support routine variables in titles
This commit is contained in:
parent
372421ef0b
commit
1de5fb9316
7 changed files with 41 additions and 17 deletions
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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" />}
|
||||||
|
|
|
||||||
|
|
@ -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 }))}
|
||||||
|
|
|
||||||
|
|
@ -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 }))}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue