mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* 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>
266 lines
9.5 KiB
Go
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
|
|
}
|