mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* fix(issues): attach labels in the create transaction (MUL-4832) Labels chosen at issue creation were attached in a second, non-atomic round-trip after the issue was already committed (web modal looped attachLabelToIssue per label; the create endpoint took no labels). A partial failure of that follow-up left the issue committed but mis-categorized, surfaced only as a toast. Carry label_ids through the create request instead: the service validates them (workspace + resource_type='issue') and attaches them inside the same transaction as the issue insert, so the issue and its labels commit together or not at all. An unknown or wrong-scope label id now fails the whole create with 400 rather than being silently dropped. Duplicate ids are idempotent (dedupe + ON CONFLICT DO NOTHING). - server: IssueCreateParams.LabelIDs + ErrIssueLabelNotFound; validate and attach in IssueService.Create; handler parses label_ids and maps the error to 400. - web: create-issue modal forwards label_ids and drops the post-create attach loop and its dead toast key. - tests: handler coverage for atomic attach, stale-id 400 (no issue left behind), duplicate-id idempotency, wrong-scope rejection; web test asserts label_ids is forwarded. Scope is deliberately labels-only. The contributor PR #5475 also auto-parsed an acceptance_criteria field from description text; that introduces a new user-facing data contract with no defined edit/display rules and is left out for a separate product decision. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): echo created labels + guard old-backend compat (MUL-4832) Addresses review of #5510. 1. Create response + issue:created event now carry the labels attached in the create transaction. IssueService.Create returns the authoritative label snapshot; the handler sets it on both the HTTP response and the issue:created broadcast payload. Without this, online members other than the creator saw the new issue unlabeled indefinitely (staleTime: Infinity, no invalidation) because this PR removed the old post-create issue_labels:changed broadcast. 2. New web + old backend compatibility. During the rolling deploy window the web app can run ahead of the backend (web auto-deploys on merge, backend deploys manually). An older backend ignores label_ids and returns an issue with no labels field. The create modal now falls back to the legacy per-label attach only when the response omits labels, so labels aren't silently dropped; when labels is present the atomic path already ran and no fallback fires. The backend always returns an explicit labels array (empty when none) as the detection signal. Tests: handler issue:created-carries-labels + response-carries-labels + response-always-includes-labels; web modal no-fallback (new backend) and fallback (old backend); ws-updaters keeps the label snapshot in list cache. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): validate create response through a schema (MUL-4832) Addresses review of #5510 (must-fix 3). The create modal keys its label-attach compatibility fallback off `issue.labels` being absent (older backend) vs a validated Label[] (current backend). But api.createIssue cast the raw JSON straight to Issue, so a malformed labels value (null, an object, a garbage array) would be !== undefined and wrongly suppress the fallback, caching a bad shape — violating the repo's API Compatibility rule. Parse the create response through CreateIssueResponseSchema: - labels absent -> undefined (older-backend signal; fallback runs) - labels valid -> Label[] (fully validated elements, not z.unknown()) - labels malformed -> undefined via .catch (safe: never masquerades as handled; worst case a redundant re-attach, never a silent drop) A whole-body parse failure degrades to EMPTY_ISSUE (never throws into React), matching the existing parseWithFallback contract. Tests: schema.test.ts covers absent / valid / null / wrong-element-shape labels and the empty-issue degrade. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): reject a malformed create response instead of faking success (MUL-4832) Follow-up to review of #5510. The prior fix degraded a schema-failed create response to EMPTY_ISSUE, which React Query treats as a successful mutation: the empty issue is written into the list cache, a blank "created" toast shows, the "View issue" link points at an empty id, and with labels the fallback attach runs against an empty issue id. A create that returns an unusable body is a failed mutation, not a safe-empty read. Fall back to null and reject: mutateAsync is already inside the create modal's try/catch, so a controlled rejection preserves the draft and shows the failure toast, and onSettled still refreshes the list so a genuinely-created issue can still surface. - CreateIssueResponseSchema tightens id to non-empty; an id-less body routes to the same reject path. - createIssue throws on parse failure (empty-message Error so the modal renders its localized "failed to create" toast; parseWithFallback already logged the schema issues + raw body). - Dropped the EMPTY_ISSUE fallback constant. - Tests: whole-body malformed and empty-id now assert createIssue rejects; only-labels-malformed still returns the real issue with labels undefined. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
574 lines
22 KiB
Go
574 lines
22 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"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/issueposition"
|
|
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
|
"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"
|
|
)
|
|
|
|
// IssueService is the single service-layer entry point for creating issues.
|
|
// Both the HTTP `POST /issues` handler and the future Lark `/issue` command
|
|
// call into Create so that duplicate guard, issue numbering, attachment
|
|
// linking, broadcast, analytics, and agent/squad enqueue stay aligned. The
|
|
// service deliberately does NOT depend on http.Request — callers parse
|
|
// their own transport and pass a fully-resolved IssueCreateParams.
|
|
type IssueService struct {
|
|
Queries *db.Queries
|
|
TxStarter TxStarter
|
|
Bus *events.Bus
|
|
Analytics analytics.Client
|
|
// Metrics is the shared business-metrics collector. Wired by
|
|
// cmd/server/router.go after construction; nil in tests / self-hosted
|
|
// without the metrics listener — obsmetrics.RecordEvent treats a nil
|
|
// Metrics as "PostHog only", so leaving it unset is safe.
|
|
Metrics *obsmetrics.BusinessMetrics
|
|
TaskService *TaskService
|
|
}
|
|
|
|
func NewIssueService(q *db.Queries, tx TxStarter, bus *events.Bus, ac analytics.Client, ts *TaskService) *IssueService {
|
|
return &IssueService{
|
|
Queries: q,
|
|
TxStarter: tx,
|
|
Bus: bus,
|
|
Analytics: ac,
|
|
TaskService: ts,
|
|
}
|
|
}
|
|
|
|
// IssueCreateParams carries the already-validated, already-resolved inputs
|
|
// to IssueService.Create. The handler owns the parsing step that turns its
|
|
// request payload into this struct; the service stays transport-agnostic.
|
|
type IssueCreateParams struct {
|
|
WorkspaceID pgtype.UUID
|
|
Title string
|
|
Description pgtype.Text
|
|
Status string
|
|
Priority string
|
|
AssigneeType pgtype.Text
|
|
AssigneeID pgtype.UUID
|
|
CreatorType string // "agent" or "member"
|
|
CreatorID pgtype.UUID
|
|
ParentIssueID pgtype.UUID
|
|
ProjectID pgtype.UUID
|
|
StartDate pgtype.Date
|
|
DueDate pgtype.Date
|
|
OriginType pgtype.Text
|
|
OriginID pgtype.UUID
|
|
AttachmentIDs []pgtype.UUID
|
|
// LabelIDs are the issue-scoped labels to attach to the new issue. They
|
|
// are validated and written inside the create transaction (see Create),
|
|
// so the issue is never committed with a partial or wrong label set. An
|
|
// unknown or non-issue label id fails the whole create with
|
|
// ErrIssueLabelNotFound rather than being silently dropped.
|
|
LabelIDs []pgtype.UUID
|
|
AllowDuplicate bool
|
|
// Stage groups this issue into an ordered barrier group under its parent
|
|
// (NULL = unstaged). See issue_child_done.go for the staged-barrier wake.
|
|
Stage pgtype.Int4
|
|
}
|
|
|
|
// IssueCreateOpts groups optional knobs for IssueService.Create. Most
|
|
// callers leave it zero-valued.
|
|
type IssueCreateOpts struct {
|
|
// BroadcastPayload, if non-nil, is invoked after the issue row is
|
|
// created and attachments are linked. Its return value is sent as
|
|
// the EventIssueCreated payload via the event bus. The HTTP handler
|
|
// uses this hook to inject its IssueResponse without forcing this
|
|
// package to depend on handler-layer types. If nil, the service
|
|
// emits a minimal `{"issue_id": <uuid>}` payload — enough for cache
|
|
// invalidation, but front-ends that expect the full response shape
|
|
// must provide BroadcastPayload. The labels argument is the authoritative
|
|
// snapshot attached in the create transaction, so the emitted payload can
|
|
// carry the new issue's labels and every online client renders it already
|
|
// labeled instead of blank until a refetch.
|
|
BroadcastPayload func(issue db.Issue, attachments []db.Attachment, labels []db.IssueLabel) map[string]any
|
|
|
|
// ActorID overrides the actor ID used for broadcast + analytics
|
|
// when it differs from the creator on the row. Agent-created issues
|
|
// use the agent UUID here (the creator_id column is the daemon
|
|
// owner). Empty falls back to CreatorID.
|
|
ActorID string
|
|
|
|
// AnalyticsAgentID is the agent associated with the issue for
|
|
// analytics purposes (assignee agent or, for agent-created issues,
|
|
// the creator agent). Resolved by the caller because it depends on
|
|
// transport context.
|
|
AnalyticsAgentID string
|
|
|
|
// Platform tags the IssueCreated analytics + business-metrics event
|
|
// with the client surface the request came in on (web / desktop /
|
|
// daemon / lark / autopilot). Derived from middleware's client
|
|
// metadata at the handler layer.
|
|
Platform string
|
|
}
|
|
|
|
// ErrActiveDuplicate signals that the duplicate guard found an active
|
|
// issue with the same (workspace, project, parent, title) tuple and
|
|
// AllowDuplicate was false. The IssueCreateResult.DuplicateIssue field is
|
|
// populated when this error is returned so callers can render the
|
|
// conflict (HTTP 409, Lark card, etc.).
|
|
var ErrActiveDuplicate = errors.New("active duplicate issue exists")
|
|
|
|
// ErrParentIssueNotFound signals that the supplied ParentIssueID does
|
|
// not exist in the issue's workspace. The service refuses to create
|
|
// orphaned or cross-workspace child issues; callers translate this into
|
|
// their transport's 400 / Lark card error.
|
|
var ErrParentIssueNotFound = errors.New("parent issue not found in this workspace")
|
|
|
|
// ErrProjectNotFound signals that the supplied ProjectID does not exist
|
|
// in the issue's workspace. Cross-workspace project IDs are rejected
|
|
// here so every create entry (HTTP `POST /issues`, Lark `/issue`, future
|
|
// MCP / API key callers) enforces the same workspace boundary without
|
|
// having to remember it. Callers translate this into 400.
|
|
var ErrProjectNotFound = errors.New("project not found in this workspace")
|
|
|
|
// ErrIssueLabelNotFound signals that one of the supplied LabelIDs does not
|
|
// exist in the issue's workspace or is not an issue-scoped label. The whole
|
|
// create is rejected so a new issue is never born with a partial or wrong
|
|
// label set. Callers translate this into their transport's 400.
|
|
var ErrIssueLabelNotFound = errors.New("issue label not found in this workspace")
|
|
|
|
// IssueCreateResult is the typed return from IssueService.Create.
|
|
//
|
|
// - On the happy path: Issue is the new row, Attachments lists the
|
|
// linked attachments (may be empty), DuplicateIssue is nil.
|
|
// - On ErrActiveDuplicate: DuplicateIssue is the row that blocked the
|
|
// create; Issue and Attachments are zero.
|
|
type IssueCreateResult struct {
|
|
Issue db.Issue
|
|
Attachments []db.Attachment
|
|
// Labels is the authoritative set of labels attached to the new issue in
|
|
// the create transaction (empty when none were requested). Callers echo it
|
|
// on the create response + issue:created event so every client renders the
|
|
// new issue already labeled and a new client can detect that the backend
|
|
// understood label_ids (see the create handler's compatibility contract).
|
|
Labels []db.IssueLabel
|
|
DuplicateIssue *db.Issue
|
|
}
|
|
|
|
// Create runs the full issue-creation pipeline atomically end-to-end:
|
|
//
|
|
// 1. Begin transaction.
|
|
// 2. Resolve & validate parent / project belong to the same workspace.
|
|
// 3. Lock & check the duplicate guard.
|
|
// 4. Increment the workspace issue counter.
|
|
// 5. Insert the issue row (with optional origin stamping).
|
|
// 6. Commit.
|
|
// 7. Link any pre-uploaded attachments (post-commit, idempotent).
|
|
// 8. Publish EventIssueCreated to the bus (payload via opts.BroadcastPayload).
|
|
// 9. Capture the IssueCreated analytics event.
|
|
// 10. Enqueue an agent task or trigger the squad leader when the issue is
|
|
// assigned and not in `backlog`.
|
|
//
|
|
// Validation that lives in the service (parent existence, project
|
|
// workspace membership, parent → project back-fill) is enforced here so
|
|
// every create entry — HTTP `POST /issues`, Lark `/issue`, future
|
|
// MCP/API-key callers — shares the same workspace boundary semantics.
|
|
// Caller-owned validation is limited to transport-shaped checks: title
|
|
// required, RFC3339 date format, assignee pair sanity.
|
|
func (s *IssueService) Create(ctx context.Context, p IssueCreateParams, opts IssueCreateOpts) (IssueCreateResult, error) {
|
|
tx, err := s.TxStarter.Begin(ctx)
|
|
if err != nil {
|
|
return IssueCreateResult{}, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
qtx := s.Queries.WithTx(tx)
|
|
|
|
// Resolve and validate parent / project before reading from the
|
|
// duplicate guard so a forged parent or project ID is rejected
|
|
// before we touch the issue counter. Both checks scope by
|
|
// WorkspaceID — there is no path from this service to a row in a
|
|
// foreign workspace.
|
|
projectID := p.ProjectID
|
|
if p.ParentIssueID.Valid {
|
|
parent, err := qtx.GetIssueInWorkspace(ctx, db.GetIssueInWorkspaceParams{
|
|
ID: p.ParentIssueID,
|
|
WorkspaceID: p.WorkspaceID,
|
|
})
|
|
if err != nil || !parent.ID.Valid {
|
|
return IssueCreateResult{}, ErrParentIssueNotFound
|
|
}
|
|
// Back-fill project from parent when the caller did not pin
|
|
// one explicitly. Matches the long-standing HTTP behavior: a
|
|
// sub-issue inherits its parent's project unless overridden.
|
|
if !projectID.Valid {
|
|
projectID = parent.ProjectID
|
|
}
|
|
}
|
|
if projectID.Valid {
|
|
if _, err := qtx.GetProjectInWorkspace(ctx, db.GetProjectInWorkspaceParams{
|
|
ID: projectID,
|
|
WorkspaceID: p.WorkspaceID,
|
|
}); err != nil {
|
|
return IssueCreateResult{}, ErrProjectNotFound
|
|
}
|
|
}
|
|
|
|
// Validate labels before we increment the issue counter so a stale or
|
|
// wrong-scope selection fails the create cheaply. The de-duplicated rows
|
|
// are attached to the issue below, inside this same transaction, and
|
|
// echoed back as the authoritative snapshot in the result.
|
|
labels, err := validateIssueLabels(ctx, qtx, p.WorkspaceID, p.LabelIDs)
|
|
if err != nil {
|
|
return IssueCreateResult{}, err
|
|
}
|
|
|
|
duplicate, found, err := issueguard.LockAndFindActiveDuplicate(ctx, qtx, p.WorkspaceID, projectID, p.ParentIssueID, p.Title, p.AllowDuplicate)
|
|
if err != nil {
|
|
return IssueCreateResult{}, fmt.Errorf("duplicate guard: %w", err)
|
|
}
|
|
if found {
|
|
dup := duplicate
|
|
return IssueCreateResult{DuplicateIssue: &dup}, ErrActiveDuplicate
|
|
}
|
|
|
|
issueNumber, err := qtx.IncrementIssueCounter(ctx, p.WorkspaceID)
|
|
if err != nil {
|
|
return IssueCreateResult{}, fmt.Errorf("increment counter: %w", err)
|
|
}
|
|
|
|
// New issues sort to the top of their (workspace, status) column for
|
|
// manual ordering. Computed inside the tx, after IncrementIssueCounter
|
|
// has already taken the workspace row lock, so two concurrent creates
|
|
// in the same workspace see each other's positions and don't both
|
|
// land on the same min-1 slot. Concurrent manual reorder via
|
|
// UpdateIssue(position) does NOT take this lock, so a create racing
|
|
// a reorder is still allowed to collide on position — manual ordering
|
|
// is best-effort and the UI tolerates equal positions by falling back
|
|
// to the secondary ORDER BY key.
|
|
newPosition, err := issueposition.NextTopPosition(ctx, tx, p.WorkspaceID, p.Status)
|
|
if err != nil {
|
|
return IssueCreateResult{}, fmt.Errorf("next top position: %w", err)
|
|
}
|
|
|
|
var issue db.Issue
|
|
if p.OriginType.Valid {
|
|
issue, err = qtx.CreateIssueWithOrigin(ctx, db.CreateIssueWithOriginParams{
|
|
WorkspaceID: p.WorkspaceID,
|
|
Title: p.Title,
|
|
Description: p.Description,
|
|
Status: p.Status,
|
|
Priority: p.Priority,
|
|
AssigneeType: p.AssigneeType,
|
|
AssigneeID: p.AssigneeID,
|
|
CreatorType: p.CreatorType,
|
|
CreatorID: p.CreatorID,
|
|
ParentIssueID: p.ParentIssueID,
|
|
Position: newPosition,
|
|
StartDate: p.StartDate,
|
|
DueDate: p.DueDate,
|
|
Number: issueNumber,
|
|
ProjectID: projectID,
|
|
OriginType: p.OriginType,
|
|
OriginID: p.OriginID,
|
|
Stage: p.Stage,
|
|
})
|
|
} else {
|
|
issue, err = qtx.CreateIssue(ctx, db.CreateIssueParams{
|
|
WorkspaceID: p.WorkspaceID,
|
|
Title: p.Title,
|
|
Description: p.Description,
|
|
Status: p.Status,
|
|
Priority: p.Priority,
|
|
AssigneeType: p.AssigneeType,
|
|
AssigneeID: p.AssigneeID,
|
|
CreatorType: p.CreatorType,
|
|
CreatorID: p.CreatorID,
|
|
ParentIssueID: p.ParentIssueID,
|
|
Position: newPosition,
|
|
StartDate: p.StartDate,
|
|
DueDate: p.DueDate,
|
|
Number: issueNumber,
|
|
ProjectID: projectID,
|
|
Stage: p.Stage,
|
|
})
|
|
}
|
|
if err != nil {
|
|
return IssueCreateResult{}, fmt.Errorf("create issue: %w", err)
|
|
}
|
|
|
|
// Attach labels inside the create transaction so the issue and its
|
|
// labels commit together — the old flow created the issue first and
|
|
// attached labels in a second, non-atomic round-trip whose partial
|
|
// failure left the issue mis-categorized. AttachLabelToIssue is
|
|
// workspace/resource_type-guarded and ON CONFLICT DO NOTHING, and the
|
|
// ids were already validated above.
|
|
for _, label := range labels {
|
|
if err := qtx.AttachLabelToIssue(ctx, db.AttachLabelToIssueParams{
|
|
IssueID: issue.ID,
|
|
LabelID: label.ID,
|
|
WorkspaceID: p.WorkspaceID,
|
|
}); err != nil {
|
|
return IssueCreateResult{}, fmt.Errorf("attach issue label: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return IssueCreateResult{}, fmt.Errorf("commit: %w", err)
|
|
}
|
|
|
|
attachments := s.linkAttachments(ctx, issue, p.AttachmentIDs)
|
|
|
|
actorID := opts.ActorID
|
|
if actorID == "" {
|
|
actorID = util.UUIDToString(issue.CreatorID)
|
|
}
|
|
|
|
s.publishIssueCreated(issue, attachments, labels, p.CreatorType, actorID, opts)
|
|
s.captureCreatedAnalytics(issue, p.CreatorType, actorID, opts)
|
|
s.maybeEnqueueOnAssign(ctx, issue, p.CreatorType, actorID)
|
|
|
|
return IssueCreateResult{Issue: issue, Attachments: attachments, Labels: labels}, nil
|
|
}
|
|
|
|
// validateIssueLabels checks that every requested label exists in the
|
|
// workspace and is issue-scoped, returning the de-duplicated label rows to
|
|
// attach. Returning the full rows (not just ids) lets Create echo an
|
|
// authoritative label snapshot on the create response + issue:created event
|
|
// without a second query. It mirrors the workspace + resource_type='issue'
|
|
// guard already enforced by AttachLabelToIssue so an unknown or wrong-scope id
|
|
// surfaces as ErrIssueLabelNotFound instead of a silent no-op insert. Invalid
|
|
// (zero) UUIDs are skipped. The label count per issue is small, so a GetLabel
|
|
// per distinct id is fine and avoids introducing a new batch query.
|
|
func validateIssueLabels(ctx context.Context, qtx *db.Queries, workspaceID pgtype.UUID, labelIDs []pgtype.UUID) ([]db.IssueLabel, error) {
|
|
if len(labelIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
seen := make(map[string]struct{}, len(labelIDs))
|
|
deduped := make([]db.IssueLabel, 0, len(labelIDs))
|
|
for _, labelID := range labelIDs {
|
|
if !labelID.Valid {
|
|
continue
|
|
}
|
|
key := util.UUIDToString(labelID)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
|
|
label, err := qtx.GetLabel(ctx, db.GetLabelParams{
|
|
ID: labelID,
|
|
WorkspaceID: workspaceID,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrIssueLabelNotFound
|
|
}
|
|
return nil, fmt.Errorf("get issue label: %w", err)
|
|
}
|
|
if label.ResourceType != "issue" {
|
|
return nil, ErrIssueLabelNotFound
|
|
}
|
|
deduped = append(deduped, label)
|
|
}
|
|
return deduped, nil
|
|
}
|
|
|
|
// linkAttachments links the given attachment IDs to the newly created
|
|
// issue and returns the re-fetched attachment rows so callers can build
|
|
// their response without a second query. Errors are logged and swallowed
|
|
// — attachment linking is a best-effort post-commit step, and a stale
|
|
// attachment row doesn't justify failing the whole create.
|
|
func (s *IssueService) linkAttachments(ctx context.Context, issue db.Issue, ids []pgtype.UUID) []db.Attachment {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
if err := s.Queries.LinkAttachmentsToIssue(ctx, db.LinkAttachmentsToIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
Column3: ids,
|
|
}); err != nil {
|
|
slog.Error("failed to link attachments to issue",
|
|
"issue_id", util.UUIDToString(issue.ID),
|
|
"error", err)
|
|
return nil
|
|
}
|
|
list, err := s.Queries.ListAttachmentsByIssue(ctx, db.ListAttachmentsByIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("failed to list attachments for new issue",
|
|
"issue_id", util.UUIDToString(issue.ID),
|
|
"error", err)
|
|
return nil
|
|
}
|
|
return list
|
|
}
|
|
|
|
func (s *IssueService) publishIssueCreated(issue db.Issue, attachments []db.Attachment, labels []db.IssueLabel, creatorType, actorID string, opts IssueCreateOpts) {
|
|
if s.Bus == nil {
|
|
return
|
|
}
|
|
var payload map[string]any
|
|
if opts.BroadcastPayload != nil {
|
|
payload = opts.BroadcastPayload(issue, attachments, labels)
|
|
} else {
|
|
// Minimal fallback so cache invalidations still fire even if the
|
|
// caller forgot to supply a builder. Front-ends that expect the
|
|
// full IssueResponse must pass BroadcastPayload.
|
|
payload = map[string]any{"issue_id": util.UUIDToString(issue.ID)}
|
|
}
|
|
s.Bus.Publish(events.Event{
|
|
Type: protocol.EventIssueCreated,
|
|
WorkspaceID: util.UUIDToString(issue.WorkspaceID),
|
|
ActorType: creatorType,
|
|
ActorID: actorID,
|
|
Payload: payload,
|
|
})
|
|
}
|
|
|
|
func (s *IssueService) captureCreatedAnalytics(issue db.Issue, creatorType, actorID string, opts IssueCreateOpts) {
|
|
if s.Analytics == nil {
|
|
return
|
|
}
|
|
source, taskID, autopilotRunID := classifyOrigin(issue, opts)
|
|
analyticsActorID := actorID
|
|
if creatorType == "agent" {
|
|
analyticsActorID = "agent:" + actorID
|
|
}
|
|
obsmetrics.RecordEvent(s.Analytics, s.Metrics, analytics.IssueCreated(
|
|
analyticsActorID,
|
|
util.UUIDToString(issue.WorkspaceID),
|
|
util.UUIDToString(issue.ID),
|
|
opts.AnalyticsAgentID,
|
|
taskID,
|
|
autopilotRunID,
|
|
source,
|
|
opts.Platform,
|
|
))
|
|
}
|
|
|
|
// classifyOrigin maps the issue's origin_type / origin_id columns into the
|
|
// analytics source labels. Unknown origin_type falls back to SourceManual
|
|
// with the warning logged — analytics drift is preferable to dropping the
|
|
// event entirely.
|
|
func classifyOrigin(issue db.Issue, opts IssueCreateOpts) (source, taskID, autopilotRunID string) {
|
|
source = analytics.SourceManual
|
|
if !issue.OriginType.Valid {
|
|
return source, "", ""
|
|
}
|
|
originID := util.UUIDToString(issue.OriginID)
|
|
switch issue.OriginType.String {
|
|
case "quick_create", "agent_create":
|
|
// Both link the issue back to the agent_task_queue row that created it
|
|
// (agent_create is the ordinary agent `issue create` path, MUL-4305);
|
|
// surface that task id and keep the manual source label.
|
|
return analytics.SourceManual, originID, ""
|
|
case "autopilot":
|
|
return analytics.SourceAutopilot, "", originID
|
|
default:
|
|
slog.Warn("analytics: unknown issue origin type",
|
|
"origin_type", issue.OriginType.String,
|
|
"issue_id", util.UUIDToString(issue.ID),
|
|
)
|
|
return analytics.SourceManual, "", ""
|
|
}
|
|
}
|
|
|
|
func (s *IssueService) maybeEnqueueOnAssign(ctx context.Context, issue db.Issue, creatorType, actorID string) {
|
|
if !issue.AssigneeType.Valid || !issue.AssigneeID.Valid {
|
|
return
|
|
}
|
|
if s.shouldEnqueueAgentTask(ctx, issue) {
|
|
if _, err := s.TaskService.EnqueueTaskForIssue(ctx, issue); err != nil {
|
|
slog.Warn("enqueue agent task on create failed",
|
|
"issue_id", util.UUIDToString(issue.ID),
|
|
"error", err)
|
|
}
|
|
}
|
|
if s.shouldEnqueueSquadLeaderOnAssign(ctx, issue) {
|
|
s.enqueueSquadLeaderTask(ctx, issue, pgtype.UUID{}, creatorType, actorID)
|
|
}
|
|
}
|
|
|
|
// shouldEnqueueAgentTask returns true when an issue create or assignment
|
|
// should trigger the assigned agent. Backlog issues are skipped — backlog
|
|
// acts as a parking lot for pre-assigning without immediate execution.
|
|
// Mirrors handler.shouldEnqueueAgentTask; kept here to make the service
|
|
// self-contained, since both code paths must move together.
|
|
func (s *IssueService) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bool {
|
|
if issue.Status == "backlog" {
|
|
return false
|
|
}
|
|
return s.isAgentAssigneeReady(ctx, issue)
|
|
}
|
|
|
|
func (s *IssueService) isAgentAssigneeReady(ctx context.Context, issue db.Issue) bool {
|
|
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "agent" || !issue.AssigneeID.Valid {
|
|
return false
|
|
}
|
|
agent, err := s.Queries.GetAgent(ctx, issue.AssigneeID)
|
|
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *IssueService) shouldEnqueueSquadLeaderOnAssign(ctx context.Context, issue db.Issue) bool {
|
|
if issue.Status == "backlog" {
|
|
return false
|
|
}
|
|
return s.isSquadLeaderReady(ctx, issue)
|
|
}
|
|
|
|
func (s *IssueService) isSquadLeaderReady(ctx context.Context, issue db.Issue) bool {
|
|
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "squad" || !issue.AssigneeID.Valid {
|
|
return false
|
|
}
|
|
squad, err := s.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
|
ID: issue.AssigneeID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
agent, err := s.Queries.GetAgent(ctx, squad.LeaderID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
ready, _, err := AgentReadiness(ctx, s.Queries, agent)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return ready
|
|
}
|
|
|
|
func (s *IssueService) enqueueSquadLeaderTask(ctx context.Context, issue db.Issue, triggerCommentID pgtype.UUID, authorType, authorID string) {
|
|
squad, err := s.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
|
ID: issue.AssigneeID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
hasPending, err := s.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
|
IssueID: issue.ID,
|
|
AgentID: squad.LeaderID,
|
|
// Key dedup on the reviewed head (TEN-356).
|
|
HeadSha: headShaText(s.TaskService.ResolveIssueReviewSHA(ctx, issue.ID)),
|
|
})
|
|
if err != nil || hasPending {
|
|
return
|
|
}
|
|
if _, err := s.TaskService.EnqueueTaskForSquadLeader(ctx, issue, squad.LeaderID, squad.ID, triggerCommentID); err != nil {
|
|
slog.Warn("enqueue squad leader task on create failed",
|
|
"issue_id", util.UUIDToString(issue.ID),
|
|
"squad_id", util.UUIDToString(squad.ID),
|
|
"leader_id", util.UUIDToString(squad.LeaderID),
|
|
"error", err)
|
|
}
|
|
}
|