Files
multica/server/internal/handler/issue_metadata_test.go
Bohan Jiang 0c767c0052 feat(issues): per-issue metadata KV (MUL-2017) (#2845)
* feat(issues): per-issue metadata KV (MUL-2017)

Adds a small JSONB KV map to every issue for agent pipeline state (attempts,
PR number, pipeline status, ...). Keys match a narrow regex, values are
primitives (string / number / bool), capped at 50 keys per issue and 8KB
per blob. Defense-in-depth via two CHECK constraints (object shape + size).

All mutations are single-key atomic (jsonb_set / `- key`). `UpdateIssue`
intentionally does NOT touch metadata: a whole-blob overwrite would race
with concurrent agent writes.

  GET    /api/issues/:id/metadata
  PUT    /api/issues/:id/metadata/:key   body: { "value": <primitive> }
  DELETE /api/issues/:id/metadata/:key

Containment filter on list: GET /api/issues?metadata=<json-object> uses
PG `@>` against a `jsonb_path_ops` GIN index. Mirrored across ListIssues,
CountIssues, ListOpenIssues, and the hand-rolled ListGroupedIssues SQL so
CLI/API and UI grouped views stay consistent.

CLI: multica issue metadata {list,get,set,delete}
  multica issue list --metadata key=value (repeatable, AND)
  set has --type to override the default value-sniffing
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): metadata test bugs + wire realtime + read-only display (MUL-2017)

- Fix two failing handler tests blocking backend CI:
  - reset decode target after delete so map merge does not mask removal
  - url.PathEscape the key segment so spaces no longer panic NewRequest
- Wire issue_metadata:changed end to end so the detail / list / my-issues
  caches stay in sync with set/delete events (other tabs, CLI writes).
- Add a read-only Metadata strip to the issue detail sidebar; hidden when
  the issue has no keys so it stays quiet in the common case.

Co-authored-by: multica-agent <github@multica.ai>

* feat(runtime): teach agents to read/write issue metadata (MUL-2017)

Add an `## Issue Metadata` section to the runtime brief plus a
`metadata list` step on entry and a `metadata set`/`delete` step on
exit. Section only emits when the task carries an issue id (comment- or
assignment-triggered); chat / quick-create / run-only autopilot stay
clean so they don't fire failing CLI calls.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): bump metadata migration to 105 and drop attempts as example (MUL-2017)

main is now at 104_drop_runtime_timezone; the migrator picks
LatestVersion() by sorted filename, so a slot before the tail would
let DBs that have already run 099–104 think they're up-to-date while
the issue.metadata column is missing — runtime would then fail with
column does not exist. Renumbering to 105 puts the migration at the
tail and forces it to run.

Also drop attempts as a positive example across docs/code comments and
test fixtures — the runtime instruction prompt already lists it under
"What NOT to pin" (runtime bookkeeping). Replace with pr_number, which
is in the recommended-keys set, so docs/tests speak the same language
as the prompt.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 16:35:45 +08:00

266 lines
9.5 KiB
Go

package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// Round-trip: set primitives of each type, list, get them back, delete, confirm gone.
func TestIssueMetadataSetGetDelete(t *testing.T) {
issueID := createMetadataTestIssue(t, "Metadata round-trip")
cases := []struct {
key string
value string // raw JSON value
}{
{"pipeline_status", `"waiting"`},
{"pr_number", `482`},
{"is_blocked", `true`},
{"is_done", `false`},
}
for _, c := range cases {
w := httptest.NewRecorder()
req := newRequest("PUT", "/api/issues/"+issueID+"/metadata/"+c.key, json.RawMessage(`{"value":`+c.value+`}`))
req = withURLParams(req, "id", issueID, "key", c.key)
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Set %s=%s: expected 200, got %d: %s", c.key, c.value, w.Code, w.Body.String())
}
}
// List returns every key with the right value type.
w := httptest.NewRecorder()
req := newRequest("GET", "/api/issues/"+issueID+"/metadata", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListIssueMetadata(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List metadata: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Metadata map[string]any `json:"metadata"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode list response: %v", err)
}
if got := resp.Metadata["pipeline_status"]; got != "waiting" {
t.Errorf("pipeline_status: expected \"waiting\", got %T %v", got, got)
}
if got := resp.Metadata["pr_number"]; got != float64(482) {
t.Errorf("pr_number: expected number 482, got %T %v", got, got)
}
if got := resp.Metadata["is_blocked"]; got != true {
t.Errorf("is_blocked: expected true, got %T %v", got, got)
}
if got := resp.Metadata["is_done"]; got != false {
t.Errorf("is_done: expected false, got %T %v", got, got)
}
// Delete a key — refresh confirms it is gone, others remain.
w = httptest.NewRecorder()
req = newRequest("DELETE", "/api/issues/"+issueID+"/metadata/pipeline_status", nil)
req = withURLParams(req, "id", issueID, "key", "pipeline_status")
testHandler.DeleteIssueMetadataKey(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Delete pipeline_status: expected 200, got %d: %s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID+"/metadata", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListIssueMetadata(w, req)
// Decode into a fresh struct — json.Decode into a non-nil map merges,
// it does not replace, so reusing `resp` would keep deleted keys around.
var afterDelete struct {
Metadata map[string]any `json:"metadata"`
}
json.NewDecoder(w.Body).Decode(&afterDelete)
if _, present := afterDelete.Metadata["pipeline_status"]; present {
t.Errorf("after delete, pipeline_status should be gone; got %+v", afterDelete.Metadata)
}
if _, present := afterDelete.Metadata["pr_number"]; !present {
t.Errorf("delete removed unrelated key; got %+v", afterDelete.Metadata)
}
}
// Invalid keys / values / shapes are rejected with 400 — the regex, primitive,
// and "no null" rules must all hold.
func TestIssueMetadataValidation(t *testing.T) {
issueID := createMetadataTestIssue(t, "Metadata validation")
bad := []struct {
name string
key string
rawBody string
}{
{"key starts with digit", "1attempts", `{"value":"x"}`},
{"key has space", "foo bar", `{"value":"x"}`},
{"value is null", "k", `{"value":null}`},
{"value is array", "k", `{"value":[1,2]}`},
{"value is object", "k", `{"value":{"a":1}}`},
{"empty body", "k", ``},
}
for _, c := range bad {
t.Run(c.name, func(t *testing.T) {
w := httptest.NewRecorder()
// chi pulls the key from URL params (injected via withURLParams);
// the raw URL needs to be a valid request line, so PathEscape any
// chars (spaces, etc.) that would otherwise break httptest.NewRequest.
req := newRequest("PUT", "/api/issues/"+issueID+"/metadata/"+url.PathEscape(c.key), json.RawMessage(c.rawBody))
req = withURLParams(req, "id", issueID, "key", c.key)
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
})
}
}
// The 8KB DB CHECK kicks in past a few hundred KV pairs of large strings; we
// blow it deliberately with one giant value to confirm the handler surfaces
// a 400 (not a generic 500).
func TestIssueMetadataSizeLimit(t *testing.T) {
issueID := createMetadataTestIssue(t, "Metadata size limit")
huge := strings.Repeat("a", 9000)
body, _ := json.Marshal(map[string]any{"value": huge})
w := httptest.NewRecorder()
req := newRequest("PUT", "/api/issues/"+issueID+"/metadata/blob", body)
req = withURLParams(req, "id", issueID, "key", "blob")
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 from size CHECK, got %d: %s", w.Code, w.Body.String())
}
}
// The 50-key cap is enforced in the handler with a clear 400.
func TestIssueMetadataKeyCountCap(t *testing.T) {
issueID := createMetadataTestIssue(t, "Metadata key count cap")
for i := 0; i < maxIssueMetadataKeys; i++ {
key := fmt.Sprintf("k_%d", i)
w := httptest.NewRecorder()
req := newRequest("PUT", "/api/issues/"+issueID+"/metadata/"+key, json.RawMessage(`{"value":"v"}`))
req = withURLParams(req, "id", issueID, "key", key)
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusOK {
t.Fatalf("key #%d: expected 200, got %d: %s", i, w.Code, w.Body.String())
}
}
w := httptest.NewRecorder()
req := newRequest("PUT", "/api/issues/"+issueID+"/metadata/overflow", json.RawMessage(`{"value":"v"}`))
req = withURLParams(req, "id", issueID, "key", "overflow")
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("overflow key: expected 400, got %d: %s", w.Code, w.Body.String())
}
// Updating an existing key past the cap is still allowed — only new keys
// are blocked.
w = httptest.NewRecorder()
req = newRequest("PUT", "/api/issues/"+issueID+"/metadata/k_0", json.RawMessage(`{"value":"v2"}`))
req = withURLParams(req, "id", issueID, "key", "k_0")
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusOK {
t.Fatalf("update existing at cap: expected 200, got %d: %s", w.Code, w.Body.String())
}
}
// ListIssues with `metadata` query param does JSONB containment filtering and
// returns only matching issues — the killer use case for autopilot.
func TestListIssuesMetadataFilter(t *testing.T) {
waitingID := createMetadataTestIssue(t, "Waiting issue")
doneID := createMetadataTestIssue(t, "Done issue")
for issueID, status := range map[string]string{waitingID: "waiting_review", doneID: "deployed"} {
w := httptest.NewRecorder()
req := newRequest("PUT", "/api/issues/"+issueID+"/metadata/pipeline_status",
json.RawMessage(`{"value":"`+status+`"}`))
req = withURLParams(req, "id", issueID, "key", "pipeline_status")
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusOK {
t.Fatalf("seed %s: %d %s", issueID, w.Code, w.Body.String())
}
}
w := httptest.NewRecorder()
req := newRequest("GET", `/api/issues?metadata={"pipeline_status":"waiting_review"}`, nil)
testHandler.ListIssues(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List with filter: expected 200, got %d: %s", w.Code, w.Body.String())
}
var listResp struct {
Issues []IssueResponse `json:"issues"`
}
json.NewDecoder(w.Body).Decode(&listResp)
foundWaiting := false
for _, iss := range listResp.Issues {
if iss.ID == doneID {
t.Errorf("filter leaked: deployed issue %s appeared in waiting_review result set", doneID)
}
if iss.ID == waitingID {
foundWaiting = true
if got, _ := iss.Metadata["pipeline_status"].(string); got != "waiting_review" {
t.Errorf("waiting issue: pipeline_status not surfaced; got %v", iss.Metadata)
}
}
}
if !foundWaiting {
t.Errorf("waiting issue %s missing from filter result; got %d issues", waitingID, len(listResp.Issues))
}
// Malformed filter → 400.
w = httptest.NewRecorder()
req = newRequest("GET", `/api/issues?metadata={not-json}`, nil)
testHandler.ListIssues(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("malformed metadata: expected 400, got %d", w.Code)
}
}
// New issues default to an empty metadata object — never null — so frontend
// reads like `issue.metadata[key]` never NPE.
func TestNewIssueDefaultsToEmptyMetadata(t *testing.T) {
issueID := createMetadataTestIssue(t, "Default empty metadata")
w := httptest.NewRecorder()
req := newRequest("GET", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.GetIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetIssue: %d %s", w.Code, w.Body.String())
}
var got IssueResponse
json.NewDecoder(w.Body).Decode(&got)
if got.Metadata == nil {
t.Fatalf("Metadata is nil on a fresh issue; expected empty object")
}
if len(got.Metadata) != 0 {
t.Fatalf("Metadata: expected empty, got %v", got.Metadata)
}
}
func createMetadataTestIssue(t *testing.T, title string) string {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "todo",
"priority": "medium",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("createMetadataTestIssue: %d %s", w.Code, w.Body.String())
}
var issue IssueResponse
if err := json.NewDecoder(w.Body).Decode(&issue); err != nil {
t.Fatalf("decode issue: %v", err)
}
return issue.ID
}