mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-18 20:29:21 +02:00
Compare commits
2 Commits
main
...
agent/j/fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e1c2d2648 | ||
|
|
9cf105fde6 |
@@ -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
|
||||
|
||||
@@ -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 的运行历史里看到。
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user