diff --git a/README.md b/README.md index 568f786..c8e715c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,12 @@ Validate your Linear API key without touching any of your data: LINEAR_API_KEY=lin_api_… npx human-handoff-linear doctor ``` +Or provision the workspace-level `Human Handoff` issue template from the versioned, public-safe markdown body. Idempotent: creates the template the first time, updates it when the bundled body drifts, reports no-change when already in sync. + +```bash +LINEAR_API_KEY=lin_api_… npx human-handoff-linear sync-template +``` + ### [`llm-cost-attribution`](packages/llm-cost-attribution) Per-issue token, turn, and quota analytics for Claude Code and Codex CLI sessions. Reads the CLIs' own session JSONLs — no custom telemetry pipeline required. diff --git a/packages/human-handoff-linear/README.md b/packages/human-handoff-linear/README.md index 3f97d76..155bf6f 100644 --- a/packages/human-handoff-linear/README.md +++ b/packages/human-handoff-linear/README.md @@ -3,11 +3,15 @@ Linear workflow primitives for installing and maintaining the Human Handoff issue template used by autonomous project workflows. -This package is still a contract shell — most commands (`setup`, -`sync-template`, `bootstrap-project`) are dry-run/no-op scaffolds awaiting -later issues. The `doctor` command, however, performs a real Linear auth check -through the GraphQL adapter, and the underlying `LinearWorkspace` adapter -implements the full surface those later commands will use. +Today the package ships two real Linear commands: + +- `doctor` — read-only auth and viewer/organization check. +- `sync-template` — provision the workspace-level `Human Handoff` issue + template idempotently: create it if missing, update its body when the + bundled markdown drifts, report no-change when already in sync. + +`setup` and `bootstrap-project` remain scaffold-only and perform no Linear +mutations; later issues will wire them to the same `LinearWorkspace` adapter. ## Requirements @@ -39,8 +43,35 @@ Subcommands: - `doctor` — validate the Linear API token by fetching the current viewer and workspace. Read-only: never creates or updates templates, labels, issues, or relations. -- `setup`, `sync-template`, `bootstrap-project` — scaffold-only today; reserved - command surfaces that later issues will wire to real Linear mutations. +- `sync-template` — create or update the workspace-level `Human Handoff` issue + template idempotently. Pass `--dry-run` to plan without writing. +- `setup`, `bootstrap-project` — scaffold-only today; reserved command surfaces + that later issues will wire to real Linear mutations. + +### `sync-template` + +```text +$ human-handoff-linear sync-template +human-handoff-linear sync-template - syncing "Human Handoff" workspace template +Created workspace template "Human Handoff" (id: tpl_…) +human-handoff-linear sync-template complete - create performed (id: tpl_…) +``` + +Run it once to install the template; run it again after editing +`templates/human-handoff-issue-body.md` to push the new body. When the body +already matches what is in Linear, `sync-template` reports `no change` and +performs no write. It is safe to run from CI on every push. + +```text +$ human-handoff-linear sync-template --dry-run # plan-only, no writes +human-handoff-linear sync-template - syncing "Human Handoff" workspace template +[dry-run] Would update workspace template "Human Handoff" (id: tpl_…) +human-handoff-linear sync-template complete - update planned +``` + +The same Linear error codes that `doctor` returns also apply to +`sync-template` (auth → 3, permission → 4, rate-limit → 5, network → 6, other +API errors → 7). ### `doctor` @@ -70,22 +101,30 @@ The CLI adapter is deliberately thin. Core behavior is available as use-case modules with injected ports: ```js +import { readFile } from 'node:fs/promises'; import { - createBootstrapProjectUseCase, createDoctorUseCase, createLinearGraphqlWorkspace, - createSetupUseCase, createSyncTemplateUseCase, } from 'human-handoff-linear'; const workspace = createLinearGraphqlWorkspace({ apiKey: process.env.LINEAR_API_KEY }); +const reporter = { info: console.log, error: console.error }; + +// Read-only auth check const doctor = createDoctorUseCase({ - reporter: { info: console.log, error: console.error }, + reporter, secretReader: { read: (name) => process.env[name] }, workspace, }); -const result = await doctor(); -if (!result.ok) process.exit(1); +const auth = await doctor(); +if (!auth.ok) process.exit(1); + +// Idempotent template sync +const templateBody = await readFile('./templates/human-handoff-issue-body.md', 'utf8'); +const sync = createSyncTemplateUseCase({ reporter, templateBody, workspace }); +const result = await sync({ dryRun: false }); +// → { action: 'create' | 'update' | 'no-change', templateId, mutationsPerformed, ... } ``` Ports are plain objects: diff --git a/packages/human-handoff-linear/src/adapters/linear-graphql-workspace.mjs b/packages/human-handoff-linear/src/adapters/linear-graphql-workspace.mjs index cc1bbd3..2964a39 100644 --- a/packages/human-handoff-linear/src/adapters/linear-graphql-workspace.mjs +++ b/packages/human-handoff-linear/src/adapters/linear-graphql-workspace.mjs @@ -179,25 +179,39 @@ export function createLinearGraphqlWorkspace({ ); return data?.template ? normalizeTemplate(data.template) : null; } - if (!teamId || !name) { - throw new TypeError('getTemplate requires { id } or { teamId, name }.'); + if (!name) { + throw new TypeError('getTemplate requires { id } or { name } (optionally scoped with { teamId }).'); } + if (teamId) { + const data = await graphql( + `query TeamTemplates($teamId: String!) { + team(id: $teamId) { + templates { nodes { id name description type teamId } } + } + }`, + { teamId }, + ); + const nodes = data?.team?.templates?.nodes ?? []; + const match = nodes.find((n) => n.name === name); + return match ? normalizeTemplate(match) : null; + } + // Workspace-level: list every template the viewer can see and filter by + // name + type === 'issue', dropping any team-scoped templates so a + // same-named team template does not shadow a missing workspace one. const data = await graphql( - `query TeamTemplates($teamId: String!) { - team(id: $teamId) { - templates { nodes { id name description type teamId } } - } + `query WorkspaceTemplates { + templates { id name description type teamId } }`, - { teamId }, ); - const nodes = data?.team?.templates?.nodes ?? []; - const match = nodes.find((n) => n.name === name); + const nodes = Array.isArray(data?.templates) ? data.templates : []; + const match = nodes.find((n) => n.name === name && n.type === 'issue' && !n.teamId); return match ? normalizeTemplate(match) : null; }, async createTemplate({ teamId, name, description, type = 'issue' } = {}) { - if (!teamId) throw new TypeError('createTemplate requires { teamId }.'); if (!name) throw new TypeError('createTemplate requires { name }.'); + const input = { name, description, type }; + if (teamId) input.teamId = teamId; const data = await graphql( `mutation CreateTemplate($input: TemplateCreateInput!) { templateCreate(input: $input) { @@ -205,7 +219,7 @@ export function createLinearGraphqlWorkspace({ template { id name description type teamId } } }`, - { input: { teamId, name, description, type } }, + { input }, ); const result = data?.templateCreate; if (!result?.success || !result.template) { diff --git a/packages/human-handoff-linear/src/cli/run-cli.mjs b/packages/human-handoff-linear/src/cli/run-cli.mjs index 8191f80..4fae730 100644 --- a/packages/human-handoff-linear/src/cli/run-cli.mjs +++ b/packages/human-handoff-linear/src/cli/run-cli.mjs @@ -56,6 +56,19 @@ export async function runCli({ argv, env, stdout, stderr, stdin = null, fetch: f }); } + if (command === 'sync-template') { + return runSyncTemplateCommand({ + options: parsed.options, + env, + stdin, + stdout, + stderr, + reporter, + fetchImpl, + workspaceFactory, + }); + } + const secretReader = createEnvironmentSecretReader(env); const workspace = createNoopLinearWorkspace(); @@ -70,6 +83,39 @@ export async function runCli({ argv, env, stdout, stderr, stdin = null, fetch: f } } +async function runSyncTemplateCommand({ options, env, stdin, stdout, stderr, reporter, fetchImpl, workspaceFactory }) { + const secretReader = createInteractiveSecretReader({ env, stdin, stdout }); + + let apiKey = null; + try { + apiKey = await secretReader.readLinearApiKey({ interactive: !options.noPrompt }); + } catch (err) { + reporter.error(`human-handoff-linear sync-template failed - ${err.message}`); + return 1; + } + if (!apiKey) { + reporter.error('human-handoff-linear sync-template failed - LINEAR_API_KEY is not set.'); + reporter.error('Export LINEAR_API_KEY or rerun without --no-prompt to be prompted.'); + return exitCodeFor('missing_token'); + } + + const factory = workspaceFactory ?? (({ apiKey: key }) => createLinearGraphqlWorkspace({ apiKey: key, fetch: fetchImpl })); + const workspace = factory({ apiKey }); + + const templateBody = await readFile(TEMPLATE_PATH, 'utf8'); + try { + const result = await createSyncTemplateUseCase({ reporter, templateBody, workspace })(options); + const suffix = result.mutationsPerformed === 1 + ? `${result.action} performed (id: ${result.templateId})` + : (result.action === 'no-change' ? `no change (id: ${result.templateId})` : `${result.action} planned`); + stdout.write(`human-handoff-linear sync-template complete - ${suffix}\n`); + return 0; + } catch (err) { + reporter.error(`human-handoff-linear sync-template failed - ${err.message}`); + return exitCodeFor(err); + } +} + async function runDoctorCommand({ options, env, stdin, stdout, stderr, reporter, fetchImpl, workspaceFactory }) { const secretReader = createInteractiveSecretReader({ env, stdin, stdout }); @@ -129,7 +175,7 @@ async function dispatchCommand({ command, options, reporter, secretReader, works function parseArgs(argv) { const args = [...argv]; - const options = { dryRun: true, quiet: false, verbose: false, team: null, noPrompt: false }; + const options = { dryRun: false, quiet: false, verbose: false, team: null, noPrompt: false }; let help = false; let command = null; @@ -139,6 +185,7 @@ function parseArgs(argv) { if (arg === '--quiet' || arg === '-q') { options.quiet = true; continue; } if (arg === '--verbose' || arg === '-v') { options.verbose = true; continue; } if (arg === '--no-prompt') { options.noPrompt = true; continue; } + if (arg === '--dry-run') { options.dryRun = true; continue; } if (arg === '--team') { if (args[i + 1] === undefined) return { options, error: '--team requires a value' }; options.team = args[i + 1]; @@ -151,6 +198,13 @@ function parseArgs(argv) { return { options, error: `Unexpected argument: ${arg}` }; } + // Scaffold commands (setup, bootstrap-project) default to dry-run / no-op + // because their use cases have not yet been implemented. sync-template + // performs real Linear writes by default unless the caller passes --dry-run. + if (command === 'setup' || command === 'bootstrap-project') { + options.dryRun = true; + } + return { command, help, options }; } @@ -169,6 +223,8 @@ Commands: ${rows} Options: + --dry-run Plan only — print the action sync-template would take + without calling any Linear write mutation. --team Linear team key for future setup/bootstrap operations --no-prompt Disable interactive prompts (for CI / non-TTY environments) -q, --quiet Print only final outcome and errors @@ -176,14 +232,19 @@ Options: -h, --help Show this help Auth: - doctor reads the Linear API key from LINEAR_API_KEY. When unset and stdin is - a TTY, it prompts for the key without echoing. Pass --no-prompt to skip the - prompt and exit with a missing-token error instead. + doctor and sync-template read the Linear API key from LINEAR_API_KEY. When + unset and stdin is a TTY, the CLI prompts for the key without echoing. Pass + --no-prompt to skip the prompt and exit with a missing-token error instead. Create a personal API key at https://linear.app/settings/api. -Current scaffold behavior: - setup, sync-template, and bootstrap-project validate routing and contracts - only. No Linear mutations are performed by those commands. +Mutations: + sync-template performs real Linear writes — it creates the "Human Handoff" + workspace issue template if missing, updates it if the body drifted, and + reports no change when already in sync. Pass --dry-run to plan without + writing. + + No Linear mutations are performed by the setup or bootstrap-project scaffold + commands; they remain dry-run placeholders awaiting later issues. `; } diff --git a/packages/human-handoff-linear/src/ports.mjs b/packages/human-handoff-linear/src/ports.mjs index fd3031a..3ee7b34 100644 --- a/packages/human-handoff-linear/src/ports.mjs +++ b/packages/human-handoff-linear/src/ports.mjs @@ -65,7 +65,7 @@ * @property {(input?: { teamId?: string }) => Promise=} listLabels * @property {(input: { teamId: string, name: string, color?: string, description?: string }) => Promise=} createLabel * @property {(input: { id?: string, teamId?: string, name?: string }) => Promise=} getTemplate - * @property {(input: { teamId: string, name: string, description?: string, type?: string }) => Promise=} createTemplate + * @property {(input: { teamId?: string, name: string, description?: string, type?: string }) => Promise=} createTemplate * @property {(input: { id: string, name?: string, description?: string }) => Promise=} updateTemplate * @property {(input: { teamId: string, title: string, description?: string, labelIds?: string[], templateId?: string, projectId?: string }) => Promise=} createIssue * @property {(input: { issueId: string, relatedIssueId: string, type?: string }) => Promise=} createRelation diff --git a/packages/human-handoff-linear/src/use-cases/define-human-handoff-linear-package-contract.mjs b/packages/human-handoff-linear/src/use-cases/define-human-handoff-linear-package-contract.mjs index 9a3cf8b..2a7c233 100644 --- a/packages/human-handoff-linear/src/use-cases/define-human-handoff-linear-package-contract.mjs +++ b/packages/human-handoff-linear/src/use-cases/define-human-handoff-linear-package-contract.mjs @@ -5,7 +5,7 @@ export const HUMAN_HANDOFF_LINEAR_COMMANDS = Object.freeze([ }), Object.freeze({ name: 'sync-template', - summary: 'Report the checked-in Human Handoff template that future work will sync.', + summary: 'Create or update the Human Handoff workspace template idempotently (--dry-run plans without writing).', }), Object.freeze({ name: 'doctor', diff --git a/packages/human-handoff-linear/src/use-cases/sync-template.mjs b/packages/human-handoff-linear/src/use-cases/sync-template.mjs index 0ec3ace..e5f5646 100644 --- a/packages/human-handoff-linear/src/use-cases/sync-template.mjs +++ b/packages/human-handoff-linear/src/use-cases/sync-template.mjs @@ -1,21 +1,82 @@ import { createHumanHandoffTemplateBody, createSetupCommand } from '../values.mjs'; +/** + * SyncHumanHandoffTemplate — create / update the workspace-level Linear issue + * template named `Human Handoff`, idempotently. + * + * Pure application policy: depends only on the injected `LinearWorkspace` + * port and on the desired body string. No HTTP, no filesystem, no GraphQL. + * + * Input shape: + * - `dryRun` (default `false`): when `true`, compute the plan and skip + * every Linear write mutation. `--dry-run` from the CLI ends up here. + * - `name` (default `'Human Handoff'`): the template name to sync. + * + * Output shape: + * - `command` — the resolved SetupCommand record (carries `dryRun`). + * - `action` — `'create' | 'update' | 'no-change'`. + * - `template.body` — the validated body that was/would be synced. + * - `templateId` — Linear template id when known (`null` on dry-run create). + * - `mutationsPerformed` — `0 | 1`; always `0` on dry-run or no-change. + * + * Adapter contract: the `workspace` port must implement `getTemplate({ name })`, + * `createTemplate({ name, description, type })`, and + * `updateTemplate({ id, description })` against workspace-level templates. + */ export function createSyncTemplateUseCase({ reporter, templateBody, workspace }) { return async function syncTemplate(input = {}) { const command = createSetupCommand('sync-template', input); const template = createHumanHandoffTemplateBody(templateBody); + const name = input.name ?? 'Human Handoff'; + const dryRun = input.dryRun === true; - reporter.info('human-handoff-linear sync-template - validating checked-in template'); - reporter.info('No Linear template write will be performed by this scaffold.'); + requirePortMethod(workspace, 'getTemplate', 'sync-template'); - if (workspace?.syncHumanHandoffTemplate && input.dryRun === false) { - throw new Error('Real Linear template sync is out of scope for this package contract scaffold.'); - } + reporter.info(`human-handoff-linear sync-template - syncing "${name}" workspace template`); + + const existing = await workspace.getTemplate({ name }); - return Object.freeze({ - command, - template, - mutationsPerformed: 0, - }); + if (existing === null || existing === undefined) { + return runCreate({ name, body: template.body, workspace, reporter, command, template, dryRun }); + } + if (existing.description === template.body) { + reporter.info(`No change - workspace template "${name}" already matches (id: ${existing.id})`); + return Object.freeze({ + command, + action: 'no-change', + template, + templateId: existing.id, + mutationsPerformed: 0, + }); + } + return runUpdate({ existing, name, body: template.body, workspace, reporter, command, template, dryRun }); }; } + +async function runCreate({ name, body, workspace, reporter, command, template, dryRun }) { + if (dryRun) { + reporter.info(`[dry-run] Would create workspace template "${name}"`); + return Object.freeze({ command, action: 'create', template, templateId: null, mutationsPerformed: 0 }); + } + requirePortMethod(workspace, 'createTemplate', 'sync-template'); + const created = await workspace.createTemplate({ name, description: body, type: 'issue' }); + reporter.info(`Created workspace template "${name}" (id: ${created.id})`); + return Object.freeze({ command, action: 'create', template, templateId: created.id, mutationsPerformed: 1 }); +} + +async function runUpdate({ existing, name, body, workspace, reporter, command, template, dryRun }) { + if (dryRun) { + reporter.info(`[dry-run] Would update workspace template "${name}" (id: ${existing.id})`); + return Object.freeze({ command, action: 'update', template, templateId: existing.id, mutationsPerformed: 0 }); + } + requirePortMethod(workspace, 'updateTemplate', 'sync-template'); + const updated = await workspace.updateTemplate({ id: existing.id, description: body }); + reporter.info(`Updated workspace template "${name}" (id: ${updated.id})`); + return Object.freeze({ command, action: 'update', template, templateId: updated.id, mutationsPerformed: 1 }); +} + +function requirePortMethod(workspace, method, useCase) { + if (workspace === null || workspace === undefined || typeof workspace[method] !== 'function') { + throw new TypeError(`${useCase} requires a LinearWorkspace port with a ${method}() method`); + } +} diff --git a/packages/human-handoff-linear/tests/cli.test.mjs b/packages/human-handoff-linear/tests/cli.test.mjs index 8fd7a9a..1904488 100644 --- a/packages/human-handoff-linear/tests/cli.test.mjs +++ b/packages/human-handoff-linear/tests/cli.test.mjs @@ -143,3 +143,92 @@ test('setup still routes through the no-op workspace path and exits 0', async () assert.equal(code, 0); assert.match(output(), /no mutations performed/); }); + +// --------- sync-template CLI wiring tests --------- + +function templateWorkspace({ existing = null, createId = 'tpl_new', updateId = 'tpl_upd' } = {}) { + return { + describe: () => ({ connected: true }), + getViewer: async () => ({ viewer: { id: 'u', name: 'Ada' }, organization: { id: 'o', name: 'Riddim', urlKey: 'riddim' } }), + listTeams: async () => [], + listLabels: async () => [], + createLabel: async () => { throw new Error('sync-template should not create labels'); }, + getTemplate: async () => existing, + createTemplate: async (input) => ({ id: createId, name: input.name, description: input.description, type: input.type ?? 'issue', teamId: null }), + updateTemplate: async (input) => ({ id: input.id ?? updateId, name: 'Human Handoff', description: input.description, type: 'issue', teamId: null }), + createIssue: async () => { throw new Error('sync-template should not create issues'); }, + createRelation: async () => { throw new Error('sync-template should not create relations'); }, + syncHumanHandoffTemplate: async () => { throw new Error('not used by sync-template'); }, + bootstrapHumanHandoffProject: async () => { throw new Error('not used by sync-template'); }, + }; +} + +test('sync-template: missing LINEAR_API_KEY exits with missing-token (exit 2)', async () => { + const { stdout, stderr } = captureStreams(); + const code = await runCli({ + argv: ['sync-template', '--no-prompt'], + env: {}, + stdout, stderr, + }); + assert.equal(code, 2); +}); + +test('sync-template: creates the template, exits 0, reports the action', async () => { + const { stdout, stderr, output } = captureStreams(); + const code = await runCli({ + argv: ['sync-template', '--no-prompt'], + env: { LINEAR_API_KEY: 'lin_fake' }, + stdout, stderr, + workspaceFactory: () => templateWorkspace({ existing: null, createId: 'tpl_brand' }), + }); + assert.equal(code, 0); + assert.match(output(), /sync-template complete - create performed/); + assert.match(output(), /tpl_brand/); +}); + +test('sync-template: --dry-run reports the planned action without performing mutations', async () => { + const { stdout, stderr, output } = captureStreams(); + const code = await runCli({ + argv: ['sync-template', '--no-prompt', '--dry-run'], + env: { LINEAR_API_KEY: 'lin_fake' }, + stdout, stderr, + workspaceFactory: () => templateWorkspace({ existing: null }), + }); + assert.equal(code, 0); + assert.match(output(), /sync-template complete - create planned/); +}); + +test('sync-template: no-change when existing description already matches', async () => { + const body = await (await import('node:fs/promises')).readFile( + new URL('../templates/human-handoff-issue-body.md', import.meta.url), + 'utf8', + ); + const { stdout, stderr, output } = captureStreams(); + const code = await runCli({ + argv: ['sync-template', '--no-prompt'], + env: { LINEAR_API_KEY: 'lin_fake' }, + stdout, stderr, + workspaceFactory: () => templateWorkspace({ + existing: { id: 'tpl_in_sync', name: 'Human Handoff', description: body.trimEnd(), type: 'issue', teamId: null }, + }), + }); + assert.equal(code, 0); + assert.match(output(), /sync-template complete - no change/); + assert.match(output(), /tpl_in_sync/); +}); + +test('sync-template: API failure maps to a non-zero exit', async () => { + const { LinearAuthError } = await import('../src/errors.mjs'); + const { stdout, stderr, errors } = captureStreams(); + const code = await runCli({ + argv: ['sync-template', '--no-prompt'], + env: { LINEAR_API_KEY: 'lin_fake' }, + stdout, stderr, + workspaceFactory: () => ({ + ...templateWorkspace(), + getTemplate: async () => { throw new LinearAuthError('rejected'); }, + }), + }); + assert.equal(code, 3, 'auth errors map to exit 3'); + assert.match(errors(), /sync-template failed/); +}); diff --git a/packages/human-handoff-linear/tests/linear-graphql-workspace.test.mjs b/packages/human-handoff-linear/tests/linear-graphql-workspace.test.mjs index 05adf93..4cbf2c2 100644 --- a/packages/human-handoff-linear/tests/linear-graphql-workspace.test.mjs +++ b/packages/human-handoff-linear/tests/linear-graphql-workspace.test.mjs @@ -262,6 +262,50 @@ test('getTemplate without id/teamId+name throws TypeError', async () => { await assert.rejects(ws.getTemplate({ teamId: 't1' }), TypeError); }); +test('getTemplate by name (no teamId) finds the workspace-level template', async () => { + const fetch = recordingFetch(() => jsonResponse({ + data: { templates: [ + { id: 'tpl_a', name: 'Bug Report', description: '', type: 'issue', teamId: 't1' }, + { id: 'tpl_b', name: 'Human Handoff', description: 'body', type: 'issue', teamId: null }, + { id: 'tpl_c', name: 'Human Handoff', description: 'team body', type: 'issue', teamId: 't1' }, + ] }, + })); + const ws = createLinearGraphqlWorkspace({ apiKey: FAKE_KEY, fetch }); + const tpl = await ws.getTemplate({ name: 'Human Handoff' }); + const body = JSON.parse(fetch.calls[0].init.body); + assert.match(body.query, /templates/); + assert.equal(body.variables ?? undefined, undefined); + assert.deepEqual(tpl, { id: 'tpl_b', name: 'Human Handoff', description: 'body', type: 'issue', teamId: null }); +}); + +test('getTemplate by name returns null when no workspace template matches', async () => { + const fetch = async () => jsonResponse({ + data: { templates: [ + { id: 'tpl_c', name: 'Human Handoff', description: '', type: 'issue', teamId: 't1' }, + ] }, + }); + const ws = createLinearGraphqlWorkspace({ apiKey: FAKE_KEY, fetch }); + assert.equal(await ws.getTemplate({ name: 'Human Handoff' }), null); +}); + +test('createTemplate without teamId creates a workspace-level template', async () => { + const fetch = recordingFetch(() => jsonResponse({ + data: { templateCreate: { success: true, template: { id: 'tpl_w', name: 'Human Handoff', description: 'd', type: 'issue', teamId: null } } }, + })); + const ws = createLinearGraphqlWorkspace({ apiKey: FAKE_KEY, fetch }); + const tpl = await ws.createTemplate({ name: 'Human Handoff', description: 'd' }); + const body = JSON.parse(fetch.calls[0].init.body); + assert.equal(body.variables.input.name, 'Human Handoff'); + assert.equal(body.variables.input.teamId, undefined, 'teamId is omitted for workspace-level'); + assert.equal(tpl.teamId, null); +}); + +test('createTemplate requires { name }', async () => { + const ws = createLinearGraphqlWorkspace({ apiKey: FAKE_KEY, fetch: async () => jsonResponse({}) }); + await assert.rejects(ws.createTemplate({}), TypeError); + await assert.rejects(ws.createTemplate({ teamId: 't1' }), TypeError); +}); + test('createTemplate posts input and returns normalized template', async () => { const fetch = recordingFetch(() => jsonResponse({ data: { templateCreate: { success: true, template: { id: 'tpl_new', name: 'Human Handoff', description: 'd', type: 'issue', teamId: 't1' } } }, diff --git a/packages/human-handoff-linear/tests/sync-template.test.mjs b/packages/human-handoff-linear/tests/sync-template.test.mjs new file mode 100644 index 0000000..42b1f88 --- /dev/null +++ b/packages/human-handoff-linear/tests/sync-template.test.mjs @@ -0,0 +1,252 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { createSyncTemplateUseCase } from '../src/use-cases/sync-template.mjs'; +import { LinearApiError, LinearAuthError } from '../src/errors.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEMPLATE_PATH = resolve(__dirname, '..', 'templates', 'human-handoff-issue-body.md'); + +function memoryReporter() { + const messages = []; + return { + messages, + reporter: { + info(message) { messages.push({ level: 'info', message }); }, + error(message) { messages.push({ level: 'error', message }); }, + }, + }; +} + +function fakeWorkspace({ existingTemplate = null, createId = 'tpl_new', updateId = 'tpl_upd' } = {}) { + const calls = { getTemplate: [], createTemplate: [], updateTemplate: [] }; + return { + calls, + async getTemplate(input) { + calls.getTemplate.push(input); + return existingTemplate; + }, + async createTemplate(input) { + calls.createTemplate.push(input); + return { id: createId, name: input.name, description: input.description, type: input.type ?? 'issue', teamId: null }; + }, + async updateTemplate(input) { + calls.updateTemplate.push(input); + return { id: input.id ?? updateId, name: 'Human Handoff', description: input.description, type: 'issue', teamId: null }; + }, + }; +} + +async function loadBody() { + return readFile(TEMPLATE_PATH, 'utf8'); +} + +test('sync-template creates the workspace template when none exists', async () => { + const body = await loadBody(); + const { reporter, messages } = memoryReporter(); + const workspace = fakeWorkspace({ existingTemplate: null, createId: 'tpl_brand_new' }); + + const result = await createSyncTemplateUseCase({ reporter, templateBody: body, workspace })(); + + assert.equal(result.action, 'create'); + assert.equal(result.templateId, 'tpl_brand_new'); + assert.equal(result.mutationsPerformed, 1); + assert.deepEqual(workspace.calls.getTemplate, [{ name: 'Human Handoff' }]); + assert.equal(workspace.calls.createTemplate.length, 1); + assert.equal(workspace.calls.createTemplate[0].name, 'Human Handoff'); + assert.equal(workspace.calls.createTemplate[0].description, body.trimEnd()); + assert.equal(workspace.calls.createTemplate[0].type, 'issue'); + assert.equal(workspace.calls.updateTemplate.length, 0); + assert.ok(messages.some((m) => /Created workspace template/.test(m.message))); +}); + +test('sync-template updates the existing template when the body drifted', async () => { + const body = await loadBody(); + const { reporter, messages } = memoryReporter(); + const workspace = fakeWorkspace({ + existingTemplate: { id: 'tpl_existing', name: 'Human Handoff', description: 'old body', type: 'issue', teamId: null }, + }); + + const result = await createSyncTemplateUseCase({ reporter, templateBody: body, workspace })(); + + assert.equal(result.action, 'update'); + assert.equal(result.templateId, 'tpl_existing'); + assert.equal(result.mutationsPerformed, 1); + assert.equal(workspace.calls.updateTemplate.length, 1); + assert.deepEqual(workspace.calls.updateTemplate[0], { id: 'tpl_existing', description: body.trimEnd() }); + assert.equal(workspace.calls.createTemplate.length, 0); + assert.ok(messages.some((m) => /Updated workspace template/.test(m.message))); +}); + +test('sync-template reports no-change when the existing body already matches', async () => { + const body = await loadBody(); + const { reporter, messages } = memoryReporter(); + const workspace = fakeWorkspace({ + existingTemplate: { id: 'tpl_existing', name: 'Human Handoff', description: body.trimEnd(), type: 'issue', teamId: null }, + }); + + const result = await createSyncTemplateUseCase({ reporter, templateBody: body, workspace })(); + + assert.equal(result.action, 'no-change'); + assert.equal(result.templateId, 'tpl_existing'); + assert.equal(result.mutationsPerformed, 0); + assert.equal(workspace.calls.createTemplate.length, 0); + assert.equal(workspace.calls.updateTemplate.length, 0); + assert.ok(messages.some((m) => /No change/.test(m.message))); +}); + +test('sync-template is idempotent — a second run after a successful sync reports no-change', async () => { + // Round-trip: simulate that the first sync wrote `body.trimEnd()` and a + // later sync reads that exact body back, even if templateBody still has a + // trailing newline. The use case normalizes via createHumanHandoffTemplateBody + // (which trimEnds), so this is the realistic round-trip. + const body = await loadBody(); + const { reporter } = memoryReporter(); + const workspace = fakeWorkspace({ + existingTemplate: { id: 'tpl_existing', name: 'Human Handoff', description: body.trimEnd(), type: 'issue', teamId: null }, + }); + + await createSyncTemplateUseCase({ reporter, templateBody: body, workspace })(); + const second = await createSyncTemplateUseCase({ reporter, templateBody: body, workspace })(); + + assert.equal(second.action, 'no-change'); + assert.equal(second.mutationsPerformed, 0); +}); + +test('sync-template --dry-run plans a create without calling createTemplate', async () => { + const body = await loadBody(); + const { reporter, messages } = memoryReporter(); + const workspace = fakeWorkspace({ existingTemplate: null }); + + const result = await createSyncTemplateUseCase({ reporter, templateBody: body, workspace })({ dryRun: true }); + + assert.equal(result.action, 'create'); + assert.equal(result.templateId, null); + assert.equal(result.mutationsPerformed, 0); + assert.equal(workspace.calls.createTemplate.length, 0); + assert.equal(workspace.calls.updateTemplate.length, 0); + assert.ok(messages.some((m) => /\[dry-run\] Would create/.test(m.message))); +}); + +test('sync-template --dry-run plans an update without calling updateTemplate', async () => { + const body = await loadBody(); + const { reporter, messages } = memoryReporter(); + const workspace = fakeWorkspace({ + existingTemplate: { id: 'tpl_existing', name: 'Human Handoff', description: 'old body', type: 'issue', teamId: null }, + }); + + const result = await createSyncTemplateUseCase({ reporter, templateBody: body, workspace })({ dryRun: true }); + + assert.equal(result.action, 'update'); + assert.equal(result.templateId, 'tpl_existing'); + assert.equal(result.mutationsPerformed, 0); + assert.equal(workspace.calls.createTemplate.length, 0); + assert.equal(workspace.calls.updateTemplate.length, 0); + assert.ok(messages.some((m) => /\[dry-run\] Would update/.test(m.message))); +}); + +test('sync-template --dry-run reports no-change without mutations when already in sync', async () => { + const body = await loadBody(); + const { reporter } = memoryReporter(); + const workspace = fakeWorkspace({ + existingTemplate: { id: 'tpl_existing', name: 'Human Handoff', description: body.trimEnd(), type: 'issue', teamId: null }, + }); + + const result = await createSyncTemplateUseCase({ reporter, templateBody: body, workspace })({ dryRun: true }); + + assert.equal(result.action, 'no-change'); + assert.equal(result.templateId, 'tpl_existing'); + assert.equal(result.mutationsPerformed, 0); +}); + +test('sync-template surfaces a Linear auth error from getTemplate', async () => { + const body = await loadBody(); + const { reporter } = memoryReporter(); + const workspace = { + async getTemplate() { throw new LinearAuthError('rejected'); }, + async createTemplate() { assert.fail('should not call createTemplate'); }, + async updateTemplate() { assert.fail('should not call updateTemplate'); }, + }; + await assert.rejects( + () => createSyncTemplateUseCase({ reporter, templateBody: body, workspace })(), + LinearAuthError, + ); +}); + +test('sync-template surfaces a Linear API error from createTemplate', async () => { + const body = await loadBody(); + const { reporter } = memoryReporter(); + const workspace = { + async getTemplate() { return null; }, + async createTemplate() { throw new LinearApiError('Linear refused to create template "Human Handoff".'); }, + async updateTemplate() { assert.fail('should not call updateTemplate'); }, + }; + await assert.rejects( + () => createSyncTemplateUseCase({ reporter, templateBody: body, workspace })(), + LinearApiError, + ); +}); + +test('sync-template surfaces a Linear API error from updateTemplate', async () => { + const body = await loadBody(); + const { reporter } = memoryReporter(); + const workspace = { + async getTemplate() { + return { id: 'tpl_existing', name: 'Human Handoff', description: 'stale', type: 'issue', teamId: null }; + }, + async createTemplate() { assert.fail('should not call createTemplate'); }, + async updateTemplate() { throw new LinearApiError('Linear refused to update template tpl_existing.'); }, + }; + await assert.rejects( + () => createSyncTemplateUseCase({ reporter, templateBody: body, workspace })(), + LinearApiError, + ); +}); + +test('sync-template --dry-run does NOT call createTemplate even if it would fail', async () => { + const body = await loadBody(); + const { reporter } = memoryReporter(); + const workspace = { + async getTemplate() { return null; }, + async createTemplate() { throw new Error('would have failed'); }, + async updateTemplate() { throw new Error('would have failed'); }, + }; + const result = await createSyncTemplateUseCase({ reporter, templateBody: body, workspace })({ dryRun: true }); + assert.equal(result.action, 'create'); + assert.equal(result.mutationsPerformed, 0); +}); + +test('sync-template throws when the workspace port is missing getTemplate', async () => { + const body = await loadBody(); + const { reporter } = memoryReporter(); + await assert.rejects( + () => createSyncTemplateUseCase({ reporter, templateBody: body, workspace: {} })(), + /getTemplate/, + ); +}); + +test('sync-template throws on a templateBody missing the required sections', async () => { + const { reporter } = memoryReporter(); + await assert.rejects( + () => createSyncTemplateUseCase({ + reporter, + templateBody: '# unrelated\n\n## Anticipated human work\n', + workspace: fakeWorkspace(), + })(), + /autonomous prep instructions/i, + ); +}); + +test('sync-template can be retargeted to a different template name via input', async () => { + const body = await loadBody(); + const { reporter } = memoryReporter(); + const workspace = fakeWorkspace({ existingTemplate: null }); + + const result = await createSyncTemplateUseCase({ reporter, templateBody: body, workspace })({ name: 'Custom HH' }); + + assert.equal(result.action, 'create'); + assert.deepEqual(workspace.calls.getTemplate, [{ name: 'Custom HH' }]); + assert.equal(workspace.calls.createTemplate[0].name, 'Custom HH'); +}); diff --git a/packages/human-handoff-linear/tests/use-cases.test.mjs b/packages/human-handoff-linear/tests/use-cases.test.mjs index 1461fe7..4dee4e1 100644 --- a/packages/human-handoff-linear/tests/use-cases.test.mjs +++ b/packages/human-handoff-linear/tests/use-cases.test.mjs @@ -7,7 +7,6 @@ import { createDoctorUseCase, createHumanHandoffTemplateBody, createSetupUseCase, - createSyncTemplateUseCase, defineHumanHandoffLinearPackageContract, } from '../src/index.mjs'; import { LinearAuthError, LinearPermissionError, LinearRateLimitError } from '../src/errors.mjs'; @@ -232,16 +231,6 @@ test('checked-in template satisfies the Human Handoff body value contract', asyn assert.match(template.body, /## Verification checklist/); }); -test('sync-template use case validates the injected template without writing to Linear', async () => { - const body = await readFile(resolve(__dirname, '..', 'templates', 'human-handoff-issue-body.md'), 'utf8'); - const { reporter } = memoryReporter(); - const result = await createSyncTemplateUseCase({ - reporter, - templateBody: body, - workspace: {}, - })(); - - assert.equal(result.command.name, 'sync-template'); - assert.equal(result.mutationsPerformed, 0); - assert.match(result.template.body, /hh-prepared/); -}); +// sync-template use-case behavior is covered in tests/sync-template.test.mjs. +// The scaffold-style test that previously lived here has been replaced by the +// full create / update / no-change / dry-run / API-failure suite over there.