@thehammer/danx-dashboard-mcp 0.1.26 → 0.1.28
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/dist/entrypoint.js +35 -0
- package/dist/entrypoint.test.js +43 -0
- package/dist/handlers.js +7 -1
- package/dist/index.js +16 -9
- package/package.json +2 -1
- package/dist/handlers.test.js +0 -51
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
/**
|
|
4
|
+
* True when this module is the process entrypoint (the published bin) rather
|
|
5
|
+
* than an `import` — the DX-1606 tool-defs generator + its drift test import
|
|
6
|
+
* the module to introspect tool schemas without booting the stdio server, so
|
|
7
|
+
* `boot()` + `server.connect()` must run ONLY for the real bin invocation.
|
|
8
|
+
*
|
|
9
|
+
* Why the realpath resolve matters (DX-1647): `import.meta.url` reports the
|
|
10
|
+
* module's REALPATH — Node resolves symlinks for ESM module URLs by default.
|
|
11
|
+
* When the bin runs via a SYMLINK — both `npx` and global installs link
|
|
12
|
+
* `node_modules/.bin/danx-dashboard-mcp` → `dist/index.js` — `process.argv[1]`
|
|
13
|
+
* is the symlink path, which `pathToFileURL` would NOT match against the
|
|
14
|
+
* realpath `import.meta.url` carries. So resolve argv[1]'s realpath first; then
|
|
15
|
+
* the entrypoint is detected under BOTH direct `node dist/index.js` and the
|
|
16
|
+
* symlinked `npx -y @thehammer/danx-dashboard-mcp` the worker spawns.
|
|
17
|
+
*
|
|
18
|
+
* Without this, npx imports the module, registers the tools, and exits 0
|
|
19
|
+
* WITHOUT starting the server — every dispatch then fails the worker's
|
|
20
|
+
* "MCP loaded" preflight (`declared-not-loaded=[danx-dashboard]`).
|
|
21
|
+
*/
|
|
22
|
+
export function isEntrypointModule(moduleUrl, argvPath) {
|
|
23
|
+
if (typeof argvPath !== "string" || argvPath.length === 0)
|
|
24
|
+
return false;
|
|
25
|
+
let resolved;
|
|
26
|
+
try {
|
|
27
|
+
resolved = realpathSync(argvPath);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// argv[1] may not resolve on disk (synthetic paths in tests) — fall back to
|
|
31
|
+
// the raw path so a non-symlinked match still holds.
|
|
32
|
+
resolved = argvPath;
|
|
33
|
+
}
|
|
34
|
+
return moduleUrl === pathToFileURL(resolved).href;
|
|
35
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, symlinkSync, rmSync, realpathSync, } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { isEntrypointModule } from "./entrypoint.js";
|
|
7
|
+
describe("isEntrypointModule (DX-1647)", () => {
|
|
8
|
+
let dir;
|
|
9
|
+
let real;
|
|
10
|
+
let link;
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
dir = mkdtempSync(join(tmpdir(), "entrypoint-test-"));
|
|
13
|
+
real = join(dir, "index.js");
|
|
14
|
+
writeFileSync(real, "// stub entry\n");
|
|
15
|
+
link = join(dir, "danx-dashboard-mcp"); // mimics node_modules/.bin symlink
|
|
16
|
+
symlinkSync(real, link);
|
|
17
|
+
});
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
// import.meta.url always reports the module REALPATH — model that here.
|
|
22
|
+
const moduleUrl = () => pathToFileURL(realpathSync(real)).href;
|
|
23
|
+
it("true when argv[1] is the real file (direct `node index.js`)", () => {
|
|
24
|
+
expect(isEntrypointModule(moduleUrl(), real)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
it("true when argv[1] is a SYMLINK to the file (npx / global bin) — the fleet regression", () => {
|
|
27
|
+
// The symlink path !== the realpath, but the entrypoint MUST still be
|
|
28
|
+
// detected, or npx imports the module and exits without booting the server.
|
|
29
|
+
expect(link).not.toBe(realpathSync(link));
|
|
30
|
+
expect(isEntrypointModule(moduleUrl(), link)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
it("false when argv[1] is undefined (module imported, not run as bin)", () => {
|
|
33
|
+
expect(isEntrypointModule(moduleUrl(), undefined)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
it("false when argv[1] is an empty string", () => {
|
|
36
|
+
expect(isEntrypointModule(moduleUrl(), "")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it("false when argv[1] points at a different real file", () => {
|
|
39
|
+
const other = join(dir, "other.js");
|
|
40
|
+
writeFileSync(other, "// other\n");
|
|
41
|
+
expect(isEntrypointModule(moduleUrl(), other)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
package/dist/handlers.js
CHANGED
|
@@ -361,12 +361,18 @@ export async function issueRequiresHuman(client, args) {
|
|
|
361
361
|
* here launches the gate when the board state is `required` OR `optional`;
|
|
362
362
|
* it is inert ONLY when the board state is `disabled`. Source of truth:
|
|
363
363
|
* `isGateEffectivelyRequired` in `src/issues/quality-gates/read.ts`.
|
|
364
|
+
*
|
|
365
|
+
* `effort_level` (DX-1760) is an independent sibling write: present (incl.
|
|
366
|
+
* `null`) sets `card_quality_gates.effort_level`; omitted leaves it untouched.
|
|
364
367
|
*/
|
|
365
368
|
export async function issueQualityGate(client, args) {
|
|
369
|
+
const body = { required: args.required };
|
|
370
|
+
if (args.effort_level !== undefined)
|
|
371
|
+
body.effort_level = args.effort_level;
|
|
366
372
|
return client.request({
|
|
367
373
|
method: "POST",
|
|
368
374
|
path: `/${encodeURIComponent(args.id)}/quality-gates/${encodeURIComponent(args.gate)}`,
|
|
369
|
-
body
|
|
375
|
+
body,
|
|
370
376
|
board: args.board,
|
|
371
377
|
});
|
|
372
378
|
}
|
package/dist/index.js
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
* agent reads `body.error` + structured fields to decide next action.
|
|
49
49
|
* 5xx and network failures throw — never silently swallowed.
|
|
50
50
|
*/
|
|
51
|
-
import {
|
|
51
|
+
import { isEntrypointModule } from "./entrypoint.js";
|
|
52
52
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
53
53
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
54
54
|
import { z } from "zod";
|
|
@@ -191,9 +191,10 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
|
|
|
191
191
|
gate: z.string().min(1),
|
|
192
192
|
enabled: z.boolean(),
|
|
193
193
|
note: z.string(),
|
|
194
|
+
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
194
195
|
}))
|
|
195
196
|
.optional()
|
|
196
|
-
.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.'),
|
|
197
|
+
.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. DX-1760: each entry also takes an OPTIONAL `effort_level` — a per-`(card, gate)` reviewer-rung override for a `plan-*` gate, persisted at seed time; omit for no override.'),
|
|
197
198
|
phase_children: z
|
|
198
199
|
.array(z.object({
|
|
199
200
|
type: z.enum(NON_EPIC_TYPES),
|
|
@@ -206,15 +207,16 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
|
|
|
206
207
|
gate: z.string().min(1),
|
|
207
208
|
enabled: z.boolean(),
|
|
208
209
|
note: z.string(),
|
|
210
|
+
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
209
211
|
}))
|
|
210
212
|
.optional()
|
|
211
|
-
.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."),
|
|
213
|
+
.describe("Per-child fail-closed gate decisions — same shape + rule as the root gate_decisions (incl. the optional per-gate effort_level, DX-1760), resolved against THIS child's own type. Required when the child's type has board-optional gates."),
|
|
212
214
|
}))
|
|
213
215
|
.optional(),
|
|
214
216
|
...boardField,
|
|
215
217
|
}, async (args) => jsonResult(await issueCreate(client, args, config.board)));
|
|
216
218
|
// ---------------- issue_edit ----------------
|
|
217
|
-
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).', {
|
|
219
|
+
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, triage_enabled. 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).', {
|
|
218
220
|
id: z.string().min(1),
|
|
219
221
|
title: z.string().min(1).optional(),
|
|
220
222
|
description: z.string().optional(),
|
|
@@ -241,6 +243,10 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
|
|
|
241
243
|
.optional()
|
|
242
244
|
.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).'),
|
|
243
245
|
list_id: z.string().min(1).nullable().optional(),
|
|
246
|
+
triage_enabled: z
|
|
247
|
+
.boolean()
|
|
248
|
+
.optional()
|
|
249
|
+
.describe("DX-1895 — per-card opt-in for the DX-1886 auto-triage dispatcher. false (default) = the automatic dispatcher trigger never selects this card, even when every other eligibility condition holds. Operator-directed POST /api/triage and direct issue_triage calls are NOT gated by this flag."),
|
|
244
250
|
...boardField,
|
|
245
251
|
}, async (args) => jsonResult(await issueEdit(client, args)));
|
|
246
252
|
// ---------------- issue_transition ----------------
|
|
@@ -320,7 +326,7 @@ server.tool("issue_requires_human", "Set or clear the requires_human dispatch ga
|
|
|
320
326
|
...boardField,
|
|
321
327
|
}, async (args) => jsonResult(await issueRequiresHuman(client, args)));
|
|
322
328
|
// ---------------- issue_quality_gate ----------------
|
|
323
|
-
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.", {
|
|
329
|
+
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`.) DX-1760: optionally pass `effort_level` — a per-`(card, gate)` reviewer-rung override for a `plan-*` gate, written alongside `required`; omit to leave it untouched, pass `null` to clear a prior override. Returns the hydrated issue. Board-scoped; pass `board` (`<repo>:<slug>`) to target another board.", {
|
|
324
330
|
id: z.string().min(1),
|
|
325
331
|
gate: z.enum([
|
|
326
332
|
"plan-dependency",
|
|
@@ -331,6 +337,7 @@ server.tool("issue_quality_gate", "Toggle a single card's per-card quality-gate
|
|
|
331
337
|
"code-quality",
|
|
332
338
|
]),
|
|
333
339
|
required: z.boolean(),
|
|
340
|
+
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
334
341
|
...boardField,
|
|
335
342
|
}, async (args) => jsonResult(await issueQualityGate(client, args)));
|
|
336
343
|
// ---------------- issue_retro ----------------
|
|
@@ -382,10 +389,10 @@ async function main() {
|
|
|
382
389
|
// Boot the stdio server ONLY when run as the entrypoint (the published bin).
|
|
383
390
|
// Importing this module (the tool-defs generator + its drift test) registers
|
|
384
391
|
// the tools on `server` without reading env or attaching stdin — so the
|
|
385
|
-
// schemas can be introspected without spawning a dispatch.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (
|
|
392
|
+
// schemas can be introspected without spawning a dispatch. The check is
|
|
393
|
+
// symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
|
|
394
|
+
// spawns, not just a direct `node dist/index.js`.
|
|
395
|
+
if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
389
396
|
main().catch((err) => {
|
|
390
397
|
console.error(`[danx-dashboard-mcp] fatal: ${err.message}`);
|
|
391
398
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thehammer/danx-dashboard-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
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",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
|
|
25
|
+
"verify:boot": "bash scripts/verify-boot.sh",
|
|
25
26
|
"start": "node dist/index.js",
|
|
26
27
|
"dev": "tsx src/index.ts",
|
|
27
28
|
"gen-tool-defs": "tsx scripts/gen-tool-defs.ts",
|
package/dist/handlers.test.js
DELETED
|
@@ -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
|
-
});
|