diff --git a/.env.example b/.env.example index c43596864..4442b4003 100644 --- a/.env.example +++ b/.env.example @@ -24,8 +24,15 @@ REDIS_URL=redis://localhost:6379 PORT=3000 LOG_LEVEL=info -# Disable SSL for local PostgreSQL (set to false for local dev without SSL) +# PostgreSQL TLS mode: +# false → no TLS (local dev without SSL) +# no-verify → TLS without certificate verification — for managed Postgres that +# requires TLS but presents a self-signed/private-CA cert (e.g. Supabase's +# connection pooler). DATABASE_CA_CERT does NOT help here because spawned +# worker containers receive DATABASE_* env but no mounted cert file. +# (unset) → TLS with verification; optionally pin a CA via DATABASE_CA_CERT. # DATABASE_SSL=false +# DATABASE_CA_CERT=/path/to/ca.pem # --- Optional: Security --- diff --git a/CHANGELOG.md b/CHANGELOG.md index d8facad0d..449b92beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable user-visible changes to CASCADE are documented here. The format is l ## Unreleased +### Added + +- **`cascade-tools` now suggests the closest command when an agent typos a topic or subcommand** ([MNG-1442](https://linear.app/issue/MNG-1442)). `bin/cascade-tools.js` registers an oclif `command_not_found` hook that turns command typos into the same structured spec-014 envelope every other CLI failure emits: JSON on stdout, a one-line prose summary on stderr, and a runnable `did you mean` hint when within the shared Levenshtein budget (MNG-1440). Unknown top-level topics (`cascade-tools sm get-pr-diff`) surface a topic enumeration in `expected` and a hint that preserves the user's trailing segments (`did you mean 'cascade-tools scm get-pr-diff'?`). Known-topic / unknown-subcommand typos (`cascade-tools pm reaad-work-item`) surface the topic's subcommand enumeration and a corrected hint (`did you mean 'cascade-tools pm read-work-item'?`). Far-away typos drop `hint` but still surface `expected` so the agent has a concrete recovery path. Exit code is **`2`** for `unknown-command` — preserved from oclif's historical `command_not_found` default and distinct from every other envelope's exit code `1`; existing exit-code consumers see no change. The hook lives at `src/cli/_shared/command-not-found-hook.ts`, intentionally inside `_shared/` so oclif's command-discovery glob in `bin/cascade-tools.js` excludes it. It is wired through `pjson.oclif.hooks` so oclif loads it dynamically only when needed — no static import is added, which preserves the existing friendly `dist/cli/bootstrap.js` missing path in the entrypoint. The pure suggestion logic lives in `src/cli/_shared/commandSuggestions.ts` (MNG-1441) and is unit-tested directly without booting oclif; candidates come strictly from the loaded oclif config (`config.commandIDs` plus non-hidden `pjson.oclif.topics`), so the `cascade-tools` binary never suggests dashboard topics that its discovery glob excludes. Existing `unknown-flag` handling from `createCLICommand()` is untouched. Closes [MNG-1442](https://linear.app/mongrel/issue/MNG-1442). + ### Fixed - **`cascade-tools` multiline text and large diff I/O are now hardened against shell-quoting footguns and stdout truncation** ([MNG-1059](https://linear.app/issue/MNG-1059)). The shared CLI factory at `src/gadgets/shared/cli/params.ts` now rejects invocations that pass `--*-file -` for two or more file-input flags (e.g. `--body-file - --comments-file -`) before any `readFileSync(0, ...)` call — stdin (fd 0) can only be drained once per process, and the previous behavior silently truncated one of the two agent payloads. The rejection emits a structured `flag-parse` error envelope (`error.flag: "body-file,comments-file"`, `hint: "Pass at most one --*-file -; for the others, write the payload to a temp file and pass ---file ."`) so agents can self-correct on the next attempt. Direct file paths remain pairwise-compatible — `--body-file - --comments-file /tmp/comments.json` and `--body-file /tmp/body.md --comments-file -` both work as before. The native-tool system prompt now renders a "cascade-tools shell-safety rules" section that documents the one-stdin-consumer invariant and provides safe heredoc / temp-file patterns for one and two payloads. The prompt renderer also suppresses inline `--body '...'` / `--text '...'` examples whose content contains backticks, code fences, `$(...)`, or newlines when a file-input companion is declared, redirecting the agent at the safer `--*-file ` form instead. File-input flag descriptions for `--body-file`, `--text-file`, `--description-file`, `--details-file`, and `--comments-file` explicitly call out markdown / multiline / backticks. Closes [MNG-908](https://linear.app/mongrel/issue/MNG-908), [MNG-910](https://linear.app/mongrel/issue/MNG-910), [MNG-917](https://linear.app/mongrel/issue/MNG-917), [MNG-1046](https://linear.app/mongrel/issue/MNG-1046). diff --git a/CLAUDE.md b/CLAUDE.md index 5e0b16e70..2bcf50104 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,7 +179,7 @@ Required: Optional: -- `DATABASE_SSL=false` to disable SSL locally; `DATABASE_CA_CERT` for managed DBs with a private CA. +- `DATABASE_SSL` — `false` disables SSL (local dev); `no-verify` keeps TLS but skips certificate verification — required for managed Postgres that requires TLS yet presents a self-signed/private-CA cert (e.g. Supabase's connection pooler), where `DATABASE_CA_CERT` can't help because spawned worker containers get `DATABASE_*` env but no mounted cert file; unset → TLS with verification. `DATABASE_CA_CERT` pins a CA for managed DBs with a private CA (verification mode only). - `CREDENTIAL_MASTER_KEY` — 64-char hex (AES-256 key) to encrypt project credentials at rest. Without it, credentials are stored as plaintext; both modes coexist. - `GITHUB_WEBHOOK_SECRET` — opt-in HMAC verification; store as the `webhook_secret` role on the GitHub SCM integration. - `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_RELEASE`, `SENTRY_TRACES_SAMPLE_RATE` — observability. diff --git a/bin/cascade-tools.js b/bin/cascade-tools.js index d822051d4..a9286f91a 100755 --- a/bin/cascade-tools.js +++ b/bin/cascade-tools.js @@ -72,6 +72,23 @@ pjson.oclif = { globPatterns: ['**/*.js', '!**/dashboard/**', '!**/_shared/**', '!base.js', '!bootstrap.js'], }, topicSeparator: ' ', + // `command_not_found` hook turns command typos into the structured + // spec-014 envelope (JSON on stdout, prose on stderr, runnable + // `did you mean` hint when within budget) instead of oclif's bare + // `command not found` message. Exit code stays 2 (oclif's + // historical default for command_not_found) via an explicit exit + // delegate inside the hook — see + // `src/cli/_shared/command-not-found-hook.ts` for the full rationale. + // + // The hook is wired via oclif's `pjson.oclif.hooks` so it is loaded + // lazily by `loadWithData` when the hook actually fires — *not* + // statically required at entrypoint time. This preserves the friendly + // `dist/cli/bootstrap.js` missing path above: if the build is absent, + // the bootstrap import throws ERR_MODULE_NOT_FOUND first and exits 1 + // with the explainer, before this hook is ever resolved. + hooks: { + command_not_found: './dist/cli/_shared/command-not-found-hook.js', + }, // Explicit topic summaries. Without this block oclif borrows each topic's // description from its FIRST command (see node_modules/@oclif/core // /lib/config/config.js — the line `this._topics.set(name, { description: diff --git a/docs/architecture/07-gadgets.md b/docs/architecture/07-gadgets.md index d9e5be336..1a6f637e9 100644 --- a/docs/architecture/07-gadgets.md +++ b/docs/architecture/07-gadgets.md @@ -138,6 +138,7 @@ The `cascade-tools` binary uses a separate oclif config (`bin/cascade-tools.js`) |--------|------| | `commandNames.ts` | Command namespace/name derivation shared by the CLI factory and manifest generator | | `examples.ts` | Tool example lookup, shell quoting, oclif example rendering, and JSON expected-shape hints | +| `suggestions.ts` | Shared Levenshtein scorer for flag and command typo suggestions (MNG-1440) | | `flags.ts` | oclif flag construction and flag metadata collection | | `booleanArgv.ts` | Boolean value-form normalization before oclif parsing | | `parseErrors.ts` | oclif parse-error classification and unknown-flag suggestions | @@ -148,6 +149,48 @@ New domain commands should not add branches in these helpers. They declare behav Core functions passed to `createCLICommand()` own domain work only. On fatal runtime/API/provider failures they throw, and the shared factory converts that exception into the structured `{"success":false,"error":{"type":"runtime","message":"..."}}` stdout envelope plus exit code 1. A returned value is always serialized as successful `data`, so gadgets must not return sentinel error strings such as `Error reading work item: ...` for fatal failures. Non-fatal command states that are part of the contract, such as guarded PM move no-ops or friction retry queueing, remain successful returns. +### Unknown-command typo suggestions (MNG-1442) + +`bin/cascade-tools.js` registers an oclif `command_not_found` hook so command typos emit the same structured envelope every other CLI failure does (spec 014): JSON on stdout, prose on stderr, runnable `did you mean` hint when within the shared Levenshtein budget. Two cases the hook covers: + +- **Unknown top-level topic** (`cascade-tools sm get-pr-diff`) — `expected` lists topics, `hint` preserves trailing segments (`did you mean 'cascade-tools scm get-pr-diff'?`). +- **Known topic, unknown subcommand** (`cascade-tools pm reaad-work-item`) — `expected` lists the topic's subcommands, `hint` runs the corrected form (`did you mean 'cascade-tools pm read-work-item'?`). + +Far-away typos drop `hint` but still surface `expected` so the agent has a concrete recovery enumeration. Exit code is **`2`** for `unknown-command` — oclif's historical `command_not_found` default — distinct from every other envelope's exit code `1`. + +The hook lives at `src/cli/_shared/command-not-found-hook.ts`, intentionally inside `_shared/` because `bin/cascade-tools.js`'s oclif command-discovery glob excludes `**/_shared/**`. The entrypoint wires it via `pjson.oclif.hooks.command_not_found` so oclif loads it dynamically only when needed — no static import is added, which preserves the existing friendly `dist/cli/bootstrap.js` missing path. The pure suggestion logic lives in `src/cli/_shared/commandSuggestions.ts` (MNG-1441) and is unit-tested directly without booting oclif; the hook is a thin wrapper that forwards `{config, id, argv}` into the helper and routes the envelope through `emitCliError` with an explicit exit-code-2 delegate. Candidates come strictly from the loaded oclif config (`config.commandIDs` plus non-hidden `pjson.oclif.topics`), so the `cascade-tools` binary never suggests dashboard topics that its discovery glob excludes. + +### Mutation result contract (MNG-1422 → MNG-1428) + +Every PM mutation core and the SCM PR comment/reply/update/review mutation cores covered by MNG-1428 return structured objects, never prose. The CLI factory serialises those objects verbatim into `{"success":true,"data":{...}}`, so consumers (downstream agents, sidecars, review/respond workflows) can read structured keys directly. + +These targeted mutations surface these contract fields on `success.data`: + +| Field | Meaning | +|---|---| +| `status` | The MUTATION OUTCOME — `"created"`/`"updated"`/`"moved"`/`"noop"`/`"aborted"`/`"deleted"` (PM) or `"ok"`/`"no-op"`/`"aborted"` (SCM). Branch on this, not on prose. | +| `updatedAt` | ISO 8601 timestamp string. It is always present and parseable; the source varies by mutation and fallback path. | + +Identity and URL fields are mutation-specific. Work-item and comment mutations expose `id` plus their canonical resource URL (`url` or, for PM comments, `workItemUrl`). `AddChecklist` exposes `checklistId` and `workItemUrl`, plus `itemIds` / `itemCount`; `PMUpdateChecklistItem` and `PMDeleteChecklistItem` expose `checkItemId` and `workItemUrl`. Targeted SCM PR comment/reply/update/review mutations additionally surface `id`, `url`, `repoFullName`, and `prNumber` (the latter widens to `number | null` for `UpdatePRComment` when GitHub returns an issue-only comment URL). `CreatePRReview` extends with `reviewUrl`, `event`, `submittedAt`, and `inlineCommentCount`. `CreatePR` is also a structured SCM mutation, but it is outside MNG-1428's shared `status` / `updatedAt` / `id` / `url` contract and keeps its existing shape: `prNumber`, `prUrl`, `repoFullName`, and `alreadyExisted` plus optional commit/push details. The full per-mutation shapes live on the matching `ToolDefinition.outputShape` blocks under `src/gadgets/{pm,github}/definitions.ts`. + +**`status` vs `workflowStatus` naming.** `status` is reserved for the mutation outcome alone. The PM provider's workflow state — Linear's "In Progress", a Trello list name, a JIRA status — lives on its own keys: `workflowStatus` (human-readable) and `workflowStatusId` (native ID). `MoveWorkItem` also exposes `previousStatus` / `previousStatusId` for the work item's pre-move workflow state on the guarded path. Mixing the two surfaces once cost ~2½ minutes of agent time (prod run `5d993b04`); the dual-key naming is now load-bearing. + +**Fatal failures throw.** Cores propagate runtime/API/provider errors as exceptions; the CLI factory emits the spec-014 runtime envelope (`{"success":false,"error":{"type":"runtime","message":"..."}}`). Do NOT return sentinel strings like `"Error creating work item: ..."` — the CLI cannot distinguish a string return from a successful `data` payload, so the envelope would say `success: true` and the agent would silently mis-act. + +**Timestamp fallback semantics.** The stable contract is that `updatedAt` is always present and parseable. `okResult` still rejects empty timestamps, so call sites using the shared success helper must provide one, but some successful PM writes synthesise timestamps today: `PostComment` uses `currentTimestamp()` for `created` / `updated`, and `MoveWorkItem` can fall back through `pickTimestamp(undefined)` for `moved`. `noOpResult` and `abortedResult` synthesise via `currentTimestamp()` because no provider write happened — the synthetic "now" reflects when the gadget evaluated the guard. Read-back failures after a successful checklist mutation fall back to a synthesised URL + timestamp in `readWorkItemContext` rather than masking the mutation success and risking an idempotency retry storm (Trello native-checklist retries duplicate rows). + +The regression coverage lives in `tests/unit/cli/pm/pm-commands.test.ts`, `tests/unit/cli/scm/scm-commands.test.ts`, `tests/unit/gadgets/pm/definitions.test.ts`, and `tests/unit/gadgets/github/definitions.test.ts`. Run the focused suite with: + +```bash +npx vitest run --project unit-core \ + tests/unit/cli/pm/pm-commands.test.ts \ + tests/unit/cli/scm/scm-commands.test.ts \ + tests/unit/gadgets/pm/definitions.test.ts \ + tests/unit/gadgets/github/definitions.test.ts +``` + +The full pre-PR gate remains `npm run lint && npm run typecheck && npm test`. + ### Shell-safety contract (MNG-1059) cascade-tools commands that accept text bodies, descriptions, or markdown payloads declare a `--*-file ` companion via `cli.fileInputAlternatives` (`--body-file`, `--text-file`, `--description-file`, `--details-file`, `--comments-file`). Agents are instructed to prefer the file form for any content containing backticks, code fences, `$(...)`, or newlines — shells expand those tokens even inside single quotes when the command is layered through `bash -c`, and newlines break argv parsing. diff --git a/drizzle.config.ts b/drizzle.config.ts index cddc5c129..707bf3662 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,4 +1,10 @@ import { defineConfig } from 'drizzle-kit'; +// drizzle-kit connects via the `url` below and IGNORES a `dbCredentials.ssl` object +// when a `url` is set, so the SSL intent must be encoded in the connection string as +// `sslmode`. `applyDbSslModeToUrl` derives it from DATABASE_SSL (shared with the runtime +// client's resolver), letting `DATABASE_SSL=no-verify` work against managed Postgres with +// self-signed certs (e.g. Supabase's pooler), which strict verification would reject. +import { applyDbSslModeToUrl } from './src/db/ssl-config'; export default defineConfig({ schema: [ @@ -14,6 +20,6 @@ export default defineConfig({ out: './src/db/migrations', dialect: 'postgresql', dbCredentials: { - url: process.env.DATABASE_URL ?? '', + url: applyDbSslModeToUrl(process.env.DATABASE_URL ?? ''), }, }); diff --git a/package-lock.json b/package-lock.json index 2df82b87b..23dea23ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@oclif/core": "^4.8.0", "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "^1.14.25", - "@sentry/node": "^10.39.0", + "@sentry/node": "^10.58.0", "@trpc/client": "^11.10.0", "@trpc/server": "^11.10.0", "@types/archiver": "^7.0.0", @@ -30,9 +30,9 @@ "eta": "^4.5.0", "execa": "^9.6.1", "fastest-levenshtein": "^1.0.16", - "hono": "^4.12.14", + "hono": "^4.12.25", "jira.js": "^5.3.0", - "js-yaml": "^4.1.1", + "js-yaml": "^4.2.0", "llmist": "^16.0.4", "marklassian": "^1.1.0", "open": "^11.0.0", @@ -62,9 +62,9 @@ "@types/pg": "^8.16.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/coverage-v8": "^3.2.6", "commander": "^14.0.2", - "concurrently": "^9.2.1", + "concurrently": "^10.0.3", "drizzle-kit": "^0.31.9", "jsdom": "^28.1.0", "lefthook": "^1.10.10", @@ -1987,72 +1987,6 @@ } } }, - "node_modules/@fastify/otel": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.18.0.tgz", - "integrity": "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.212.0", - "@opentelemetry/semantic-conventions": "^1.28.0", - "minimatch": "^10.2.4" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/api-logs": { - "version": "0.212.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", - "integrity": "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/instrumentation": { - "version": "0.212.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", - "integrity": "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.212.0", - "import-in-the-middle": "^2.0.6", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@fastify/otel/node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - } - }, "node_modules/@google/genai": { "version": "1.44.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.44.0.tgz", @@ -2090,7 +2024,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.3", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -2729,18 +2665,6 @@ "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.1.tgz", - "integrity": "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/core": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", @@ -2773,393 +2697,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.61.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.61.0.tgz", - "integrity": "sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.57.0.tgz", - "integrity": "sha512-FMEBChnI4FLN5TE9DHwfH7QpNir1JzXno1uz/TAucVdLCyrG0jTrKIcNHt/i30A0M2AunNBCkcd8Ei26dIPKdg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/connect": "3.4.38" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.31.0.tgz", - "integrity": "sha512-f654tZFQXS5YeLDNb9KySrwtg7SnqZN119FauD7acBoTzuLduaiGTNz88ixcVSOOMGZ+EjJu/RFtx5klObC95g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-express": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.62.0.tgz", - "integrity": "sha512-Tvx+vgAZKEQxU3Rx+xWLiR0mLxHwmk69/8ya04+VsV9WYh8w6Lhx5hm5yAMvo1wy0KqWgFKBLwSeo3sHCwdOww==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.33.0.tgz", - "integrity": "sha512-sCZWXGalQ01wr3tAhSR9ucqFJ0phidpAle6/17HVjD6gN8FLmZMK/8sKxdXYHy3PbnlV1P4zeiSVFNKpbFMNLA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.57.0.tgz", - "integrity": "sha512-orhmlaK+ZIW9hKU+nHTbXrCSXZcH83AescTqmpamHRobRmYSQwRbD0a1odc0yAzuzOtxYiHiXAnpnIpaSSY7Ow==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.62.0.tgz", - "integrity": "sha512-3YNuLVPUxafXkH1jBAbGsKNsP3XVzcFDhCDCE3OqBwCwShlqQbLMRMFh1T/d5jaVZiGVmSsfof+ICKD2iOV8xg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.60.0.tgz", - "integrity": "sha512-aNljZKYrEa7obLAxd1bCEDxF7kzCLGXTuTJZ8lMR9rIVEjmuKBXN1gfqpm/OB//Zc2zP4iIve1jBp7sr3mQV6w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.214.0.tgz", - "integrity": "sha512-FlkDhZDRjDJDcO2LcSCtjRpkal1NJ8y0fBqBhTvfAR3JSYY2jAIj1kSS5IjmEBt4c3aWv+u/lqLuoCDrrKCSKg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/instrumentation": "0.214.0", - "@opentelemetry/semantic-conventions": "^1.29.0", - "forwarded-parse": "2.1.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.62.0.tgz", - "integrity": "sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.23.0.tgz", - "integrity": "sha512-4K+nVo+zI+aDz0Z85SObwbdixIbzS9moIuKJaYsdlzcHYnKOPtB7ya8r8Ezivy/GVIBHiKJVq4tv+BEkgOMLaQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.30.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.58.0.tgz", - "integrity": "sha512-Hc/o8fSsaWxZ8r1Yw4rNDLwTpUopTf4X32y4W6UhlHmW8Wizz8wfhgOKIelSeqFVTKBBPIDUOsQWuIMxBmu8Bw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.62.0.tgz", - "integrity": "sha512-uVip0VuGUQXZ+vFxkKxAUNq8qNl+VFlyHDh/U6IQ8COOEDfbEchdaHnpFrMYF3psZRUuoSIgb7xOeXj00RdwDA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.36.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.58.0.tgz", - "integrity": "sha512-6grM3TdMyHzlGY1cUA+mwoPueB1F3dYKgKtZIH6jOFXqfHAByyLTc+6PFjGM9tKh52CFBJaDwodNlL/Td39z7Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.67.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.67.0.tgz", - "integrity": "sha512-1WJp5N1lYfHq2IhECOTewFs5Tf2NfUOwQRqs/rZdXKTezArMlucxgzAaqcgp3A3YREXopXTpXHsxZTGHjNhMdQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.60.0.tgz", - "integrity": "sha512-8BahAZpKsOoc+lrZGb7Ofn4g3z8qtp5IxDfvAVpKXsEheQN7ONMH5djT5ihy6yf8yyeQJGS0gXFfpEAEeEHqQg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.60.0.tgz", - "integrity": "sha512-08pO8GFPEIz2zquKDGteBZDNmwketdgH8hTe9rVYgW9kCJXq1Psj3wPQGx+VaX4ZJKCfPeoLMYup9+cxHvZyVQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/mysql": "2.15.27" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.60.0.tgz", - "integrity": "sha512-m/5d3bxQALllCzezYDk/6vajh0tj5OijMMvOZGr+qN1NMXm1dzMNwyJ0gNZW7Fo3YFRyj/jJMxIw+W7d525dlw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@opentelemetry/sql-common": "^0.41.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.66.0.tgz", - "integrity": "sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.34.0", - "@opentelemetry/sql-common": "^0.41.2", - "@types/pg": "8.15.6", - "@types/pg-pool": "2.0.7" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pg/node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@opentelemetry/instrumentation-redis": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.62.0.tgz", - "integrity": "sha512-y3pPpot7WzR/8JtHcYlTYsyY8g+pbFhAqbwAuG5bLPnR6v6pt1rQc0DpH0OlGP/9CZbWBP+Zhwp9yFoygf/ZXQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.33.0.tgz", - "integrity": "sha512-Q6WQwAD01MMTub31GlejoiFACYNw26J426wyjvU7by7fDIr2nZXNW4vhTGs7i7F0TnXBO3xN688g1tdUgYwJ5w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/tedious": "^4.0.14" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-undici": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.24.0.tgz", - "integrity": "sha512-oKzZ3uvqP17sV0EsoQcJgjEfIp0kiZRbYu/eD8p13Cbahumf8lb/xpYeNr/hfAJ4owzEtIDcGIjprfLcYbIKBQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.24.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.7.0" - } - }, - "node_modules/@opentelemetry/redis-common": { - "version": "0.38.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", - "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - } - }, "node_modules/@opentelemetry/resources": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", @@ -3202,21 +2739,6 @@ "node": ">=14" } }, - "node_modules/@opentelemetry/sql-common": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", - "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "license": "MIT", @@ -3225,61 +2747,10 @@ "node": ">=14" } }, - "node_modules/@prisma/instrumentation": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.6.0.tgz", - "integrity": "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.207.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.8" - } - }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", - "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@prisma/instrumentation/node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - } - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { @@ -3293,27 +2764,24 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { "version": "1.0.2", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "license": "BSD-3-Clause" @@ -3685,54 +3153,29 @@ "license": "MIT" }, "node_modules/@sentry/core": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.47.0.tgz", - "integrity": "sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA==", + "version": "10.58.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.58.0.tgz", + "integrity": "sha512-bkIbh2c6dzwhrWn/FGWu7j8hf6TAat2XxpkGM91LiN09fLYUXIMwcohVsXqze5l2cq35TnvqmSROAbRNr27GVw==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@sentry/node": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.47.0.tgz", - "integrity": "sha512-R+btqPepv88o635G6HtVewLjqCLUedBg5HBs7Nq1qbbKvyti01uArUF2f+3DsLenk5B9LUNiRlE+frZA44Ahmw==", + "version": "10.58.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.58.0.tgz", + "integrity": "sha512-KICgacBS+I/eWzFlAembutSwFwy0WVSrGp8UMV9n1XZqqu4EBTlALRsbLNlDSv61UgH85L9L3vk91tgq6nJXAA==", "license": "MIT", "dependencies": { - "@fastify/otel": "0.18.0", "@opentelemetry/api": "^1.9.1", - "@opentelemetry/context-async-hooks": "^2.6.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/instrumentation-amqplib": "0.61.0", - "@opentelemetry/instrumentation-connect": "0.57.0", - "@opentelemetry/instrumentation-dataloader": "0.31.0", - "@opentelemetry/instrumentation-express": "0.62.0", - "@opentelemetry/instrumentation-fs": "0.33.0", - "@opentelemetry/instrumentation-generic-pool": "0.57.0", - "@opentelemetry/instrumentation-graphql": "0.62.0", - "@opentelemetry/instrumentation-hapi": "0.60.0", - "@opentelemetry/instrumentation-http": "0.214.0", - "@opentelemetry/instrumentation-ioredis": "0.62.0", - "@opentelemetry/instrumentation-kafkajs": "0.23.0", - "@opentelemetry/instrumentation-knex": "0.58.0", - "@opentelemetry/instrumentation-koa": "0.62.0", - "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", - "@opentelemetry/instrumentation-mongodb": "0.67.0", - "@opentelemetry/instrumentation-mongoose": "0.60.0", - "@opentelemetry/instrumentation-mysql": "0.60.0", - "@opentelemetry/instrumentation-mysql2": "0.60.0", - "@opentelemetry/instrumentation-pg": "0.66.0", - "@opentelemetry/instrumentation-redis": "0.62.0", - "@opentelemetry/instrumentation-tedious": "0.33.0", - "@opentelemetry/instrumentation-undici": "0.24.0", - "@opentelemetry/resources": "^2.6.1", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", - "@prisma/instrumentation": "7.6.0", - "@sentry/core": "10.47.0", - "@sentry/node-core": "10.47.0", - "@sentry/opentelemetry": "10.47.0", + "@sentry/core": "10.58.0", + "@sentry/node-core": "10.58.0", + "@sentry/opentelemetry": "10.58.0", + "@sentry/server-utils": "10.58.0", "import-in-the-middle": "^3.0.0" }, "engines": { @@ -3740,13 +3183,13 @@ } }, "node_modules/@sentry/node-core": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.47.0.tgz", - "integrity": "sha512-qv6LsqHbkQmd0aQEUox/svRSz26J+l4gGjFOUNEay2armZu9XLD+Ct89jpFgZD5oIPNAj2jraodTRqydXiwS5w==", + "version": "10.58.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.58.0.tgz", + "integrity": "sha512-7dTbYuoaSwSmF2GWDl7KK+sXQL8iqaZeZ2I/aFm+SvPZLckZF3OGFb2VsluWsSXQLnxtxPX9QP93viyK+VZsuA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.47.0", - "@sentry/opentelemetry": "10.47.0", + "@sentry/core": "10.58.0", + "@sentry/opentelemetry": "10.58.0", "import-in-the-middle": "^3.0.0" }, "engines": { @@ -3754,11 +3197,9 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", - "@opentelemetry/resources": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" }, @@ -3766,9 +3207,6 @@ "@opentelemetry/api": { "optional": true }, - "@opentelemetry/context-async-hooks": { - "optional": true - }, "@opentelemetry/core": { "optional": true }, @@ -3778,9 +3216,6 @@ "@opentelemetry/instrumentation": { "optional": true }, - "@opentelemetry/resources": { - "optional": true - }, "@opentelemetry/sdk-trace-base": { "optional": true }, @@ -3790,24 +3225,35 @@ } }, "node_modules/@sentry/opentelemetry": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.47.0.tgz", - "integrity": "sha512-f6Hw2lrpCjlOksiosP0Z2jK/+l+21SIdoNglVeG/sttMyx8C8ywONKh0Ha50sFsvB1VaB8n94RKzzf3hkh9V3g==", + "version": "10.58.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.58.0.tgz", + "integrity": "sha512-qKOGVmt02wDaq7E70VekG8Z9XM641trJPoTHSeVUfGaXVcmGc46ZldTNtfWbxJq/8f/fge2pap60gn066ido2Q==", "license": "MIT", "dependencies": { - "@sentry/core": "10.47.0" + "@sentry/core": "10.58.0" }, "engines": { "node": ">=18" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, + "node_modules/@sentry/server-utils": { + "version": "10.58.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.58.0.tgz", + "integrity": "sha512-PywIl2jvl+tO5R4j+n72Lcf3ItanHcaMN/oL1U9ZHE8icaT2zpo2W4uOaslpQeQvqPC24HGZ3BW2etzsCFQbag==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.58.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", @@ -3983,15 +3429,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4039,15 +3476,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mysql": { - "version": "2.15.27", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", - "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -4061,6 +3489,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -4068,15 +3497,6 @@ "pg-types": "^2.2.0" } }, - "node_modules/@types/pg-pool": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", - "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", - "license": "MIT", - "dependencies": { - "@types/pg": "*" - } - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -4131,15 +3551,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/tedious": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", - "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@unblessed/core": { "version": "1.0.0-alpha.23", "license": "MIT", @@ -4162,9 +3573,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", + "integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==", "dev": true, "license": "MIT", "dependencies": { @@ -4186,8 +3597,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" + "@vitest/browser": "3.2.6", + "vitest": "3.2.6" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4359,9 +3770,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -5263,118 +4674,156 @@ } }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "4.1.2", + "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", "tree-kill": "1.2.2", - "yargs": "17.7.2" + "yargs": "18.0.0" }, "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/concurrently/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "node_modules/concurrently/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently/node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/concurrently/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/concurrently/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/concurrently/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/concurrently/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^7.2.0", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/concurrently/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/content-disposition": { @@ -7105,14 +6554,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -7137,12 +6588,6 @@ "node": ">= 0.6" } }, - "node_modules/forwarded-parse": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", - "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", - "license": "MIT" - }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -7412,7 +6857,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7431,9 +6878,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.26", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -7573,9 +7020,9 @@ } }, "node_modules/import-in-the-middle": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.0.tgz", - "integrity": "sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.1.0.tgz", + "integrity": "sha512-c0AeAV8VcwZzfYE7euTZY3H+VXUPMVugiovdosq80lqEXJmOekg3zGUAYg6KImHMaMuBoTUfTv7xNpUFdy0hJA==", "license": "Apache-2.0", "dependencies": { "acorn": "^8.15.0", @@ -7969,9 +7416,19 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -9403,24 +8860,23 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", - "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -9467,9 +8923,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -9954,9 +9410,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { @@ -10665,9 +10121,9 @@ } }, "node_modules/undici": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.1.tgz", - "integrity": "sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -11033,9 +10489,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 843cdbc4f..027fdf47e 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@oclif/core": "^4.8.0", "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "^1.14.25", - "@sentry/node": "^10.39.0", + "@sentry/node": "^10.58.0", "@trpc/client": "^11.10.0", "@trpc/server": "^11.10.0", "@types/archiver": "^7.0.0", @@ -75,9 +75,9 @@ "eta": "^4.5.0", "execa": "^9.6.1", "fastest-levenshtein": "^1.0.16", - "hono": "^4.12.14", + "hono": "^4.12.25", "jira.js": "^5.3.0", - "js-yaml": "^4.1.1", + "js-yaml": "^4.2.0", "llmist": "^16.0.4", "marklassian": "^1.1.0", "open": "^11.0.0", @@ -103,9 +103,9 @@ "@types/pg": "^8.16.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/coverage-v8": "^3.2.6", "commander": "^14.0.2", - "concurrently": "^9.2.1", + "concurrently": "^10.0.3", "drizzle-kit": "^0.31.9", "jsdom": "^28.1.0", "lefthook": "^1.10.10", @@ -139,6 +139,7 @@ "lodash-es": "^4.18.1", "brace-expansion": "^2.0.3", "axios": "^1.15.0", - "protobufjs": "^7.5.8" + "protobufjs": "^7.6.4", + "form-data": "^4.0.6" } } diff --git a/src/agents/contracts/index.ts b/src/agents/contracts/index.ts index e3ff5af59..6a074ed8b 100644 --- a/src/agents/contracts/index.ts +++ b/src/agents/contracts/index.ts @@ -66,6 +66,35 @@ export interface ToolManifestParameter { fileInputAlternative?: string; } +/** + * MNG-1427: a single field inside the `success.data` JSON payload a CLI + * command returns. Mirrors `OutputShapeField` on {@link ToolDefinition} so + * downstream consumers (prompt renderer, generated help, integration tests) + * can read the same shape without depending on `src/gadgets/`. + */ +export interface ToolManifestOutputShapeField { + /** Field key as it appears in `success.data`. */ + name: string; + /** Type description, e.g. `'string'`, `'number'`, or `'"created" | "updated"'`. */ + type: string; + /** Optional human-readable explanation. */ + description?: string; + /** Whether the field may be absent. Defaults to `false`. */ + optional?: boolean; +} + +/** + * MNG-1427: declarative description of the `success.data` payload returned + * by a CLI command. Surfaced on each {@link ToolManifest} so agents can learn + * which JSON keys to parse without running the tool first. + */ +export interface ToolManifestOutputShape { + /** Optional one-line summary of what `success.data` represents. */ + summary?: string; + /** Field-by-field description of `success.data`. */ + fields: ToolManifestOutputShapeField[]; +} + /** * Describes a CASCADE-specific CLI tool available to the agent. */ @@ -83,6 +112,12 @@ export interface ToolManifest { * index with ad-hoc shapes — new code should cast to `ToolManifestParameter`. */ parameters: Record; + /** + * MNG-1427: optional declarative description of the shape of `success.data` + * returned by the CLI command. Populated for mutation commands so agents + * know which JSON fields to parse without inspecting the response prose. + */ + outputShape?: ToolManifestOutputShape; } /** diff --git a/src/agents/prompts/templates/splitting.eta b/src/agents/prompts/templates/splitting.eta index 1058adaec..06d981c24 100644 --- a/src/agents/prompts/templates/splitting.eta +++ b/src/agents/prompts/templates/splitting.eta @@ -110,11 +110,11 @@ You are running in a cloned copy of the project repository. Before creating stor - Use "🔗 Dependencies" as the checklist name - Add each dependency as a checklist item (use <%= it.workItemNoun || 'card' %> title or URL) - Skip this checklist for foundational stories with no dependencies -6. **Post summary comment** using `PostComment` once you've confirmed PR creation: +6. **Post summary comment** using `PostComment` once all story <%= it.workItemNounPlural || 'cards' %> and their required checklists have been created: - Post a comment on the ORIGINAL <%= it.workItemNoun || 'card' %> listing all created stories - Use markdown links: `[Story Title](URL)` for each <%= it.workItemNoun || 'card' %> - See "Summary Comment Format" section below - - Use only real and confirmed PR numbers and URLs - see output from CreatePR + - Use only real story <%= it.workItemNoun || 'card' %> URLs returned by `CreateWorkItem` — never invent IDs or links 7. **Only if blocked**, post a comment using `PostComment`: - Only if there's genuine ambiguity that prevents story creation - Ask ONE specific question, then STOP diff --git a/src/api/routers/auth.ts b/src/api/routers/auth.ts index 52291f040..d951efcc3 100644 --- a/src/api/routers/auth.ts +++ b/src/api/routers/auth.ts @@ -1,4 +1,7 @@ +import bcrypt from 'bcrypt'; +import { z } from 'zod'; import { getOrganization, listAllOrganizations } from '../../db/repositories/settingsRepository.js'; +import { deleteUserSessions, updateUser } from '../../db/repositories/usersRepository.js'; import { protectedProcedure, router } from '../trpc.js'; export const authRouter = router({ @@ -19,4 +22,16 @@ export const authRouter = router({ } return { ...base, availableOrgs: undefined as { id: string; name: string }[] | undefined }; }), + + changePassword: protectedProcedure + .input( + z.object({ + password: z.string().min(12), + }), + ) + .mutation(async ({ ctx, input }) => { + const passwordHash = await bcrypt.hash(input.password, 10); + await updateUser(ctx.user.id, { passwordHash }); + await deleteUserSessions(ctx.user.id, ctx.token || undefined); + }), }); diff --git a/src/api/trpc.ts b/src/api/trpc.ts index 66d4dbdd5..28874b818 100644 --- a/src/api/trpc.ts +++ b/src/api/trpc.ts @@ -12,6 +12,7 @@ export interface TRPCUser { export interface TRPCContext { user: TRPCUser | null; effectiveOrgId: string | null; + token: string | null; } const t = initTRPC.context().create({ @@ -34,7 +35,11 @@ export const protectedProcedure = t.procedure.use(async (opts) => { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return opts.next({ - ctx: { user: opts.ctx.user, effectiveOrgId: opts.ctx.effectiveOrgId }, + ctx: { + user: opts.ctx.user, + effectiveOrgId: opts.ctx.effectiveOrgId, + token: opts.ctx.token, + }, }); }); diff --git a/src/backends/claude-code/models.ts b/src/backends/claude-code/models.ts index 809f6f036..bab70d9f4 100644 --- a/src/backends/claude-code/models.ts +++ b/src/backends/claude-code/models.ts @@ -1,4 +1,6 @@ export const CLAUDE_CODE_MODELS = [ + { value: 'claude-opus-4-8', label: 'Claude Opus 4.8' }, + { value: 'claude-opus-4-8[1m]', label: 'Claude Opus 4.8 (1M context)' }, { value: 'claude-opus-4-7', label: 'Claude Opus 4.7' }, { value: 'claude-opus-4-7[1m]', label: 'Claude Opus 4.7 (1M context)' }, { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, diff --git a/src/backends/shared/nativeToolPrompts.ts b/src/backends/shared/nativeToolPrompts.ts index 7fec53ee0..2fb923553 100644 --- a/src/backends/shared/nativeToolPrompts.ts +++ b/src/backends/shared/nativeToolPrompts.ts @@ -1,3 +1,7 @@ +import type { + ToolManifestOutputShape, + ToolManifestOutputShapeField, +} from '../../agents/contracts/index.js'; import { formatJsonExample, formatShellScalar } from '../../gadgets/shared/cli/shellValues.js'; import type { ContextInjection, ToolManifest } from '../types.js'; import { buildInlineContextSection, offloadLargeContext } from './contextFiles.js'; @@ -166,6 +170,45 @@ function formatParam(key: string, schema: PromptParamSchema): string { return result; } +/** + * MNG-1427: render a tool manifest's `outputShape` as a concise, parseable + * block beneath the command snippet. The intent is to give native-tool agents + * the JSON field contract for `success.data` without forcing them to run the + * tool first or rely on provider docs. + * + * Rendered shape: + * + * **Output shape** (`success.data`): + * + * - `` (``) — + * - `?` (``) — + * + * The renderer is intentionally lossless: every field in the manifest's + * `outputShape.fields` is emitted in declared order. Empty `fields` arrays + * render as a single placeholder so a definition that opted in but forgot to + * populate fields is loudly visible to the maintainer (and to the agent). + */ +function formatOutputShape(shape: ToolManifestOutputShape): string { + let out = '\n**Output shape** (`success.data`):\n'; + if (shape.summary) { + out += `${shape.summary}\n`; + } + if (shape.fields.length === 0) { + out += '- (shape declared but no fields documented)\n'; + return out; + } + for (const field of shape.fields) { + out += `${formatOutputShapeFieldLine(field)}\n`; + } + return out; +} + +function formatOutputShapeFieldLine(field: ToolManifestOutputShapeField): string { + const nameSuffix = field.optional ? '?' : ''; + const head = `- \`${field.name}${nameSuffix}\` (\`${field.type}\`)`; + return field.description ? `${head} — ${field.description}` : head; +} + /** * Build prompt guidance for CASCADE-specific CLI tools. * Native-tool engines invoke these via shell commands. @@ -191,7 +234,15 @@ export function buildToolGuidance(tools: ToolManifest[]): string { guidance += formatParam(key, schema as PromptParamSchema); } - guidance += '\n```\n\n'; + guidance += '\n```\n'; + + // MNG-1427: render the JSON output contract after the command block so + // agents see `success.data` field-list inline with the command. + if (tool.outputShape) { + guidance += formatOutputShape(tool.outputShape); + } + + guidance += '\n'; } return guidance; diff --git a/src/cli/_shared/command-not-found-hook.ts b/src/cli/_shared/command-not-found-hook.ts new file mode 100644 index 000000000..91978ba8c --- /dev/null +++ b/src/cli/_shared/command-not-found-hook.ts @@ -0,0 +1,74 @@ +/** + * oclif `command_not_found` hook for `cascade-tools` (MNG-1442). + * + * When an agent invokes a topic or subcommand that doesn't exist (e.g. + * `cascade-tools sm get-pr-diff` or `cascade-tools pm reaad-work-item`), + * oclif's default behavior is to throw `command not found`, which the + * binary entrypoint terminates with exit code 2. That fallback has no + * structure, no suggestion, and no candidate list — pre-spec-014 ergonomics + * the rest of `cascade-tools` has moved past. + * + * This hook turns command typos into the same structured envelope every + * other `cascade-tools` failure emits (spec 014): JSON on stdout, a one-line + * prose summary on stderr, and a runnable `did you mean` hint when the typo + * is within the Levenshtein budget. Exit code `2` is preserved — that is + * oclif's documented `command_not_found` default and existing consumers + * (including the `bin/cascade-tools.js` catch block) rely on it. + * + * **Hook placement.** This file lives under `src/cli/_shared/` because the + * oclif command-discovery glob in `bin/cascade-tools.js` explicitly excludes + * `**\/_shared/**`. Without that exclusion, a default-exported function in + * a discoverable directory would be loaded as a fake top-level command and + * shadow the hook contract — see `bin/cascade-tools.js` for the glob. + * + * **No static import in the entrypoint.** The hook is wired through + * `pjson.oclif.hooks.command_not_found`, which oclif loads dynamically via + * `loadWithData` only when the hook actually fires. `bin/cascade-tools.js` + * therefore keeps its existing friendly `dist/` missing path intact — if + * `dist/cli/bootstrap.js` is absent, the entrypoint emits the friendly + * stderr explainer and exits 1 before this module is ever resolved. + * + * **Envelope-building delegation.** The pure suggestion logic lives in + * `./commandSuggestions.ts` (MNG-1441) so it can be unit-tested without + * booting oclif or installing this hook. This module is a thin wrapper that + * forwards `{config, id, argv}` into the helper and routes the returned + * envelope options through `emitCliError` with the documented exit-code-2 + * override. + */ + +import type { Hook } from '@oclif/core'; + +import { emitCliError } from '../../gadgets/shared/errorEnvelope.js'; +import { buildUnknownCommandEnvelope, type OclifLikeConfig } from './commandSuggestions.js'; + +/** + * Explicit exit delegate that ignores the input code and exits with `2`. + * + * `emitCliError` always passes `1` to its exit delegate — the spec-014 + * default for every other CLI failure type (`flag-parse`, `runtime`, etc.). + * For `unknown-command` we deliberately diverge: `2` is oclif's + * `command_not_found` default and the cascade-tools entrypoint already + * forwards it through `process.exit(err.oclif.exit)` for unknown commands. + * Keeping the same exit code on the structured-envelope path avoids + * regressing any tooling that branches on the historical 2 vs other codes. + */ +const exitWithCode2: (code: number) => never = () => process.exit(2); + +const commandNotFoundHook: Hook<'command_not_found'> = async (opts) => { + const envelopeOpts = buildUnknownCommandEnvelope({ + // oclif's full Config carries dozens of fields the helper does not + // consume. `OclifLikeConfig` declares the narrow subset (bin, + // commandIDs, pjson.oclif.topics) the helper needs, so a structural + // cast through `unknown` is safe and avoids dragging the full Config + // dependency through the helper module. + config: opts.config as unknown as OclifLikeConfig, + id: opts.id, + argv: opts.argv, + }); + emitCliError({ + ...envelopeOpts, + exit: exitWithCode2, + }); +}; + +export default commandNotFoundHook; diff --git a/src/cli/_shared/commandSuggestions.ts b/src/cli/_shared/commandSuggestions.ts new file mode 100644 index 000000000..df79f891c --- /dev/null +++ b/src/cli/_shared/commandSuggestions.ts @@ -0,0 +1,239 @@ +/** + * Pure helper that builds the `unknown-command` CLI envelope options for a + * typoed `cascade-tools` invocation, given an oclif-like config. The helper + * is intentionally side-effect free — it does NOT install oclif's + * `command_not_found` hook, instantiate provider clients, or load command + * classes — so unit tests can pin the suggestion decisions without booting + * the full CLI surface. + * + * Suggestions are derived strictly from the loaded oclif config: + * + * - **Top-level topics** come from the union of (a) the first segment of + * every entry in `config.commandIDs` and (b) the keys of + * `config.pjson.oclif.topics` (skipping `hidden: true` topics). This makes + * the candidate set match the actual binary surface — the dashboard topic + * is excluded automatically when running under `cascade-tools`, because + * `bin/cascade-tools.js` excludes the dashboard glob from oclif's command + * discovery and overrides `pjson.oclif.topics` to a four-entry whitelist. + * - **Subcommands** for a known topic come from `config.commandIDs` entries + * that start with `:`, taking the next segment as the subcommand + * name. + * + * Hints are formatted with spaces (the cascade-tools topicSeparator), e.g. + * `cascade-tools pm read-work-item`, so the agent can copy-paste them + * directly into the next attempt. + * + * Distance + ratio thresholds are imported from the shared scorer at + * `gadgets/shared/cli/suggestions.ts` (MNG-1440) so command and flag + * suggestions stay calibrated against the same budget. The local + * `suggestClosestViable` variant in this file applies those thresholds + * with closest-VIABLE tie-breaking instead of closest-then-validate — + * see its docstring for why command topics with mixed lengths require + * that adjustment. + */ + +import { distance } from 'fastest-levenshtein'; + +import { + MAX_SUGGESTION_DISTANCE, + MAX_SUGGESTION_RATIO, +} from '../../gadgets/shared/cli/suggestions.js'; +import type { EmitCliErrorOptions } from '../../gadgets/shared/errorEnvelope.js'; + +/** + * Minimal shape of the `@oclif/core` `Config` object this helper needs. + * + * Kept narrow on purpose: declaring the full `Config` type would force unit + * tests to construct (or mock) a value that satisfies dozens of unrelated + * fields. Anything not used by the helper stays off the contract. + */ +export interface OclifLikeConfig { + /** CLI binary name, e.g. `'cascade-tools'`. Used to format runnable hints. */ + readonly bin: string; + /** + * All loaded command IDs, with `:` as topic separator. Oclif internally + * normalises every command id to colon-separated regardless of the + * configured `topicSeparator`. + */ + readonly commandIDs: readonly string[]; + readonly pjson: { + readonly oclif?: { + readonly topics?: Readonly< + Record + >; + }; + }; +} + +/** + * Envelope options shape returned by {@link buildUnknownCommandEnvelope}. + * Matches the input contract of `emitCliError` minus the stream/exit hooks + * the helper does not own. + */ +export type UnknownCommandEnvelopeOptions = Omit; + +export interface BuildUnknownCommandEnvelopeInput { + readonly config: OclifLikeConfig; + /** + * Oclif-normalised command id with `:` separators (e.g. + * `'pm:reaad-work-item'`). This is the `id` field the + * `command_not_found` hook receives. + */ + readonly id: string; + /** + * Optional positional argv slice oclif passes alongside `id`. Reserved + * for future use; the helper does not consume it today. + */ + readonly argv?: readonly string[]; +} + +const HINT_SEPARATOR = ' '; + +/** Extract the deduped, sorted union of topic names available to the CLI. */ +function collectTopics(config: OclifLikeConfig): string[] { + const set = new Set(); + for (const id of config.commandIDs) { + const topic = id.split(':')[0]; + if (topic) set.add(topic); + } + const explicit = config.pjson.oclif?.topics; + if (explicit) { + for (const [name, value] of Object.entries(explicit)) { + if (value?.hidden) continue; + set.add(name); + } + } + return [...set].sort(); +} + +/** Extract the deduped, sorted list of subcommand names under `topic`. */ +function collectSubcommandsForTopic(config: OclifLikeConfig, topic: string): string[] { + const prefix = `${topic}:`; + const set = new Set(); + for (const id of config.commandIDs) { + if (!id.startsWith(prefix)) continue; + const rest = id.slice(prefix.length); + if (!rest) continue; + const sub = rest.split(':')[0]; + if (sub) set.add(sub); + } + return [...set].sort(); +} + +/** Join `[bin, ...segments]` with the cascade-tools topicSeparator. */ +function formatCommand(bin: string, segments: readonly string[]): string { + return [bin, ...segments].join(HINT_SEPARATOR); +} + +/** Format the comma-separated `expected` field shown to the agent. */ +function formatExpected(candidates: readonly string[]): string { + return candidates.join(', '); +} + +/** + * Return the closest VIABLE candidate to `unknown` — viable meaning the + * candidate passes both the distance budget ({@link MAX_SUGGESTION_DISTANCE}) + * and the ratio budget ({@link MAX_SUGGESTION_RATIO}) defined by the shared + * scorer at `gadgets/shared/cli/suggestions.ts` (MNG-1440). + * + * The shared `suggestClosest` picks the first equidistant candidate by + * iteration order and THEN validates the budget. That contract suits flag + * suggestions (homogeneous lengths, alias→canonical fan-in) but misfires + * for command topics with mixed lengths: e.g. `sm` typo ties `pm` and + * `scm` at distance 1; `pm` iterates first then fails the 0.4 ratio gate + * (1 / max(2,2) = 0.5), suppressing the viable `scm` suggestion. + * + * This local variant evaluates eligibility on every candidate and picks + * the closest one that survives. Ties are still broken by input order + * among viable candidates, matching the shared scorer's documented + * stability guarantee. + */ +function suggestClosestViable(unknown: string, candidates: readonly string[]): string | null { + let best: { name: string; dist: number } | null = null; + for (const candidate of candidates) { + const d = distance(unknown, candidate); + if (d > MAX_SUGGESTION_DISTANCE) continue; + const target = Math.max(unknown.length, candidate.length); + if (target > 0 && d / target > MAX_SUGGESTION_RATIO) continue; + if (best === null || d < best.dist) { + best = { name: candidate, dist: d }; + } + } + return best?.name ?? null; +} + +/** + * Build the `unknown-command` envelope options for an unknown `cascade-tools` + * invocation. Two cases: + * + * 1. **Unknown top-level topic** (e.g. `sm get-pr-diff`) — compare the + * first segment against the union topic set; if a close match is found, + * hint with the corrected topic and the user's preserved trailing + * segments. + * 2. **Known topic, unknown subcommand** (e.g. `pm reaad-work-item`) — + * compare the trailing segment against the topic's subcommand set; if a + * close match is found, hint with ` `. + * + * When no candidate is within the suggestion budget (distance `<= 2`, + * ratio `<= 0.4`), the envelope omits the `hint` field but still carries + * the `expected` candidate list so the agent can self-correct from a + * concrete enumeration. + */ +export function buildUnknownCommandEnvelope( + input: BuildUnknownCommandEnvelopeInput, +): UnknownCommandEnvelopeOptions { + const { config, id } = input; + const segments = id.split(':').filter((seg) => seg.length > 0); + const topicSegment = segments[0] ?? ''; + const rest = segments.slice(1); + const got = segments.join(HINT_SEPARATOR); + + const topics = collectTopics(config); + + // Case A: unknown top-level topic. Suggest closest topic, preserve rest. + if (!topics.includes(topicSegment)) { + const closest = suggestClosestViable(topicSegment, topics); + const envelope: UnknownCommandEnvelopeOptions = { + type: 'unknown-command', + message: `Unknown command '${formatCommand(config.bin, segments)}'`, + got, + expected: formatExpected(topics), + }; + if (closest) { + envelope.hint = `did you mean '${formatCommand(config.bin, [closest, ...rest])}'?`; + } + return envelope; + } + + // Case B: known topic, no subcommand. Surface the topic's subcommands so + // the agent has a runnable enumeration. oclif normally routes bare-topic + // invocations to topic-help before command_not_found fires, but the + // helper handles this case defensively for direct callers. + const subcommands = collectSubcommandsForTopic(config, topicSegment); + if (rest.length === 0) { + return { + type: 'unknown-command', + message: `Unknown command '${formatCommand(config.bin, segments)}'`, + got, + expected: formatExpected(subcommands), + }; + } + + // Case C: known topic, unknown subcommand. Compare the trailing segment + // only (cascade-tools commands are flat: `:` with no nested + // topics). Preserve any further trailing segments verbatim in the hint + // for forward compatibility. + const unknownSub = rest[0]; + const trailing = rest.slice(1); + const closest = suggestClosestViable(unknownSub, subcommands); + const envelope: UnknownCommandEnvelopeOptions = { + type: 'unknown-command', + message: `Unknown command '${formatCommand(config.bin, segments)}'`, + got, + expected: formatExpected(subcommands), + }; + if (closest) { + envelope.hint = `did you mean '${formatCommand(config.bin, [topicSegment, closest, ...trailing])}'?`; + } + return envelope; +} diff --git a/src/config/rateLimits.ts b/src/config/rateLimits.ts index 5ab494d42..91988d999 100644 --- a/src/config/rateLimits.ts +++ b/src/config/rateLimits.ts @@ -19,6 +19,13 @@ export const MODEL_RATE_LIMITS: ModelRateLimits = { safetyMargin: 0.8, // Conservative - start throttling at 80% }, + // Claude Opus 4.8 (Tier 1: 50 RPM, 10K TPM — Opus is throttle-sensitive) + 'anthropic:claude-opus-4-8': { + requestsPerMinute: 50, + tokensPerMinute: 10_000, + safetyMargin: 0.85, + }, + // Claude Opus 4.7 (Tier 1: 50 RPM, 10K TPM — Opus is throttle-sensitive) 'anthropic:claude-opus-4-7': { requestsPerMinute: 50, diff --git a/src/dashboard.ts b/src/dashboard.ts index 20f78948b..148dec2c7 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -77,10 +77,10 @@ app.use( endpoint: '/trpc', router: appRouter, createContext: async (_opts, c) => { - const token = getCookie(c, SESSION_COOKIE_NAME); + const token = getCookie(c, SESSION_COOKIE_NAME) || null; const user = token ? await resolveUserFromSession(token) : null; const effectiveOrgId = await computeEffectiveOrgId(user, c.req.header('x-org-context')); - return { user, effectiveOrgId }; + return { user, effectiveOrgId, token }; }, }), ); diff --git a/src/db/client.ts b/src/db/client.ts index 1facb85d5..54e989b3b 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -1,7 +1,7 @@ -import fs, { existsSync } from 'node:fs'; import { drizzle } from 'drizzle-orm/node-postgres'; import pg from 'pg'; import * as schema from './schema/index.js'; +import { resolveDbSslConfig } from './ssl-config.js'; // ============================================================================ // DatabaseContext class @@ -47,7 +47,7 @@ export class DatabaseContext { export function createDatabaseContext(): DatabaseContext { return new DatabaseContext({ connectionString: getDatabaseUrl(), - ssl: getSslConfig(), + ssl: resolveDbSslConfig(), }); } @@ -134,18 +134,3 @@ function getDatabaseUrl(): string { throw new Error('DATABASE_URL or CASCADE_POSTGRES_HOST must be set'); } - -function getSslConfig(): false | { rejectUnauthorized: boolean; ca?: string } { - if (process.env.DATABASE_SSL === 'false') { - return false; - } - const sslConfig: { rejectUnauthorized: boolean; ca?: string } = { rejectUnauthorized: true }; - if (process.env.DATABASE_CA_CERT) { - const certPath = process.env.DATABASE_CA_CERT; - if (!existsSync(certPath)) { - throw new Error(`DATABASE_CA_CERT file not found: ${certPath}`); - } - sslConfig.ca = fs.readFileSync(certPath, 'utf8'); - } - return sslConfig; -} diff --git a/src/db/ssl-config.ts b/src/db/ssl-config.ts new file mode 100644 index 000000000..a6ab0c80e --- /dev/null +++ b/src/db/ssl-config.ts @@ -0,0 +1,63 @@ +import fs, { existsSync } from 'node:fs'; + +export type DbSslConfig = false | { rejectUnauthorized: boolean; ca?: string }; + +/** + * Resolve node-postgres SSL options from DATABASE_SSL / DATABASE_CA_CERT. + * + * Modes (DATABASE_SSL): + * - `'false'` → no TLS (local dev, or networks that terminate TLS elsewhere). + * - `'no-verify'` → TLS, but skip certificate verification. Required for managed + * Postgres that REQUIRES TLS yet presents a self-signed / private-CA + * certificate (e.g. Supabase's connection pooler). A CA file is not a + * workable alternative there: spawned worker containers receive the + * `DATABASE_*` env but no mounted cert file (see + * `src/router/worker-container-launcher.ts`), so `DATABASE_CA_CERT` + * would point at a nonexistent path inside every worker. + * - anything else → TLS WITH verification, plus an optional CA from `DATABASE_CA_CERT`. + * + * Single source of truth shared by the runtime DB client (`src/db/client.ts`) and + * drizzle-kit migrations (`drizzle.config.ts`) so both connect identically. + */ +export function resolveDbSslConfig(): DbSslConfig { + if (process.env.DATABASE_SSL === 'false') { + return false; + } + if (process.env.DATABASE_SSL === 'no-verify') { + return { rejectUnauthorized: false }; + } + const sslConfig: { rejectUnauthorized: boolean; ca?: string } = { rejectUnauthorized: true }; + if (process.env.DATABASE_CA_CERT) { + const certPath = process.env.DATABASE_CA_CERT; + if (!existsSync(certPath)) { + throw new Error(`DATABASE_CA_CERT file not found: ${certPath}`); + } + sslConfig.ca = fs.readFileSync(certPath, 'utf8'); + } + return sslConfig; +} + +/** + * Encode the `DATABASE_SSL` intent as a libpq `sslmode` query param on a connection URL. + * + * Needed for **drizzle-kit migrations only**: drizzle-kit connects via the `url` in + * `drizzle.config.ts` but ignores a `dbCredentials.ssl` object when a `url` is set, so + * `resolveDbSslConfig()` can't reach it — the SSL mode has to live in the connection + * string instead. (The runtime client and the data-migration tools pass the resolved + * `ssl` object directly, where the object form is honored.) + * + * - `DATABASE_SSL=no-verify` → append `sslmode=no-verify` (TLS, skip cert verification) + * - `DATABASE_SSL=false` → append `sslmode=disable` (no TLS) + * - otherwise → URL unchanged (driver default; verification not forced + * here to preserve existing local-dev behavior) + * + * No-ops on an empty URL or one that already pins an `sslmode`. + */ +export function applyDbSslModeToUrl(url: string): string { + const mode = process.env.DATABASE_SSL; + const sslmode = mode === 'false' ? 'disable' : mode === 'no-verify' ? 'no-verify' : undefined; + if (!url || !sslmode || /[?&]sslmode=/.test(url)) { + return url; + } + return `${url}${url.includes('?') ? '&' : '?'}sslmode=${sslmode}`; +} diff --git a/src/gadgets/README.md b/src/gadgets/README.md index 976612e0b..2b1351366 100644 --- a/src/gadgets/README.md +++ b/src/gadgets/README.md @@ -116,11 +116,11 @@ examples: [ ## The error envelope -Every cascade-tools failure — flag parse, JSON parse, missing-required, enum-mismatch, unknown-flag, auth, runtime — emits through the shared `emitCliError` helper: +Every cascade-tools failure — flag parse, JSON parse, missing-required, enum-mismatch, unknown-flag, unknown-command, auth, runtime — emits through the shared `emitCliError` helper: - **Structured JSON on stdout** (`{ "success": false, "error": {...} }`) so agents parse a single stable surface. - **One-line prose summary on stderr** so humans running the CLI directly get a readable error without piping through `jq`. -- **Exit code 1.** +- **Exit code 1** for every type except `unknown-command`, which preserves oclif's historical `command_not_found` exit code **2** (see "Mistyped commands" below). The envelope shape is part of the cascade-tools contract. Renaming fields is a breaking change — agents rely on `error.type` / `error.flag` / `error.hint` to self-correct on the next attempt. @@ -128,12 +128,12 @@ Envelope fields: | field | when populated | |---|---| -| `type` | always; one of `flag-parse` / `json-parse` / `missing-required` / `enum-mismatch` / `unknown-flag` / `auth` / `runtime` | +| `type` | always; one of `flag-parse` / `json-parse` / `missing-required` / `enum-mismatch` / `unknown-flag` / `unknown-command` / `auth` / `runtime` | | `flag` | for flag-scoped failures | | `message` | always; human-readable | -| `got` | the offending input, truncated to ~80 chars | -| `expected` | shape fragment (from `example` when available, else `describe`) | -| `hint` | an action the agent can take (e.g. `did you mean --comments?`, `use --comments-file `) | +| `got` | the offending input, truncated to ~80 chars (for `unknown-command`, the typed command in space-separated form, e.g. `pm reaad-work-item`) | +| `expected` | shape fragment (from `example` when available, else `describe`; for `unknown-command`, the comma-separated candidate list — topics for top-level typos, subcommands for known-topic typos) | +| `hint` | an action the agent can take (e.g. `did you mean --comments?`, `use --comments-file `, `did you mean 'cascade-tools scm get-pr-diff'?`) | | `example` | runnable invocation, when known | You do not call `emitCliError` directly. The shared factory routes every failure through it automatically — your job is to make the declarative metadata (describe text, examples, aliases, file alternatives) rich enough that the auto-generated envelope is actually useful. @@ -142,6 +142,82 @@ Core gadget functions must throw for fatal runtime/API/provider failures. Do not --- +## Mutation result contract (MNG-1422 → MNG-1428) + +Every PM mutation core and the SCM PR comment/reply/update/review mutation cores covered by MNG-1428 return structured objects — never prose. The CLI factory serialises those objects verbatim into the `{ "success": true, "data": {...} }` stdout envelope, so consumers (downstream agents, sidecar tooling, review/respond workflows) can read structured keys without regex'ing sentence fragments. Mutation outcomes use the shared shapes declared in `src/gadgets/pm/core/mutationResults.ts` and `src/gadgets/github/core/mutationResults.ts`. + +### Mutation identity and status fields + +| Field | Meaning | +|---|---| +| `status` | The MUTATION OUTCOME — `"created"` / `"updated"` / `"moved"` / `"noop"` / `"aborted"` / `"deleted"` (PM) or `"ok"` / `"no-op"` / `"aborted"` (SCM). Branch on this, not on prose. | +| `updatedAt` | ISO 8601 timestamp string. It is always present and parseable; the source varies by mutation and fallback path. | + +Identity and URL fields are mutation-specific: + +- Work-item and comment mutations expose `id` plus their canonical resource URL (`url` or, for PM comments, `workItemUrl`). +- `AddChecklist` exposes `checklistId` and `workItemUrl`, plus `itemIds` / `itemCount`. +- `PMUpdateChecklistItem` and `PMDeleteChecklistItem` expose `checkItemId` and `workItemUrl`. +- Targeted SCM PR comment/reply/update/review mutations expose `id`, `url`, and the parent PR context: `repoFullName` (e.g. `"acme/myapp"`) and `prNumber` (or `number | null` for the rare issue-only `UpdatePRComment` case). `CreatePRReview` extends that shape with `reviewUrl`, `event`, `submittedAt`, and `inlineCommentCount`. +- `CreatePR` is also a structured SCM mutation, but it is outside MNG-1428's shared `status` / `updatedAt` / `id` / `url` contract and keeps its existing shape: `prNumber`, `prUrl`, `repoFullName`, and `alreadyExisted` plus optional commit/push details. + +### `status` vs `workflowStatus` naming — do not conflate + +`status` is reserved for the **mutation outcome** alone. The PM provider's **workflow state** (e.g. Linear's "In Progress", a Trello list name, a JIRA status) lives on its own keys: + +- `workflowStatus` (string, optional) — human-readable workflow state name. +- `workflowStatusId` (string, optional) — provider-native ID (Linear state UUID, Trello list ID). +- `previousStatus` / `previousStatusId` on `MoveWorkItem` — the work item's pre-move workflow state read back from the provider on the guarded path. + +A historical mix-up between the two surfaces cost ~2½ minutes of agent time once (prod run `5d993b04`) when an agent treated a Trello list name surfaced through a `status` key as a mutation outcome. The dual-key naming is now load-bearing — keep mutation outcomes and workflow states on separate fields. + +### Fatal failures throw — no prose sentinels + +Mutation cores propagate runtime / API / provider errors as thrown exceptions. The shared `createCLICommand()` factory wraps them in the spec-014 runtime envelope: + +```json +{ "success": false, "error": { "type": "runtime", "message": "Provider 422" } } +``` + +Do not return strings like `"Error creating work item: ..."` from a mutation core. The CLI cannot distinguish a sentinel-string return from a successful `data` payload, so the envelope would say `success: true` and the agent would silently mis-act on the prose. + +The only exceptions are intentional non-fatal outcomes that are part of the contract — e.g. `MoveWorkItem` returning `status: "noop"` when the work item is already in the destination, or `ReportFriction` returning `status: "queued_slot_missing"` when the friction slot isn't configured. These are structured returns, not prose sentinels. + +### Timestamp fallback semantics + +The stable contract is that `updatedAt` is present and parseable. Its source varies: + +- `okResult(providerTs)` still rejects empty timestamps, so call sites that use the shared success helper must provide a timestamp. +- Some successful PM writes synthesise timestamps today: `PostComment` uses `currentTimestamp()` for its `created` / `updated` outcomes, and `MoveWorkItem` can fall back through `pickTimestamp(undefined)` for `moved`. +- `"noop"` / `"aborted"` outcomes synthesise via `currentTimestamp()` because no provider write happened. The synthetic "now" reflects when the gadget evaluated the guard, not a provider write. +- Read-back failures after a successful checklist mutation fall back to a synthesised URL + timestamp inside `readWorkItemContext` rather than masking the mutation success and risking an idempotency retry storm (especially on Trello's native checklists, where retries duplicate rows). + +### Focused verification command (MNG-1428) + +The regression coverage for this contract lives in three test files. To re-run them in isolation: + +```bash +npx vitest run --project unit-core \ + tests/unit/cli/pm/pm-commands.test.ts \ + tests/unit/cli/scm/scm-commands.test.ts \ + tests/unit/gadgets/pm/definitions.test.ts \ + tests/unit/gadgets/github/definitions.test.ts +``` + +Each suite parses the CLI stdout envelope and asserts `success.data.status`, parseable `success.data.updatedAt`, and the mutation-specific identity/URL fields (`id` / `url`, `workItemUrl`, `checklistId`, or `checkItemId` as applicable, plus `repoFullName` / `prNumber` for targeted SCM PR comment/reply/update/review mutations). The suites also pin the runtime envelope shape for thrown core failures. The output-shape tests in the gadget-definition suites pin the `status` vs `workflowStatus` split as well. + +The full pre-PR gate is unchanged: + +```bash +npm run lint # biome check (also via lint:fix during iteration) +npm run typecheck # tsc --noEmit +npm test # all four unit projects +``` + +Changed surfaces touched by this contract: PM mutation cores under `src/gadgets/pm/core/`, targeted SCM PR comment/reply/update/review mutation cores under `src/gadgets/github/core/`, the matching CLI commands under `src/cli/pm/` and `src/cli/scm/`, and the `outputShape` blocks on the matching `ToolDefinition`s in `src/gadgets/{pm,github}/definitions.ts`. `CreatePR` remains documented and tested as its own structured mutation shape. + +--- + ## Shared CLI helper layout `createCLICommand()` is intentionally the stable public facade for command files under `src/cli/**`. Its implementation delegates to focused helpers under `src/gadgets/shared/cli/`: @@ -179,7 +255,26 @@ If you find yourself opening one of those files, stop — the right fix is almos The factory intercepts oclif's `NonExistentFlagsError`, runs a Levenshtein match against every declared canonical flag name + alias, and surfaces the closest canonical name as `error.hint`. No gadget work required — just declare your flags truthfully. -Two tuning constants live in `src/gadgets/shared/cli/parseErrors.ts`: `MAX_FLAG_SUGGESTION_DISTANCE` (default 2) and `MAX_FLAG_SUGGESTION_RATIO` (default 0.4). Wildly-off mistypes get no suggestion rather than a misleading one. +Two tuning constants live in `src/gadgets/shared/cli/suggestions.ts` (MNG-1440 shared helper): `MAX_SUGGESTION_DISTANCE` (default 2) and `MAX_SUGGESTION_RATIO` (default 0.4). Wildly-off mistypes get no suggestion rather than a misleading one. The same constants gate the command-typo path described below — flag and command suggestions stay calibrated against one shared budget. + +--- + +## Mistyped commands → "did you mean" (MNG-1442) + +`cascade-tools` registers an oclif `command_not_found` hook that turns typoed topics or subcommands into the same structured envelope every other failure emits — instead of oclif's bare `command not found` message. Two cases: + +- **Unknown top-level topic** (e.g. `cascade-tools sm get-pr-diff`) — `expected` carries the topic enumeration, and `hint` carries the closest viable topic with the user's trailing segments preserved (`did you mean 'cascade-tools scm get-pr-diff'?`). +- **Known topic, unknown subcommand** (e.g. `cascade-tools pm reaad-work-item`) — `expected` carries the topic's subcommand enumeration, and `hint` carries the closest viable subcommand (`did you mean 'cascade-tools pm read-work-item'?`). + +Far-away typos (beyond the shared distance / ratio budget) drop the `hint` field but still surface `expected` so the agent has a concrete recovery path. The exit code is **`2`** (oclif's historical `command_not_found` default) — distinct from every other envelope's exit code `1`. Existing exit-code consumers, including the `bin/cascade-tools.js` catch block, see no change. + +**Hook placement.** The hook lives at `src/cli/_shared/command-not-found-hook.ts`, intentionally inside `_shared/` because the oclif command-discovery glob in `bin/cascade-tools.js` excludes `**/_shared/**` — without that exclusion, a default-exported function in a discoverable directory would be loaded as a fake top-level command. The hook is wired via `pjson.oclif.hooks.command_not_found` so oclif loads it dynamically with `loadWithData` only when the hook actually fires; the entrypoint never statically imports it, which preserves the existing friendly `dist/cli/bootstrap.js` missing path. + +**Pure envelope builder.** The suggestion logic lives in `src/cli/_shared/commandSuggestions.ts` (MNG-1441), which is side-effect free and unit-tested directly without booting oclif. The hook is a thin oclif-side wrapper that forwards `{config, id, argv}` into the helper and routes the envelope options through `emitCliError` with an explicit exit-code-2 delegate. + +**Suggestion-source contract.** Candidates come strictly from the loaded oclif config (`config.commandIDs` plus `pjson.oclif.topics`, skipping `hidden` topics). The `cascade-tools` binary uses a separate oclif config that excludes the dashboard topic from its discovery glob, so dashboard commands are never suggested for `cascade-tools` typos even if they would be within edit distance. + +No gadget work required — declaring your command and topics in the standard oclif config is everything. --- diff --git a/src/gadgets/github/PostPRComment.ts b/src/gadgets/github/PostPRComment.ts index 1da480aaf..8638a0f1c 100644 --- a/src/gadgets/github/PostPRComment.ts +++ b/src/gadgets/github/PostPRComment.ts @@ -1,12 +1,18 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { postPRComment } from './core/postPRComment.js'; import { postPRCommentDef } from './definitions.js'; export const PostPRComment = createGadgetClass(postPRCommentDef, async (params) => { - return postPRComment( - params.owner as string, - params.repo as string, - params.prNumber as number, - params.body as string, - ); + try { + const result = await postPRComment( + params.owner as string, + params.repo as string, + params.prNumber as number, + params.body as string, + ); + return `Comment posted (id: ${result.id}): ${result.url ?? ''}`; + } catch (error) { + return formatGadgetError('posting PR comment', error); + } }); diff --git a/src/gadgets/github/ReplyToReviewComment.ts b/src/gadgets/github/ReplyToReviewComment.ts index 72eaf9a03..d49bf2e55 100644 --- a/src/gadgets/github/ReplyToReviewComment.ts +++ b/src/gadgets/github/ReplyToReviewComment.ts @@ -1,13 +1,19 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { replyToReviewComment } from './core/replyToReviewComment.js'; import { replyToReviewCommentDef } from './definitions.js'; export const ReplyToReviewComment = createGadgetClass(replyToReviewCommentDef, async (params) => { - return replyToReviewComment( - params.owner as string, - params.repo as string, - params.prNumber as number, - params.commentId as number, - params.body as string, - ); + try { + const result = await replyToReviewComment( + params.owner as string, + params.repo as string, + params.prNumber as number, + params.commentId as number, + params.body as string, + ); + return `Reply posted successfully: ${result.url ?? ''}`; + } catch (error) { + return formatGadgetError('replying to comment', error); + } }); diff --git a/src/gadgets/github/UpdatePRComment.ts b/src/gadgets/github/UpdatePRComment.ts index e500cb28c..68d8e9b80 100644 --- a/src/gadgets/github/UpdatePRComment.ts +++ b/src/gadgets/github/UpdatePRComment.ts @@ -1,12 +1,18 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { updatePRComment } from './core/updatePRComment.js'; import { updatePRCommentDef } from './definitions.js'; export const UpdatePRComment = createGadgetClass(updatePRCommentDef, async (params) => { - return updatePRComment( - params.owner as string, - params.repo as string, - params.commentId as number, - params.body as string, - ); + try { + const result = await updatePRComment( + params.owner as string, + params.repo as string, + params.commentId as number, + params.body as string, + ); + return `Comment updated (id: ${result.id}): ${result.url ?? ''}`; + } catch (error) { + return formatGadgetError('updating PR comment', error); + } }); diff --git a/src/gadgets/github/core/createPRReview.ts b/src/gadgets/github/core/createPRReview.ts index b76ef5e64..3d752b92a 100644 --- a/src/gadgets/github/core/createPRReview.ts +++ b/src/gadgets/github/core/createPRReview.ts @@ -1,5 +1,6 @@ import { githubClient } from '../../../github/client.js'; import { buildRunLinkFooterFromEnv } from '../../../utils/runLink.js'; +import { type GitHubMutationResult, okResult, pickTimestamp } from './mutationResults.js'; export interface CreatePRReviewParams { owner: string; @@ -10,9 +11,28 @@ export interface CreatePRReviewParams { comments?: Array<{ path: string; line?: number; body: string }>; } -export interface CreatePRReviewResult { +/** + * Structured result returned by `createPRReview`. Extends + * `GitHubMutationResult` with review-specific context — `reviewUrl` (alias of + * `url` preserved for back-compat with the existing sidecar shape and + * `recordReviewSubmission` callers), `event` (the requested action, + * `APPROVE` / `REQUEST_CHANGES` / `COMMENT`), the PR identity, the + * `submittedAt` timestamp, and the inline-comment count (zero when the + * caller didn't pass any inline comments). + * + * Failures throw (no prose sentinels). The CLI factory wraps thrown errors in + * the spec-014 `runtime` envelope; the gadget wrapper formats them for the + * agent tool-result channel. + */ +export interface CreatePRReviewResult extends GitHubMutationResult { + /** Alias of `url`, retained because existing sidecar/session-state code reads `reviewUrl`. */ reviewUrl: string; - event: string; + event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT'; + repoFullName: string; + prNumber: number; + /** GitHub-supplied `submitted_at`. Provider-supplied for submitted reviews. */ + submittedAt: string; + inlineCommentCount: number; } export async function createPRReview(params: CreatePRReviewParams): Promise { @@ -27,5 +47,21 @@ export async function createPRReview(params: CreatePRReviewParams): Promise 0) { + return providerTimestamp; + } + return currentTimestamp(); +} + +function requireProviderTimestamp(updatedAt: string): string { + if (typeof updatedAt !== 'string' || updatedAt.length === 0) { + throw new TypeError('okResult requires a GitHub-supplied updatedAt timestamp'); + } + return updatedAt; +} + +/** + * Build an `'ok'` mutation result. The caller must pass the GitHub response's + * `updated_at` (or equivalent) so successful results never fabricate resource + * timestamps. + */ +export function okResult(args: { + id: string | number; + updatedAt: string; + url?: string; + message?: string; +}): GitHubMutationResult { + const result: GitHubMutationResult = { + id: String(args.id), + status: 'ok', + updatedAt: requireProviderTimestamp(args.updatedAt), + }; + if (args.url) result.url = args.url; + if (args.message) result.message = args.message; + return result; +} + +/** + * Build a `'no-op'` mutation result. Used when the gadget detects the + * desired state already holds (e.g. createPR finds an existing PR for the + * branch). The timestamp is synthesised because no GitHub write occurred. + */ +export function noOpResult(args: { + id: string | number; + url?: string; + message?: string; +}): GitHubMutationResult { + const result: GitHubMutationResult = { + id: String(args.id), + status: 'no-op', + updatedAt: currentTimestamp(), + }; + if (args.url) result.url = args.url; + if (args.message) result.message = args.message; + return result; +} + +/** + * Build an `'aborted'` mutation result. Used when a guard refused to attempt + * the mutation. Timestamp semantics identical to no-op. + */ +export function abortedResult(args: { + id: string | number; + url?: string; + message?: string; +}): GitHubMutationResult { + const result: GitHubMutationResult = { + id: String(args.id), + status: 'aborted', + updatedAt: currentTimestamp(), + }; + if (args.url) result.url = args.url; + if (args.message) result.message = args.message; + return result; +} diff --git a/src/gadgets/github/core/postPRComment.ts b/src/gadgets/github/core/postPRComment.ts index 51cdfa221..431e23d68 100644 --- a/src/gadgets/github/core/postPRComment.ts +++ b/src/gadgets/github/core/postPRComment.ts @@ -1,19 +1,37 @@ import { githubClient } from '../../../github/client.js'; import { buildRunLinkFooterFromEnv } from '../../../utils/runLink.js'; +import { type GitHubMutationResult, okResult } from './mutationResults.js'; + +/** + * Structured result returned by `postPRComment`. Extends `GitHubMutationResult` + * with the PR identity (`repoFullName`, `prNumber`) so structured-output consumers + * (CLI sidecars, downstream review/respond flows) can correlate the comment + * back to its parent PR without re-parsing the URL. + * + * Failures throw — there are NO prose sentinel strings here (MNG-1425). + * `createCLICommand` wraps thrown errors in the spec-014 `runtime` envelope. + */ +export interface PostPRCommentResult extends GitHubMutationResult { + repoFullName: string; + prNumber: number; +} export async function postPRComment( owner: string, repo: string, prNumber: number, body: string, -): Promise { - try { - const runLinkFooter = buildRunLinkFooterFromEnv(); - const fullBody = runLinkFooter ? body + runLinkFooter : body; - const result = await githubClient.createPRComment(owner, repo, prNumber, fullBody); - return `Comment posted (id: ${result.id}): ${result.htmlUrl}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error posting PR comment: ${message}`; - } +): Promise { + const runLinkFooter = buildRunLinkFooterFromEnv(); + const fullBody = runLinkFooter ? body + runLinkFooter : body; + const created = await githubClient.createPRComment(owner, repo, prNumber, fullBody); + return { + ...okResult({ + id: created.id, + updatedAt: created.updatedAt, + url: created.htmlUrl, + }), + repoFullName: `${owner}/${repo}`, + prNumber, + }; } diff --git a/src/gadgets/github/core/replyToReviewComment.ts b/src/gadgets/github/core/replyToReviewComment.ts index 031490343..c301748bf 100644 --- a/src/gadgets/github/core/replyToReviewComment.ts +++ b/src/gadgets/github/core/replyToReviewComment.ts @@ -1,4 +1,20 @@ import { githubClient } from '../../../github/client.js'; +import { type GitHubMutationResult, okResult, pickTimestamp } from './mutationResults.js'; + +/** + * Structured result returned by `replyToReviewComment`. Extends + * `GitHubMutationResult` with the parent-PR identity (`repoFullName`, + * `prNumber`). The reply's `updatedAt` is preferred from GitHub's response; + * we fall back to `createdAt` because some Octokit response shapes omit + * `updated_at` on freshly-created review-comment replies. + * + * Failures throw (no prose sentinels). The CLI factory wraps thrown errors in + * the spec-014 `runtime` envelope. + */ +export interface ReplyToReviewCommentResult extends GitHubMutationResult { + repoFullName: string; + prNumber: number; +} export async function replyToReviewComment( owner: string, @@ -6,12 +22,15 @@ export async function replyToReviewComment( prNumber: number, commentId: number, body: string, -): Promise { - try { - const reply = await githubClient.replyToReviewComment(owner, repo, prNumber, commentId, body); - return `Reply posted successfully: ${reply.htmlUrl}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error replying to comment: ${message}`; - } +): Promise { + const reply = await githubClient.replyToReviewComment(owner, repo, prNumber, commentId, body); + return { + ...okResult({ + id: reply.id, + updatedAt: pickTimestamp(reply.updatedAt ?? reply.createdAt), + url: reply.htmlUrl, + }), + repoFullName: `${owner}/${repo}`, + prNumber, + }; } diff --git a/src/gadgets/github/core/updatePRComment.ts b/src/gadgets/github/core/updatePRComment.ts index 472b65a4b..56b6fbe76 100644 --- a/src/gadgets/github/core/updatePRComment.ts +++ b/src/gadgets/github/core/updatePRComment.ts @@ -1,16 +1,44 @@ import { githubClient } from '../../../github/client.js'; +import { type GitHubMutationResult, okResult } from './mutationResults.js'; + +/** + * Structured result returned by `updatePRComment`. Extends + * `GitHubMutationResult` with the parent-PR identity (`repoFullName`, + * `prNumber`). `prNumber` is included for parity with `postPRComment` and + * `replyToReviewComment`; we recover it from the comment URL because the + * issue-comment update API doesn't echo the issue number on the response. + * + * Failures throw (no prose sentinels). The CLI factory wraps thrown errors in + * the spec-014 `runtime` envelope. + */ +export interface UpdatePRCommentResult extends GitHubMutationResult { + repoFullName: string; + prNumber: number | null; +} + +const PR_NUMBER_FROM_HTML_URL_REGEX = /\/pull\/(\d+)/; + +function extractPRNumberFromHtmlUrl(htmlUrl: string): number | null { + const match = htmlUrl.match(PR_NUMBER_FROM_HTML_URL_REGEX); + if (!match) return null; + const n = Number.parseInt(match[1], 10); + return Number.isFinite(n) ? n : null; +} export async function updatePRComment( owner: string, repo: string, commentId: number, body: string, -): Promise { - try { - const result = await githubClient.updatePRComment(owner, repo, commentId, body); - return `Comment updated (id: ${result.id}): ${result.htmlUrl}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error updating PR comment: ${message}`; - } +): Promise { + const updated = await githubClient.updatePRComment(owner, repo, commentId, body); + return { + ...okResult({ + id: updated.id, + updatedAt: updated.updatedAt, + url: updated.htmlUrl, + }), + repoFullName: `${owner}/${repo}`, + prNumber: extractPRNumberFromHtmlUrl(updated.htmlUrl), + }; } diff --git a/src/gadgets/github/definitions.ts b/src/gadgets/github/definitions.ts index d2b8df11a..34317b58d 100644 --- a/src/gadgets/github/definitions.ts +++ b/src/gadgets/github/definitions.ts @@ -153,6 +153,32 @@ If hooks fail, the full output will be shown.`, }, ], }, + outputShape: { + summary: 'CreatePR returns the new (or pre-existing) PR identity.', + fields: [ + { name: 'prNumber', type: 'number', description: 'GitHub pull request number.' }, + { name: 'prUrl', type: 'string', description: 'Pull request HTML URL.' }, + { name: 'repoFullName', type: 'string', description: '`owner/repo` of the pull request.' }, + { + name: 'alreadyExisted', + type: 'boolean', + description: + '`true` when GitHub returned an existing PR for the branch instead of creating one.', + }, + { + name: 'pushOutput', + type: 'string', + optional: true, + description: 'Captured stdout+stderr of `git push` (includes pre-push hook output).', + }, + { + name: 'commitOutput', + type: 'string', + optional: true, + description: 'Captured stdout+stderr of `git commit` (includes pre-commit hook output).', + }, + ], + }, }; export const createPRReviewDef: ToolDefinition = { @@ -253,6 +279,48 @@ export const createPRReviewDef: ToolDefinition = { }, ], }, + outputShape: { + summary: + 'CreatePRReview returns the submitted review identity, event, and inline-comment count.', + fields: [ + { + name: 'status', + type: '"ok" | "no-op" | "aborted"', + description: 'Generic GitHub mutation status — `"ok"` when the review was submitted.', + }, + { name: 'id', type: 'string', description: 'Review ID (numeric, stringified).' }, + { name: 'url', type: 'string', optional: true, description: 'Review HTML URL.' }, + { + name: 'reviewUrl', + type: 'string', + description: 'Alias of `url`, retained for downstream session-state code.', + }, + { + name: 'event', + type: '"APPROVE" | "REQUEST_CHANGES" | "COMMENT"', + description: 'The review event that was submitted.', + }, + { name: 'repoFullName', type: 'string', description: '`owner/repo` of the pull request.' }, + { name: 'prNumber', type: 'number', description: 'Pull request number.' }, + { + name: 'submittedAt', + type: 'string', + description: 'GitHub-supplied `submitted_at` ISO 8601 timestamp.', + }, + { name: 'updatedAt', type: 'string', description: 'ISO 8601 timestamp.' }, + { + name: 'inlineCommentCount', + type: 'number', + description: 'Number of inline review comments accepted by GitHub.', + }, + { + name: 'message', + type: 'string', + optional: true, + description: 'Optional human-readable note explaining the outcome.', + }, + ], + }, }; export const getPRDetailsDef: ToolDefinition = { @@ -522,6 +590,31 @@ export const postPRCommentDef: ToolDefinition = { }, ], }, + outputShape: { + summary: 'PostPRComment returns the posted comment identity.', + fields: [ + { + name: 'status', + type: '"ok" | "no-op" | "aborted"', + description: 'Generic GitHub mutation status — `"ok"` when the comment was posted.', + }, + { name: 'id', type: 'string', description: 'New comment ID (numeric, stringified).' }, + { name: 'url', type: 'string', optional: true, description: 'Comment HTML URL.' }, + { name: 'repoFullName', type: 'string', description: '`owner/repo` of the pull request.' }, + { name: 'prNumber', type: 'number', description: 'Pull request number.' }, + { + name: 'updatedAt', + type: 'string', + description: 'GitHub-supplied `updated_at` ISO 8601 timestamp.', + }, + { + name: 'message', + type: 'string', + optional: true, + description: 'Optional human-readable note explaining the outcome.', + }, + ], + }, }; export const updatePRCommentDef: ToolDefinition = { @@ -582,6 +675,36 @@ export const updatePRCommentDef: ToolDefinition = { }, ], }, + outputShape: { + summary: 'UpdatePRComment returns the updated comment identity.', + fields: [ + { + name: 'status', + type: '"ok" | "no-op" | "aborted"', + description: 'Generic GitHub mutation status — `"ok"` when the comment body was edited.', + }, + { name: 'id', type: 'string', description: 'Comment ID (numeric, stringified).' }, + { name: 'url', type: 'string', optional: true, description: 'Comment HTML URL.' }, + { name: 'repoFullName', type: 'string', description: '`owner/repo` of the pull request.' }, + { + name: 'prNumber', + type: 'number | null', + description: + 'Pull request number when GitHub returns one in the comment html_url; `null` for issue-only comments.', + }, + { + name: 'updatedAt', + type: 'string', + description: 'GitHub-supplied `updated_at` ISO 8601 timestamp.', + }, + { + name: 'message', + type: 'string', + optional: true, + description: 'Optional human-readable note explaining the outcome.', + }, + ], + }, }; export const replyToReviewCommentDef: ToolDefinition = { @@ -648,6 +771,31 @@ export const replyToReviewCommentDef: ToolDefinition = { }, ], }, + outputShape: { + summary: 'ReplyToReviewComment returns the threaded reply identity.', + fields: [ + { + name: 'status', + type: '"ok" | "no-op" | "aborted"', + description: 'Generic GitHub mutation status — `"ok"` when the reply was posted.', + }, + { name: 'id', type: 'string', description: 'New reply comment ID (numeric, stringified).' }, + { name: 'url', type: 'string', optional: true, description: 'Reply HTML URL.' }, + { name: 'repoFullName', type: 'string', description: '`owner/repo` of the pull request.' }, + { name: 'prNumber', type: 'number', description: 'Pull request number.' }, + { + name: 'updatedAt', + type: 'string', + description: 'GitHub-supplied `updated_at` ISO 8601 timestamp.', + }, + { + name: 'message', + type: 'string', + optional: true, + description: 'Optional human-readable note explaining the outcome.', + }, + ], + }, }; export const getCIRunLogsDef: ToolDefinition = { diff --git a/src/gadgets/pm/AddChecklist.ts b/src/gadgets/pm/AddChecklist.ts index 7e19fb6b5..623c3f9aa 100644 --- a/src/gadgets/pm/AddChecklist.ts +++ b/src/gadgets/pm/AddChecklist.ts @@ -1,11 +1,17 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { addChecklist, type ChecklistItemInput } from './core/addChecklist.js'; import { addChecklistDef } from './definitions.js'; export const AddChecklist = createGadgetClass(addChecklistDef, async (params) => { - return addChecklist({ - workItemId: params.workItemId as string, - checklistName: params.checklistName as string, - items: params.item as ChecklistItemInput[], - }); + try { + const result = await addChecklist({ + workItemId: params.workItemId as string, + checklistName: params.checklistName as string, + items: params.item as ChecklistItemInput[], + }); + return `Checklist "${result.checklistName}" created (id: ${result.checklistId}) with ${result.itemCount} items on ${result.workItemUrl}`; + } catch (error) { + return formatGadgetError('adding checklist', error); + } }); diff --git a/src/gadgets/pm/CreateWorkItem.ts b/src/gadgets/pm/CreateWorkItem.ts index 883c6c65f..da3b06712 100644 --- a/src/gadgets/pm/CreateWorkItem.ts +++ b/src/gadgets/pm/CreateWorkItem.ts @@ -1,11 +1,17 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { createWorkItem } from './core/createWorkItem.js'; import { createWorkItemDef } from './definitions.js'; export const CreateWorkItem = createGadgetClass(createWorkItemDef, async (params) => { - return createWorkItem({ - containerId: params.containerId as string, - title: params.title as string, - description: params.description as string | undefined, - }); + try { + const result = await createWorkItem({ + containerId: params.containerId as string, + title: params.title as string, + description: params.description as string | undefined, + }); + return `Work item created successfully: "${result.title}" [id: ${result.id}] - ${result.url}`; + } catch (error) { + return formatGadgetError('creating work item', error); + } }); diff --git a/src/gadgets/pm/DeleteChecklistItem.ts b/src/gadgets/pm/DeleteChecklistItem.ts index c1f669569..587334a89 100644 --- a/src/gadgets/pm/DeleteChecklistItem.ts +++ b/src/gadgets/pm/DeleteChecklistItem.ts @@ -1,7 +1,16 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { deleteChecklistItem } from './core/deleteChecklistItem.js'; import { pmDeleteChecklistItemDef } from './definitions.js'; export const PMDeleteChecklistItem = createGadgetClass(pmDeleteChecklistItemDef, async (params) => { - return deleteChecklistItem(params.workItemId as string, params.checkItemId as string); + try { + const result = await deleteChecklistItem( + params.workItemId as string, + params.checkItemId as string, + ); + return `Checklist item ${result.checkItemId} deleted from ${result.workItemUrl}`; + } catch (error) { + return formatGadgetError('deleting checklist item', error); + } }); diff --git a/src/gadgets/pm/MoveWorkItem.ts b/src/gadgets/pm/MoveWorkItem.ts index e6c99f769..29c39347e 100644 --- a/src/gadgets/pm/MoveWorkItem.ts +++ b/src/gadgets/pm/MoveWorkItem.ts @@ -1,10 +1,25 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { moveWorkItem } from './core/moveWorkItem.js'; import { moveWorkItemDef } from './definitions.js'; export const MoveWorkItem = createGadgetClass(moveWorkItemDef, async (params) => { - return moveWorkItem({ - workItemId: params.workItemId as string, - destination: params.destination as string, - }); + try { + const result = await moveWorkItem({ + workItemId: params.workItemId as string, + destination: params.destination as string, + expectedSourceState: params.expectedSourceState as string | undefined, + }); + + switch (result.status) { + case 'moved': + return `Work item ${result.id} moved to ${result.destination} successfully`; + case 'noop': + return result.message ?? `Work item ${result.id} already in destination state — no-op`; + case 'aborted': + return result.message ?? `Aborted move of work item ${result.id}`; + } + } catch (error) { + return formatGadgetError('moving work item', error); + } }); diff --git a/src/gadgets/pm/PostComment.ts b/src/gadgets/pm/PostComment.ts index 95edbcb1c..a1f1df761 100644 --- a/src/gadgets/pm/PostComment.ts +++ b/src/gadgets/pm/PostComment.ts @@ -1,7 +1,13 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { postComment } from './core/postComment.js'; import { postCommentDef } from './definitions.js'; export const PostComment = createGadgetClass(postCommentDef, async (params) => { - return postComment(params.workItemId as string, params.text as string); + try { + await postComment(params.workItemId as string, params.text as string); + return 'Comment posted successfully'; + } catch (error) { + return formatGadgetError('posting comment', error); + } }); diff --git a/src/gadgets/pm/UpdateChecklistItem.ts b/src/gadgets/pm/UpdateChecklistItem.ts index 098809579..5c7e58817 100644 --- a/src/gadgets/pm/UpdateChecklistItem.ts +++ b/src/gadgets/pm/UpdateChecklistItem.ts @@ -1,11 +1,18 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { updateChecklistItem } from './core/updateChecklistItem.js'; import { pmUpdateChecklistItemDef } from './definitions.js'; export const PMUpdateChecklistItem = createGadgetClass(pmUpdateChecklistItemDef, async (params) => { - return updateChecklistItem( - params.workItemId as string, - params.checkItemId as string, - (params.state as string) === 'complete', - ); + try { + const result = await updateChecklistItem( + params.workItemId as string, + params.checkItemId as string, + (params.state as string) === 'complete', + ); + const action = result.complete ? 'marked complete' : 'marked incomplete'; + return `Checklist item ${result.checkItemId} ${action} on ${result.workItemUrl}`; + } catch (error) { + return formatGadgetError('updating checklist item', error); + } }); diff --git a/src/gadgets/pm/UpdateWorkItem.ts b/src/gadgets/pm/UpdateWorkItem.ts index 5e2ceb28a..b39c7b023 100644 --- a/src/gadgets/pm/UpdateWorkItem.ts +++ b/src/gadgets/pm/UpdateWorkItem.ts @@ -1,12 +1,27 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { updateWorkItem } from './core/updateWorkItem.js'; import { updateWorkItemDef } from './definitions.js'; export const UpdateWorkItem = createGadgetClass(updateWorkItemDef, async (params) => { - return updateWorkItem({ - workItemId: params.workItemId as string, - title: params.title as string | undefined, - description: params.description as string | undefined, - addLabelIds: params.addLabelId as string[] | undefined, - }); + try { + const result = await updateWorkItem({ + workItemId: params.workItemId as string, + title: params.title as string | undefined, + description: params.description as string | undefined, + addLabelIds: params.addLabelId as string[] | undefined, + }); + + if (result.status === 'noop') { + return result.message ?? 'Nothing to update - provide title, description, or labels'; + } + + const updated: string[] = [...result.changedFields]; + if (result.addedLabelIds.length > 0) { + updated.push(`${result.addedLabelIds.length} label(s)`); + } + return `Work item updated: ${updated.join(', ')}`; + } catch (error) { + return formatGadgetError('updating work item', error); + } }); diff --git a/src/gadgets/pm/core/addChecklist.ts b/src/gadgets/pm/core/addChecklist.ts index 8ed7af098..1381ce99d 100644 --- a/src/gadgets/pm/core/addChecklist.ts +++ b/src/gadgets/pm/core/addChecklist.ts @@ -1,5 +1,7 @@ import { getPMProvider } from '../../../pm/index.js'; -import type { ChecklistItemDraft } from '../../../pm/types.js'; +import type { Checklist, ChecklistItemDraft } from '../../../pm/types.js'; +import type { ChecklistCreatedResult } from './mutationResults.js'; +import { readWorkItemContext } from './readWorkItemContext.js'; export type ChecklistItemInput = string | { name: string; description?: string }; @@ -9,7 +11,26 @@ export interface AddChecklistParams { items: ChecklistItemInput[]; } -export async function addChecklist(params: AddChecklistParams): Promise { +/** + * Create a checklist on a work item with one or more items. + * + * Returns a structured `ChecklistCreatedResult` so downstream consumers can + * branch on shape rather than parsing prose. The result carries the freshly- + * created checklist identity, parent work-item URL/timestamp (read back from + * the provider), the item count, and any per-item IDs the provider surfaced. + * + * Inline-description providers (Linear, JIRA) consume the optional bulk + * `createChecklistWithItems` fast path and emit deterministic hashed IDs in + * `result.itemIds`. Trello's native-checklist provider falls through to the + * per-item path; `addChecklistItem` returns `void` there so `itemIds` ends up + * empty — that's expected, and the field documentation calls it out. + * + * Runtime provider errors propagate (no internal try/catch) per the spec + * MNG-1424 contract. The gadget wrapper at `src/gadgets/pm/AddChecklist.ts` + * wraps thrown errors with `formatGadgetError`; the CLI factory wraps them in + * the spec-014 runtime envelope. + */ +export async function addChecklist(params: AddChecklistParams): Promise { if (params.items.length === 0) { throw new Error('At least one checklist item is required'); } @@ -17,25 +38,36 @@ export async function addChecklist(params: AddChecklistParams): Promise const provider = getPMProvider(); const items = params.items.map(normalizeChecklistItem); + let checklist: Checklist; if (typeof provider.createChecklistWithItems === 'function') { - await provider.createChecklistWithItems(params.workItemId, params.checklistName, items); - return successMessage(params.workItemId, params.checklistName, items.length); + checklist = await provider.createChecklistWithItems( + params.workItemId, + params.checklistName, + items, + ); + } else { + checklist = await provider.createChecklist(params.workItemId, params.checklistName); + for (const item of items) { + await provider.addChecklistItem(checklist.id, item.name, item.checked, item.description); + } } - const checklist = await provider.createChecklist(params.workItemId, params.checklistName); + const itemIds = (checklist.items ?? []).map((i) => i.id); + const { workItemUrl, updatedAt } = await readWorkItemContext(params.workItemId); - for (const item of items) { - await provider.addChecklistItem(checklist.id, item.name, item.checked, item.description); - } - - return successMessage(params.workItemId, params.checklistName, items.length); + return { + status: 'created', + checklistId: checklist.id, + checklistName: params.checklistName, + workItemId: params.workItemId, + workItemUrl, + updatedAt, + itemCount: items.length, + itemIds, + }; } function normalizeChecklistItem(item: ChecklistItemInput): ChecklistItemDraft { if (typeof item === 'string') return { name: item, checked: false }; return { name: item.name, checked: false, description: item.description }; } - -function successMessage(workItemId: string, checklistName: string, itemCount: number): string { - return `Checklist "${checklistName}" created with ${itemCount} items on work item ${workItemId}`; -} diff --git a/src/gadgets/pm/core/createWorkItem.ts b/src/gadgets/pm/core/createWorkItem.ts index ebe66108f..9fa9d74f4 100644 --- a/src/gadgets/pm/core/createWorkItem.ts +++ b/src/gadgets/pm/core/createWorkItem.ts @@ -1,4 +1,5 @@ import { getPMProvider } from '../../../pm/index.js'; +import { pickTimestamp, type WorkItemCreatedResult } from './mutationResults.js'; export interface CreateWorkItemParams { containerId: string; @@ -6,12 +7,41 @@ export interface CreateWorkItemParams { description?: string; } -export async function createWorkItem(params: CreateWorkItemParams): Promise { +/** + * Create a work item in a container (Trello list / JIRA project / Linear + * team). + * + * Returns a structured `WorkItemCreatedResult` so downstream consumers can + * branch on shape rather than parsing prose. The result carries the + * freshly-created work-item identity (`id`, `title`, `url`), the action + * status (`'created'`), a provider-preferred `updatedAt`, and any + * workflow-state fields the provider surfaced (`workflowStatus`, + * `workflowStatusId`). Each provider populates these fields opportunistically + * — JIRA's create endpoint does not echo a status, while Trello returns the + * destination list ID and Linear surfaces the workflow state name via + * `WorkItem.status`. + * + * Runtime provider errors propagate (no internal try/catch) so the CLI + * factory emits the spec-014 `runtime` envelope and gadget wrappers can wrap + * with `formatGadgetError`. The previous prose-returning contract was + * lossy — consumers had to regex out `[id: ...]` and `https://...` from the + * sentence to act on the result. + */ +export async function createWorkItem(params: CreateWorkItemParams): Promise { const item = await getPMProvider().createWorkItem({ containerId: params.containerId, title: params.title, description: params.description, }); - return `Work item created successfully: "${item.title}" [id: ${item.id}] - ${item.url}`; + const result: WorkItemCreatedResult = { + status: 'created', + id: item.id, + title: item.title, + url: item.url, + updatedAt: pickTimestamp(item.updatedAt ?? item.createdAt), + }; + if (item.status) result.workflowStatus = item.status; + if (item.statusId) result.workflowStatusId = item.statusId; + return result; } diff --git a/src/gadgets/pm/core/deleteChecklistItem.ts b/src/gadgets/pm/core/deleteChecklistItem.ts index a5c3e6fa0..09588a6c3 100644 --- a/src/gadgets/pm/core/deleteChecklistItem.ts +++ b/src/gadgets/pm/core/deleteChecklistItem.ts @@ -1,14 +1,37 @@ import { getPMProvider } from '../../../pm/index.js'; +import type { ChecklistItemDeletedResult } from './mutationResults.js'; +import { readWorkItemContext } from './readWorkItemContext.js'; +/** + * Delete a checklist item from a work item. + * + * Returns a structured `ChecklistItemDeletedResult` so downstream consumers + * can branch on shape rather than parsing prose. The result carries the parent + * work-item context (read back from the provider for URL + timestamp), the + * deleted `checkItemId`, and the action status (`'deleted'`). + * + * Runtime provider errors propagate (no internal try/catch) per the spec + * MNG-1424 contract. The gadget wrapper at + * `src/gadgets/pm/DeleteChecklistItem.ts` wraps thrown errors with + * `formatGadgetError`; the CLI factory wraps them in the spec-014 runtime + * envelope. Read-back failures after a successful mutation fall back to a + * synthesised URL + timestamp inside `readWorkItemContext` rather than + * masking the mutation success. + */ export async function deleteChecklistItem( workItemId: string, checkItemId: string, -): Promise { - try { - await getPMProvider().deleteChecklistItem(workItemId, checkItemId); - return `Checklist item ${checkItemId} deleted from work item ${workItemId}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Error deleting checklist item: ${message}`); - } +): Promise { + const provider = getPMProvider(); + await provider.deleteChecklistItem(workItemId, checkItemId); + + const { workItemUrl, updatedAt } = await readWorkItemContext(workItemId); + + return { + status: 'deleted', + workItemId, + workItemUrl, + checkItemId, + updatedAt, + }; } diff --git a/src/gadgets/pm/core/moveWorkItem.ts b/src/gadgets/pm/core/moveWorkItem.ts index 2c3c3be02..25185fecb 100644 --- a/src/gadgets/pm/core/moveWorkItem.ts +++ b/src/gadgets/pm/core/moveWorkItem.ts @@ -1,5 +1,6 @@ import { getPMProvider } from '../../../pm/index.js'; import type { WorkItem } from '../../../pm/types.js'; +import { currentTimestamp, pickTimestamp, type WorkItemMovedResult } from './mutationResults.js'; export interface MoveWorkItemParams { workItemId: string; @@ -43,34 +44,94 @@ function formatCurrentStatus(current: WorkItem): string { return `${current.status ?? 'unknown'} (${current.statusId})`; } -async function guardedMove(params: MoveWorkItemParams): Promise { +/** + * Build the previous-status fields for guarded outcomes. Keeps the result + * keys consistent across `'noop'` / `'aborted'` / `'moved'` returns from the + * guarded path. + */ +function buildPreviousStatusFields(current: WorkItem): { + previousStatus?: string; + previousStatusId?: string; +} { + const fields: { previousStatus?: string; previousStatusId?: string } = {}; + if (current.status) fields.previousStatus = current.status; + if (current.statusId) fields.previousStatusId = current.statusId; + return fields; +} + +async function guardedMove(params: MoveWorkItemParams): Promise { const provider = getPMProvider(); const current = await provider.getWorkItem(params.workItemId); const expected = normalizeStatus(params.expectedSourceState); const destination = normalizeStatus(params.destination); + const previousStatusFields = buildPreviousStatusFields(current); if (isAlreadyInDestination(current, destination)) { - return `Work item ${params.workItemId} already in destination state '${current.status ?? current.statusId}' — no-op`; + return { + status: 'noop', + id: params.workItemId, + url: current.url || provider.getWorkItemUrl(params.workItemId), + destination: params.destination, + updatedAt: pickTimestamp(current.updatedAt), + ...previousStatusFields, + message: `Work item already in destination state '${current.status ?? current.statusId}' — no-op`, + }; } if (!matchesExpectedSource(current, expected)) { - return `Aborted: work item ${params.workItemId} is in '${formatCurrentStatus(current)}', expected '${params.expectedSourceState}' (likely already moved by a parallel agent — skipping to avoid duplicate downstream work)`; + return { + status: 'aborted', + id: params.workItemId, + url: current.url || provider.getWorkItemUrl(params.workItemId), + destination: params.destination, + updatedAt: currentTimestamp(), + ...previousStatusFields, + message: `Aborted: work item is in '${formatCurrentStatus(current)}', expected '${params.expectedSourceState}' (likely already moved by a parallel agent — skipping to avoid duplicate downstream work)`, + }; } await provider.moveWorkItem(params.workItemId, params.destination); - return `Work item ${params.workItemId} moved to ${params.destination} successfully`; + return { + status: 'moved', + id: params.workItemId, + url: current.url || provider.getWorkItemUrl(params.workItemId), + destination: params.destination, + updatedAt: pickTimestamp(undefined), + ...previousStatusFields, + }; } -export async function moveWorkItem(params: MoveWorkItemParams): Promise { - try { - if (params.expectedSourceState !== undefined) { - return await guardedMove(params); - } - - await getPMProvider().moveWorkItem(params.workItemId, params.destination); - return `Work item ${params.workItemId} moved to ${params.destination} successfully`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Error moving work item: ${message}`); +/** + * Move a work item to a different list or status. + * + * Returns a structured `WorkItemMovedResult` so downstream consumers can + * branch on shape rather than parsing prose. Three outcomes: + * - `'moved'` — the provider accepted the move. + * - `'noop'` — the work item was already in the destination (guarded + * path only). + * - `'aborted'` — the work item was in an unexpected source state and the + * guarded path refused the move. + * + * `expectedSourceState` is the parallel-agent race guard introduced for the + * MNG-538 incident (2026-05-06). When provided, the gadget fetches the work + * item's current status and aborts unless it matches (case-insensitive). + * + * Runtime provider errors propagate (no internal try/catch) so the CLI + * factory emits the spec-014 `runtime` envelope and gadget wrappers can wrap + * with `formatGadgetError`. + */ +export async function moveWorkItem(params: MoveWorkItemParams): Promise { + if (params.expectedSourceState !== undefined) { + return guardedMove(params); } + + const provider = getPMProvider(); + await provider.moveWorkItem(params.workItemId, params.destination); + return { + status: 'moved', + id: params.workItemId, + url: provider.getWorkItemUrl(params.workItemId), + destination: params.destination, + updatedAt: pickTimestamp(undefined), + }; } diff --git a/src/gadgets/pm/core/mutationResults.ts b/src/gadgets/pm/core/mutationResults.ts new file mode 100644 index 000000000..697176a10 --- /dev/null +++ b/src/gadgets/pm/core/mutationResults.ts @@ -0,0 +1,348 @@ +/** + * PM mutation result contract — typed outcome/status unions and helpers for + * normalizing mutation results into a stable shape with `id`, `url`, `status`, + * and `updatedAt`. + * + * Spec MNG-1422: introduces reusable PM mutation result types so later + * mutation cores (createWorkItem, updateWorkItem, postComment, moveWorkItem, + * etc.) can return predictable objects instead of free-form prose. + * + * Status semantics: + * - `'ok'` — the provider accepted the mutation and we surface its + * fresh `updatedAt`. + * - `'no-op'` — there was nothing to do (e.g. the work item was already + * in the destination state). We synthesize `updatedAt` + * because no provider write happened. + * - `'aborted'` — the mutation was deliberately not attempted (e.g. + * pre-move guard mismatch). Same fallback semantics as + * no-op. + * + * Timestamp policy: + * - We always PREFER a provider-supplied timestamp when present. + * - We ONLY fall back to the current ISO timestamp for synthetic outcomes + * (`no-op` / `aborted`). For `'ok'` outcomes the caller MUST pass the + * provider timestamp. Missing provider timestamps are rejected rather than + * silently pretending the provider wrote data at "now". + */ + +/** + * Status union for PM mutation outcomes. Stable across all PM mutation + * gadgets so consumers can branch on shape, not on prose. + */ +export type PMMutationStatus = 'ok' | 'no-op' | 'aborted'; + +/** + * Normalized result shape for any PM mutation. Optional fields stay optional + * — mutations that don't touch a URL or status (e.g. a comment update) just + * omit those fields rather than carrying empty strings. + */ +export interface PMMutationResult { + /** Stable identifier of the affected resource (work item ID, comment ID, etc.). */ + id: string; + /** Outcome status — `'ok'` means the provider wrote data. */ + status: PMMutationStatus; + /** + * ISO 8601 timestamp reflecting when the resource was last updated. + * Provider-supplied for `'ok'` outcomes; synthesised via `currentTimestamp()` + * for `'no-op'` / `'aborted'` outcomes. + */ + updatedAt: string; + /** Optional URL of the affected resource (work item URL, comment URL, etc.). */ + url?: string; + /** + * Optional human-readable note explaining the outcome (e.g. "already in + * destination state"). Consumers can surface this; it's not load-bearing. + */ + message?: string; +} + +/** + * Returns the current ISO 8601 timestamp. Used as the fallback for synthetic + * no-op / aborted outcomes where no provider write happened. + * + * Centralized here so tests can spy on it without per-call-site `vi.spyOn`. + */ +export function currentTimestamp(): string { + return new Date().toISOString(); +} + +/** + * Prefer a provider-supplied timestamp, falling back to the current ISO + * timestamp only when none is available. + * + * IMPORTANT: this fallback is intended for synthetic outcomes (no-op, + * aborted). For `'ok'` outcomes the caller must pass a provider timestamp to + * `okResult`; missing successful-resource timestamps are rejected. + * + * The helper exists to avoid littering call sites with the same `?? new + * Date().toISOString()` expression. + */ +export function pickTimestamp(providerTimestamp: string | undefined | null): string { + if (providerTimestamp && providerTimestamp.length > 0) { + return providerTimestamp; + } + return currentTimestamp(); +} + +function requireProviderTimestamp(updatedAt: string): string { + if (typeof updatedAt !== 'string' || updatedAt.length === 0) { + throw new TypeError('okResult requires a provider-supplied updatedAt timestamp'); + } + return updatedAt; +} + +/** + * Build an `'ok'` mutation result. Used by mutation cores that successfully + * wrote data through the provider. + * + * The provider timestamp is required so consumers can treat `updatedAt` on a + * successful result as provider-supplied. Synthetic timestamps are reserved + * for `no-op` and `aborted` results. + */ +export function okResult(args: { + id: string; + updatedAt: string; + url?: string; + message?: string; +}): PMMutationResult { + const result: PMMutationResult = { + id: args.id, + status: 'ok', + updatedAt: requireProviderTimestamp(args.updatedAt), + }; + if (args.url) result.url = args.url; + if (args.message) result.message = args.message; + return result; +} + +/** + * Build a `'no-op'` mutation result. Used when the mutation gadget detected + * that the desired state already holds (e.g. moveWorkItem found the item + * already in the destination state). The timestamp is the current ISO — no + * provider write happened, so we never claim a fresh provider timestamp here. + */ +export function noOpResult(args: { id: string; url?: string; message?: string }): PMMutationResult { + const result: PMMutationResult = { + id: args.id, + status: 'no-op', + updatedAt: currentTimestamp(), + }; + if (args.url) result.url = args.url; + if (args.message) result.message = args.message; + return result; +} + +/** + * Build an `'aborted'` mutation result. Used when a guard refused to attempt + * the mutation (e.g. expectedSourceState mismatch in moveWorkItem). Same + * timestamp semantics as no-op — synthesised because no write happened. + */ +export function abortedResult(args: { + id: string; + url?: string; + message?: string; +}): PMMutationResult { + const result: PMMutationResult = { + id: args.id, + status: 'aborted', + updatedAt: currentTimestamp(), + }; + if (args.url) result.url = args.url; + if (args.message) result.message = args.message; + return result; +} + +// ─── Work-item & comment mutation result contracts (MNG-1423) ─────────────── +// +// Work-item and comment mutations expose action-specific outcome statuses +// alongside the parent work-item identity / URL / timestamp. They live in this +// shared module so consumers can import all PM mutation result shapes from a +// single surface; `pickTimestamp` / `currentTimestamp` above are reused as-is. +// +// The acceptance criteria from MNG-1423 use action-specific status literals +// (`created`, `updated`, `moved`, `noop`, `aborted`) instead of the generic +// `'ok' | 'no-op' | 'aborted'` union that the original PM mutation contract +// shipped. The earlier union (re-exported above for parity with the GitHub +// mutation contract) is still useful to callers building their own mutations +// — the explicit literal unions below are what the four work-item / comment +// cores return. + +/** + * Result returned by `createWorkItem`. Surfaces the freshly-created work item's + * identity (`id`, `title`, `url`), the action status (`'created'`), + * provider-preferred `updatedAt`, and any workflow-state fields the provider + * surfaced on creation (`status`, `statusId`). The optional state fields are + * provider-dependent — Trello returns the destination list ID via `status`, + * Linear returns the workflow state via `statusId`, JIRA's create endpoint + * does not surface a status on the create response. + */ +export interface WorkItemCreatedResult { + status: 'created'; + id: string; + title: string; + url: string; + updatedAt: string; + /** Optional human-readable workflow state name (e.g. Linear state name). */ + workflowStatus?: string; + /** Optional native workflow state ID (e.g. Linear state UUID, Trello list ID). */ + workflowStatusId?: string; +} + +/** + * Result returned by `updateWorkItem`. Two outcomes: + * - `'updated'` — the provider accepted at least one field update or label + * addition. `changedFields` lists the work-item fields that were sent + * (any of `'title'` / `'description'`); `addedLabelIds` lists the labels + * that were applied. The current work-item metadata (`title`, `url`, + * `updatedAt`) is read back from the provider after the mutation. + * - `'noop'` — the caller did not pass any updates (no title, + * description, or labels). No provider write happened; `updatedAt` is + * synthesised via `currentTimestamp()` and `title` / `url` are best-effort + * (read back from the provider when available). + * + * `changedFields` and `addedLabelIds` are always present (as arrays) so + * consumers never branch on `undefined`. They may be empty on the `'noop'` + * outcome. + */ +export interface WorkItemUpdatedResult { + status: 'updated' | 'noop'; + id: string; + title: string; + url: string; + updatedAt: string; + changedFields: Array<'title' | 'description'>; + addedLabelIds: string[]; + /** Optional human-readable note explaining the outcome (used on `noop`). */ + message?: string; +} + +/** + * Result returned by `moveWorkItem`. Three outcomes: + * - `'moved'` — the provider accepted the move from the caller's source + * into the requested destination. The new workflow state is reflected in + * `destination` (the value passed to the provider). + * - `'noop'` — the work item was already in the requested destination + * (idempotent guard via `expectedSourceState`). No provider write + * happened. + * - `'aborted'` — the work item's current status did not match + * `expectedSourceState` (parallel-agent race guard). No provider write + * happened. + * + * The work-item `url` is sourced via `provider.getWorkItemUrl(id)` (or the + * read-back `WorkItem.url` when the guarded path already fetched it). The + * `previousStatus` field surfaces the work-item's current human-readable + * workflow status / status ID when the guarded path read it back from the + * provider — useful for diagnostics on `'noop'` and `'aborted'` outcomes. + */ +export interface WorkItemMovedResult { + status: 'moved' | 'noop' | 'aborted'; + id: string; + url: string; + destination: string; + updatedAt: string; + /** + * The work item's current status / status ID at the time of the guarded + * read-back. Present for `'noop'` and `'aborted'` outcomes (and for + * `'moved'` outcomes that went through the guarded path); omitted for + * `'moved'` outcomes that bypassed the guard (no `expectedSourceState`). + */ + previousStatus?: string; + /** + * The previousStatus's native ID when known (e.g. Linear state UUID, + * Trello list ID). Optional; consumers can fall back to `previousStatus`. + */ + previousStatusId?: string; + /** Optional human-readable note explaining the outcome. */ + message?: string; +} + +/** + * Result returned by `postComment`. Two outcomes: + * - `'created'` — a new comment was added via `provider.addComment`. `id` + * is the new comment's provider ID. + * - `'updated'` — an existing progress comment was replaced via + * `provider.updateComment`. `id` is the existing comment's provider ID. + * + * The parent work-item context (`workItemId`, `workItemUrl`) is always + * present so downstream consumers can correlate the comment back to its + * parent. `updatedAt` reflects when the comment was written; because the + * `PMProvider.addComment` / `updateComment` surface returns only an ID + * (not the full comment record), we synthesise the timestamp via + * `currentTimestamp()` — the comment was just written, so the synthetic + * "now" closely tracks the provider-side reality. + */ +export interface CommentPostedResult { + status: 'created' | 'updated'; + id: string; + workItemId: string; + workItemUrl: string; + updatedAt: string; +} + +// ─── Checklist mutation result contracts (MNG-1424) ───────────────────────── +// +// PM checklist mutations have action-specific outcome statuses (`created`, +// `updated`, `deleted`) rather than the generic `'ok' | 'no-op' | 'aborted'` +// outcomes used for work-item/comment mutations. They live in this shared +// module so consumers can import all PM mutation result shapes from a single +// surface; `pickTimestamp` / `currentTimestamp` above are reused as-is. +// +// Timestamp policy mirrors the parent contract: provider-supplied timestamps +// win when available; we fall back to `currentTimestamp()` only when the +// provider's read-back omits an `updatedAt` (e.g. legacy code paths). The +// mutation itself already succeeded, so the structured result never throws +// just because the timestamp can't be sourced from the provider. + +/** + * Result returned by `addChecklist`. Carries the freshly-created checklist's + * identity (`checklistId`, `checklistName`), the parent work-item context + * (`workItemId`, `workItemUrl`), the action status (`'created'`), + * provider-preferred `updatedAt`, the number of items written, and the per-item + * IDs the provider surfaced. + * + * `itemIds` is best-effort — the inline-description providers (Linear, JIRA) + * return deterministic hashed IDs from `createChecklistWithItems`, while + * Trello's native-checklist per-item fallback path does not surface IDs from + * `addChecklistItem`. The field is always present (as an array) so consumers + * never branch on `undefined`; it may be empty when the provider did not + * return IDs. + */ +export interface ChecklistCreatedResult { + status: 'created'; + checklistId: string; + checklistName: string; + workItemId: string; + workItemUrl: string; + updatedAt: string; + itemCount: number; + itemIds: string[]; +} + +/** + * Result returned by `updateChecklistItem`. Surfaces the work-item context + * (`workItemId`, `workItemUrl`), the affected item ID (`checkItemId`), the + * resulting boolean state (`complete`), the action status (`'updated'`), + * and a provider-preferred `updatedAt`. Used by consumers that want to + * confirm both the request was acknowledged AND the resulting state. + */ +export interface ChecklistItemUpdatedResult { + status: 'updated'; + workItemId: string; + workItemUrl: string; + checkItemId: string; + complete: boolean; + updatedAt: string; +} + +/** + * Result returned by `deleteChecklistItem`. Surfaces the work-item context + * (`workItemId`, `workItemUrl`), the deleted item ID (`checkItemId`), the + * action status (`'deleted'`), and a provider-preferred `updatedAt`. + */ +export interface ChecklistItemDeletedResult { + status: 'deleted'; + workItemId: string; + workItemUrl: string; + checkItemId: string; + updatedAt: string; +} diff --git a/src/gadgets/pm/core/postComment.ts b/src/gadgets/pm/core/postComment.ts index 836e532d4..19b7abf29 100644 --- a/src/gadgets/pm/core/postComment.ts +++ b/src/gadgets/pm/core/postComment.ts @@ -2,37 +2,73 @@ import { clearProgressCommentId, readProgressCommentId } from '../../../backends import { getPMProvider } from '../../../pm/index.js'; import { logger } from '../../../utils/logging.js'; import { buildRunLinkFooterFromEnv } from '../../../utils/runLink.js'; +import { type CommentPostedResult, currentTimestamp } from './mutationResults.js'; +import { readWorkItemContext } from './readWorkItemContext.js'; -export async function postComment(workItemId: string, text: string): Promise { - try { - const provider = getPMProvider(); +/** + * Post a comment on a work item, or replace the progress-comment when one was + * pre-seeded for this work item. + * + * Returns a structured `CommentPostedResult` so downstream consumers can + * branch on shape rather than parsing prose. Two outcomes: + * - `'created'` — a new comment was added via `provider.addComment`. + * - `'updated'` — the existing progress comment (id read from + * `CASCADE_PROGRESS_COMMENT_ID`) was replaced via + * `provider.updateComment`. Falls back to `'created'` if the update fails + * so a stale progress-comment id never blocks a real comment. + * + * The result carries the parent work-item context (`workItemId`, + * `workItemUrl`) so downstream consumers can correlate the comment back to + * its parent without re-parsing IDs. + * + * `updatedAt` is synthesised via `currentTimestamp()` because the + * `PMProvider.addComment` / `updateComment` interface returns only an ID, + * not the full comment record. The synthetic "now" closely tracks the + * provider-side write (the call just returned). + * + * Runtime provider errors propagate (no internal try/catch wrapping) per the + * MNG-1423 contract — the CLI factory wraps thrown errors in the spec-014 + * `runtime` envelope and gadget wrappers can wrap with `formatGadgetError`. + */ +export async function postComment(workItemId: string, text: string): Promise { + const provider = getPMProvider(); - // Append run link footer when enabled via env vars (injected by secretBuilder for subprocesses) - const runLinkFooter = buildRunLinkFooterFromEnv(workItemId); - const fullText = runLinkFooter ? text + runLinkFooter : text; + // Append run link footer when enabled via env vars (injected by secretBuilder for subprocesses) + const runLinkFooter = buildRunLinkFooterFromEnv(workItemId); + const fullText = runLinkFooter ? text + runLinkFooter : text; - // Check if there is a progress comment we should update instead of creating new - const progressState = readProgressCommentId(); - if (progressState && progressState.workItemId === workItemId) { - try { - await provider.updateComment(workItemId, progressState.commentId, fullText); - clearProgressCommentId(); - return 'Comment posted successfully'; - } catch (error) { - // Fall back to creating a new comment if update fails - logger.warn('Failed to update progress comment, creating new one', { - workItemId, - commentId: progressState.commentId, - error: error instanceof Error ? error.message : String(error), - }); - clearProgressCommentId(); - } + // Check if there is a progress comment we should update instead of creating new + const progressState = readProgressCommentId(); + if (progressState && progressState.workItemId === workItemId) { + try { + await provider.updateComment(workItemId, progressState.commentId, fullText); + clearProgressCommentId(); + const { workItemUrl } = await readWorkItemContext(workItemId); + return { + status: 'updated', + id: progressState.commentId, + workItemId, + workItemUrl, + updatedAt: currentTimestamp(), + }; + } catch (error) { + // Fall back to creating a new comment if update fails + logger.warn('Failed to update progress comment, creating new one', { + workItemId, + commentId: progressState.commentId, + error: error instanceof Error ? error.message : String(error), + }); + clearProgressCommentId(); } - - await provider.addComment(workItemId, fullText); - return 'Comment posted successfully'; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Error posting comment: ${message}`); } + + const commentId = await provider.addComment(workItemId, fullText); + const { workItemUrl } = await readWorkItemContext(workItemId); + return { + status: 'created', + id: commentId, + workItemId, + workItemUrl, + updatedAt: currentTimestamp(), + }; } diff --git a/src/gadgets/pm/core/readWorkItemContext.ts b/src/gadgets/pm/core/readWorkItemContext.ts new file mode 100644 index 000000000..3dbc3e74f --- /dev/null +++ b/src/gadgets/pm/core/readWorkItemContext.ts @@ -0,0 +1,45 @@ +import { getPMProvider } from '../../../pm/index.js'; +import { pickTimestamp } from './mutationResults.js'; + +/** + * Shared read-back helper used by PM mutation cores (`addChecklist`, + * `updateChecklistItem`, `deleteChecklistItem`, `updateWorkItem`, + * `postComment`) to surface the parent work-item's URL + `updatedAt` (and, + * for callers that need it, `title`) on the structured result. + * + * Implements the technical-notes pattern from MNG-1424: "Use work-item + * read-back for URL/status/timestamp where provider APIs do not return deep + * checklist links." The Trello, JIRA, and Linear adapters all surface + * `updatedAt` on `WorkItem` when the provider reports it. + * + * Read-back failure handling: the calling mutation has ALREADY succeeded by + * the time this helper runs. Propagating a read-back exception would mask the + * mutation success and risk an idempotency retry storm — especially on the + * native-checklist provider (Trello) where a retried `addChecklistItem` + * duplicates rows. We therefore swallow the read-back error and fall back to + * the synchronous `getWorkItemUrl(id)` constructor plus a synthesised current + * ISO timestamp. The mutation success is preserved; the timestamp is just + * synthesised rather than provider-supplied. `title` is `undefined` on the + * fallback path because the synchronous `getWorkItemUrl` surface only returns + * a URL. + */ +export async function readWorkItemContext(workItemId: string): Promise<{ + workItemUrl: string; + updatedAt: string; + title?: string; +}> { + const provider = getPMProvider(); + try { + const item = await provider.getWorkItem(workItemId); + return { + workItemUrl: item.url, + updatedAt: pickTimestamp(item.updatedAt), + title: item.title, + }; + } catch { + return { + workItemUrl: provider.getWorkItemUrl(workItemId), + updatedAt: pickTimestamp(undefined), + }; + } +} diff --git a/src/gadgets/pm/core/updateChecklistItem.ts b/src/gadgets/pm/core/updateChecklistItem.ts index 2f110924d..45c4344d2 100644 --- a/src/gadgets/pm/core/updateChecklistItem.ts +++ b/src/gadgets/pm/core/updateChecklistItem.ts @@ -1,17 +1,40 @@ import { getPMProvider } from '../../../pm/index.js'; +import type { ChecklistItemUpdatedResult } from './mutationResults.js'; +import { readWorkItemContext } from './readWorkItemContext.js'; +/** + * Toggle a checklist item's complete state on a work item. + * + * Returns a structured `ChecklistItemUpdatedResult` so downstream consumers + * can branch on shape rather than parsing prose. The result carries the parent + * work-item context (read back from the provider for URL + timestamp), the + * affected `checkItemId`, the resulting boolean state, and the action status + * (`'updated'`). + * + * Runtime provider errors propagate (no internal try/catch) per the spec + * MNG-1424 contract. The gadget wrapper at + * `src/gadgets/pm/UpdateChecklistItem.ts` wraps thrown errors with + * `formatGadgetError`; the CLI factory wraps them in the spec-014 runtime + * envelope. Read-back failures after a successful mutation fall back to a + * synthesised URL + timestamp inside `readWorkItemContext` rather than + * masking the mutation success. + */ export async function updateChecklistItem( workItemId: string, checkItemId: string, complete: boolean, -): Promise { - try { - await getPMProvider().updateChecklistItem(workItemId, checkItemId, complete); +): Promise { + const provider = getPMProvider(); + await provider.updateChecklistItem(workItemId, checkItemId, complete); - const action = complete ? 'marked complete' : 'marked incomplete'; - return `Checklist item ${checkItemId} ${action} on work item ${workItemId}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Error updating checklist item: ${message}`); - } + const { workItemUrl, updatedAt } = await readWorkItemContext(workItemId); + + return { + status: 'updated', + workItemId, + workItemUrl, + checkItemId, + complete, + updatedAt, + }; } diff --git a/src/gadgets/pm/core/updateWorkItem.ts b/src/gadgets/pm/core/updateWorkItem.ts index 981fc09b1..0a9f865cf 100644 --- a/src/gadgets/pm/core/updateWorkItem.ts +++ b/src/gadgets/pm/core/updateWorkItem.ts @@ -1,4 +1,6 @@ import { getPMProvider } from '../../../pm/index.js'; +import { currentTimestamp, type WorkItemUpdatedResult } from './mutationResults.js'; +import { readWorkItemContext } from './readWorkItemContext.js'; export interface UpdateWorkItemParams { workItemId: string; @@ -7,35 +9,80 @@ export interface UpdateWorkItemParams { addLabelIds?: string[]; } -export async function updateWorkItem(params: UpdateWorkItemParams): Promise { - if (!params.title && !params.description && !params.addLabelIds?.length) { - return 'Nothing to update - provide title, description, or labels'; +/** + * Update a work item — any of `title`, `description`, or `addLabelIds`. Title + * and description are sent in one provider call; label additions go through + * `provider.addLabel` per label. + * + * Returns a structured `WorkItemUpdatedResult` so downstream consumers can + * branch on shape rather than parsing prose. Two outcomes: + * - `'updated'` — at least one field or label was sent to the provider. + * `changedFields` lists the fields written; `addedLabelIds` echoes the + * applied label IDs. The current work-item metadata (`title`, `url`, + * `updatedAt`) is read back from the provider after the mutation so + * consumers see the post-write state. + * - `'noop'` — the caller passed no updates. No provider write happens; + * the result still surfaces the work-item identity (best-effort URL + + * synthesised timestamp) so consumers can correlate the call back to a + * work item. + * + * Runtime provider errors propagate (no internal try/catch) so the CLI + * factory emits the spec-014 `runtime` envelope and gadget wrappers can wrap + * with `formatGadgetError`. Read-back failures after a successful mutation + * fall back to a synthesised URL + timestamp rather than masking the mutation + * success (delegated to `readWorkItemContext`). + */ +export async function updateWorkItem(params: UpdateWorkItemParams): Promise { + const provider = getPMProvider(); + const hasTitle = Boolean(params.title); + const hasDescription = Boolean(params.description); + const labelIds = params.addLabelIds ?? []; + const hasLabels = labelIds.length > 0; + + if (!hasTitle && !hasDescription && !hasLabels) { + return buildNoopResult(params.workItemId); } - try { - const provider = getPMProvider(); + if (hasTitle || hasDescription) { + await provider.updateWorkItem(params.workItemId, { + title: params.title, + description: params.description, + }); + } - if (params.title || params.description) { - await provider.updateWorkItem(params.workItemId, { - title: params.title, - description: params.description, - }); + if (hasLabels) { + for (const labelId of labelIds) { + await provider.addLabel(params.workItemId, labelId); } + } - if (params.addLabelIds?.length) { - for (const labelId of params.addLabelIds) { - await provider.addLabel(params.workItemId, labelId); - } - } + const changedFields: Array<'title' | 'description'> = []; + if (hasTitle) changedFields.push('title'); + if (hasDescription) changedFields.push('description'); - const updated: string[] = []; - if (params.title) updated.push('title'); - if (params.description) updated.push('description'); - if (params.addLabelIds?.length) updated.push(`${params.addLabelIds.length} label(s)`); + const { title, workItemUrl, updatedAt } = await readWorkItemContext(params.workItemId); - return `Work item updated: ${updated.join(', ')}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Error updating work item: ${message}`); - } + return { + status: 'updated', + id: params.workItemId, + title: title ?? params.title ?? '', + url: workItemUrl, + updatedAt, + changedFields, + addedLabelIds: [...labelIds], + }; +} + +async function buildNoopResult(workItemId: string): Promise { + const { title, workItemUrl } = await readWorkItemContext(workItemId); + return { + status: 'noop', + id: workItemId, + title: title ?? '', + url: workItemUrl, + updatedAt: currentTimestamp(), + changedFields: [], + addedLabelIds: [], + message: 'Nothing to update - provide title, description, or labels', + }; } diff --git a/src/gadgets/pm/definitions.ts b/src/gadgets/pm/definitions.ts index ff6017f35..017f25a9d 100644 --- a/src/gadgets/pm/definitions.ts +++ b/src/gadgets/pm/definitions.ts @@ -72,6 +72,25 @@ export const postCommentDef: ToolDefinition = { }, ], }, + outputShape: { + summary: 'PostComment returns the new or updated progress comment context.', + fields: [ + { + name: 'status', + type: '"created" | "updated"', + description: + '`"created"` when a new comment was added; `"updated"` when an existing progress comment was edited.', + }, + { name: 'id', type: 'string', description: 'Provider-side comment ID.' }, + { name: 'workItemId', type: 'string', description: 'Parent work item ID.' }, + { name: 'workItemUrl', type: 'string', description: 'Parent work item URL.' }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 timestamp of when the comment was written.', + }, + ], + }, }; export const updateWorkItemDef: ToolDefinition = { @@ -121,6 +140,42 @@ export const updateWorkItemDef: ToolDefinition = { }, ], }, + outputShape: { + summary: + 'UpdateWorkItem returns the affected work item along with the fields that were actually changed.', + fields: [ + { + name: 'status', + type: '"updated" | "noop"', + description: + '`"updated"` if the provider accepted at least one field; `"noop"` when no title/description/labels were supplied.', + }, + { name: 'id', type: 'string', description: 'Work item ID.' }, + { name: 'title', type: 'string', description: 'Current title read back from the provider.' }, + { name: 'url', type: 'string', description: 'Work item URL.' }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 timestamp of the update (synthesised on `"noop"`).', + }, + { + name: 'changedFields', + type: '("title" | "description")[]', + description: 'Fields that were sent to the provider; empty array on `"noop"`.', + }, + { + name: 'addedLabelIds', + type: 'string[]', + description: 'Label IDs successfully attached; empty array when no labels were supplied.', + }, + { + name: 'message', + type: 'string', + optional: true, + description: 'Optional human-readable note (used on `"noop"`).', + }, + ], + }, }; export const createWorkItemDef: ToolDefinition = { @@ -166,6 +221,36 @@ export const createWorkItemDef: ToolDefinition = { }, ], }, + outputShape: { + summary: 'CreateWorkItem returns the newly-created work item.', + fields: [ + { + name: 'status', + type: '"created"', + description: 'Always `"created"` when the provider accepted the new work item.', + }, + { name: 'id', type: 'string', description: 'New work item ID.' }, + { name: 'title', type: 'string', description: 'Persisted title.' }, + { name: 'url', type: 'string', description: 'Work item URL.' }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 creation timestamp from the provider.', + }, + { + name: 'workflowStatus', + type: 'string', + optional: true, + description: 'Human-readable workflow state when the provider surfaces one on create.', + }, + { + name: 'workflowStatusId', + type: 'string', + optional: true, + description: 'Provider-native workflow state ID (Trello list ID, Linear state UUID, etc.).', + }, + ], + }, }; export const reportFrictionDef: ToolDefinition = { @@ -297,6 +382,48 @@ export const moveWorkItemDef: ToolDefinition = { 'Backlog-manager moving a freshly-picked item to TODO — guarded so a parallel run that already moved it cannot duplicate the move.', }, ], + outputShape: { + summary: 'MoveWorkItem reports whether the provider accepted the move, skipped it, or aborted.', + fields: [ + { + name: 'status', + type: '"moved" | "noop" | "aborted"', + description: + '`"moved"` on a successful move; `"noop"` when the work item was already in `destination`; `"aborted"` when `expectedSourceState` did not match the current state.', + }, + { name: 'id', type: 'string', description: 'Work item ID.' }, + { name: 'url', type: 'string', description: 'Work item URL.' }, + { + name: 'destination', + type: 'string', + description: 'The destination passed to the provider (list ID or status name).', + }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 timestamp; synthesised for `"noop"` and `"aborted"` outcomes.', + }, + { + name: 'previousStatus', + type: 'string', + optional: true, + description: + 'Current human-readable workflow status read back from the provider on the guarded path.', + }, + { + name: 'previousStatusId', + type: 'string', + optional: true, + description: 'Native ID of the previous status (Trello list ID, Linear state UUID, etc.).', + }, + { + name: 'message', + type: 'string', + optional: true, + description: 'Optional human-readable note explaining the outcome.', + }, + ], + }, }; export const addChecklistDef: ToolDefinition = { @@ -344,6 +471,40 @@ export const addChecklistDef: ToolDefinition = { comment: 'Add implementation steps with descriptions to a JIRA issue', }, ], + outputShape: { + summary: 'AddChecklist returns the freshly-created checklist and its item identities.', + fields: [ + { + name: 'status', + type: '"created"', + description: 'Always `"created"` when the provider accepted the checklist write.', + }, + { name: 'checklistId', type: 'string', description: 'New checklist ID.' }, + { + name: 'checklistName', + type: 'string', + description: 'Persisted checklist name (matches the `checklistName` argument).', + }, + { name: 'workItemId', type: 'string', description: 'Parent work item ID.' }, + { name: 'workItemUrl', type: 'string', description: 'Parent work item URL.' }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 timestamp from the provider read-back.', + }, + { + name: 'itemCount', + type: 'number', + description: 'Count of checklist items written by the provider.', + }, + { + name: 'itemIds', + type: 'string[]', + description: + "Per-item IDs surfaced by the provider. Best-effort: inline-description providers (Linear, JIRA) return deterministic hashed IDs; Trello's native fallback may return an empty array.", + }, + ], + }, }; export const pmUpdateChecklistItemDef: ToolDefinition = { @@ -379,6 +540,25 @@ export const pmUpdateChecklistItemDef: ToolDefinition = { comment: 'Mark an item as complete', }, ], + outputShape: { + summary: 'PMUpdateChecklistItem confirms the resulting checklist item state.', + fields: [ + { name: 'status', type: '"updated"', description: 'Always `"updated"` on success.' }, + { name: 'workItemId', type: 'string', description: 'Parent work item ID.' }, + { name: 'workItemUrl', type: 'string', description: 'Parent work item URL.' }, + { name: 'checkItemId', type: 'string', description: 'The affected checklist item ID.' }, + { + name: 'complete', + type: 'boolean', + description: 'Resulting state — `true` for `"complete"`, `false` for `"incomplete"`.', + }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 timestamp from the provider read-back.', + }, + ], + }, }; export const pmDeleteChecklistItemDef: ToolDefinition = { @@ -407,4 +587,18 @@ export const pmDeleteChecklistItemDef: ToolDefinition = { comment: 'Delete a descoped subtask from a JIRA issue', }, ], + outputShape: { + summary: 'PMDeleteChecklistItem confirms the removed checklist item.', + fields: [ + { name: 'status', type: '"deleted"', description: 'Always `"deleted"` on success.' }, + { name: 'workItemId', type: 'string', description: 'Parent work item ID.' }, + { name: 'workItemUrl', type: 'string', description: 'Parent work item URL.' }, + { name: 'checkItemId', type: 'string', description: 'The deleted checklist item ID.' }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 timestamp from the provider read-back.', + }, + ], + }, }; diff --git a/src/gadgets/shared/cli/examples.ts b/src/gadgets/shared/cli/examples.ts index 8bfcc8c74..c3eb223a3 100644 --- a/src/gadgets/shared/cli/examples.ts +++ b/src/gadgets/shared/cli/examples.ts @@ -1,4 +1,10 @@ -import type { ParameterDefinition, ToolDefinition, ToolExample } from '../toolDefinition.js'; +import type { + OutputShape, + OutputShapeField, + ParameterDefinition, + ToolDefinition, + ToolExample, +} from '../toolDefinition.js'; import { formatJsonExample, formatShellScalar, shellQuote } from './shellValues.js'; /** @@ -65,3 +71,46 @@ export function expectedShapeFor(paramDef: ParameterDefinition, example?: unknow } return paramDef.describe; } + +/** + * MNG-1427: render an `OutputShape` as a plain-text block suitable for + * appending to an oclif `static description`. The block surfaces in + * `cascade-tools --help` output beneath the regular + * description so agents reading help text see the same `success.data` + * contract as in the system-prompt guidance. + * + * The renderer is intentionally minimal — no markdown emphasis, no surrounding + * blank lines — because oclif word-wraps and indents the description block + * automatically. + */ +export function renderOutputShapeForHelp(shape: OutputShape): string { + let block = 'Output shape (success.data):'; + if (shape.summary) { + block += `\n${shape.summary}`; + } + if (shape.fields.length === 0) { + block += '\n- (shape declared but no fields documented)'; + return block; + } + for (const field of shape.fields) { + block += `\n${formatOutputShapeFieldForHelp(field)}`; + } + return block; +} + +function formatOutputShapeFieldForHelp(field: OutputShapeField): string { + const nameSuffix = field.optional ? '?' : ''; + const head = `- ${field.name}${nameSuffix} (${field.type})`; + return field.description ? `${head} — ${field.description}` : head; +} + +/** + * MNG-1427: assemble the oclif `static description` string by appending the + * rendered output-shape block (when declared) to the tool's base description. + * Used by `createCLICommand` so `--help` output picks up the contract without + * touching the prompt path. + */ +export function buildOclifDescription(def: ToolDefinition): string { + if (!def.outputShape) return def.description; + return `${def.description}\n\n${renderOutputShapeForHelp(def.outputShape)}`; +} diff --git a/src/gadgets/shared/cli/parseErrors.ts b/src/gadgets/shared/cli/parseErrors.ts index 8754a9f92..9680af322 100644 --- a/src/gadgets/shared/cli/parseErrors.ts +++ b/src/gadgets/shared/cli/parseErrors.ts @@ -1,33 +1,28 @@ -import { distance } from 'fastest-levenshtein'; - import type { EmitCliErrorOptions } from '../errorEnvelope.js'; - -const MAX_FLAG_SUGGESTION_DISTANCE = 2; -const MAX_FLAG_SUGGESTION_RATIO = 0.4; +import { suggestClosestCandidate } from './suggestions.js'; /** * For the given unknown flag and the command's declared flag names + aliases, * return the Levenshtein-closest canonical declared name if it passes the * distance threshold; otherwise null. + * + * Delegates scoring to the generic suggestion helper while preserving the + * canonical-flag-name return contract: when an alias is the closest match, + * the canonical name it points at is returned (so the agent always sees a real + * flag spelling, not an alias). */ export function suggestFlag( unknown: string, candidates: { canonical: string; aliases: readonly string[] }[], ): string | null { - let best: { canonical: string; dist: number } | null = null; + const names: { name: string; canonical: string; ratioBasis: string }[] = []; for (const { canonical, aliases } of candidates) { - for (const candidate of [canonical, ...aliases]) { - const d = distance(unknown, candidate); - if (best === null || d < best.dist) { - best = { canonical, dist: d }; - } + for (const name of [canonical, ...aliases]) { + names.push({ name, canonical, ratioBasis: canonical }); } } - if (best === null) return null; - const target = Math.max(unknown.length, best.canonical.length); - if (best.dist > MAX_FLAG_SUGGESTION_DISTANCE) return null; - if (target > 0 && best.dist / target > MAX_FLAG_SUGGESTION_RATIO) return null; - return best.canonical; + const closest = suggestClosestCandidate(unknown, names); + return closest?.canonical ?? null; } /** diff --git a/src/gadgets/shared/cli/suggestions.ts b/src/gadgets/shared/cli/suggestions.ts new file mode 100644 index 000000000..928eb42ba --- /dev/null +++ b/src/gadgets/shared/cli/suggestions.ts @@ -0,0 +1,70 @@ +import { distance } from 'fastest-levenshtein'; + +/** + * Maximum Levenshtein distance allowed between an unknown input and a + * suggested candidate. Distances above this are treated as "too far" and + * suppress the suggestion entirely. + */ +export const MAX_SUGGESTION_DISTANCE = 2; + +/** + * Maximum ratio of (distance / longer length) between an unknown input and a + * suggested candidate. Guards against weak matches on very short candidates + * (e.g. a distance of 2 on a 3-letter word, which would otherwise pass the + * distance gate but is statistically meaningless). + */ +export const MAX_SUGGESTION_RATIO = 0.4; + +export interface SuggestionCandidate { + name: string; + /** + * Optional spelling to use for the ratio gate after the candidate wins by + * edit distance. Defaults to `name`. + */ + ratioBasis?: string; +} + +/** + * Return the Levenshtein-closest candidate to `unknown`, provided the + * distance falls within the suggestion budget (distance `<= 2` and ratio + * `< 0.4` of the longer length). Returns `null` when no candidate is close + * enough to be a plausible typo or when the candidate list is empty. + * + * The helper is intentionally pure and string-only so it can power flag + * suggestions, command-name suggestions, and any other CLI ergonomics + * without loading oclif command classes. + * + * Ties are broken by input order: the first candidate matching the best + * (lowest) distance wins. + */ +export function suggestClosest(unknown: string, candidates: readonly string[]): string | null { + const closest = suggestClosestCandidate( + unknown, + candidates.map((name) => ({ name })), + ); + return closest?.name ?? null; +} + +/** + * Return the closest structured candidate using `name` for distance scoring. + * `ratioBasis` lets callers keep legacy canonical-name gating while matching + * aliases by edit distance. + */ +export function suggestClosestCandidate( + unknown: string, + candidates: readonly TCandidate[], +): TCandidate | null { + let best: { candidate: TCandidate; dist: number } | null = null; + for (const candidate of candidates) { + const d = distance(unknown, candidate.name); + if (best === null || d < best.dist) { + best = { candidate, dist: d }; + } + } + if (best === null) return null; + const ratioBasis = best.candidate.ratioBasis ?? best.candidate.name; + const target = Math.max(unknown.length, ratioBasis.length); + if (best.dist > MAX_SUGGESTION_DISTANCE) return null; + if (target > 0 && best.dist / target > MAX_SUGGESTION_RATIO) return null; + return best.candidate; +} diff --git a/src/gadgets/shared/cliCommandFactory.ts b/src/gadgets/shared/cliCommandFactory.ts index cf349963c..4ab471cfc 100644 --- a/src/gadgets/shared/cliCommandFactory.ts +++ b/src/gadgets/shared/cliCommandFactory.ts @@ -13,7 +13,7 @@ import { CredentialScopedCommand } from '../../cli/base.js'; import { massageBooleanFlagValues } from './cli/booleanArgv.js'; import { deriveCLICommand } from './cli/commandNames.js'; import { buildSink } from './cli/errorSink.js'; -import { buildOclifExamples } from './cli/examples.js'; +import { buildOclifDescription, buildOclifExamples } from './cli/examples.js'; import { buildFlagsRecord, collectBooleanFlagNames, collectCandidateFlags } from './cli/flags.js'; import { rejectMultipleStdinConsumers, @@ -81,10 +81,11 @@ export function createCLICommand( const commandPrefix = deriveCLICommand(def.name); const staticExamples = buildOclifExamples(def, commandPrefix); + const staticDescription = buildOclifDescription(def); const booleanFlagNames = collectBooleanFlagNames(def); class FactoryCommand extends CredentialScopedCommand { - static override description = def.description; + static override description = staticDescription; static override flags = flagsRecord; static override examples = staticExamples; diff --git a/src/gadgets/shared/errorEnvelope.ts b/src/gadgets/shared/errorEnvelope.ts index df1a76630..4e098f2a4 100644 --- a/src/gadgets/shared/errorEnvelope.ts +++ b/src/gadgets/shared/errorEnvelope.ts @@ -2,7 +2,8 @@ * Shared cascade-tools CLI error envelope (spec 014). * * Every cascade-tools failure — flag-parse, JSON-parse, missing-required, - * enum-mismatch, unknown-flag, auth, runtime — emits through {@link emitCliError}: + * enum-mismatch, unknown-flag, unknown-command, auth, runtime — emits through + * {@link emitCliError}: * * - Structured JSON on stdout: `{"success":false,"error":}` so agents * parsing CLI output see one stable surface. @@ -17,6 +18,15 @@ /** * Classification of a cascade-tools failure. Agents may branch on this. + * + * `unknown-command` is emitted when the user invokes a topic or subcommand + * that is not registered (e.g. `cascade-tools sm get-pr-diff` or + * `cascade-tools pm reaad-work-item`). The envelope's `expected` field + * carries the comma-separated list of valid candidates the typo was + * compared against; `hint` carries the runnable suggestion when one falls + * inside the Levenshtein-distance budget. See + * `src/cli/_shared/commandSuggestions.ts` for the pure helper that builds + * the envelope options. */ export type CliErrorType = | 'flag-parse' @@ -24,6 +34,7 @@ export type CliErrorType = | 'missing-required' | 'enum-mismatch' | 'unknown-flag' + | 'unknown-command' | 'auth' | 'runtime'; diff --git a/src/gadgets/shared/manifestGenerator.ts b/src/gadgets/shared/manifestGenerator.ts index 4f461000a..ef433b802 100644 --- a/src/gadgets/shared/manifestGenerator.ts +++ b/src/gadgets/shared/manifestGenerator.ts @@ -12,10 +12,14 @@ * - The `cliCommand` is derived from the definition name (kebab-cased) */ -import type { ToolManifest, ToolManifestParameter } from '../../agents/contracts/index.js'; +import type { + ToolManifest, + ToolManifestOutputShape, + ToolManifestParameter, +} from '../../agents/contracts/index.js'; import { deriveCLICommand } from './cli/commandNames.js'; import { findExampleForParam } from './cli/examples.js'; -import type { ParameterDefinition, ToolDefinition } from './toolDefinition.js'; +import type { OutputShape, ParameterDefinition, ToolDefinition } from './toolDefinition.js'; // --------------------------------------------------------------------------- // Helpers @@ -96,6 +100,32 @@ export function generateToolManifest( def: ToolDefinition, cliCommandOverride?: string, ): ToolManifest { + const parameters = buildManifestParameters(def); + const cliCommand = deriveCLICommand(def.name, cliCommandOverride); + + // MNG-1427: thread the optional output-shape descriptor unchanged into the + // manifest so downstream consumers (prompt renderer, generated help, + // integration tests) see the same shape declared on the definition. + const outputShape = buildManifestOutputShape(def.outputShape); + + return { + name: def.name, + description: def.description, + cliCommand, + parameters, + ...(outputShape ? { outputShape } : {}), + }; +} + +/** + * Build the `parameters` map for a manifest — including direct params from the + * definition AND file-input alternative flags. Extracted from + * {@link generateToolManifest} so the top-level function stays under the + * cognitive-complexity budget; the rendering rules (gadgetOnly exclusion, + * file-input cross-references, examples) are unchanged from the original + * inline code. + */ +function buildManifestParameters(def: ToolDefinition): Record { const parameters: Record = {}; // MNG-1059: build a quick lookup of paramName → fileFlag so the manifest @@ -127,28 +157,34 @@ export function generateToolManifest( } // Add file-input alternative flags to the manifest - if (def.cli?.fileInputAlternatives) { - for (const alt of def.cli.fileInputAlternatives) { - const description = - alt.description ?? - `Path to file with ${alt.paramName} (prefer over --${alt.paramName} for long content)`; - parameters[alt.fileFlag] = { - type: 'string', - description, - // MNG-1059: cross-reference back to the direct text param so the - // prompt renderer can group `--body` and `--body-file` semantically. - fileInputFor: alt.paramName, - // File flags are always optional (they are alternatives to the direct param) - }; - } + for (const alt of def.cli?.fileInputAlternatives ?? []) { + const description = + alt.description ?? + `Path to file with ${alt.paramName} (prefer over --${alt.paramName} for long content)`; + parameters[alt.fileFlag] = { + type: 'string', + description, + // MNG-1059: cross-reference back to the direct text param so the + // prompt renderer can group `--body` and `--body-file` semantically. + fileInputFor: alt.paramName, + // File flags are always optional (they are alternatives to the direct param) + }; } - const cliCommand = deriveCLICommand(def.name, cliCommandOverride); + return parameters; +} +function buildManifestOutputShape( + outputShape: OutputShape | undefined, +): ToolManifestOutputShape | undefined { + if (!outputShape) return undefined; return { - name: def.name, - description: def.description, - cliCommand, - parameters, + ...(outputShape.summary ? { summary: outputShape.summary } : {}), + fields: outputShape.fields.map((f) => ({ + name: f.name, + type: f.type, + ...(f.description ? { description: f.description } : {}), + ...(f.optional ? { optional: true } : {}), + })), }; } diff --git a/src/gadgets/shared/toolDefinition.ts b/src/gadgets/shared/toolDefinition.ts index 68541e877..bcffe81ae 100644 --- a/src/gadgets/shared/toolDefinition.ts +++ b/src/gadgets/shared/toolDefinition.ts @@ -246,6 +246,71 @@ export interface ToolExample { comment?: string; } +// --------------------------------------------------------------------------- +// Output shape (MNG-1427) +// --------------------------------------------------------------------------- + +/** + * A single field inside the `success.data` payload returned by the CLI. + * + * Output-shape fields are declarative metadata — the same descriptor flows + * unchanged through the generated manifest, the native-tool prompt guidance, + * and the generated `cascade-tools --help` output. Agents + * use them to learn which JSON keys to parse without having to run the tool + * first or read provider docs. + * + * @example + * { name: 'id', type: 'string', description: 'The comment ID' } + * { name: 'status', type: '"created" | "updated"', description: 'Whether a new comment was created or an existing one was updated' } + * { name: 'workflowStatus', type: 'string', optional: true, description: 'Human-readable workflow state' } + */ +export interface OutputShapeField { + /** Field key as it appears in `success.data` (camelCase by convention). */ + name: string; + /** + * Type description rendered into prompts/help verbatim. Use simple JSON-ish + * notation: `'string'`, `'number'`, `'boolean'`, `'string[]'`, or a literal + * union like `'"created" | "updated"'`. + */ + type: string; + /** Optional one-line explanation of the field. */ + description?: string; + /** Whether the field may be absent from `success.data`. Defaults to `false`. */ + optional?: boolean; +} + +/** + * Declarative description of the shape of `success.data` returned by a tool. + * + * The metadata is rendered (not interpreted) by the prompt and help layers, + * so the same descriptor populates: + * - Native-tool prompt guidance (a concise field-list rendered after the + * command block in the system prompt). + * - `cascade-tools --help` output (an "OUTPUT SHAPE" + * section appended to the description). + * - Generated manifests (so downstream consumers can re-render or assert on + * the contract). + * + * Omit on read-only commands whose response shapes are already self-evident + * from their underlying read API. Populate on mutation commands so agents can + * confirm affected IDs / URLs / statuses without parsing free-form prose. + */ +export interface OutputShape { + /** + * Optional one-line summary of what `success.data` represents. Rendered as + * a single sentence above the field list. + * + * @example 'A PostComment success returns the new (or updated) progress comment context.' + */ + summary?: string; + /** + * Field-by-field description of `success.data`. At least one entry is + * expected; an empty array is treated as "shape declared but unspecified" + * and rendered with a note. + */ + fields: OutputShapeField[]; +} + // --------------------------------------------------------------------------- // Hook types // --------------------------------------------------------------------------- @@ -383,4 +448,17 @@ export interface ToolDefinition { * Use for gadgets that signal session termination (e.g., Finish). */ exclusive?: boolean; + + /** + * MNG-1427: declarative description of `success.data` shape returned by + * this tool. When set, the field is propagated unchanged into the generated + * manifest, rendered into native-tool prompt guidance, and surfaced in + * `cascade-tools --help` output so agents know which + * JSON fields to parse. + * + * Populate on mutation commands (PostComment, CreatePR, etc.). Read-only + * commands typically omit this field; their response shapes are already + * conveyed by the description and the underlying read API. + */ + outputShape?: OutputShape; } diff --git a/src/github/client.ts b/src/github/client.ts index 41dc3f217..b397ffa9f 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -43,9 +43,31 @@ export interface PRReviewComment { login: string; }; createdAt: string; + /** + * GitHub-supplied timestamp for the last update of the comment. Optional — + * only present on writes (createReplyForReviewComment) where GitHub returns + * `updated_at` alongside the new comment. Read paths + * (`listReviewComments`) don't surface it because the consumer doesn't need + * it for context shaping. + */ + updatedAt?: string; inReplyToId?: number; } +/** + * Result shape for issue-comment write mutations (`createPRComment`, + * `updatePRComment`). Includes the GitHub-supplied `updated_at` so downstream + * structured-mutation helpers can surface a real provider timestamp rather + * than a synthetic `new Date().toISOString()` (MNG-1425 / spec MNG-1422). + */ +export interface CreatedIssueComment { + id: number; + htmlUrl: string; + body: string; + createdAt: string; + updatedAt: string; +} + export interface PRReview { id: number; state: 'approved' | 'changes_requested' | 'commented' | 'dismissed'; @@ -116,6 +138,22 @@ export interface CreatedPR { title: string; } +/** + * Result shape for `createPRReview`. Surfaces the GitHub-supplied `state` + * (e.g. `APPROVED`, `CHANGES_REQUESTED`, `COMMENTED`) and `submitted_at` so + * downstream structured-mutation helpers can record the real provider + * timestamp (MNG-1425 / spec MNG-1422). `submittedAt` is nullable because + * `pulls.createReview` returns `null` for `PENDING` reviews even though + * gadget callers always submit (event != null). + */ +export interface CreatedPRReview { + id: number; + htmlUrl: string; + body: string; + state: string; + submittedAt: string | null; +} + export const githubClient = { async getPR(owner: string, repo: string, prNumber: number): Promise { logger.debug('Fetching PR', { owner, repo, prNumber }); @@ -191,6 +229,7 @@ export const githubClient = { login: data.user?.login || 'unknown', }, createdAt: data.created_at, + updatedAt: data.updated_at, inReplyToId: data.in_reply_to_id, }; }, @@ -200,7 +239,7 @@ export const githubClient = { repo: string, prNumber: number, body: string, - ): Promise<{ id: number; htmlUrl: string }> { + ): Promise { logger.debug('Creating PR comment', { owner, repo, prNumber }); const { data } = await getClient().issues.createComment({ owner, @@ -211,6 +250,9 @@ export const githubClient = { return { id: data.id, htmlUrl: data.html_url, + body: data.body ?? '', + createdAt: data.created_at, + updatedAt: data.updated_at, }; }, @@ -219,7 +261,7 @@ export const githubClient = { repo: string, commentId: number, body: string, - ): Promise<{ id: number; htmlUrl: string }> { + ): Promise { logger.debug('Updating PR comment', { owner, repo, commentId }); const { data } = await getClient().issues.updateComment({ owner, @@ -230,6 +272,9 @@ export const githubClient = { return { id: data.id, htmlUrl: data.html_url, + body: data.body ?? '', + createdAt: data.created_at, + updatedAt: data.updated_at, }; }, @@ -401,7 +446,7 @@ export const githubClient = { event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', body: string, comments?: Array<{ path: string; line?: number; body: string }>, - ): Promise<{ id: number; htmlUrl: string }> { + ): Promise { logger.debug('Creating PR review', { owner, repo, prNumber, event }); const { data } = await getClient().pulls.createReview({ owner, @@ -418,6 +463,9 @@ export const githubClient = { return { id: data.id, htmlUrl: data.html_url, + body: data.body ?? '', + state: data.state, + submittedAt: data.submitted_at ?? null, }; }, diff --git a/src/jira/client.ts b/src/jira/client.ts index a2a24629b..c00f6799e 100644 --- a/src/jira/client.ts +++ b/src/jira/client.ts @@ -60,6 +60,11 @@ export const jiraClient = { 'subtasks', 'attachment', 'comment', + // MNG-1422: surface provider timestamps so the PM adapter can + // hydrate `WorkItem.createdAt` / `updatedAt`. JIRA exposes + // `created` / `updated` directly on the issue fields object. + 'created', + 'updated', ], }); }, @@ -212,7 +217,10 @@ export const jiraClient = { return (issue.fields?.labels as string[]) ?? []; }, - async searchIssues(jql: string, fields: string[] = ['summary', 'status', 'labels']) { + async searchIssues( + jql: string, + fields: string[] = ['summary', 'status', 'labels', 'created', 'updated'], + ) { logger.debug('Searching JIRA issues', { jql }); const result = await getClient().issueSearch.searchForIssuesUsingJql({ jql, diff --git a/src/pm/jira/adapter.ts b/src/pm/jira/adapter.ts index f5d1f9e60..2fbf32792 100644 --- a/src/pm/jira/adapter.ts +++ b/src/pm/jira/adapter.ts @@ -59,6 +59,8 @@ interface JiraConfig { interface JiraComment { id?: string; created?: string; + /** ISO 8601 last-updated timestamp. JIRA exposes this on the comment payload as `updated`. */ + updated?: string; body?: unknown; author?: { accountId?: string; displayName?: string; emailAddress?: string }; } @@ -73,6 +75,10 @@ interface JiraSearchIssue { labels?: string[]; subtasks?: JiraSubtask[]; attachment?: JiraAttachment[]; + /** ISO 8601 timestamp of issue creation. JIRA exposes this on `fields.created`. */ + created?: string; + /** ISO 8601 timestamp of last issue update. JIRA exposes this on `fields.updated`. */ + updated?: string; }; } @@ -116,6 +122,13 @@ export class JiraPMProvider implements PMProvider { ? resolveJiraMediaUrls(mediaRefs, attachments, 'description') : undefined; + // MNG-1422: JIRA returns `fields.created` / `fields.updated` as ISO + // timestamps when those fields are requested (the client requests them + // by default). Surface them as optional fields without altering + // existing behavior when the values are missing. + const created = (fields as { created?: string }).created; + const updated = (fields as { updated?: string }).updated; + return { id: issue.key ?? id, title: (fields.summary as string) ?? '', @@ -129,6 +142,8 @@ export class JiraPMProvider implements PMProvider { }), ), ...(inlineMedia !== undefined && inlineMedia.length > 0 ? { inlineMedia } : {}), + ...(created ? { createdAt: created } : {}), + ...(updated ? { updatedAt: updated } : {}), }; } @@ -143,6 +158,11 @@ export class JiraPMProvider implements PMProvider { name: c.author?.displayName ?? '', username: c.author?.emailAddress ?? '', }, + // JIRA comments carry both `created` and `updated` ISO timestamps. + // Preserve them on the normalized shape; fall back to `created` + // for `updatedAt` when JIRA hasn't recorded a separate edit. + ...(c.created ? { createdAt: c.created } : {}), + ...((c.updated ?? c.created) ? { updatedAt: c.updated ?? c.created } : {}), })); } @@ -230,6 +250,8 @@ export class JiraPMProvider implements PMProvider { labels: ((issue.fields?.labels as string[]) ?? []).map( (l: string): WorkItemLabel => ({ id: l, name: l }), ), + ...(issue.fields?.created ? { createdAt: issue.fields.created } : {}), + ...(issue.fields?.updated ? { updatedAt: issue.fields.updated } : {}), })); } diff --git a/src/pm/linear/adapter.ts b/src/pm/linear/adapter.ts index 2eccc8b85..d7ed8d144 100644 --- a/src/pm/linear/adapter.ts +++ b/src/pm/linear/adapter.ts @@ -154,6 +154,12 @@ export class LinearPMProvider implements PMProvider { }), ), inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined, + // Linear surfaces `createdAt` / `updatedAt` directly on the issue + // payload (see `LinearIssue` in `src/linear/types.ts`). Preserve + // empty-string sentinels as undefined to keep callers from + // branching on falsy strings. + ...(issue.createdAt ? { createdAt: issue.createdAt } : {}), + ...(issue.updatedAt ? { updatedAt: issue.updatedAt } : {}), }; } @@ -171,6 +177,8 @@ export class LinearPMProvider implements PMProvider { username: c.user?.email ?? '', }, inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined, + ...(c.createdAt ? { createdAt: c.createdAt } : {}), + ...(c.updatedAt ? { updatedAt: c.updatedAt } : {}), }; }); } @@ -217,6 +225,8 @@ export class LinearPMProvider implements PMProvider { description: issue.description ?? '', url: issue.url, labels: [], + ...(issue.createdAt ? { createdAt: issue.createdAt } : {}), + ...(issue.updatedAt ? { updatedAt: issue.updatedAt } : {}), }; } @@ -248,6 +258,8 @@ export class LinearPMProvider implements PMProvider { color: l.color, }), ), + ...(issue.createdAt ? { createdAt: issue.createdAt } : {}), + ...(issue.updatedAt ? { updatedAt: issue.updatedAt } : {}), })); } diff --git a/src/pm/trello/adapter.ts b/src/pm/trello/adapter.ts index 4a57463f5..3a5143625 100644 --- a/src/pm/trello/adapter.ts +++ b/src/pm/trello/adapter.ts @@ -67,6 +67,11 @@ export class TrelloPMProvider implements PMProvider { }), ), inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined, + // Trello surfaces only `dateLastActivity` — map it to `updatedAt` + // when present. No `createdAt` is reliably available (cards expose + // only the most recent activity), so we leave it undefined rather + // than synthesising one. + ...(card.dateLastActivity ? { updatedAt: card.dateLastActivity } : {}), }; } @@ -84,6 +89,11 @@ export class TrelloPMProvider implements PMProvider { username: c.memberCreator.username, }, inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined, + // Trello's action `date` is the creation/edit time for the + // commentCard action. Surface it on both timestamp fields so + // downstream consumers don't need to re-encode the `date` + // field they already see. + ...(c.date ? { createdAt: c.date, updatedAt: c.date } : {}), }; }); } @@ -124,6 +134,12 @@ export class TrelloPMProvider implements PMProvider { color: l.color, }), ), + // New cards have a fresh `dateLastActivity`. Surface it on both + // timestamp fields — for a newly-created card the activity timestamp + // is effectively the creation timestamp. + ...(card.dateLastActivity + ? { createdAt: card.dateLastActivity, updatedAt: card.dateLastActivity } + : {}), }; } @@ -148,6 +164,7 @@ export class TrelloPMProvider implements PMProvider { color: l.color, }), ), + ...(card.dateLastActivity ? { updatedAt: card.dateLastActivity } : {}), })); } diff --git a/src/pm/types.ts b/src/pm/types.ts index 31aa233f1..95900b778 100644 --- a/src/pm/types.ts +++ b/src/pm/types.ts @@ -101,6 +101,20 @@ export interface WorkItem { labels: WorkItemLabel[]; /** Inline media references parsed from the work item description */ inlineMedia?: MediaReference[]; + /** + * ISO 8601 timestamp of when the work item was created, as reported by the + * provider. Optional because some providers (or legacy code paths) may not + * surface this. Downstream mutation-result helpers prefer this over + * synthetic timestamps — see `src/gadgets/pm/core/mutationResults.ts`. + */ + createdAt?: string; + /** + * ISO 8601 timestamp of the last provider-reported update. Optional for the + * same reasons as `createdAt`. Mutation-result helpers fall back to the + * current ISO timestamp only when the mutation was a synthetic no-op or + * aborted outcome — never to pretend a provider actually wrote new data. + */ + updatedAt?: string; } export interface WorkItemLabel { @@ -120,6 +134,20 @@ export interface WorkItemComment { }; /** Inline media references parsed from the comment text */ inlineMedia?: MediaReference[]; + /** + * ISO 8601 timestamp of when the comment was created, as reported by the + * provider. Optional — when present, mutation-result helpers prefer this + * over synthetic timestamps. Trello/JIRA derive this from the comment's + * `date`/`created` field; Linear surfaces it from `createdAt`. + */ + createdAt?: string; + /** + * ISO 8601 timestamp of the last provider-reported update on the comment. + * Optional. Linear exposes this distinctly from `createdAt`; Trello/JIRA + * may map this to the same value as `createdAt` when no edit history is + * surfaced. + */ + updatedAt?: string; } export interface Checklist { diff --git a/src/router/bullmq-workers.ts b/src/router/bullmq-workers.ts index c02a4ce25..267a9e2e0 100644 --- a/src/router/bullmq-workers.ts +++ b/src/router/bullmq-workers.ts @@ -6,15 +6,49 @@ * and Sentry capture). */ -import { type ConnectionOptions, type Job, Worker } from 'bullmq'; +import { type ConnectionOptions, type Job, UnrecoverableError, Worker } from 'bullmq'; import { captureException } from '../sentry.js'; import { logger } from '../utils/logging.js'; import { parseRedisUrl } from '../utils/redis.js'; -import { releaseLocksForFailedJob } from './dispatch-compensator.js'; +import { recordSpawnFailureStub, releaseLocksForFailedJob } from './dispatch-compensator.js'; // Re-export so existing callers (worker-manager.ts) don't need to change imports. export { parseRedisUrl }; +/** + * BullMQ emits the `failed` event on EVERY attempt, including intermediate + * retries: `Worker.handleFailed` calls `job.moveToFailed(...)` and then + * `emit('failed', ...)` unconditionally. On a retryable attempt + * (`attemptsMade < attempts` and the error is not an `UnrecoverableError`), + * `moveToFailed` re-queues the job to `delayed` and leaves `finishedOn` UNSET; + * only a terminal failure (retries exhausted, or `UnrecoverableError`) sets + * `finishedOn`. + * + * Spec 015 deliberately propagates transient spawn errors (registry 429 / + * ECONNRESET / ECONNREFUSED / ENOTFOUND / 409 / SLOT_WAIT_TIMEOUT) unchanged so + * BullMQ retries them via `attempts: 4`. A side effect — recorded as a `failed` + * stub run row per emission — would therefore plant one bogus row per + * intermediate retry for a transient error that later succeeds. So any such + * side effect must run ONLY on a terminal failure. + * + * `finishedOn` is the canonical terminal signal and matches BullMQ's own + * retry/terminal branch; the exhausted-attempts and `UnrecoverableError` checks + * are defensive fallbacks should a BullMQ build leave `finishedOn` unset on a + * terminal emission. The name check guards against a cross-realm/duplicate-copy + * `UnrecoverableError` that fails `instanceof`. + */ +export function isTerminalDispatchFailure(job: Job, err: unknown): boolean { + if (typeof job.finishedOn === 'number') return true; + if ( + err instanceof UnrecoverableError || + (err as { name?: string } | null)?.name === 'UnrecoverableError' + ) { + return true; + } + const attempts = job.opts?.attempts; + return typeof attempts === 'number' && attempts > 0 && job.attemptsMade >= attempts; +} + export interface QueueWorkerConfig { queueName: string; /** Human-readable label used in log messages and Sentry tags */ @@ -72,6 +106,21 @@ export function createQueueWorker(config: QueueWorkerConfig): Wo tags: { source: 'dispatch_compensator_uncaught', queue: queueName }, }); }); + // Insert a `failed` stub run row so the dispatch is visible in the + // dashboard / `cascade runs list`. The `failed` event fires on EVERY + // attempt, so gate this to a TERMINAL failure — otherwise a transient + // spawn error (deliberately retried per spec 015) that later succeeds + // would leave one bogus `failed` row per intermediate retry. Lock + // compensation above must still run on every attempt. Recorder never + // throws. See `isTerminalDispatchFailure`. + if (isTerminalDispatchFailure(job, err)) { + void recordSpawnFailureStub(job.data, err).catch((stubErr) => { + logger.warn('[WorkerManager] stub-row recorder threw — defensively logged', { + jobId: job.id, + error: String(stubErr), + }); + }); + } } }); diff --git a/src/router/container-manager.ts b/src/router/container-manager.ts index 056a76a9b..ce4785091 100644 --- a/src/router/container-manager.ts +++ b/src/router/container-manager.ts @@ -33,7 +33,7 @@ import { extractProjectIdFromJob, extractWorkItemId, } from './worker-env.js'; -import { isImageNotFoundError } from './worker-snapshots.js'; +import { isImageNotFoundError, pullImageOnce } from './worker-snapshots.js'; import { buildWorkerContainerName, resolveSpawnSettings } from './worker-spawn-settings.js'; // Re-export from sub-modules so existing callers importing from container-manager.ts @@ -100,6 +100,73 @@ async function launchConfiguredWorkerContainer( ); } +/** + * Launch a worker container; if the **base** image is missing, pull it once and + * retry. Snapshot-image 404s propagate so the snapshot fallback path in + * `spawnWorker` still fires — snapshot images are local commits, not in any + * registry, so pulling them never helps. + * + * Closes the 2026-06-15 outage class where a host-side prune of + * `cascade-worker:latest` produced silent terminal `UnrecoverableError`s for + * every spawn — see the post-mortem in `docs/specs/` and the dispatch-error + * classifier comment that already promised this behaviour. + */ +async function launchOrPullAndRetry( + job: Job, + jobId: string, + containerName: string, + projectId: string | null, + workItemId: string | undefined, + agentType: string | undefined, + config: WorkerContainerLaunchConfig, +): Promise { + try { + await launchConfiguredWorkerContainer( + job, + jobId, + containerName, + projectId, + workItemId, + agentType, + config, + ); + } catch (err) { + if (!isImageNotFoundError(err) || config.workerImage !== routerConfig.workerImage) { + throw err; + } + const imageName = config.workerImage; + logger.info('[WorkerManager] Base worker image missing — pulling', { jobId, imageName }); + try { + await pullImageOnce(imageName); + } catch (pullErr) { + logger.error('[WorkerManager] Failed to pull base worker image:', { + jobId, + imageName, + error: String(pullErr), + }); + captureException(pullErr, { + tags: { source: 'worker_image_pull_fallback', jobType: job.data.type }, + extra: { jobId, imageName }, + }); + // Propagate the pull error (not the original 404) so the dispatch-error + // classifier can see its actual shape — registry 429s, ECONNRESET, and + // other transient pull failures should burn a BullMQ retry instead of + // being misclassified as terminal via `isImageNotFoundError`. + throw pullErr; + } + logger.info('[WorkerManager] Base image pulled, retrying spawn', { jobId, imageName }); + await launchConfiguredWorkerContainer( + job, + jobId, + containerName, + projectId, + workItemId, + agentType, + config, + ); + } +} + /** * Spawn a worker container for a job. * Sets up timeout tracking and monitors container exit asynchronously. @@ -159,7 +226,7 @@ export async function spawnWorker(job: Job): Promise { }; try { - await launchConfiguredWorkerContainer( + await launchOrPullAndRetry( job, jobId, containerName, @@ -185,7 +252,7 @@ export async function spawnWorker(job: Job): Promise { workerEnv: fallbackEnv, }; try { - await launchConfiguredWorkerContainer( + await launchOrPullAndRetry( job, jobId, containerName, diff --git a/src/router/dispatch-compensator.ts b/src/router/dispatch-compensator.ts index 826c9f5cb..c542b56f6 100644 --- a/src/router/dispatch-compensator.ts +++ b/src/router/dispatch-compensator.ts @@ -12,9 +12,13 @@ * BullMQ worker; instead we capture to Sentry and log, then resolve. */ +import { resolveEngineName } from '../backends/resolution.js'; +import { completeRun, createRun } from '../db/repositories/runsRepository.js'; import { captureException } from '../sentry.js'; +import type { TriggerResult } from '../types/index.js'; import { logger } from '../utils/logging.js'; import { clearAgentTypeEnqueued, clearRecentlyDispatched } from './agent-type-lock.js'; +import { loadProjectConfig } from './config.js'; import type { CascadeJob } from './queue.js'; import { clearWorkItemEnqueued } from './work-item-lock.js'; import { extractAgentType, extractProjectIdFromJob, extractWorkItemId } from './worker-env.js'; @@ -47,3 +51,58 @@ export async function releaseLocksForFailedJob(data: unknown): Promise { }); } } + +/** + * Insert a `failed` stub run row so a dispatch that never produced a worker + * still surfaces in the dashboard and `cascade runs list`. Called from + * BullMQ's `worker.on('failed')` handler, so it fires exactly once per + * permanently-failed job — either after the retry budget is exhausted + * (transient) or immediately when `UnrecoverableError` is wrapped (terminal). + * Intermediate retries do NOT trigger it, so a transient Docker socket error + * that BullMQ later recovers from leaves no stub behind to mislead operators. + * + * Without this row, the worker (which calls `tryCreateRun` at boot) never + * runs, `failOrphanedRun` no-ops because there is no `status='running'` row, + * and the failure is invisible outside Sentry — the 2026-06-15 Damisa + * outage class. + * + * Best-effort: any DB failure here is logged at WARN and swallowed. + */ +export async function recordSpawnFailureStub(data: unknown, err: unknown): Promise { + try { + const projectId = await extractProjectIdFromJob(data as CascadeJob); + if (!projectId) return; + const agentType = extractAgentType(data as CascadeJob); + if (!agentType) return; + const workItemId = extractWorkItemId(data as CascadeJob); + const triggerResult = (data as { triggerResult?: TriggerResult }).triggerResult; + const prNumber = triggerResult?.prNumber; + const triggerType = triggerResult?.agentInput?.triggerType; + let engine = 'unknown'; + try { + const { fullProjects } = await loadProjectConfig(); + const projectCfg = fullProjects.find((p) => p.id === projectId); + if (projectCfg) engine = resolveEngineName(agentType, projectCfg); + } catch { + // engine column is NOT NULL — fall through with 'unknown' rather + // than letting a config-read problem block the visibility stub. + } + const runId = await createRun({ + projectId, + workItemId, + prNumber, + agentType, + engine, + triggerType, + }); + await completeRun(runId, { + status: 'failed', + durationMs: 0, + error: `Worker spawn failed: ${String(err)}`, + }); + } catch (dbErr) { + logger.warn('[dispatch-compensator] failed to record spawn-failure stub run', { + error: String(dbErr), + }); + } +} diff --git a/src/router/platformClients/credentials.ts b/src/router/platformClients/credentials.ts index 5f8468389..6a602147c 100644 --- a/src/router/platformClients/credentials.ts +++ b/src/router/platformClients/credentials.ts @@ -73,7 +73,7 @@ export async function resolveLinearCredentials( * - `'github'`: resolves the `webhook_secret` credential from the SCM integration. * - `'trello'`: resolves the `api_secret` credential from the PM integration. * Trello computes webhook HMAC signatures using the API Secret (shown below the - * API Key at https://trello.com/app-key), not the public API Key. + * API Key at https://trello.com/power-ups/admin/), not the public API Key. * - `'jira'`: resolves the `webhook_secret` credential from the PM integration. * - `'linear'`: resolves the `webhook_secret` credential from the PM integration. * diff --git a/src/router/worker-snapshots.ts b/src/router/worker-snapshots.ts index d3e2ad84a..0a316f9a3 100644 --- a/src/router/worker-snapshots.ts +++ b/src/router/worker-snapshots.ts @@ -108,3 +108,50 @@ export function isImageNotFoundError(err: unknown): boolean { String(err).toLowerCase().includes('no such image') ); } + +/** Default budget for an on-demand image pull triggered by base-image self-heal. */ +export const IMAGE_PULL_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * Single-flight in-flight pull cache. A second caller for the same image while + * the first pull is running awaits the same promise instead of triggering a + * concurrent pull. The entry is cleared on settle so a subsequent prune still + * triggers a fresh pull next time. + */ +const inFlightPulls = new Map>(); + +/** + * Pull a Docker image, deduplicating concurrent requests by image name and + * enforcing a wall-clock timeout. + * + * Used by the spawn self-heal path in `container-manager.ts` when the base + * worker image was pruned from the host between spawns. Failure cases: + * - Pull stream emits an error → reject with that error. + * - Pull exceeds `timeoutMs` → reject with a `pull timeout` error; the + * underlying stream is abandoned (no cancel hook in dockerode). + * - Registry auth missing / network down → propagates the dockerode error; + * the caller still has the original 404 to re-throw. + */ +export function pullImageOnce(imageName: string, timeoutMs = IMAGE_PULL_TIMEOUT_MS): Promise { + const existing = inFlightPulls.get(imageName); + if (existing) return existing; + + const promise = (async () => { + const pullStream = (await docker.pull(imageName)) as NodeJS.ReadableStream; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`pull timeout after ${timeoutMs}ms for ${imageName}`)); + }, timeoutMs); + docker.modem.followProgress(pullStream, (err: Error | null) => { + clearTimeout(timer); + if (err) reject(err); + else resolve(); + }); + }); + })().finally(() => { + inFlightPulls.delete(imageName); + }); + + inFlightPulls.set(imageName, promise); + return promise; +} diff --git a/src/trello/client.ts b/src/trello/client.ts index 1064da0ed..80e6f9d81 100644 --- a/src/trello/client.ts +++ b/src/trello/client.ts @@ -81,6 +81,13 @@ export interface TrelloCard { shortUrl: string; idList: string; labels: Array<{ id: string; name: string; color: string }>; + /** + * Trello does not expose a true creation timestamp on cards; the closest + * provider field is `dateLastActivity` (last touched). We surface it as + * `dateLastActivity` so the PM adapter can wire it into `WorkItem.updatedAt` + * without pretending it's a creation marker. + */ + dateLastActivity?: string; } function mapCardResponse(card: { @@ -91,6 +98,7 @@ function mapCardResponse(card: { shortUrl?: string; idList?: string; labels?: unknown; + dateLastActivity?: string; }): TrelloCard { const labels = card.labels as Array<{ id?: string; name?: string; color?: string }> | undefined; return { @@ -101,6 +109,7 @@ function mapCardResponse(card: { shortUrl: card.shortUrl || '', idList: card.idList || '', labels: mapLabels(labels), + ...(card.dateLastActivity ? { dateLastActivity: card.dateLastActivity } : {}), }; } diff --git a/src/utils/llmMetrics.ts b/src/utils/llmMetrics.ts index b58e9b25e..c55c99806 100644 --- a/src/utils/llmMetrics.ts +++ b/src/utils/llmMetrics.ts @@ -10,6 +10,8 @@ import type { TokenUsage } from 'llmist'; */ const MODEL_PRICING: Record = { // Anthropic Claude 4 family + 'anthropic:claude-opus-4-8': { input: 5.0, output: 25.0, cachedInput: 0.5 }, + 'anthropic:claude-opus-4-8[1m]': { input: 5.0, output: 25.0, cachedInput: 0.5 }, 'anthropic:claude-opus-4-7': { input: 5.0, output: 25.0, cachedInput: 0.5 }, 'anthropic:claude-opus-4-7[1m]': { input: 5.0, output: 25.0, cachedInput: 0.5 }, 'anthropic:claude-opus-4-6[1m]': { input: 5.0, output: 25.0, cachedInput: 0.5 }, diff --git a/tests/helpers/fakePMProvider.ts b/tests/helpers/fakePMProvider.ts index 787c0d144..cc4bad724 100644 --- a/tests/helpers/fakePMProvider.ts +++ b/tests/helpers/fakePMProvider.ts @@ -116,6 +116,44 @@ function nextId(prefix: string): string { return `${prefix}-${_idCounter}`; } +/** + * Deterministic timestamp source used by the fake provider so unit tests can + * assert exact ISO strings without `vi.useFakeTimers()`. Tests can override + * the next timestamp returned via `setNextFakeTimestamp`. When no override + * is queued, the helper falls back to a monotonic stamp based on a fixed + * epoch so consecutive calls still produce stable, ordered values. + * + * MNG-1422: the mutation-result contracts pin `updatedAt` semantics; the fake + * must produce predictable provider timestamps so callers can verify the + * contract under both `'ok'` (provider stamp) and `'no-op'` (synthetic + * fallback) paths. + */ +const FAKE_EPOCH_MS = Date.UTC(2026, 0, 1, 0, 0, 0); // 2026-01-01T00:00:00.000Z +let _timestampCounter = 0; +const _timestampOverrides: string[] = []; + +function nextFakeTimestamp(): string { + const override = _timestampOverrides.shift(); + if (override) return override; + _timestampCounter += 1; + return new Date(FAKE_EPOCH_MS + _timestampCounter * 1000).toISOString(); +} + +/** + * Queue a specific timestamp for the next provider write. Multiple queued + * values are consumed FIFO. Useful when a test needs to assert that a fresh + * provider write flows through to `WorkItem.updatedAt`. + */ +export function setNextFakeTimestamp(iso: string): void { + _timestampOverrides.push(iso); +} + +/** Reset the deterministic timestamp counter + queue. */ +export function resetFakeTimestamps(): void { + _timestampCounter = 0; + _timestampOverrides.length = 0; +} + // ── The provider implementation ───────────────────────────────────────── export function createFakePMProvider(): { provider: PMProvider; store: FakePMStore } { @@ -139,15 +177,19 @@ export function createFakePMProvider(): { provider: PMProvider; store: FakePMSto if (!item) throw new Error(`Fake work item '${id}' not found`); if (updates.title !== undefined) item.title = updates.title; if (updates.description !== undefined) item.description = updates.description; + item.updatedAt = nextFakeTimestamp(); }, async addComment(id, text): Promise { const commentId = nextId('comment'); + const timestamp = nextFakeTimestamp(); const comment: WorkItemComment = { id: commentId, - date: new Date().toISOString(), + date: timestamp, text, author: { id: 'fake-user', name: 'Fake User', username: 'fake' }, + createdAt: timestamp, + updatedAt: timestamp, }; const list = store.comments.get(id) ?? []; list.push(comment); @@ -160,6 +202,7 @@ export function createFakePMProvider(): { provider: PMProvider; store: FakePMSto const comment = list.find((c) => c.id === commentId); if (!comment) throw new Error(`Fake comment '${commentId}' not found on '${id}'`); comment.text = text; + comment.updatedAt = nextFakeTimestamp(); }, async createWorkItem(config): Promise { @@ -168,6 +211,7 @@ export function createFakePMProvider(): { provider: PMProvider; store: FakePMSto if (!container) throw new Error(`Fake container '${containerId}' not found`); const id = nextId('item'); + const timestamp = nextFakeTimestamp(); const labels: WorkItemLabel[] = (config.labels ?? []).map((raw) => { const labelId = parseLabelId(raw); const existing = store.labels.get(labelId); @@ -183,6 +227,8 @@ export function createFakePMProvider(): { provider: PMProvider; store: FakePMSto status: 'Todo', labels, containerId, + createdAt: timestamp, + updatedAt: timestamp, }; store.workItems.set(id, workItem); container.workItemIds.add(id); @@ -238,6 +284,7 @@ export function createFakePMProvider(): { provider: PMProvider; store: FakePMSto // throw on test-provided values that aren't in the store. item.status = branded; } + item.updatedAt = nextFakeTimestamp(); }, async addLabel(id, labelIdOrName): Promise { diff --git a/tests/helpers/mockPMProvider.ts b/tests/helpers/mockPMProvider.ts index 51f2220da..d94458f1b 100644 --- a/tests/helpers/mockPMProvider.ts +++ b/tests/helpers/mockPMProvider.ts @@ -2,6 +2,75 @@ import { vi } from 'vitest'; import type { MediaReference } from '../../src/pm/types.js'; +/** + * Stable deterministic timestamp the mock provider stamps onto generated + * fixtures (work items, comments). Tests that need to assert exact + * timestamps can reach for this constant rather than calling + * `new Date().toISOString()` (which changes per test run). + * + * MNG-1422: introduced alongside the mutation-result contracts so tests can + * verify provider timestamps flow through to `WorkItem.updatedAt` / + * `WorkItemComment.updatedAt` without timer mocking. + */ +export const MOCK_PROVIDER_TIMESTAMP = '2026-01-01T00:00:00.000Z'; + +/** + * Build a minimal WorkItem fixture with deterministic timestamps. Provided + * as a sibling helper to `createMockPMProvider` so callers stamping + * `getWorkItem.mockResolvedValue(...)` don't have to remember the field + * names. Override the returned object as needed. + */ +export function createMockWorkItem( + overrides?: Partial<{ + id: string; + title: string; + description: string; + url: string; + status: string; + statusId: string; + labels: Array<{ id: string; name: string; color?: string }>; + inlineMedia: MediaReference[]; + createdAt: string; + updatedAt: string; + }>, +) { + return { + id: 'mock-item-1', + title: 'Mock work item', + description: '', + url: 'mock://workitem/mock-item-1', + labels: [], + createdAt: MOCK_PROVIDER_TIMESTAMP, + updatedAt: MOCK_PROVIDER_TIMESTAMP, + ...overrides, + }; +} + +/** + * Build a minimal WorkItemComment fixture with deterministic timestamps. + */ +export function createMockWorkItemComment( + overrides?: Partial<{ + id: string; + date: string; + text: string; + author: { id: string; name: string; username: string }; + inlineMedia: MediaReference[]; + createdAt: string; + updatedAt: string; + }>, +) { + return { + id: 'mock-comment-1', + date: MOCK_PROVIDER_TIMESTAMP, + text: '', + author: { id: 'mock-user', name: 'Mock User', username: 'mock' }, + createdAt: MOCK_PROVIDER_TIMESTAMP, + updatedAt: MOCK_PROVIDER_TIMESTAMP, + ...overrides, + }; +} + /** * Creates a mock PMProvider with all methods stubbed as vi.fn(). * Use this factory instead of copy-pasting the mock object in every test file. @@ -24,6 +93,10 @@ import type { MediaReference } from '../../src/pm/types.js'; * inlineMedia: [{ url: '...', mimeType: 'image/png', source: 'description' }], * }); * ``` + * + * Companion helpers `createMockWorkItem` / `createMockWorkItemComment` stamp + * deterministic `createdAt` / `updatedAt` values from `MOCK_PROVIDER_TIMESTAMP` + * — use them when asserting on the new optional timestamp fields (MNG-1422). */ export function createMockPMProvider() { return { @@ -40,6 +113,8 @@ export function createMockPMProvider() { text: string; author: { id: string; name: string; username: string }; inlineMedia?: MediaReference[]; + createdAt?: string; + updatedAt?: string; }> > >(), diff --git a/tests/integration/router/dispatch-failure-compensation.test.ts b/tests/integration/router/dispatch-failure-compensation.test.ts index c9a945fe9..958bab51a 100644 --- a/tests/integration/router/dispatch-failure-compensation.test.ts +++ b/tests/integration/router/dispatch-failure-compensation.test.ts @@ -18,6 +18,14 @@ vi.mock('bullmq', () => ({ Worker: vi.fn().mockImplementation((_queueName, _processFn, _opts) => ({ on: vi.fn(), })), + // `isTerminalDispatchFailure` does `err instanceof UnrecoverableError` — + // expose a real-enough subclass so the predicate works under the mock. + UnrecoverableError: class UnrecoverableError extends Error { + constructor(message?: string) { + super(message); + this.name = 'UnrecoverableError'; + } + }, })); vi.mock('../../../src/sentry.js', () => ({ diff --git a/tests/unit/agents/prompts.test.ts b/tests/unit/agents/prompts.test.ts index cc12c9654..e59c984e5 100644 --- a/tests/unit/agents/prompts.test.ts +++ b/tests/unit/agents/prompts.test.ts @@ -144,6 +144,32 @@ describe('system prompts content', () => { expect(prompt).toContain('INVEST'); }); + it('splitting prompt summary instructions reference story work items, not PR creation', () => { + const prompt = getSystemPrompt('splitting'); + // Regression for MNG-1084: the splitting workflow has no PR-creation + // capability, so the summary-comment instructions must not direct the + // agent to confirm or reference CreatePR output. + expect(prompt).not.toContain('CreatePR'); + expect(prompt).not.toContain('confirmed PR creation'); + expect(prompt).not.toContain('real and confirmed PR numbers'); + + // The replacement wording should ground the summary in confirmed + // CreateWorkItem story URLs. + expect(prompt).toContain('CreateWorkItem'); + expect(prompt).toContain('Stories Created'); + expect(prompt).toContain('[Story Title](URL)'); + expect(prompt).toContain('URLs returned by `CreateWorkItem`'); + }); + + it('splitting prompt preserves "one PR, one day" sizing heuristic', () => { + // The story-sizing language intentionally references PRs to describe + // the *target size* of a story; we must not strip that wording when + // cleaning up action-oriented PR-creation instructions. + const prompt = getSystemPrompt('splitting'); + expect(prompt).toContain('One PR, One Day'); + expect(prompt).toContain('one PR, one day of senior engineer work'); + }); + it('planning prompt includes key instructions', () => { const prompt = getSystemPrompt('planning'); expect(prompt).toContain('ReadWorkItem'); diff --git a/tests/unit/api/routers/auth.test.ts b/tests/unit/api/routers/auth.test.ts index d91a23e3f..6ed4d9ed7 100644 --- a/tests/unit/api/routers/auth.test.ts +++ b/tests/unit/api/routers/auth.test.ts @@ -2,16 +2,24 @@ import { describe, expect, it, vi } from 'vitest'; import { createMockSuperAdmin, createMockUser } from '../../../helpers/factories.js'; import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; -const { mockListAllOrganizations, mockGetOrganization } = vi.hoisted(() => ({ - mockListAllOrganizations: vi.fn(), - mockGetOrganization: vi.fn(), -})); +const { mockListAllOrganizations, mockGetOrganization, mockUpdateUser, mockDeleteUserSessions } = + vi.hoisted(() => ({ + mockListAllOrganizations: vi.fn(), + mockGetOrganization: vi.fn(), + mockUpdateUser: vi.fn(), + mockDeleteUserSessions: vi.fn(), + })); vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ listAllOrganizations: mockListAllOrganizations, getOrganization: mockGetOrganization, })); +vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ + updateUser: mockUpdateUser, + deleteUserSessions: mockDeleteUserSessions, +})); + import { authRouter } from '../../../../src/api/routers/auth.js'; const createCaller = createCallerFor(authRouter); @@ -62,8 +70,34 @@ describe('authRouter', () => { }); it('throws UNAUTHORIZED when not authenticated', async () => { - const caller = createCaller({ user: null, effectiveOrgId: null }); + const caller = createCaller({ user: null, effectiveOrgId: null, token: null }); await expectTRPCError(caller.me(), 'UNAUTHORIZED'); }); }); + + describe('changePassword', () => { + it('hashes password, updates user, and deletes other sessions', async () => { + const mockUser = createMockUser(); + const caller = createCaller({ + user: mockUser, + effectiveOrgId: mockUser.orgId, + token: 'current-session-token', + }); + + await caller.changePassword({ password: 'new-secure-password-123' }); + + expect(mockUpdateUser).toHaveBeenCalledWith(mockUser.id, { + passwordHash: expect.any(String), + }); + expect(mockDeleteUserSessions).toHaveBeenCalledWith(mockUser.id, 'current-session-token'); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null, token: null }); + await expectTRPCError( + caller.changePassword({ password: 'new-secure-password-123' }), + 'UNAUTHORIZED', + ); + }); + }); }); diff --git a/tests/unit/backends/claude-code.test.ts b/tests/unit/backends/claude-code.test.ts index 703c833fa..65e386775 100644 --- a/tests/unit/backends/claude-code.test.ts +++ b/tests/unit/backends/claude-code.test.ts @@ -295,10 +295,12 @@ describe('buildSystemPrompt', () => { describe('CLAUDE_CODE_MODELS constants', () => { it('contains the expected models', () => { - expect(CLAUDE_CODE_MODELS).toHaveLength(8); + expect(CLAUDE_CODE_MODELS).toHaveLength(10); }); - it('includes Opus 4.7 and the 1M context variants', () => { + it('includes Opus 4.8, Opus 4.7, and the 1M context variants', () => { + expect(CLAUDE_CODE_MODEL_IDS).toContain('claude-opus-4-8'); + expect(CLAUDE_CODE_MODEL_IDS).toContain('claude-opus-4-8[1m]'); expect(CLAUDE_CODE_MODEL_IDS).toContain('claude-opus-4-7'); expect(CLAUDE_CODE_MODEL_IDS).toContain('claude-opus-4-7[1m]'); expect(CLAUDE_CODE_MODEL_IDS).toContain('claude-sonnet-4-6[1m]'); @@ -323,6 +325,8 @@ describe('CLAUDE_CODE_MODELS constants', () => { describe('resolveClaudeModel', () => { it('passes through known Claude Code model IDs', () => { + expect(resolveClaudeModel('claude-opus-4-8')).toBe('claude-opus-4-8'); + expect(resolveClaudeModel('claude-opus-4-8[1m]')).toBe('claude-opus-4-8[1m]'); expect(resolveClaudeModel('claude-opus-4-7')).toBe('claude-opus-4-7'); expect(resolveClaudeModel('claude-opus-4-7[1m]')).toBe('claude-opus-4-7[1m]'); expect(resolveClaudeModel('claude-sonnet-4-6[1m]')).toBe('claude-sonnet-4-6[1m]'); diff --git a/tests/unit/backends/shared-nativeToolPrompts.test.ts b/tests/unit/backends/shared-nativeToolPrompts.test.ts index 6792d71f9..3cf235d91 100644 --- a/tests/unit/backends/shared-nativeToolPrompts.test.ts +++ b/tests/unit/backends/shared-nativeToolPrompts.test.ts @@ -553,6 +553,130 @@ describe('buildToolGuidance', () => { }); }); +// ------------------------------------------------------------------------- +// MNG-1427: output-shape rendering after the command block +// ------------------------------------------------------------------------- +describe('outputShape — render after the command block (MNG-1427)', () => { + it('renders an Output shape section when the manifest declares one', () => { + const result = buildToolGuidance([ + makeManifest({ + name: 'PostComment', + outputShape: { + summary: 'PostComment returns the new comment context.', + fields: [ + { name: 'status', type: '"created" | "updated"', description: 'Outcome.' }, + { name: 'id', type: 'string', description: 'Comment ID.' }, + ], + }, + }), + ]); + + expect(result).toContain('**Output shape** (`success.data`):'); + expect(result).toContain('PostComment returns the new comment context.'); + expect(result).toContain('- `status` (`"created" | "updated"`) — Outcome.'); + expect(result).toContain('- `id` (`string`) — Comment ID.'); + }); + + it('renders the output shape after the closing code fence (parseable order)', () => { + const result = buildToolGuidance([ + makeManifest({ + name: 'CreateWorkItem', + outputShape: { + fields: [{ name: 'id', type: 'string', description: 'New work item ID.' }], + }, + }), + ]); + + const fenceIndex = result.indexOf('\n```\n'); + const outputIndex = result.indexOf('**Output shape**'); + expect(fenceIndex).toBeGreaterThan(-1); + expect(outputIndex).toBeGreaterThan(fenceIndex); + }); + + it('marks optional fields with a trailing `?`', () => { + const result = buildToolGuidance([ + makeManifest({ + outputShape: { + fields: [ + { name: 'id', type: 'string' }, + { + name: 'workflowStatus', + type: 'string', + optional: true, + description: 'Provider-dependent.', + }, + ], + }, + }), + ]); + + expect(result).toContain('- `id` (`string`)'); + expect(result).toContain('- `workflowStatus?` (`string`) — Provider-dependent.'); + }); + + it('does not render Output shape section when no shape is declared', () => { + const result = buildToolGuidance([makeManifest({ name: 'ReadOnlyTool' })]); + expect(result).not.toContain('**Output shape**'); + }); + + it('renders a placeholder line when fields array is empty', () => { + const result = buildToolGuidance([ + makeManifest({ + outputShape: { fields: [] }, + }), + ]); + + expect(result).toContain('**Output shape**'); + expect(result).toContain('- (shape declared but no fields documented)'); + }); + + it('omits the summary line when none is declared', () => { + const result = buildToolGuidance([ + makeManifest({ + outputShape: { + fields: [{ name: 'id', type: 'string' }], + }, + }), + ]); + + expect(result).toContain('**Output shape**'); + expect(result).toContain('- `id` (`string`)'); + }); + + it('renders fields without descriptions as bare name/type entries', () => { + const result = buildToolGuidance([ + makeManifest({ + outputShape: { + fields: [{ name: 'workItemId', type: 'string' }], + }, + }), + ]); + + expect(result).toContain('- `workItemId` (`string`)'); + expect(result).not.toContain('- `workItemId` (`string`) —'); + }); + + it('preserves declared field order', () => { + const result = buildToolGuidance([ + makeManifest({ + outputShape: { + fields: [ + { name: 'first', type: 'string' }, + { name: 'second', type: 'string' }, + { name: 'third', type: 'string' }, + ], + }, + }), + ]); + + const firstIdx = result.indexOf('- `first`'); + const secondIdx = result.indexOf('- `second`'); + const thirdIdx = result.indexOf('- `third`'); + expect(firstIdx).toBeLessThan(secondIdx); + expect(secondIdx).toBeLessThan(thirdIdx); + }); +}); + // ───────── buildSystemPrompt ───────── describe('buildSystemPrompt', () => { it('prepends native tool execution rules', () => { diff --git a/tests/unit/cli/cascade-tools-command-suggestions.test.ts b/tests/unit/cli/cascade-tools-command-suggestions.test.ts new file mode 100644 index 000000000..f5d371085 --- /dev/null +++ b/tests/unit/cli/cascade-tools-command-suggestions.test.ts @@ -0,0 +1,187 @@ +/** + * Pin the spawn-level `command_not_found` behavior of `cascade-tools` (MNG-1442). + * + * The pure suggestion helper is covered by + * `tests/unit/cli/command-suggestions.test.ts`. This file covers the WIRING: + * + * - The oclif `command_not_found` hook is actually installed for the + * `cascade-tools` binary (not just the helper). + * - A typoed topic (`sm get-pr-diff`) and a typoed subcommand + * (`pm reaad-work-item`) each surface a structured JSON envelope on + * stdout, a one-line prose summary on stderr, and exit code `2`. + * - A typo that is far from every candidate drops the `hint` field but + * still surfaces an `expected` candidate enumeration so the agent has a + * concrete recovery path. + * - The existing `unknown-flag` handling for valid commands keeps emitting + * `unknown-flag` from `createCLICommand()` — unknown-command logic must + * not regress the flag-suggestion path. + * + * Tests skip with a clear message when the repo has not been built (the + * binary at `bin/cascade-tools.js` requires `dist/cli/bootstrap.js` before + * oclif can route any command, including the not-found hook). This mirrors + * `tests/unit/cli/cascade-tools-help.test.ts`. + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = resolve(__dirname, '../../..'); +const BIN = resolve(REPO_ROOT, 'bin/cascade-tools.js'); +const DIST = resolve(REPO_ROOT, 'dist/cli/bootstrap.js'); +const HOOK_DIST = resolve(REPO_ROOT, 'dist/cli/_shared/command-not-found-hook.js'); + +interface SpawnResult { + stdout: string; + stderr: string; + code: number | null; +} + +function runCascadeTools(args: string[]): SpawnResult { + // Strip NODE_ENV — vitest sets it to 'test' which trips the integration + // entrypoint loaded by `bin/cascade-tools.js` and exits 2 with no + // diagnostic. Unrelated to the unknown-command behavior under test. + const env = { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }; + delete env.NODE_ENV; + const result = spawnSync('node', [BIN, ...args], { + cwd: REPO_ROOT, + encoding: 'utf-8', + env, + timeout: 30_000, + }); + return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', code: result.status }; +} + +interface UnknownCommandEnvelope { + success: false; + error: { + type: 'unknown-command'; + message: string; + got?: string; + expected?: string; + hint?: string; + }; +} + +interface UnknownFlagEnvelope { + success: false; + error: { + type: 'unknown-flag'; + flag?: string; + message: string; + }; +} + +function parseEnvelope(stdout: string): T { + const trimmed = stdout.trim(); + // Each spec-014 envelope is a single JSON line — but defensively parse the + // last non-empty line in case oclif ever prepends warnings. + const lastLine = trimmed.split(/\n+/).pop() ?? ''; + return JSON.parse(lastLine) as T; +} + +describe('cascade-tools command_not_found hook (MNG-1442)', () => { + const built = existsSync(DIST) && existsSync(HOOK_DIST); + + it.skipIf(!built)( + 'unknown topic `sm get-pr-diff` emits unknown-command JSON envelope on stdout with a `scm get-pr-diff` hint', + () => { + const result = runCascadeTools(['sm', 'get-pr-diff']); + expect(result.code).toBe(2); + + const env = parseEnvelope(result.stdout); + expect(env.success).toBe(false); + expect(env.error.type).toBe('unknown-command'); + expect(env.error.hint).toBe("did you mean 'cascade-tools scm get-pr-diff'?"); + expect(env.error.got).toBe('sm get-pr-diff'); + // Topic-level enumeration so the agent sees the four candidates even + // when the hint already nails it. + expect((env.error.expected ?? '').split(', ')).toEqual( + expect.arrayContaining(['alerting', 'pm', 'scm', 'session']), + ); + + // Prose summary on stderr — one line, humans-readable, no JSON. + expect(result.stderr.trim().split('\n').length).toBe(1); + expect(result.stderr).toContain('unknown-command'); + expect(result.stderr).not.toContain('{"success"'); + }, + ); + + it.skipIf(!built)( + 'unknown subcommand `pm reaad-work-item` emits unknown-command JSON with a `pm read-work-item` hint', + () => { + const result = runCascadeTools(['pm', 'reaad-work-item']); + expect(result.code).toBe(2); + + const env = parseEnvelope(result.stdout); + expect(env.success).toBe(false); + expect(env.error.type).toBe('unknown-command'); + expect(env.error.hint).toBe("did you mean 'cascade-tools pm read-work-item'?"); + expect(env.error.got).toBe('pm reaad-work-item'); + // Subcommand-level enumeration: must include sibling pm commands and + // must NOT leak other topics' subcommands (e.g. scm:create-pr). + expect(env.error.expected).toContain('read-work-item'); + expect(env.error.expected).not.toContain('create-pr'); + }, + ); + + it.skipIf(!built)( + 'far-away typos omit the `hint` field but still surface the candidate enumeration', + () => { + // `zzzzzzzz` is well beyond the distance budget for any registered + // topic. The agent should still get a runnable list of candidates. + const result = runCascadeTools(['zzzzzzzz', 'something']); + expect(result.code).toBe(2); + + const env = parseEnvelope(result.stdout); + expect(env.error.type).toBe('unknown-command'); + expect(env.error.hint).toBeUndefined(); + expect((env.error.expected ?? '').split(', ')).toEqual( + expect.arrayContaining(['alerting', 'pm', 'scm', 'session']), + ); + }, + ); + + it.skipIf(!built)( + 'far-away subcommand typo on a known topic also omits `hint` but lists subcommands', + () => { + // `totallyunrelated` shares almost nothing with any pm subcommand — + // confirms the noise gate applies on the subcommand path too. + const result = runCascadeTools(['pm', 'totallyunrelated']); + expect(result.code).toBe(2); + + const env = parseEnvelope(result.stdout); + expect(env.error.type).toBe('unknown-command'); + expect(env.error.hint).toBeUndefined(); + expect(env.error.expected).toContain('read-work-item'); + expect(env.error.expected).not.toContain('create-pr'); + }, + ); + + it.skipIf(!built)( + 'existing unknown-flag handling is untouched (regression net for createCLICommand())', + () => { + // `pm read-work-item` is a real command; `--unknownflag` is not. + // The unknown-flag envelope is emitted by `createCLICommand()`'s + // parse-error classifier, not by the command_not_found hook. This + // test pins that the new hook does NOT shadow that path. + const result = runCascadeTools([ + 'pm', + 'read-work-item', + '--workItemId', + 'foo', + '--unknownflag', + 'bar', + ]); + // `unknown-flag` exits with code 1 (the spec-014 default for every + // non-command envelope); a successful exit (0) or `unknown-command` + // (2) would each be a regression. + expect(result.code).toBe(1); + + const env = parseEnvelope(result.stdout); + expect(env.success).toBe(false); + expect(env.error.type).toBe('unknown-flag'); + }, + ); +}); diff --git a/tests/unit/cli/command-suggestions.test.ts b/tests/unit/cli/command-suggestions.test.ts new file mode 100644 index 000000000..7cc3b49be --- /dev/null +++ b/tests/unit/cli/command-suggestions.test.ts @@ -0,0 +1,224 @@ +/** + * Pin the pure unknown-command suggestion helper (MNG-1441). + * + * The helper is the testable seam that decides which suggestion (if any) is + * surfaced when a `cascade-tools` agent typos a topic or subcommand. These + * tests guarantee: + * + * - Topic typos suggest the closest topic and preserve the user's trailing + * segments (`sm get-pr-diff` → `scm get-pr-diff`). + * - Subcommand typos under a known topic suggest the closest subcommand + * (`pm reaad-work-item` → `pm read-work-item`). + * - Far-away typos drop the `hint` field but still surface the candidate + * list via `expected`, so the agent has a concrete enumeration to + * self-correct from. + * - The candidate set never leaks topics that are not loaded by + * `cascade-tools` (the dashboard topic is excluded automatically because + * its glob is filtered out in `bin/cascade-tools.js`). + * + * The helper does NOT install oclif's `command_not_found` hook — wiring is + * out of scope for MNG-1441. These tests pin decisions only. + */ + +import { describe, expect, it } from 'vitest'; + +import { + buildUnknownCommandEnvelope, + type OclifLikeConfig, +} from '../../../src/cli/_shared/commandSuggestions.js'; + +/** + * Construct a minimal oclif-like config that mirrors the shape + * `bin/cascade-tools.js` produces (four topics, flat `:` IDs, + * no dashboard surface). + */ +function makeConfig(overrides: Partial = {}): OclifLikeConfig { + return { + bin: 'cascade-tools', + commandIDs: [ + 'pm:read-work-item', + 'pm:post-comment', + 'pm:update-work-item', + 'pm:add-checklist', + 'pm:update-checklist-item', + 'scm:create-pr', + 'scm:get-pr-diff', + 'scm:post-pr-comment', + 'scm:create-pr-review', + 'alerting:get-alerting-event', + 'alerting:get-alerting-issue', + 'session:finish', + ], + pjson: { + oclif: { + topics: { + pm: { description: 'PM topic' }, + scm: { description: 'SCM topic' }, + alerting: { description: 'Alerting topic' }, + session: { description: 'Session topic' }, + }, + }, + }, + ...overrides, + }; +} + +describe('buildUnknownCommandEnvelope', () => { + it('marks the envelope as unknown-command with the typed input in `got`', () => { + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'pm:reaad-work-item', + }); + expect(envelope.type).toBe('unknown-command'); + expect(envelope.got).toBe('pm reaad-work-item'); + // No `flag` field for command-level failures. + expect(envelope.flag).toBeUndefined(); + // Message renders the runnable form so humans reading stderr see what + // they typed in CLI shape, not oclif's colon-separated id. + expect(envelope.message).toBe("Unknown command 'cascade-tools pm reaad-work-item'"); + }); + + it('suggests the closest topic for a top-level typo (`sm get-pr-diff` → `scm get-pr-diff`)', () => { + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'sm:get-pr-diff', + }); + expect(envelope.hint).toBe("did you mean 'cascade-tools scm get-pr-diff'?"); + // Topic-typo envelopes list topics in `expected`, not subcommands — + // the unknown segment is the topic. + expect(envelope.expected).toBe('alerting, pm, scm, session'); + }); + + it('suggests the closest subcommand under a known topic (`pm reaad-work-item` → `pm read-work-item`)', () => { + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'pm:reaad-work-item', + }); + expect(envelope.hint).toBe("did you mean 'cascade-tools pm read-work-item'?"); + // Subcommand-typo envelopes list the topic's subcommands so the agent + // has a concrete enumeration even when the hint is not exact. + expect(envelope.expected).toContain('read-work-item'); + expect(envelope.expected).toContain('post-comment'); + // Subcommand expected MUST NOT leak other topics' subcommands. + expect(envelope.expected).not.toContain('create-pr'); + }); + + it('omits `hint` when the typo is too far from any candidate', () => { + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'pm:totallyunrelated', + }); + expect(envelope.hint).toBeUndefined(); + // `expected` is still populated so the agent has a recovery path. + expect(envelope.expected).toContain('read-work-item'); + expect(envelope.message).toBe("Unknown command 'cascade-tools pm totallyunrelated'"); + }); + + it('omits `hint` for far-away top-level topic typos', () => { + // 'zzzzzzzz' is well beyond the distance budget for any registered + // cascade-tools topic. + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'zzzzzzzz:something', + }); + expect(envelope.hint).toBeUndefined(); + expect(envelope.expected).toBe('alerting, pm, scm, session'); + }); + + it("surfaces a useful `expected` field for subcommand typos (the topic's actual command list)", () => { + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'scm:get-pr-diffx', + }); + // Comma-separated, deterministic (sorted) list matches the + // enum-mismatch shape agents already parse from `parseErrors.ts`. + expect(envelope.expected).toBe('create-pr, create-pr-review, get-pr-diff, post-pr-comment'); + }); + + it('excludes dashboard topics when they are not loaded by cascade-tools', () => { + // Mirror `bin/cascade-tools.js`: dashboard glob is excluded from + // command discovery, and `pjson.oclif.topics` does not declare a + // `dashboard` entry. Topic candidates must reflect that. + const config = makeConfig(); + const envelope = buildUnknownCommandEnvelope({ + config, + id: 'dashbord:projects', + }); + // The candidate list does not include `dashboard`, so even if + // `dashbord` were within edit distance, it could not be suggested. + expect(envelope.expected.split(', ')).not.toContain('dashboard'); + expect(envelope.hint).toBeUndefined(); + }); + + it('does include a topic derived from commandIDs even when not in pjson.oclif.topics', () => { + // Topics are the union of commandIDs' first segments + explicit + // pjson topics. A plugin-contributed topic that didn't make it into + // `pjson.oclif.topics` is still a valid candidate. + const config = makeConfig({ + commandIDs: [...makeConfig().commandIDs, 'plugin:do-thing'], + }); + const envelope = buildUnknownCommandEnvelope({ + config, + id: 'plugn:do-thing', // distance 1 from 'plugin' + }); + expect(envelope.hint).toBe("did you mean 'cascade-tools plugin do-thing'?"); + expect(envelope.expected.split(', ')).toContain('plugin'); + }); + + it('skips hidden topics from `pjson.oclif.topics` when building candidates', () => { + // Hidden topics never appear in `cascade-tools --help` and should + // not be suggested either. Without this filter, a typo that + // resembles a hidden topic would surface confusing guidance. + const config = makeConfig({ + commandIDs: ['pm:read-work-item'], + pjson: { + oclif: { + topics: { + pm: { description: 'PM' }, + internal: { description: 'Internal', hidden: true }, + }, + }, + }, + }); + const envelope = buildUnknownCommandEnvelope({ + config, + id: 'internel:thing', // distance 1 from 'internal' + }); + expect(envelope.expected.split(', ')).not.toContain('internal'); + expect(envelope.hint).toBeUndefined(); + }); + + it("preserves the user's trailing positional segments when suggesting a topic", () => { + // The trailing segment(s) are echoed verbatim in the hint so the + // agent can immediately retry without retyping. Helpful when the + // trailing form happens to be valid under the corrected topic. + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'sm:create-pr', + }); + expect(envelope.hint).toBe("did you mean 'cascade-tools scm create-pr'?"); + }); + + it('handles bare topic invocations defensively (no subcommand to suggest)', () => { + // oclif normally routes bare-topic input to topic-help before + // command_not_found fires, but direct callers may still hit this + // path. The envelope should surface the subcommand enumeration. + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'pm', + }); + expect(envelope.type).toBe('unknown-command'); + expect(envelope.expected).toContain('read-work-item'); + expect(envelope.hint).toBeUndefined(); + }); + + it('produces no candidates and no hint when the config has no loaded commands or explicit topics', () => { + const envelope = buildUnknownCommandEnvelope({ + config: { bin: 'cascade-tools', commandIDs: [], pjson: { oclif: {} } }, + id: 'pm:read-work-item', + }); + expect(envelope.type).toBe('unknown-command'); + expect(envelope.expected).toBe(''); + expect(envelope.hint).toBeUndefined(); + }); +}); diff --git a/tests/unit/cli/pm/pm-commands.test.ts b/tests/unit/cli/pm/pm-commands.test.ts index 2bb76ddcc..5c34f9d9d 100644 --- a/tests/unit/cli/pm/pm-commands.test.ts +++ b/tests/unit/cli/pm/pm-commands.test.ts @@ -64,7 +64,19 @@ vi.mock('../../../../src/gadgets/pm/core/reportFriction.js', () => ({ vi.mock('../../../../src/gadgets/pm/core/postComment.js', () => ({ postComment: vi.fn().mockResolvedValue({ id: 'comment-1' }), })); +vi.mock('../../../../src/gadgets/pm/core/updateWorkItem.js', () => ({ + updateWorkItem: vi.fn().mockResolvedValue({ id: 'wi-1', status: 'updated' }), +})); +vi.mock('../../../../src/gadgets/pm/core/addChecklist.js', () => ({ + addChecklist: vi.fn().mockResolvedValue({ id: 'wi-1', status: 'created' }), +})); +// Suppress the PM-write sidecar side effect — the structured-output assertions +// only care about the CLI's JSON envelope. +vi.mock('../../../../src/gadgets/session/core/sidecar.js', () => ({ + writePMWriteSidecar: vi.fn(() => true), +})); +import AddChecklist from '../../../../src/cli/pm/add-checklist.js'; import CreateWorkItem from '../../../../src/cli/pm/create-work-item.js'; import DeleteChecklistItem from '../../../../src/cli/pm/delete-checklist-item.js'; import ListWorkItems from '../../../../src/cli/pm/list-work-items.js'; @@ -73,6 +85,8 @@ import PostComment from '../../../../src/cli/pm/post-comment.js'; import ReadWorkItem from '../../../../src/cli/pm/read-work-item.js'; import ReportFriction from '../../../../src/cli/pm/report-friction.js'; import UpdateChecklistItem from '../../../../src/cli/pm/update-checklist-item.js'; +import UpdateWorkItem from '../../../../src/cli/pm/update-work-item.js'; +import { addChecklist } from '../../../../src/gadgets/pm/core/addChecklist.js'; import { createWorkItem } from '../../../../src/gadgets/pm/core/createWorkItem.js'; import { deleteChecklistItem } from '../../../../src/gadgets/pm/core/deleteChecklistItem.js'; import { listWorkItems } from '../../../../src/gadgets/pm/core/listWorkItems.js'; @@ -81,6 +95,7 @@ import { postComment } from '../../../../src/gadgets/pm/core/postComment.js'; import { readWorkItem } from '../../../../src/gadgets/pm/core/readWorkItem.js'; import { reportFriction } from '../../../../src/gadgets/pm/core/reportFriction.js'; import { updateChecklistItem } from '../../../../src/gadgets/pm/core/updateChecklistItem.js'; +import { updateWorkItem } from '../../../../src/gadgets/pm/core/updateWorkItem.js'; /** Create a fresh minimal oclif config to satisfy this.parse() in each test */ function makeMockConfig() { @@ -473,3 +488,487 @@ describe('PostComment command (basic params)', () => { }); }); }); + +// --------------------------------------------------------------------------- +// MNG-1428: Structured-output contract regression coverage +// +// Each targeted PM mutation CLI must serialise the structured core result into +// the `{ success: true, data: ... }` envelope without rewriting it into a prose +// sentinel. These tests parse stdout and pin `success.data.id`, +// `success.data.url`, `success.data.status`, and `success.data.updatedAt` +// (where applicable) so a future renderer drift that drops a field surfaces +// loudly in CI instead of silently regressing the agent-facing contract. +// +// Read-only commands (read-work-item, list-work-items) are excluded — they +// have no mutation outcome and so no required `status` / `updatedAt` fields. +// --------------------------------------------------------------------------- +describe('PM CLI structured-output contract (MNG-1428)', () => { + function readJsonOutput(logSpy: ReturnType) { + const lines = logSpy.mock.calls.map((c) => c[0] as string); + const jsonLine = lines.find((l) => typeof l === 'string' && l.startsWith('{')) ?? ''; + return JSON.parse(jsonLine) as { + success: boolean; + data?: Record; + error?: { type: string; message: string }; + }; + } + + it('CreateWorkItem stdout exposes id, url, status="created", and updatedAt', async () => { + vi.mocked(createWorkItem).mockResolvedValue({ + status: 'created', + id: 'wi-new', + title: 'New Card', + url: 'https://pm.example/card/wi-new', + updatedAt: '2026-06-01T12:00:00.000Z', + workflowStatus: 'Backlog', + workflowStatusId: 'list-backlog', + } as never); + const cmd = new CreateWorkItem( + ['--containerId', 'list-1', '--title', 'New Card'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'created', + id: 'wi-new', + url: 'https://pm.example/card/wi-new', + updatedAt: '2026-06-01T12:00:00.000Z', + }); + // Provider-specific workflow state lives on its own keys — pinning the + // `status` vs `workflowStatus` naming so the mutation outcome is never + // confused with the workflow column name. + expect(output.data?.workflowStatus).toBe('Backlog'); + expect(output.data?.workflowStatusId).toBe('list-backlog'); + }); + + it('PostComment stdout exposes id, workItemUrl, status, and updatedAt', async () => { + vi.mocked(postComment).mockResolvedValue({ + status: 'created', + id: 'comment-42', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + updatedAt: '2026-06-01T12:34:56.000Z', + } as never); + const cmd = new PostComment( + ['--workItemId', 'card-1', '--text', 'Status update'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'created', + id: 'comment-42', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + updatedAt: '2026-06-01T12:34:56.000Z', + }); + }); + + it('PostComment exposes status="updated" when the progress comment was replaced', async () => { + vi.mocked(postComment).mockResolvedValue({ + status: 'updated', + id: 'comment-7', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + updatedAt: '2026-06-01T12:34:56.000Z', + } as never); + const cmd = new PostComment( + ['--workItemId', 'card-1', '--text', 'Final summary'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.data?.status).toBe('updated'); + expect(output.data?.id).toBe('comment-7'); + }); + + it('UpdateWorkItem stdout exposes id, url, status, updatedAt, and the changed-field arrays', async () => { + vi.mocked(updateWorkItem).mockResolvedValue({ + status: 'updated', + id: 'card-9', + title: 'Renamed', + url: 'https://pm.example/card/card-9', + updatedAt: '2026-06-01T13:00:00.000Z', + changedFields: ['title', 'description'], + addedLabelIds: ['label-1'], + } as never); + const cmd = new UpdateWorkItem( + [ + '--workItemId', + 'card-9', + '--title', + 'Renamed', + '--description', + 'New body', + '--addLabelId', + 'label-1', + ], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'updated', + id: 'card-9', + url: 'https://pm.example/card/card-9', + updatedAt: '2026-06-01T13:00:00.000Z', + changedFields: ['title', 'description'], + addedLabelIds: ['label-1'], + }); + }); + + it('UpdateWorkItem exposes status="noop" when no updates were supplied', async () => { + vi.mocked(updateWorkItem).mockResolvedValue({ + status: 'noop', + id: 'card-9', + title: '', + url: 'https://pm.example/card/card-9', + updatedAt: '2026-06-01T13:00:00.000Z', + changedFields: [], + addedLabelIds: [], + message: 'Nothing to update - provide title, description, or labels', + } as never); + const cmd = new UpdateWorkItem(['--workItemId', 'card-9'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.data?.status).toBe('noop'); + expect(output.data?.changedFields).toEqual([]); + expect(output.data?.addedLabelIds).toEqual([]); + }); + + it('MoveWorkItem stdout exposes id, url, status="moved", and updatedAt', async () => { + vi.mocked(moveWorkItem).mockResolvedValue({ + status: 'moved', + id: 'card-2', + url: 'https://pm.example/card/card-2', + destination: 'list-done', + updatedAt: '2026-06-01T14:00:00.000Z', + } as never); + const cmd = new MoveWorkItem( + ['--workItemId', 'card-2', '--destination', 'list-done'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'moved', + id: 'card-2', + url: 'https://pm.example/card/card-2', + destination: 'list-done', + updatedAt: '2026-06-01T14:00:00.000Z', + }); + }); + + it('MoveWorkItem exposes status="noop" with previousStatus when already in destination', async () => { + vi.mocked(moveWorkItem).mockResolvedValue({ + status: 'noop', + id: 'card-2', + url: 'https://pm.example/card/card-2', + destination: 'list-done', + updatedAt: '2026-06-01T14:00:00.000Z', + previousStatus: 'Done', + previousStatusId: 'list-done', + message: "Work item already in destination state 'Done' — no-op", + } as never); + const cmd = new MoveWorkItem( + ['--workItemId', 'card-2', '--destination', 'list-done', '--expectedSourceState', 'Backlog'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.data?.status).toBe('noop'); + expect(output.data?.previousStatus).toBe('Done'); + expect(output.data?.previousStatusId).toBe('list-done'); + }); + + it('MoveWorkItem exposes status="aborted" when the guard rejected the move', async () => { + vi.mocked(moveWorkItem).mockResolvedValue({ + status: 'aborted', + id: 'card-2', + url: 'https://pm.example/card/card-2', + destination: 'list-done', + updatedAt: '2026-06-01T14:00:00.000Z', + previousStatus: 'In Progress', + message: "Aborted: expected 'Backlog', found 'In Progress'", + } as never); + const cmd = new MoveWorkItem( + ['--workItemId', 'card-2', '--destination', 'list-done', '--expectedSourceState', 'Backlog'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.data?.status).toBe('aborted'); + expect(output.data?.previousStatus).toBe('In Progress'); + }); + + it('AddChecklist stdout exposes checklistId, workItemUrl, itemIds, itemCount, status, and updatedAt', async () => { + vi.mocked(addChecklist).mockResolvedValue({ + status: 'created', + checklistId: 'cl-1', + checklistName: 'Acceptance Criteria', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + updatedAt: '2026-06-01T15:00:00.000Z', + itemCount: 2, + itemIds: ['item-1', 'item-2'], + } as never); + // AddChecklist's --item param is declared as `array of object`, so the + // CLI factory expects a single JSON-encoded array payload. + const cmd = new AddChecklist( + [ + '--workItemId', + 'card-1', + '--checklistName', + 'Acceptance Criteria', + '--item', + JSON.stringify([{ name: 'First step' }, { name: 'Second step' }]), + ], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'created', + checklistId: 'cl-1', + checklistName: 'Acceptance Criteria', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + updatedAt: '2026-06-01T15:00:00.000Z', + itemCount: 2, + itemIds: ['item-1', 'item-2'], + }); + }); + + it('UpdateChecklistItem stdout exposes workItemUrl, checkItemId, status="updated", complete, and updatedAt', async () => { + vi.mocked(updateChecklistItem).mockResolvedValue({ + status: 'updated', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + checkItemId: 'item-456', + complete: true, + updatedAt: '2026-06-01T16:00:00.000Z', + } as never); + const cmd = new UpdateChecklistItem( + ['--workItemId', 'card-1', '--checkItemId', 'item-456', '--state', 'complete'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'updated', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + checkItemId: 'item-456', + complete: true, + updatedAt: '2026-06-01T16:00:00.000Z', + }); + }); + + it('PMDeleteChecklistItem stdout exposes workItemUrl, checkItemId, status="deleted", and updatedAt', async () => { + vi.mocked(deleteChecklistItem).mockResolvedValue({ + status: 'deleted', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + checkItemId: 'item-456', + updatedAt: '2026-06-01T16:30:00.000Z', + } as never); + const cmd = new DeleteChecklistItem( + ['--workItemId', 'card-1', '--checkItemId', 'item-456'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'deleted', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + checkItemId: 'item-456', + updatedAt: '2026-06-01T16:30:00.000Z', + }); + }); + + it('updatedAt values are ISO 8601 strings (regression guard against renderer drift)', async () => { + // Pins the timestamp surface contract: cores prefer provider-supplied + // timestamps and fall back to `currentTimestamp()` for synthetic outcomes. + // Either way the CLI envelope must carry a parseable ISO 8601 string, + // not a Date instance or a free-form prose value. + vi.mocked(createWorkItem).mockResolvedValue({ + status: 'created', + id: 'wi-new', + title: 'X', + url: 'https://pm.example/card/wi-new', + updatedAt: '2026-06-01T17:00:00.000Z', + } as never); + const cmd = new CreateWorkItem( + ['--containerId', 'list-1', '--title', 'X'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(typeof output.data?.updatedAt).toBe('string'); + expect(Number.isNaN(Date.parse(output.data?.updatedAt as string))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// MNG-1428: Runtime failure envelopes +// +// Each PM mutation must surface fatal core errors as the spec-014 runtime +// envelope (`{ success: false, error: { type: 'runtime', message: ... } }`) +// — never as a successful prose sentinel like +// `"Error creating work item: ..."`. These tests pin the CLI translation per +// command so a regression that reverts to prose surfaces immediately. +// --------------------------------------------------------------------------- +describe('PM CLI runtime failure envelopes (MNG-1428)', () => { + function readJsonOutput(logSpy: ReturnType) { + const lines = logSpy.mock.calls.map((c) => c[0] as string); + const jsonLine = lines.find((l) => typeof l === 'string' && l.startsWith('{')) ?? ''; + return JSON.parse(jsonLine) as { + success: boolean; + data?: unknown; + error?: { type: string; message: string }; + }; + } + + it('CreateWorkItem surfaces a runtime envelope when createWorkItem throws', async () => { + vi.mocked(createWorkItem).mockRejectedValueOnce(new Error('Provider 403')); + const cmd = new CreateWorkItem( + ['--containerId', 'list-1', '--title', 'New Card'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + const exitSpy = vi.spyOn(cmd, 'exit'); + + await runExpectingExit(cmd); + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error).toEqual({ type: 'runtime', message: 'Provider 403' }); + expect(output.data).toBeUndefined(); + }); + + it('UpdateWorkItem surfaces a runtime envelope when updateWorkItem throws', async () => { + vi.mocked(updateWorkItem).mockRejectedValueOnce(new Error('Provider 422')); + const cmd = new UpdateWorkItem( + ['--workItemId', 'card-9', '--title', 'New'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + const exitSpy = vi.spyOn(cmd, 'exit'); + + await runExpectingExit(cmd); + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error).toEqual({ type: 'runtime', message: 'Provider 422' }); + }); + + it('MoveWorkItem surfaces a runtime envelope when moveWorkItem throws', async () => { + vi.mocked(moveWorkItem).mockRejectedValueOnce(new Error('Provider 500')); + const cmd = new MoveWorkItem( + ['--workItemId', 'card-2', '--destination', 'list-done'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + const exitSpy = vi.spyOn(cmd, 'exit'); + + await runExpectingExit(cmd); + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error).toEqual({ type: 'runtime', message: 'Provider 500' }); + }); + + it('AddChecklist surfaces a runtime envelope when addChecklist throws', async () => { + vi.mocked(addChecklist).mockRejectedValueOnce(new Error('Provider 429')); + const cmd = new AddChecklist( + [ + '--workItemId', + 'card-1', + '--checklistName', + 'CL', + '--item', + JSON.stringify([{ name: 'step' }]), + ], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + const exitSpy = vi.spyOn(cmd, 'exit'); + + await runExpectingExit(cmd); + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error).toEqual({ type: 'runtime', message: 'Provider 429' }); + }); + + it('UpdateChecklistItem surfaces a runtime envelope when updateChecklistItem throws', async () => { + vi.mocked(updateChecklistItem).mockRejectedValueOnce(new Error('Provider 503')); + const cmd = new UpdateChecklistItem( + ['--workItemId', 'card-1', '--checkItemId', 'item-456', '--state', 'complete'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + const exitSpy = vi.spyOn(cmd, 'exit'); + + await runExpectingExit(cmd); + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error).toEqual({ type: 'runtime', message: 'Provider 503' }); + }); + + it('PMDeleteChecklistItem surfaces a runtime envelope when deleteChecklistItem throws', async () => { + vi.mocked(deleteChecklistItem).mockRejectedValueOnce(new Error('Provider 404')); + const cmd = new DeleteChecklistItem( + ['--workItemId', 'card-1', '--checkItemId', 'item-456'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + const exitSpy = vi.spyOn(cmd, 'exit'); + + await runExpectingExit(cmd); + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error).toEqual({ type: 'runtime', message: 'Provider 404' }); + }); +}); diff --git a/tests/unit/cli/scm/scm-commands.test.ts b/tests/unit/cli/scm/scm-commands.test.ts index 807e59ea7..ac064521d 100644 --- a/tests/unit/cli/scm/scm-commands.test.ts +++ b/tests/unit/cli/scm/scm-commands.test.ts @@ -17,8 +17,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // --------------------------------------------------------------------------- // Mock credential-scoping dependencies // --------------------------------------------------------------------------- +// CreatePRReview also calls the GitHub client directly to delete the ack +// comment after a successful review submission, so `githubClient.deletePRComment` +// must be defined here for that code path. vi.mock('../../../../src/github/client.js', () => ({ withGitHubToken: vi.fn((_token: string, fn: () => Promise) => fn()), + githubClient: { + deletePRComment: vi.fn().mockResolvedValue(undefined), + }, })); vi.mock('../../../../src/trello/client.js', () => ({ withTrelloCredentials: vi.fn( @@ -62,7 +68,16 @@ vi.mock('../../../../src/gadgets/github/core/replyToReviewComment.js', () => ({ vi.mock('../../../../src/gadgets/github/core/updatePRComment.js', () => ({ updatePRComment: vi.fn().mockResolvedValue({ id: 300, body: 'Updated' }), })); +vi.mock('../../../../src/gadgets/github/core/createPRReview.js', () => ({ + createPRReview: vi.fn().mockResolvedValue({ id: '400', reviewUrl: 'https://gh/r/400' }), +})); +// Suppress sidecar side effects so the structured-output assertions stay +// focused on the CLI's JSON envelope. +vi.mock('../../../../src/gadgets/session/core/sidecar.js', () => ({ + writeReviewSidecar: vi.fn(() => true), +})); +import CreatePRReview from '../../../../src/cli/scm/create-pr-review.js'; import GetCIRunLogs from '../../../../src/cli/scm/get-ci-run-logs.js'; import GetPRChecks from '../../../../src/cli/scm/get-pr-checks.js'; import GetPRComments from '../../../../src/cli/scm/get-pr-comments.js'; @@ -71,6 +86,7 @@ import GetPRDiff from '../../../../src/cli/scm/get-pr-diff.js'; import PostPRComment from '../../../../src/cli/scm/post-pr-comment.js'; import ReplyToReviewComment from '../../../../src/cli/scm/reply-to-review-comment.js'; import UpdatePRComment from '../../../../src/cli/scm/update-pr-comment.js'; +import { createPRReview } from '../../../../src/gadgets/github/core/createPRReview.js'; import { getCIRunLogs } from '../../../../src/gadgets/github/core/getCIRunLogs.js'; import { getPRChecks } from '../../../../src/gadgets/github/core/getPRChecks.js'; import { getPRComments } from '../../../../src/gadgets/github/core/getPRComments.js'; @@ -445,3 +461,317 @@ describe('UpdatePRComment command', () => { expect(output.data).toEqual({ id: 555, body: 'New content' }); }); }); + +// --------------------------------------------------------------------------- +// MNG-1425: runtime failure envelopes +// +// The structured-output rewrite of post-pr-comment / update-pr-comment / +// reply-to-review-comment cores throws on GitHub failures rather than +// returning prose sentinel strings. The CLI factory (`createCLICommand`) +// wraps thrown errors in the spec-014 runtime envelope. These tests pin +// that contract per CLI so a regression here surfaces immediately. +// --------------------------------------------------------------------------- +describe('SCM CLI runtime failure envelopes (MNG-1425)', () => { + function readJsonOutput(logSpy: ReturnType) { + const lines = logSpy.mock.calls.map((c) => c[0] as string); + const jsonLine = lines.find((l) => typeof l === 'string' && l.startsWith('{')) ?? ''; + return JSON.parse(jsonLine) as { + success: boolean; + error?: { type: string; message: string }; + }; + } + + /** + * Runtime failures emit the envelope, then call exit(1). Oclif's exit + * surfaces as a thrown EEXIT error from `cmd.run()`, which is the expected + * post-envelope shape — we swallow it so we can inspect the envelope. + */ + async function runExpectingExit(cmd: { run: () => Promise }): Promise { + try { + await cmd.run(); + } catch (err) { + const status = (err as { oclif?: { exit?: number }; code?: string })?.oclif?.exit; + const code = (err as { code?: string })?.code; + if (status === 1 || code === 'EEXIT') return; + throw err; + } + } + + it('PostPRComment surfaces a runtime envelope when postPRComment throws', async () => { + vi.mocked(postPRComment).mockRejectedValueOnce(new Error('Rate limited')); + const cmd = new PostPRComment( + ['--prNumber', '42', '--body', 'Hello'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await runExpectingExit(cmd); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error?.type).toBe('runtime'); + expect(output.error?.message).toBe('Rate limited'); + }); + + it('UpdatePRComment surfaces a runtime envelope when updatePRComment throws', async () => { + vi.mocked(updatePRComment).mockRejectedValueOnce(new Error('Not Found')); + const cmd = new UpdatePRComment( + ['--commentId', '555', '--body', 'New content'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await runExpectingExit(cmd); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error?.type).toBe('runtime'); + expect(output.error?.message).toBe('Not Found'); + }); + + it('ReplyToReviewComment surfaces a runtime envelope when replyToReviewComment throws', async () => { + vi.mocked(replyToReviewComment).mockRejectedValueOnce(new Error('Unprocessable Entity')); + const cmd = new ReplyToReviewComment( + ['--prNumber', '42', '--commentId', '101', '--body', 'Reply'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await runExpectingExit(cmd); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error?.type).toBe('runtime'); + expect(output.error?.message).toBe('Unprocessable Entity'); + }); +}); + +// --------------------------------------------------------------------------- +// MNG-1428: SCM CLI structured-output regression coverage +// +// Each targeted SCM mutation CLI (post-pr-comment / update-pr-comment / +// reply-to-review-comment / create-pr-review) must serialise the GitHub +// mutation result into the `{ success: true, data: ... }` envelope and carry +// the minimum structured contract — `success.data.id`, `success.data.url`, +// `success.data.status`, `success.data.updatedAt`, plus the PR/repo context +// (`repoFullName`, `prNumber`). These tests parse stdout and pin each field so +// a future renderer drift surfaces in CI rather than silently regressing the +// agent-facing contract. +// +// CreatePRReview also exposes `reviewUrl`, `event`, `submittedAt`, and +// `inlineCommentCount` — pinned here too because review workflows downstream +// consume those keys directly from the structured envelope. +// --------------------------------------------------------------------------- +describe('SCM CLI structured-output contract (MNG-1428)', () => { + function readJsonOutput(logSpy: ReturnType) { + const lines = logSpy.mock.calls.map((c) => c[0] as string); + const jsonLine = lines.find((l) => typeof l === 'string' && l.startsWith('{')) ?? ''; + return JSON.parse(jsonLine) as { + success: boolean; + data?: Record; + error?: { type: string; message: string }; + }; + } + + /** + * Runtime failures emit the envelope, then call exit(1). Oclif's exit + * surfaces as a thrown EEXIT error from `cmd.run()`. Mirrors the helper + * scoped to the MNG-1425 describe — local copy avoids leaking state. + */ + async function runExpectingExit(cmd: { run: () => Promise }): Promise { + try { + await cmd.run(); + } catch (err) { + const status = (err as { oclif?: { exit?: number }; code?: string })?.oclif?.exit; + const code = (err as { code?: string })?.code; + if (status === 1 || code === 'EEXIT') return; + throw err; + } + } + + it('PostPRComment stdout exposes id, url, status="ok", updatedAt, repoFullName, prNumber', async () => { + vi.mocked(postPRComment).mockResolvedValueOnce({ + status: 'ok', + id: '987654321', + url: 'https://github.com/owner/repo/pull/42#issuecomment-987654321', + updatedAt: '2026-06-01T18:00:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + } as never); + const cmd = new PostPRComment( + ['--prNumber', '42', '--body', 'Working on it...'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'ok', + id: '987654321', + url: 'https://github.com/owner/repo/pull/42#issuecomment-987654321', + updatedAt: '2026-06-01T18:00:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + }); + }); + + it('UpdatePRComment stdout exposes id, url, status, updatedAt, repoFullName, prNumber', async () => { + vi.mocked(updatePRComment).mockResolvedValueOnce({ + status: 'ok', + id: '111222333', + url: 'https://github.com/owner/repo/pull/42#issuecomment-111222333', + updatedAt: '2026-06-01T18:30:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + } as never); + const cmd = new UpdatePRComment( + ['--commentId', '111222333', '--body', 'Updated'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'ok', + id: '111222333', + url: 'https://github.com/owner/repo/pull/42#issuecomment-111222333', + updatedAt: '2026-06-01T18:30:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + }); + }); + + it('UpdatePRComment accepts prNumber=null when the comment is not on a PR thread', async () => { + // The UpdatePRComment contract specifies prNumber as `number | null` — + // pinned in `updatePRCommentDef.outputShape` — because some issue-only + // comments don't expose `/pull/` in their html_url. This test makes + // sure the CLI envelope round-trips that nullable value. + vi.mocked(updatePRComment).mockResolvedValueOnce({ + status: 'ok', + id: '111222333', + url: 'https://github.com/owner/repo/issues/9#issuecomment-111222333', + updatedAt: '2026-06-01T18:30:00.000Z', + repoFullName: 'owner/repo', + prNumber: null, + } as never); + const cmd = new UpdatePRComment( + ['--commentId', '111222333', '--body', 'Updated'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data?.prNumber).toBeNull(); + }); + + it('ReplyToReviewComment stdout exposes id, url, status, updatedAt, repoFullName, prNumber', async () => { + vi.mocked(replyToReviewComment).mockResolvedValueOnce({ + status: 'ok', + id: '500', + url: 'https://github.com/owner/repo/pull/42#discussion_r500', + updatedAt: '2026-06-01T19:00:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + } as never); + const cmd = new ReplyToReviewComment( + ['--prNumber', '42', '--commentId', '12345', '--body', 'Done'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'ok', + id: '500', + url: 'https://github.com/owner/repo/pull/42#discussion_r500', + updatedAt: '2026-06-01T19:00:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + }); + }); + + it('CreatePRReview stdout exposes id, url, status, updatedAt, repoFullName, prNumber, reviewUrl, event, submittedAt, inlineCommentCount', async () => { + vi.mocked(createPRReview).mockResolvedValueOnce({ + status: 'ok', + id: '700', + url: 'https://github.com/owner/repo/pull/42#pullrequestreview-700', + updatedAt: '2026-06-01T20:00:00.000Z', + reviewUrl: 'https://github.com/owner/repo/pull/42#pullrequestreview-700', + event: 'REQUEST_CHANGES', + repoFullName: 'owner/repo', + prNumber: 42, + submittedAt: '2026-06-01T20:00:00.000Z', + inlineCommentCount: 1, + } as never); + const cmd = new CreatePRReview( + [ + '--prNumber', + '42', + '--event', + 'REQUEST_CHANGES', + '--body', + 'Please address inline comments.', + ], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'ok', + id: '700', + url: 'https://github.com/owner/repo/pull/42#pullrequestreview-700', + updatedAt: '2026-06-01T20:00:00.000Z', + reviewUrl: 'https://github.com/owner/repo/pull/42#pullrequestreview-700', + event: 'REQUEST_CHANGES', + repoFullName: 'owner/repo', + prNumber: 42, + submittedAt: '2026-06-01T20:00:00.000Z', + inlineCommentCount: 1, + }); + }); + + it('CreatePRReview surfaces a runtime envelope when createPRReview throws (MNG-1425 + MNG-1428)', async () => { + vi.mocked(createPRReview).mockRejectedValueOnce(new Error('Validation Failed')); + const cmd = new CreatePRReview( + ['--prNumber', '42', '--event', 'APPROVE', '--body', 'LGTM'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await runExpectingExit(cmd); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error?.type).toBe('runtime'); + expect(output.error?.message).toBe('Validation Failed'); + }); + + it('updatedAt values are ISO 8601 strings across SCM mutations', async () => { + // Pins the GitHub-supplied timestamp surface — postPRComment / replyToReviewComment / + // updatePRComment use the response's `updated_at`; createPRReview falls back through + // pickTimestamp(submitted_at). The CLI envelope must carry parseable ISO 8601 strings + // either way. + vi.mocked(postPRComment).mockResolvedValueOnce({ + status: 'ok', + id: '1', + url: 'https://github.com/owner/repo/pull/42#issuecomment-1', + updatedAt: '2026-06-01T21:00:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + } as never); + const cmd = new PostPRComment(['--prNumber', '42', '--body', 'hi'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(typeof output.data?.updatedAt).toBe('string'); + expect(Number.isNaN(Date.parse(output.data?.updatedAt as string))).toBe(false); + }); +}); diff --git a/tests/unit/db/client.test.ts b/tests/unit/db/client.test.ts index 63f22275e..3413bb9eb 100644 --- a/tests/unit/db/client.test.ts +++ b/tests/unit/db/client.test.ts @@ -309,6 +309,20 @@ describe('getDb', () => { expect(mockPoolConstructor).toHaveBeenCalledWith(expect.objectContaining({ ssl: false })); }); + it('creates pool with rejectUnauthorized:false when DATABASE_SSL=no-verify', () => { + // Managed Postgres that requires TLS but presents a self-signed cert + // (e.g. Supabase's connection pooler). No CA file is read. + vi.stubEnv('DATABASE_SSL', 'no-verify'); + vi.stubEnv('DATABASE_CA_CERT', '/path/to/ca.pem'); + + getDb(); + + expect(mockReadFileSync).not.toHaveBeenCalled(); + expect(mockPoolConstructor).toHaveBeenCalledWith( + expect.objectContaining({ ssl: { rejectUnauthorized: false } }), + ); + }); + it('returns singleton — second call returns same instance', () => { const first = getDb(); const second = getDb(); diff --git a/tests/unit/db/ssl-config.test.ts b/tests/unit/db/ssl-config.test.ts new file mode 100644 index 000000000..a0f54eb05 --- /dev/null +++ b/tests/unit/db/ssl-config.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// ── Hoisted fs mock ─────────────────────────────────────────────────────────── +const { mockReadFileSync, mockExistsSync } = vi.hoisted(() => ({ + mockReadFileSync: vi.fn().mockReturnValue('mock-ca-cert-content'), + mockExistsSync: vi.fn().mockReturnValue(true), +})); + +vi.mock('node:fs', () => ({ + default: { readFileSync: mockReadFileSync }, + existsSync: mockExistsSync, +})); + +import { applyDbSslModeToUrl, resolveDbSslConfig } from '../../../src/db/ssl-config.js'; + +describe('resolveDbSslConfig', () => { + afterEach(() => { + vi.unstubAllEnvs(); + mockReadFileSync.mockClear(); + mockExistsSync.mockClear(); + mockExistsSync.mockReturnValue(true); + }); + + it('returns false when DATABASE_SSL=false', () => { + vi.stubEnv('DATABASE_SSL', 'false'); + expect(resolveDbSslConfig()).toBe(false); + }); + + it('returns { rejectUnauthorized: false } when DATABASE_SSL=no-verify', () => { + vi.stubEnv('DATABASE_SSL', 'no-verify'); + vi.stubEnv('DATABASE_CA_CERT', ''); + expect(resolveDbSslConfig()).toEqual({ rejectUnauthorized: false }); + }); + + it('ignores DATABASE_CA_CERT in no-verify mode (no file read)', () => { + vi.stubEnv('DATABASE_SSL', 'no-verify'); + vi.stubEnv('DATABASE_CA_CERT', '/path/to/ca.pem'); + expect(resolveDbSslConfig()).toEqual({ rejectUnauthorized: false }); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + + it('returns { rejectUnauthorized: true } by default (DATABASE_SSL unset)', () => { + vi.stubEnv('DATABASE_SSL', ''); + vi.stubEnv('DATABASE_CA_CERT', ''); + expect(resolveDbSslConfig()).toEqual({ rejectUnauthorized: true }); + }); + + it('attaches CA cert when DATABASE_CA_CERT is set (verify mode)', () => { + vi.stubEnv('DATABASE_SSL', ''); + vi.stubEnv('DATABASE_CA_CERT', '/path/to/ca.pem'); + expect(resolveDbSslConfig()).toEqual({ rejectUnauthorized: true, ca: 'mock-ca-cert-content' }); + expect(mockReadFileSync).toHaveBeenCalledWith('/path/to/ca.pem', 'utf8'); + }); + + it('throws a descriptive error when DATABASE_CA_CERT path does not exist', () => { + vi.stubEnv('DATABASE_SSL', ''); + vi.stubEnv('DATABASE_CA_CERT', '/nonexistent/ca.pem'); + mockExistsSync.mockReturnValueOnce(false); + expect(() => resolveDbSslConfig()).toThrow( + 'DATABASE_CA_CERT file not found: /nonexistent/ca.pem', + ); + }); +}); + +describe('applyDbSslModeToUrl', () => { + afterEach(() => vi.unstubAllEnvs()); + + const URL = 'postgresql://u:p@host:5432/db'; + + it('appends sslmode=no-verify when DATABASE_SSL=no-verify', () => { + vi.stubEnv('DATABASE_SSL', 'no-verify'); + expect(applyDbSslModeToUrl(URL)).toBe(`${URL}?sslmode=no-verify`); + }); + + it('appends sslmode=disable when DATABASE_SSL=false', () => { + vi.stubEnv('DATABASE_SSL', 'false'); + expect(applyDbSslModeToUrl(URL)).toBe(`${URL}?sslmode=disable`); + }); + + it('leaves the URL unchanged when DATABASE_SSL is unset (verify mode)', () => { + vi.stubEnv('DATABASE_SSL', ''); + expect(applyDbSslModeToUrl(URL)).toBe(URL); + }); + + it('uses & when the URL already has a query string', () => { + vi.stubEnv('DATABASE_SSL', 'no-verify'); + expect(applyDbSslModeToUrl(`${URL}?application_name=x`)).toBe( + `${URL}?application_name=x&sslmode=no-verify`, + ); + }); + + it('does not override an sslmode already present in the URL', () => { + vi.stubEnv('DATABASE_SSL', 'no-verify'); + const withMode = `${URL}?sslmode=require`; + expect(applyDbSslModeToUrl(withMode)).toBe(withMode); + }); + + it('returns an empty URL unchanged', () => { + vi.stubEnv('DATABASE_SSL', 'no-verify'); + expect(applyDbSslModeToUrl('')).toBe(''); + }); +}); diff --git a/tests/unit/gadgets/github/core/misc.test.ts b/tests/unit/gadgets/github/core/misc.test.ts index 6ada43437..95f45f3fb 100644 --- a/tests/unit/gadgets/github/core/misc.test.ts +++ b/tests/unit/gadgets/github/core/misc.test.ts @@ -278,74 +278,96 @@ describe('getPRComments', () => { }); describe('postPRComment', () => { - it('returns success with comment ID and URL', async () => { + it('returns success with structured fields', async () => { mockGithub.createPRComment.mockResolvedValue({ id: 50, htmlUrl: 'https://github.com/o/r/pull/1#issuecomment-50', + body: 'Nice work!', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-01T10:00:00Z', } as Awaited>); const result = await postPRComment('o', 'r', 1, 'Nice work!'); - expect(result).toContain('id: 50'); - expect(result).toContain('issuecomment-50'); + expect(result.id).toBe('50'); + expect(result.url).toBe('https://github.com/o/r/pull/1#issuecomment-50'); + expect(result.status).toBe('ok'); + expect(result.prNumber).toBe(1); + expect(result.repoFullName).toBe('o/r'); }); - it('returns error on failure', async () => { + it('throws on failure', async () => { mockGithub.createPRComment.mockRejectedValue(new Error('Rate limited')); - const result = await postPRComment('o', 'r', 1, 'Comment'); - - expect(result).toBe('Error posting PR comment: Rate limited'); + await expect(postPRComment('o', 'r', 1, 'Comment')).rejects.toThrow('Rate limited'); }); }); describe('updatePRComment', () => { - it('returns success with comment ID and URL', async () => { + it('returns success with structured fields', async () => { mockGithub.updatePRComment.mockResolvedValue({ id: 50, htmlUrl: 'https://github.com/o/r/pull/1#issuecomment-50', + body: 'Updated content', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', } as Awaited>); const result = await updatePRComment('o', 'r', 50, 'Updated content'); - expect(result).toContain('id: 50'); - expect(result).toContain('Comment updated'); + expect(result.id).toBe('50'); + expect(result.status).toBe('ok'); + expect(result.url).toBe('https://github.com/o/r/pull/1#issuecomment-50'); + expect(result.updatedAt).toBe('2026-05-02T11:00:00Z'); + expect(result.prNumber).toBe(1); + expect(result.repoFullName).toBe('o/r'); }); - it('returns error on failure', async () => { + it('throws on failure', async () => { mockGithub.updatePRComment.mockRejectedValue(new Error('Not found')); - const result = await updatePRComment('o', 'r', 50, 'Content'); - - expect(result).toBe('Error updating PR comment: Not found'); + await expect(updatePRComment('o', 'r', 50, 'Content')).rejects.toThrow('Not found'); }); }); describe('replyToReviewComment', () => { - it('returns success with reply URL', async () => { + it('returns success with structured fields', async () => { mockGithub.replyToReviewComment.mockResolvedValue({ + id: 200, + body: 'Acknowledged', + path: 'src/index.ts', + line: 5, htmlUrl: 'https://github.com/o/r/pull/1#reply-200', + user: { login: 'bot' }, + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-01T10:00:00Z', + inReplyToId: 100, } as Awaited>); const result = await replyToReviewComment('o', 'r', 1, 100, 'Acknowledged'); - expect(result).toContain('Reply posted successfully'); - expect(result).toContain('reply-200'); + expect(result.id).toBe('200'); + expect(result.status).toBe('ok'); + expect(result.url).toBe('https://github.com/o/r/pull/1#reply-200'); + expect(result.prNumber).toBe(1); + expect(result.repoFullName).toBe('o/r'); }); - it('returns error on failure', async () => { + it('throws on failure', async () => { mockGithub.replyToReviewComment.mockRejectedValue(new Error('Not found')); - const result = await replyToReviewComment('o', 'r', 1, 100, 'Reply'); - - expect(result).toBe('Error replying to comment: Not found'); + await expect(replyToReviewComment('o', 'r', 1, 100, 'Reply')).rejects.toThrow('Not found'); }); }); describe('createPRReview', () => { - it('creates review and returns reviewUrl + event', async () => { + it('creates review and returns structured fields', async () => { mockGithub.createPRReview.mockResolvedValue({ + id: 300, htmlUrl: 'https://github.com/o/r/pull/1#pullrequestreview-300', + body: 'LGTM', + state: 'APPROVED', + submittedAt: '2026-05-01T10:00:00Z', } as Awaited>); const result = await createPRReview({ @@ -356,19 +378,34 @@ describe('createPRReview', () => { body: 'LGTM', }); - expect(result).toEqual({ + expect(result).toMatchObject({ + id: '300', + status: 'ok', + updatedAt: '2026-05-01T10:00:00Z', + url: 'https://github.com/o/r/pull/1#pullrequestreview-300', reviewUrl: 'https://github.com/o/r/pull/1#pullrequestreview-300', event: 'APPROVE', + repoFullName: 'o/r', + prNumber: 1, + submittedAt: '2026-05-01T10:00:00Z', + inlineCommentCount: 0, }); }); - it('passes inline comments when provided', async () => { + it('passes inline comments through and reports their count', async () => { mockGithub.createPRReview.mockResolvedValue({ - htmlUrl: 'https://github.com/o/r/pull/1#pullrequestreview-300', + id: 301, + htmlUrl: 'https://github.com/o/r/pull/1#pullrequestreview-301', + body: 'Needs work', + state: 'CHANGES_REQUESTED', + submittedAt: '2026-05-01T10:00:00Z', } as Awaited>); - const comments = [{ path: 'file.ts', line: 10, body: 'Fix' }]; - await createPRReview({ + const comments = [ + { path: 'file.ts', line: 10, body: 'Fix' }, + { path: 'other.ts', line: 5, body: 'Also fix' }, + ]; + const result = await createPRReview({ owner: 'o', repo: 'r', prNumber: 1, @@ -385,6 +422,8 @@ describe('createPRReview', () => { 'Needs work', comments, ); + expect(result.inlineCommentCount).toBe(2); + expect(result.event).toBe('REQUEST_CHANGES'); }); it('throws on failure (no try/catch)', async () => { @@ -400,4 +439,25 @@ describe('createPRReview', () => { }), ).rejects.toThrow('Forbidden'); }); + + it('synthesises a submittedAt timestamp when GitHub omits it', async () => { + mockGithub.createPRReview.mockResolvedValue({ + id: 302, + htmlUrl: 'https://github.com/o/r/pull/1#pullrequestreview-302', + body: 'pending', + state: 'PENDING', + submittedAt: null, + } as Awaited>); + + const result = await createPRReview({ + owner: 'o', + repo: 'r', + prNumber: 1, + event: 'COMMENT', + body: 'pending', + }); + + expect(result.submittedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(result.updatedAt).toBe(result.submittedAt); + }); }); diff --git a/tests/unit/gadgets/github/core/mutationResults.test.ts b/tests/unit/gadgets/github/core/mutationResults.test.ts new file mode 100644 index 000000000..e966a6a46 --- /dev/null +++ b/tests/unit/gadgets/github/core/mutationResults.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + abortedResult, + currentTimestamp, + type GitHubMutationResult, + noOpResult, + okResult, + pickTimestamp, +} from '../../../../../src/gadgets/github/core/mutationResults.js'; + +const FROZEN_NOW = new Date('2026-03-15T12:34:56.789Z'); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FROZEN_NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('GitHub mutationResults', () => { + describe('currentTimestamp', () => { + it('returns the current ISO timestamp', () => { + expect(currentTimestamp()).toBe(FROZEN_NOW.toISOString()); + }); + }); + + describe('pickTimestamp', () => { + it('prefers the GitHub-supplied timestamp', () => { + expect(pickTimestamp('2025-12-01T01:02:03Z')).toBe('2025-12-01T01:02:03Z'); + }); + + it('falls back to the current ISO timestamp when undefined', () => { + expect(pickTimestamp(undefined)).toBe(FROZEN_NOW.toISOString()); + }); + + it('falls back when the provider value is null', () => { + expect(pickTimestamp(null)).toBe(FROZEN_NOW.toISOString()); + }); + + it('falls back when the provider value is the empty string', () => { + expect(pickTimestamp('')).toBe(FROZEN_NOW.toISOString()); + }); + }); + + describe('okResult', () => { + it('preserves the GitHub timestamp and stringifies numeric ids', () => { + const result: GitHubMutationResult = okResult({ + id: 4242, + updatedAt: '2025-12-01T01:02:03Z', + url: 'https://github.com/o/r/pull/42', + }); + expect(result).toEqual({ + id: '4242', + status: 'ok', + updatedAt: '2025-12-01T01:02:03Z', + url: 'https://github.com/o/r/pull/42', + }); + }); + + it('accepts string ids unchanged', () => { + const result = okResult({ id: 'gh-comment-id', updatedAt: '2025-12-01T01:02:03Z' }); + expect(result.id).toBe('gh-comment-id'); + }); + + it('rejects an empty GitHub timestamp', () => { + expect(() => okResult({ id: 1, updatedAt: '' })).toThrow( + 'okResult requires a GitHub-supplied updatedAt timestamp', + ); + }); + + it('rejects a missing GitHub timestamp at runtime', () => { + expect(() => okResult({ id: 1 } as Parameters[0])).toThrow( + 'okResult requires a GitHub-supplied updatedAt timestamp', + ); + }); + + it('omits optional fields when not provided', () => { + const result = okResult({ id: 1, updatedAt: '2025-12-01T01:02:03Z' }); + expect(result.url).toBeUndefined(); + expect(result.message).toBeUndefined(); + }); + }); + + describe('noOpResult', () => { + it('uses the current ISO timestamp', () => { + const result = noOpResult({ + id: 99, + message: 'PR already exists for this branch', + url: 'https://github.com/o/r/pull/99', + }); + expect(result).toEqual({ + id: '99', + status: 'no-op', + updatedAt: FROZEN_NOW.toISOString(), + url: 'https://github.com/o/r/pull/99', + message: 'PR already exists for this branch', + }); + }); + }); + + describe('abortedResult', () => { + it('uses the current ISO timestamp', () => { + const result = abortedResult({ + id: 'comment-1', + message: 'expected author mismatch', + }); + expect(result).toEqual({ + id: 'comment-1', + status: 'aborted', + updatedAt: FROZEN_NOW.toISOString(), + message: 'expected author mismatch', + }); + }); + + it('omits optional fields when not provided', () => { + const result = abortedResult({ id: 1 }); + expect(result.url).toBeUndefined(); + expect(result.message).toBeUndefined(); + }); + }); + + describe('status union exhaustiveness', () => { + it('covers every GitHubMutationStatus member', () => { + const ok = okResult({ id: 1, updatedAt: '2025-12-01T00:00:00Z' }); + const noop = noOpResult({ id: 2 }); + const aborted = abortedResult({ id: 3 }); + expect(new Set([ok.status, noop.status, aborted.status])).toEqual( + new Set(['ok', 'no-op', 'aborted']), + ); + }); + }); +}); diff --git a/tests/unit/gadgets/github/core/postPRComment.test.ts b/tests/unit/gadgets/github/core/postPRComment.test.ts index a5f38a242..96866eca3 100644 --- a/tests/unit/gadgets/github/core/postPRComment.test.ts +++ b/tests/unit/gadgets/github/core/postPRComment.test.ts @@ -22,18 +22,26 @@ describe('postPRComment', () => { vi.clearAllMocks(); }); - it('returns "Comment posted" with id and URL on success (no run link footer)', async () => { + it('returns a structured PostPRCommentResult on success (no run link footer)', async () => { mockBuildRunLinkFooter.mockReturnValue(null); mockGithub.createPRComment.mockResolvedValue({ id: 123, htmlUrl: 'https://github.com/owner/repo/pull/42#issuecomment-123', + body: 'Hello from test', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-01T10:00:00Z', } as Awaited>); const result = await postPRComment('owner', 'repo', 42, 'Hello from test'); - expect(result).toBe( - 'Comment posted (id: 123): https://github.com/owner/repo/pull/42#issuecomment-123', - ); + expect(result).toEqual({ + id: '123', + status: 'ok', + updatedAt: '2026-05-01T10:00:00Z', + url: 'https://github.com/owner/repo/pull/42#issuecomment-123', + repoFullName: 'owner/repo', + prNumber: 42, + }); expect(mockGithub.createPRComment).toHaveBeenCalledWith('owner', 'repo', 42, 'Hello from test'); }); @@ -42,6 +50,9 @@ describe('postPRComment', () => { mockGithub.createPRComment.mockResolvedValue({ id: 456, htmlUrl: 'https://github.com/owner/repo/pull/42#issuecomment-456', + body: 'My comment\n\n[Run details](https://example.com/run/1)', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-01T10:00:00Z', } as Awaited>); const result = await postPRComment('owner', 'repo', 42, 'My comment'); @@ -52,17 +63,34 @@ describe('postPRComment', () => { 42, 'My comment\n\n[Run details](https://example.com/run/1)', ); - expect(result).toBe( - 'Comment posted (id: 456): https://github.com/owner/repo/pull/42#issuecomment-456', - ); + expect(result).toMatchObject({ + id: '456', + status: 'ok', + url: 'https://github.com/owner/repo/pull/42#issuecomment-456', + repoFullName: 'owner/repo', + prNumber: 42, + }); }); - it('returns error message string when githubClient throws', async () => { + it('throws when githubClient throws (no prose sentinel)', async () => { mockBuildRunLinkFooter.mockReturnValue(null); mockGithub.createPRComment.mockRejectedValue(new Error('Forbidden')); - const result = await postPRComment('owner', 'repo', 42, 'My comment'); + await expect(postPRComment('owner', 'repo', 42, 'My comment')).rejects.toThrow('Forbidden'); + }); + + it('surfaces the GitHub-supplied updatedAt timestamp', async () => { + mockBuildRunLinkFooter.mockReturnValue(null); + mockGithub.createPRComment.mockResolvedValue({ + id: 789, + htmlUrl: 'https://github.com/o/r/pull/1#issuecomment-789', + body: 'Body', + createdAt: '2025-12-01T01:02:03Z', + updatedAt: '2025-12-01T01:02:03Z', + } as Awaited>); + + const result = await postPRComment('o', 'r', 1, 'Body'); - expect(result).toBe('Error posting PR comment: Forbidden'); + expect(result.updatedAt).toBe('2025-12-01T01:02:03Z'); }); }); diff --git a/tests/unit/gadgets/github/core/replyToReviewComment.test.ts b/tests/unit/gadgets/github/core/replyToReviewComment.test.ts index d0962fc89..e6e11c24c 100644 --- a/tests/unit/gadgets/github/core/replyToReviewComment.test.ts +++ b/tests/unit/gadgets/github/core/replyToReviewComment.test.ts @@ -16,16 +16,29 @@ describe('replyToReviewComment', () => { vi.clearAllMocks(); }); - it('returns "Reply posted successfully" with URL on success', async () => { + it('returns a structured ReplyToReviewCommentResult on success', async () => { mockGithub.replyToReviewComment.mockResolvedValue({ + id: 999, + body: 'Looks good!', + path: 'src/index.ts', + line: 5, htmlUrl: 'https://github.com/owner/repo/pull/42#discussion_r999', + user: { login: 'bot' }, + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-01T10:00:00Z', + inReplyToId: 101, } as Awaited>); const result = await replyToReviewComment('owner', 'repo', 42, 101, 'Looks good!'); - expect(result).toBe( - 'Reply posted successfully: https://github.com/owner/repo/pull/42#discussion_r999', - ); + expect(result).toEqual({ + id: '999', + status: 'ok', + updatedAt: '2026-05-01T10:00:00Z', + url: 'https://github.com/owner/repo/pull/42#discussion_r999', + repoFullName: 'owner/repo', + prNumber: 42, + }); expect(mockGithub.replyToReviewComment).toHaveBeenCalledWith( 'owner', 'repo', @@ -35,11 +48,29 @@ describe('replyToReviewComment', () => { ); }); - it('returns error message string when githubClient throws', async () => { + it('throws when githubClient throws (no prose sentinel)', async () => { mockGithub.replyToReviewComment.mockRejectedValue(new Error('Unprocessable Entity')); - const result = await replyToReviewComment('owner', 'repo', 42, 101, 'My reply'); + await expect(replyToReviewComment('owner', 'repo', 42, 101, 'My reply')).rejects.toThrow( + 'Unprocessable Entity', + ); + }); + + it('falls back to createdAt when updatedAt is missing', async () => { + mockGithub.replyToReviewComment.mockResolvedValue({ + id: 1010, + body: 'My reply', + path: 'src/index.ts', + line: 1, + htmlUrl: 'https://github.com/owner/repo/pull/42#discussion_r1010', + user: { login: 'bot' }, + createdAt: '2026-04-01T10:00:00Z', + // updatedAt deliberately omitted to simulate the rare Octokit response shape + inReplyToId: 200, + } as Awaited>); + + const result = await replyToReviewComment('owner', 'repo', 42, 200, 'My reply'); - expect(result).toBe('Error replying to comment: Unprocessable Entity'); + expect(result.updatedAt).toBe('2026-04-01T10:00:00Z'); }); }); diff --git a/tests/unit/gadgets/github/core/updatePRComment.test.ts b/tests/unit/gadgets/github/core/updatePRComment.test.ts index 5daac47e4..b861047c9 100644 --- a/tests/unit/gadgets/github/core/updatePRComment.test.ts +++ b/tests/unit/gadgets/github/core/updatePRComment.test.ts @@ -16,25 +16,61 @@ describe('updatePRComment', () => { vi.clearAllMocks(); }); - it('returns "Comment updated" with id and URL on success', async () => { + it('returns a structured UpdatePRCommentResult on success', async () => { mockGithub.updatePRComment.mockResolvedValue({ id: 789, htmlUrl: 'https://github.com/owner/repo/pull/42#issuecomment-789', + body: 'Updated body', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', } as Awaited>); const result = await updatePRComment('owner', 'repo', 789, 'Updated body'); - expect(result).toBe( - 'Comment updated (id: 789): https://github.com/owner/repo/pull/42#issuecomment-789', - ); + expect(result).toEqual({ + id: '789', + status: 'ok', + updatedAt: '2026-05-02T11:00:00Z', + url: 'https://github.com/owner/repo/pull/42#issuecomment-789', + repoFullName: 'owner/repo', + prNumber: 42, + }); expect(mockGithub.updatePRComment).toHaveBeenCalledWith('owner', 'repo', 789, 'Updated body'); }); - it('returns error message string when githubClient throws', async () => { + it('throws when githubClient throws (no prose sentinel)', async () => { mockGithub.updatePRComment.mockRejectedValue(new Error('Not Found')); - const result = await updatePRComment('owner', 'repo', 789, 'Updated body'); + await expect(updatePRComment('owner', 'repo', 789, 'Updated body')).rejects.toThrow( + 'Not Found', + ); + }); + + it('extracts prNumber from the comment html_url', async () => { + mockGithub.updatePRComment.mockResolvedValue({ + id: 555, + htmlUrl: 'https://github.com/big-co/platform/pull/9999#issuecomment-555', + body: 'New content', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', + } as Awaited>); + + const result = await updatePRComment('big-co', 'platform', 555, 'New content'); + + expect(result.prNumber).toBe(9999); + }); + + it('returns prNumber=null when html_url does not match the /pull/ pattern', async () => { + mockGithub.updatePRComment.mockResolvedValue({ + id: 777, + htmlUrl: 'https://github.com/owner/repo/issues/42#issuecomment-777', + body: 'Body', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', + } as Awaited>); + + const result = await updatePRComment('owner', 'repo', 777, 'Body'); - expect(result).toBe('Error updating PR comment: Not Found'); + expect(result.prNumber).toBeNull(); }); }); diff --git a/tests/unit/gadgets/github/createPRReview.test.ts b/tests/unit/gadgets/github/createPRReview.test.ts index 6c79d9849..24f872ff9 100644 --- a/tests/unit/gadgets/github/createPRReview.test.ts +++ b/tests/unit/gadgets/github/createPRReview.test.ts @@ -33,6 +33,22 @@ const BASE_PARAMS = { body: 'LGTM!', }; +function structuredReviewResult(overrides: Partial<{ reviewUrl: string; event: string }> = {}) { + return { + id: '1', + status: 'ok' as const, + updatedAt: '2026-05-01T10:00:00Z', + url: 'https://github.com/acme/myapp/pull/42#pullrequestreview-1', + reviewUrl: 'https://github.com/acme/myapp/pull/42#pullrequestreview-1', + event: 'APPROVE' as const, + repoFullName: 'acme/myapp', + prNumber: 42, + submittedAt: '2026-05-01T10:00:00Z', + inlineCommentCount: 0, + ...overrides, + }; +} + describe('CreatePRReview', () => { let gadget: InstanceType; @@ -41,10 +57,7 @@ describe('CreatePRReview', () => { }); it('submits review, records it, and deletes ack comment on success', async () => { - mockCreatePRReview.mockResolvedValue({ - reviewUrl: 'https://github.com/acme/myapp/pull/42#pullrequestreview-1', - event: 'APPROVE', - }); + mockCreatePRReview.mockResolvedValue(structuredReviewResult()); const result = await gadget.execute(BASE_PARAMS); @@ -66,10 +79,7 @@ describe('CreatePRReview', () => { }); it('does not fail if deleteInitialComment throws', async () => { - mockCreatePRReview.mockResolvedValue({ - reviewUrl: 'https://github.com/acme/myapp/pull/42#pullrequestreview-1', - event: 'APPROVE', - }); + mockCreatePRReview.mockResolvedValue(structuredReviewResult()); // deleteInitialComment itself handles errors internally, but simulate it throwing mockDeleteInitialComment.mockRejectedValueOnce(new Error('GitHub API error')); diff --git a/tests/unit/gadgets/github/definitions.test.ts b/tests/unit/gadgets/github/definitions.test.ts index 11f55423b..e7cba681a 100644 --- a/tests/unit/gadgets/github/definitions.test.ts +++ b/tests/unit/gadgets/github/definitions.test.ts @@ -335,6 +335,155 @@ describe('getPRDiffDef', () => { }); }); +// --------------------------------------------------------------------------- +// MNG-1427: GitHub mutation output-shape coverage +// --------------------------------------------------------------------------- + +describe('GitHub mutation output shapes (MNG-1427)', () => { + const MUTATION_DEFS_WITH_REQUIRED_OUTPUT_SHAPE: ToolDefinition[] = [ + createPRDef, + createPRReviewDef, + postPRCommentDef, + updatePRCommentDef, + replyToReviewCommentDef, + ]; + + const READ_ONLY_DEFS_WITHOUT_OUTPUT_SHAPE: ToolDefinition[] = [ + getPRDetailsDef, + getPRDiffDef, + getPRChecksDef, + getPRCommentsDef, + getCIRunLogsDef, + ]; + + it('every SCM mutation definition declares an outputShape with at least one field', () => { + for (const def of MUTATION_DEFS_WITH_REQUIRED_OUTPUT_SHAPE) { + expect(def.outputShape, `${def.name} must declare outputShape`).toBeDefined(); + expect( + def.outputShape?.fields.length, + `${def.name} outputShape must list at least one field`, + ).toBeGreaterThan(0); + } + }); + + it('every output-shape field has a non-empty name and type', () => { + for (const def of MUTATION_DEFS_WITH_REQUIRED_OUTPUT_SHAPE) { + for (const field of def.outputShape?.fields ?? []) { + expect(typeof field.name).toBe('string'); + expect(field.name.length).toBeGreaterThan(0); + expect(typeof field.type).toBe('string'); + expect(field.type.length).toBeGreaterThan(0); + } + } + }); + + it('read-only SCM definitions do not declare an outputShape', () => { + for (const def of READ_ONLY_DEFS_WITHOUT_OUTPUT_SHAPE) { + expect(def.outputShape, `${def.name} must NOT declare outputShape`).toBeUndefined(); + } + }); + + it('CreatePR output shape mirrors the CreatePRResult contract', () => { + const names = createPRDef.outputShape?.fields.map((f) => f.name) ?? []; + expect(names).toContain('prNumber'); + expect(names).toContain('prUrl'); + expect(names).toContain('repoFullName'); + expect(names).toContain('alreadyExisted'); + }); + + it('CreatePR pushOutput / commitOutput are optional', () => { + const fieldsByName = new Map((createPRDef.outputShape?.fields ?? []).map((f) => [f.name, f])); + expect(fieldsByName.get('pushOutput')?.optional).toBe(true); + expect(fieldsByName.get('commitOutput')?.optional).toBe(true); + }); + + it('CreatePRReview output shape includes reviewUrl + inlineCommentCount', () => { + const names = createPRReviewDef.outputShape?.fields.map((f) => f.name) ?? []; + expect(names).toContain('reviewUrl'); + expect(names).toContain('inlineCommentCount'); + expect(names).toContain('event'); + }); + + it('CreatePRReview event type covers the APPROVE / REQUEST_CHANGES / COMMENT union', () => { + const event = createPRReviewDef.outputShape?.fields.find((f) => f.name === 'event'); + expect(event?.type).toContain('APPROVE'); + expect(event?.type).toContain('REQUEST_CHANGES'); + expect(event?.type).toContain('COMMENT'); + }); + + it('UpdatePRComment prNumber is `number | null`', () => { + const prNumber = updatePRCommentDef.outputShape?.fields.find((f) => f.name === 'prNumber'); + expect(prNumber?.type).toBe('number | null'); + }); +}); + +// --------------------------------------------------------------------------- +// MNG-1428: SCM minimum structured-output contract +// +// Every SCM PR comment / reply / update / review mutation must declare the +// minimum structured-output fields (`status`, `id`, `url`, `updatedAt`) plus +// the PR/repo context (`repoFullName`, `prNumber`). These tests pin each +// minimum field as a regression guard — a future drift that drops a key from +// outputShape (or changes the type) surfaces in CI rather than silently +// breaking the documented agent-facing contract. +// +// The CLI envelope round-trips these fields verbatim through stdout; consumers +// (CLI sidecars, downstream review/respond flows) read them as structured +// data and don't have to parse prose. +// --------------------------------------------------------------------------- +describe('SCM minimum structured-output contract (MNG-1428)', () => { + const PR_MUTATION_DEFS = [ + postPRCommentDef, + updatePRCommentDef, + replyToReviewCommentDef, + createPRReviewDef, + ]; + + for (const def of PR_MUTATION_DEFS) { + describe(`${def.name}`, () => { + it('declares the minimum structured-output fields (id, url, status, updatedAt)', () => { + const fieldsByName = new Map((def.outputShape?.fields ?? []).map((f) => [f.name, f])); + expect(fieldsByName.get('id'), `${def.name} must declare id`).toBeDefined(); + expect(fieldsByName.get('id')?.type).toBe('string'); + expect(fieldsByName.get('url'), `${def.name} must declare url`).toBeDefined(); + expect(fieldsByName.get('url')?.type).toBe('string'); + expect(fieldsByName.get('status'), `${def.name} must declare status`).toBeDefined(); + expect(fieldsByName.get('updatedAt'), `${def.name} must declare updatedAt`).toBeDefined(); + expect(fieldsByName.get('updatedAt')?.type).toBe('string'); + }); + + it('declares the PR/repo context fields (repoFullName, prNumber)', () => { + const fieldsByName = new Map((def.outputShape?.fields ?? []).map((f) => [f.name, f])); + expect( + fieldsByName.get('repoFullName'), + `${def.name} must declare repoFullName`, + ).toBeDefined(); + expect(fieldsByName.get('repoFullName')?.type).toBe('string'); + expect(fieldsByName.get('prNumber'), `${def.name} must declare prNumber`).toBeDefined(); + // UpdatePRComment widens to `number | null` because some issue-only + // comments don't expose a /pull/ segment in html_url — but the + // field is still always present on the output shape. + expect(['number', 'number | null']).toContain(fieldsByName.get('prNumber')?.type); + }); + + it('declares the generic GitHub mutation status union on `status`', () => { + const status = def.outputShape?.fields.find((f) => f.name === 'status'); + // All four PR-mutation outputs reuse the shared + // `GitHubMutationStatus` union (`"ok" | "no-op" | "aborted"`). + expect(status?.type).toBe('"ok" | "no-op" | "aborted"'); + }); + }); + } + + it('CreatePRReview additionally declares reviewUrl, event, submittedAt, inlineCommentCount', () => { + const names = createPRReviewDef.outputShape?.fields.map((f) => f.name) ?? []; + expect(names).toContain('reviewUrl'); + expect(names).toContain('event'); + expect(names).toContain('submittedAt'); + expect(names).toContain('inlineCommentCount'); + }); +}); + // --------------------------------------------------------------------------- // Spec 014 plan 2: createPRReviewDef declarative opt-in // --------------------------------------------------------------------------- diff --git a/tests/unit/gadgets/pm/core/addChecklist.test.ts b/tests/unit/gadgets/pm/core/addChecklist.test.ts index 8063b528f..8bd002d58 100644 --- a/tests/unit/gadgets/pm/core/addChecklist.test.ts +++ b/tests/unit/gadgets/pm/core/addChecklist.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockPMProvider } from '../../../../helpers/mockPMProvider.js'; +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; const mockProvider = createMockPMProvider(); @@ -14,172 +14,311 @@ const providerWithBulk = mockProvider as typeof mockProvider & { createChecklistWithItems?: ReturnType; }; +beforeEach(() => { + vi.clearAllMocks(); + delete providerWithBulk.createChecklistWithItems; + // Default work-item read-back for URL + updatedAt. Individual tests + // override `getWorkItem` to stub provider-specific timestamps. + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }), + ); + mockProvider.getWorkItemUrl.mockReturnValue('https://trello.com/c/item1'); +}); + describe('addChecklist', () => { - beforeEach(() => { - vi.clearAllMocks(); - delete providerWithBulk.createChecklistWithItems; - }); + describe('inline-style (bulk path / createChecklistWithItems)', () => { + it('uses provider bulk creation when available and returns the structured result', async () => { + providerWithBulk.createChecklistWithItems = vi.fn().mockResolvedValue({ + id: 'cl1', + name: 'My Tasks', + workItemId: 'item1', + // Inline-description providers (Linear/JIRA) return deterministic + // hashed item IDs from the bulk path — mirror that shape here. + items: [ + { id: 'task-a-hash', name: 'Task A', complete: false }, + { id: 'task-b-hash', name: 'Task B', complete: false }, + ], + }); - it('uses provider bulk creation when available', async () => { - providerWithBulk.createChecklistWithItems = vi.fn().mockResolvedValue({ - id: 'cl1', - name: 'My Tasks', - workItemId: 'item1', - items: [], - }); + const result = await addChecklist({ + workItemId: 'item1', + checklistName: 'My Tasks', + items: ['Task A', { name: 'Task B', description: 'Details' }], + }); - const result = await addChecklist({ - workItemId: 'item1', - checklistName: 'My Tasks', - items: ['Task A', { name: 'Task B', description: 'Details' }], + expect(providerWithBulk.createChecklistWithItems).toHaveBeenCalledTimes(1); + expect(providerWithBulk.createChecklistWithItems).toHaveBeenCalledWith('item1', 'My Tasks', [ + { name: 'Task A', checked: false }, + { name: 'Task B', checked: false, description: 'Details' }, + ]); + expect(mockProvider.createChecklist).not.toHaveBeenCalled(); + expect(mockProvider.addChecklistItem).not.toHaveBeenCalled(); + expect(result).toEqual({ + status: 'created', + checklistId: 'cl1', + checklistName: 'My Tasks', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + itemCount: 2, + itemIds: ['task-a-hash', 'task-b-hash'], + }); }); - expect(providerWithBulk.createChecklistWithItems).toHaveBeenCalledTimes(1); - expect(providerWithBulk.createChecklistWithItems).toHaveBeenCalledWith('item1', 'My Tasks', [ - { name: 'Task A', checked: false }, - { name: 'Task B', checked: false, description: 'Details' }, - ]); - expect(mockProvider.createChecklist).not.toHaveBeenCalled(); - expect(mockProvider.addChecklistItem).not.toHaveBeenCalled(); - expect(result).toBe('Checklist "My Tasks" created with 2 items on work item item1'); - }); + it('returns an empty itemIds array when the bulk path returns no item IDs', async () => { + providerWithBulk.createChecklistWithItems = vi.fn().mockResolvedValue({ + id: 'cl1', + name: 'My Tasks', + workItemId: 'item1', + items: [], + }); - it('creates checklist and adds string items', async () => { - mockProvider.createChecklist.mockResolvedValue({ - id: 'cl1', - name: 'My Tasks', - workItemId: 'item1', - items: [], - }); - mockProvider.addChecklistItem.mockResolvedValue(undefined); + const result = await addChecklist({ + workItemId: 'item1', + checklistName: 'My Tasks', + items: ['Task A'], + }); - const result = await addChecklist({ - workItemId: 'item1', - checklistName: 'My Tasks', - items: ['Task A', 'Task B'], + expect(result.itemIds).toEqual([]); + expect(result.itemCount).toBe(1); }); - - expect(mockProvider.createChecklist).toHaveBeenCalledWith('item1', 'My Tasks'); - expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(2); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', 'Task A', false, undefined); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', 'Task B', false, undefined); - expect(result).toBe('Checklist "My Tasks" created with 2 items on work item item1'); }); - it('creates checklist and adds object items with descriptions', async () => { - mockProvider.createChecklist.mockResolvedValue({ - id: 'cl1', - name: 'Steps', - workItemId: 'PROJ-42', - items: [], - }); - mockProvider.addChecklistItem.mockResolvedValue(undefined); - - const result = await addChecklist({ - workItemId: 'PROJ-42', - checklistName: 'Steps', - items: [ - { name: 'Add endpoint', description: '**Files:** `src/api.ts`\n- Add POST route' }, - { name: 'Write tests' }, - ], + describe('native-style (per-item fallback path)', () => { + it('creates checklist and adds string items', async () => { + mockProvider.createChecklist.mockResolvedValue({ + id: 'cl1', + name: 'My Tasks', + workItemId: 'item1', + items: [], + }); + mockProvider.addChecklistItem.mockResolvedValue(undefined); + + const result = await addChecklist({ + workItemId: 'item1', + checklistName: 'My Tasks', + items: ['Task A', 'Task B'], + }); + + expect(mockProvider.createChecklist).toHaveBeenCalledWith('item1', 'My Tasks'); + expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(2); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', 'Task A', false, undefined); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', 'Task B', false, undefined); + expect(result).toEqual({ + status: 'created', + checklistId: 'cl1', + checklistName: 'My Tasks', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + itemCount: 2, + itemIds: [], + }); }); - expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(2); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( - 'cl1', - 'Add endpoint', - false, - '**Files:** `src/api.ts`\n- Add POST route', - ); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( - 'cl1', - 'Write tests', - false, - undefined, - ); - expect(result).toBe('Checklist "Steps" created with 2 items on work item PROJ-42'); - }); + it('creates checklist and adds object items with descriptions', async () => { + mockProvider.createChecklist.mockResolvedValue({ + id: 'cl1', + name: 'Steps', + workItemId: 'PROJ-42', + items: [], + }); + mockProvider.addChecklistItem.mockResolvedValue(undefined); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'PROJ-42', + url: 'https://jira.example.com/browse/PROJ-42', + updatedAt: '2026-03-15T13:00:00.000Z', + }), + ); + + const result = await addChecklist({ + workItemId: 'PROJ-42', + checklistName: 'Steps', + items: [ + { name: 'Add endpoint', description: '**Files:** `src/api.ts`\n- Add POST route' }, + { name: 'Write tests' }, + ], + }); - it('handles mixed string and object items', async () => { - mockProvider.createChecklist.mockResolvedValue({ - id: 'cl1', - name: 'Mixed', - workItemId: 'item1', - items: [], + expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(2); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( + 'cl1', + 'Add endpoint', + false, + '**Files:** `src/api.ts`\n- Add POST route', + ); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( + 'cl1', + 'Write tests', + false, + undefined, + ); + expect(result).toMatchObject({ + status: 'created', + checklistId: 'cl1', + checklistName: 'Steps', + workItemId: 'PROJ-42', + workItemUrl: 'https://jira.example.com/browse/PROJ-42', + updatedAt: '2026-03-15T13:00:00.000Z', + itemCount: 2, + }); }); - mockProvider.addChecklistItem.mockResolvedValue(undefined); - await addChecklist({ - workItemId: 'item1', - checklistName: 'Mixed', - items: [ + it('handles mixed string and object items', async () => { + mockProvider.createChecklist.mockResolvedValue({ + id: 'cl1', + name: 'Mixed', + workItemId: 'item1', + items: [], + }); + mockProvider.addChecklistItem.mockResolvedValue(undefined); + + const result = await addChecklist({ + workItemId: 'item1', + checklistName: 'Mixed', + items: [ + 'Simple string item', + { name: 'Object item', description: 'Detailed description' }, + 'Another string', + ], + }); + + expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(3); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( + 'cl1', 'Simple string item', - { name: 'Object item', description: 'Detailed description' }, + false, + undefined, + ); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( + 'cl1', + 'Object item', + false, + 'Detailed description', + ); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( + 'cl1', 'Another string', - ], + false, + undefined, + ); + expect(result.itemCount).toBe(3); }); - - expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(3); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( - 'cl1', - 'Simple string item', - false, - undefined, - ); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( - 'cl1', - 'Object item', - false, - 'Detailed description', - ); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( - 'cl1', - 'Another string', - false, - undefined, - ); }); - it('throws error when creating checklist with no items', async () => { - await expect( - addChecklist({ + describe('validation and provider errors', () => { + it('throws error when creating checklist with no items', async () => { + await expect( + addChecklist({ + workItemId: 'item1', + checklistName: 'Empty', + items: [], + }), + ).rejects.toThrow('At least one checklist item is required'); + + expect(mockProvider.createChecklist).not.toHaveBeenCalled(); + expect(mockProvider.addChecklistItem).not.toHaveBeenCalled(); + }); + + it('throws on createChecklist failure (no prose sentinel)', async () => { + mockProvider.createChecklist.mockRejectedValue(new Error('API error')); + + await expect( + addChecklist({ + workItemId: 'item1', + checklistName: 'Tasks', + items: ['A'], + }), + ).rejects.toThrow('API error'); + }); + + it('throws on createChecklistWithItems failure (no prose sentinel)', async () => { + providerWithBulk.createChecklistWithItems = vi + .fn() + .mockRejectedValue(new Error('Bulk creation failed')); + + await expect( + addChecklist({ + workItemId: 'item1', + checklistName: 'Tasks', + items: ['A'], + }), + ).rejects.toThrow('Bulk creation failed'); + }); + + it('throws if addChecklistItem fails (no prose sentinel)', async () => { + mockProvider.createChecklist.mockResolvedValue({ + id: 'cl1', + name: 'Tasks', workItemId: 'item1', - checklistName: 'Empty', items: [], - }), - ).rejects.toThrow('At least one checklist item is required'); + }); + mockProvider.addChecklistItem.mockRejectedValue(new Error('Add item failed')); - expect(mockProvider.createChecklist).not.toHaveBeenCalled(); - expect(mockProvider.addChecklistItem).not.toHaveBeenCalled(); + await expect( + addChecklist({ + workItemId: 'item1', + checklistName: 'Tasks', + items: ['A'], + }), + ).rejects.toThrow('Add item failed'); + }); }); - it('throws on createChecklist failure', async () => { - mockProvider.createChecklist.mockRejectedValue(new Error('API error')); + describe('read-back fallback', () => { + // A successful mutation must not be masked by a failing work-item + // read-back — the helper at `readWorkItemContext` swallows the + // read-back error and synthesises a fallback URL+timestamp. + it('falls back to getWorkItemUrl + synthesised timestamp when read-back throws', async () => { + providerWithBulk.createChecklistWithItems = vi.fn().mockResolvedValue({ + id: 'cl1', + name: 'Tasks', + workItemId: 'item1', + items: [{ id: 'a-hash', name: 'A', complete: false }], + }); + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/item1'); - await expect( - addChecklist({ + const result = await addChecklist({ workItemId: 'item1', checklistName: 'Tasks', items: ['A'], - }), - ).rejects.toThrow('API error'); - }); + }); - it('throws if addChecklistItem fails', async () => { - mockProvider.createChecklist.mockResolvedValue({ - id: 'cl1', - name: 'Tasks', - workItemId: 'item1', - items: [], + expect(result.status).toBe('created'); + expect(result.workItemUrl).toBe('https://fallback.example/item1'); + expect(typeof result.updatedAt).toBe('string'); + expect(result.updatedAt.length).toBeGreaterThan(0); }); - mockProvider.addChecklistItem.mockRejectedValue(new Error('Add item failed')); - await expect( - addChecklist({ + it('synthesises updatedAt when the provider omits it on read-back', async () => { + providerWithBulk.createChecklistWithItems = vi.fn().mockResolvedValue({ + id: 'cl1', + name: 'Tasks', + workItemId: 'item1', + items: [], + }); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + updatedAt: undefined, + }), + ); + + const result = await addChecklist({ workItemId: 'item1', checklistName: 'Tasks', items: ['A'], - }), - ).rejects.toThrow('Add item failed'); + }); + + expect(result.workItemUrl).toBe('https://trello.com/c/item1'); + expect(result.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); }); }); diff --git a/tests/unit/gadgets/pm/core/createWorkItem.test.ts b/tests/unit/gadgets/pm/core/createWorkItem.test.ts index 99bb22030..6a2f00277 100644 --- a/tests/unit/gadgets/pm/core/createWorkItem.test.ts +++ b/tests/unit/gadgets/pm/core/createWorkItem.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { createMockPMProvider } from '../../../../helpers/mockPMProvider.js'; +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; const mockProvider = createMockPMProvider(); @@ -11,14 +11,18 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { createWorkItem } from '../../../../../src/gadgets/pm/core/createWorkItem.js'; describe('createWorkItem', () => { - it('creates a work item and returns success message', async () => { - mockProvider.createWorkItem.mockResolvedValue({ - id: 'item1', - title: 'New Feature', - description: 'A new feature', - url: 'https://trello.com/c/item1', - labels: [], - }); + it('returns a structured WorkItemCreatedResult with provider metadata', async () => { + mockProvider.createWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + title: 'New Feature', + description: 'A new feature', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + createdAt: '2026-03-15T12:00:00.000Z', + labels: [], + }), + ); const result = await createWorkItem({ containerId: 'list1', @@ -31,31 +35,106 @@ describe('createWorkItem', () => { title: 'New Feature', description: 'A new feature', }); - expect(result).toBe( - 'Work item created successfully: "New Feature" [id: item1] - https://trello.com/c/item1', - ); + expect(result).toEqual({ + status: 'created', + id: 'item1', + title: 'New Feature', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }); }); it('creates work item without description', async () => { - mockProvider.createWorkItem.mockResolvedValue({ + mockProvider.createWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item2', + title: 'Simple Item', + description: '', + url: 'https://trello.com/c/item2', + updatedAt: '2026-03-15T13:00:00.000Z', + createdAt: '2026-03-15T13:00:00.000Z', + labels: [], + }), + ); + + const result = await createWorkItem({ + containerId: 'list1', + title: 'Simple Item', + }); + + expect(result).toMatchObject({ + status: 'created', id: 'item2', title: 'Simple Item', - description: '', url: 'https://trello.com/c/item2', + }); + }); + + it('surfaces optional workflow-state fields when the provider returned them', async () => { + mockProvider.createWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'MNG-1', + title: 'Linear-like create', + description: '', + url: 'https://linear.app/team/issue/MNG-1', + updatedAt: '2026-03-15T14:00:00.000Z', + status: 'Backlog', + statusId: 'state-backlog-uuid', + }), + ); + + const result = await createWorkItem({ + containerId: 'team-x', + title: 'Linear-like create', + }); + + expect(result).toEqual({ + status: 'created', + id: 'MNG-1', + title: 'Linear-like create', + url: 'https://linear.app/team/issue/MNG-1', + updatedAt: '2026-03-15T14:00:00.000Z', + workflowStatus: 'Backlog', + workflowStatusId: 'state-backlog-uuid', + }); + }); + + it('falls back to createdAt when updatedAt is absent (creation-only providers)', async () => { + mockProvider.createWorkItem.mockResolvedValue({ + id: 'item-only-created', + title: 'Created-only timestamp', + description: '', + url: 'https://trello.com/c/item-only-created', labels: [], + createdAt: '2026-03-15T15:00:00.000Z', }); const result = await createWorkItem({ containerId: 'list1', - title: 'Simple Item', + title: 'Created-only timestamp', }); - expect(result).toBe( - 'Work item created successfully: "Simple Item" [id: item2] - https://trello.com/c/item2', - ); + expect(result.updatedAt).toBe('2026-03-15T15:00:00.000Z'); + }); + + it('synthesises a current ISO timestamp when the provider omits both timestamps', async () => { + mockProvider.createWorkItem.mockResolvedValue({ + id: 'item-no-ts', + title: 'No timestamps', + description: '', + url: 'https://trello.com/c/item-no-ts', + labels: [], + }); + + const result = await createWorkItem({ + containerId: 'list1', + title: 'No timestamps', + }); + + expect(result.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); - it('throws on failure instead of swallowing errors', async () => { + it('throws on failure instead of swallowing errors (no prose sentinel)', async () => { mockProvider.createWorkItem.mockRejectedValue(new Error('API error')); await expect( diff --git a/tests/unit/gadgets/pm/core/deleteChecklistItem.test.ts b/tests/unit/gadgets/pm/core/deleteChecklistItem.test.ts index 5b93d8d4a..e53e58ff6 100644 --- a/tests/unit/gadgets/pm/core/deleteChecklistItem.test.ts +++ b/tests/unit/gadgets/pm/core/deleteChecklistItem.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockPMProvider } from '../../../../helpers/mockPMProvider.js'; +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; const mockProvider = createMockPMProvider(); @@ -10,29 +10,72 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { deleteChecklistItem } from '../../../../../src/gadgets/pm/core/deleteChecklistItem.js'; +beforeEach(() => { + vi.clearAllMocks(); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }), + ); + mockProvider.getWorkItemUrl.mockReturnValue('https://trello.com/c/item1'); +}); + describe('deleteChecklistItem', () => { - it('deletes a checklist item and returns success message', async () => { + it('deletes a checklist item and returns the structured result', async () => { mockProvider.deleteChecklistItem.mockResolvedValue(undefined); const result = await deleteChecklistItem('item1', 'checkItem1'); expect(mockProvider.deleteChecklistItem).toHaveBeenCalledWith('item1', 'checkItem1'); - expect(result).toBe('Checklist item checkItem1 deleted from work item item1'); + expect(result).toEqual({ + status: 'deleted', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + checkItemId: 'checkItem1', + updatedAt: '2026-03-15T12:00:00.000Z', + }); }); - it('throws an error message on failure', async () => { + it('throws when the provider mutation fails (no prose sentinel)', async () => { mockProvider.deleteChecklistItem.mockRejectedValue(new Error('API error')); - await expect(deleteChecklistItem('item1', 'checkItem1')).rejects.toThrow( - 'Error deleting checklist item: API error', - ); + await expect(deleteChecklistItem('item1', 'checkItem1')).rejects.toThrow('API error'); }); - it('handles non-Error thrown value', async () => { + it('propagates non-Error thrown values as-is', async () => { mockProvider.deleteChecklistItem.mockRejectedValue('string error'); - await expect(deleteChecklistItem('item1', 'ci1')).rejects.toThrow( - 'Error deleting checklist item: string error', + await expect(deleteChecklistItem('item1', 'ci1')).rejects.toBe('string error'); + }); + + it('falls back to getWorkItemUrl + synthesised timestamp when read-back fails', async () => { + mockProvider.deleteChecklistItem.mockResolvedValue(undefined); + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/item1'); + + const result = await deleteChecklistItem('item1', 'checkItem1'); + + expect(result.status).toBe('deleted'); + expect(result.workItemUrl).toBe('https://fallback.example/item1'); + expect(typeof result.updatedAt).toBe('string'); + expect(result.updatedAt.length).toBeGreaterThan(0); + }); + + it('surfaces the provider-supplied updatedAt when present', async () => { + mockProvider.deleteChecklistItem.mockResolvedValue(undefined); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'PROJ-42', + url: 'https://jira.example.com/browse/PROJ-42', + updatedAt: '2025-12-01T01:02:03.000Z', + }), ); + + const result = await deleteChecklistItem('PROJ-42', 'sub-48'); + + expect(result.updatedAt).toBe('2025-12-01T01:02:03.000Z'); + expect(result.workItemUrl).toBe('https://jira.example.com/browse/PROJ-42'); }); }); diff --git a/tests/unit/gadgets/pm/core/moveWorkItem.test.ts b/tests/unit/gadgets/pm/core/moveWorkItem.test.ts index a9a54f9e8..1daa050c7 100644 --- a/tests/unit/gadgets/pm/core/moveWorkItem.test.ts +++ b/tests/unit/gadgets/pm/core/moveWorkItem.test.ts @@ -13,40 +13,52 @@ import { moveWorkItem } from '../../../../../src/gadgets/pm/core/moveWorkItem.js describe('moveWorkItem', () => { beforeEach(() => { vi.clearAllMocks(); + mockProvider.getWorkItemUrl.mockReturnValue('https://trello.com/c/card1'); }); - it('calls provider.moveWorkItem with correct args and returns success message', async () => { - mockProvider.moveWorkItem.mockResolvedValue(undefined); + describe('unguarded path (no expectedSourceState)', () => { + it('returns a structured moved result on success', async () => { + mockProvider.moveWorkItem.mockResolvedValue(undefined); - const result = await moveWorkItem({ - workItemId: 'card1', - destination: 'list2', - }); + const result = await moveWorkItem({ + workItemId: 'card1', + destination: 'list2', + }); - expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('card1', 'list2'); - expect(result).toBe('Work item card1 moved to list2 successfully'); - }); + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('card1', 'list2'); + expect(mockProvider.getWorkItem).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + status: 'moved', + id: 'card1', + url: 'https://trello.com/c/card1', + destination: 'list2', + }); + expect(typeof result.updatedAt).toBe('string'); + expect(result.previousStatus).toBeUndefined(); + expect(result.previousStatusId).toBeUndefined(); + }); - it('throws an error message on failure', async () => { - mockProvider.moveWorkItem.mockRejectedValue(new Error('API error')); + it('throws on provider failure (no prose sentinel)', async () => { + mockProvider.moveWorkItem.mockRejectedValue(new Error('API error')); - await expect( - moveWorkItem({ - workItemId: 'card1', - destination: 'list2', - }), - ).rejects.toThrow('Error moving work item: API error'); - }); + await expect( + moveWorkItem({ + workItemId: 'card1', + destination: 'list2', + }), + ).rejects.toThrow('API error'); + }); - it('handles non-Error throws', async () => { - mockProvider.moveWorkItem.mockRejectedValue('network timeout'); + it('propagates non-Error throws', async () => { + mockProvider.moveWorkItem.mockRejectedValue('network timeout'); - await expect( - moveWorkItem({ - workItemId: 'card1', - destination: 'list2', - }), - ).rejects.toThrow('Error moving work item: network timeout'); + await expect( + moveWorkItem({ + workItemId: 'card1', + destination: 'list2', + }), + ).rejects.toThrow('network timeout'); + }); }); // ── expectedSourceState guard ──────────────────────────────────────────── @@ -64,7 +76,7 @@ describe('moveWorkItem', () => { labels: [], }; - it('proceeds with move when current status matches expectedSourceState', async () => { + it('returns a moved result when current status matches expectedSourceState', async () => { mockProvider.getWorkItem.mockResolvedValue({ ...baseItem, status: 'Backlog', @@ -79,7 +91,13 @@ describe('moveWorkItem', () => { expect(mockProvider.getWorkItem).toHaveBeenCalledWith('MNG-538'); expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('MNG-538', 'todo-state-id'); - expect(result).toContain('moved'); + expect(result).toMatchObject({ + status: 'moved', + id: 'MNG-538', + url: 'https://linear.app/mongrel/issue/MNG-538', + destination: 'todo-state-id', + previousStatus: 'Backlog', + }); }); it('proceeds with move when current statusId matches expectedSourceState', async () => { @@ -97,10 +115,12 @@ describe('moveWorkItem', () => { }); expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('MNG-538', 'state-todo'); - expect(result).toContain('moved'); + expect(result.status).toBe('moved'); + expect(result.previousStatus).toBe('Ready'); + expect(result.previousStatusId).toBe('state-backlog'); }); - it('aborts move when current status differs from expectedSourceState', async () => { + it('returns aborted result when current status differs from expectedSourceState', async () => { mockProvider.getWorkItem.mockResolvedValue({ ...baseItem, status: 'In Progress', @@ -113,12 +133,18 @@ describe('moveWorkItem', () => { }); expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); - expect(result).toMatch(/Aborted|aborted|skipped/); - expect(result).toContain('In Progress'); - expect(result).toContain('Backlog'); + expect(result).toMatchObject({ + status: 'aborted', + id: 'MNG-538', + url: 'https://linear.app/mongrel/issue/MNG-538', + destination: 'todo-state-id', + previousStatus: 'In Progress', + }); + expect(result.message).toContain('In Progress'); + expect(result.message).toContain('Backlog'); }); - it('aborts move when Linear issue is in an unmapped Ideas statusId', async () => { + it('aborts when Linear issue is in an unmapped Ideas statusId', async () => { mockProvider.getWorkItem.mockResolvedValue({ ...baseItem, status: 'Ideas', @@ -132,8 +158,11 @@ describe('moveWorkItem', () => { }); expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); - expect(result).toContain('Ideas (state-ideas)'); - expect(result).toContain('state-backlog'); + expect(result.status).toBe('aborted'); + expect(result.previousStatus).toBe('Ideas'); + expect(result.previousStatusId).toBe('state-ideas'); + expect(result.message).toContain('Ideas (state-ideas)'); + expect(result.message).toContain('state-backlog'); }); it('matches expectedSourceState case-insensitively (Linear vs Trello casing drift)', async () => { @@ -150,10 +179,10 @@ describe('moveWorkItem', () => { }); expect(mockProvider.moveWorkItem).toHaveBeenCalled(); - expect(result).toContain('moved'); + expect(result.status).toBe('moved'); }); - it('skips silently when current status is already the destination (idempotency)', async () => { + it('returns noop when current status is already the destination (idempotency)', async () => { // expectedSourceState matches but current status equals destination — // rare race where a parallel agent already moved the item. Treat as // no-op rather than firing a redundant Linear API call. @@ -169,7 +198,9 @@ describe('moveWorkItem', () => { }); expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); - expect(result).toMatch(/already|no-op|aborted/i); + expect(result.status).toBe('noop'); + expect(result.previousStatus).toBe('Todo'); + expect(result.message).toMatch(/already|no-op/i); }); it('does NOT call getWorkItem when expectedSourceState is omitted (back-compat)', async () => { @@ -184,7 +215,7 @@ describe('moveWorkItem', () => { expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('card1', 'list2'); }); - it('throws a structured error if getWorkItem throws', async () => { + it('throws when guarded read-back throws (no prose sentinel)', async () => { mockProvider.getWorkItem.mockRejectedValue(new Error('API down')); await expect( @@ -193,7 +224,7 @@ describe('moveWorkItem', () => { destination: 'todo-state-id', expectedSourceState: 'Backlog', }), - ).rejects.toThrow('Error moving work item: API down'); + ).rejects.toThrow('API down'); expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); }); diff --git a/tests/unit/gadgets/pm/core/mutationResults.test.ts b/tests/unit/gadgets/pm/core/mutationResults.test.ts new file mode 100644 index 000000000..46fcc46fd --- /dev/null +++ b/tests/unit/gadgets/pm/core/mutationResults.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + abortedResult, + currentTimestamp, + noOpResult, + okResult, + type PMMutationResult, + pickTimestamp, +} from '../../../../../src/gadgets/pm/core/mutationResults.js'; + +const FROZEN_NOW = new Date('2026-03-15T12:34:56.789Z'); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FROZEN_NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('PM mutationResults', () => { + describe('currentTimestamp', () => { + it('returns the current ISO timestamp', () => { + expect(currentTimestamp()).toBe(FROZEN_NOW.toISOString()); + }); + }); + + describe('pickTimestamp', () => { + it('prefers the provider timestamp when present', () => { + expect(pickTimestamp('2025-12-01T01:02:03.000Z')).toBe('2025-12-01T01:02:03.000Z'); + }); + + it('falls back to the current ISO timestamp when undefined', () => { + expect(pickTimestamp(undefined)).toBe(FROZEN_NOW.toISOString()); + }); + + it('falls back when the provider value is null', () => { + expect(pickTimestamp(null)).toBe(FROZEN_NOW.toISOString()); + }); + + it('falls back when the provider value is the empty string', () => { + expect(pickTimestamp('')).toBe(FROZEN_NOW.toISOString()); + }); + }); + + describe('okResult', () => { + it('uses the provider timestamp on the ok result', () => { + const result: PMMutationResult = okResult({ + id: 'item-1', + updatedAt: '2025-12-01T01:02:03.000Z', + url: 'https://trello.com/c/item-1', + }); + expect(result).toEqual({ + id: 'item-1', + status: 'ok', + updatedAt: '2025-12-01T01:02:03.000Z', + url: 'https://trello.com/c/item-1', + }); + }); + + it('rejects an empty provider timestamp', () => { + expect(() => okResult({ id: 'item-1', updatedAt: '' })).toThrow( + 'okResult requires a provider-supplied updatedAt timestamp', + ); + }); + + it('rejects a missing provider timestamp at runtime', () => { + expect(() => okResult({ id: 'item-1' } as Parameters[0])).toThrow( + 'okResult requires a provider-supplied updatedAt timestamp', + ); + }); + + it('omits optional fields when not provided', () => { + const result = okResult({ id: 'item-1', updatedAt: '2025-12-01T01:02:03.000Z' }); + expect(result.url).toBeUndefined(); + expect(result.message).toBeUndefined(); + }); + + it('includes the message when provided', () => { + const result = okResult({ + id: 'item-1', + updatedAt: '2025-12-01T01:02:03.000Z', + message: 'Updated title', + }); + expect(result.message).toBe('Updated title'); + }); + }); + + describe('noOpResult', () => { + it('uses the current ISO timestamp', () => { + const result = noOpResult({ + id: 'item-1', + message: 'already in destination state', + url: 'https://trello.com/c/item-1', + }); + expect(result).toEqual({ + id: 'item-1', + status: 'no-op', + updatedAt: FROZEN_NOW.toISOString(), + url: 'https://trello.com/c/item-1', + message: 'already in destination state', + }); + }); + + it('never accepts a provider timestamp (synthetic outcome)', () => { + // Type-level guard: noOpResult signature omits updatedAt. Calling + // with one would be a compile error. This test pins the runtime + // behavior — the result always carries the current ISO timestamp. + const result = noOpResult({ id: 'item-1' }); + expect(result.updatedAt).toBe(FROZEN_NOW.toISOString()); + }); + }); + + describe('abortedResult', () => { + it('uses the current ISO timestamp', () => { + const result = abortedResult({ + id: 'item-1', + message: 'expected source state mismatch', + }); + expect(result).toEqual({ + id: 'item-1', + status: 'aborted', + updatedAt: FROZEN_NOW.toISOString(), + message: 'expected source state mismatch', + }); + }); + + it('omits optional fields when not provided', () => { + const result = abortedResult({ id: 'item-1' }); + expect(result.url).toBeUndefined(); + expect(result.message).toBeUndefined(); + }); + }); + + describe('status union exhaustiveness', () => { + // This test is a discriminator-coverage guard: it pins that the three + // helper builders cover every status union member. Adding a new + // status without a matching helper here surfaces as an unused + // literal at compile time. + it('covers every PMMutationStatus member', () => { + const ok = okResult({ id: 'a', updatedAt: '2025-12-01T00:00:00.000Z' }); + const noop = noOpResult({ id: 'b' }); + const aborted = abortedResult({ id: 'c' }); + const statuses: Array<'ok' | 'no-op' | 'aborted'> = [ok.status, noop.status, aborted.status]; + expect(new Set(statuses)).toEqual(new Set(['ok', 'no-op', 'aborted'])); + }); + }); +}); diff --git a/tests/unit/gadgets/pm/core/postComment.test.ts b/tests/unit/gadgets/pm/core/postComment.test.ts index 8618d13a2..e8ff23765 100644 --- a/tests/unit/gadgets/pm/core/postComment.test.ts +++ b/tests/unit/gadgets/pm/core/postComment.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockPMProvider } from '../../../../helpers/mockPMProvider.js'; +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; const mockProvider = createMockPMProvider(); @@ -36,28 +36,39 @@ const mockLogger = vi.mocked(logger); beforeEach(() => { mockReadProgressCommentId.mockReturnValue(null); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + }), + ); + mockProvider.getWorkItemUrl.mockReturnValue('https://trello.com/c/item1'); }); describe('postComment', () => { - it('posts a comment and returns success message', async () => { - mockProvider.addComment.mockResolvedValue(undefined); + it('returns a structured created result when no progress comment exists', async () => { + mockProvider.addComment.mockResolvedValue('comment-new'); const result = await postComment('item1', 'Hello world'); expect(mockProvider.addComment).toHaveBeenCalledWith('item1', 'Hello world'); - expect(result).toBe('Comment posted successfully'); + expect(result).toMatchObject({ + status: 'created', + id: 'comment-new', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + }); + expect(typeof result.updatedAt).toBe('string'); }); - it('throws an error message on failure', async () => { + it('throws on provider failure (no prose sentinel)', async () => { mockProvider.addComment.mockRejectedValue(new Error('Network error')); - await expect(postComment('item1', 'text')).rejects.toThrow( - 'Error posting comment: Network error', - ); + await expect(postComment('item1', 'text')).rejects.toThrow('Network error'); }); - it('passes multi-line text correctly', async () => { - mockProvider.addComment.mockResolvedValue(undefined); + it('passes multi-line text through unchanged', async () => { + mockProvider.addComment.mockResolvedValue('comment-multi'); const text = 'Line 1\n\nLine 2\n\nLine 3'; await postComment('item1', text); @@ -65,16 +76,25 @@ describe('postComment', () => { expect(mockProvider.addComment).toHaveBeenCalledWith('item1', text); }); - it('handles non-Error thrown value', async () => { + it('propagates non-Error thrown values', async () => { mockProvider.addComment.mockRejectedValue('string error'); - await expect(postComment('item1', 'text')).rejects.toThrow( - 'Error posting comment: string error', - ); + await expect(postComment('item1', 'text')).rejects.toThrow('string error'); + }); + + it('falls back to getWorkItemUrl when read-back fails', async () => { + mockProvider.addComment.mockResolvedValue('comment-fallback'); + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/item1'); + + const result = await postComment('item1', 'text'); + + expect(result.status).toBe('created'); + expect(result.workItemUrl).toBe('https://fallback.example/item1'); }); describe('progress comment replacement', () => { - it('updates existing progress comment when state matches workItemId', async () => { + it('returns an updated result when existing progress comment is replaced', async () => { mockReadProgressCommentId.mockReturnValue({ workItemId: 'item1', commentId: 'comment-42' }); mockProvider.updateComment.mockResolvedValue(undefined); @@ -87,26 +107,33 @@ describe('postComment', () => { ); expect(mockProvider.addComment).not.toHaveBeenCalled(); expect(mockClearProgressCommentId).toHaveBeenCalled(); - expect(result).toBe('Comment posted successfully'); + expect(result).toMatchObject({ + status: 'updated', + id: 'comment-42', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + }); }); - it('does not update when workItemId does not match state', async () => { + it('does not update when workItemId does not match progress state', async () => { mockReadProgressCommentId.mockReturnValue({ workItemId: 'other-item', commentId: 'comment-42', }); - mockProvider.addComment.mockResolvedValue(undefined); + mockProvider.addComment.mockResolvedValue('comment-new'); - await postComment('item1', 'My comment'); + const result = await postComment('item1', 'My comment'); expect(mockProvider.updateComment).not.toHaveBeenCalled(); expect(mockProvider.addComment).toHaveBeenCalledWith('item1', 'My comment'); + expect(result.status).toBe('created'); + expect(result.id).toBe('comment-new'); }); - it('falls back to addComment when updateComment fails, and clears state', async () => { + it('falls back to addComment when updateComment fails and surfaces a created result', async () => { mockReadProgressCommentId.mockReturnValue({ workItemId: 'item1', commentId: 'comment-42' }); mockProvider.updateComment.mockRejectedValue(new Error('Comment not found')); - mockProvider.addComment.mockResolvedValue(undefined); + mockProvider.addComment.mockResolvedValue('comment-new'); const result = await postComment('item1', 'Final summary'); @@ -125,24 +152,18 @@ describe('postComment', () => { ); expect(mockProvider.addComment).toHaveBeenCalledWith('item1', 'Final summary'); expect(mockClearProgressCommentId).toHaveBeenCalled(); - expect(result).toBe('Comment posted successfully'); - }); - - it('creates new comment (no state) when no progress comment exists', async () => { - mockReadProgressCommentId.mockReturnValue(null); - mockProvider.addComment.mockResolvedValue(undefined); - - const result = await postComment('item1', 'New comment'); - - expect(mockProvider.updateComment).not.toHaveBeenCalled(); - expect(mockProvider.addComment).toHaveBeenCalledWith('item1', 'New comment'); - expect(result).toBe('Comment posted successfully'); + expect(result).toMatchObject({ + status: 'created', + id: 'comment-new', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + }); }); - it('clears state before fallback so subsequent calls create new comments', async () => { + it('clears progress state before the fallback addComment runs', async () => { mockReadProgressCommentId.mockReturnValue({ workItemId: 'item1', commentId: 'comment-42' }); mockProvider.updateComment.mockRejectedValue(new Error('gone')); - mockProvider.addComment.mockResolvedValue(undefined); + mockProvider.addComment.mockResolvedValue('comment-new'); await postComment('item1', 'text'); diff --git a/tests/unit/gadgets/pm/core/readWorkItemContext.test.ts b/tests/unit/gadgets/pm/core/readWorkItemContext.test.ts new file mode 100644 index 000000000..451763330 --- /dev/null +++ b/tests/unit/gadgets/pm/core/readWorkItemContext.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; + +const mockProvider = createMockPMProvider(); + +vi.mock('../../../../../src/pm/index.js', () => ({ + getPMProvider: vi.fn(() => mockProvider), +})); + +import { readWorkItemContext } from '../../../../../src/gadgets/pm/core/readWorkItemContext.js'; + +const FROZEN_NOW = new Date('2026-03-15T12:34:56.789Z'); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FROZEN_NOW); + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('readWorkItemContext', () => { + it('returns the provider URL + updatedAt + title when getWorkItem succeeds', async () => { + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + title: 'Mock work item', + url: 'https://trello.com/c/item1', + updatedAt: '2026-02-01T01:02:03.000Z', + }), + ); + + const result = await readWorkItemContext('item1'); + + expect(result).toEqual({ + workItemUrl: 'https://trello.com/c/item1', + updatedAt: '2026-02-01T01:02:03.000Z', + title: 'Mock work item', + }); + }); + + it('synthesises the current ISO timestamp when the provider omits updatedAt', async () => { + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + updatedAt: undefined, + }), + ); + + const result = await readWorkItemContext('item1'); + + expect(result.updatedAt).toBe(FROZEN_NOW.toISOString()); + }); + + it('falls back to getWorkItemUrl + synthesised timestamp when read-back throws', async () => { + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/item1'); + + const result = await readWorkItemContext('item1'); + + expect(result).toEqual({ + workItemUrl: 'https://fallback.example/item1', + updatedAt: FROZEN_NOW.toISOString(), + }); + }); + + it('never propagates the read-back exception (mutation must remain successful)', async () => { + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/x'); + + await expect(readWorkItemContext('x')).resolves.toBeDefined(); + }); +}); diff --git a/tests/unit/gadgets/pm/core/updateChecklistItem.test.ts b/tests/unit/gadgets/pm/core/updateChecklistItem.test.ts index da22e30ef..8c40c363a 100644 --- a/tests/unit/gadgets/pm/core/updateChecklistItem.test.ts +++ b/tests/unit/gadgets/pm/core/updateChecklistItem.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockPMProvider } from '../../../../helpers/mockPMProvider.js'; +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; const mockProvider = createMockPMProvider(); @@ -10,38 +10,89 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { updateChecklistItem } from '../../../../../src/gadgets/pm/core/updateChecklistItem.js'; +beforeEach(() => { + vi.clearAllMocks(); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }), + ); + mockProvider.getWorkItemUrl.mockReturnValue('https://trello.com/c/item1'); +}); + describe('updateChecklistItem', () => { - it('marks a checklist item as complete', async () => { + it('marks a checklist item as complete and returns the structured result', async () => { mockProvider.updateChecklistItem.mockResolvedValue(undefined); const result = await updateChecklistItem('item1', 'checkItem1', true); expect(mockProvider.updateChecklistItem).toHaveBeenCalledWith('item1', 'checkItem1', true); - expect(result).toBe('Checklist item checkItem1 marked complete on work item item1'); + expect(result).toEqual({ + status: 'updated', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + checkItemId: 'checkItem1', + complete: true, + updatedAt: '2026-03-15T12:00:00.000Z', + }); }); - it('marks a checklist item as incomplete', async () => { + it('marks a checklist item as incomplete and returns the structured result', async () => { mockProvider.updateChecklistItem.mockResolvedValue(undefined); const result = await updateChecklistItem('item1', 'checkItem1', false); expect(mockProvider.updateChecklistItem).toHaveBeenCalledWith('item1', 'checkItem1', false); - expect(result).toBe('Checklist item checkItem1 marked incomplete on work item item1'); + expect(result).toEqual({ + status: 'updated', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + checkItemId: 'checkItem1', + complete: false, + updatedAt: '2026-03-15T12:00:00.000Z', + }); }); - it('throws an error message on failure', async () => { + it('throws when the provider mutation fails (no prose sentinel)', async () => { mockProvider.updateChecklistItem.mockRejectedValue(new Error('API error')); - await expect(updateChecklistItem('item1', 'checkItem1', true)).rejects.toThrow( - 'Error updating checklist item: API error', - ); + await expect(updateChecklistItem('item1', 'checkItem1', true)).rejects.toThrow('API error'); }); - it('handles non-Error thrown value', async () => { + it('propagates non-Error thrown values as-is', async () => { mockProvider.updateChecklistItem.mockRejectedValue('string error'); - await expect(updateChecklistItem('item1', 'ci1', false)).rejects.toThrow( - 'Error updating checklist item: string error', + await expect(updateChecklistItem('item1', 'ci1', false)).rejects.toBe('string error'); + }); + + it('falls back to getWorkItemUrl + synthesised timestamp when read-back fails', async () => { + mockProvider.updateChecklistItem.mockResolvedValue(undefined); + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/item1'); + + const result = await updateChecklistItem('item1', 'checkItem1', true); + + expect(result.status).toBe('updated'); + expect(result.workItemUrl).toBe('https://fallback.example/item1'); + expect(typeof result.updatedAt).toBe('string'); + expect(result.updatedAt.length).toBeGreaterThan(0); + }); + + it('surfaces the provider-supplied updatedAt when present', async () => { + mockProvider.updateChecklistItem.mockResolvedValue(undefined); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'PROJ-42', + url: 'https://jira.example.com/browse/PROJ-42', + updatedAt: '2025-12-01T01:02:03.000Z', + }), ); + + const result = await updateChecklistItem('PROJ-42', 'sub-1', true); + + expect(result.updatedAt).toBe('2025-12-01T01:02:03.000Z'); + expect(result.workItemUrl).toBe('https://jira.example.com/browse/PROJ-42'); }); }); diff --git a/tests/unit/gadgets/pm/core/updateWorkItem.test.ts b/tests/unit/gadgets/pm/core/updateWorkItem.test.ts index 12f889c4d..adda50aab 100644 --- a/tests/unit/gadgets/pm/core/updateWorkItem.test.ts +++ b/tests/unit/gadgets/pm/core/updateWorkItem.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockPMProvider } from '../../../../helpers/mockPMProvider.js'; +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; const mockProvider = createMockPMProvider(); @@ -10,91 +10,145 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { updateWorkItem } from '../../../../../src/gadgets/pm/core/updateWorkItem.js'; -describe('updateWorkItem', () => { - it('returns early message when nothing to update', async () => { - const result = await updateWorkItem({ workItemId: 'item1' }); - expect(result).toBe('Nothing to update - provide title, description, or labels'); - expect(mockProvider.updateWorkItem).not.toHaveBeenCalled(); - }); - - it('updates title only', async () => { - mockProvider.updateWorkItem.mockResolvedValue(undefined); - - const result = await updateWorkItem({ workItemId: 'item1', title: 'New Title' }); +beforeEach(() => { + // Default work-item read-back for the post-mutation metadata fetch. + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + title: 'Stored title', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }), + ); + mockProvider.getWorkItemUrl.mockReturnValue('https://trello.com/c/item1'); +}); - expect(mockProvider.updateWorkItem).toHaveBeenCalledWith('item1', { - title: 'New Title', - description: undefined, +describe('updateWorkItem', () => { + describe('noop path (nothing to update)', () => { + it('returns a structured noop result when no fields are provided', async () => { + const result = await updateWorkItem({ workItemId: 'item1' }); + expect(result).toMatchObject({ + status: 'noop', + id: 'item1', + title: 'Stored title', + url: 'https://trello.com/c/item1', + changedFields: [], + addedLabelIds: [], + message: 'Nothing to update - provide title, description, or labels', + }); + expect(typeof result.updatedAt).toBe('string'); + expect(mockProvider.updateWorkItem).not.toHaveBeenCalled(); + expect(mockProvider.addLabel).not.toHaveBeenCalled(); }); - expect(result).toBe('Work item updated: title'); - }); - - it('updates description only', async () => { - mockProvider.updateWorkItem.mockResolvedValue(undefined); - - const result = await updateWorkItem({ workItemId: 'item1', description: 'New description' }); - expect(mockProvider.updateWorkItem).toHaveBeenCalledWith('item1', { - title: undefined, - description: 'New description', + it('returns a structured noop when addLabelIds is empty', async () => { + const result = await updateWorkItem({ workItemId: 'item1', addLabelIds: [] }); + expect(result.status).toBe('noop'); + expect(result.addedLabelIds).toEqual([]); + expect(mockProvider.addLabel).not.toHaveBeenCalled(); }); - expect(result).toBe('Work item updated: description'); }); - it('adds labels', async () => { - mockProvider.addLabel.mockResolvedValue(undefined); + describe('updated path (provider write)', () => { + it('updates title only and surfaces post-write metadata', async () => { + mockProvider.updateWorkItem.mockResolvedValue(undefined); + + const result = await updateWorkItem({ workItemId: 'item1', title: 'New Title' }); + + expect(mockProvider.updateWorkItem).toHaveBeenCalledWith('item1', { + title: 'New Title', + description: undefined, + }); + expect(result).toEqual({ + status: 'updated', + id: 'item1', + title: 'Stored title', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + changedFields: ['title'], + addedLabelIds: [], + }); + }); - const result = await updateWorkItem({ workItemId: 'item1', addLabelIds: ['label1', 'label2'] }); + it('updates description only', async () => { + mockProvider.updateWorkItem.mockResolvedValue(undefined); - expect(mockProvider.addLabel).toHaveBeenCalledTimes(2); - expect(mockProvider.addLabel).toHaveBeenCalledWith('item1', 'label1'); - expect(mockProvider.addLabel).toHaveBeenCalledWith('item1', 'label2'); - expect(result).toBe('Work item updated: 2 label(s)'); - }); + const result = await updateWorkItem({ workItemId: 'item1', description: 'New description' }); - it('updates title and description together', async () => { - mockProvider.updateWorkItem.mockResolvedValue(undefined); + expect(mockProvider.updateWorkItem).toHaveBeenCalledWith('item1', { + title: undefined, + description: 'New description', + }); + expect(result.status).toBe('updated'); + expect(result.changedFields).toEqual(['description']); + expect(result.addedLabelIds).toEqual([]); + }); - const result = await updateWorkItem({ workItemId: 'item1', title: 'T', description: 'D' }); + it('adds labels and echoes addedLabelIds without writing title/description', async () => { + mockProvider.addLabel.mockResolvedValue(undefined); + + const result = await updateWorkItem({ + workItemId: 'item1', + addLabelIds: ['label1', 'label2'], + }); + + expect(mockProvider.addLabel).toHaveBeenCalledTimes(2); + expect(mockProvider.addLabel).toHaveBeenCalledWith('item1', 'label1'); + expect(mockProvider.addLabel).toHaveBeenCalledWith('item1', 'label2'); + expect(mockProvider.updateWorkItem).not.toHaveBeenCalled(); + expect(result.status).toBe('updated'); + expect(result.changedFields).toEqual([]); + expect(result.addedLabelIds).toEqual(['label1', 'label2']); + }); - expect(mockProvider.updateWorkItem).toHaveBeenCalledOnce(); - expect(result).toBe('Work item updated: title, description'); - }); + it('combines title, description, and labels in a single result', async () => { + mockProvider.updateWorkItem.mockResolvedValue(undefined); + mockProvider.addLabel.mockResolvedValue(undefined); - it('updates title, description, and labels together', async () => { - mockProvider.updateWorkItem.mockResolvedValue(undefined); - mockProvider.addLabel.mockResolvedValue(undefined); + const result = await updateWorkItem({ + workItemId: 'item1', + title: 'T', + description: 'D', + addLabelIds: ['l1'], + }); - const result = await updateWorkItem({ - workItemId: 'item1', - title: 'T', - description: 'D', - addLabelIds: ['l1'], + expect(result.status).toBe('updated'); + expect(result.changedFields).toEqual(['title', 'description']); + expect(result.addedLabelIds).toEqual(['l1']); }); - - expect(result).toBe('Work item updated: title, description, 1 label(s)'); }); - it('throws an error message on failure', async () => { - mockProvider.updateWorkItem.mockRejectedValue(new Error('API error')); + describe('read-back fallback', () => { + it('synthesises url + timestamp when post-write read-back throws', async () => { + mockProvider.updateWorkItem.mockResolvedValue(undefined); + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/item1'); - await expect(updateWorkItem({ workItemId: 'item1', title: 'T' })).rejects.toThrow( - 'Error updating work item: API error', - ); - }); + const result = await updateWorkItem({ workItemId: 'item1', title: 'T' }); - it('does not call updateWorkItem when only labels provided', async () => { - mockProvider.addLabel.mockResolvedValue(undefined); + expect(result.status).toBe('updated'); + expect(result.url).toBe('https://fallback.example/item1'); + expect(typeof result.updatedAt).toBe('string'); + // Title falls back to the caller-supplied title when read-back fails + expect(result.title).toBe('T'); + }); + }); - await updateWorkItem({ workItemId: 'item1', addLabelIds: ['l1'] }); + describe('error propagation', () => { + it('throws on provider updateWorkItem failure (no prose sentinel)', async () => { + mockProvider.updateWorkItem.mockRejectedValue(new Error('API error')); - expect(mockProvider.updateWorkItem).not.toHaveBeenCalled(); - }); + await expect(updateWorkItem({ workItemId: 'item1', title: 'T' })).rejects.toThrow( + 'API error', + ); + }); - it('does not add labels when addLabelIds is empty array', async () => { - const result = await updateWorkItem({ workItemId: 'item1', addLabelIds: [] }); + it('throws on provider addLabel failure', async () => { + mockProvider.addLabel.mockRejectedValue(new Error('Label not found')); - expect(result).toBe('Nothing to update - provide title, description, or labels'); - expect(mockProvider.addLabel).not.toHaveBeenCalled(); + await expect(updateWorkItem({ workItemId: 'item1', addLabelIds: ['l1'] })).rejects.toThrow( + 'Label not found', + ); + }); }); }); diff --git a/tests/unit/gadgets/pm/definitions.test.ts b/tests/unit/gadgets/pm/definitions.test.ts index 4971a257c..387fbd821 100644 --- a/tests/unit/gadgets/pm/definitions.test.ts +++ b/tests/unit/gadgets/pm/definitions.test.ts @@ -312,4 +312,203 @@ describe('PM gadget definitions', () => { expect(pmDeleteChecklistItemDef.parameters.checkItemId?.required).toBe(true); }); }); + + // ─── Output shape coverage (MNG-1427) ────────────────────────────────────── + describe('output shape coverage (MNG-1427)', () => { + const MUTATION_DEFS_WITH_REQUIRED_OUTPUT_SHAPE: ToolDefinition[] = [ + postCommentDef, + updateWorkItemDef, + createWorkItemDef, + moveWorkItemDef, + addChecklistDef, + pmUpdateChecklistItemDef, + pmDeleteChecklistItemDef, + ]; + + const READ_ONLY_DEFS_WITHOUT_OUTPUT_SHAPE: ToolDefinition[] = [ + readWorkItemDef, + listWorkItemsDef, + ]; + + it('every PM mutation definition declares an outputShape with at least one field', () => { + for (const def of MUTATION_DEFS_WITH_REQUIRED_OUTPUT_SHAPE) { + expect(def.outputShape, `${def.name} must declare outputShape`).toBeDefined(); + expect( + def.outputShape?.fields.length, + `${def.name} outputShape must list at least one field`, + ).toBeGreaterThan(0); + } + }); + + it('every output-shape field has a non-empty name and type', () => { + for (const def of MUTATION_DEFS_WITH_REQUIRED_OUTPUT_SHAPE) { + for (const field of def.outputShape?.fields ?? []) { + expect(typeof field.name).toBe('string'); + expect(field.name.length).toBeGreaterThan(0); + expect(typeof field.type).toBe('string'); + expect(field.type.length).toBeGreaterThan(0); + } + } + }); + + it('read-only definitions do not declare an outputShape', () => { + for (const def of READ_ONLY_DEFS_WITHOUT_OUTPUT_SHAPE) { + expect(def.outputShape, `${def.name} must NOT declare outputShape`).toBeUndefined(); + } + }); + + it('PostComment output shape mirrors the CommentPostedResult contract', () => { + const names = postCommentDef.outputShape?.fields.map((f) => f.name) ?? []; + expect(names).toContain('status'); + expect(names).toContain('id'); + expect(names).toContain('workItemId'); + expect(names).toContain('workItemUrl'); + expect(names).toContain('updatedAt'); + }); + + it('UpdateWorkItem output shape mirrors the WorkItemUpdatedResult contract', () => { + const fieldsByName = new Map( + (updateWorkItemDef.outputShape?.fields ?? []).map((f) => [f.name, f]), + ); + expect(fieldsByName.get('status')?.type).toBe('"updated" | "noop"'); + expect(fieldsByName.get('changedFields')?.type).toBe('("title" | "description")[]'); + expect(fieldsByName.get('addedLabelIds')?.type).toBe('string[]'); + expect(fieldsByName.get('message')?.optional).toBe(true); + }); + + it('MoveWorkItem output shape encodes the moved/noop/aborted union', () => { + const status = moveWorkItemDef.outputShape?.fields.find((f) => f.name === 'status'); + expect(status?.type).toBe('"moved" | "noop" | "aborted"'); + const previousStatus = moveWorkItemDef.outputShape?.fields.find( + (f) => f.name === 'previousStatus', + ); + expect(previousStatus?.optional).toBe(true); + }); + + it('CreateWorkItem output shape includes workflowStatus / workflowStatusId as optional', () => { + const fieldsByName = new Map( + (createWorkItemDef.outputShape?.fields ?? []).map((f) => [f.name, f]), + ); + expect(fieldsByName.get('workflowStatus')?.optional).toBe(true); + expect(fieldsByName.get('workflowStatusId')?.optional).toBe(true); + }); + + it('AddChecklist output shape carries checklistId + itemIds', () => { + const names = addChecklistDef.outputShape?.fields.map((f) => f.name) ?? []; + expect(names).toContain('checklistId'); + expect(names).toContain('itemIds'); + expect(names).toContain('itemCount'); + }); + + it('PMUpdateChecklistItem output shape surfaces the resulting boolean state', () => { + const complete = pmUpdateChecklistItemDef.outputShape?.fields.find( + (f) => f.name === 'complete', + ); + expect(complete?.type).toBe('boolean'); + }); + + it('PMDeleteChecklistItem output shape uses status="deleted"', () => { + const status = pmDeleteChecklistItemDef.outputShape?.fields.find((f) => f.name === 'status'); + expect(status?.type).toBe('"deleted"'); + }); + }); + + // ─── Structured-output naming contract (MNG-1428) ───────────────────────── + // + // `status` is reserved for the MUTATION OUTCOME (`"created"`, `"updated"`, + // `"moved"`, `"noop"`, `"aborted"`, `"deleted"`). The PROVIDER WORKFLOW + // STATE (e.g. Linear "In Progress", Trello list "Backlog") lives on its + // own keys — `workflowStatus` (human-readable) and `workflowStatusId` + // (native ID). Mixing the two cost ~2½ minutes of agent run time once + // (prod run 5d993b04) when an agent treated a Trello list name returned + // in `status` as a mutation outcome. These tests pin the split so a + // future drift can never silently collapse the two surfaces. + describe('status vs workflowStatus naming contract (MNG-1428)', () => { + it('CreateWorkItem distinguishes mutation `status` from provider `workflowStatus`', () => { + const fieldsByName = new Map( + (createWorkItemDef.outputShape?.fields ?? []).map((f) => [f.name, f]), + ); + // Mutation outcome — required, always "created" on success. + expect(fieldsByName.get('status')?.type).toBe('"created"'); + expect(fieldsByName.get('status')?.optional).toBeFalsy(); + // Provider workflow state — optional human-readable name. + expect(fieldsByName.get('workflowStatus')?.type).toBe('string'); + expect(fieldsByName.get('workflowStatus')?.optional).toBe(true); + // Provider workflow state — optional native ID. + expect(fieldsByName.get('workflowStatusId')?.type).toBe('string'); + expect(fieldsByName.get('workflowStatusId')?.optional).toBe(true); + }); + + it('MoveWorkItem distinguishes mutation `status` from `previousStatus` / `previousStatusId`', () => { + const fieldsByName = new Map( + (moveWorkItemDef.outputShape?.fields ?? []).map((f) => [f.name, f]), + ); + // Mutation outcome — required, union of allowed move outcomes. + expect(fieldsByName.get('status')?.type).toBe('"moved" | "noop" | "aborted"'); + expect(fieldsByName.get('status')?.optional).toBeFalsy(); + // Provider workflow read-back values — optional, distinct keys. + expect(fieldsByName.get('previousStatus')?.optional).toBe(true); + expect(fieldsByName.get('previousStatusId')?.optional).toBe(true); + }); + + // Linear has no custom-field concept and so does not surface a workflow + // status on AddChecklist — but the naming-collision guard still applies: + // `status` must remain the mutation outcome, not the parent work item's + // workflow state. + it('AddChecklist `status` field is the mutation outcome (always "created")', () => { + const status = addChecklistDef.outputShape?.fields.find((f) => f.name === 'status'); + expect(status?.type).toBe('"created"'); + expect(status?.optional).toBeFalsy(); + }); + + it('PMUpdateChecklistItem `status` field is the mutation outcome (always "updated")', () => { + const status = pmUpdateChecklistItemDef.outputShape?.fields.find((f) => f.name === 'status'); + expect(status?.type).toBe('"updated"'); + expect(status?.optional).toBeFalsy(); + }); + + it('PMDeleteChecklistItem `status` field is the mutation outcome (always "deleted")', () => { + const status = pmDeleteChecklistItemDef.outputShape?.fields.find((f) => f.name === 'status'); + expect(status?.type).toBe('"deleted"'); + expect(status?.optional).toBeFalsy(); + }); + + it('PostComment `status` is the comment-mutation outcome, not a workflow state', () => { + const status = postCommentDef.outputShape?.fields.find((f) => f.name === 'status'); + expect(status?.type).toBe('"created" | "updated"'); + // PostComment does not carry workflowStatus at all — the parent work + // item's state is irrelevant to a comment write. + const names = postCommentDef.outputShape?.fields.map((f) => f.name) ?? []; + expect(names).not.toContain('workflowStatus'); + expect(names).not.toContain('workflowStatusId'); + }); + }); + + // ─── Timestamp surface contract (MNG-1428) ──────────────────────────────── + // + // Every PM mutation outputShape must declare an `updatedAt` ISO 8601 field + // — provider-supplied on `"created"`/`"updated"`/`"moved"`/`"deleted"` + // outcomes, synthesised via `currentTimestamp()` for `"noop"`/`"aborted"`. + // The CLI envelope round-trips that string verbatim; downstream consumers + // rely on it being a parseable ISO 8601 timestamp. + describe('updatedAt field is present on every mutation outputShape (MNG-1428)', () => { + const MUTATION_DEFS = [ + postCommentDef, + updateWorkItemDef, + createWorkItemDef, + moveWorkItemDef, + addChecklistDef, + pmUpdateChecklistItemDef, + pmDeleteChecklistItemDef, + ]; + + for (const def of MUTATION_DEFS) { + it(`${def.name} declares an updatedAt field with type "string"`, () => { + const updatedAt = def.outputShape?.fields.find((f) => f.name === 'updatedAt'); + expect(updatedAt, `${def.name} must declare an updatedAt field`).toBeDefined(); + expect(updatedAt?.type).toBe('string'); + expect(updatedAt?.optional).toBeFalsy(); + }); + } + }); }); diff --git a/tests/unit/gadgets/pm/wrappers.test.ts b/tests/unit/gadgets/pm/wrappers.test.ts new file mode 100644 index 000000000..3be7fc20f --- /dev/null +++ b/tests/unit/gadgets/pm/wrappers.test.ts @@ -0,0 +1,284 @@ +/** + * Focused wrapper tests for the four work-item / comment mutation gadgets. + * + * The cores (`createWorkItem`, `updateWorkItem`, `moveWorkItem`, `postComment`) + * have their own deep tests in `tests/unit/gadgets/pm/core/`. These tests pin + * the wrapper behavior end-to-end — specifically that: + * - Wrappers translate the structured core result to a concise human-readable + * string for the agent tool-result channel (the in-process gadget surface). + * - Wrappers wrap thrown core errors via `formatGadgetError` rather than + * letting them escape. + * - The MoveWorkItem wrapper forwards `expectedSourceState` to the core + * (regression guard for the gap discovered in MNG-1423). + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/gadgets/pm/core/createWorkItem.js', () => ({ + createWorkItem: vi.fn(), +})); +vi.mock('../../../../src/gadgets/pm/core/updateWorkItem.js', () => ({ + updateWorkItem: vi.fn(), +})); +vi.mock('../../../../src/gadgets/pm/core/moveWorkItem.js', () => ({ + moveWorkItem: vi.fn(), +})); +vi.mock('../../../../src/gadgets/pm/core/postComment.js', () => ({ + postComment: vi.fn(), +})); + +import { CreateWorkItem } from '../../../../src/gadgets/pm/CreateWorkItem.js'; +import { createWorkItem } from '../../../../src/gadgets/pm/core/createWorkItem.js'; +import { moveWorkItem } from '../../../../src/gadgets/pm/core/moveWorkItem.js'; +import { postComment } from '../../../../src/gadgets/pm/core/postComment.js'; +import { updateWorkItem } from '../../../../src/gadgets/pm/core/updateWorkItem.js'; +import { MoveWorkItem } from '../../../../src/gadgets/pm/MoveWorkItem.js'; +import { PostComment } from '../../../../src/gadgets/pm/PostComment.js'; +import { UpdateWorkItem } from '../../../../src/gadgets/pm/UpdateWorkItem.js'; + +const mockCreateWorkItem = vi.mocked(createWorkItem); +const mockUpdateWorkItem = vi.mocked(updateWorkItem); +const mockMoveWorkItem = vi.mocked(moveWorkItem); +const mockPostComment = vi.mocked(postComment); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// CreateWorkItem wrapper +// --------------------------------------------------------------------------- +describe('CreateWorkItem gadget wrapper', () => { + it('formats the structured result into a concise success string', async () => { + mockCreateWorkItem.mockResolvedValue({ + status: 'created', + id: 'item1', + title: 'New Feature', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }); + + const gadget = new CreateWorkItem(); + const out = await gadget.execute({ + containerId: 'list1', + title: 'New Feature', + description: 'A new feature', + }); + + expect(mockCreateWorkItem).toHaveBeenCalledWith({ + containerId: 'list1', + title: 'New Feature', + description: 'A new feature', + }); + expect(out).toBe( + 'Work item created successfully: "New Feature" [id: item1] - https://trello.com/c/item1', + ); + }); + + it('returns a formatted error string on thrown core failure', async () => { + mockCreateWorkItem.mockRejectedValue(new Error('Boom')); + + const gadget = new CreateWorkItem(); + const out = await gadget.execute({ + containerId: 'list1', + title: 'New Feature', + }); + + expect(out).toBe('Error creating work item: Boom'); + }); +}); + +// --------------------------------------------------------------------------- +// UpdateWorkItem wrapper +// --------------------------------------------------------------------------- +describe('UpdateWorkItem gadget wrapper', () => { + it('renders the noop message when the core returns a noop result', async () => { + mockUpdateWorkItem.mockResolvedValue({ + status: 'noop', + id: 'item1', + title: '', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + changedFields: [], + addedLabelIds: [], + message: 'Nothing to update - provide title, description, or labels', + }); + + const gadget = new UpdateWorkItem(); + const out = await gadget.execute({ workItemId: 'item1' }); + + expect(out).toBe('Nothing to update - provide title, description, or labels'); + }); + + it('renders the updated fields list for the in-process channel', async () => { + mockUpdateWorkItem.mockResolvedValue({ + status: 'updated', + id: 'item1', + title: 'New', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + changedFields: ['title', 'description'], + addedLabelIds: ['l1', 'l2'], + }); + + const gadget = new UpdateWorkItem(); + const out = await gadget.execute({ + workItemId: 'item1', + title: 'New', + description: 'New desc', + addLabelId: ['l1', 'l2'], + }); + + expect(out).toBe('Work item updated: title, description, 2 label(s)'); + }); + + it('returns a formatted error string on thrown core failure', async () => { + mockUpdateWorkItem.mockRejectedValue(new Error('Boom')); + + const gadget = new UpdateWorkItem(); + const out = await gadget.execute({ workItemId: 'item1', title: 'T' }); + + expect(out).toBe('Error updating work item: Boom'); + }); +}); + +// --------------------------------------------------------------------------- +// MoveWorkItem wrapper +// --------------------------------------------------------------------------- +describe('MoveWorkItem gadget wrapper', () => { + it('forwards expectedSourceState to the core (regression guard for MNG-1423)', async () => { + mockMoveWorkItem.mockResolvedValue({ + status: 'moved', + id: 'card1', + url: 'https://trello.com/c/card1', + destination: 'list2', + updatedAt: '2026-03-15T12:00:00.000Z', + }); + + const gadget = new MoveWorkItem(); + await gadget.execute({ + workItemId: 'card1', + destination: 'list2', + expectedSourceState: 'Backlog', + }); + + expect(mockMoveWorkItem).toHaveBeenCalledWith({ + workItemId: 'card1', + destination: 'list2', + expectedSourceState: 'Backlog', + }); + }); + + it('renders the success message for a moved outcome', async () => { + mockMoveWorkItem.mockResolvedValue({ + status: 'moved', + id: 'card1', + url: 'https://trello.com/c/card1', + destination: 'list2', + updatedAt: '2026-03-15T12:00:00.000Z', + }); + + const gadget = new MoveWorkItem(); + const out = await gadget.execute({ workItemId: 'card1', destination: 'list2' }); + + expect(out).toBe('Work item card1 moved to list2 successfully'); + }); + + it('renders the noop message when the work item is already in destination', async () => { + mockMoveWorkItem.mockResolvedValue({ + status: 'noop', + id: 'MNG-1', + url: 'https://linear.app/team/issue/MNG-1', + destination: 'state-todo', + updatedAt: '2026-03-15T12:00:00.000Z', + previousStatus: 'Todo', + message: "Work item already in destination state 'Todo' — no-op", + }); + + const gadget = new MoveWorkItem(); + const out = await gadget.execute({ + workItemId: 'MNG-1', + destination: 'state-todo', + expectedSourceState: 'Backlog', + }); + + expect(out).toBe("Work item already in destination state 'Todo' — no-op"); + }); + + it('renders the aborted message when the guard rejects the move', async () => { + mockMoveWorkItem.mockResolvedValue({ + status: 'aborted', + id: 'MNG-1', + url: 'https://linear.app/team/issue/MNG-1', + destination: 'state-todo', + updatedAt: '2026-03-15T12:00:00.000Z', + previousStatus: 'In Progress', + message: + "Aborted: work item is in 'In Progress', expected 'Backlog' (likely already moved by a parallel agent — skipping to avoid duplicate downstream work)", + }); + + const gadget = new MoveWorkItem(); + const out = await gadget.execute({ + workItemId: 'MNG-1', + destination: 'state-todo', + expectedSourceState: 'Backlog', + }); + + expect(out).toContain('Aborted'); + expect(out).toContain('In Progress'); + }); + + it('returns a formatted error string on thrown core failure', async () => { + mockMoveWorkItem.mockRejectedValue(new Error('Boom')); + + const gadget = new MoveWorkItem(); + const out = await gadget.execute({ workItemId: 'card1', destination: 'list2' }); + + expect(out).toBe('Error moving work item: Boom'); + }); +}); + +// --------------------------------------------------------------------------- +// PostComment wrapper +// --------------------------------------------------------------------------- +describe('PostComment gadget wrapper', () => { + it('returns a concise success message for the created path', async () => { + mockPostComment.mockResolvedValue({ + status: 'created', + id: 'comment-1', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }); + + const gadget = new PostComment(); + const out = await gadget.execute({ workItemId: 'item1', text: 'Hello' }); + + expect(mockPostComment).toHaveBeenCalledWith('item1', 'Hello'); + expect(out).toBe('Comment posted successfully'); + }); + + it('returns a concise success message for the updated (progress-comment replacement) path', async () => { + mockPostComment.mockResolvedValue({ + status: 'updated', + id: 'comment-42', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }); + + const gadget = new PostComment(); + const out = await gadget.execute({ workItemId: 'item1', text: 'Final summary' }); + + expect(out).toBe('Comment posted successfully'); + }); + + it('returns a formatted error string on thrown core failure', async () => { + mockPostComment.mockRejectedValue(new Error('Boom')); + + const gadget = new PostComment(); + const out = await gadget.execute({ workItemId: 'item1', text: 'Hello' }); + + expect(out).toBe('Error posting comment: Boom'); + }); +}); diff --git a/tests/unit/gadgets/shared/cli/parseErrors.test.ts b/tests/unit/gadgets/shared/cli/parseErrors.test.ts index 4270b784b..a1e280be2 100644 --- a/tests/unit/gadgets/shared/cli/parseErrors.test.ts +++ b/tests/unit/gadgets/shared/cli/parseErrors.test.ts @@ -17,6 +17,12 @@ describe('CLI parse errors', () => { expect(suggestFlag('zzzzzzzz', [{ canonical: 'comments', aliases: ['comment'] }])).toBeNull(); }); + it('uses the canonical flag length for alias ratio gating', () => { + expect(suggestFlag('y', [{ canonical: 'long-flag-name', aliases: ['x'] }])).toBe( + 'long-flag-name', + ); + }); + it('recognizes oclif nonexistent-flag error shapes', () => { class NonExistentFlagsError extends Error { public flags = ['coment']; diff --git a/tests/unit/gadgets/shared/cli/suggestions.test.ts b/tests/unit/gadgets/shared/cli/suggestions.test.ts new file mode 100644 index 000000000..5697c78fc --- /dev/null +++ b/tests/unit/gadgets/shared/cli/suggestions.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + MAX_SUGGESTION_DISTANCE, + MAX_SUGGESTION_RATIO, + suggestClosest, +} from '../../../../../src/gadgets/shared/cli/suggestions.js'; + +describe('suggestClosest', () => { + it('suggests the closest candidate when a single typo is within the distance budget', () => { + expect(suggestClosest('coment', ['comments', 'body'])).toBe('comments'); + }); + + it('suggests an exact match when present', () => { + expect(suggestClosest('comments', ['comments', 'body'])).toBe('comments'); + }); + + it('breaks ties by input order (first equally-close candidate wins)', () => { + // distance('foo', 'fop') === distance('foo', 'foop') === 1, so the + // iteration order of the candidate array decides which one wins. This + // matches the existing canonical-before-alias tie-breaking that + // `suggestFlag()` depends on. + expect(suggestClosest('foo', ['fop', 'foop'])).toBe('fop'); + expect(suggestClosest('foo', ['foop', 'fop'])).toBe('foop'); + }); + + it('returns null when no candidate is within the distance budget', () => { + // Distance from 'zzzzzzzz' to either candidate is 8, well past the budget. + expect(suggestClosest('zzzzzzzz', ['comments', 'body'])).toBeNull(); + }); + + it('returns null for an empty candidate list', () => { + expect(suggestClosest('foo', [])).toBeNull(); + }); + + it('rejects far matches even when the distance gate alone would pass on short candidates', () => { + // distance('foo', 'bar') === 3 → fails the distance gate already. + expect(suggestClosest('foo', ['bar'])).toBeNull(); + }); + + it('rejects matches that pass the distance gate but fail the ratio gate', () => { + // distance('ab', 'cd') === 2 — passes MAX_SUGGESTION_DISTANCE (2) but the + // ratio is 2/2 = 1.0, which is well above MAX_SUGGESTION_RATIO (0.4). + expect(suggestClosest('ab', ['cd'])).toBeNull(); + }); + + it('handles an empty unknown string by returning null when no candidate is close enough', () => { + // distance('', 'body') === 4 → fails the distance gate. + expect(suggestClosest('', ['body'])).toBeNull(); + }); + + it('keeps the documented thresholds stable so flag + future command suggestions agree', () => { + expect(MAX_SUGGESTION_DISTANCE).toBe(2); + expect(MAX_SUGGESTION_RATIO).toBe(0.4); + }); +}); diff --git a/tests/unit/gadgets/shared/factories.test.ts b/tests/unit/gadgets/shared/factories.test.ts index 4da31dbce..2d8b7cef3 100644 --- a/tests/unit/gadgets/shared/factories.test.ts +++ b/tests/unit/gadgets/shared/factories.test.ts @@ -1583,6 +1583,218 @@ describe('generateToolManifest — widened fields (spec 014)', () => { }); }); +// --------------------------------------------------------------------------- +// MNG-1427: createCLICommand surfaces outputShape in oclif description (`--help`) +// --------------------------------------------------------------------------- + +describe('createCLICommand — outputShape in --help description (MNG-1427)', () => { + it('appends "Output shape (success.data):" to the oclif description when declared', () => { + const def: ToolDefinition = { + name: 'PostComment', + description: 'Post a comment to a work item.', + parameters: { + workItemId: { type: 'string', describe: 'Work item ID', required: true }, + text: { type: 'string', describe: 'Comment text', required: true }, + }, + outputShape: { + summary: 'PostComment returns the new comment context.', + fields: [ + { name: 'status', type: '"created" | "updated"', description: 'Outcome.' }, + { name: 'id', type: 'string', description: 'Comment ID.' }, + ], + }, + }; + + const coreFn: CLICoreFn = async () => 'ok'; + const CommandClass = createCLICommand(def, coreFn); + + expect(CommandClass.description).toContain('Post a comment to a work item.'); + expect(CommandClass.description).toContain('Output shape (success.data):'); + expect(CommandClass.description).toContain('PostComment returns the new comment context.'); + expect(CommandClass.description).toContain('- status ("created" | "updated") — Outcome.'); + expect(CommandClass.description).toContain('- id (string) — Comment ID.'); + }); + + it('keeps the oclif description unchanged when no outputShape is declared', () => { + const def: ToolDefinition = { + name: 'ReadOnlyTool', + description: 'Read-only operation, no mutation result.', + parameters: { + workItemId: { type: 'string', describe: 'Work item ID', required: true }, + }, + }; + + const coreFn: CLICoreFn = async () => 'ok'; + const CommandClass = createCLICommand(def, coreFn); + + expect(CommandClass.description).toBe('Read-only operation, no mutation result.'); + expect(CommandClass.description).not.toContain('Output shape'); + }); + + it('marks optional fields with a trailing `?` in the help block', () => { + const def: ToolDefinition = { + name: 'CreateWorkItem', + description: 'Create a new work item.', + parameters: { + containerId: { type: 'string', describe: 'Container', required: true }, + title: { type: 'string', describe: 'Title', required: true }, + }, + outputShape: { + fields: [ + { name: 'id', type: 'string', description: 'Required.' }, + { + name: 'workflowStatus', + type: 'string', + optional: true, + description: 'Provider-dependent.', + }, + ], + }, + }; + + const coreFn: CLICoreFn = async () => 'ok'; + const CommandClass = createCLICommand(def, coreFn); + expect(CommandClass.description).toContain('- id (string) — Required.'); + expect(CommandClass.description).toContain('- workflowStatus? (string) — Provider-dependent.'); + }); + + it('omits markdown emphasis to keep oclif word-wrap clean', () => { + const def: ToolDefinition = { + name: 'Plain', + description: 'A plain mutation.', + parameters: { + value: { type: 'string', describe: 'value', required: true }, + }, + outputShape: { + fields: [{ name: 'id', type: 'string' }], + }, + }; + + const coreFn: CLICoreFn = async () => 'ok'; + const CommandClass = createCLICommand(def, coreFn); + // The help renderer is intentionally plain text — no `**` emphasis, no + // backtick-wrapped field names. Help formatting is provided by oclif. + expect(CommandClass.description).not.toContain('**Output shape**'); + expect(CommandClass.description).not.toContain('`id`'); + }); + + it('renders the empty-fields placeholder when fields: [] is declared', () => { + const def: ToolDefinition = { + name: 'EmptyShape', + description: 'Mutation with no documented fields.', + parameters: { + value: { type: 'string', describe: 'value', required: true }, + }, + outputShape: { fields: [] }, + }; + + const coreFn: CLICoreFn = async () => 'ok'; + const CommandClass = createCLICommand(def, coreFn); + expect(CommandClass.description).toContain('Output shape (success.data):'); + expect(CommandClass.description).toContain('- (shape declared but no fields documented)'); + }); +}); + +// --------------------------------------------------------------------------- +// MNG-1427: generateToolManifest threads outputShape into the manifest +// --------------------------------------------------------------------------- + +describe('generateToolManifest — outputShape propagation (MNG-1427)', () => { + it('threads outputShape onto the manifest when the definition declares one', () => { + const def: ToolDefinition = { + name: 'PostComment', + description: 'Post a comment.', + parameters: { + workItemId: { type: 'string', describe: 'Work item ID', required: true }, + text: { type: 'string', describe: 'Comment text', required: true }, + }, + outputShape: { + summary: 'PostComment returns the new comment context.', + fields: [ + { name: 'status', type: '"created" | "updated"', description: 'Outcome.' }, + { name: 'id', type: 'string', description: 'Comment ID.' }, + ], + }, + }; + + const manifest = generateToolManifest(def); + expect(manifest.outputShape).toEqual({ + summary: 'PostComment returns the new comment context.', + fields: [ + { name: 'status', type: '"created" | "updated"', description: 'Outcome.' }, + { name: 'id', type: 'string', description: 'Comment ID.' }, + ], + }); + }); + + it('omits manifest.outputShape when the definition has no outputShape', () => { + const def: ToolDefinition = { + name: 'ReadOnlyTool', + description: 'A read-only tool.', + parameters: { + workItemId: { type: 'string', describe: 'Work item ID', required: true }, + }, + }; + + const manifest = generateToolManifest(def); + expect(manifest.outputShape).toBeUndefined(); + }); + + it('omits empty summary keys while preserving the rest of the shape', () => { + const def: ToolDefinition = { + name: 'SilentOutcome', + description: 'Mutation without a summary.', + parameters: { + value: { type: 'string', describe: 'value', required: true }, + }, + outputShape: { + fields: [{ name: 'id', type: 'string' }], + }, + }; + + const manifest = generateToolManifest(def); + expect(manifest.outputShape).toEqual({ fields: [{ name: 'id', type: 'string' }] }); + expect(manifest.outputShape).not.toHaveProperty('summary'); + }); + + it('preserves the optional flag on output-shape fields end-to-end', () => { + const def: ToolDefinition = { + name: 'WithOptionalField', + description: 'Definition with an optional output field.', + parameters: { + value: { type: 'string', describe: 'value', required: true }, + }, + outputShape: { + fields: [ + { name: 'id', type: 'string', description: 'always present' }, + { name: 'message', type: 'string', optional: true, description: 'only on error' }, + ], + }, + }; + + const manifest = generateToolManifest(def); + expect(manifest.outputShape?.fields[0]?.optional).toBeUndefined(); + expect(manifest.outputShape?.fields[1]?.optional).toBe(true); + }); + + it('rejects mutation of the source definition (manifest is a fresh clone)', () => { + const fields = [{ name: 'id', type: 'string' }]; + const def: ToolDefinition = { + name: 'Mutated', + description: 'Mutation definition.', + parameters: { + value: { type: 'string', describe: 'value', required: true }, + }, + outputShape: { fields }, + }; + + const manifest = generateToolManifest(def); + // Mutating the manifest output must not propagate back to the source. + manifest.outputShape?.fields.push({ name: 'extra', type: 'string' }); + expect(fields).toHaveLength(1); + }); +}); + // --------------------------------------------------------------------------- // MNG-1059: manifest threads fileInputFor / fileInputAlternative cross-refs // --------------------------------------------------------------------------- diff --git a/tests/unit/github/client.test.ts b/tests/unit/github/client.test.ts index 48b3c82c5..8f4d47727 100644 --- a/tests/unit/github/client.test.ts +++ b/tests/unit/github/client.test.ts @@ -233,7 +233,7 @@ describe('githubClient', () => { }); describe('replyToReviewComment', () => { - it('creates reply and returns mapped result', async () => { + it('creates reply and returns mapped result including updatedAt', async () => { mockPulls.createReplyForReviewComment.mockResolvedValue({ data: { id: 99, @@ -243,6 +243,7 @@ describe('githubClient', () => { html_url: 'https://github.com/...', user: { login: 'bot' }, created_at: '2024-01-01', + updated_at: '2024-01-01T00:01:00Z', in_reply_to_id: 1, }, }); @@ -253,6 +254,7 @@ describe('githubClient', () => { expect(result.id).toBe(99); expect(result.inReplyToId).toBe(1); + expect(result.updatedAt).toBe('2024-01-01T00:01:00Z'); expect(mockPulls.createReplyForReviewComment).toHaveBeenCalledWith({ owner: 'owner', repo: 'repo', @@ -264,11 +266,14 @@ describe('githubClient', () => { }); describe('createPRComment', () => { - it('creates issue comment and returns id and url', async () => { + it('creates issue comment and returns id, url, body and timestamps', async () => { mockIssues.createComment.mockResolvedValue({ data: { id: 200, html_url: 'https://github.com/owner/repo/pull/42#issuecomment-200', + body: 'Hello', + created_at: '2026-05-01T10:00:00Z', + updated_at: '2026-05-01T10:00:00Z', }, }); @@ -279,6 +284,9 @@ describe('githubClient', () => { expect(result).toEqual({ id: 200, htmlUrl: 'https://github.com/owner/repo/pull/42#issuecomment-200', + body: 'Hello', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-01T10:00:00Z', }); expect(mockIssues.createComment).toHaveBeenCalledWith({ owner: 'owner', @@ -290,11 +298,14 @@ describe('githubClient', () => { }); describe('updatePRComment', () => { - it('updates comment and returns result', async () => { + it('updates comment and returns id, url, body and timestamps', async () => { mockIssues.updateComment.mockResolvedValue({ data: { id: 200, html_url: 'https://github.com/...', + body: 'Updated', + created_at: '2026-05-01T10:00:00Z', + updated_at: '2026-05-02T11:00:00Z', }, }); @@ -302,7 +313,13 @@ describe('githubClient', () => { githubClient.updatePRComment('owner', 'repo', 200, 'Updated'), ); - expect(result.id).toBe(200); + expect(result).toEqual({ + id: 200, + htmlUrl: 'https://github.com/...', + body: 'Updated', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', + }); expect(mockIssues.updateComment).toHaveBeenCalledWith({ owner: 'owner', repo: 'repo', @@ -812,11 +829,14 @@ describe('githubClient', () => { }); describe('createPRReview', () => { - it('creates review and returns result', async () => { + it('creates review and returns id, url, body, state and submittedAt', async () => { mockPulls.createReview.mockResolvedValue({ data: { id: 500, html_url: 'https://github.com/...', + body: 'LGTM', + state: 'APPROVED', + submitted_at: '2026-05-01T10:00:00Z', }, }); @@ -827,6 +847,9 @@ describe('githubClient', () => { expect(result).toEqual({ id: 500, htmlUrl: 'https://github.com/...', + body: 'LGTM', + state: 'APPROVED', + submittedAt: '2026-05-01T10:00:00Z', }); expect(mockPulls.createReview).toHaveBeenCalledWith({ owner: 'owner', @@ -840,7 +863,13 @@ describe('githubClient', () => { it('passes file comments when provided', async () => { mockPulls.createReview.mockResolvedValue({ - data: { id: 501, html_url: 'url' }, + data: { + id: 501, + html_url: 'url', + body: 'Please fix', + state: 'CHANGES_REQUESTED', + submitted_at: '2026-05-01T10:00:00Z', + }, }); await withGitHubToken('test-token', () => @@ -855,6 +884,25 @@ describe('githubClient', () => { }), ); }); + + it('returns submittedAt=null when GitHub responds with a null submitted_at', async () => { + mockPulls.createReview.mockResolvedValue({ + data: { + id: 502, + html_url: 'url', + body: 'pending', + state: 'PENDING', + submitted_at: null, + }, + }); + + const result = await withGitHubToken('test-token', () => + githubClient.createPRReview('owner', 'repo', 42, 'COMMENT', 'pending'), + ); + + expect(result.submittedAt).toBeNull(); + expect(result.body).toBe('pending'); + }); }); describe('createPR', () => { diff --git a/tests/unit/integrations/pm-fake-lifecycle.test.ts b/tests/unit/integrations/pm-fake-lifecycle.test.ts index b471692ca..c0f67dab1 100644 --- a/tests/unit/integrations/pm-fake-lifecycle.test.ts +++ b/tests/unit/integrations/pm-fake-lifecycle.test.ts @@ -10,13 +10,19 @@ * they opt into `manifest.lifecycle.enabled = true` in plans 2, 3, 4. */ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { createFakePMManifest, createFakePMProvider, + resetFakeTimestamps, runLifecycleScenario, + setNextFakeTimestamp, } from '../../helpers/fakePMProvider.js'; +beforeEach(() => { + resetFakeTimestamps(); +}); + describe('FakePMProvider — lifecycle', () => { it('createFakePMProvider returns a typed PMProvider wired to an in-memory store', () => { const { provider, store } = createFakePMProvider(); @@ -136,4 +142,87 @@ describe('FakePMProvider — lifecycle', () => { const parsed2 = schema.parse(JSON.parse(JSON.stringify(parsed1))); expect(parsed2).toEqual(parsed1); }); + + describe('deterministic timestamps (MNG-1422)', () => { + it('stamps createWorkItem with provider-shaped createdAt and updatedAt', async () => { + const { provider, store } = createFakePMProvider(); + const containerId = Array.from(store.containers.keys())[0]; + if (!containerId) throw new Error('fake provider initialised without containers'); + + setNextFakeTimestamp('2026-01-15T00:00:00.000Z'); + const created = await provider.createWorkItem({ + containerId, + title: 'Stamped', + }); + + expect(created.createdAt).toBe('2026-01-15T00:00:00.000Z'); + expect(created.updatedAt).toBe('2026-01-15T00:00:00.000Z'); + }); + + it('bumps updatedAt on subsequent updateWorkItem without altering createdAt', async () => { + const { provider, store } = createFakePMProvider(); + const containerId = Array.from(store.containers.keys())[0]; + if (!containerId) throw new Error('fake provider initialised without containers'); + + setNextFakeTimestamp('2026-01-01T00:00:00.000Z'); + const created = await provider.createWorkItem({ containerId, title: 'Item' }); + + setNextFakeTimestamp('2026-02-01T00:00:00.000Z'); + await provider.updateWorkItem(created.id, { title: 'Renamed' }); + + const reloaded = await provider.getWorkItem(created.id); + expect(reloaded.createdAt).toBe('2026-01-01T00:00:00.000Z'); + expect(reloaded.updatedAt).toBe('2026-02-01T00:00:00.000Z'); + }); + + it('stamps addComment with deterministic createdAt and updatedAt', async () => { + const { provider, store } = createFakePMProvider(); + const containerId = Array.from(store.containers.keys())[0]; + if (!containerId) throw new Error('fake provider initialised without containers'); + + const created = await provider.createWorkItem({ containerId, title: 'Item' }); + + setNextFakeTimestamp('2026-03-01T00:00:00.000Z'); + const commentId = await provider.addComment(created.id, 'hello'); + + const comments = await provider.getWorkItemComments(created.id); + const c = comments.find((x) => x.id === commentId); + expect(c?.createdAt).toBe('2026-03-01T00:00:00.000Z'); + expect(c?.updatedAt).toBe('2026-03-01T00:00:00.000Z'); + }); + + it('updates comment.updatedAt without changing createdAt on subsequent edit', async () => { + const { provider, store } = createFakePMProvider(); + const containerId = Array.from(store.containers.keys())[0]; + if (!containerId) throw new Error('fake provider initialised without containers'); + + const created = await provider.createWorkItem({ containerId, title: 'Item' }); + + setNextFakeTimestamp('2026-03-01T00:00:00.000Z'); + const commentId = await provider.addComment(created.id, 'hello'); + + setNextFakeTimestamp('2026-04-01T00:00:00.000Z'); + await provider.updateComment(created.id, commentId, 'edited'); + + const c = (await provider.getWorkItemComments(created.id)).find((x) => x.id === commentId); + expect(c?.createdAt).toBe('2026-03-01T00:00:00.000Z'); + expect(c?.updatedAt).toBe('2026-04-01T00:00:00.000Z'); + }); + + it('falls back to monotonic synthetic stamps when no override is queued', async () => { + const { provider, store } = createFakePMProvider(); + const containerId = Array.from(store.containers.keys())[0]; + if (!containerId) throw new Error('fake provider initialised without containers'); + + const a = await provider.createWorkItem({ containerId, title: 'First' }); + const b = await provider.createWorkItem({ containerId, title: 'Second' }); + + expect(a.createdAt).toBeTruthy(); + expect(b.createdAt).toBeTruthy(); + // Synthetic stamps are strictly monotonic — second is later than first. + expect(new Date(b.createdAt ?? '').getTime()).toBeGreaterThan( + new Date(a.createdAt ?? '').getTime(), + ); + }); + }); }); diff --git a/tests/unit/integrations/pm-mock-provider-timestamps.test.ts b/tests/unit/integrations/pm-mock-provider-timestamps.test.ts new file mode 100644 index 000000000..ab062945f --- /dev/null +++ b/tests/unit/integrations/pm-mock-provider-timestamps.test.ts @@ -0,0 +1,68 @@ +/** + * Companion tests for the mock-provider timestamp helpers introduced in + * MNG-1422. These guard the deterministic-fixture contract: tests that need + * to assert provider timestamps flow through to mutation-result helpers must + * be able to do so without timer mocking. + */ + +import { describe, expect, it } from 'vitest'; + +import { + createMockPMProvider, + createMockWorkItem, + createMockWorkItemComment, + MOCK_PROVIDER_TIMESTAMP, +} from '../../helpers/mockPMProvider.js'; + +describe('mockPMProvider — deterministic timestamps (MNG-1422)', () => { + it('MOCK_PROVIDER_TIMESTAMP is a stable ISO string', () => { + expect(MOCK_PROVIDER_TIMESTAMP).toBe('2026-01-01T00:00:00.000Z'); + // Sanity check: parses back to a valid Date. + expect(Number.isNaN(new Date(MOCK_PROVIDER_TIMESTAMP).getTime())).toBe(false); + }); + + it('createMockWorkItem produces a work item with stable timestamps', () => { + const item = createMockWorkItem(); + expect(item.createdAt).toBe(MOCK_PROVIDER_TIMESTAMP); + expect(item.updatedAt).toBe(MOCK_PROVIDER_TIMESTAMP); + }); + + it('createMockWorkItem accepts overrides', () => { + const item = createMockWorkItem({ + id: 'custom-id', + updatedAt: '2026-05-01T00:00:00.000Z', + }); + expect(item.id).toBe('custom-id'); + expect(item.createdAt).toBe(MOCK_PROVIDER_TIMESTAMP); // unchanged default + expect(item.updatedAt).toBe('2026-05-01T00:00:00.000Z'); + }); + + it('createMockWorkItemComment produces a comment with stable timestamps', () => { + const comment = createMockWorkItemComment(); + expect(comment.createdAt).toBe(MOCK_PROVIDER_TIMESTAMP); + expect(comment.updatedAt).toBe(MOCK_PROVIDER_TIMESTAMP); + expect(comment.date).toBe(MOCK_PROVIDER_TIMESTAMP); + }); + + it('createMockWorkItemComment accepts overrides', () => { + const comment = createMockWorkItemComment({ + id: 'c-99', + text: 'override text', + updatedAt: '2026-06-01T00:00:00.000Z', + }); + expect(comment.id).toBe('c-99'); + expect(comment.text).toBe('override text'); + expect(comment.createdAt).toBe(MOCK_PROVIDER_TIMESTAMP); + expect(comment.updatedAt).toBe('2026-06-01T00:00:00.000Z'); + }); + + it('createMockPMProvider still exposes every vi.fn() stub the prior contract listed', () => { + const mock = createMockPMProvider(); + // Spot-check a handful of methods that pre-date MNG-1422 so the + // timestamp addition does not silently regress the surface. + expect(typeof mock.getWorkItem).toBe('function'); + expect(typeof mock.addComment).toBe('function'); + expect(typeof mock.moveWorkItem).toBe('function'); + expect(typeof mock.createWorkItem).toBe('function'); + }); +}); diff --git a/tests/unit/pm/jira/adapter.test.ts b/tests/unit/pm/jira/adapter.test.ts index a7b70665b..a2141e4c4 100644 --- a/tests/unit/pm/jira/adapter.test.ts +++ b/tests/unit/pm/jira/adapter.test.ts @@ -188,6 +188,42 @@ describe('JiraPMProvider', () => { ); expect(result.inlineMedia).toEqual(resolvedMedia); }); + + it('preserves JIRA fields.created and fields.updated as work-item timestamps', async () => { + mockJiraClient.getIssue.mockResolvedValue({ + key: 'PROJ-301', + fields: { + summary: 'Timestamped issue', + description: { type: 'doc' }, + status: { name: 'To Do' }, + labels: [], + created: '2026-04-01T08:00:00.000Z', + updated: '2026-04-15T09:30:00.000Z', + }, + }); + + const result = await provider.getWorkItem('PROJ-301'); + + expect(result.createdAt).toBe('2026-04-01T08:00:00.000Z'); + expect(result.updatedAt).toBe('2026-04-15T09:30:00.000Z'); + }); + + it('leaves createdAt and updatedAt undefined when JIRA omits them', async () => { + mockJiraClient.getIssue.mockResolvedValue({ + key: 'PROJ-302', + fields: { + summary: 'No timestamps', + description: { type: 'doc' }, + status: { name: 'Done' }, + labels: [], + }, + }); + + const result = await provider.getWorkItem('PROJ-302'); + + expect(result.createdAt).toBeUndefined(); + expect(result.updatedAt).toBeUndefined(); + }); }); describe('getWorkItemComments', () => { @@ -218,6 +254,10 @@ describe('JiraPMProvider', () => { name: 'Alice', username: 'alice@example.com', }, + // MNG-1422: JIRA comments expose `created`; absent `updated` + // falls back to `created` so consumers always see a value. + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', }, ]); }); @@ -238,6 +278,26 @@ describe('JiraPMProvider', () => { ]); }); + it('uses comment `updated` when present, falling back to `created` otherwise', async () => { + mockAdfToPlainText.mockReturnValue('Edited text'); + mockJiraClient.getIssueComments.mockResolvedValue([ + { + id: 'c-edit', + created: '2024-01-01T00:00:00.000Z', + updated: '2024-01-05T00:00:00.000Z', + body: { type: 'doc' }, + author: { accountId: 'u', displayName: 'A', emailAddress: 'a@example.com' }, + }, + ]); + + const result = await provider.getWorkItemComments('PROJ-123'); + + expect(result[0]).toMatchObject({ + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-05T00:00:00.000Z', + }); + }); + it('does not include inlineMedia on comments (comment media resolution is not supported)', async () => { mockJiraClient.getIssueComments.mockResolvedValue([ { @@ -477,6 +537,28 @@ describe('JiraPMProvider', () => { ); }); }); + + it('preserves JIRA timestamps on listed items', async () => { + mockJiraClient.searchIssues.mockResolvedValue([ + { + key: 'PROJ-T', + fields: { + summary: 'Timestamped', + status: { name: 'To Do' }, + labels: [], + created: '2026-04-01T08:00:00.000Z', + updated: '2026-04-15T09:30:00.000Z', + }, + }, + ]); + + const result = await provider.listWorkItems('PROJ'); + + expect(result[0]).toMatchObject({ + createdAt: '2026-04-01T08:00:00.000Z', + updatedAt: '2026-04-15T09:30:00.000Z', + }); + }); }); describe('moveWorkItem', () => { diff --git a/tests/unit/pm/linear/adapter.test.ts b/tests/unit/pm/linear/adapter.test.ts index 7639d9596..7978117fb 100644 --- a/tests/unit/pm/linear/adapter.test.ts +++ b/tests/unit/pm/linear/adapter.test.ts @@ -179,6 +179,33 @@ describe('LinearPMProvider', () => { const result = await provider.getWorkItem('issue-uuid'); expect(result.inlineMedia).toBeUndefined(); }); + + it('preserves Linear createdAt and updatedAt on the work item', async () => { + mockGetIssue.mockResolvedValue( + makeIssue({ + createdAt: '2026-04-01T08:00:00Z', + updatedAt: '2026-04-15T09:30:00Z', + }), + ); + + const result = await provider.getWorkItem('issue-uuid'); + + expect(result.createdAt).toBe('2026-04-01T08:00:00Z'); + expect(result.updatedAt).toBe('2026-04-15T09:30:00Z'); + }); + + it('leaves createdAt/updatedAt undefined when Linear returns empty strings', async () => { + // LinearClient normalizes missing timestamps to '' — the adapter + // treats empties as "no provider value" so downstream mutation- + // result helpers fall through cleanly instead of round-tripping + // empty strings. + mockGetIssue.mockResolvedValue(makeIssue({ createdAt: '', updatedAt: '' })); + + const result = await provider.getWorkItem('issue-uuid'); + + expect(result.createdAt).toBeUndefined(); + expect(result.updatedAt).toBeUndefined(); + }); }); // ========================================================================= @@ -279,6 +306,26 @@ describe('LinearPMProvider', () => { expect(result[0].inlineMedia).toBeUndefined(); }); + + it('preserves Linear createdAt and updatedAt on comments', async () => { + mockGetIssueComments.mockResolvedValue([ + { + id: 'c-ts', + body: 'Timestamped', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', + issueId: 'issue-uuid', + user: null, + }, + ]); + + const result = await provider.getWorkItemComments('issue-uuid'); + + expect(result[0]).toMatchObject({ + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', + }); + }); }); // ========================================================================= diff --git a/tests/unit/pm/trello/adapter.test.ts b/tests/unit/pm/trello/adapter.test.ts index c339777b2..5c75c3091 100644 --- a/tests/unit/pm/trello/adapter.test.ts +++ b/tests/unit/pm/trello/adapter.test.ts @@ -151,6 +151,40 @@ describe('TrelloPMProvider', () => { expect(result.inlineMedia).toBeUndefined(); }); + + it('preserves Trello dateLastActivity as updatedAt when present', async () => { + mockTrelloClient.getCard.mockResolvedValue({ + id: 'card-7', + name: 'With activity', + desc: '', + url: 'https://trello.com/c/abc', + idList: 'list-1', + labels: [], + dateLastActivity: '2026-04-01T12:00:00.000Z', + }); + + const result = await provider.getWorkItem('card-7'); + + expect(result.updatedAt).toBe('2026-04-01T12:00:00.000Z'); + // Trello cards don't expose a true creation timestamp — leave + // `createdAt` undefined rather than synthesising one. + expect(result.createdAt).toBeUndefined(); + }); + + it('omits updatedAt when Trello does not surface dateLastActivity', async () => { + mockTrelloClient.getCard.mockResolvedValue({ + id: 'card-8', + name: 'No activity', + desc: '', + url: 'https://trello.com/c/abc', + idList: 'list-1', + labels: [], + }); + + const result = await provider.getWorkItem('card-8'); + + expect(result.updatedAt).toBeUndefined(); + }); }); describe('getWorkItemComments', () => { @@ -174,6 +208,10 @@ describe('TrelloPMProvider', () => { text: 'Hello world', author: { id: 'member-1', name: 'Alice', username: 'alice' }, inlineMedia: undefined, + // MNG-1422: Trello action `date` is preserved on both + // timestamp fields. + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', }, ]); }); @@ -259,6 +297,24 @@ describe('TrelloPMProvider', () => { expect(ref.source).toBe('comment'); } }); + + it('preserves comment date as createdAt and updatedAt', async () => { + mockTrelloClient.getCardComments.mockResolvedValue([ + { + id: 'comment-ts', + date: '2026-05-01T10:00:00.000Z', + data: { text: 'a comment' }, + memberCreator: { id: 'm1', fullName: 'Alice', username: 'alice' }, + }, + ]); + + const result = await provider.getWorkItemComments('card-1'); + + expect(result[0]).toMatchObject({ + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:00:00.000Z', + }); + }); }); describe('updateWorkItem', () => { @@ -318,6 +374,25 @@ describe('TrelloPMProvider', () => { }); expect(result).toMatchObject({ id: 'new-card', title: 'New Feature' }); }); + + it('uses Trello dateLastActivity as both createdAt and updatedAt on a fresh card', async () => { + mockTrelloClient.createCard.mockResolvedValue({ + id: 'fresh-card', + name: 'Fresh', + desc: '', + url: 'https://trello.com/c/fresh', + labels: [], + dateLastActivity: '2026-04-01T12:00:00.000Z', + }); + + const result = await provider.createWorkItem({ + containerId: 'list-1', + title: 'Fresh', + }); + + expect(result.createdAt).toBe('2026-04-01T12:00:00.000Z'); + expect(result.updatedAt).toBe('2026-04-01T12:00:00.000Z'); + }); }); describe('listWorkItems', () => { diff --git a/tests/unit/router/bullmq-workers.test.ts b/tests/unit/router/bullmq-workers.test.ts index bc6ddc419..219deed38 100644 --- a/tests/unit/router/bullmq-workers.test.ts +++ b/tests/unit/router/bullmq-workers.test.ts @@ -8,6 +8,13 @@ vi.mock('bullmq', () => ({ Worker: vi.fn().mockImplementation((_queueName, _processFn, _opts) => ({ on: vi.fn(), })), + // Real-enough subclass so `instanceof` in the predicate works under the mock. + UnrecoverableError: class UnrecoverableError extends Error { + constructor(message?: string) { + super(message); + this.name = 'UnrecoverableError'; + } + }, })); vi.mock('../../../src/sentry.js', () => ({ @@ -16,6 +23,7 @@ vi.mock('../../../src/sentry.js', () => ({ vi.mock('../../../src/router/dispatch-compensator.js', () => ({ releaseLocksForFailedJob: vi.fn().mockResolvedValue(undefined), + recordSpawnFailureStub: vi.fn().mockResolvedValue(undefined), })); // Mock logger @@ -32,9 +40,16 @@ vi.mock('../../../src/utils/logging.js', () => ({ // Imports (after mocks) // --------------------------------------------------------------------------- -import { Worker } from 'bullmq'; -import { createQueueWorker, parseRedisUrl } from '../../../src/router/bullmq-workers.js'; -import { releaseLocksForFailedJob } from '../../../src/router/dispatch-compensator.js'; +import { UnrecoverableError, Worker } from 'bullmq'; +import { + createQueueWorker, + isTerminalDispatchFailure, + parseRedisUrl, +} from '../../../src/router/bullmq-workers.js'; +import { + recordSpawnFailureStub, + releaseLocksForFailedJob, +} from '../../../src/router/dispatch-compensator.js'; import { captureException } from '../../../src/sentry.js'; import { logger } from '../../../src/utils/logging.js'; @@ -42,12 +57,15 @@ const MockWorker = vi.mocked(Worker); const mockCaptureException = vi.mocked(captureException); const mockLogger = vi.mocked(logger); const mockReleaseLocksForFailedJob = vi.mocked(releaseLocksForFailedJob); +const mockRecordSpawnFailureStub = vi.mocked(recordSpawnFailureStub); beforeEach(() => { MockWorker.mockClear(); mockCaptureException.mockClear(); mockReleaseLocksForFailedJob.mockClear(); mockReleaseLocksForFailedJob.mockResolvedValue(undefined); + mockRecordSpawnFailureStub.mockClear(); + mockRecordSpawnFailureStub.mockResolvedValue(undefined); // Re-establish default mock so each test gets a fresh mock worker MockWorker.mockImplementation( (_queueName, _processFn, _opts) => @@ -267,4 +285,110 @@ describe('createQueueWorker', () => { // Existing log + Sentry behavior preserved expect(mockLogger.error).toHaveBeenCalled(); }); + + // ------------------------------------------------------------------------- + // Spawn-failure stub gating — the failed event fires on EVERY attempt + // (including intermediate retries); the stub must only be recorded on a + // terminal failure, or transient retries leave bogus `failed` run rows. + // ------------------------------------------------------------------------- + + type FailedHandler = (job: Record | undefined, err: unknown) => void; + + function getFailedHandler(): FailedHandler { + const worker = createQueueWorker(baseConfig); + return vi.mocked(worker.on).mock.calls.find((c) => c[0] === 'failed')?.[1] as FailedHandler; + } + + it('records the spawn-failure stub on a terminal failure (finishedOn set)', () => { + const jobData = { type: 'github', payload: 'x' }; + getFailedHandler()( + { id: 'job-term', data: jobData, attemptsMade: 4, opts: { attempts: 4 }, finishedOn: 1234 }, + new Error('image not found after fallback'), + ); + + expect(mockRecordSpawnFailureStub).toHaveBeenCalledTimes(1); + expect(mockRecordSpawnFailureStub).toHaveBeenCalledWith(jobData, expect.any(Error)); + }); + + it('does NOT record the stub on an intermediate retry (finishedOn unset, attempts remain)', () => { + getFailedHandler()( + { id: 'job-retry', data: { type: 'github' }, attemptsMade: 1, opts: { attempts: 4 } }, + new Error('ECONNRESET pulling image'), + ); + + expect(mockRecordSpawnFailureStub).not.toHaveBeenCalled(); + // Lock compensation must STILL fire on every attempt — only the run-row + // stub is gated. + expect(mockReleaseLocksForFailedJob).toHaveBeenCalledTimes(1); + }); + + it('records the stub when retries are exhausted even if finishedOn is unset (defensive fallback)', () => { + getFailedHandler()( + { id: 'job-exhausted', data: { type: 'github' }, attemptsMade: 4, opts: { attempts: 4 } }, + new Error('still failing'), + ); + + expect(mockRecordSpawnFailureStub).toHaveBeenCalledTimes(1); + }); + + it('records the stub for an UnrecoverableError regardless of remaining attempts', () => { + getFailedHandler()( + { id: 'job-unrec', data: { type: 'github' }, attemptsMade: 1, opts: { attempts: 4 } }, + new UnrecoverableError('validation failed'), + ); + + expect(mockRecordSpawnFailureStub).toHaveBeenCalledTimes(1); + }); + + it('swallows a throw from the stub recorder', async () => { + mockRecordSpawnFailureStub.mockRejectedValueOnce(new Error('stub boom')); + expect(() => + getFailedHandler()( + { id: 'job-stubthrow', data: { type: 'github' }, finishedOn: 99 }, + new Error('terminal'), + ), + ).not.toThrow(); + await new Promise((r) => setImmediate(r)); + }); +}); + +// --------------------------------------------------------------------------- +// isTerminalDispatchFailure — predicate behind the stub gate +// --------------------------------------------------------------------------- + +describe('isTerminalDispatchFailure', () => { + // Minimal Job-shaped fixtures; the predicate reads only finishedOn / attemptsMade / opts. + const job = (over: Record) => over as never; + + it('is terminal when BullMQ set finishedOn', () => { + expect(isTerminalDispatchFailure(job({ finishedOn: 1 }), new Error('x'))).toBe(true); + }); + + it('is NOT terminal mid-retry (no finishedOn, attempts remain)', () => { + expect( + isTerminalDispatchFailure(job({ attemptsMade: 1, opts: { attempts: 4 } }), new Error('x')), + ).toBe(false); + }); + + it('is terminal once attemptsMade reaches the attempt budget', () => { + expect( + isTerminalDispatchFailure(job({ attemptsMade: 4, opts: { attempts: 4 } }), new Error('x')), + ).toBe(true); + }); + + it('is terminal for an UnrecoverableError even with attempts remaining', () => { + expect( + isTerminalDispatchFailure( + job({ attemptsMade: 1, opts: { attempts: 4 } }), + new UnrecoverableError('terminal'), + ), + ).toBe(true); + }); + + it('treats an error named UnrecoverableError as terminal (cross-realm safety)', () => { + const err = Object.assign(new Error('x'), { name: 'UnrecoverableError' }); + expect(isTerminalDispatchFailure(job({ attemptsMade: 1, opts: { attempts: 4 } }), err)).toBe( + true, + ); + }); }); diff --git a/tests/unit/router/container-manager.test.ts b/tests/unit/router/container-manager.test.ts index 01a03e6ac..d69009950 100644 --- a/tests/unit/router/container-manager.test.ts +++ b/tests/unit/router/container-manager.test.ts @@ -8,11 +8,15 @@ const { mockDockerCreateContainer, mockDockerGetContainer, mockDockerListContainers, + mockDockerPull, + mockFollowProgress, mockLoadProjectConfig, } = vi.hoisted(() => ({ mockDockerCreateContainer: vi.fn(), mockDockerGetContainer: vi.fn(), mockDockerListContainers: vi.fn(), + mockDockerPull: vi.fn(), + mockFollowProgress: vi.fn(), mockLoadProjectConfig: vi.fn().mockResolvedValue({ projects: [], fullProjects: [] }), })); @@ -25,6 +29,8 @@ vi.mock('dockerode', () => ({ createContainer: mockDockerCreateContainer, getContainer: mockDockerGetContainer, listContainers: mockDockerListContainers, + pull: mockDockerPull, + modem: { followProgress: mockFollowProgress }, })), })); @@ -257,6 +263,11 @@ describe('spawnWorker', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); mockGetAllProjectCredentials.mockResolvedValue({}); mockLoadProjectConfig.mockResolvedValue({ projects: [], fullProjects: [] }); + mockDockerPull.mockResolvedValue({} as never); + mockFollowProgress.mockImplementation(((_stream: unknown, cb: (err: Error | null) => void) => + cb(null)) as never); + mockDockerPull.mockClear(); + mockFollowProgress.mockClear(); detachAll(); }); @@ -399,6 +410,56 @@ describe('spawnWorker', () => { resolveWait(); }); + // Self-heal + visibility on missing base image — the 2026-06-15 outage class. + // Without these, a host-side prune of `cascade-worker:latest` produces silent + // `UnrecoverableError`s on every spawn and `runs list` shows nothing at all. + + it('self-heals when the base image is missing: pulls once and retries spawn', async () => { + const notFound = Object.assign( + new Error('(HTTP code 404) no such container - No such image: test-worker:latest'), + { statusCode: 404 }, + ); + mockDockerCreateContainer.mockRejectedValueOnce(notFound); + const { resolveWait } = setupMockContainer(); + + await spawnWorker(makeJob({ id: 'job-pull-heal' }) as never); + + expect(mockDockerPull).toHaveBeenCalledTimes(1); + expect(mockDockerPull).toHaveBeenCalledWith('test-worker:latest'); + expect(mockDockerCreateContainer).toHaveBeenCalledTimes(2); + + resolveWait(); + }); + + it('propagates the pull error (not the original 404) when pull fails so the dispatch-error classifier can retry transient pull errors', async () => { + // Reviewer concern: throwing the original ImageNotFound on pull failure + // converts transient pull errors (registry 429, ECONNRESET) into terminal + // classification. spawnWorker must surface the pull error itself so the + // classifier can examine its actual shape. + const notFound = Object.assign( + new Error('(HTTP code 404) no such container - No such image: test-worker:latest'), + { statusCode: 404 }, + ); + mockDockerCreateContainer.mockRejectedValue(notFound); + const transientPullErr = Object.assign(new Error('registry 429'), { statusCode: 429 }); + mockFollowProgress.mockImplementation(((_s: unknown, cb: (e: Error | null) => void) => + cb(transientPullErr)) as never); + + await expect( + spawnWorker( + makeJob({ + id: 'job-pull-transient', + data: { + type: 'trello', + projectId: 'proj-tr', + workItemId: 'card-tr', + agentType: 'review', + } as CascadeJob, + }) as never, + ), + ).rejects.toBe(transientPullErr); + }); + it('uses project watchdogTimeoutMs + 2min buffer when available', async () => { mockLoadProjectConfig.mockResolvedValue({ projects: [], diff --git a/tests/unit/router/dispatch-compensator.test.ts b/tests/unit/router/dispatch-compensator.test.ts index 8ec7441c0..b1e9927d1 100644 --- a/tests/unit/router/dispatch-compensator.test.ts +++ b/tests/unit/router/dispatch-compensator.test.ts @@ -25,11 +25,26 @@ vi.mock('../../../src/utils/logging.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); +const mockCreateRun = vi.fn().mockResolvedValue('stub-run-id'); +const mockCompleteRun = vi.fn().mockResolvedValue(undefined); +vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ + createRun: (...args: unknown[]) => mockCreateRun(...args), + completeRun: (...args: unknown[]) => mockCompleteRun(...args), +})); + +const mockLoadProjectConfig = vi.fn().mockResolvedValue({ projects: [], fullProjects: [] }); +vi.mock('../../../src/router/config.js', () => ({ + loadProjectConfig: (...args: unknown[]) => mockLoadProjectConfig(...args), +})); + import { clearAgentTypeEnqueued, clearRecentlyDispatched, } from '../../../src/router/agent-type-lock.js'; -import { releaseLocksForFailedJob } from '../../../src/router/dispatch-compensator.js'; +import { + recordSpawnFailureStub, + releaseLocksForFailedJob, +} from '../../../src/router/dispatch-compensator.js'; import { clearWorkItemEnqueued } from '../../../src/router/work-item-lock.js'; import { extractAgentType, @@ -146,3 +161,120 @@ describe('releaseLocksForFailedJob', () => { expect(mockClearRecentlyDispatched).not.toHaveBeenCalled(); }); }); + +// Lives next to releaseLocksForFailedJob because it ALSO runs from BullMQ's +// `worker.on('failed')` handler, fires exactly once per permanently-dead job, +// and shares the same extractor/extraction shape. Reviewer concern from PR +// #1408: the recorder must NOT run on retryable failures that BullMQ later +// recovers from — that surface is guaranteed here, not in spawnWorker's catch. +describe('recordSpawnFailureStub', () => { + beforeEach(() => { + mockExtractProjectIdFromJob.mockReset(); + mockExtractWorkItemId.mockReset(); + mockExtractAgentType.mockReset(); + mockCreateRun.mockReset().mockResolvedValue('stub-run-id'); + mockCompleteRun.mockReset().mockResolvedValue(undefined); + mockLoadProjectConfig.mockReset().mockResolvedValue({ projects: [], fullProjects: [] }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('inserts a failed run row with extracted projectId, workItemId, prNumber, agentType, and triggerType', async () => { + mockExtractProjectIdFromJob.mockResolvedValue('p1'); + mockExtractWorkItemId.mockReturnValue('w1'); + mockExtractAgentType.mockReturnValue('review'); + + const err = new Error('worker died at boot'); + await recordSpawnFailureStub( + { + type: 'github', + triggerResult: { + prNumber: 2273, + agentInput: { triggerType: 'review-requested' }, + }, + }, + err, + ); + + expect(mockCreateRun).toHaveBeenCalledWith({ + projectId: 'p1', + workItemId: 'w1', + prNumber: 2273, + agentType: 'review', + engine: 'unknown', + triggerType: 'review-requested', + }); + expect(mockCompleteRun).toHaveBeenCalledWith( + 'stub-run-id', + expect.objectContaining({ + status: 'failed', + durationMs: 0, + error: expect.stringContaining('worker died at boot'), + }), + ); + }); + + it('resolves the engine from project config when available', async () => { + mockExtractProjectIdFromJob.mockResolvedValue('p2'); + mockExtractWorkItemId.mockReturnValue(undefined); + mockExtractAgentType.mockReturnValue('implementation'); + mockLoadProjectConfig.mockResolvedValue({ + projects: [], + fullProjects: [ + { + id: 'p2', + agentEngine: { default: 'codex', overrides: { implementation: 'opencode' } }, + }, + ], + }); + + await recordSpawnFailureStub({ type: 'trello' }, new Error('boom')); + + expect(mockCreateRun).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'p2', agentType: 'implementation', engine: 'opencode' }), + ); + }); + + it('falls back to engine="unknown" when loadProjectConfig throws (must not block visibility)', async () => { + mockExtractProjectIdFromJob.mockResolvedValue('p3'); + mockExtractWorkItemId.mockReturnValue('w3'); + mockExtractAgentType.mockReturnValue('review'); + mockLoadProjectConfig.mockRejectedValue(new Error('config read failed')); + + await recordSpawnFailureStub({ type: 'github' }, new Error('boom')); + + expect(mockCreateRun).toHaveBeenCalledWith(expect.objectContaining({ engine: 'unknown' })); + }); + + it('skips the row when projectId is null', async () => { + mockExtractProjectIdFromJob.mockResolvedValue(null); + mockExtractAgentType.mockReturnValue('review'); + + await recordSpawnFailureStub({ type: 'github' }, new Error('boom')); + + expect(mockCreateRun).not.toHaveBeenCalled(); + }); + + it('skips the row when agentType cannot be resolved', async () => { + mockExtractProjectIdFromJob.mockResolvedValue('p4'); + mockExtractAgentType.mockReturnValue(undefined); + + await recordSpawnFailureStub({ type: 'github' }, new Error('boom')); + + expect(mockCreateRun).not.toHaveBeenCalled(); + }); + + it('never throws even if createRun rejects', async () => { + mockExtractProjectIdFromJob.mockResolvedValue('p5'); + mockExtractWorkItemId.mockReturnValue('w5'); + mockExtractAgentType.mockReturnValue('review'); + mockCreateRun.mockRejectedValue(new Error('DB down')); + + await expect( + recordSpawnFailureStub({ type: 'github' }, new Error('boom')), + ).resolves.toBeUndefined(); + expect(mockCompleteRun).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/router/snapshot-integration.test.ts b/tests/unit/router/snapshot-integration.test.ts index fcffc6708..8dcf56c65 100644 --- a/tests/unit/router/snapshot-integration.test.ts +++ b/tests/unit/router/snapshot-integration.test.ts @@ -17,6 +17,8 @@ const { mockDockerCreateContainer, mockDockerGetContainer, mockDockerGetImage, + mockDockerPull, + mockFollowProgress, mockLoadProjectConfig, mockGetSnapshot, mockRegisterSnapshot, @@ -30,6 +32,10 @@ const { mockDockerGetImage: vi.fn().mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Size: 1_234_567_890 }), }), + mockDockerPull: vi.fn().mockResolvedValue({}), + mockFollowProgress: vi + .fn() + .mockImplementation((_stream: unknown, cb: (err: Error | null) => void) => cb(null)), mockLoadProjectConfig: vi.fn().mockResolvedValue({ projects: [], fullProjects: [] }), mockGetSnapshot: vi.fn().mockReturnValue(undefined), mockRegisterSnapshot: vi.fn(), @@ -45,6 +51,8 @@ vi.mock('dockerode', () => ({ createContainer: mockDockerCreateContainer, getContainer: mockDockerGetContainer, getImage: mockDockerGetImage, + pull: mockDockerPull, + modem: { followProgress: mockFollowProgress }, })), })); @@ -188,6 +196,12 @@ beforeEach(() => { mockDockerGetImage.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Size: 1_234_567_890 }), }); + // Pull + followProgress defaults also get wiped by per-describe + // vi.restoreAllMocks() — re-arm them so the spawn self-heal path's + // optional pull doesn't hang on a no-op followProgress. + mockDockerPull.mockResolvedValue({}); + mockFollowProgress.mockImplementation(((_stream: unknown, cb: (err: Error | null) => void) => + cb(null)) as never); }); // --------------------------------------------------------------------------- @@ -591,22 +605,29 @@ describe('spawnWorker — stale snapshot (image not found fallback)', () => { expect(mockInvalidateSnapshot).toHaveBeenCalledWith('proj-snap', 'card-snap'); }); - it('propagates 404 without retry when base image is missing (snapshotReuse=false)', async () => { - // No snapshot hit — fresh run, snapshotReuse will be false + it('self-heals when base image is missing (snapshotReuse=false): pulls then retries spawn', async () => { + // No snapshot hit — fresh run, snapshotReuse will be false. The catch + // path now treats a missing base image as recoverable: pull once, retry + // once. Closes the 2026-06-15 outage class where a pruned base image + // produced silent UnrecoverableErrors on every spawn. mockGetSnapshot.mockReturnValue(undefined); const baseImageError = Object.assign( new Error('(HTTP code 404) no such container - No such image: base-worker:latest'), { statusCode: 404 }, ); mockDockerCreateContainer.mockRejectedValueOnce(baseImageError); + const { resolveWait } = setupMockContainer(); - await expect(spawnWorker(makeJob() as never)).rejects.toThrow( - 'No such image: base-worker:latest', - ); + await spawnWorker(makeJob() as never); - // Should not retry — only one createContainer call - expect(mockDockerCreateContainer).toHaveBeenCalledTimes(1); + expect(mockDockerPull).toHaveBeenCalledTimes(1); + expect(mockDockerPull).toHaveBeenCalledWith('base-worker:latest'); + expect(mockDockerCreateContainer).toHaveBeenCalledTimes(2); + // Snapshot invalidation only applies to stale snapshots; base-image + // recovery does not touch the snapshot registry. expect(mockInvalidateSnapshot).not.toHaveBeenCalled(); + + resolveWait(); }); }); diff --git a/tests/unit/router/worker-snapshots.test.ts b/tests/unit/router/worker-snapshots.test.ts index ac500a578..c6bc19aa8 100644 --- a/tests/unit/router/worker-snapshots.test.ts +++ b/tests/unit/router/worker-snapshots.test.ts @@ -6,6 +6,8 @@ const { mockContainerRemove, mockDockerGetContainer, mockDockerGetImage, + mockDockerPull, + mockFollowProgress, mockImageInspect, mockLoggerWarn, mockRegisterSnapshot, @@ -15,6 +17,8 @@ const { mockContainerRemove: vi.fn(), mockDockerGetContainer: vi.fn(), mockDockerGetImage: vi.fn(), + mockDockerPull: vi.fn(), + mockFollowProgress: vi.fn(), mockImageInspect: vi.fn(), mockLoggerWarn: vi.fn(), mockRegisterSnapshot: vi.fn(), @@ -24,6 +28,8 @@ vi.mock('dockerode', () => ({ default: vi.fn().mockImplementation(() => ({ getContainer: mockDockerGetContainer, getImage: mockDockerGetImage, + pull: mockDockerPull, + modem: { followProgress: mockFollowProgress }, })), })); @@ -46,6 +52,7 @@ import { buildWorkerSnapshotImageName, commitWorkerSnapshot, isImageNotFoundError, + pullImageOnce, removeWorkerContainerBestEffort, } from '../../../src/router/worker-snapshots.js'; @@ -158,3 +165,64 @@ describe('worker-snapshots', () => { ); }); }); + +// Spec: pullImageOnce backs the spawn self-heal in container-manager.ts. +// Single-flight + timeout are non-negotiable: without the in-flight cache, +// every queued job under a missing-image outage races its own multi-GB pull. +describe('pullImageOnce', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDockerPull.mockResolvedValue({} as never); + mockFollowProgress.mockImplementation(((_stream: unknown, cb: (err: Error | null) => void) => + cb(null)) as never); + }); + + it('resolves when the pull stream completes without error', async () => { + await expect(pullImageOnce('img:latest')).resolves.toBeUndefined(); + expect(mockDockerPull).toHaveBeenCalledWith('img:latest'); + expect(mockFollowProgress).toHaveBeenCalledTimes(1); + }); + + it('rejects when the pull stream emits an error', async () => { + const err = new Error('manifest denied'); + mockFollowProgress.mockImplementation(((_stream: unknown, cb: (err: Error | null) => void) => + cb(err)) as never); + await expect(pullImageOnce('img:latest')).rejects.toThrow('manifest denied'); + }); + + it('rejects with a pull-timeout error when the stream never completes', async () => { + mockFollowProgress.mockImplementation((() => { + // Never invoke the callback — exercise the timeout race. + }) as never); + await expect(pullImageOnce('img:latest', 30)).rejects.toThrow(/pull timeout after 30ms/); + }); + + it('deduplicates concurrent calls for the same image (single-flight)', async () => { + let fire!: () => void; + mockFollowProgress.mockImplementation(((_stream: unknown, cb: (err: Error | null) => void) => { + fire = () => cb(null); + }) as never); + const p1 = pullImageOnce('img:latest'); + const p2 = pullImageOnce('img:latest'); + // pullImageOnce awaits docker.pull before reaching followProgress; flush + // microtasks so the deferred-fire callback is captured before we trigger it. + await new Promise((r) => setTimeout(r, 0)); + fire(); + await Promise.all([p1, p2]); + expect(mockDockerPull).toHaveBeenCalledTimes(1); + expect(mockFollowProgress).toHaveBeenCalledTimes(1); + }); + + it('does NOT deduplicate calls for different images', async () => { + await Promise.all([pullImageOnce('a:latest'), pullImageOnce('b:latest')]); + expect(mockDockerPull).toHaveBeenCalledTimes(2); + expect(mockDockerPull).toHaveBeenNthCalledWith(1, 'a:latest'); + expect(mockDockerPull).toHaveBeenNthCalledWith(2, 'b:latest'); + }); + + it('clears the in-flight cache after settling so the next call pulls fresh', async () => { + await pullImageOnce('img:latest'); + await pullImageOnce('img:latest'); + expect(mockDockerPull).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tools/migrate-hooks.ts b/tools/migrate-hooks.ts index 6d35549cf..49c16db58 100644 --- a/tools/migrate-hooks.ts +++ b/tools/migrate-hooks.ts @@ -8,8 +8,8 @@ * npx tsx tools/migrate-hooks.ts --apply # Apply changes */ -import { existsSync, readFileSync } from 'node:fs'; import pg from 'pg'; +import { resolveDbSslConfig } from '../src/db/ssl-config.js'; const DATABASE_URL = process.env.DATABASE_URL ?? ''; if (!DATABASE_URL) { @@ -17,21 +17,6 @@ if (!DATABASE_URL) { process.exit(1); } -function getSslConfig(): false | { rejectUnauthorized: boolean; ca?: string } { - if (process.env.DATABASE_SSL === 'false') { - return false; - } - const sslConfig: { rejectUnauthorized: boolean; ca?: string } = { rejectUnauthorized: true }; - if (process.env.DATABASE_CA_CERT) { - const certPath = process.env.DATABASE_CA_CERT; - if (!existsSync(certPath)) { - throw new Error(`DATABASE_CA_CERT file not found: ${certPath}`); - } - sslConfig.ca = readFileSync(certPath, 'utf8'); - } - return sslConfig; -} - const dryRun = !process.argv.includes('--apply'); interface LegacyBackend { @@ -140,7 +125,7 @@ async function main() { const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 2, - ssl: getSslConfig(), + ssl: resolveDbSslConfig(), }); try { diff --git a/web/package-lock.json b/web/package-lock.json index 711660c00..b26b3dc90 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -39,13 +39,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -54,9 +54,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -64,21 +64,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -95,14 +95,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -112,14 +112,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -129,9 +129,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -139,29 +139,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -181,9 +181,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -191,9 +191,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -201,9 +201,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -211,27 +211,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -273,33 +273,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -307,14 +307,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2988,6 +2988,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -3449,19 +3513,22 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -3479,11 +3546,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3493,9 +3560,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001770", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", - "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -3837,9 +3904,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", "dev": true, "license": "ISC" }, @@ -5114,11 +5181,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/parse-entities": { "version": "4.0.2", @@ -5332,9 +5402,9 @@ } }, "node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT", "peer": true }, diff --git a/web/src/components/layout/header.tsx b/web/src/components/layout/header.tsx index a9b1e790f..a72907e0f 100644 --- a/web/src/components/layout/header.tsx +++ b/web/src/components/layout/header.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; +import { Link, useNavigate } from '@tanstack/react-router'; import { LogOut, Moon, Sun } from 'lucide-react'; import { useTheme } from 'next-themes'; import { type ReactNode, useEffect, useState } from 'react'; @@ -41,7 +41,12 @@ export function Header({ user, mobileMenuTrigger }: HeaderProps) {
{user && ( - {user.name} + + {user.name} + )} {mounted && (
); diff --git a/web/src/components/projects/pm-providers/jira/wizard.ts b/web/src/components/projects/pm-providers/jira/wizard.ts index d52f247fa..a3e55141e 100644 --- a/web/src/components/projects/pm-providers/jira/wizard.ts +++ b/web/src/components/projects/pm-providers/jira/wizard.ts @@ -355,8 +355,6 @@ export const jiraProviderWizard: ProviderWizardDefinition = { customField.createJiraCustomFieldMutation.mutate({ name }); }; - const webhookUrl = projectId ? `${window.location.origin}/webhooks/${projectId}/jira` : ''; - // Plan 012/2 — webhook plumbing. Mirrors the legacy `useWebhookManagement` // formula (plan 012/4 deletes that hook). The server-side // `jiraEnsureLabels` side-effect fires inside @@ -365,6 +363,12 @@ export const jiraProviderWizard: ProviderWizardDefinition = { API_URL || (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + // Display the exact callback URL that actually gets registered — the + // router route is `/jira/webhook`, not a synthetic + // `/webhooks//jira` path the router never serves. The two had + // diverged, so the displayed URL pointed operators at a dead endpoint. + const webhookUrl = callbackBaseUrl ? `${callbackBaseUrl}/jira/webhook` : ''; + const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId: projectId ?? '' })); const activeJiraWebhooks = normalizeJiraActiveWebhooks(webhooksQuery.data); diff --git a/web/src/components/projects/pm-providers/trello/oauth-step.tsx b/web/src/components/projects/pm-providers/trello/oauth-step.tsx index dfe16102d..73a35923a 100644 --- a/web/src/components/projects/pm-providers/trello/oauth-step.tsx +++ b/web/src/components/projects/pm-providers/trello/oauth-step.tsx @@ -60,6 +60,14 @@ export function TrelloOAuthStep({ state, dispatch }: ProviderWizardStepProps) { return () => clearInterval(interval); }, [isWaitingForAuth]); + // Once a token is provided (e.g. pasted manually after granting access in the + // popup), authorization is complete — stop waiting even if the popup is still open. + useEffect(() => { + if (state.trelloToken && isWaitingForAuth) { + setIsWaitingForAuth(false); + } + }, [state.trelloToken, isWaitingForAuth]); + return (
{state.isEditing && state.hasStoredCredentials && !state.trelloApiKey && ( @@ -84,12 +92,12 @@ export function TrelloOAuthStep({ state, dispatch }: ProviderWizardStepProps) {

Find your API key at{' '} - trello.com/app-key + trello.com/power-ups/admin

diff --git a/web/src/components/projects/pm-providers/trello/wizard.ts b/web/src/components/projects/pm-providers/trello/wizard.ts index 0469e67df..0d3c28f48 100644 --- a/web/src/components/projects/pm-providers/trello/wizard.ts +++ b/web/src/components/projects/pm-providers/trello/wizard.ts @@ -373,8 +373,6 @@ export const trelloProviderWizard: ProviderWizardDefinition = { customField.createCustomFieldMutation.mutate({ name }); }; - const webhookUrl = projectId ? `${window.location.origin}/webhooks/${projectId}/trello` : ''; - // Plan 012/1 — webhook plumbing. Mirrors the legacy `useWebhookManagement` // formula (plan 012/4 deletes that hook). Computes the public router URL // from the Vite env (dev) or current origin (prod), fetches active @@ -384,6 +382,12 @@ export const trelloProviderWizard: ProviderWizardDefinition = { API_URL || (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + // Display the exact callback URL that actually gets registered — the + // router route is `/trello/webhook`, not a synthetic + // `/webhooks//trello` path the router never serves. The two had + // diverged, so the displayed URL pointed operators at a dead endpoint. + const webhookUrl = callbackBaseUrl ? `${callbackBaseUrl}/trello/webhook` : ''; + const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId: projectId ?? '' })); const activeTrelloWebhooks = normalizeTrelloActiveWebhooks(webhooksQuery.data); diff --git a/web/src/routes/route-tree.ts b/web/src/routes/route-tree.ts index 8e67d0241..509514a29 100644 --- a/web/src/routes/route-tree.ts +++ b/web/src/routes/route-tree.ts @@ -17,6 +17,7 @@ import { projectsIndexRoute } from './projects/index.js'; import { prRunsRoute } from './prs/$projectId.$prNumber.js'; import { runDetailRoute } from './runs/$runId.js'; import { settingsGeneralRoute } from './settings/general.js'; +import { settingsProfileRoute } from './settings/profile.js'; import { settingsUsersRoute } from './settings/users.js'; import { workItemRunsRoute } from './work-items/$projectId.$workItemId.js'; @@ -35,6 +36,7 @@ export const routeTree = rootRoute.addChildren([ projectLifecycleRoute, ]), settingsGeneralRoute, + settingsProfileRoute, settingsUsersRoute, globalDefinitionsRoute, globalWebhookLogsRoute, diff --git a/web/src/routes/settings/profile.tsx b/web/src/routes/settings/profile.tsx new file mode 100644 index 000000000..1f83d95cb --- /dev/null +++ b/web/src/routes/settings/profile.tsx @@ -0,0 +1,146 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { createRoute } from '@tanstack/react-router'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { rootRoute } from '../__root.js'; + +function ProfilePage() { + const meQuery = useQuery(trpc.auth.me.queryOptions()); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const changePasswordMutation = useMutation({ + mutationFn: (data: { password: string }) => trpcClient.auth.changePassword.mutate(data), + onSuccess: () => { + toast.success('Password changed successfully'); + setPassword(''); + setConfirmPassword(''); + }, + onError: (error) => { + toast.error('Failed to change password', { description: error.message }); + }, + }); + + if (meQuery.isLoading) { + return
Loading profile...
; + } + + if (!meQuery.data) { + return
Failed to load profile.
; + } + + const user = meQuery.data; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirmPassword) { + toast.error('Passwords do not match'); + return; + } + if (password.length < 12) { + toast.error('Password must be at least 12 characters'); + return; + } + changePasswordMutation.mutate({ password }); + }; + + return ( +
+
+

User Profile

+

+ Manage your account settings and change your password. +

+
+ +
+ {/* Account Information Card */} + + + Account Information + Overview of your account details. + + +
+ Name + {user.name} +
+
+ Email + {user.email} +
+
+ Role + + {user.role} + +
+
+ Organization + + {user.orgName || 'N/A'} + +
+
+
+ + {/* Change Password Card */} + + + Change Password + + Set a new password for your account. Minimum 12 characters. + + + +
+
+ + setPassword(e.target.value)} + placeholder="Minimum 12 characters" + minLength={12} + required + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + minLength={12} + required + /> +
+
+ +
+
+
+
+
+
+ ); +} + +export const settingsProfileRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings/profile', + component: ProfilePage, +});