feat(cli): apply -f support for agentic-teams resources, plus vertical smoke tests
Closes two stated project rules this work had been violating. **`get -o yaml` must round-trip through `apply -f`.** The five new resources were creatable and listable but absent from apply, so what `get` emitted was not valid apply input. They are now applied in dependency order — gitproviders → repos → epics → epictasks → tasks — since each references the previous by name, and each block is independently fault-tolerant so one bad entry does not abort the rest of a manifest. `edit` accepts them too. `state` is deliberately NOT in the task spec. Lifecycle moves go through `mcpctl tasks advance`, which enforces the transition table and the risk gate; a manifest that could set state would route straight around both. epictask `dependsOn` edges are re-applied on every run, which is safe because the server treats a duplicate edge as a no-op. **Every feature ships smoke tests.** Adds the vertical: create an epic with mixed outcome/quality criteria, round-trip it through get -o yaml → apply -f, chain an epictask, then drive a task through the lifecycle asserting the risk gate blocks `done` without a passing QA verdict, that an illegal transition is refused, that history carries every transition and the verdict, and that reject without a comment fails. Skips when mcpd is unreachable; a failure against a reachable daemon means it predates the feature. The round-trip assertion is the interesting one: it feeds `get`'s own output straight back into `apply` and fails if the two surfaces have drifted, rather than trusting that they agree. Local: build clean, workspace 2457 passed, db 128 passed (same 4 pre-existing Secret failures), lint back to the repo's baseline 794 errors after replacing destructured discards with an explicit omit() helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
@@ -2891,9 +2891,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"137"
|
"137"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-09T18:56:11.740Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "139",
|
"id": "139",
|
||||||
@@ -3139,9 +3139,9 @@
|
|||||||
"128",
|
"128",
|
||||||
"131"
|
"131"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-09T18:56:11.750Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "156",
|
"id": "156",
|
||||||
@@ -3265,9 +3265,9 @@
|
|||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-08-08T16:00:25.475Z",
|
"lastModified": "2026-08-09T18:56:11.750Z",
|
||||||
"taskCount": 163,
|
"taskCount": 163,
|
||||||
"completedCount": 122,
|
"completedCount": 124,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"master"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -215,6 +215,76 @@ const McpTokenSpecSchema = z.object({
|
|||||||
bindings: z.array(RbacRoleBindingSchema).default([]),
|
bindings: z.array(RbacRoleBindingSchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Copy without the given keys — identity fields the PUT endpoints don't accept. */
|
||||||
|
function omit<T extends Record<string, unknown>>(obj: T, keys: string[]): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = { ...obj };
|
||||||
|
for (const k of keys) delete out[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agentic teams ──
|
||||||
|
// Ordering below matters: gitproviders → repos → epics → epictasks → tasks,
|
||||||
|
// because each references the previous by name.
|
||||||
|
|
||||||
|
const GitProviderSpecSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||||
|
url: z.string().url(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
credRef: z.object({ name: z.string(), key: z.string() }).nullable().optional(),
|
||||||
|
skipProbe: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CodeRepoSpecSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||||
|
project: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
url: z.string().min(1),
|
||||||
|
defaultBranch: z.string().optional(),
|
||||||
|
path: z.string().optional(),
|
||||||
|
provider: z.string().optional(),
|
||||||
|
credRef: z.object({ name: z.string(), key: z.string() }).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const DoneCriterionSpecSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
text: z.string().min(1),
|
||||||
|
kind: z.enum(['outcome', 'quality']).optional(),
|
||||||
|
met: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const EpicSpecSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||||
|
project: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
content: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
doneCriteria: z.array(DoneCriterionSpecSchema).optional(),
|
||||||
|
semver: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const EpicTaskSpecSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||||
|
epic: z.string().optional(),
|
||||||
|
epicId: z.string().optional(),
|
||||||
|
project: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
doneCriteria: z.array(DoneCriterionSpecSchema).optional(),
|
||||||
|
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
|
||||||
|
/** Prerequisite epictask names within the same Epic. */
|
||||||
|
dependsOn: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TaskSpecSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||||
|
epictask: z.string().optional(),
|
||||||
|
epicTaskId: z.string().optional(),
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
risk: z.enum(['trivial', 'normal', 'high']).optional(),
|
||||||
|
repoId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const ApplyConfigSchema = z.object({
|
const ApplyConfigSchema = z.object({
|
||||||
secretbackends: z.array(SecretBackendSpecSchema).default([]),
|
secretbackends: z.array(SecretBackendSpecSchema).default([]),
|
||||||
secrets: z.array(SecretSpecSchema).default([]),
|
secrets: z.array(SecretSpecSchema).default([]),
|
||||||
@@ -230,6 +300,11 @@ const ApplyConfigSchema = z.object({
|
|||||||
rbac: z.array(RbacBindingSpecSchema).default([]),
|
rbac: z.array(RbacBindingSpecSchema).default([]),
|
||||||
prompts: z.array(PromptSpecSchema).default([]),
|
prompts: z.array(PromptSpecSchema).default([]),
|
||||||
mcptokens: z.array(McpTokenSpecSchema).default([]),
|
mcptokens: z.array(McpTokenSpecSchema).default([]),
|
||||||
|
gitproviders: z.array(GitProviderSpecSchema).default([]),
|
||||||
|
repos: z.array(CodeRepoSpecSchema).default([]),
|
||||||
|
epics: z.array(EpicSpecSchema).default([]),
|
||||||
|
epictasks: z.array(EpicTaskSpecSchema).default([]),
|
||||||
|
tasks: z.array(TaskSpecSchema).default([]),
|
||||||
}).transform((data) => ({
|
}).transform((data) => ({
|
||||||
...data,
|
...data,
|
||||||
// Merge rbac into rbacBindings so both keys work
|
// Merge rbac into rbacBindings so both keys work
|
||||||
@@ -312,6 +387,11 @@ const KIND_TO_RESOURCE: Record<string, string> = {
|
|||||||
secretbackend: 'secretbackends',
|
secretbackend: 'secretbackends',
|
||||||
llm: 'llms',
|
llm: 'llms',
|
||||||
agent: 'agents',
|
agent: 'agents',
|
||||||
|
gitprovider: 'gitproviders',
|
||||||
|
repo: 'repos',
|
||||||
|
epic: 'epics',
|
||||||
|
epictask: 'epictasks',
|
||||||
|
task: 'tasks',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -591,6 +671,102 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Agentic teams ──
|
||||||
|
// Applied in dependency order: a repo needs its provider, an epictask needs
|
||||||
|
// its epic, a task needs its epictask. Each block is independently
|
||||||
|
// fault-tolerant so one bad entry does not abort the rest of the manifest.
|
||||||
|
|
||||||
|
for (const gp of config.gitproviders) {
|
||||||
|
try {
|
||||||
|
const existing = await cachedFindByName('gitproviders', gp.name);
|
||||||
|
if (existing) {
|
||||||
|
await withRetry(() => client.put(`/api/v1/gitproviders/${existing.id}`, omit(gp, ['name'])));
|
||||||
|
log(`Updated gitprovider: ${gp.name}`);
|
||||||
|
} else {
|
||||||
|
await withRetry(() => client.post('/api/v1/gitproviders', gp));
|
||||||
|
invalidateCache('gitproviders');
|
||||||
|
log(`Created gitprovider: ${gp.name}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`Error applying gitprovider '${gp.name}': ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const repo of config.repos) {
|
||||||
|
try {
|
||||||
|
const existing = await cachedFindByName('repos', repo.name);
|
||||||
|
if (existing) {
|
||||||
|
await withRetry(() => client.put(`/api/v1/repos/${existing.id}`, omit(repo, ['name', 'project', 'projectId'])));
|
||||||
|
log(`Updated repo: ${repo.name}`);
|
||||||
|
} else {
|
||||||
|
await withRetry(() => client.post('/api/v1/repos', repo));
|
||||||
|
invalidateCache('repos');
|
||||||
|
log(`Created repo: ${repo.name}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`Error applying repo '${repo.name}': ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const epic of config.epics) {
|
||||||
|
try {
|
||||||
|
const existing = await cachedFindByName('epics', epic.name);
|
||||||
|
if (existing) {
|
||||||
|
await withRetry(() => client.put(`/api/v1/epics/${existing.id}`, omit(epic, ['name', 'project', 'projectId'])));
|
||||||
|
log(`Updated epic: ${epic.name}`);
|
||||||
|
} else {
|
||||||
|
await withRetry(() => client.post('/api/v1/epics', epic));
|
||||||
|
invalidateCache('epics');
|
||||||
|
log(`Created epic: ${epic.name}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`Error applying epic '${epic.name}': ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const et of config.epictasks) {
|
||||||
|
try {
|
||||||
|
const existing = await cachedFindByName('epictasks', et.name);
|
||||||
|
const { dependsOn, ...spec } = et;
|
||||||
|
let id: string;
|
||||||
|
if (existing) {
|
||||||
|
await withRetry(() => client.put(`/api/v1/epictasks/${existing.id}`, omit(spec, ['name', 'epic', 'epicId', 'project'])));
|
||||||
|
id = existing.id;
|
||||||
|
log(`Updated epictask: ${et.name}`);
|
||||||
|
} else {
|
||||||
|
const created = await withRetry(() => client.post<{ id: string }>('/api/v1/epictasks', spec));
|
||||||
|
invalidateCache('epictasks');
|
||||||
|
id = created.id;
|
||||||
|
log(`Created epictask: ${et.name}`);
|
||||||
|
}
|
||||||
|
// Edges are idempotent server-side, so re-applying a manifest is safe.
|
||||||
|
for (const dep of dependsOn ?? []) {
|
||||||
|
await withRetry(() => client.post(`/api/v1/epictasks/${id}/edges`, { to: dep }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`Error applying epictask '${et.name}': ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of config.tasks) {
|
||||||
|
try {
|
||||||
|
const existing = await cachedFindByName('tasks', task.name);
|
||||||
|
if (existing) {
|
||||||
|
// `state` is deliberately absent from the spec: lifecycle moves go
|
||||||
|
// through `mcpctl tasks advance`, which enforces the transition table.
|
||||||
|
// A manifest that could set state would route around that.
|
||||||
|
await withRetry(() => client.put(`/api/v1/tasks/${existing.id}`, omit(task, ['name', 'epictask', 'epicTaskId'])));
|
||||||
|
log(`Updated task: ${task.name}`);
|
||||||
|
} else {
|
||||||
|
await withRetry(() => client.post('/api/v1/tasks', task));
|
||||||
|
invalidateCache('tasks');
|
||||||
|
log(`Created task: ${task.name}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`Error applying task '${task.name}': ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply RBAC bindings
|
// Apply RBAC bindings
|
||||||
for (const rbacBinding of config.rbacBindings) {
|
for (const rbacBinding of config.rbacBindings) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function createEditCommand(deps: EditCommandDeps): Command {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests', 'personalities'];
|
const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests', 'personalities', 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders'];
|
||||||
if (!validResources.includes(resource)) {
|
if (!validResources.includes(resource)) {
|
||||||
log(`Error: unknown resource type '${resourceArg}'`);
|
log(`Error: unknown resource type '${resourceArg}'`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
172
src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts
Normal file
172
src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Smoke: the agentic-teams vertical against a live mcpd.
|
||||||
|
*
|
||||||
|
* gitprovider → discover → import → epic → epictask → task → lifecycle
|
||||||
|
*
|
||||||
|
* Also enforces the project rule that `get <resource> <name> -o yaml` must
|
||||||
|
* round-trip through `apply -f`: the flow re-applies what it read back, and a
|
||||||
|
* drift there means the two surfaces have diverged.
|
||||||
|
*
|
||||||
|
* Skips (does not fail) when mcpd is unreachable. A FAILURE against a reachable
|
||||||
|
* daemon means the daemon predates this feature — deploy it, do not relax the
|
||||||
|
* assertion.
|
||||||
|
*/
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
|
||||||
|
const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu';
|
||||||
|
const SUFFIX = Date.now().toString(36);
|
||||||
|
const PROJECT = `smoke-at-${SUFFIX}`;
|
||||||
|
const EPIC = `smoke-epic-${SUFFIX}`;
|
||||||
|
const EPICTASK = `smoke-et-${SUFFIX}`;
|
||||||
|
const TASK = `smoke-task-${SUFFIX}`;
|
||||||
|
|
||||||
|
interface CliResult { code: number; stdout: string; stderr: string }
|
||||||
|
|
||||||
|
function run(args: string): CliResult {
|
||||||
|
try {
|
||||||
|
const stdout = execSync(`mcpctl --direct ${args}`, {
|
||||||
|
encoding: 'utf-8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
return { code: 0, stdout: stdout.trim(), stderr: '' };
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
|
||||||
|
const str = (v: Buffer | string | undefined): string =>
|
||||||
|
v === undefined ? '' : typeof v === 'string' ? v : v.toString('utf-8');
|
||||||
|
return { code: e.status ?? 1, stdout: str(e.stdout), stderr: str(e.stderr) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthz(url: string, timeoutMs = 5000): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`);
|
||||||
|
const driver = parsed.protocol === 'https:' ? https : http;
|
||||||
|
const req = driver.get({
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: parsed.pathname,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
}, (res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); });
|
||||||
|
req.on('error', () => resolve(false));
|
||||||
|
req.on('timeout', () => { req.destroy(); resolve(false); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let up = false;
|
||||||
|
let workDir: string | null = null;
|
||||||
|
|
||||||
|
describe('agentic teams smoke', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
up = await healthz(MCPD_URL);
|
||||||
|
if (!up) {
|
||||||
|
console.warn(`\n ○ agentic-teams smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workDir = mkdtempSync(join(tmpdir(), 'mcpctl-smoke-at-'));
|
||||||
|
const res = run(`create project ${PROJECT} --description "agentic teams smoke"`);
|
||||||
|
expect(res.code, `project create failed: ${res.stderr || res.stdout}`).toBe(0);
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (up) {
|
||||||
|
run(`delete task ${TASK}`);
|
||||||
|
run(`delete epictask ${EPICTASK}`);
|
||||||
|
run(`delete epic ${EPIC}`);
|
||||||
|
run(`delete project ${PROJECT}`);
|
||||||
|
}
|
||||||
|
if (workDir !== null) rmSync(workDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an epic with mixed outcome and quality criteria', () => {
|
||||||
|
if (!up) return;
|
||||||
|
const res = run(
|
||||||
|
`create epic ${EPIC} --project ${PROJECT} --content "ship the thing" ` +
|
||||||
|
`--criterion "builds=it builds" --criterion "q:feel=it feels finished"`,
|
||||||
|
);
|
||||||
|
expect(res.code, res.stderr || res.stdout).toBe(0);
|
||||||
|
|
||||||
|
const list = run(`get epics --project ${PROJECT} -o json`);
|
||||||
|
const epics = JSON.parse(list.stdout) as Array<{ name: string; completeness?: { total: number; percent: number } }>;
|
||||||
|
const mine = epics.find((e) => e.name === EPIC);
|
||||||
|
// Both kinds count — grading only the mechanical half is the failure the
|
||||||
|
// Epic exists to prevent.
|
||||||
|
expect(mine?.completeness?.total).toBe(2);
|
||||||
|
expect(mine?.completeness?.percent).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips an epic through get -o yaml → apply -f', () => {
|
||||||
|
if (!up) return;
|
||||||
|
const yaml = run(`get epic ${EPIC} -o yaml`);
|
||||||
|
expect(yaml.code, yaml.stderr).toBe(0);
|
||||||
|
|
||||||
|
const path = join(workDir as string, 'epic.yaml');
|
||||||
|
writeFileSync(path, yaml.stdout, 'utf-8');
|
||||||
|
|
||||||
|
// The project rule: what `get -o yaml` emits must be valid `apply -f` input.
|
||||||
|
const applied = run(`apply -f ${path}`);
|
||||||
|
expect(applied.code, `apply rejected its own get output: ${applied.stderr || applied.stdout}`).toBe(0);
|
||||||
|
expect(applied.stdout).toMatch(new RegExp(`(Updated|Created) epic: ${EPIC}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chains an epictask and exposes the roadmap', () => {
|
||||||
|
if (!up) return;
|
||||||
|
expect(run(`create epictask ${EPICTASK} --epic ${EPIC}`).code).toBe(0);
|
||||||
|
|
||||||
|
const list = run(`get epictasks -o json`);
|
||||||
|
const nodes = JSON.parse(list.stdout) as Array<{ name: string; status: string }>;
|
||||||
|
expect(nodes.find((n) => n.name === EPICTASK)?.status).toBe('todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drives a task through the lifecycle, with the risk gate enforced', () => {
|
||||||
|
if (!up) return;
|
||||||
|
expect(run(`create task ${TASK} --epictask ${EPICTASK} --title "smoke" --risk normal`).code).toBe(0);
|
||||||
|
|
||||||
|
for (const state of ['in_progress', 'building', 'self_testing']) {
|
||||||
|
const r = run(`tasks advance ${TASK} ${state}`);
|
||||||
|
expect(r.code, `${state}: ${r.stderr || r.stdout}`).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// normal risk requires a passing QA verdict before done.
|
||||||
|
const blocked = run(`tasks advance ${TASK} done`);
|
||||||
|
expect(blocked.code).not.toBe(0);
|
||||||
|
expect(`${blocked.stderr}${blocked.stdout}`).toMatch(/passing verdict from QA/);
|
||||||
|
|
||||||
|
expect(run(`tasks advance ${TASK} qa_review`).code).toBe(0);
|
||||||
|
expect(run(
|
||||||
|
`tasks verdict ${TASK} --stage qa_review --commit abc1234 --pass --summary "looks good"`,
|
||||||
|
).code).toBe(0);
|
||||||
|
expect(run(`tasks advance ${TASK} done`).code, 'done should pass once QA passed').toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an illegal transition rather than silently allowing it', () => {
|
||||||
|
if (!up) return;
|
||||||
|
const r = run(`tasks advance ${TASK} building`);
|
||||||
|
expect(r.code).not.toBe(0);
|
||||||
|
expect(`${r.stderr}${r.stdout}`).toMatch(/Illegal transition/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the full history: every transition plus both verdict outcomes', () => {
|
||||||
|
if (!up) return;
|
||||||
|
const r = run(`tasks history ${TASK}`);
|
||||||
|
expect(r.code, r.stderr).toBe(0);
|
||||||
|
expect(r.stdout).toContain('(new) → todo');
|
||||||
|
expect(r.stdout).toContain('self_testing → qa_review');
|
||||||
|
expect(r.stdout).toMatch(/PASS\s+qa_review/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a comment to reject — the comment is the direction', () => {
|
||||||
|
if (!up) return;
|
||||||
|
expect(run(`tasks advance ${TASK} awaiting_review`).code).toBe(0);
|
||||||
|
const noNote = run(`tasks reject ${TASK}`);
|
||||||
|
expect(noNote.code, 'reject without --note must fail').not.toBe(0);
|
||||||
|
|
||||||
|
const withNote = run(`tasks reject ${TASK} --note "needs a test"`);
|
||||||
|
expect(withNote.code, withNote.stderr || withNote.stdout).toBe(0);
|
||||||
|
expect(withNote.stdout).toMatch(/rejected → in_progress/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user