diff --git a/.changeset/tugasna-cli.md b/.changeset/tugasna-cli.md new file mode 100644 index 0000000..422908a --- /dev/null +++ b/.changeset/tugasna-cli.md @@ -0,0 +1,12 @@ +--- +"@sawala/cli": minor +--- + +Add a `sawala tugasna` command group for driving Tugasna work-tracking boards +from the CLI. You can now list/create/update/delete/archive boards and their +statuses, manage items on a board (create, update, move, delete), work the +project backlog (create, place onto a board, unplace), read and write item +comments, and read the project timeline and tags — all scoped to the active +project (`sawala project use `). Write commands take their body via +`-f/--file` or `-d/--data`, support `--dry-run`, and destructive verbs require +`-y/--yes`. diff --git a/packages/sawala/src/cli.ts b/packages/sawala/src/cli.ts index f577c4d..eec352c 100644 --- a/packages/sawala/src/cli.ts +++ b/packages/sawala/src/cli.ts @@ -10,6 +10,7 @@ import { createLogoutCommand } from './commands/logout' import { createOrgCommand } from './commands/org' import { createProjectCommand } from './commands/project' import { createSebarCommand } from './commands/sebar' +import { createTugasnaCommand } from './commands/tugasna' import { createWhoamiCommand } from './commands/whoami' export function createProgram(): Command { @@ -32,6 +33,7 @@ export function createProgram(): Command { program.addCommand(createDatanaCommand()) program.addCommand(createAjenaCommand()) program.addCommand(createSebarCommand()) + program.addCommand(createTugasnaCommand()) return program } diff --git a/packages/sawala/src/commands/tugasna.ts b/packages/sawala/src/commands/tugasna.ts new file mode 100644 index 0000000..b5687d2 --- /dev/null +++ b/packages/sawala/src/commands/tugasna.ts @@ -0,0 +1,607 @@ +import { Command } from 'commander' +import { + SAWALA_BRAND, + apiFetch, + loadContext, + requireActiveOrg, + requireActiveProject, + requireActiveProjectId, +} from '@sawala/auth' +import { confirmOrThrow, resolveInputPayload } from '../lib/io' + +/** + * Tugasna is the work-tracking product in the Sawala suite: boards with + * ordered statuses (columns), items placed on those boards, a project-level + * backlog of unplaced items, and per-item comments. This CLI surface covers + * the dashboard/CLI bearer surface forwarded via the gateway's + * `/cli/tugasna/*` prefix. + * + * Tugasna is project-scoped: the worker resolves `:projId` in the URL path as + * the project's stable ULID — not its slug. So every URL below is built from + * `ctx.activeProjectId`, which `sawala project use ` persists alongside + * the slug. + * + * This is the core-CRUD pass: boards (+ statuses, archive), board items + * (+ move), the backlog (+ place/unplace), comments, plus reads for tags and + * the timeline. Labels, custom fields, checklists and assignee-labels are left + * for a follow-up. + */ + +interface BoardRow { + id: string + slug: string + name: string + archivedAt?: number | null + [k: string]: unknown +} + +interface ItemRow { + id: string + title: string + statusId?: string | null + [k: string]: unknown +} + +interface TagRow { + id: string + name: string + [k: string]: unknown +} + +function projectBase(projectId: string): string { + return `/cli/tugasna/projects/${encodeURIComponent(projectId)}` +} + +function boardsBase(projectId: string): string { + return `${projectBase(projectId)}/boards` +} + +function boardItemsBase(projectId: string, boardId: string): string { + return `${boardsBase(projectId)}/${encodeURIComponent(boardId)}/items` +} + +function statusesBase(projectId: string, boardId: string): string { + return `${boardsBase(projectId)}/${encodeURIComponent(boardId)}/statuses` +} + +function commentsBase(projectId: string, itemId: string): string { + return `${projectBase(projectId)}/items/${encodeURIComponent(itemId)}/comments` +} + +function printJson(value: unknown): void { + process.stdout.write(JSON.stringify(value, null, 2) + '\n') +} + +// Every write command resolves org + project the same way; centralise it so +// each action stays a couple of lines. +async function projectContext(): Promise<{ ctx: Awaited>; projectId: string; activeProject: string }> { + const ctx = await loadContext(SAWALA_BRAND) + requireActiveOrg(ctx, SAWALA_BRAND) + const activeProject = requireActiveProject(ctx, SAWALA_BRAND) + const projectId = requireActiveProjectId(ctx, SAWALA_BRAND) + return { ctx, projectId, activeProject } +} + +export function createTugasnaCommand(): Command { + const tugasna = new Command('tugasna').description( + 'Tugasna work-tracking commands (boards, items, backlog, comments).', + ) + + // ── boards ────────────────────────────────────────────────────────────── + const board = new Command('board').description( + 'Manage Tugasna boards (list, get, create, update, delete, archive) and their statuses.', + ) + + board + .command('list') + .description('List boards in the active project. --archived shows only archived boards.') + .option('--archived', 'List archived boards instead of active ones.') + .action(async (opts: { archived?: boolean }) => { + const { ctx, projectId, activeProject } = await projectContext() + const qs = opts.archived ? '?archived=true' : '' + const rows = await apiFetch(ctx, `${boardsBase(projectId)}${qs}`) + if (rows.length === 0) { + process.stdout.write(`No ${opts.archived ? 'archived ' : ''}boards in '${activeProject}'.\n`) + return + } + for (const b of rows) { + process.stdout.write(`${b.id.padEnd(38)} ${b.slug.padEnd(24)} ${b.name}\n`) + } + }) + + board + .command('get ') + .description('Fetch one board with its statuses, fields and labels.') + .action(async (boardId: string) => { + const { ctx, projectId } = await projectContext() + printJson(await apiFetch(ctx, `${boardsBase(projectId)}/${encodeURIComponent(boardId)}`)) + }) + + board + .command('create') + .description('Create a board (seeds default statuses). Body: { name, description?, color?, startDate?, endDate? }.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'POST', body } }) + return + } + printJson(await apiFetch(ctx, boardsBase(projectId), { method: 'POST', body })) + }) + + board + .command('update ') + .description('Update a board (PATCH). Body: any subset of { name, description, color, startDate, endDate }.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (boardId: string, opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'PATCH', body } }) + return + } + printJson( + await apiFetch(ctx, `${boardsBase(projectId)}/${encodeURIComponent(boardId)}`, { + method: 'PATCH', + body, + }), + ) + }) + + board + .command('delete ') + .description('Delete a board and everything on it. Requires --yes or a TTY for confirmation.') + .option('-y, --yes', 'Skip the confirmation prompt.') + .action(async (boardId: string, opts: { yes?: boolean }) => { + const { ctx, projectId } = await projectContext() + if (!opts.yes) { + await confirmOrThrow(`Delete board '${boardId}' and all its items?`) + } + printJson( + await apiFetch(ctx, `${boardsBase(projectId)}/${encodeURIComponent(boardId)}`, { + method: 'DELETE', + }), + ) + }) + + board + .command('archive ') + .description('Archive a board (hides it from the default list).') + .action(async (boardId: string) => { + const { ctx, projectId } = await projectContext() + printJson( + await apiFetch(ctx, `${boardsBase(projectId)}/${encodeURIComponent(boardId)}/archive`, { + method: 'POST', + }), + ) + }) + + board + .command('unarchive ') + .description('Unarchive a board.') + .action(async (boardId: string) => { + const { ctx, projectId } = await projectContext() + printJson( + await apiFetch(ctx, `${boardsBase(projectId)}/${encodeURIComponent(boardId)}/unarchive`, { + method: 'POST', + }), + ) + }) + + // board status ─────────────────────────────────────────────────────────── + const status = new Command('status').description( + 'Manage a board’s statuses/columns (create, update, delete, reorder).', + ) + + status + .command('create ') + .description('Add a status/column. Body: { name, color? }.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (boardId: string, opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'POST', body } }) + return + } + printJson(await apiFetch(ctx, statusesBase(projectId, boardId), { method: 'POST', body })) + }) + + status + .command('update ') + .description('Update a status/column (PATCH). Body: any subset of { name, color }.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (boardId: string, statusId: string, opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'PATCH', body } }) + return + } + printJson( + await apiFetch(ctx, `${statusesBase(projectId, boardId)}/${encodeURIComponent(statusId)}`, { + method: 'PATCH', + body, + }), + ) + }) + + status + .command('delete ') + .description('Delete a status/column. Requires --yes or a TTY for confirmation.') + .option('-y, --yes', 'Skip the confirmation prompt.') + .action(async (boardId: string, statusId: string, opts: { yes?: boolean }) => { + const { ctx, projectId } = await projectContext() + if (!opts.yes) { + await confirmOrThrow(`Delete status '${statusId}' from board '${boardId}'?`) + } + printJson( + await apiFetch(ctx, `${statusesBase(projectId, boardId)}/${encodeURIComponent(statusId)}`, { + method: 'DELETE', + }), + ) + }) + + status + .command('reorder ') + .description('Reorder a board’s statuses. Body: { statusIds: [id, …] } in the desired order.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (boardId: string, opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'POST', body } }) + return + } + printJson( + await apiFetch(ctx, `${statusesBase(projectId, boardId)}/reorder`, { method: 'POST', body }), + ) + }) + + board.addCommand(status) + tugasna.addCommand(board) + + // ── items (board-scoped) ───────────────────────────────────────────────── + const item = new Command('item').description( + 'Manage items on a board (list, get, create, update, delete, move).', + ) + + item + .command('list ') + .description('List items on a board. --status filters by status id; --assignee by assignee.') + .option('--status ', 'Filter by status/column id.') + .option('--assignee ', 'Filter by assignee membership.') + .action(async (boardId: string, opts: { status?: string; assignee?: string }) => { + const { ctx, projectId } = await projectContext() + const params = new URLSearchParams() + if (opts.status) params.set('status', opts.status) + if (opts.assignee) params.set('assignee', opts.assignee) + const qs = params.toString() ? `?${params.toString()}` : '' + const rows = await apiFetch(ctx, `${boardItemsBase(projectId, boardId)}${qs}`) + if (rows.length === 0) { + process.stdout.write(`No items on board '${boardId}'.\n`) + return + } + for (const i of rows) { + process.stdout.write(`${i.id.padEnd(38)} ${(i.statusId ?? '').padEnd(38)} ${i.title}\n`) + } + }) + + item + .command('get ') + .description('Fetch one item on a board.') + .action(async (boardId: string, itemId: string) => { + const { ctx, projectId } = await projectContext() + printJson( + await apiFetch( + ctx, + `${boardItemsBase(projectId, boardId)}/${encodeURIComponent(itemId)}`, + ), + ) + }) + + item + .command('create ') + .description('Create an item on a board. Body: { title, description?, statusId?, assignees?, startDate?, dueDate? }.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (boardId: string, opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'POST', body } }) + return + } + printJson(await apiFetch(ctx, boardItemsBase(projectId, boardId), { method: 'POST', body })) + }) + + item + .command('update ') + .description('Update an item (PATCH). Body: any subset of the item fields.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (boardId: string, itemId: string, opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'PATCH', body } }) + return + } + printJson( + await apiFetch( + ctx, + `${boardItemsBase(projectId, boardId)}/${encodeURIComponent(itemId)}`, + { method: 'PATCH', body }, + ), + ) + }) + + item + .command('delete ') + .description('Delete an item. Requires --yes or a TTY for confirmation.') + .option('-y, --yes', 'Skip the confirmation prompt.') + .action(async (boardId: string, itemId: string, opts: { yes?: boolean }) => { + const { ctx, projectId } = await projectContext() + if (!opts.yes) { + await confirmOrThrow(`Delete item '${itemId}' from board '${boardId}'?`) + } + printJson( + await apiFetch( + ctx, + `${boardItemsBase(projectId, boardId)}/${encodeURIComponent(itemId)}`, + { method: 'DELETE' }, + ), + ) + }) + + item + .command('move ') + .description('Move/reorder an item within or across statuses. Body: { statusId?, position? }.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (boardId: string, itemId: string, opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'POST', body } }) + return + } + printJson( + await apiFetch( + ctx, + `${boardItemsBase(projectId, boardId)}/${encodeURIComponent(itemId)}/move`, + { method: 'POST', body }, + ), + ) + }) + + tugasna.addCommand(item) + + // ── backlog (project-scoped, unplaced items) ───────────────────────────── + const backlog = new Command('backlog').description( + 'Manage the project backlog pool (list, create, get, update, children, place, unplace).', + ) + + backlog + .command('list') + .description('List the project backlog (unplaced items).') + .action(async () => { + const { ctx, projectId } = await projectContext() + printJson(await apiFetch(ctx, `${projectBase(projectId)}/backlog`)) + }) + + backlog + .command('create') + .description('Create a backlog item (project-level, no board). Body: { title, description?, parentId? }.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'POST', body } }) + return + } + printJson(await apiFetch(ctx, `${projectBase(projectId)}/items`, { method: 'POST', body })) + }) + + backlog + .command('get ') + .description('Fetch one item by id (project-scoped, board or backlog).') + .action(async (itemId: string) => { + const { ctx, projectId } = await projectContext() + printJson( + await apiFetch(ctx, `${projectBase(projectId)}/items/${encodeURIComponent(itemId)}`), + ) + }) + + backlog + .command('update ') + .description('Update a project item (PATCH). Body: any subset of the item fields.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (itemId: string, opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'PATCH', body } }) + return + } + printJson( + await apiFetch(ctx, `${projectBase(projectId)}/items/${encodeURIComponent(itemId)}`, { + method: 'PATCH', + body, + }), + ) + }) + + backlog + .command('children ') + .description('List the child items of an item (sub-items).') + .action(async (itemId: string) => { + const { ctx, projectId } = await projectContext() + printJson( + await apiFetch( + ctx, + `${projectBase(projectId)}/items/${encodeURIComponent(itemId)}/children`, + ), + ) + }) + + backlog + .command('place ') + .description('Place a backlog item onto a board. Body: { boardId, statusId?, position? }.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (itemId: string, opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'POST', body } }) + return + } + printJson( + await apiFetch( + ctx, + `${projectBase(projectId)}/items/${encodeURIComponent(itemId)}/place`, + { method: 'POST', body }, + ), + ) + }) + + backlog + .command('unplace ') + .description('Return a placed item to the backlog (removes it from its board).') + .action(async (itemId: string) => { + const { ctx, projectId } = await projectContext() + printJson( + await apiFetch( + ctx, + `${projectBase(projectId)}/items/${encodeURIComponent(itemId)}/unplace`, + { method: 'POST' }, + ), + ) + }) + + tugasna.addCommand(backlog) + + // ── comments (item-scoped) ─────────────────────────────────────────────── + const comment = new Command('comment').description( + 'Manage comments on an item (list, create, update, delete).', + ) + + comment + .command('list ') + .description('List comments on an item.') + .action(async (itemId: string) => { + const { ctx, projectId } = await projectContext() + printJson(await apiFetch(ctx, commentsBase(projectId, itemId))) + }) + + comment + .command('create ') + .description('Add a comment to an item. Body: { body } (the comment text).') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (itemId: string, opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'POST', body } }) + return + } + printJson(await apiFetch(ctx, commentsBase(projectId, itemId), { method: 'POST', body })) + }) + + comment + .command('update ') + .description('Edit a comment (PATCH). Body: { body }.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action(async (itemId: string, commentId: string, opts: { file?: string; data?: string; dryRun?: boolean }) => { + const { ctx, projectId } = await projectContext() + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + printJson({ wouldSend: { method: 'PATCH', body } }) + return + } + printJson( + await apiFetch( + ctx, + `${commentsBase(projectId, itemId)}/${encodeURIComponent(commentId)}`, + { method: 'PATCH', body }, + ), + ) + }) + + comment + .command('delete ') + .description('Delete a comment. Requires --yes or a TTY for confirmation.') + .option('-y, --yes', 'Skip the confirmation prompt.') + .action(async (itemId: string, commentId: string, opts: { yes?: boolean }) => { + const { ctx, projectId } = await projectContext() + if (!opts.yes) { + await confirmOrThrow(`Delete comment '${commentId}' on item '${itemId}'?`) + } + printJson( + await apiFetch( + ctx, + `${commentsBase(projectId, itemId)}/${encodeURIComponent(commentId)}`, + { method: 'DELETE' }, + ), + ) + }) + + tugasna.addCommand(comment) + + // ── tags (project-scoped, read) ────────────────────────────────────────── + const tag = new Command('tag').description('Read Tugasna project tags.') + + tag + .command('list') + .description('List tags defined in the active project.') + .action(async () => { + const { ctx, projectId, activeProject } = await projectContext() + const rows = await apiFetch(ctx, `${projectBase(projectId)}/tags`) + if (rows.length === 0) { + process.stdout.write(`No tags in '${activeProject}'.\n`) + return + } + for (const t of rows) { + process.stdout.write(`${t.id.padEnd(38)} ${t.name}\n`) + } + }) + + tugasna.addCommand(tag) + + // ── timeline (project-scoped, read) ────────────────────────────────────── + tugasna + .command('timeline') + .description('Show the project timeline (items with start/due dates).') + .action(async () => { + const { ctx, projectId } = await projectContext() + printJson(await apiFetch(ctx, `${projectBase(projectId)}/timeline`)) + }) + + return tugasna +} diff --git a/packages/sawala/test/cli.test.ts b/packages/sawala/test/cli.test.ts index 99055d2..c699ef8 100644 --- a/packages/sawala/test/cli.test.ts +++ b/packages/sawala/test/cli.test.ts @@ -55,5 +55,6 @@ describe('sawala CLI smoke', () => { expect(names).toContain('formulir') expect(names).toContain('berkasna') expect(names).toContain('datana') + expect(names).toContain('tugasna') }) }) diff --git a/packages/sawala/test/tugasna.test.ts b/packages/sawala/test/tugasna.test.ts new file mode 100644 index 0000000..5f01a15 --- /dev/null +++ b/packages/sawala/test/tugasna.test.ts @@ -0,0 +1,377 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { writeConfig, writeCredentials, SAWALA_BRAND } from '@sawala/auth' +import { createProgram } from '../src/cli' + +const VALID_TOKEN = 'koda_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' +const PROJECT_ID = 'proj_01abc' +const PROJECT_SLUG = 'blog' +const API_BASE = 'https://api.sawala.cloud' +const PROJECT = `${API_BASE}/cli/tugasna/projects/${PROJECT_ID}` +const BOARDS = `${PROJECT}/boards` + +const ENV_KEYS = [ + 'SAWALA_API_TOKEN', + 'SAWALA_ORG', + 'SAWALA_PROJECT', + 'SAWALA_API_BASE', + 'SAWALA_CONFIG_DIR', +] as const + +let tmpDir: string +const savedEnv: Partial> = {} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(join(tmpdir(), 'sawala-tugasna-')) + for (const k of ENV_KEYS) { + savedEnv[k] = process.env[k] + delete process.env[k] + } + process.env['SAWALA_CONFIG_DIR'] = tmpDir + await writeCredentials(SAWALA_BRAND, { + token: VALID_TOKEN, + apiBase: API_BASE, + savedAt: '2026-06-17T00:00:00Z', + scopeOrgId: null, + scopeOrgSlug: null, + }) + await writeConfig(SAWALA_BRAND, { + activeOrg: 'acme', + activeProject: PROJECT_SLUG, + activeProjectId: PROJECT_ID, + }) +}) + +afterEach(async () => { + vi.restoreAllMocks() + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k] + else process.env[k] = savedEnv[k] + } + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function captureStdout(): { lines: string[]; restore: () => void } { + const lines: string[] = [] + const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + lines.push(typeof chunk === 'string' ? chunk : chunk.toString()) + return true + }) + return { lines, restore: () => spy.mockRestore() } +} + +describe('sawala tugasna board list / get', () => { + it('list GETs /boards and prints a terse column per board', async () => { + const fetchMock = vi.fn(async () => + jsonResponse([{ id: 'brd_1', slug: 'sprint', name: 'Sprint Board' }]), + ) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync(['node', 'sawala', 'tugasna', 'board', 'list']) + cap.restore() + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(BOARDS) + const out = cap.lines.join('') + expect(out).toContain('brd_1') + expect(out).toContain('sprint') + expect(out).toContain('Sprint Board') + }) + + it('list --archived sets ?archived=true', async () => { + const fetchMock = vi.fn(async () => jsonResponse([])) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync(['node', 'sawala', 'tugasna', 'board', 'list', '--archived']) + cap.restore() + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${BOARDS}?archived=true`) + }) + + it('get fetches /boards/', async () => { + const board = { id: 'brd_1', slug: 'sprint', name: 'Sprint', statuses: [] } + const fetchMock = vi.fn(async () => jsonResponse(board)) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync(['node', 'sawala', 'tugasna', 'board', 'get', 'brd_1']) + cap.restore() + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${BOARDS}/brd_1`) + expect(JSON.parse(cap.lines.join(''))).toEqual(board) + }) +}) + +describe('sawala tugasna board create / update / archive', () => { + it('create POSTs the --data body to /boards', async () => { + const body = { name: 'Sprint Board', color: '#f00' } + const fetchMock = vi.fn(async () => jsonResponse({ id: 'brd_1', ...body }, 201)) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'board', 'create', '--data', JSON.stringify(body), + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(BOARDS) + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual(body) + }) + + it('create --dry-run prints the request without calling fetch', async () => { + const body = { name: 'Sprint Board' } + const fetchMock = vi.fn(async () => jsonResponse({}, 201)) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'board', 'create', '--data', JSON.stringify(body), '--dry-run', + ]) + cap.restore() + expect(fetchMock).not.toHaveBeenCalled() + expect(JSON.parse(cap.lines.join('')).wouldSend).toEqual({ method: 'POST', body }) + }) + + it('update PATCHes /boards/', async () => { + const body = { name: 'Renamed' } + const fetchMock = vi.fn(async () => jsonResponse({ id: 'brd_1', ...body })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'board', 'update', 'brd_1', '--data', JSON.stringify(body), + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${BOARDS}/brd_1`) + expect(init.method).toBe('PATCH') + }) + + it('archive POSTs /boards//archive', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ archived: true })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync(['node', 'sawala', 'tugasna', 'board', 'archive', 'brd_1']) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${BOARDS}/brd_1/archive`) + expect(init.method).toBe('POST') + }) +}) + +describe('sawala tugasna board status', () => { + it('status create POSTs /boards//statuses', async () => { + const body = { name: 'In Review', color: '#0f0' } + const fetchMock = vi.fn(async () => jsonResponse({ id: 'sta_1', ...body }, 201)) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'board', 'status', 'create', 'brd_1', '--data', JSON.stringify(body), + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${BOARDS}/brd_1/statuses`) + expect(init.method).toBe('POST') + }) + + it('status reorder POSTs /boards//statuses/reorder', async () => { + const body = { statusIds: ['sta_2', 'sta_1'] } + const fetchMock = vi.fn(async () => jsonResponse({ ok: true })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'board', 'status', 'reorder', 'brd_1', '--data', JSON.stringify(body), + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${BOARDS}/brd_1/statuses/reorder`) + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual(body) + }) +}) + +describe('sawala tugasna item', () => { + it('list GETs /boards//items with optional filters', async () => { + const fetchMock = vi.fn(async () => jsonResponse([{ id: 'itm_1', title: 'Do the thing', statusId: 'sta_1' }])) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'item', 'list', 'brd_1', '--status', 'sta_1', + ]) + cap.restore() + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + const parsed = new URL(url) + expect(parsed.pathname).toBe(`/cli/tugasna/projects/${PROJECT_ID}/boards/brd_1/items`) + expect(parsed.searchParams.get('status')).toBe('sta_1') + expect(cap.lines.join('')).toContain('Do the thing') + }) + + it('create POSTs the body to /boards//items', async () => { + const body = { title: 'New task', statusId: 'sta_1' } + const fetchMock = vi.fn(async () => jsonResponse({ id: 'itm_1', ...body }, 201)) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'item', 'create', 'brd_1', '--data', JSON.stringify(body), + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${BOARDS}/brd_1/items`) + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual(body) + }) + + it('move POSTs /boards//items//move', async () => { + const body = { statusId: 'sta_2', position: 0 } + const fetchMock = vi.fn(async () => jsonResponse({ id: 'itm_1' })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'item', 'move', 'brd_1', 'itm_1', '--data', JSON.stringify(body), + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${BOARDS}/brd_1/items/itm_1/move`) + expect(init.method).toBe('POST') + }) + + it('delete with --yes DELETEs /boards//items/', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ deleted: true })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'item', 'delete', 'brd_1', 'itm_1', '--yes', + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${BOARDS}/brd_1/items/itm_1`) + expect(init.method).toBe('DELETE') + }) + + it('delete without --yes in non-TTY refuses to run', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ deleted: true })) + vi.stubGlobal('fetch', fetchMock) + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }) + await expect( + createProgram().parseAsync(['node', 'sawala', 'tugasna', 'item', 'delete', 'brd_1', 'itm_1']), + ).rejects.toThrow(/Refusing destructive operation without --yes/) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('sawala tugasna backlog', () => { + it('list GETs /backlog', async () => { + const fetchMock = vi.fn(async () => jsonResponse([])) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync(['node', 'sawala', 'tugasna', 'backlog', 'list']) + cap.restore() + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${PROJECT}/backlog`) + }) + + it('create POSTs /items (project-level)', async () => { + const body = { title: 'Backlog idea' } + const fetchMock = vi.fn(async () => jsonResponse({ id: 'itm_9', ...body }, 201)) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'backlog', 'create', '--data', JSON.stringify(body), + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${PROJECT}/items`) + expect(init.method).toBe('POST') + }) + + it('place POSTs /items//place', async () => { + const body = { boardId: 'brd_1', statusId: 'sta_1' } + const fetchMock = vi.fn(async () => jsonResponse({ id: 'itm_9' })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'backlog', 'place', 'itm_9', '--data', JSON.stringify(body), + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${PROJECT}/items/itm_9/place`) + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual(body) + }) + + it('unplace POSTs /items//unplace', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ id: 'itm_9' })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync(['node', 'sawala', 'tugasna', 'backlog', 'unplace', 'itm_9']) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${PROJECT}/items/itm_9/unplace`) + expect(init.method).toBe('POST') + }) +}) + +describe('sawala tugasna comment', () => { + it('list GETs /items//comments', async () => { + const fetchMock = vi.fn(async () => jsonResponse([])) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync(['node', 'sawala', 'tugasna', 'comment', 'list', 'itm_1']) + cap.restore() + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${PROJECT}/items/itm_1/comments`) + }) + + it('create POSTs the body to /items//comments', async () => { + const body = { body: 'Looks good' } + const fetchMock = vi.fn(async () => jsonResponse({ id: 'cmt_1', ...body }, 201)) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', 'sawala', 'tugasna', 'comment', 'create', 'itm_1', '--data', JSON.stringify(body), + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${PROJECT}/items/itm_1/comments`) + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual(body) + }) + + it('delete without --yes in non-TTY refuses to run', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ deleted: true })) + vi.stubGlobal('fetch', fetchMock) + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }) + await expect( + createProgram().parseAsync(['node', 'sawala', 'tugasna', 'comment', 'delete', 'itm_1', 'cmt_1']), + ).rejects.toThrow(/Refusing destructive operation without --yes/) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('sawala tugasna timeline / tag', () => { + it('timeline GETs /timeline', async () => { + const fetchMock = vi.fn(async () => jsonResponse([])) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync(['node', 'sawala', 'tugasna', 'timeline']) + cap.restore() + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${PROJECT}/timeline`) + }) + + it('tag list GETs /tags and prints a column per tag', async () => { + const fetchMock = vi.fn(async () => jsonResponse([{ id: 'tag_1', name: 'urgent' }])) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync(['node', 'sawala', 'tugasna', 'tag', 'list']) + cap.restore() + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${PROJECT}/tags`) + expect(cap.lines.join('')).toContain('urgent') + }) +})