Files
multica/server/internal/service/autopilot_test.go
Bohan Jiang eabfb8f3d1 fix(autopilots): reject unknown {{...}} tokens in issue title template (MUL-2370) (#2799)
* 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>

* 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>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:12:14 +08:00

195 lines
7.4 KiB
Go

package service
import (
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func TestAutopilotErrorType(t *testing.T) {
cases := map[string]string{
"unknown execution_mode: nope": "configuration",
"issue blocked": "issue_terminal",
"issue cancelled": "issue_terminal",
"enqueue task: no runtime": "dispatch_error",
"task failed": "task_error",
"unexpected": "autopilot_error",
}
for reason, want := range cases {
if got := autopilotErrorType(reason); got != want {
t.Fatalf("autopilotErrorType(%q) = %q, want %q", reason, got, want)
}
}
}
func TestBuildIssueDescription_NoTriggerPayload(t *testing.T) {
s := &AutopilotService{}
ap := db.Autopilot{Description: pgtype.Text{String: "do the thing", Valid: true}}
run := db.AutopilotRun{Source: "schedule"}
got := s.buildIssueDescription(ap, run)
if !strings.HasPrefix(got.String, "do the thing") {
t.Fatalf("description should preserve user description: %q", got.String)
}
if !strings.Contains(got.String, "Autopilot run triggered at") {
t.Fatalf("description should include schedule note: %q", got.String)
}
if strings.Contains(got.String, "Webhook event") {
t.Fatalf("description must not mention webhook for non-webhook source: %q", got.String)
}
}
func TestBuildIssueDescription_WithWebhookPayload(t *testing.T) {
s := &AutopilotService{}
ap := db.Autopilot{Description: pgtype.Text{String: "watch PRs", Valid: true}}
payload := []byte(`{"event":"github.pull_request.opened","eventPayload":{"number":7},"request":{"receivedAt":"2026-05-09T00:00:00Z","contentType":"application/json"}}`)
run := db.AutopilotRun{Source: "webhook", TriggerPayload: payload}
got := s.buildIssueDescription(ap, run)
if !strings.HasPrefix(got.String, "watch PRs") {
t.Fatalf("user description not preserved: %q", got.String)
}
if !strings.Contains(got.String, "Webhook event: github.pull_request.opened") {
t.Fatalf("description should include webhook event line: %q", got.String)
}
if !strings.Contains(got.String, "\"number\": 7") && !strings.Contains(got.String, "\"number\":7") {
t.Fatalf("description should include payload json: %q", got.String)
}
// Italic schedule line must come before the webhook block.
idxItalic := strings.Index(got.String, "*Autopilot run triggered")
idxWebhook := strings.Index(got.String, "Webhook event")
if idxItalic < 0 || idxWebhook < 0 || idxItalic > idxWebhook {
t.Fatalf("italic line should appear before webhook block: %q", got.String)
}
}
func TestBuildIssueDescription_WebhookSourceMissingEnvelope(t *testing.T) {
// Defensive: if a future caller stuffs a non-envelope JSON object into
// trigger_payload, we should still emit a webhook block with sensible
// defaults rather than skipping the section entirely.
s := &AutopilotService{}
ap := db.Autopilot{Description: pgtype.Text{String: "thing", Valid: true}}
payload := []byte(`{"raw":"missing envelope"}`)
run := db.AutopilotRun{Source: "webhook", TriggerPayload: payload}
got := s.buildIssueDescription(ap, run)
if !strings.Contains(got.String, "Webhook event:") {
t.Fatalf("should still emit webhook block: %q", got.String)
}
}
func TestBuildIssueDescription_NonWebhookSourceWithPayloadIgnored(t *testing.T) {
// Manual / schedule with a payload should not get a webhook block.
s := &AutopilotService{}
ap := db.Autopilot{Description: pgtype.Text{String: "thing", Valid: true}}
run := db.AutopilotRun{Source: "manual", TriggerPayload: []byte(`{"event":"x.y"}`)}
got := s.buildIssueDescription(ap, run)
if strings.Contains(got.String, "Webhook event") {
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)
}
})
}
}