Files
multica/packages/core/issues/queries.test.ts
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

133 lines
4.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import type { Issue, ListIssuesParams, ListIssuesResponse } from "../types";
import {
PROJECT_GANTT_MAX_ISSUES,
PROJECT_GANTT_PAGE_LIMIT,
issueKeys,
projectGanttIssuesOptions,
} from "./queries";
const WS_ID = "ws-1";
const PROJECT_ID = "project-1";
function makeIssue(idx: number): Issue {
return {
id: `issue-${idx}`,
workspace_id: WS_ID,
number: idx,
identifier: `MUL-${idx}`,
title: `Issue ${idx}`,
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: PROJECT_ID,
position: idx,
start_date: "2026-05-01T00:00:00Z",
due_date: null,
labels: [],
metadata: {},
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
}
// Type-only shim — only the methods the queries.ts code path under test calls.
function installFakeApi(listIssues: (params?: ListIssuesParams) => Promise<ListIssuesResponse>) {
setApiInstance({ listIssues } as unknown as ApiClient);
}
describe("projectGanttIssuesOptions", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("returns the first page directly when it fits under PROJECT_GANTT_PAGE_LIMIT", async () => {
const listIssues = vi
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
.mockResolvedValue({
issues: [makeIssue(1), makeIssue(2)],
total: 2,
});
installFakeApi(listIssues);
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
expect(listIssues).toHaveBeenCalledTimes(1);
expect(listIssues).toHaveBeenCalledWith({
project_id: PROJECT_ID,
scheduled: true,
limit: PROJECT_GANTT_PAGE_LIMIT,
offset: 0,
});
expect(data).toHaveLength(2);
});
it("loops through pages until total is satisfied (no silent truncation)", async () => {
const total = PROJECT_GANTT_PAGE_LIMIT + 7;
const firstPage = Array.from({ length: PROJECT_GANTT_PAGE_LIMIT }, (_, i) =>
makeIssue(i),
);
const secondPage = Array.from({ length: 7 }, (_, i) =>
makeIssue(PROJECT_GANTT_PAGE_LIMIT + i),
);
const listIssues = vi
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
.mockImplementation(async (params) => {
if (!params) throw new Error("expected params");
const offset = params.offset ?? 0;
if (offset === 0)
return { issues: firstPage, total };
if (offset === PROJECT_GANTT_PAGE_LIMIT)
return { issues: secondPage, total };
throw new Error(`unexpected offset ${offset}`);
});
installFakeApi(listIssues);
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
expect(listIssues).toHaveBeenCalledTimes(2);
expect(data).toHaveLength(total);
});
it("stops looping when the server reports a smaller-than-limit page (safety net for total drift)", async () => {
// Server says `total` is huge but only ever returns short pages — the
// loop must terminate on the first short page to avoid an infinite fetch.
const listIssues = vi
.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>()
.mockResolvedValue({
issues: [makeIssue(1)],
total: PROJECT_GANTT_MAX_ISSUES,
});
installFakeApi(listIssues);
const data = await qc.fetchQuery(projectGanttIssuesOptions(WS_ID, PROJECT_ID));
expect(listIssues).toHaveBeenCalledTimes(1);
expect(data).toHaveLength(1);
});
it("uses the project-scoped Gantt cache key", () => {
const options = projectGanttIssuesOptions(WS_ID, PROJECT_ID);
expect(options.queryKey).toEqual(issueKeys.projectGantt(WS_ID, PROJECT_ID));
});
});