Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
9e1c2d2648 fix(autopilots): render {{ date }} whitespace form too (MUL-2370)
Validator permitted {{ date }} but interpolateTemplate only matched the
exact string {{date}}, so a template that passed create/update could
still emit a literal {{ date }} at trigger time — re-introducing the
silent-literal behaviour the validator was meant to remove.

Route rendering through the same regex as validation so every accepted
form is also a substituted form. Cover {{ date }} substitution in
TestInterpolateTemplate.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:28:52 +08:00
Jiang Bohan
9cf105fde6 fix(autopilots): reject unknown {{...}} tokens in issue title template (MUL-2370)
`--issue-title-template` (and the matching `issue_title_template` API
field) silently kept any placeholder other than `{{date}}` as a literal
string in the rendered issue title — `{{.TriggeredAt}}`, `{{trigger_id}}`,
`${date}`, etc. would all slip through `strings.ReplaceAll` unchanged
because the renderer only knew one token. The flag name and help text
("Template for issue titles (create_issue mode)") and the docs phrasing
("the title supports interpolation like `{{date}}`") both implied a
richer placeholder set existed.

Tightens the contract on three fronts:
- Reject any `{{...}}` token other than `{{date}}` at create/update time
  with `unknown template variable %q; supported: {{date}}` — turns the
  silent-on-trigger surprise into an explicit 400 the moment the user
  sets the template.
- Update CLI flag help on `autopilot create --issue-title-template` and
  `autopilot update --issue-title-template` to spell out that only
  `{{date}}` (UTC, YYYY-MM-DD) is interpolated.
- Update `apps/docs/content/docs/autopilots{,.zh}.mdx` to drop the
  "like `{{date}}`" phrasing for the single supported placeholder.

Adds service-layer tests covering `interpolateTemplate` (substitution,
empty-template fallback, no-placeholder verbatim) and
`ValidateIssueTitleTemplate` (accepts empty / plain / `{{date}}` /
`{{ date }}`; rejects Go-template, Mustache-style, future placeholders
like `{{datetime}}`, and templates that mix one valid and one invalid
token).

Expanding the placeholder set (`{{datetime}}`, `{{trigger_id}}`,
`{{trigger_source}}`) is tracked as a separate enhancement — those
need run/trigger context plumbed into the renderer, which is out of
scope for this bug fix.

Closes #2732

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:17:32 +08:00
6 changed files with 173 additions and 6 deletions

View File

@@ -22,7 +22,7 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
An autopilot has two execution modes. **Start with "create issue" mode.**
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title currently supports a single placeholder, `{{date}}`, which interpolates to the UTC date in `YYYY-MM-DD` format; any other `{{...}}` token is rejected at create-time so a typo cannot silently land as the literal string in your issue titles), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
## Run it on a schedule

View File

@@ -22,7 +22,7 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题目前只支持一个占位符 `{{date}}`,会插值成 UTC 日期 `YYYY-MM-DD`;其他 `{{...}}` 形式的占位符会在创建时被拒绝,避免拼错以后悄无声息地把原文当成 issue 标题),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **直跑模式**`run_only`)—— 不建 issue直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
## 让它按时间跑

View File

@@ -123,7 +123,7 @@ func init() {
autopilotCreateCmd.Flags().String("mode", "", "Execution mode: create_issue or run_only (required)")
autopilotCreateCmd.Flags().String("priority", "none", "Priority for created issues (none, low, medium, high, urgent)")
autopilotCreateCmd.Flags().String("project", "", "Project ID (optional)")
autopilotCreateCmd.Flags().String("issue-title-template", "", "Template for issue titles (create_issue mode)")
autopilotCreateCmd.Flags().String("issue-title-template", "", "Template for issue titles (create_issue mode). Only {{date}} (UTC, YYYY-MM-DD) is interpolated; any other {{...}} token is rejected at create-time.")
autopilotCreateCmd.Flags().String("output", "json", "Output format: table or json")
// update
@@ -134,7 +134,7 @@ func init() {
autopilotUpdateCmd.Flags().String("priority", "", "New priority")
autopilotUpdateCmd.Flags().String("status", "", "New status (active, paused)")
autopilotUpdateCmd.Flags().String("mode", "", "New execution mode (create_issue or run_only)")
autopilotUpdateCmd.Flags().String("issue-title-template", "", "New issue title template")
autopilotUpdateCmd.Flags().String("issue-title-template", "", "New issue title template. Only {{date}} (UTC, YYYY-MM-DD) is interpolated; any other {{...}} token is rejected.")
autopilotUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// delete

View File

@@ -347,6 +347,12 @@ func (h *Handler) CreateAutopilot(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "execution_mode must be create_issue or run_only")
return
}
if req.IssueTitleTemplate != nil {
if err := service.ValidateIssueTitleTemplate(*req.IssueTitleTemplate); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
workspaceID := h.resolveWorkspaceID(r)
userID, ok := requireUserID(w, r)
@@ -440,6 +446,12 @@ func (h *Handler) UpdateAutopilot(w http.ResponseWriter, r *http.Request) {
params.Description = ptrToText(req.Description)
}
if _, ok := rawFields["issue_title_template"]; ok {
if req.IssueTitleTemplate != nil {
if err := service.ValidateIssueTitleTemplate(*req.IssueTitleTemplate); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
params.IssueTitleTemplate = ptrToText(req.IssueTitleTemplate)
}
if _, ok := rawFields["assignee_id"]; ok {

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
@@ -637,14 +638,68 @@ func prettifyJSON(raw []byte) ([]byte, error) {
return json.MarshalIndent(v, "", " ")
}
// interpolateTemplate replaces {{date}} in the issue title template.
// issueTitleTemplateTokenRE matches any {{...}} token in an issue-title
// template. We deliberately permit whitespace inside the braces ({{ date }})
// so users can format templates either way; the canonical token is still
// {{date}}.
var issueTitleTemplateTokenRE = regexp.MustCompile(`\{\{\s*([^{}]*?)\s*\}\}`)
// interpolateTemplate substitutes supported {{name}} placeholders in the
// issue title template. Whitespace inside the braces ({{ date }}) is
// tolerated so the render layer accepts every form that
// ValidateIssueTitleTemplate accepts — otherwise users would save templates
// that pass validation but still emit a literal token at trigger time.
func (s *AutopilotService) interpolateTemplate(ap db.Autopilot) string {
tmpl := ap.Title
if ap.IssueTitleTemplate.Valid && ap.IssueTitleTemplate.String != "" {
tmpl = ap.IssueTitleTemplate.String
}
now := time.Now().UTC().Format("2006-01-02")
return strings.ReplaceAll(tmpl, "{{date}}", now)
return issueTitleTemplateTokenRE.ReplaceAllStringFunc(tmpl, func(match string) string {
name := strings.TrimSpace(match[2 : len(match)-2])
switch name {
case "date":
return now
default:
return match
}
})
}
// SupportedIssueTitleTemplateVariables enumerates the placeholders that
// interpolateTemplate will substitute. Keep this in sync with the
// substitution logic above and with the docs in autopilots.mdx /
// autopilots.zh.mdx.
var SupportedIssueTitleTemplateVariables = []string{"date"}
// ValidateIssueTitleTemplate rejects templates that contain any {{...}} token
// other than the supported set. An empty template is valid (the autopilot
// falls back to its own Title). The error message names the first offending
// token to keep CLI feedback actionable.
func ValidateIssueTitleTemplate(tmpl string) error {
if tmpl == "" {
return nil
}
for _, m := range issueTitleTemplateTokenRE.FindAllStringSubmatch(tmpl, -1) {
name := m[1]
if !isSupportedIssueTitleVariable(name) {
return fmt.Errorf(
"unknown template variable %q; supported: {{%s}}",
name,
strings.Join(SupportedIssueTitleTemplateVariables, "}}, {{"),
)
}
}
return nil
}
func isSupportedIssueTitleVariable(name string) bool {
for _, v := range SupportedIssueTitleTemplateVariables {
if name == v {
return true
}
}
return false
}
func (s *AutopilotService) getIssuePrefix(workspaceID pgtype.UUID) string {

View File

@@ -3,6 +3,7 @@ package service
import (
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
@@ -92,3 +93,102 @@ func TestBuildIssueDescription_NonWebhookSourceWithPayloadIgnored(t *testing.T)
t.Fatalf("non-webhook source should not include webhook block: %q", got.String)
}
}
// TestInterpolateTemplate covers the three behaviours that real autopilot
// runs depend on: {{date}} substitution, falling back to Title when the
// template is unset/empty, and leaving any non-{{date}} text alone (the
// handler is the layer that prevents unknown tokens from being stored in
// the first place — service-layer interpolation stays substitute-or-leave).
func TestInterpolateTemplate(t *testing.T) {
s := &AutopilotService{}
today := time.Now().UTC().Format("2006-01-02")
cases := []struct {
name string
ap db.Autopilot
expect string
}{
{
name: "date placeholder substituted",
ap: db.Autopilot{Title: "fallback", IssueTitleTemplate: pgtype.Text{String: "probe — {{date}}", Valid: true}},
expect: "probe — " + today,
},
{
name: "date placeholder with whitespace substituted",
ap: db.Autopilot{Title: "fallback", IssueTitleTemplate: pgtype.Text{String: "probe — {{ date }}", Valid: true}},
expect: "probe — " + today,
},
{
name: "empty template falls back to autopilot title",
ap: db.Autopilot{Title: "fallback title", IssueTitleTemplate: pgtype.Text{Valid: false}},
expect: "fallback title",
},
{
name: "template without placeholder is returned verbatim",
ap: db.Autopilot{Title: "fallback", IssueTitleTemplate: pgtype.Text{String: "static title", Valid: true}},
expect: "static title",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := s.interpolateTemplate(tc.ap); got != tc.expect {
t.Fatalf("interpolateTemplate = %q, want %q", got, tc.expect)
}
})
}
}
// TestValidateIssueTitleTemplate locks down what create/update accept.
// Reject path: anything inside {{...}} that is not in the supported set.
// Accept path: empty, plain text, and the canonical {{date}} placeholder
// in both compact and whitespace-padded forms.
func TestValidateIssueTitleTemplate(t *testing.T) {
t.Run("accepts empty template", func(t *testing.T) {
if err := ValidateIssueTitleTemplate(""); err != nil {
t.Fatalf("empty template must be valid: %v", err)
}
})
t.Run("accepts plain text", func(t *testing.T) {
if err := ValidateIssueTitleTemplate("daily report"); err != nil {
t.Fatalf("plain text must be valid: %v", err)
}
})
t.Run("accepts {{date}}", func(t *testing.T) {
if err := ValidateIssueTitleTemplate("probe — {{date}}"); err != nil {
t.Fatalf("{{date}} must be valid: %v", err)
}
})
t.Run("accepts {{ date }} with whitespace", func(t *testing.T) {
if err := ValidateIssueTitleTemplate("probe — {{ date }}"); err != nil {
t.Fatalf("{{ date }} must be valid: %v", err)
}
})
rejections := []struct {
name string
tmpl string
// nameInError is the offending variable name that must appear in the
// returned error so CLI users see which token was rejected.
nameInError string
}{
{"go template style", "probe — {{.TriggeredAt}}", ".TriggeredAt"},
{"mustache style unknown variable", "probe — {{trigger_id}}", "trigger_id"},
{"datetime not yet supported", "probe — {{datetime}}", "datetime"},
{"empty placeholder", "probe — {{}}", ""},
{"mixed valid + invalid still fails", "probe — {{date}} {{trigger_source}}", "trigger_source"},
}
for _, tc := range rejections {
t.Run(tc.name, func(t *testing.T) {
err := ValidateIssueTitleTemplate(tc.tmpl)
if err == nil {
t.Fatalf("expected rejection for %q", tc.tmpl)
}
if !strings.Contains(err.Error(), "unknown template variable") {
t.Fatalf("error should mention unknown template variable: %v", err)
}
if tc.nameInError != "" && !strings.Contains(err.Error(), tc.nameInError) {
t.Fatalf("error should name the offending token %q: %v", tc.nameInError, err)
}
})
}
}