@thehammer/danx-dashboard-mcp 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,7 @@ All exposed as `mcp__danx_dashboard__<name>` once wired through the workspace `m
24
24
  | `issue_list` | `GET /api/issues` | filters: `type`, `parent_id` (null → root-only), `dispatchable_derived`, `assigned_agent`, `include_closed`, `limit`, `offset` |
25
25
  | `issue_get` | `GET /api/issues/:id` | Returns hydrated card + ancestor chain |
26
26
  | `issue_create` | `POST /api/issues` | Epic REQUIRES non-empty `phase_children[]` (atomic insert) |
27
- | `issue_edit` | `PATCH /api/issues/:id/edit` | Prose-only semantic keys refused with 400 + pointer to dedicated handler |
27
+ | `issue_edit` | `PATCH /api/issues/:id/edit` | Prose + structured keys (`title`, `description`, `ac`, `checklists`, `effort_level`, `parent_id`, `priority`, `list_id`); semantic keys refused with 400 + pointer to dedicated handler. `priority` (DX-1532) takes a tier word (`low`/`high`/…) or a number in `[0,6)` — the ONLY way to set the numeric column the Trello label + dashboard badge read; never set priority via description prose |
28
28
  | `issue_transition` | `POST /api/issues/:id/transition` | Actions: ready, pickup, rollback_pickup, complete, cancel, block, unblock, archive, reopen |
29
29
  | `issue_triage` | `POST /api/issues/:id/triage` | Verdicts: approve, cancel, keep, defer (with optional ICE + ttl_seconds) |
30
30
  | `issue_comment` | `POST/PATCH/DELETE /api/issues/:id/comments[/:cid]` | Author server-stamped, soft-delete preserved |
package/dist/handlers.js CHANGED
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { readFile } from "node:fs/promises";
25
25
  import { basename, extname, isAbsolute } from "node:path";
26
+ import { resolvePriority } from "./priority.js";
26
27
  export async function issueList(client, args) {
27
28
  // status_derived is display-only on the server but the route accepts
28
29
  // it as a projection filter — passthrough verbatim. parent_id=null is
@@ -88,8 +89,8 @@ export async function issueCreate(client, args, defaultBoard) {
88
89
  body.effort_level = args.effort_level;
89
90
  if (args.list_id !== undefined)
90
91
  body.list_id = args.list_id;
91
- if (args.required_gates !== undefined)
92
- body.required_gates = args.required_gates;
92
+ if (args.gate_decisions !== undefined)
93
+ body.gate_decisions = args.gate_decisions;
93
94
  if (args.phase_children !== undefined)
94
95
  body.phase_children = args.phase_children;
95
96
  return client.request({ method: "POST", path: "", body, board });
@@ -97,8 +98,9 @@ export async function issueCreate(client, args, defaultBoard) {
97
98
  export async function issueEdit(client, args) {
98
99
  // `board` is a transport concern (query), NOT a patch field — pull it
99
100
  // out of `rest` so it never leaks into the `/edit` body (the server
100
- // 400s on non-allowlisted keys).
101
- const { id, board, ...rest } = args;
101
+ // 400s on non-allowlisted keys). `priority` is pulled out separately so it
102
+ // can be resolved (tier word numeric) before it reaches the body.
103
+ const { id, board, priority, ...rest } = args;
102
104
  // Strip undefined so we don't send `{title: undefined}` — JSON.stringify
103
105
  // drops them anyway but explicit is clearer.
104
106
  const body = {};
@@ -106,6 +108,12 @@ export async function issueEdit(client, args) {
106
108
  if (v !== undefined)
107
109
  body[k] = v;
108
110
  }
111
+ // DX-1532 — resolve a tier WORD to its numeric midpoint (inverse of
112
+ // priorityTier); a raw number passes through for the route to range-validate.
113
+ // Omitted (undefined) → never sent, so a prose-only edit is unchanged.
114
+ if (priority !== undefined) {
115
+ body.priority = resolvePriority(priority);
116
+ }
109
117
  return client.request({
110
118
  method: "PATCH",
111
119
  path: `/${encodeURIComponent(id)}/edit`,
@@ -338,7 +346,7 @@ export async function issueRequiresHuman(client, args) {
338
346
  * POST /api/issues/:id/quality-gates/:gate {required} — the same write the
339
347
  * dashboard drawer's Quality Gates tab performs (DX-1181). This is the ONLY
340
348
  * post-create path to mark a gate required/not — `issue_create` carries
341
- * `required_gates[]` at birth, and `issue_edit` rejects gate keys; without
349
+ * `gate_decisions` at birth, and `issue_edit` rejects gate keys; without
342
350
  * this tool an agent that created a card cannot turn a gate on afterward.
343
351
  *
344
352
  * `gate` is a registry name (`plan-dependency` | `plan-architecture` |
package/dist/index.js CHANGED
@@ -53,6 +53,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
53
53
  import { z } from "zod";
54
54
  import { DashboardHttpClient } from "./http-client.js";
55
55
  import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueRequiresHuman, issueRetro, issueTransition, issueTriage, } from "./handlers.js";
56
+ import { PRIORITY_TIER_WORDS } from "./priority.js";
56
57
  function readEnvOrDie(name) {
57
58
  const v = process.env[name];
58
59
  if (typeof v !== "string" || v === "") {
@@ -164,7 +165,7 @@ server.tool("issue_get", 'Fetch a single hydrated issue via GET /api/issues/:id.
164
165
  ...boardField,
165
166
  }, async (args) => jsonResult(await issueGet(client, args)));
166
167
  // ---------------- issue_create ----------------
167
- server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-scoped; defaults to the dispatch\'s board. Pass `board` (a qualified id `<repo>:<slug>`) to create the card on another board (forwarded into body.board + ?board=; unknown board → 404). INVARIANT: type=Epic REQUIRES non-empty phase_children[] (epic-with-phases atomicity per DX-575) and the route atomically inserts the epic + every phase in ONE transaction. Non-Epic types REFUSE phase_children[] with 400. Status defaults to Review (no lifecycle timestamps stamped on create). parent_id optional. ac items take {title}; phase children inherit the new epic\'s id as parent_id. Optional list_id PLACES the card directly into a column in ONE call. **Pass EITHER a board_lists id OR the list\'s display NAME (case-insensitive, emoji-tolerant — e.g. a queue name like "⚙️ Fulfillment Queue" or just "Fulfillment Queue") — the server resolves a name to its id.** The card lands DIRECTLY in that column with the matching lifecycle stamped automatically — a `ready`-type queue → ToDo, a `completed` list → Done, etc. **You do NOT need a separate issue_transition(ready) + issue_edit(list_id) afterward — just pass the queue name here and the card is created already in that column.** Omit list_id for the default (Review). NOT valid on type=Epic (Epic status derives from children) → 400. Unknown name/id → 400. Optional required_gates[] flags which quality gates (dependency, architecture, tdd, code) are REQUIRED on this card so they run pre-dispatch (only when the board also has that gate enabled); unknown gate name 400.', {
168
+ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-scoped; defaults to the dispatch\'s board. Pass `board` (a qualified id `<repo>:<slug>`) to create the card on another board (forwarded into body.board + ?board=; unknown board → 404). INVARIANT: type=Epic REQUIRES non-empty phase_children[] (epic-with-phases atomicity per DX-575) and the route atomically inserts the epic + every phase in ONE transaction. Non-Epic types REFUSE phase_children[] with 400. Status defaults to Review (no lifecycle timestamps stamped on create). parent_id optional. ac items take {title}; phase children inherit the new epic\'s id as parent_id. Optional list_id PLACES the card directly into a column in ONE call. **Pass EITHER a board_lists id OR the list\'s display NAME (case-insensitive, emoji-tolerant — e.g. a queue name like "⚙️ Fulfillment Queue" or just "Fulfillment Queue") — the server resolves a name to its id.** The card lands DIRECTLY in that column with the matching lifecycle stamped automatically — a `ready`-type queue → ToDo, a `completed` list → Done, etc. **You do NOT need a separate issue_transition(ready) + issue_edit(list_id) afterward — just pass the queue name here and the card is created already in that column.** Omit list_id for the default (Review). NOT valid on type=Epic (Epic status derives from children) → 400. Unknown name/id → 400. **gate_decisions is REQUIRED whenever the board has any OPTIONAL quality gate for the card\'s type** (DX-1594): supply one `{gate, enabled, note}` per board-optional gate. The create FAILS CLOSED — a missing decision returns 400 `{error, required_gate_decisions:[...]}` enumerating exactly which gates to answer, so just retry with a decision for each listed gate. `required`/`disabled` board gates take no decision; a board with no optional gates needs no gate_decisions at all.', {
168
169
  type: z.enum(ISSUE_TYPES),
169
170
  title: z.string().min(1),
170
171
  description: z.string(),
@@ -172,10 +173,14 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
172
173
  ac: z.array(z.object({ title: z.string().min(1) })).optional(),
173
174
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
174
175
  list_id: z.string().min(1).nullable().optional(),
175
- required_gates: z
176
- .array(z.string().min(1))
176
+ gate_decisions: z
177
+ .array(z.object({
178
+ gate: z.string().min(1),
179
+ enabled: z.boolean(),
180
+ note: z.string(),
181
+ }))
177
182
  .optional()
178
- .describe('Optional array of quality-gate names to mark REQUIRED on this card (e.g. ["architecture","tdd"]). Board requirement is TRI-STATE per gate (`board_quality_gate_settings.default_state`), NOT binary: `required` runs always; `optional` runs WHEN flagged on the card (per-card opt-in `optional` is ENABLED, not off); `disabled` never runs (flag inert). So flagging makes the gate run unless the board state is `disabled`. Use this when you (the creating agent) judge the card needs that pre-dispatch review gate; omit for cards that don\'t. Known gates: dependency, architecture, tdd, code. The operator can also toggle these per-card later in the issue drawer.'),
183
+ .describe('REQUIRED fail-closed quality-gate decisions (DX-1594 replaces required_gates). One {gate, enabled, note} per board-OPTIONAL gate of the card\'s type: `enabled` answers whether the gate runs on this card, `note` records the rationale (persisted as the decision rationale, distinct from the reviewer verdict). The board requirement is TRI-STATE per gate (`board_quality_gate_settings.default_state`): `required` runs always (NO decision — auto-on); `optional` REQUIRES a decision here (unanswered the create 400s); `disabled` never runs (NO decision). Omit this only on a board with no optional gates; otherwise the 400 body\'s `required_gate_decisions` lists exactly which gates to answer retry with {enabled, note} for each. A decision naming a non-optional gate is rejected 400.'),
179
184
  phase_children: z
180
185
  .array(z.object({
181
186
  type: z.enum(NON_EPIC_TYPES),
@@ -183,12 +188,20 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
183
188
  description: z.string(),
184
189
  ac: z.array(z.object({ title: z.string().min(1) })).optional(),
185
190
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
191
+ gate_decisions: z
192
+ .array(z.object({
193
+ gate: z.string().min(1),
194
+ enabled: z.boolean(),
195
+ note: z.string(),
196
+ }))
197
+ .optional()
198
+ .describe("Per-child fail-closed gate decisions — same shape + rule as the root gate_decisions, resolved against THIS child's own type. Required when the child's type has board-optional gates."),
186
199
  }))
187
200
  .optional(),
188
201
  ...boardField,
189
202
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
190
203
  // ---------------- issue_edit ----------------
191
- server.tool("issue_edit", 'Patch prose fields only via PATCH /api/issues/:id/edit. ALLOWED keys: title, description, ac, checklists, effort_level, parent_id, list_id. ANY OTHER KEY (lifecycle timestamps, triage state, dependencies, retro, requires_human, blocked/dispatch gates) returns 400 with offending_keys[] and a pointer to the dedicated semantic handler — use issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro instead. CHECKLISTS (DX-1290): a card carries 0..N named checklists, each item ONE 4-state status `incomplete|failing|passing|cancelled` (terminal = passing|cancelled). `ac` is the 2-state CONVENIENCE onto the default "Acceptance Criteria" checklist (checked:true ↔ passing, false ↔ incomplete) — wholesale soft-delete + reinsert of that checklist\'s items. `checklists` is the GENERIC wholesale write path: it REPLACES every named checklist on the card with full 4-state control (each `{name, items:[{label, detail?, status}]}`) — use it to author named checklists like "Feature Tests". Send EITHER `ac` OR `checklists`, NOT both (400). list_id (DX-1192 / DX-1200) PINS the card to a specific board list. **Pass EITHER a board_lists id OR the list\'s display NAME (case-insensitive, e.g. a queue name like "⚙️ Fulfillment Queue") — the server resolves a name to its id.** Its type MUST match the card\'s CURRENT derived-status list-type (so to route a ToDo card into a `ready`-type queue, ready it first; mismatch / unknown name or id → 400); pass null to clear the pin (back to default-for-type).', {
204
+ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues/:id/edit. ALLOWED keys: title, description, ac, checklists, effort_level, parent_id, priority, list_id. ANY OTHER KEY (lifecycle timestamps, triage state, dependencies, retro, requires_human, blocked/dispatch gates) returns 400 with offending_keys[] and a pointer to the dedicated semantic handler — use issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro instead. PRIORITY (DX-1532): set card priority via the `priority` key — a tier WORD ("lowest"/"low"/"medium"/"high"/"very_high"/"critical", resolved to the tier midpoint) OR a raw number in [0,6). This is the ONLY way to change priority: the numeric `issues.priority` column is what the Trello priority label AND the dashboard badge read — editing a "Priority: <x>" line in the DESCRIPTION changes nothing downstream (a silent false-positive). To honor a "set priority" request, write `priority` here, do NOT edit description prose. CHECKLISTS (DX-1290): a card carries 0..N named checklists, each item ONE 4-state status `incomplete|failing|passing|cancelled` (terminal = passing|cancelled). `ac` is the 2-state CONVENIENCE onto the default "Acceptance Criteria" checklist (checked:true ↔ passing, false ↔ incomplete) — wholesale soft-delete + reinsert of that checklist\'s items. `checklists` is the GENERIC wholesale write path: it REPLACES every named checklist on the card with full 4-state control (each `{name, items:[{label, detail?, status}]}`) — use it to author named checklists like "Feature Tests". Send EITHER `ac` OR `checklists`, NOT both (400). list_id (DX-1192 / DX-1200) PINS the card to a specific board list. **Pass EITHER a board_lists id OR the list\'s display NAME (case-insensitive, e.g. a queue name like "⚙️ Fulfillment Queue") — the server resolves a name to its id.** Its type MUST match the card\'s CURRENT derived-status list-type (so to route a ToDo card into a `ready`-type queue, ready it first; mismatch / unknown name or id → 400); pass null to clear the pin (back to default-for-type).', {
192
205
  id: z.string().min(1),
193
206
  title: z.string().min(1).optional(),
194
207
  description: z.string().optional(),
@@ -210,6 +223,10 @@ server.tool("issue_edit", 'Patch prose fields only via PATCH /api/issues/:id/edi
210
223
  .optional(),
211
224
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
212
225
  parent_id: z.string().nullable().optional(),
226
+ priority: z
227
+ .union([z.enum(PRIORITY_TIER_WORDS), z.number()])
228
+ .optional()
229
+ .describe('Card priority (DX-1532). A tier WORD ("lowest"/"low"/"medium"/"high"/"very_high"/"critical") resolved to the tier midpoint, OR a raw number in [0,6). Writes the numeric `issues.priority` column the Trello label + dashboard badge read — set priority HERE, never via description prose (which no system reads for priority).'),
213
230
  list_id: z.string().min(1).nullable().optional(),
214
231
  ...boardField,
215
232
  }, async (args) => jsonResult(await issueEdit(client, args)));
@@ -290,7 +307,7 @@ server.tool("issue_requires_human", "Set or clear the requires_human dispatch ga
290
307
  ...boardField,
291
308
  }, async (args) => jsonResult(await issueRequiresHuman(client, args)));
292
309
  // ---------------- issue_quality_gate ----------------
293
- server.tool("issue_quality_gate", "Toggle a single card's per-card quality-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the SAME write the dashboard drawer's Quality Gates tab performs (DX-1181). This is the ONLY post-create way to mark a gate required/not-required: `issue_create` carries `required_gates[]` at birth, and `issue_edit` REJECTS gate keys (400 offending_keys) — without this tool a card created without a gate can never have it turned on by an agent. `gate` is a registry name: `plan-dependency` | `plan-architecture` | `plan-tdd` | `code-test-quality` | `code-architecture` | `code-quality` (the PRE/plan- gates run before the work dispatch; the POST/code- gates block issue_transition complete). Unknown gate → 400 (never a silent no-op); a card with no seeded row for a registered gate → 500 (canonical corruption). NOTE board requirement is TRI-STATE per gate (`board_quality_gate_settings.default_state`, the Agents-tab surface), NOT a binary on/off: `required` = gate always runs (this flag irrelevant); `optional` = gate runs WHEN this per-card flag is true (per-card opt-in — `optional` is ENABLED, NOT off); `disabled` = never runs (this flag inert). So flipping `required:true` here LAUNCHES the gate when the board state is `required` OR `optional`; it is inert ONLY when the board state is `disabled`. Do not read `optional` as off. (Source of truth: `isGateEffectivelyRequired` in `src/issues/quality-gates/read.ts`.) Returns the hydrated issue. Board-scoped; pass `board` (`<repo>:<slug>`) to target another board.", {
310
+ server.tool("issue_quality_gate", "Toggle a single card's per-card quality-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the SAME write the dashboard drawer's Quality Gates tab performs (DX-1181). This is the ONLY post-create way to mark a gate required/not-required: `issue_create` carries `gate_decisions` at birth, and `issue_edit` REJECTS gate keys (400 offending_keys) — without this tool a card created without a gate can never have it turned on by an agent. `gate` is a registry name: `plan-dependency` | `plan-architecture` | `plan-tdd` | `code-test-quality` | `code-architecture` | `code-quality` (the PRE/plan- gates run before the work dispatch; the POST/code- gates block issue_transition complete). Unknown gate → 400 (never a silent no-op); a card with no seeded row for a registered gate → 500 (canonical corruption). NOTE board requirement is TRI-STATE per gate (`board_quality_gate_settings.default_state`, the Agents-tab surface), NOT a binary on/off: `required` = gate always runs (this flag irrelevant); `optional` = gate runs WHEN this per-card flag is true (per-card opt-in — `optional` is ENABLED, NOT off); `disabled` = never runs (this flag inert). So flipping `required:true` here LAUNCHES the gate when the board state is `required` OR `optional`; it is inert ONLY when the board state is `disabled`. Do not read `optional` as off. (Source of truth: `isGateEffectivelyRequired` in `src/issues/quality-gates/read.ts`.) Returns the hydrated issue. Board-scoped; pass `board` (`<repo>:<slug>`) to target another board.", {
294
311
  id: z.string().min(1),
295
312
  gate: z.enum([
296
313
  "plan-dependency",
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tier-word → numeric priority resolution for the MCP `issue_edit` tool
3
+ * (DX-1532).
4
+ *
5
+ * Priority is a STRUCTURED numeric column `issues.priority` (float in `[0,6)`).
6
+ * Both the Trello "priority" label and the dashboard priority badge read it via
7
+ * `priorityTier(issue.priority)` — description PROSE ("Priority: Low") is read
8
+ * by neither. So an agent that "sets priority" by editing description text
9
+ * produces a silent false-positive: nothing downstream changes. The fix is to
10
+ * let the agent write the numeric column through `issue_edit`.
11
+ *
12
+ * Agents (and operators) speak in tier WORDS ("low" / "high"), so the tool
13
+ * accepts a tier word and resolves it to the tier's MIDPOINT here — the inverse
14
+ * of `src/issue-tracker/priority-tier.ts#priorityTier`. A raw numeric is passed
15
+ * through unchanged for the server route to range-validate (it 400s on
16
+ * out-of-`[0,6)`), so the MCP boundary never has to duplicate that bound.
17
+ *
18
+ * This package is a standalone published artifact and cannot import the
19
+ * backend's `priority-tier` module, so the midpoint table is duplicated here.
20
+ * Each value IS the `[min,max)` bucket midpoint by construction and equals the
21
+ * backend's `PRIORITY_TIERS.defaultValue`; `src/issue-tracker/priority-tier.test.ts`
22
+ * asserts `priorityTier(defaultValue) === key` on the canonical side, pinning
23
+ * the round-trip these midpoints rely on.
24
+ */
25
+ /**
26
+ * The six priority tier words, low → high. A const tuple so it doubles as the
27
+ * `z.enum` source in `index.ts`.
28
+ */
29
+ export const PRIORITY_TIER_WORDS = [
30
+ "lowest",
31
+ "low",
32
+ "medium",
33
+ "high",
34
+ "very_high",
35
+ "critical",
36
+ ];
37
+ /**
38
+ * Tier word → bucket midpoint (inverse of `priorityTier`). Lands the numeric
39
+ * column squarely inside the requested tier so a later `priorityTier` read
40
+ * returns the same word.
41
+ */
42
+ export const PRIORITY_TIER_MIDPOINTS = {
43
+ lowest: 0.5,
44
+ low: 1.5,
45
+ medium: 2.5,
46
+ high: 3.5,
47
+ very_high: 4.5,
48
+ critical: 5.5,
49
+ };
50
+ /**
51
+ * Resolve a priority input — a tier WORD or a raw numeric — to the numeric
52
+ * value the `PATCH /api/issues/:id/edit` route stores in `issues.priority`.
53
+ *
54
+ * - tier word → its bucket midpoint (`PRIORITY_TIER_MIDPOINTS`).
55
+ * - number → passed through unchanged; the server route range-validates it to
56
+ * `[0,6)` and 400s otherwise (the MCP boundary deliberately does not
57
+ * re-validate the bound — single source of truth on the route).
58
+ * - unknown string → throws here at the MCP boundary (no round-trip). The Zod
59
+ * enum on the tool schema already rejects this for tool callers; the explicit
60
+ * throw keeps the helper safe for any direct caller.
61
+ */
62
+ export function resolvePriority(input) {
63
+ if (typeof input === "number")
64
+ return input;
65
+ const midpoint = PRIORITY_TIER_MIDPOINTS[input];
66
+ if (midpoint === undefined) {
67
+ throw new Error(`issue_edit: unknown priority tier word "${input}" (expected one of ${PRIORITY_TIER_WORDS.join(", ")} or a number in [0,6))`);
68
+ }
69
+ return midpoint;
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,51 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { DashboardHttpClient } from "./http-client.js";
3
- import { issueList, issueTransition } from "./handlers.js";
4
- /**
5
- * `issue_list` query-param forwarding. A fake `fetch` captures the built
6
- * URL so each filter is asserted at the wire, through the real client's
7
- * URL builder (`?repo=` always stamped, extra query merged after).
8
- */
9
- function clientCapturing() {
10
- const urls = [];
11
- const fetchImpl = (async (url) => {
12
- urls.push(url);
13
- return new Response(JSON.stringify({ issues: [] }), { status: 200 });
14
- });
15
- const client = new DashboardHttpClient({ baseUrl: "http://localhost:5555", repo: "danxbot", token: "t" }, fetchImpl);
16
- return { client, urls };
17
- }
18
- describe("issueList — query forwarding", () => {
19
- it("forwards q as the server-side search needle", async () => {
20
- const { client, urls } = clientCapturing();
21
- await issueList(client, { q: "retire" });
22
- expect(urls[0]).toContain("q=retire");
23
- });
24
- it("omits q when not provided", async () => {
25
- const { client, urls } = clientCapturing();
26
- await issueList(client, { include_closed: true });
27
- expect(urls[0]).not.toContain("q=");
28
- expect(urls[0]).toContain("include_closed=true");
29
- });
30
- });
31
- describe("issueTransition — body forwarding", () => {
32
- function clientCapturingBody() {
33
- const bodies = [];
34
- const fetchImpl = (async (_url, init) => {
35
- bodies.push(JSON.parse(String(init?.body ?? "{}")));
36
- return new Response(JSON.stringify({ issue: {} }), { status: 200 });
37
- });
38
- const client = new DashboardHttpClient({ baseUrl: "http://localhost:5555", repo: "danxbot", token: "t" }, fetchImpl);
39
- return { client, bodies };
40
- }
41
- it("forwards manual: true on pickup (DX-946 operator self-pickup)", async () => {
42
- const { client, bodies } = clientCapturingBody();
43
- await issueTransition(client, { id: "DX-1", action: "pickup", manual: true });
44
- expect(bodies[0]).toEqual({ action: "pickup", manual: true });
45
- });
46
- it("omits manual when not provided", async () => {
47
- const { client, bodies } = clientCapturingBody();
48
- await issueTransition(client, { id: "DX-1", action: "pickup" });
49
- expect(bodies[0]).toEqual({ action: "pickup" });
50
- });
51
- });