mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +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>
269 lines
9.1 KiB
Go
269 lines
9.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/multica-ai/multica/server/internal/events"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// createTestIssueLabel creates an issue-scoped label in the test workspace via
|
|
// the public handler and registers cleanup. It returns the new label id.
|
|
func createTestIssueLabel(t *testing.T, name string) string {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/labels", map[string]any{
|
|
"name": name,
|
|
"color": "#ef4444",
|
|
})
|
|
testHandler.CreateLabel(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateLabel %q: expected 201, got %d: %s", name, w.Code, w.Body.String())
|
|
}
|
|
var created LabelResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode label: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue_label WHERE id = $1`, created.ID)
|
|
})
|
|
return created.ID
|
|
}
|
|
|
|
func countIssueLabel(t *testing.T, issueID, labelID string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT COUNT(*) FROM issue_to_label WHERE issue_id = $1 AND label_id = $2`,
|
|
issueID, labelID,
|
|
).Scan(&n); err != nil {
|
|
t.Fatalf("count issue_to_label: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func countIssuesWithTitle(t *testing.T, title string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT COUNT(*) FROM issue WHERE workspace_id = $1 AND title = $2`,
|
|
testWorkspaceID, title,
|
|
).Scan(&n); err != nil {
|
|
t.Fatalf("count issues: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// TestCreateIssueAttachesLabelsAtomically verifies that labels passed in the
|
|
// create request are written in the same transaction as the issue, so a
|
|
// created issue already carries its labels without a second round-trip.
|
|
func TestCreateIssueAttachesLabelsAtomically(t *testing.T) {
|
|
labelA := createTestIssueLabel(t, "cl-a-"+uuid.NewString()[:8])
|
|
labelB := createTestIssueLabel(t, "cl-b-"+uuid.NewString()[:8])
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "create-labels attaches",
|
|
"status": "todo",
|
|
"priority": "low",
|
|
"label_ids": []string{labelA, labelB},
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
t.Cleanup(func() { deleteTestIssue(t, issue.ID) })
|
|
|
|
if got := countIssueLabel(t, issue.ID, labelA); got != 1 {
|
|
t.Errorf("label A: expected 1 attachment, got %d", got)
|
|
}
|
|
if got := countIssueLabel(t, issue.ID, labelB); got != 1 {
|
|
t.Errorf("label B: expected 1 attachment, got %d", got)
|
|
}
|
|
|
|
// The create response must echo the authoritative labels so every online
|
|
// client (not just the creator) renders the new issue already labeled and
|
|
// a newer client can tell the backend understood label_ids.
|
|
if issue.Labels == nil {
|
|
t.Fatalf("create response must include a labels field")
|
|
}
|
|
got := map[string]bool{}
|
|
for _, l := range *issue.Labels {
|
|
got[l.ID] = true
|
|
}
|
|
if !got[labelA] || !got[labelB] {
|
|
t.Errorf("response labels %v missing labelA=%s or labelB=%s", got, labelA, labelB)
|
|
}
|
|
}
|
|
|
|
// TestCreateIssueResponseAlwaysIncludesLabelsField verifies that even when no
|
|
// labels are requested the create response carries an explicit (empty) labels
|
|
// list — the signal a newer client uses to know the backend handled labels and
|
|
// skip its legacy per-label attach fallback.
|
|
func TestCreateIssueResponseAlwaysIncludesLabelsField(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "create-labels no-label response",
|
|
"status": "todo",
|
|
"priority": "low",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
t.Cleanup(func() { deleteTestIssue(t, issue.ID) })
|
|
|
|
if issue.Labels == nil {
|
|
t.Fatalf("create response must include a labels field even when empty")
|
|
}
|
|
if len(*issue.Labels) != 0 {
|
|
t.Errorf("expected empty labels, got %d", len(*issue.Labels))
|
|
}
|
|
}
|
|
|
|
// TestCreateIssueEventCarriesLabels verifies that the issue:created websocket
|
|
// event carries the attached labels, so online members other than the creator
|
|
// render the new issue already labeled instead of blank until a refetch (the
|
|
// old flow relied on a separate issue_labels:changed broadcast this PR removed).
|
|
func TestCreateIssueEventCarriesLabels(t *testing.T) {
|
|
label := createTestIssueLabel(t, "cl-evt-"+uuid.NewString()[:8])
|
|
|
|
gotEvent := make(chan events.Event, 1)
|
|
testHandler.Bus.Subscribe(protocol.EventIssueCreated, func(e events.Event) {
|
|
select {
|
|
case gotEvent <- e:
|
|
default:
|
|
}
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "create-labels event carries",
|
|
"status": "todo",
|
|
"priority": "low",
|
|
"label_ids": []string{label},
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
t.Cleanup(func() { deleteTestIssue(t, issue.ID) })
|
|
|
|
select {
|
|
case ev := <-gotEvent:
|
|
pm, ok := ev.Payload.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("issue:created payload has unexpected type %T", ev.Payload)
|
|
}
|
|
resp, ok := pm["issue"].(IssueResponse)
|
|
if !ok {
|
|
t.Fatalf("issue:created payload issue has unexpected type %T", pm["issue"])
|
|
}
|
|
if resp.Labels == nil {
|
|
t.Fatalf("issue:created payload must include a labels field")
|
|
}
|
|
found := false
|
|
for _, l := range *resp.Labels {
|
|
if l.ID == label {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("issue:created labels %+v missing %s", *resp.Labels, label)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("did not receive issue:created event")
|
|
}
|
|
}
|
|
|
|
// TestCreateIssueDedupesDuplicateLabelIDs verifies that a repeated label id in
|
|
// the request attaches exactly once (ON CONFLICT DO NOTHING) and still 201s.
|
|
func TestCreateIssueDedupesDuplicateLabelIDs(t *testing.T) {
|
|
label := createTestIssueLabel(t, "cl-dup-"+uuid.NewString()[:8])
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "create-labels dedupe",
|
|
"status": "todo",
|
|
"priority": "low",
|
|
"label_ids": []string{label, label},
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
t.Cleanup(func() { deleteTestIssue(t, issue.ID) })
|
|
|
|
if got := countIssueLabel(t, issue.ID, label); got != 1 {
|
|
t.Errorf("expected label attached exactly once, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestCreateIssueRejectsUnknownLabelWithoutCreating verifies that an unknown
|
|
// label id fails the whole create with 400 and leaves no issue behind — the
|
|
// atomicity guarantee the old post-create attach flow could not offer.
|
|
func TestCreateIssueRejectsUnknownLabelWithoutCreating(t *testing.T) {
|
|
const title = "create-labels unknown rejected"
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
"status": "todo",
|
|
"priority": "low",
|
|
"label_ids": []string{uuid.NewString()},
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := countIssuesWithTitle(t, title); got != 0 {
|
|
t.Errorf("expected no issue created on invalid label, found %d", got)
|
|
}
|
|
}
|
|
|
|
// TestCreateIssueRejectsNonIssueScopedLabel verifies that a label scoped to a
|
|
// non-issue resource (e.g. agent) cannot be attached to an issue at create,
|
|
// mirroring the resource_type guard baked into AttachLabelToIssue.
|
|
func TestCreateIssueRejectsNonIssueScopedLabel(t *testing.T) {
|
|
var agentLabelID string
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`INSERT INTO issue_label (workspace_id, resource_type, name, color)
|
|
VALUES ($1, 'agent', $2, '#000000') RETURNING id`,
|
|
testWorkspaceID, "create-labels agent "+uuid.NewString(),
|
|
).Scan(&agentLabelID); err != nil {
|
|
t.Fatalf("insert agent label: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue_label WHERE id = $1`, agentLabelID)
|
|
})
|
|
|
|
const title = "create-labels wrong scope rejected"
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
"status": "todo",
|
|
"priority": "low",
|
|
"label_ids": []string{agentLabelID},
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := countIssuesWithTitle(t, title); got != 0 {
|
|
t.Errorf("expected no issue created on wrong-scope label, found %d", got)
|
|
}
|
|
}
|