Files
multica/server/internal/service/autopilot.go
Jiayuan Zhang 4c7a990a25 fix(autopilot): attribute autopilot-created issue to assignee agent (MUL-2293) (#2719)
Before: dispatchCreateIssue copied autopilot.created_by_type/id onto the
new issue's creator_type/creator_id, and the same fields were used as the
ActorType/ActorID of the issue:created event. Result: any issue spawned by
an autopilot was reported as created by the human who first configured
the autopilot, not by the agent that actually owns the work. Downstream
subscriber/activity/notification listeners inherited the same wrong actor.

After: creator and actor are both the autopilot's assignee agent
(creator_type=agent, creator_id=ap.assignee_id). The human owner is still
recoverable via origin_type=autopilot + origin_id.

Audited the other ap.created_by_* usages: analytics attribution
(autopilotActorID, task.go user-id), and the private-agent visibility
gate in shouldSkipDispatch — all correctly read the autopilot's owner,
not the executor, so they stay as-is.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 09:32:15 +02:00

678 lines
22 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/issueguard"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// TxStarter abstracts transaction creation (satisfied by pgxpool.Pool).
type TxStarter interface {
Begin(ctx context.Context) (pgx.Tx, error)
}
type AutopilotService struct {
Queries *db.Queries
TxStarter TxStarter
Bus *events.Bus
TaskSvc *TaskService
}
func NewAutopilotService(q *db.Queries, tx TxStarter, bus *events.Bus, taskSvc *TaskService) *AutopilotService {
return &AutopilotService{Queries: q, TxStarter: tx, Bus: bus, TaskSvc: taskSvc}
}
// DispatchAutopilot is the core execution entry point.
// It creates a run and either creates an issue or enqueues a direct agent task
// depending on execution_mode.
//
// Before any work is queued we run an admission check against the assignee
// agent's runtime: if it is not online, we record a `skipped` run with a
// failure_reason and return without enqueueing. This is the "触发时准入" gate
// from MUL-1899 — without it a paused laptop / offline daemon causes scheduled
// autopilots to pile thousands of doomed tasks onto agent_task_queue.
func (s *AutopilotService) DispatchAutopilot(
ctx context.Context,
autopilot db.Autopilot,
triggerID pgtype.UUID,
source string,
payload []byte,
) (*db.AutopilotRun, error) {
if reason, skip := s.shouldSkipDispatch(ctx, autopilot); skip {
return s.recordSkippedRun(ctx, autopilot, triggerID, source, payload, reason)
}
// Determine initial status based on execution mode.
initialStatus := "issue_created"
if autopilot.ExecutionMode == "run_only" {
initialStatus = "running"
}
run, err := s.Queries.CreateAutopilotRun(ctx, db.CreateAutopilotRunParams{
AutopilotID: autopilot.ID,
TriggerID: triggerID,
Source: source,
Status: initialStatus,
TriggerPayload: payload,
})
if err != nil {
return nil, fmt.Errorf("create run: %w", err)
}
s.captureAutopilotRunStarted(autopilot, run, source)
switch autopilot.ExecutionMode {
case "create_issue":
if err := s.dispatchCreateIssue(ctx, autopilot, &run); err != nil {
var duplicate *issueguard.ActiveDuplicateError
if errors.As(err, &duplicate) {
updatedRun := s.skipDuplicateRun(ctx, autopilot, run, source, duplicate)
if source == "manual" {
return &updatedRun, fmt.Errorf("dispatch create_issue: %w", err)
}
return &updatedRun, nil
}
s.failRun(ctx, run.ID, err.Error())
s.captureAutopilotRunFailed(autopilot, run, source, err.Error())
return &run, fmt.Errorf("dispatch create_issue: %w", err)
}
case "run_only":
if err := s.dispatchRunOnly(ctx, autopilot, &run); err != nil {
s.failRun(ctx, run.ID, err.Error())
s.captureAutopilotRunFailed(autopilot, run, source, err.Error())
return &run, fmt.Errorf("dispatch run_only: %w", err)
}
default:
s.failRun(ctx, run.ID, "unknown execution_mode: "+autopilot.ExecutionMode)
s.captureAutopilotRunFailed(autopilot, run, source, "unknown execution_mode: "+autopilot.ExecutionMode)
return &run, fmt.Errorf("unknown execution_mode: %s", autopilot.ExecutionMode)
}
// Update last_run_at on the autopilot.
s.Queries.UpdateAutopilotLastRunAt(ctx, autopilot.ID)
// Publish run start event.
s.Bus.Publish(events.Event{
Type: protocol.EventAutopilotRunStart,
WorkspaceID: util.UUIDToString(autopilot.WorkspaceID),
ActorType: "system",
Payload: map[string]any{
"run_id": util.UUIDToString(run.ID),
"autopilot_id": util.UUIDToString(autopilot.ID),
"source": source,
"status": run.Status,
},
})
return &run, nil
}
// dispatchCreateIssue creates an issue and enqueues a task for the agent.
func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopilot, run *db.AutopilotRun) error {
tx, err := s.TxStarter.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
qtx := s.Queries.WithTx(tx)
title := s.interpolateTemplate(ap)
description := s.buildIssueDescription(ap)
duplicate, foundDuplicate, err := issueguard.LockAndFindActiveDuplicate(ctx, qtx, ap.WorkspaceID, pgtype.UUID{}, pgtype.UUID{}, title, false)
if err != nil {
return fmt.Errorf("check duplicate issue: %w", err)
}
if foundDuplicate {
return issueguard.NewActiveDuplicateError(duplicate, s.getIssuePrefix(ap.WorkspaceID))
}
issueNumber, err := qtx.IncrementIssueCounter(ctx, ap.WorkspaceID)
if err != nil {
return fmt.Errorf("increment issue counter: %w", err)
}
issue, err := qtx.CreateIssueWithOrigin(ctx, db.CreateIssueWithOriginParams{
WorkspaceID: ap.WorkspaceID,
Title: title,
Description: description,
Status: "todo",
Priority: "none",
AssigneeType: pgtype.Text{String: "agent", Valid: true},
AssigneeID: ap.AssigneeID,
// The agent that the autopilot dispatches to is the issue's creator,
// not the human who originally configured the autopilot. The latter
// is captured separately via origin_type=autopilot + origin_id.
CreatorType: "agent",
CreatorID: ap.AssigneeID,
ParentIssueID: pgtype.UUID{},
Position: 0,
DueDate: pgtype.Timestamptz{},
Number: issueNumber,
ProjectID: pgtype.UUID{},
OriginType: pgtype.Text{String: "autopilot", Valid: true},
OriginID: ap.ID,
})
if err != nil {
return fmt.Errorf("create issue: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
// Update run with the linked issue.
updatedRun, err := s.Queries.UpdateAutopilotRunIssueCreated(ctx, db.UpdateAutopilotRunIssueCreatedParams{
ID: run.ID,
IssueID: issue.ID,
})
if err != nil {
return fmt.Errorf("link run to issue: %w", err)
}
*run = updatedRun
// Publish issue:created so the existing event chain fires
// (subscriber listeners, activity listeners, notification listeners).
prefix := s.getIssuePrefix(ap.WorkspaceID)
s.Bus.Publish(events.Event{
Type: protocol.EventIssueCreated,
WorkspaceID: util.UUIDToString(ap.WorkspaceID),
ActorType: "agent",
ActorID: util.UUIDToString(ap.AssigneeID),
Payload: map[string]any{
"issue": issueToMap(issue, prefix),
},
})
s.captureIssueCreatedFromAutopilot(ap, run, issue)
// Enqueue agent task via the existing flow.
if _, err := s.TaskSvc.EnqueueTaskForIssue(ctx, issue); err != nil {
return fmt.Errorf("enqueue task for issue: %w", err)
}
slog.Info("autopilot dispatched (create_issue)",
"autopilot_id", util.UUIDToString(ap.ID),
"issue_id", util.UUIDToString(issue.ID),
"run_id", util.UUIDToString(run.ID),
)
return nil
}
// dispatchRunOnly enqueues a direct agent task without creating an issue.
func (s *AutopilotService) dispatchRunOnly(ctx context.Context, ap db.Autopilot, run *db.AutopilotRun) error {
agent, err := s.Queries.GetAgent(ctx, ap.AssigneeID)
if err != nil {
return fmt.Errorf("load agent: %w", err)
}
if agent.ArchivedAt.Valid {
return fmt.Errorf("agent is archived")
}
if !agent.RuntimeID.Valid {
return fmt.Errorf("agent has no runtime")
}
task, err := s.Queries.CreateAutopilotTask(ctx, db.CreateAutopilotTaskParams{
AgentID: ap.AssigneeID,
RuntimeID: agent.RuntimeID,
Priority: 0,
AutopilotRunID: run.ID,
// Snapshot the autopilot title so task rows self-describe later
// without joining back to autopilot. Truncated for the same
// transmission-cost reason as comment-driven summaries.
TriggerSummary: pgtype.Text{
String: truncateForSummary(ap.Title, triggerSummaryMaxLen),
Valid: ap.Title != "",
},
})
if err != nil {
return fmt.Errorf("create autopilot task: %w", err)
}
// Update run with task reference.
updatedRun, err := s.Queries.UpdateAutopilotRunRunning(ctx, db.UpdateAutopilotRunRunningParams{
ID: run.ID,
TaskID: task.ID,
})
if err != nil {
slog.Warn("failed to update run with task_id", "run_id", util.UUIDToString(run.ID), "error", err)
} else {
*run = updatedRun
}
// Drop the empty-claim cache and wake the daemon. dispatchRunOnly
// inserts the task row directly via Queries.CreateAutopilotTask
// (bypassing TaskService.Enqueue*), so without this the runtime
// would not get a wakeup and any cached "empty" verdict would
// stall the task until the TTL expired.
s.TaskSvc.NotifyTaskEnqueued(ctx, task)
slog.Info("autopilot dispatched (run_only)",
"autopilot_id", util.UUIDToString(ap.ID),
"task_id", util.UUIDToString(task.ID),
"run_id", util.UUIDToString(run.ID),
)
return nil
}
// SyncRunFromIssue updates the autopilot run when its linked issue reaches a terminal status.
func (s *AutopilotService) SyncRunFromIssue(ctx context.Context, issue db.Issue) {
if !issue.OriginType.Valid || issue.OriginType.String != "autopilot" {
return
}
run, err := s.Queries.GetAutopilotRunByIssue(ctx, issue.ID)
if err != nil {
return // no active run linked to this issue
}
autopilot, err := s.Queries.GetAutopilot(ctx, run.AutopilotID)
if err != nil {
return
}
wsID := util.UUIDToString(issue.WorkspaceID)
switch issue.Status {
case "done", "in_review":
updatedRun, err := s.Queries.UpdateAutopilotRunCompleted(ctx, db.UpdateAutopilotRunCompletedParams{
ID: run.ID,
})
if err != nil {
slog.Warn("failed to complete autopilot run", "run_id", util.UUIDToString(run.ID), "error", err)
return
}
s.captureAutopilotRunCompleted(autopilot, updatedRun)
s.publishRunDone(wsID, updatedRun, "completed")
case "cancelled", "blocked":
reason := "issue " + issue.Status
updatedRun, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{
ID: run.ID,
FailureReason: pgtype.Text{String: reason, Valid: true},
})
if err != nil {
slog.Warn("failed to fail autopilot run", "run_id", util.UUIDToString(run.ID), "error", err)
return
}
s.captureAutopilotRunFailed(autopilot, updatedRun, updatedRun.Source, reason)
s.publishRunDone(wsID, updatedRun, "failed")
}
}
// SyncRunFromTask updates the autopilot run when a run_only task completes or fails.
func (s *AutopilotService) SyncRunFromTask(ctx context.Context, task db.AgentTaskQueue) {
if !task.AutopilotRunID.Valid {
return
}
run, err := s.Queries.GetAutopilotRun(ctx, task.AutopilotRunID)
if err != nil {
return
}
autopilot, err := s.Queries.GetAutopilot(ctx, run.AutopilotID)
if err != nil {
return
}
wsID := util.UUIDToString(autopilot.WorkspaceID)
switch task.Status {
case "completed":
updatedRun, err := s.Queries.UpdateAutopilotRunCompleted(ctx, db.UpdateAutopilotRunCompletedParams{
ID: run.ID,
Result: task.Result,
})
if err != nil {
slog.Warn("failed to complete autopilot run from task", "run_id", util.UUIDToString(run.ID), "error", err)
return
}
s.captureAutopilotRunCompleted(autopilot, updatedRun)
s.publishRunDone(wsID, updatedRun, "completed")
case "failed", "cancelled":
reason := "task " + task.Status
if task.Error.Valid {
reason = task.Error.String
}
updatedRun, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{
ID: run.ID,
FailureReason: pgtype.Text{String: reason, Valid: true},
})
if err != nil {
slog.Warn("failed to fail autopilot run from task", "run_id", util.UUIDToString(run.ID), "error", err)
return
}
s.captureAutopilotRunFailed(autopilot, updatedRun, updatedRun.Source, reason)
s.publishRunDone(wsID, updatedRun, "failed")
}
}
func (s *AutopilotService) failRun(ctx context.Context, runID pgtype.UUID, reason string) {
if _, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{
ID: runID,
FailureReason: pgtype.Text{String: reason, Valid: true},
}); err != nil {
slog.Warn("failed to mark autopilot run as failed", "run_id", util.UUIDToString(runID), "error", err)
}
}
func (s *AutopilotService) skipDuplicateRun(
ctx context.Context,
autopilot db.Autopilot,
run db.AutopilotRun,
source string,
duplicate *issueguard.ActiveDuplicateError,
) db.AutopilotRun {
reason := duplicate.Error()
result, err := json.Marshal(map[string]any{
"code": "active_duplicate_issue",
"issue": map[string]any{
"id": duplicate.ID,
"identifier": duplicate.Identifier,
"title": duplicate.Title,
"status": duplicate.Status,
},
})
if err != nil {
slog.Warn("failed to marshal duplicate autopilot result",
"run_id", util.UUIDToString(run.ID),
"error", err,
)
}
updatedRun, err := s.Queries.UpdateAutopilotRunSkippedWithResult(ctx, db.UpdateAutopilotRunSkippedWithResultParams{
ID: run.ID,
FailureReason: pgtype.Text{String: reason, Valid: true},
Result: result,
})
if err != nil {
slog.Warn("failed to mark duplicate autopilot run as skipped",
"run_id", util.UUIDToString(run.ID),
"error", err,
)
return run
}
slog.Info("autopilot duplicate issue skipped",
"autopilot_id", util.UUIDToString(autopilot.ID),
"run_id", util.UUIDToString(run.ID),
"source", source,
"existing_issue_id", duplicate.ID,
)
s.Queries.UpdateAutopilotLastRunAt(ctx, autopilot.ID)
s.publishRunDone(util.UUIDToString(autopilot.WorkspaceID), updatedRun, "skipped")
return updatedRun
}
// shouldSkipDispatch is the pre-flight admission check from MUL-1899.
// Returns (reason, true) when dispatching now would only enqueue a doomed
// task — i.e. the assignee agent is gone, archived, has no runtime bound, or
// its runtime is not currently online. Returns ("", false) on the happy path.
//
// Errors loading the agent / runtime are logged but treated as "do not skip"
// so a transient DB hiccup never silently swallows a scheduled run.
func (s *AutopilotService) shouldSkipDispatch(ctx context.Context, ap db.Autopilot) (string, bool) {
if !ap.AssigneeID.Valid {
return "autopilot has no assignee", true
}
agent, err := s.Queries.GetAgent(ctx, ap.AssigneeID)
if err != nil {
slog.Warn("autopilot admission: failed to load assignee agent",
"autopilot_id", util.UUIDToString(ap.ID),
"agent_id", util.UUIDToString(ap.AssigneeID),
"error", err,
)
return "", false
}
if agent.ArchivedAt.Valid {
return "assignee agent is archived", true
}
if !agent.RuntimeID.Valid {
return "assignee agent has no runtime bound", true
}
rt, err := s.Queries.GetAgentRuntime(ctx, agent.RuntimeID)
if err != nil {
slog.Warn("autopilot admission: failed to load runtime",
"autopilot_id", util.UUIDToString(ap.ID),
"runtime_id", util.UUIDToString(agent.RuntimeID),
"error", err,
)
return "", false
}
if rt.Status != "online" {
return "agent runtime is " + rt.Status + " at dispatch time", true
}
// Private-agent gate at the autopilot layer. Caller identity = the
// autopilot's creator: if the creator no longer has access to the
// (now-private) target agent, the dispatch is recorded as `skipped`.
// Agent-created autopilots bypass the gate to preserve A2A
// collaboration. Errors loading the workspace member fail closed —
// without an authoritative role the gate cannot grant access.
if agent.Visibility == "private" && ap.CreatedByType == "member" {
creatorID := util.UUIDToString(ap.CreatedByID)
if util.UUIDToString(agent.OwnerID) != creatorID {
member, err := s.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: ap.CreatedByID,
WorkspaceID: ap.WorkspaceID,
})
if err != nil {
return "autopilot creator no longer in workspace", true
}
if member.Role != "owner" && member.Role != "admin" {
return "autopilot creator lacks access to private assignee agent", true
}
}
}
return "", false
}
// recordSkippedRun persists a `skipped` autopilot_run with the given reason
// and emits the same WS / analytics signals that a normal terminal transition
// would. Returns the run + nil error so callers (scheduler tick, manual
// trigger handler) treat this as a successful — but no-op — dispatch.
func (s *AutopilotService) recordSkippedRun(
ctx context.Context,
autopilot db.Autopilot,
triggerID pgtype.UUID,
source string,
payload []byte,
reason string,
) (*db.AutopilotRun, error) {
run, err := s.Queries.CreateAutopilotRun(ctx, db.CreateAutopilotRunParams{
AutopilotID: autopilot.ID,
TriggerID: triggerID,
Source: source,
Status: "skipped",
TriggerPayload: payload,
})
if err != nil {
return nil, fmt.Errorf("create skipped run: %w", err)
}
updated, err := s.Queries.UpdateAutopilotRunSkipped(ctx, db.UpdateAutopilotRunSkippedParams{
ID: run.ID,
FailureReason: pgtype.Text{String: reason, Valid: true},
})
if err == nil {
run = updated
} else {
slog.Warn("failed to set skip reason on autopilot run",
"run_id", util.UUIDToString(run.ID), "error", err)
}
slog.Info("autopilot dispatch skipped",
"autopilot_id", util.UUIDToString(autopilot.ID),
"run_id", util.UUIDToString(run.ID),
"source", source,
"reason", reason,
)
// Bump last_run_at so scheduler advancement and "last seen" UI both
// reflect that we did evaluate the trigger this tick.
s.Queries.UpdateAutopilotLastRunAt(ctx, autopilot.ID)
s.publishRunDone(util.UUIDToString(autopilot.WorkspaceID), run, "skipped")
return &run, nil
}
func (s *AutopilotService) publishRunDone(workspaceID string, run db.AutopilotRun, status string) {
s.Bus.Publish(events.Event{
Type: protocol.EventAutopilotRunDone,
WorkspaceID: workspaceID,
ActorType: "system",
Payload: map[string]any{
"run_id": util.UUIDToString(run.ID),
"autopilot_id": util.UUIDToString(run.AutopilotID),
"status": status,
},
})
}
func (s *AutopilotService) captureIssueCreatedFromAutopilot(ap db.Autopilot, run *db.AutopilotRun, issue db.Issue) {
if s.TaskSvc == nil || s.TaskSvc.Analytics == nil {
return
}
s.TaskSvc.Analytics.Capture(analytics.IssueCreated(
autopilotActorID(ap),
util.UUIDToString(ap.WorkspaceID),
util.UUIDToString(issue.ID),
util.UUIDToString(ap.AssigneeID),
"",
util.UUIDToString(run.ID),
analytics.SourceAutopilot,
))
}
func (s *AutopilotService) captureAutopilotRunStarted(ap db.Autopilot, run db.AutopilotRun, triggerSource string) {
if s.TaskSvc == nil || s.TaskSvc.Analytics == nil {
return
}
s.TaskSvc.Analytics.Capture(analytics.AutopilotRunStarted(
autopilotActorID(ap),
util.UUIDToString(ap.WorkspaceID),
util.UUIDToString(ap.ID),
util.UUIDToString(run.ID),
util.UUIDToString(ap.AssigneeID),
triggerSource,
))
}
func (s *AutopilotService) captureAutopilotRunCompleted(ap db.Autopilot, run db.AutopilotRun) {
if s.TaskSvc == nil || s.TaskSvc.Analytics == nil {
return
}
s.TaskSvc.Analytics.Capture(analytics.AutopilotRunCompleted(
autopilotActorID(ap),
util.UUIDToString(ap.WorkspaceID),
util.UUIDToString(ap.ID),
util.UUIDToString(run.ID),
util.UUIDToString(ap.AssigneeID),
run.Source,
autopilotRunDurationMS(run),
))
}
func (s *AutopilotService) captureAutopilotRunFailed(ap db.Autopilot, run db.AutopilotRun, triggerSource, reason string) {
if s.TaskSvc == nil || s.TaskSvc.Analytics == nil {
return
}
if reason == "" {
reason = "unknown"
}
s.TaskSvc.Analytics.Capture(analytics.AutopilotRunFailed(
autopilotActorID(ap),
util.UUIDToString(ap.WorkspaceID),
util.UUIDToString(ap.ID),
util.UUIDToString(run.ID),
util.UUIDToString(ap.AssigneeID),
triggerSource,
reason,
autopilotErrorType(reason),
false,
autopilotRunDurationMS(run),
))
}
func autopilotErrorType(reason string) string {
switch {
case strings.Contains(reason, "unknown execution_mode"):
return "configuration"
case strings.HasPrefix(reason, "issue "):
return "issue_terminal"
case strings.Contains(reason, "create issue"), strings.Contains(reason, "enqueue task"), strings.Contains(reason, "dispatch"):
return "dispatch_error"
case strings.HasPrefix(reason, "task "):
return "task_error"
default:
return "autopilot_error"
}
}
func autopilotActorID(ap db.Autopilot) string {
id := util.UUIDToString(ap.CreatedByID)
if ap.CreatedByType == "agent" && id != "" {
return "agent:" + id
}
if id != "" {
return id
}
return "system"
}
func autopilotRunDurationMS(run db.AutopilotRun) int64 {
if !run.CompletedAt.Valid {
return 0
}
start := run.TriggeredAt
if !start.Valid {
start = run.CreatedAt
}
if !start.Valid {
return 0
}
ms := run.CompletedAt.Time.Sub(start.Time).Milliseconds()
if ms < 0 {
return 0
}
return ms
}
// buildIssueDescription appends an autopilot system instruction to the
// user-provided description, asking the agent to rename the issue after
// it understands the actual work.
func (s *AutopilotService) buildIssueDescription(ap db.Autopilot) pgtype.Text {
now := time.Now().UTC().Format("2006-01-02 15:04 UTC")
note := fmt.Sprintf("\n\n---\n*Autopilot run triggered at %s. After starting work, rename this issue to accurately reflect what you are doing.*", now)
base := ap.Description.String
return pgtype.Text{String: base + note, Valid: true}
}
// interpolateTemplate replaces {{date}} in the issue title template.
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)
}
func (s *AutopilotService) getIssuePrefix(workspaceID pgtype.UUID) string {
ws, err := s.Queries.GetWorkspace(context.Background(), workspaceID)
if err != nil {
return ""
}
return ws.IssuePrefix
}