mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 01:19:42 +02:00
* fix(engine): broadcast the full issue payload for chat-created issues
An issue opened with /issue from a chat channel broadcast
{"issue_id": <uuid>} — the minimal fallback IssueService.Create emits
whenever a caller leaves IssueCreateOpts.BroadcastPayload nil, which the
engine did. Every issue:created consumer reads payload["issue"], so the
missing key meant extractIssueFields (cmd/server/subscriber_listeners.go)
failed its map assertion and returned false before it ever reached the
id / creator_id check. The listener returned early and no auto-subscribe
rule ran, so the person who typed /issue was never subscribed to the
issue they had just filed and got no notifications for it. Both channels
that register with the engine — Feishu/Lark and Slack — were affected.
The HTTP handler already supplies a BroadcastPayload and autopilot
publishes its own event through issueToMap; only the engine path was
short. Export issueToMap as IssueToMap and have the engine use it, so a
single builder is the one source of truth for that shape instead of
three descriptions drifting apart. The workspace issue prefix comes from
the GetWorkspace call the /issue path already made for the chat reply's
identifier, hoisted so it is read once and used for both.
No regression — the payload only gains keys, and consumers read named
fields. The activity and notification listeners type-assert
payload["issue"] to handler.IssueResponse and still skip a map, exactly
as they already do for autopilot-created issues. The subscriber listener
accepts either shape. The workspace WS fanout marshals the payload
as-is, so open clients now render a chat-created issue live instead of
waiting for a refetch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(service): complete the issue:created payload contract
IssueToMap omitted project_id, stage, metadata and properties, all of
which handler.IssueResponse — the other rendering of the same event —
always emits. Clients type both as a complete Issue and insert the
object straight into the list cache without runtime validation, so an
issue created by autopilot, quick-create or the chat /issue command
appeared in open clients missing its project and custom properties
until the next refetch. Broadcasting the full issue from the chat path
would otherwise have spread that existing defect to a third entry
point.
Fill in the missing keys and pin the contract with a test that fails if
the two renderings ever drift apart again, so adding a field to
IssueResponse without adding it here is caught in CI rather than in the
UI. metadata and properties render as {} when unset, matching the
"always present" part of the contract.
Also route both identifier renderings through service.IssueIdentifier,
so a workspace lookup that degrades the prefix cannot show "#42" in the
chat reply while the realtime list shows "-42".
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(service): name the actual IssueToMap call sites
The doc comment and the shape test listed quick-create among the
non-HTTP publishers of the issue payload. It is not one: the three call
sites are autopilot and the channel engine on issue:created, and the
background stuck-issue status reset on issue:updated.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
183 lines
6.5 KiB
Go
183 lines
6.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// An issue reaches clients rendered two different ways: the HTTP handler
|
|
// marshals IssueResponse, while events published outside it (autopilot and the
|
|
// channel engine's /issue command on issue:created, the background stuck-issue
|
|
// status reset on issue:updated) marshal service.IssueToMap.
|
|
// Clients type both as a complete Issue and insert the
|
|
// object straight into the list cache without runtime validation, so a key
|
|
// present in one rendering and absent from the other is a field that reads
|
|
// back undefined depending on which entry point created the issue — a project
|
|
// that never lands in its project view, a custom-property column that shows
|
|
// empty until the next refetch.
|
|
//
|
|
// Adding a field to IssueResponse without adding it to IssueToMap must fail
|
|
// here rather than in the UI.
|
|
func TestIssueToMap_KeysMatchIssueResponse(t *testing.T) {
|
|
const prefix = "MUL"
|
|
issue := fullyPopulatedIssue(t)
|
|
|
|
responseKeys := jsonKeys(t, issueToResponse(issue, prefix))
|
|
mapKeys := jsonKeys(t, service.IssueToMap(issue, prefix))
|
|
|
|
for _, k := range responseKeys {
|
|
if !hasKey(mapKeys, k) {
|
|
t.Errorf("service.IssueToMap is missing %q, which handler.IssueResponse emits; "+
|
|
"clients treat both as a complete Issue", k)
|
|
}
|
|
}
|
|
for _, k := range mapKeys {
|
|
if !hasKey(responseKeys, k) {
|
|
t.Errorf("service.IssueToMap emits %q, which handler.IssueResponse does not; "+
|
|
"the two renderings must describe the same issue", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The two renderings must also agree on values and JSON types, not just on
|
|
// which keys exist — a number sent as a string, or a date formatted
|
|
// differently, breaks clients just as quietly as a missing field.
|
|
func TestIssueToMap_ValuesMatchIssueResponse(t *testing.T) {
|
|
const prefix = "MUL"
|
|
issue := fullyPopulatedIssue(t)
|
|
|
|
response, err := json.Marshal(issueToResponse(issue, prefix))
|
|
if err != nil {
|
|
t.Fatalf("marshal IssueResponse: %v", err)
|
|
}
|
|
fromMap, err := json.Marshal(service.IssueToMap(issue, prefix))
|
|
if err != nil {
|
|
t.Fatalf("marshal IssueToMap: %v", err)
|
|
}
|
|
|
|
var want, got map[string]any
|
|
if err := json.Unmarshal(response, &want); err != nil {
|
|
t.Fatalf("unmarshal IssueResponse: %v", err)
|
|
}
|
|
if err := json.Unmarshal(fromMap, &got); err != nil {
|
|
t.Fatalf("unmarshal IssueToMap: %v", err)
|
|
}
|
|
|
|
for k, wantValue := range want {
|
|
gotValue, ok := got[k]
|
|
if !ok {
|
|
continue // reported by the key test
|
|
}
|
|
wantJSON, _ := json.Marshal(wantValue)
|
|
gotJSON, _ := json.Marshal(gotValue)
|
|
if string(wantJSON) != string(gotJSON) {
|
|
t.Errorf("issue[%q]: IssueToMap has %s, IssueResponse has %s", k, gotJSON, wantJSON)
|
|
}
|
|
}
|
|
}
|
|
|
|
// metadata and properties are documented as always present so clients can
|
|
// index them without a nil guard. A row that never had either bag written
|
|
// must still broadcast {}, never null.
|
|
func TestIssueToMap_UnsetJSONBagsAreEmptyObjects(t *testing.T) {
|
|
issue := db.Issue{Number: 42}
|
|
|
|
raw, err := json.Marshal(service.IssueToMap(issue, "MUL"))
|
|
if err != nil {
|
|
t.Fatalf("marshal IssueToMap: %v", err)
|
|
}
|
|
var payload map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
t.Fatalf("unmarshal IssueToMap: %v", err)
|
|
}
|
|
|
|
for _, key := range []string{"metadata", "properties"} {
|
|
if got := string(payload[key]); got != "{}" {
|
|
t.Errorf("issue[%q] = %s; want {} so clients can index it without a nil guard", key, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A workspace lookup can fail on the chat path, which degrades the prefix to
|
|
// "". The identifier is then rendered from the number alone rather than as a
|
|
// stray "-42", and the chat reply and the broadcast payload must not disagree
|
|
// about it (both call service.IssueIdentifier).
|
|
func TestIssueIdentifier_DegradesWithoutPrefix(t *testing.T) {
|
|
if got := service.IssueIdentifier("MUL", 42); got != "MUL-42" {
|
|
t.Errorf("IssueIdentifier(\"MUL\", 42) = %q; want MUL-42", got)
|
|
}
|
|
if got := service.IssueIdentifier("", 42); got != "#42" {
|
|
t.Errorf("IssueIdentifier(\"\", 42) = %q; want #42", got)
|
|
}
|
|
}
|
|
|
|
// fullyPopulatedIssue returns a row with every nullable column set, so a
|
|
// rendering that drops a field cannot pass by coincidentally emitting the
|
|
// zero value both ways.
|
|
func fullyPopulatedIssue(t *testing.T) db.Issue {
|
|
t.Helper()
|
|
utcDate := func(y int, m time.Month, d int) time.Time {
|
|
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
|
}
|
|
return db.Issue{
|
|
ID: parseUUID("11111111-1111-1111-1111-111111111111"),
|
|
WorkspaceID: parseUUID("22222222-2222-2222-2222-222222222222"),
|
|
Number: 42,
|
|
Title: "Fix login",
|
|
Description: pgtype.Text{String: "details", Valid: true},
|
|
Status: "todo",
|
|
Priority: "high",
|
|
AssigneeType: pgtype.Text{String: "agent", Valid: true},
|
|
AssigneeID: parseUUID("33333333-3333-3333-3333-333333333333"),
|
|
CreatorType: "member",
|
|
CreatorID: parseUUID("44444444-4444-4444-4444-444444444444"),
|
|
ParentIssueID: parseUUID("55555555-5555-5555-5555-555555555555"),
|
|
ProjectID: parseUUID("66666666-6666-6666-6666-666666666666"),
|
|
Position: 1024.5,
|
|
Stage: pgtype.Int4{Int32: 2, Valid: true},
|
|
StartDate: pgtype.Date{Time: utcDate(2026, time.January, 1), Valid: true},
|
|
DueDate: pgtype.Date{Time: utcDate(2026, time.February, 1), Valid: true},
|
|
CreatedAt: pgtype.Timestamptz{Time: utcDate(2026, time.January, 1), Valid: true},
|
|
UpdatedAt: pgtype.Timestamptz{Time: utcDate(2026, time.January, 2), Valid: true},
|
|
Metadata: []byte(`{"pr_url":"https://example.test/pr/1"}`),
|
|
Properties: []byte(`{"77777777-7777-7777-7777-777777777777":"done"}`),
|
|
}
|
|
}
|
|
|
|
// jsonKeys marshals a value and returns its top-level JSON keys. Fields tagged
|
|
// omitempty (reactions, attachments, labels) are absent from both renderings
|
|
// here by construction: they are optional decorations the client already
|
|
// nil-guards, not part of the core issue shape this test pins.
|
|
func jsonKeys(t *testing.T, v any) []string {
|
|
t.Helper()
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("marshal %T: %v", v, err)
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
|
t.Fatalf("unmarshal %T: %v", v, err)
|
|
}
|
|
keys := make([]string, 0, len(fields))
|
|
for k := range fields {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func hasKey(keys []string, key string) bool {
|
|
for _, k := range keys {
|
|
if k == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|