diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5a4e4c4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + check-lint-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Check types + run: deno task check + + - name: Lint + run: deno task lint + + - name: Test + run: deno task test diff --git a/scenarios/entropy-audit-report.md b/scenarios/entropy-audit-report.md new file mode 100644 index 0000000..cbb1430 --- /dev/null +++ b/scenarios/entropy-audit-report.md @@ -0,0 +1,31 @@ +# Entropy Audit Update + +Date: 2026-02-15 Branch: `codex/entropy-reduction` + +## Finding Status + +1. Shared command-context adoption: **closed** +2. Resolver/enum duplication: **closed** +3. Direct handler console usage: **closed** +4. Monolithic command layout (`issue` / `project`): **closed** +5. Scale assumptions (`agentSessions`, team overview, project preview): + **closed** +6. Guardrails and CI gates: **closed** + +## Notes + +- Command concerns are now split and routed through directory indexes: + - `src/commands/issue/index.ts` + `read.ts` + `mutate.ts` + `comment.ts` + `watch.ts` + `shared.ts` + - `src/commands/project/index.ts` + `read.ts` + `mutate.ts` + `milestone.ts` + `status.ts` + `shared.ts` + - compatibility shims retained at `src/commands/issue.ts` and `src/commands/project.ts` +- Session and overview paths now cover scale cases: + - issue sessions fetched via paginated helper in `src/commands/issue/shared.ts` + - team overview issues paginated beyond 200 in `src/commands/team.ts` + - project issue preview semantics explicit in `src/commands/project/read.ts` +- Watch output contracts are explicit and tested for `table|compact|json`, including timeout. +- Scenario regression remains intentionally manual (LLM + eyeballing) and out of CI scope. + +## Next Backlog + +1. Optional: server-side filtering for issue sessions if/when SDK/API exposes issue-scoped `agentSessions` filters. +2. Optional: broader integration coverage for long-running watch polling in end-to-end harnesses. diff --git a/src/commands/__tests__/auth_initiative_smoke_test.ts b/src/commands/__tests__/auth_initiative_smoke_test.ts new file mode 100644 index 0000000..743059f --- /dev/null +++ b/src/commands/__tests__/auth_initiative_smoke_test.ts @@ -0,0 +1,27 @@ +import { assert, assertStringIncludes } from "@std/assert" + +Deno.test("auth status/whoami/logout use shared command context", async () => { + const source = await Deno.readTextFile( + new URL("../auth.ts", import.meta.url), + ) + + assertStringIncludes(source, "await getCommandContext(options)") + assert(source.includes('.description("Show authentication status")')) + assert(source.includes('.description("Show current user")')) + assert(source.includes('.description("Remove stored credentials")')) +}) + +Deno.test("initiative list/view use shared context and centralized status parser", async () => { + const source = await Deno.readTextFile( + new URL("../initiative.ts", import.meta.url), + ) + + assertStringIncludes( + source, + "const { format, client } = await getCommandContext(options)", + ) + assertStringIncludes(source, "function parseInitiativeStatus(input: string)") + assertStringIncludes(source, "initiativeStatusLabel(") + assertStringIncludes(source, "parseInitiativeStatus(options.status)") + assert(!source.includes("const statusMap: Record")) +}) diff --git a/src/commands/__tests__/context_adoption_contract_test.ts b/src/commands/__tests__/context_adoption_contract_test.ts new file mode 100644 index 0000000..184115c --- /dev/null +++ b/src/commands/__tests__/context_adoption_contract_test.ts @@ -0,0 +1,45 @@ +import { assertEquals } from "@std/assert" +import { join } from "@std/path" + +const COMMANDS_ROOT = new URL("../", import.meta.url) + +async function listFiles(dirUrl: URL): Promise { + const files: string[] = [] + for await (const entry of Deno.readDir(dirUrl)) { + if (entry.isDirectory) { + if (entry.name === "__tests__" || entry.name === "_shared") continue + files.push(...await listFiles(new URL(`${entry.name}/`, dirUrl))) + continue + } + if (entry.isFile && entry.name.endsWith(".ts")) { + files.push(join(dirUrl.pathname, entry.name)) + } + } + return files +} + +Deno.test("command handlers use shared context, not manual api key/client wiring", async () => { + const files = await listFiles(COMMANDS_ROOT) + const offenders: string[] = [] + + for (const file of files) { + const source = await Deno.readTextFile(file) + const isAuth = file.endsWith("/auth.ts") + + if (source.includes("await getAPIKey()")) { + offenders.push(`${file} -> getAPIKey`) + } + if (!isAuth && source.includes("createClient(")) { + offenders.push(`${file} -> createClient`) + } + if (!isAuth && source.includes("getFormat(")) { + offenders.push(`${file} -> getFormat`) + } + } + + assertEquals( + offenders, + [], + `Manual command context wiring found:\n${offenders.join("\n")}`, + ) +}) diff --git a/src/commands/__tests__/formatter_snapshot_test.ts b/src/commands/__tests__/formatter_snapshot_test.ts new file mode 100644 index 0000000..11d08b0 --- /dev/null +++ b/src/commands/__tests__/formatter_snapshot_test.ts @@ -0,0 +1,41 @@ +import { assertEquals } from "@std/assert" +import { render } from "../../output/formatter.ts" + +function captureConsoleLog(run: () => void): string[] { + const logs: string[] = [] + const original = console.log + console.log = (...args: unknown[]) => { + logs.push(args.map((v) => String(v)).join(" ")) + } + try { + run() + } finally { + console.log = original + } + return logs +} + +Deno.test("formatter compact table snapshot", () => { + const logs = captureConsoleLog(() => { + render("compact", { + headers: ["ID", "State"], + rows: [["POL-1", "Todo"], ["POL-2", "Done"]], + }) + }) + + assertEquals(logs.join("\n"), "ID\tSTATE\nPOL-1\tTodo\nPOL-2\tDone") +}) + +Deno.test("formatter compact detail snapshot", () => { + const logs = captureConsoleLog(() => { + render("compact", { + title: "Issue", + fields: [ + { label: "ID", value: "POL-1" }, + { label: "State", value: "Todo" }, + ], + }) + }) + + assertEquals(logs.join("\n"), "id\tPOL-1\nstate\tTodo") +}) diff --git a/src/commands/__tests__/mutation_contract_commands_test.ts b/src/commands/__tests__/mutation_contract_commands_test.ts index 6aae22f..7a9da15 100644 --- a/src/commands/__tests__/mutation_contract_commands_test.ts +++ b/src/commands/__tests__/mutation_contract_commands_test.ts @@ -2,7 +2,7 @@ import { assertStringIncludes } from "@std/assert" Deno.test("issue porcelain mutations define standardized action ids", async () => { const issueSource = await Deno.readTextFile( - new URL("../issue.ts", import.meta.url), + new URL("../issue/mutate.ts", import.meta.url), ) for (const action of ["close", "reopen", "start", "assign"]) { @@ -11,9 +11,13 @@ Deno.test("issue porcelain mutations define standardized action ids", async () = }) Deno.test("project porcelain mutations define standardized action ids", async () => { - const projectSource = await Deno.readTextFile( - new URL("../project.ts", import.meta.url), + const projectMutateSource = await Deno.readTextFile( + new URL("../project/mutate.ts", import.meta.url), ) + const projectStatusSource = await Deno.readTextFile( + new URL("../project/status.ts", import.meta.url), + ) + const projectSource = `${projectMutateSource}\n${projectStatusSource}` for ( const action of [ diff --git a/src/commands/__tests__/no_direct_console_in_handlers_test.ts b/src/commands/__tests__/no_direct_console_in_handlers_test.ts new file mode 100644 index 0000000..3ec7967 --- /dev/null +++ b/src/commands/__tests__/no_direct_console_in_handlers_test.ts @@ -0,0 +1,40 @@ +import { assertEquals } from "@std/assert" +import { join } from "@std/path" + +const COMMANDS_ROOT = new URL("../", import.meta.url) + +async function listCommandFiles(dirUrl: URL): Promise { + const files: string[] = [] + for await (const entry of Deno.readDir(dirUrl)) { + const nextPath = join(dirUrl.pathname, entry.name) + if (entry.isDirectory) { + if (entry.name === "__tests__" || entry.name === "_shared") continue + files.push(...await listCommandFiles(new URL(`${entry.name}/`, dirUrl))) + continue + } + if (entry.isFile && entry.name.endsWith(".ts")) { + files.push(nextPath) + } + } + return files +} + +Deno.test("command handlers do not directly call console.log/error", async () => { + const files = await listCommandFiles(COMMANDS_ROOT) + const offenders: string[] = [] + + for (const file of files) { + const text = await Deno.readTextFile(file) + if (/console\.(log|error)\(/.test(text)) { + offenders.push(file) + } + } + + assertEquals( + offenders, + [], + `Direct console usage is not allowed in command handlers:\n${ + offenders.join("\n") + }`, + ) +}) diff --git a/src/commands/__tests__/project_preview_contract_test.ts b/src/commands/__tests__/project_preview_contract_test.ts new file mode 100644 index 0000000..232bb84 --- /dev/null +++ b/src/commands/__tests__/project_preview_contract_test.ts @@ -0,0 +1,12 @@ +import { assertStringIncludes } from "@std/assert" + +Deno.test("project view includes explicit preview metadata fields", async () => { + const source = await Deno.readTextFile( + new URL("../project/read.ts", import.meta.url), + ) + + assertStringIncludes(source, "issuePreviewCount") + assertStringIncludes(source, "issuePreviewLimit") + assertStringIncludes(source, "issuePreviewHasMore") + assertStringIncludes(source, "issueTotalCount") +}) diff --git a/src/commands/__tests__/resolver_parity_test.ts b/src/commands/__tests__/resolver_parity_test.ts new file mode 100644 index 0000000..6abad26 --- /dev/null +++ b/src/commands/__tests__/resolver_parity_test.ts @@ -0,0 +1,85 @@ +import { assertEquals, assertRejects } from "@std/assert" +import type { LinearClient } from "@linear/sdk" +import { CliError } from "../../errors.ts" +import { + resolveTeam, + resolveTeamId, + resolveUser, + resolveUserEntity, +} from "../../resolve.ts" + +function mockClient(overrides: Partial): LinearClient { + return overrides as LinearClient +} + +Deno.test("resolveUserEntity and resolveUser share exact/partial semantics", async () => { + const users = [ + { id: "u1", name: "Jane Smith", email: "jane@example.com" }, + { id: "u2", name: "Janet Stone", email: "janet@example.com" }, + { id: "u3", name: "Alice Doe", email: "alice@example.com" }, + ] + const client = mockClient({ + viewer: Promise.resolve(users[2] as never), + users: () => Promise.resolve({ nodes: users } as never), + }) + + const byExactName = await resolveUserEntity(client, "jane smith") + assertEquals(byExactName.id, "u1") + + const byEmail = await resolveUserEntity(client, "JANET@EXAMPLE.COM") + assertEquals(byEmail.id, "u2") + + const byPartial = await resolveUserEntity(client, "alice") + assertEquals(byPartial.id, "u3") + + const meId = await resolveUser(client, "me") + assertEquals(meId, "u3") + + const parityId = await resolveUser(client, "jane smith") + assertEquals(parityId, byExactName.id) + + await assertRejects( + () => resolveUserEntity(client, "jan"), + CliError, + 'ambiguous user "jan"', + ) + + await assertRejects( + () => resolveUserEntity(client, "unknown"), + CliError, + 'user not found: "unknown"', + ) +}) + +Deno.test("resolveTeam and resolveTeamId share exact/partial semantics", async () => { + const teams = [ + { id: "t1", key: "POL" }, + { id: "t2", key: "PLAT" }, + { id: "t3", key: "OPS" }, + ] + + const client = mockClient({ + teams: () => Promise.resolve({ nodes: teams } as never), + }) + + const exact = await resolveTeam(client, "pol") + assertEquals(exact.id, "t1") + + const partial = await resolveTeam(client, "pla") + assertEquals(partial.id, "t2") + + const parityId = await resolveTeamId(client, "OPS") + assertEquals(parityId, "t3") + + await assertRejects( + () => resolveTeam(client, "p"), + CliError, + 'ambiguous team "p"', + ) + + await assertRejects( + () => resolveTeam(client, "zzz"), + CliError, + 'team not found: "zzz"', + ) +}) diff --git a/src/commands/__tests__/scale_sessions_test.ts b/src/commands/__tests__/scale_sessions_test.ts new file mode 100644 index 0000000..e96ace1 --- /dev/null +++ b/src/commands/__tests__/scale_sessions_test.ts @@ -0,0 +1,115 @@ +import { assertEquals } from "@std/assert" +import type { LinearClient } from "@linear/sdk" +import { fetchIssueAgentSessions, getLatestSession } from "../issue/index.ts" + +type MockConnection = { + nodes: T[] + pageInfo: { hasNextPage: boolean } + fetchNext: () => Promise> +} + +function makeConnection(pages: T[][], index = 0): MockConnection { + return { + nodes: pages[index] ?? [], + pageInfo: { hasNextPage: index < pages.length - 1 }, + fetchNext: () => Promise.resolve(makeConnection(pages, index + 1)), + } +} + +function makeSession( + issueId: string, + createdAt: string, + status: string, + agent: string, +) { + return { + issue: Promise.resolve({ id: issueId }), + appUser: Promise.resolve({ name: agent }), + status, + createdAt: new Date(createdAt), + externalLinks: [{ url: `https://example.com/${agent}` }], + activities: () => + Promise.resolve({ + nodes: [ + { + content: { + __typename: "AgentActivityResponseContent", + body: `summary-${agent}`, + }, + ephemeral: false, + createdAt: new Date(createdAt), + }, + ], + }), + } +} + +Deno.test("fetchIssueAgentSessions paginates through many pages and filters by issue", async () => { + const targetIssueId = "issue-target" + const pages = [ + Array.from( + { length: 100 }, + (_, i) => + i % 2 === 0 + ? makeSession( + targetIssueId, + `2026-01-01T00:${String(i % 60).padStart(2, "0")}:00Z`, + "complete", + `a${i}`, + ) + : makeSession( + "other", + `2026-01-01T00:${String(i % 60).padStart(2, "0")}:00Z`, + "complete", + `b${i}`, + ), + ), + Array.from( + { length: 100 }, + (_, i) => + makeSession( + targetIssueId, + `2026-01-02T00:${String(i % 60).padStart(2, "0")}:00Z`, + "complete", + `c${i}`, + ), + ), + Array.from( + { length: 60 }, + (_, i) => + makeSession( + "other-2", + `2026-01-03T00:${String(i % 60).padStart(2, "0")}:00Z`, + "complete", + `d${i}`, + ), + ), + ] + + const client = { + agentSessions: () => Promise.resolve(makeConnection(pages)), + } as unknown as LinearClient + + const sessions = await fetchIssueAgentSessions(client, targetIssueId, false) + assertEquals(sessions.length, 150) + assertEquals( + sessions.every((s) => s.agent.startsWith("a") || s.agent.startsWith("c")), + true, + ) +}) + +Deno.test("getLatestSession returns the newest matching session", async () => { + const targetIssueId = "issue-target" + const pages = [[ + makeSession(targetIssueId, "2026-01-01T00:00:00Z", "complete", "old"), + makeSession(targetIssueId, "2026-01-04T00:00:00Z", "error", "new"), + ]] + + const client = { + agentSessions: () => Promise.resolve(makeConnection(pages)), + } as unknown as LinearClient + + const latest = await getLatestSession(client, targetIssueId) + assertEquals(latest?.agent, "new") + assertEquals(latest?.status, "error") +}) diff --git a/src/commands/__tests__/scale_team_overview_test.ts b/src/commands/__tests__/scale_team_overview_test.ts new file mode 100644 index 0000000..b3ef459 --- /dev/null +++ b/src/commands/__tests__/scale_team_overview_test.ts @@ -0,0 +1,46 @@ +import { assertEquals } from "@std/assert" +import type { LinearClient } from "@linear/sdk" +import { fetchTeamOverviewIssues } from "../team.ts" + +type MockConnection = { + nodes: T[] + pageInfo: { hasNextPage: boolean } + fetchNext: () => Promise> +} + +function makeConnection(pages: T[][], index = 0): MockConnection { + return { + nodes: pages[index] ?? [], + pageInfo: { hasNextPage: index < pages.length - 1 }, + fetchNext: () => Promise.resolve(makeConnection(pages, index + 1)), + } +} + +Deno.test("team overview issue fetch paginates beyond 200 records", async () => { + const firstPage = Array.from({ length: 100 }, (_, i) => ({ id: `i-${i}` })) + const secondPage = Array.from({ length: 100 }, (_, i) => ({ + id: `i-${100 + i}`, + })) + const thirdPage = Array.from( + { length: 35 }, + (_, i) => ({ id: `i-${200 + i}` }), + ) + + let capturedFirst: unknown + const client = { + issues: (args: unknown) => { + capturedFirst = args + return Promise.resolve(makeConnection([firstPage, secondPage, thirdPage])) + }, + } as unknown as LinearClient + + const issues = await fetchTeamOverviewIssues(client, "POL") + + assertEquals(issues.length, 235) + assertEquals((issues[0] as { id: string }).id, "i-0") + assertEquals((issues[234] as { id: string }).id, "i-234") + assertEquals( + (capturedFirst as { first?: number }).first, + 100, + ) +}) diff --git a/src/commands/__tests__/watch_output_contract_test.ts b/src/commands/__tests__/watch_output_contract_test.ts new file mode 100644 index 0000000..608d4b9 --- /dev/null +++ b/src/commands/__tests__/watch_output_contract_test.ts @@ -0,0 +1,90 @@ +import { assertEquals, assertStringIncludes } from "@std/assert" +import { + renderWatchResult, + renderWatchTimeoutResult, + type WatchResult, + type WatchTimeoutResult, +} from "../issue/watch.ts" + +interface CaptureResult { + out: string[] + err: string[] +} + +function captureStreams(run: () => void): CaptureResult { + const out: string[] = [] + const err: string[] = [] + const originalLog = console.log + const originalStderrWrite = Deno.stderr.writeSync + console.log = (...args: unknown[]) => { + out.push(args.map((v) => String(v)).join(" ")) + } + Deno.stderr.writeSync = ((p: Uint8Array) => { + err.push(new TextDecoder().decode(p)) + return p.length + }) as typeof Deno.stderr.writeSync + + try { + run() + } finally { + console.log = originalLog + Deno.stderr.writeSync = originalStderrWrite + } + + return { out, err } +} + +const watchResult: WatchResult = { + issue: "POL-7", + agent: "Linear Agent", + status: "complete", + summary: "First line.\nSecond line with details.", + externalUrl: "https://linear.app/example/task/1", + elapsed: 42, +} + +Deno.test("watch compact result prints complete summary on stdout", () => { + const logs = captureStreams(() => renderWatchResult("compact", watchResult)) + assertEquals(logs.err.length, 0) + assertEquals(logs.out.length, 1) + + const row = logs.out[0] + assertStringIncludes(row, "POL-7") + assertStringIncludes(row, "Linear Agent") + assertStringIncludes(row, "complete") + assertStringIncludes(row, "42s") + assertStringIncludes(row, "First line. Second line with details.") + assertStringIncludes(row, "https://linear.app/example/task/1") +}) + +Deno.test("watch json timeout payload is structured", () => { + const timeout: WatchTimeoutResult = { + issue: "POL-7", + status: "timeout", + lastSessionStatus: "started", + elapsed: 120, + } + const logs = captureStreams(() => renderWatchTimeoutResult("json", timeout)) + assertEquals(logs.err.length, 0) + assertEquals(logs.out.length, 1) + + const payload = JSON.parse(logs.out[0]) as Record + assertEquals(payload.issue, "POL-7") + assertEquals(payload.status, "timeout") + assertEquals(payload.lastSessionStatus, "started") + assertEquals(payload.elapsed, 120) +}) + +Deno.test("watch table timeout prints final status on stdout", () => { + const timeout: WatchTimeoutResult = { + issue: "POL-7", + status: "timeout", + lastSessionStatus: "no session", + elapsed: 30, + } + const logs = captureStreams(() => renderWatchTimeoutResult("table", timeout)) + assertEquals(logs.err.length, 0) + assertEquals(logs.out.length, 2) + assertStringIncludes(logs.out[0], "POL-7: timeout (30s)") + assertStringIncludes(logs.out[1], "Last session status: no session") +}) diff --git a/src/commands/_shared/streams.ts b/src/commands/_shared/streams.ts new file mode 100644 index 0000000..cec6748 --- /dev/null +++ b/src/commands/_shared/streams.ts @@ -0,0 +1,27 @@ +import type { Format } from "../../output/formatter.ts" +import { renderMessage } from "../../output/formatter.ts" + +const encoder = new TextEncoder() + +function writeStderr(text: string): void { + Deno.stderr.writeSync(encoder.encode(text)) +} + +export function renderOutputMessage(format: Format, message: string): void { + renderMessage(format, message) +} + +export function renderStderrMessage(format: Format, message: string): void { + if (format === "json") return + writeStderr(`${message}\n`) +} + +export function renderTableHint(format: Format, message: string): void { + if (format !== "table") return + writeStderr(`${message}\n`) +} + +export function renderTableProgress(format: Format, message: string): void { + if (format !== "table") return + writeStderr(`${message}\r`) +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 2253ada..de118c2 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,15 +1,13 @@ import { Command } from "@cliffy/command" import { Input } from "@cliffy/prompt" +import type { LinearClient } from "@linear/sdk" import { createClient } from "../client.ts" -import { - getAPIKey, - removeCredentials, - saveCredentials, -} from "../auth.ts" +import { removeCredentials, saveCredentials } from "../auth.ts" import { CliError } from "../errors.ts" import { getFormat } from "../types.ts" import { render, renderMessage } from "../output/formatter.ts" import { renderJson } from "../output/json.ts" +import { getCommandContext } from "./_shared/context.ts" interface ViewerInfo { name: string @@ -19,8 +17,9 @@ interface ViewerInfo { workspace: string } -async function fetchViewerInfo(apiKey: string): Promise { - const client = createClient(apiKey) +async function fetchViewerInfoFromClient( + client: LinearClient, +): Promise { try { const viewer = await client.viewer const organization = await viewer.organization @@ -41,6 +40,11 @@ async function fetchViewerInfo(apiKey: string): Promise { } } +async function fetchViewerInfo(apiKey: string): Promise { + const client = createClient(apiKey) + return await fetchViewerInfoFromClient(client) +} + export const authCommand = new Command() .description("Manage authentication") .command( @@ -68,9 +72,8 @@ export const authCommand = new Command() new Command() .description("Remove stored credentials") .action(async (options) => { - const format = getFormat(options) - const apiKey = await getAPIKey() - const viewer = await fetchViewerInfo(apiKey) + const { format, client } = await getCommandContext(options) + const viewer = await fetchViewerInfoFromClient(client) await removeCredentials(viewer.workspace) renderMessage(format, `Logged out of workspace ${viewer.workspace}`) }), @@ -80,9 +83,8 @@ export const authCommand = new Command() new Command() .description("Show authentication status") .action(async (options) => { - const format = getFormat(options) - const apiKey = await getAPIKey() - const viewer = await fetchViewerInfo(apiKey) + const { format, client } = await getCommandContext(options) + const viewer = await fetchViewerInfoFromClient(client) renderMessage( format, `Authenticated as ${viewer.name} (${viewer.email})\nWorkspace: ${viewer.workspace}`, @@ -94,9 +96,8 @@ export const authCommand = new Command() new Command() .description("Show current user") .action(async (options) => { - const format = getFormat(options) - const apiKey = await getAPIKey() - const payload = await fetchViewerInfo(apiKey) + const { format, client } = await getCommandContext(options) + const payload = await fetchViewerInfoFromClient(client) if (format === "json") { renderJson(payload) return diff --git a/src/commands/cycle.ts b/src/commands/cycle.ts index 4ae585f..6579f90 100644 --- a/src/commands/cycle.ts +++ b/src/commands/cycle.ts @@ -1,7 +1,8 @@ import { Command } from "@cliffy/command" import { CliError } from "../errors.ts" -import { render } from "../output/formatter.ts" +import { render, renderMessage } from "../output/formatter.ts" import { renderJson } from "../output/json.ts" +import { resolveTeam } from "../resolve.ts" import { formatDate, relativeTime } from "../time.ts" import { getCommandContext } from "./_shared/context.ts" @@ -13,17 +14,7 @@ const listCommand = new Command() requireTeam: true, }) - const teams = await client.teams() - const team = teams.nodes.find( - (t) => t.key.toLowerCase() === teamKey.toLowerCase(), - ) - if (!team) { - throw new CliError( - `team not found: "${teamKey}"`, - 3, - "check team key with: linear team list", - ) - } + const team = await resolveTeam(client, teamKey) const cycles = await team.cycles() const items = cycles.nodes.sort( @@ -63,17 +54,7 @@ const viewCommand = new Command() requireTeam: true, }) - const teams = await client.teams() - const team = teams.nodes.find( - (t) => t.key.toLowerCase() === teamKey.toLowerCase(), - ) - if (!team) { - throw new CliError( - `team not found: "${teamKey}"`, - 3, - "check team key with: linear team list", - ) - } + const team = await resolveTeam(client, teamKey) const cycles = await team.cycles() const cycle = cycles.nodes.find((c) => c.number === number) @@ -130,7 +111,7 @@ const viewCommand = new Command() `ends\t${payload.endsAt ? formatDate(payload.endsAt) : "-"}`, `progress\t${payload.progressSummary}`, ] - console.log(lines.join("\n")) + renderMessage(format, lines.join("\n")) return } @@ -151,14 +132,12 @@ const viewCommand = new Command() }) if (payload.issues.length > 0) { - console.log("\nIssues:") - for (const r of payload.issues) { - console.log( - ` ${r.identifier} ${r.state} ${r.assignee} ${r.title} ${ - relativeTime(r.updatedAt) - }`, - ) - } + const issueLines = payload.issues.map((r) => + ` ${r.identifier} ${r.state} ${r.assignee} ${r.title} ${ + relativeTime(r.updatedAt) + }` + ) + renderMessage(format, `\nIssues:\n${issueLines.join("\n")}`) } }) diff --git a/src/commands/document.ts b/src/commands/document.ts index b60996a..8bb50cf 100644 --- a/src/commands/document.ts +++ b/src/commands/document.ts @@ -87,9 +87,9 @@ const viewCommand = new Command() `creator\t${payload.creator ?? "-"}`, `url\t${payload.url}`, ] - console.log(lines.join("\n")) + renderMessage(format, lines.join("\n")) if (payload.content) { - console.log(`\n${payload.content}`) + renderMessage(format, `\n${payload.content}`) } return } @@ -116,7 +116,7 @@ const viewCommand = new Command() }) if (payload.content) { - console.log(`\n${payload.content}`) + renderMessage(format, `\n${payload.content}`) } }) diff --git a/src/commands/initiative.ts b/src/commands/initiative.ts index 53e2aa8..6100ac7 100644 --- a/src/commands/initiative.ts +++ b/src/commands/initiative.ts @@ -1,10 +1,7 @@ import { Command } from "@cliffy/command" import { InitiativeStatus } from "@linear/sdk" -import { createClient } from "../client.ts" import { CliError } from "../errors.ts" -import { getAPIKey } from "../auth.ts" -import { getFormat } from "../types.ts" -import { render } from "../output/formatter.ts" +import { render, renderMessage } from "../output/formatter.ts" import { renderJson } from "../output/json.ts" import { readStdin, resolveInitiative, resolveUser } from "../resolve.ts" import { formatDate, relativeTime } from "../time.ts" @@ -14,6 +11,37 @@ import { renderMutationOutput, } from "./_shared/mutation_output.ts" +const INITIATIVE_STATUS_MAP: Record = { + planned: InitiativeStatus.Planned, + active: InitiativeStatus.Active, + completed: InitiativeStatus.Completed, +} + +function parseInitiativeStatus(input: string): InitiativeStatus { + const status = INITIATIVE_STATUS_MAP[input.toLowerCase()] + if (!status) { + throw new CliError( + `invalid status "${input}"`, + 4, + "use: planned, active, completed", + ) + } + return status +} + +function initiativeStatusLabel(status: InitiativeStatus): string { + switch (status) { + case InitiativeStatus.Planned: + return "planned" + case InitiativeStatus.Active: + return "active" + case InitiativeStatus.Completed: + return "completed" + default: + return String(status).toLowerCase() + } +} + // Linear API uses "status" for initiatives (vs "state" for issues) const listCommand = new Command() .description("List initiatives") @@ -26,15 +54,15 @@ const listCommand = new Command() ) .option("--owner ", "Filter by owner name (substring match)") .action(async (options) => { - const format = getFormat(options) - const apiKey = await getAPIKey() - const client = createClient(apiKey) + const { format, client } = await getCommandContext(options) const initiatives = await client.initiatives() let items = initiatives.nodes if (options.status) { - const target = options.status.toLowerCase() + const target = initiativeStatusLabel( + parseInitiativeStatus(options.status), + ) items = items.filter( (i) => i.status.toLowerCase() === target, ) @@ -82,9 +110,7 @@ const viewCommand = new Command() .example("View an initiative", "linear initiative view 'Q1 Goals'") .arguments("") .action(async (options, name: string) => { - const format = getFormat(options) - const apiKey = await getAPIKey() - const client = createClient(apiKey) + const { format, client } = await getCommandContext(options) const initiative = await resolveInitiative(client, name) @@ -121,7 +147,7 @@ const viewCommand = new Command() `projects\t${payload.projects.join(", ") || "-"}`, `url\t${payload.url}`, ] - console.log(lines.join("\n")) + renderMessage(format, lines.join("\n")) return } @@ -148,7 +174,7 @@ const viewCommand = new Command() }) if (payload.description) { - console.log(`\n${payload.description}`) + renderMessage(format, `\n${payload.description}`) } }) @@ -174,19 +200,7 @@ const createCommand = new Command() // Resolve status enum let status: InitiativeStatus | undefined if (options.status) { - const statusMap: Record = { - planned: InitiativeStatus.Planned, - active: InitiativeStatus.Active, - completed: InitiativeStatus.Completed, - } - status = statusMap[options.status.toLowerCase()] - if (!status) { - throw new CliError( - `invalid status "${options.status}"`, - 4, - "use: planned, active, completed", - ) - } + status = parseInitiativeStatus(options.status) } const ownerId = options.owner @@ -241,19 +255,7 @@ const updateCommand = new Command() let status: InitiativeStatus | undefined if (options.status) { - const statusMap: Record = { - planned: InitiativeStatus.Planned, - active: InitiativeStatus.Active, - completed: InitiativeStatus.Completed, - } - status = statusMap[options.status.toLowerCase()] - if (!status) { - throw new CliError( - `invalid status "${options.status}"`, - 4, - "use: planned, active, completed", - ) - } + status = parseInitiativeStatus(options.status) } const ownerId = options.owner diff --git a/src/commands/issue.ts b/src/commands/issue.ts index b13c5a3..c7ef4d1 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -1,1385 +1 @@ -import { Command } from "@cliffy/command" -import { type LinearClient, PaginationOrderBy } from "@linear/sdk" -// Exit codes: 0 success, 1 runtime error, 2 auth error, 3 not found, 4 validation/usage -import { CliError } from "../errors.ts" -import { render, renderMessage } from "../output/formatter.ts" -import { renderJson } from "../output/json.ts" -import { - readStdin, - resolveIssue, - resolveLabel, - resolvePriority, - resolveProject, - resolveState, - resolveTeamId, - resolveUser, -} from "../resolve.ts" -import { compactTime, formatDate, relativeTime } from "../time.ts" -import { renderMarkdown } from "../output/markdown.ts" -import { confirmDangerousAction } from "./_shared/confirm.ts" -import { getCommandContext } from "./_shared/context.ts" -import { - buildMutationResult, - renderMutationOutput, -} from "./_shared/mutation_output.ts" - -function priorityIndicator(priority: number): string { - switch (priority) { - case 1: - return "!!!" - case 2: - return "!!" - case 3: - return "!" - default: - return "---" - } -} - -function priorityName(priority: number): string { - switch (priority) { - case 1: - return "Urgent" - case 2: - return "High" - case 3: - return "Medium" - case 4: - return "Low" - default: - return "None" - } -} - -const DEFAULT_ACTIVE_STATES = ["triage", "backlog", "unstarted", "started"] - -const listCommand = new Command() - .description("List issues") - .example("List team issues", "linear issue list --team POL") - .example("List my issues", "linear issue list --team POL --assignee me") - .example("Urgent issues", "linear issue list --team POL --priority urgent") - .example("Current cycle", "linear issue list --team POL --cycle current") - .example("Overdue issues", "linear issue list --team POL --overdue") - .option("-s, --state ", "State type filter", { - collect: true, - }) - .option("--status ", "Alias for --state", { - collect: true, - hidden: true, - }) - .option("-a, --assignee ", "Filter by assignee") - .option("-U, --unassigned", "Show only unassigned") - .option("-l, --label ", "Filter by label", { collect: true }) - .option("-p, --project ", "Filter by project") - .option( - "--priority ", - "Filter by priority: urgent, high, medium, low, none (or 0-4)", - ) - .option( - "--cycle ", - "Filter by cycle: current, next, or cycle number", - ) - .option("--due ", "Issues due on or before date (YYYY-MM-DD)") - .option("--overdue", "Show only overdue issues (past due date)") - .option("--sort ", "Sort: updated, created, priority", { - default: "updatedAt", - }) - .option("--limit ", "Max results", { default: 50 }) - .option("--include-completed", "Include completed/canceled") - .option("--mine", "Only my issues (shorthand for --assignee me)", { - hidden: true, - }) - .action(async (options) => { - const { format, client, teamKey } = await getCommandContext(options, { - requireTeam: true, - }) - - // --- Resolve all filter values --- - - // State filter (--status is hidden alias for --state) - const states = options.state ?? options.status - const stateTypes = states?.length - ? states - : options.includeCompleted - ? undefined - : DEFAULT_ACTIVE_STATES - - // --mine is shorthand for --assignee me (--assignee wins if both set) - const assigneeName = options.assignee ?? (options.mine ? "me" : undefined) - const userId = assigneeName - ? await resolveUser(client, assigneeName) - : undefined - - // Label filter (AND semantics — all must match) - const teamId = (options.label?.length || options.cycle) - ? await resolveTeamId(client, teamKey) - : undefined - const labelIds = options.label?.length && teamId - ? await Promise.all( - options.label.map((l: string) => resolveLabel(client, teamId, l)), - ) - : undefined - - const projectId = options.project - ? await resolveProject(client, options.project) - : undefined - - // Cycle resolution (requires team → cycles) - let cycleId: string | undefined - if (options.cycle) { - const teams = await client.teams() - const team = teams.nodes.find( - (t: { key: string }) => t.key.toLowerCase() === teamKey.toLowerCase(), - ) - if (!team) { - throw new CliError( - `team not found: "${teamKey}"`, - 3, - "check team key with: linear team list", - ) - } - const cycles = await team.cycles() - const now = new Date() - - if (options.cycle === "current") { - const current = cycles.nodes.find(( - c: { startsAt: Date; endsAt: Date }, - ) => new Date(c.startsAt) <= now && now <= new Date(c.endsAt)) - if (!current) { - throw new CliError( - "no active cycle found", - 3, - "list cycles with: linear cycle list --team " + teamKey, - ) - } - cycleId = current.id - } else if (options.cycle === "next") { - const future = cycles.nodes - .filter((c: { startsAt: Date }) => new Date(c.startsAt) > now) - .sort((a: { startsAt: Date }, b: { startsAt: Date }) => - new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime() - ) - if (future.length === 0) { - throw new CliError( - "no upcoming cycle found", - 3, - "list cycles with: linear cycle list --team " + teamKey, - ) - } - cycleId = future[0].id - } else { - if (!/^\d+$/.test(options.cycle)) { - throw new CliError( - `invalid cycle "${options.cycle}"`, - 4, - "--cycle current, --cycle next, or --cycle ", - ) - } - const num = Number(options.cycle) - const match = cycles.nodes.find((c: { number: number }) => - c.number === num - ) - if (!match) { - throw new CliError( - `cycle #${num} not found`, - 3, - "list cycles with: linear cycle list --team " + teamKey, - ) - } - cycleId = match.id - } - } - - // Due date (--due and --overdue can combine) - if (options.due && !/^\d{4}-\d{2}-\d{2}$/.test(options.due)) { - throw new CliError(`invalid date "${options.due}"`, 4, "--due YYYY-MM-DD") - } - const dueDate = (options.due || options.overdue) - ? { - ...(options.due && { lte: options.due }), - ...(options.overdue && { lt: new Date().toISOString().slice(0, 10) }), - } - : undefined - - // --- Build filter --- - const filter = { - team: { key: { eq: teamKey } }, - ...(stateTypes && { state: { type: { in: stateTypes } } }), - ...(userId && { assignee: { id: { eq: userId } } }), - ...(options.unassigned && !assigneeName && { assignee: { null: true } }), - ...(labelIds && { labels: { id: { in: labelIds } } }), - ...(projectId && { project: { id: { eq: projectId } } }), - ...(options.priority && - { priority: { eq: resolvePriority(options.priority) } }), - ...(cycleId && { cycle: { id: { eq: cycleId } } }), - ...(dueDate && { dueDate }), - } - - // Normalize human-friendly sort values - const sortMap: Record = { - updated: "updatedAt", - created: "createdAt", - } - const sortField = sortMap[options.sort] ?? options.sort - - // Order - const orderBy = sortField === "createdAt" - ? PaginationOrderBy.CreatedAt - : PaginationOrderBy.UpdatedAt - - // Progress indication for slow list fetch - if (Deno.stderr.isTerminal()) { - Deno.stderr.writeSync(new TextEncoder().encode("Fetching...\r")) - } - - const issues = await client.issues({ - filter, - first: options.limit || undefined, - orderBy, - }) - - // Resolve lazy fields - const rows = await Promise.all( - issues.nodes.map(async (issue) => { - const state = await issue.state - const assignee = await issue.assignee - const delegate = await issue.delegate - return { - identifier: issue.identifier, - title: issue.title, - priority: issue.priority, - state: state?.name ?? "-", - assignee: assignee?.name ?? "-", - delegate: delegate?.name ?? null, - updatedAt: issue.updatedAt, - url: issue.url, - } - }), - ) - - // Client-side priority sort if requested - if (sortField === "priority") { - rows.sort((a, b) => (a.priority ?? 5) - (b.priority ?? 5)) - } - - if (format === "json") { - renderJson(rows) - return - } - - if (rows.length === 0) { - // Confirm what was resolved when results are empty - if (options.assignee) { - const viewer = options.assignee === "me" - ? await (async () => { - const v = await client.viewer - return v.name - })() - : options.assignee - renderMessage( - format, - `No issues found for assignee "${viewer}"${ - options.assignee === "me" ? ' (resolved from "me")' : "" - } in team ${teamKey}`, - ) - } else { - renderMessage(format, `No issues found in team ${teamKey}`) - } - return - } - - const hasDelegate = rows.some((r) => r.delegate) - - if (format === "table") { - const headers = ["\u25CC", "ID", "State", "Assignee"] - if (hasDelegate) headers.push("Delegate") - headers.push("Title", "Updated") - render("table", { - headers, - rows: rows.map((r) => { - const row = [ - priorityIndicator(r.priority), - r.identifier, - r.state, - r.assignee, - ] - if (hasDelegate) row.push(r.delegate ?? "-") - row.push(r.title, relativeTime(r.updatedAt)) - return row - }), - }) - } else { - const headers = ["ID", "State", "Assignee"] - if (hasDelegate) headers.push("Delegate") - headers.push("Title", "Updated") - render("compact", { - headers, - rows: rows.map((r) => { - const row = [r.identifier, r.state, r.assignee] - if (hasDelegate) row.push(r.delegate ?? "-") - row.push(r.title, compactTime(r.updatedAt)) - return row - }), - }) - } - }) - -const viewCommand = new Command() - .alias("show") - .description("View issue details") - .example("View an issue", "linear issue view POL-5") - .arguments("") - .option("-v, --verbose", "Show full agent activity log") - .action(async (options, id: string) => { - const { format, client } = await getCommandContext(options) - const teamKey = (options as unknown as { team?: string }).team - - const issue = await resolveIssue(client, id, teamKey) - const state = await issue.state - const assignee = await issue.assignee - const delegate = await issue.delegate - const labelsConn = await issue.labels() - const labels = labelsConn.nodes - const project = await issue.project - const cycle = await issue.cycle - const commentsConn = await issue.comments() - const comments = commentsConn.nodes - - const branchName = await issue.branchName - - // Fetch agent sessions for this issue - const allSessions = await client.agentSessions() - const sessions = [] - for (const session of allSessions.nodes) { - const sessionIssue = await session.issue - if (sessionIssue?.id === issue.id) { - const appUser = await session.appUser - const activities = await session.activities() - // Find the last response activity (the summary/result) - const responseActivity = activities.nodes.find( - (a) => a.content.__typename === "AgentActivityResponseContent", - ) - const summary = responseActivity?.content.__typename === - "AgentActivityResponseContent" - ? responseActivity.content.body - : null - sessions.push({ - agent: appUser?.name ?? "Unknown", - status: session.status, - createdAt: session.createdAt, - summary, - // externalUrls is typed as Record in SDK but is actually { url: string }[] - externalUrl: session.externalLinks?.[0]?.url ?? - (session.externalUrls as unknown as { url?: string }[] | undefined) - ?.[0]?.url ?? - null, - activities: (options as { verbose?: boolean }).verbose - ? activities.nodes.map((a) => ({ - type: a.content.__typename - .replace("AgentActivity", "") - .replace("Content", "") - .toLowerCase(), - body: "body" in a.content ? a.content.body : "", - ephemeral: a.ephemeral, - createdAt: a.createdAt, - })) - : null, - }) - } - } - - const commentData = await Promise.all( - comments.map(async (c) => { - const user = await c.user - return { - author: user?.name ?? "Unknown", - body: c.body, - createdAt: c.createdAt, - } - }), - ) - - const payload = { - id: issue.identifier, - title: issue.title, - state: state?.name ?? "-", - priority: priorityName(issue.priority), - assignee: assignee?.name ?? null, - delegate: delegate?.name ?? null, - labels: labels.map((l) => l.name), - project: project?.name ?? null, - cycle: cycle?.name ?? null, - createdAt: issue.createdAt, - updatedAt: issue.updatedAt, - url: issue.url, - branchName: branchName ?? null, - description: issue.description ?? null, - comments: commentData, - agentSessions: sessions, - } - - if (format === "json") { - renderJson(payload) - return - } - - if (format === "compact") { - const lines = [ - `id\t${payload.id}`, - `title\t${payload.title}`, - `state\t${payload.state}`, - `priority\t${payload.priority}`, - `assignee\t${payload.assignee ?? "-"}`, - `delegate\t${payload.delegate ?? "-"}`, - `labels\t${payload.labels.length ? payload.labels.join(", ") : "-"}`, - `project\t${payload.project ?? "-"}`, - `cycle\t${payload.cycle ?? "-"}`, - `created\t${new Date(payload.createdAt).toISOString()}`, - `updated\t${new Date(payload.updatedAt).toISOString()}`, - `url\t${payload.url}`, - `branch\t${payload.branchName ?? "-"}`, - `description\t${payload.description ?? "-"}`, - ] - if (payload.agentSessions.length > 0) { - for (const session of payload.agentSessions) { - lines.push( - `agent_session\t${session.agent}\t${session.status}\t${ - session.summary?.replace(/\n/g, " ").slice(0, 200) ?? "-" - }\t${session.externalUrl ?? "-"}`, - ) - } - } - console.log(lines.join("\n")) - return - } - - // Table format — detail view - render("table", { - title: `${payload.id}: ${payload.title}`, - fields: [ - { label: "State", value: payload.state }, - { label: "Priority", value: payload.priority }, - { label: "Assignee", value: payload.assignee ?? "-" }, - { label: "Delegate", value: payload.delegate ?? "-" }, - { - label: "Labels", - value: payload.labels.length ? payload.labels.join(", ") : "-", - }, - { label: "Project", value: payload.project ?? "-" }, - { label: "Cycle", value: payload.cycle ?? "-" }, - { - label: "Created", - value: `${formatDate(payload.createdAt)} (${ - relativeTime(payload.createdAt) - })`, - }, - { - label: "Updated", - value: `${formatDate(payload.updatedAt)} (${ - relativeTime(payload.updatedAt) - })`, - }, - { label: "URL", value: payload.url }, - { label: "Branch", value: payload.branchName ?? "-" }, - ], - }) - - if (payload.description) { - console.log( - `\nDescription:\n${renderMarkdown(payload.description)}`, - ) - } - - if (payload.comments.length > 0) { - console.log(`\nComments (${payload.comments.length}):`) - for (const comment of payload.comments) { - console.log( - `\n${comment.author} (${relativeTime(comment.createdAt)}):\n${ - renderMarkdown(comment.body, { indent: " " }) - }`, - ) - } - } - - if (payload.agentSessions.length > 0) { - console.log(`\nAgent Sessions (${payload.agentSessions.length}):`) - for (const session of payload.agentSessions) { - const statusLabel = session.status === "complete" - ? "complete" - : session.status === "awaitingInput" - ? "needs input" - : session.status === "error" - ? "error" - : session.status - console.log( - `\n${session.agent} · ${statusLabel} · ${ - relativeTime(session.createdAt) - }`, - ) - if (session.summary) { - console.log(renderMarkdown(session.summary, { indent: " " })) - } - if (session.externalUrl) { - console.log(` View task → ${session.externalUrl}`) - } - if (session.activities) { - console.log(` Activities:`) - for (const act of session.activities) { - const raw = act.body.length > 120 - ? act.body.slice(0, 117) + "..." - : act.body - console.log( - ` [${act.type}] ${renderMarkdown(raw, { indent: " " })}`, - ) - } - } - } - } - }) - -const createCommand = new Command() - .description("Create issue") - .example( - "Create a bug", - "linear issue create --team POL --title 'Login crash' --priority urgent --label bug", - ) - .example( - "Create and assign to me", - "linear issue create --team POL --title 'Fix tests' --assignee me", - ) - .option("--title ", "Issue title", { required: true }) - .option("-d, --description ", "Description") - .option("-a, --assignee ", "Assignee name or 'me'") - .option("-s, --state ", "Initial state name") - .option("--status ", "Alias for --state", { hidden: true }) - .option( - "--priority ", - "Priority: urgent, high, medium, low, none (or 0-4)", - ) - .option("-l, --label ", "Label name", { collect: true }) - .option("--type ", "Alias for --label", { hidden: true }) - .option("-p, --project ", "Project name") - .option("--parent ", "Parent issue identifier") - .action(async (options) => { - const { format, client, teamKey } = await getCommandContext(options, { - requireTeam: true, - }) - const teamId = await resolveTeamId(client, teamKey) - - // Description: flag → stdin → undefined - const description = options.description ?? (await readStdin()) - - // Resolve all async values before building input - const stateName = options.state ?? options.status - const assigneeId = options.assignee - ? await resolveUser(client, options.assignee) - : undefined - const stateId = stateName - ? await resolveState(client, teamId, stateName) - : undefined - const labelNames = options.label?.length - ? options.label - : options.type - ? [options.type] - : undefined - const labelIds = labelNames?.length - ? await Promise.all( - labelNames.map((l: string) => resolveLabel(client, teamId, l)), - ) - : undefined - const projectId = options.project - ? await resolveProject(client, options.project) - : undefined - const parentId = options.parent - ? (await resolveIssue(client, options.parent, teamKey)).id - : undefined - - const payload = await client.createIssue({ - teamId, - title: options.title, - ...(description && { description }), - ...(options.priority && { priority: resolvePriority(options.priority) }), - ...(assigneeId && { assigneeId }), - ...(stateId && { stateId }), - ...(labelIds && { labelIds }), - ...(projectId && { projectId }), - ...(parentId && { parentId }), - }) - const issue = await payload.issue - - if (!issue) { - throw new CliError("failed to create issue", 1) - } - - renderMutationOutput({ - format, - result: buildMutationResult({ - id: issue.identifier, - entity: "issue", - action: "create", - status: "success", - url: issue.url, - metadata: { title: issue.title }, - }), - }) - if (format === "table") { - console.error(` assign: linear issue assign ${issue.identifier}`) - } - }) - -const updateCommand = new Command() - .description("Update issue") - .example("Change priority", "linear issue update POL-5 --priority high") - .example("Add a label", "linear issue update POL-5 --add-label bug") - .arguments("") - .option("--title ", "New title") - .option("-d, --description ", "New description") - .option("-a, --assignee ", "New assignee (empty to unassign)") - .option("-s, --state ", "New state name") - .option("--status ", "Alias for --state", { hidden: true }) - .option( - "--priority ", - "Priority: urgent, high, medium, low, none (or 0-4)", - ) - .option("-l, --label ", "Replace all labels", { collect: true }) - .option("--add-label ", "Add label", { collect: true }) - .option("--remove-label ", "Remove label", { collect: true }) - .option("-p, --project ", "Move to project") - .option("--parent ", "Set parent issue") - .action(async (options, id: string) => { - const { format, client } = await getCommandContext(options) - const teamKey = (options as unknown as { team?: string }).team - - const issue = await resolveIssue(client, id, teamKey) - - // Resolve all async values before building input - const description = options.description ?? (await readStdin()) - - const assigneeId = options.assignee !== undefined - ? (options.assignee === "" - ? null - : await resolveUser(client, options.assignee)) - : undefined - - const stateName = options.state ?? options.status - let stateId: string | undefined - if (stateName) { - const state = await issue.state - const team = await state?.team - if (team?.id) { - stateId = await resolveState(client, team.id, stateName) - } - } - - // Resolve labels (replace-all or delta) - let labelIds: string[] | undefined - const issueTeamId = async () => { - const state = await issue.state - const team = await state?.team - return team?.id ?? "" - } - if (options.label?.length) { - const tid = await issueTeamId() - labelIds = await Promise.all( - options.label.map((l: string) => resolveLabel(client, tid, l)), - ) - } else if (options.addLabel?.length || options.removeLabel?.length) { - const currentLabels = await issue.labels() - const currentIds = currentLabels.nodes.map((l: { id: string }) => l.id) - const tid = await issueTeamId() - let ids = [...currentIds] - if (options.addLabel?.length) { - const addIds = await Promise.all( - options.addLabel.map((l: string) => resolveLabel(client, tid, l)), - ) - ids = [...new Set([...ids, ...addIds])] - } - if (options.removeLabel?.length) { - const removeIds = await Promise.all( - options.removeLabel.map((l: string) => resolveLabel(client, tid, l)), - ) - ids = ids.filter((id) => !removeIds.includes(id)) - } - labelIds = ids - } - - const projectId = options.project - ? await resolveProject(client, options.project) - : undefined - const parentId = options.parent - ? (await resolveIssue(client, options.parent, teamKey)).id - : undefined - - await client.updateIssue(issue.id, { - ...(options.title && { title: options.title }), - ...(description !== undefined && { description }), - ...(options.priority && { priority: resolvePriority(options.priority) }), - ...(assigneeId !== undefined && { assigneeId }), - ...(stateId && { stateId }), - ...(labelIds && { labelIds }), - ...(projectId && { projectId }), - ...(parentId && { parentId }), - }) - - // Re-fetch and display updated issue - const updated = await client.issue(issue.id) - const updatedState = await updated.state - const updatedAssignee = await updated.assignee - const updatedDelegate = await updated.delegate - - renderMutationOutput({ - format, - result: buildMutationResult({ - id: updated.identifier, - entity: "issue", - action: "update", - status: "success", - url: updated.url, - metadata: { - title: updated.title, - state: updatedState?.name ?? "-", - priority: priorityName(updated.priority), - assignee: updatedAssignee?.name ?? null, - delegate: updatedDelegate?.name ?? null, - }, - }), - }) - }) - -const deleteCommand = new Command() - .description("Delete (archive) issue") - .example("Delete an issue", "linear issue delete POL-5") - .example("Delete multiple issues", "linear issue delete POL-1 POL-2 POL-3") - .arguments("") - .example("Delete without confirmation", "linear issue delete POL-5 --yes") - .option("-y, --yes", "Skip confirmation prompt") - .action(async (options, ...ids: [string, ...Array]) => { - const { format, client, noInput } = await getCommandContext(options) - const teamKey = (options as { team?: string }).team - - const issues = await Promise.all( - ids.map((id) => resolveIssue(client, id, teamKey)), - ) - - const label = issues.length === 1 - ? `${issues[0].identifier} "${issues[0].title}"` - : `${issues.length} issues (${issues.map((issue) => issue.identifier).join(", ")})` - const confirmed = await confirmDangerousAction({ - prompt: `Delete ${label}?`, - skipConfirm: Boolean((options as { yes?: boolean }).yes) || noInput, - }) - if (!confirmed) { - renderMessage(format, "Canceled") - return - } - - await Promise.all(issues.map((issue) => client.archiveIssue(issue.id))) - - const payload = issues.map((issue) => ({ - id: issue.identifier, - status: "success", - url: issue.url, - metadata: { title: issue.title }, - })) - if (format === "json") { - renderJson(payload.length === 1 ? payload[0] : payload) - return - } - - for (const issue of issues) { - renderMutationOutput({ - format, - result: buildMutationResult({ - id: issue.identifier, - entity: "issue", - action: "delete", - status: "success", - url: issue.url, - metadata: { title: issue.title }, - }), - }) - } - }) - -const commentListCommand = new Command() - .description("List comments on issue") - .arguments("") - .action(async (options, id: string) => { - const { format, client } = await getCommandContext(options) - const teamKey = (options as unknown as { team?: string }).team - - const issue = await resolveIssue(client, id, teamKey) - const commentsConn = await issue.comments() - const comments = commentsConn.nodes - - const rows = await Promise.all( - comments.map(async (c) => { - const user = await c.user - return { - author: user?.name ?? "Unknown", - body: c.body ?? "", - createdAt: c.createdAt, - } - }), - ) - - if (format === "json") { - renderJson(rows) - return - } - - if (format === "table") { - render("table", { - headers: ["Author", "Age", "Body"], - rows: rows.map((r) => [ - r.author, - relativeTime(r.createdAt), - r.body.length > 80 ? r.body.slice(0, 77) + "..." : r.body, - ]), - }) - } else { - render("compact", { - headers: ["Author", "Age", "Body"], - rows: rows.map((r) => [ - r.author, - compactTime(r.createdAt), - r.body.replace(/\n/g, " "), - ]), - }) - } - }) - -async function addComment( - options: unknown, - id: string, - bodyArg?: string, -): Promise { - const { format, client } = await getCommandContext(options) - const teamKey = (options as unknown as { team?: string }).team - - const issue = await resolveIssue(client, id, teamKey) - - const body = bodyArg ?? - (options as { body?: string }).body ?? (await readStdin()) - if (!body) { - throw new CliError( - "comment body required", - 4, - `issue comment ${id} "your comment" (or --body or pipe via stdin)`, - ) - } - - await client.createComment({ issueId: issue.id, body }) - renderMessage(format, `Comment added to ${issue.identifier}`) -} - -const commentCommand = new Command() - .description("Add comment or list comments") - .example("Add a comment", "linear issue comment POL-5 'Looks good'") - .example("List comments", "linear issue comment list POL-5") - .arguments(" [body:string]") - .option("--body ", "Comment text (alternative to positional)") - .action( - (options: Record, id: string, bodyArg?: string) => - addComment(options, id, bodyArg), - ) - .command( - "add", - new Command() - .description("Add comment to issue") - .arguments(" [body:string]") - .option( - "--body ", - "Comment text (alternative to positional)", - ) - .action( - (options: Record, id: string, bodyArg?: string) => - addComment(options, id, bodyArg), - ), - ) - .command("list", commentListCommand) - -const branchCommand = new Command() - .description("Get git branch name for issue") - .example("Get branch name", "linear issue branch POL-5") - .arguments("") - .action(async (options, id: string) => { - const { format, client } = await getCommandContext(options) - const teamKey = (options as unknown as { team?: string }).team - - const issue = await resolveIssue(client, id, teamKey) - const payload = { branchName: issue.branchName } - - if (format === "json") { - renderJson(payload) - return - } - - renderMessage(format, payload.branchName) - }) - -const closeCommand = new Command() - .alias("done").alias("finish").alias("complete").alias("resolve") - .description("Close issue (set to completed state)") - .example("Close an issue", "linear issue close POL-5") - .example("Close multiple issues", "linear issue close POL-1 POL-2 POL-3") - .arguments("") - .action(async (options, ...ids: [string, ...Array]) => { - const { format, client } = await getCommandContext(options) - const teamKey = (options as unknown as { team?: string }).team - - const completedStateByTeam = new Map() - const closedIssues: Array<{ - identifier: string - url: string - state: string - }> = [] - - for (const id of ids) { - const issue = await resolveIssue(client, id, teamKey) - const state = await issue.state - const team = await state?.team - if (!team) throw new CliError("cannot determine team for issue", 1) - - let completed = completedStateByTeam.get(team.id) - if (!completed) { - const states = await team.states() - const stateNode = states.nodes.find((s) => s.type === "completed") - if (!stateNode) { - throw new CliError( - "no completed state found for team", - 1, - "check team workflow settings in Linear", - ) - } - completed = { id: stateNode.id, name: stateNode.name } - completedStateByTeam.set(team.id, completed) - } - - await client.updateIssue(issue.id, { stateId: completed.id }) - closedIssues.push({ - identifier: issue.identifier, - url: issue.url, - state: completed.name, - }) - } - - if (format === "json") { - const payload = closedIssues.map((issue) => ({ - id: issue.identifier, - status: "success", - url: issue.url, - metadata: { state: issue.state }, - })) - renderJson(payload.length === 1 ? payload[0] : payload) - return - } - - for (const issue of closedIssues) { - renderMutationOutput({ - format, - result: buildMutationResult({ - id: issue.identifier, - entity: "issue", - action: "close", - status: "success", - url: issue.url, - metadata: { state: issue.state }, - }), - }) - } - }) - -const reopenCommand = new Command() - .alias("open") - .description("Reopen issue (set to unstarted state)") - .example("Reopen an issue", "linear issue reopen POL-5") - .example("Reopen multiple issues", "linear issue reopen POL-1 POL-2") - .arguments("") - .action(async (options, ...ids: [string, ...Array]) => { - const { format, client } = await getCommandContext(options) - const teamKey = (options as unknown as { team?: string }).team - - const unstartedStateByTeam = new Map() - const reopenedIssues: Array<{ - identifier: string - url: string - state: string - }> = [] - - for (const id of ids) { - const issue = await resolveIssue(client, id, teamKey) - const state = await issue.state - const team = await state?.team - if (!team) throw new CliError("cannot determine team for issue", 1) - - let unstarted = unstartedStateByTeam.get(team.id) - if (!unstarted) { - const states = await team.states() - const stateNode = states.nodes.find((s) => s.type === "unstarted") - if (!stateNode) { - throw new CliError( - "no unstarted state found for team", - 1, - "check team workflow settings in Linear", - ) - } - unstarted = { id: stateNode.id, name: stateNode.name } - unstartedStateByTeam.set(team.id, unstarted) - } - - await client.updateIssue(issue.id, { stateId: unstarted.id }) - reopenedIssues.push({ - identifier: issue.identifier, - url: issue.url, - state: unstarted.name, - }) - if (format === "table") { - console.error(` assign: linear issue assign ${issue.identifier}`) - } - } - - if (format === "json") { - const payload = reopenedIssues.map((issue) => ({ - id: issue.identifier, - status: "success", - url: issue.url, - metadata: { state: issue.state }, - })) - renderJson(payload.length === 1 ? payload[0] : payload) - return - } - - for (const issue of reopenedIssues) { - renderMutationOutput({ - format, - result: buildMutationResult({ - id: issue.identifier, - entity: "issue", - action: "reopen", - status: "success", - url: issue.url, - metadata: { state: issue.state }, - }), - }) - } - }) - -const startCommand = new Command() - .alias("begin") - .description("Start issue (set to in-progress state)") - .example("Start working on issue", "linear issue start POL-5") - .example("Start multiple issues", "linear issue start POL-1 POL-2") - .arguments("") - .action(async (options, ...ids: [string, ...Array]) => { - const { format, client } = await getCommandContext(options) - const teamKey = (options as unknown as { team?: string }).team - - const startedStateByTeam = new Map() - const startedIssues: Array<{ - identifier: string - url: string - state: string - }> = [] - - for (const id of ids) { - const issue = await resolveIssue(client, id, teamKey) - const state = await issue.state - const team = await state?.team - if (!team) throw new CliError("cannot determine team for issue", 1) - - let started = startedStateByTeam.get(team.id) - if (!started) { - const states = await team.states() - const stateNode = states.nodes.find((s) => s.type === "started") - if (!stateNode) { - throw new CliError( - "no started state found for team", - 1, - "check team workflow settings in Linear", - ) - } - started = { id: stateNode.id, name: stateNode.name } - startedStateByTeam.set(team.id, started) - } - - await client.updateIssue(issue.id, { stateId: started.id }) - startedIssues.push({ - identifier: issue.identifier, - url: issue.url, - state: started.name, - }) - if (format === "table") { - console.error(` close when done: linear issue close ${issue.identifier}`) - } - } - - if (format === "json") { - const payload = startedIssues.map((issue) => ({ - id: issue.identifier, - status: "success", - url: issue.url, - metadata: { state: issue.state }, - })) - renderJson(payload.length === 1 ? payload[0] : payload) - return - } - - for (const issue of startedIssues) { - renderMutationOutput({ - format, - result: buildMutationResult({ - id: issue.identifier, - entity: "issue", - action: "start", - status: "success", - url: issue.url, - metadata: { state: issue.state }, - }), - }) - } - }) - -const assignCommand = new Command() - .description("Assign issue to user (defaults to me)") - .example("Assign to me", "linear issue assign POL-5") - .example("Assign to someone", "linear issue assign POL-5 'Jane Smith'") - .example( - "Assign multiple issues", - "linear issue assign POL-1 POL-2 --user 'Jane Smith'", - ) - .arguments("") - .option("-u, --user ", "Assignee (defaults to me)") - .action(async (options, ...targets: [string, ...Array]) => { - const { format, client } = await getCommandContext(options) - const teamKey = (options as { team?: string }).team - - const ids = [...targets] - let assigneeName = (options as { user?: string }).user - - if ( - !assigneeName && - ids.length > 1 && - !/^[A-Za-z0-9]+-\d+$/.test(ids[ids.length - 1]) - ) { - assigneeName = ids.pop() - } - - if (ids.length === 0) { - throw new CliError( - "at least one issue id is required", - 4, - "issue assign POL-1 [POL-2 ...] [--user ]", - ) - } - - assigneeName = assigneeName ?? "me" - const assigneeId = await resolveUser(client, assigneeName) - - const issues = await Promise.all( - ids.map((id) => resolveIssue(client, id, teamKey)), - ) - await Promise.all(issues.map((issue) => client.updateIssue(issue.id, { assigneeId }))) - - // Resolve display name for confirmation - let displayName: string - if (assigneeName === "me") { - const viewer = await client.viewer - displayName = viewer.name - } else { - // Re-fetch to get the resolved name - const updated = await client.issue(issues[0].id) - const assignee = await updated.assignee - displayName = assignee?.name ?? assigneeName - } - - if (format === "json") { - const payload = issues.map((issue) => ({ - id: issue.identifier, - status: "success", - url: issue.url, - metadata: { assignee: displayName }, - })) - renderJson(payload.length === 1 ? payload[0] : payload) - return - } - - for (const issue of issues) { - renderMutationOutput({ - format, - result: buildMutationResult({ - id: issue.identifier, - entity: "issue", - action: "assign", - status: "success", - url: issue.url, - metadata: { assignee: displayName }, - }), - }) - if (format === "table") { - console.error(` start: linear issue start ${issue.identifier}`) - } - } - }) - -const TERMINAL_SESSION_STATES = new Set(["complete", "error", "awaitingInput"]) - -/** Find the latest agent session for an issue. Returns null if none. */ -async function getLatestSession( - client: LinearClient, - issueId: string, -) { - const allSessions = await client.agentSessions() - let latest = null - for (const session of allSessions.nodes) { - const sessionIssue = await session.issue - if (sessionIssue?.id === issueId) { - if ( - !latest || - new Date(session.createdAt) > new Date(latest.createdAt) - ) { - latest = session - } - } - } - return latest -} - -const watchCommand = new Command() - .description("Watch issue until agent session completes") - .example( - "Watch until done", - "linear issue watch POL-7", - ) - .example( - "Custom interval and timeout", - "linear issue watch POL-7 --interval 30 --timeout 600", - ) - .arguments("") - .option("--interval ", "Poll interval in seconds", { - default: 15, - }) - .option("--timeout ", "Timeout in seconds (0 = no limit)", { - default: 0, - }) - .action(async (options, id: string) => { - const { format, client } = await getCommandContext(options) - const teamKey = (options as unknown as { team?: string }).team - - const issue = await resolveIssue(client, id, teamKey) - const interval = (options.interval ?? 15) * 1000 - const timeout = (options.timeout ?? 0) * 1000 - const start = Date.now() - - if (format === "table") { - console.error( - `Watching ${issue.identifier} for agent session completion...`, - ) - } - - while (true) { - const session = await getLatestSession(client, issue.id) - - if (session && TERMINAL_SESSION_STATES.has(session.status)) { - const appUser = await session.appUser - const activities = await session.activities() - const responseActivity = activities.nodes.find( - (a) => a.content.__typename === "AgentActivityResponseContent", - ) - const summary = responseActivity?.content.__typename === - "AgentActivityResponseContent" - ? responseActivity.content.body - : null - - const result = { - issue: issue.identifier, - agent: appUser?.name ?? "Unknown", - status: session.status, - summary, - // externalUrls is typed as Record in SDK but is actually { url: string }[] - externalUrl: session.externalLinks?.[0]?.url ?? - (session.externalUrls as unknown as { url?: string }[] | undefined) - ?.[0]?.url ?? - null, - elapsed: Math.round((Date.now() - start) / 1000), - } - - if (format === "json") { - renderJson(result) - } else if (format === "compact") { - console.log( - `${result.issue}\t${result.agent}\t${result.status}\t${result.elapsed}s\t${ - result.summary?.replace(/\n/g, " ").slice(0, 200) ?? "-" - }\t${result.externalUrl ?? "-"}`, - ) - } else { - console.log( - `${result.issue}: ${result.agent} → ${result.status} (${result.elapsed}s)`, - ) - if (result.summary) { - console.log( - renderMarkdown(result.summary, { indent: " " }), - ) - } - if (result.externalUrl) { - console.log(` View task → ${result.externalUrl}`) - } - } - - // Exit code based on status - if (session.status === "error") Deno.exit(1) - if (session.status === "awaitingInput") Deno.exit(2) - return // complete → exit 0 - } - - // Timeout check - if (timeout > 0 && Date.now() - start > timeout) { - const status = session ? session.status : "no session" - const timeoutPayload = { - issue: issue.identifier, - status: "timeout", - lastSessionStatus: status, - elapsed: Math.round((Date.now() - start) / 1000), - } - if (format === "json") { - renderJson(timeoutPayload) - } else { - console.error( - `Timeout: ${issue.identifier} still ${status} after ${ - Math.round((Date.now() - start) / 1000) - }s`, - ) - } - Deno.exit(124) - } - - // Status update on stderr (doesn't pollute output) - if (format === "table") { - const status = session ? session.status : "waiting for session" - console.error( - ` ${status} (${Math.round((Date.now() - start) / 1000)}s)`, - ) - } - - await new Promise((r) => setTimeout(r, interval)) - } - }) - -export const issueCommand = new Command() - .description("Manage issues") - .alias("issues") - .example("List issues", "linear issue list --team POL") - .example("View issue", "linear issue view POL-5") - .example("Close issue", "linear issue close POL-5") - .command("list", listCommand) - .command("view", viewCommand) - .command("create", createCommand) - .command("update", updateCommand) - .command("delete", deleteCommand) - .command("comment", commentCommand) - .command("branch", branchCommand) - .command("close", closeCommand) - .command("reopen", reopenCommand) - .command("start", startCommand) - .command("assign", assignCommand) - .command("watch", watchCommand) +export { issueCommand } from "./issue/index.ts" diff --git a/src/commands/issue/comment.ts b/src/commands/issue/comment.ts new file mode 100644 index 0000000..6c9b83a --- /dev/null +++ b/src/commands/issue/comment.ts @@ -0,0 +1,105 @@ +import { Command } from "@cliffy/command" +import { CliError } from "../../errors.ts" +import { render, renderMessage } from "../../output/formatter.ts" +import { renderJson } from "../../output/json.ts" +import { readStdin, resolveIssue } from "../../resolve.ts" +import { compactTime, relativeTime } from "../../time.ts" +import { getCommandContext } from "../_shared/context.ts" + +export const commentListCommand = new Command() + .description("List comments on issue") + .arguments("") + .action(async (options, id: string) => { + const { format, client } = await getCommandContext(options) + const teamKey = (options as unknown as { team?: string }).team + + const issue = await resolveIssue(client, id, teamKey) + const commentsConn = await issue.comments() + const comments = commentsConn.nodes + + const rows = await Promise.all( + comments.map(async (c) => { + const user = await c.user + return { + author: user?.name ?? "Unknown", + body: c.body ?? "", + createdAt: c.createdAt, + } + }), + ) + + if (format === "json") { + renderJson(rows) + return + } + + if (format === "table") { + render("table", { + headers: ["Author", "Age", "Body"], + rows: rows.map((r) => [ + r.author, + relativeTime(r.createdAt), + r.body.length > 80 ? r.body.slice(0, 77) + "..." : r.body, + ]), + }) + } else { + render("compact", { + headers: ["Author", "Age", "Body"], + rows: rows.map((r) => [ + r.author, + compactTime(r.createdAt), + r.body.replace(/\n/g, " "), + ]), + }) + } + }) + +async function addComment( + options: unknown, + id: string, + bodyArg?: string, +): Promise { + const { format, client } = await getCommandContext(options) + const teamKey = (options as unknown as { team?: string }).team + + const issue = await resolveIssue(client, id, teamKey) + + const body = bodyArg ?? (options as { body?: string }).body ?? + (await readStdin()) + if (!body) { + throw new CliError( + "comment body required", + 4, + `issue comment ${id} "your comment" (or --body or pipe via stdin)`, + ) + } + + await client.createComment({ issueId: issue.id, body }) + renderMessage(format, `Comment added to ${issue.identifier}`) +} + +export const commentCommand = new Command() + .description("Add comment or list comments") + .example("Add a comment", "linear issue comment POL-5 'Looks good'") + .example("List comments", "linear issue comment list POL-5") + .arguments(" [body:string]") + .option("--body ", "Comment text (alternative to positional)") + .action((options: Record, id: string, bodyArg?: string) => + addComment(options, id, bodyArg) + ) + .command( + "add", + new Command() + .description("Add comment to issue") + .arguments(" [body:string]") + .option( + "--body ", + "Comment text (alternative to positional)", + ) + .action(( + options: Record, + id: string, + bodyArg?: string, + ) => addComment(options, id, bodyArg)), + ) + .command("list", commentListCommand) diff --git a/src/commands/issue/index.ts b/src/commands/issue/index.ts new file mode 100644 index 0000000..50ba44d --- /dev/null +++ b/src/commands/issue/index.ts @@ -0,0 +1,34 @@ +import { Command } from "@cliffy/command" +import { commentCommand } from "./comment.ts" +import { branchCommand, listCommand, viewCommand } from "./read.ts" +import { + assignCommand, + closeCommand, + createCommand, + deleteCommand, + reopenCommand, + startCommand, + updateCommand, +} from "./mutate.ts" +import { watchCommand } from "./watch.ts" + +export const issueCommand = new Command() + .description("Manage issues") + .alias("issues") + .example("List issues", "linear issue list --team POL") + .example("View issue", "linear issue view POL-5") + .example("Close issue", "linear issue close POL-5") + .command("list", listCommand) + .command("view", viewCommand) + .command("create", createCommand) + .command("update", updateCommand) + .command("delete", deleteCommand) + .command("comment", commentCommand) + .command("branch", branchCommand) + .command("close", closeCommand) + .command("reopen", reopenCommand) + .command("start", startCommand) + .command("assign", assignCommand) + .command("watch", watchCommand) + +export { fetchIssueAgentSessions, getLatestSession } from "./shared.ts" diff --git a/src/commands/issue/mutate.ts b/src/commands/issue/mutate.ts new file mode 100644 index 0000000..98ca5af --- /dev/null +++ b/src/commands/issue/mutate.ts @@ -0,0 +1,577 @@ +import { Command } from "@cliffy/command" +import { CliError } from "../../errors.ts" +import { renderMessage } from "../../output/formatter.ts" +import { renderJson } from "../../output/json.ts" +import { + readStdin, + resolveIssue, + resolveLabel, + resolvePriority, + resolveProject, + resolveState, + resolveTeamId, + resolveUser, +} from "../../resolve.ts" +import { confirmDangerousAction } from "../_shared/confirm.ts" +import { getCommandContext } from "../_shared/context.ts" +import { + buildMutationResult, + renderMutationOutput, +} from "../_shared/mutation_output.ts" +import { renderTableHint } from "../_shared/streams.ts" +import { priorityName } from "./shared.ts" + +export const createCommand = new Command() + .description("Create issue") + .example( + "Create a bug", + "linear issue create --team POL --title 'Login crash' --priority urgent --label bug", + ) + .example( + "Create and assign to me", + "linear issue create --team POL --title 'Fix tests' --assignee me", + ) + .option("--title ", "Issue title", { required: true }) + .option("-d, --description ", "Description") + .option("-a, --assignee ", "Assignee name or 'me'") + .option("-s, --state ", "Initial state name") + .option("--status ", "Alias for --state", { hidden: true }) + .option( + "--priority ", + "Priority: urgent, high, medium, low, none (or 0-4)", + ) + .option("-l, --label ", "Label name", { collect: true }) + .option("--type ", "Alias for --label", { hidden: true }) + .option("-p, --project ", "Project name") + .option("--parent ", "Parent issue identifier") + .action(async (options) => { + const { format, client, teamKey } = await getCommandContext(options, { + requireTeam: true, + }) + const teamId = await resolveTeamId(client, teamKey) + + const description = options.description ?? (await readStdin()) + const stateName = options.state ?? options.status + const assigneeId = options.assignee + ? await resolveUser(client, options.assignee) + : undefined + const stateId = stateName + ? await resolveState(client, teamId, stateName) + : undefined + const labelNames = options.label?.length + ? options.label + : options.type + ? [options.type] + : undefined + const labelIds = labelNames?.length + ? await Promise.all( + labelNames.map((l: string) => resolveLabel(client, teamId, l)), + ) + : undefined + const projectId = options.project + ? await resolveProject(client, options.project) + : undefined + const parentId = options.parent + ? (await resolveIssue(client, options.parent, teamKey)).id + : undefined + + const payload = await client.createIssue({ + teamId, + title: options.title, + ...(description && { description }), + ...(options.priority && { priority: resolvePriority(options.priority) }), + ...(assigneeId && { assigneeId }), + ...(stateId && { stateId }), + ...(labelIds && { labelIds }), + ...(projectId && { projectId }), + ...(parentId && { parentId }), + }) + const issue = await payload.issue + + if (!issue) { + throw new CliError("failed to create issue", 1) + } + + renderMutationOutput({ + format, + result: buildMutationResult({ + id: issue.identifier, + entity: "issue", + action: "create", + status: "success", + url: issue.url, + metadata: { title: issue.title }, + }), + }) + renderTableHint(format, ` assign: linear issue assign ${issue.identifier}`) + }) + +export const updateCommand = new Command() + .description("Update issue") + .example("Change priority", "linear issue update POL-5 --priority high") + .example("Add a label", "linear issue update POL-5 --add-label bug") + .arguments("") + .option("--title ", "New title") + .option("-d, --description ", "New description") + .option("-a, --assignee ", "New assignee (empty to unassign)") + .option("-s, --state ", "New state name") + .option("--status ", "Alias for --state", { hidden: true }) + .option( + "--priority ", + "Priority: urgent, high, medium, low, none (or 0-4)", + ) + .option("-l, --label ", "Replace all labels", { collect: true }) + .option("--add-label ", "Add label", { collect: true }) + .option("--remove-label ", "Remove label", { collect: true }) + .option("-p, --project ", "Move to project") + .option("--parent ", "Set parent issue") + .action(async (options, id: string) => { + const { format, client } = await getCommandContext(options) + const teamKey = (options as unknown as { team?: string }).team + + const issue = await resolveIssue(client, id, teamKey) + const description = options.description ?? (await readStdin()) + + const assigneeId = options.assignee !== undefined + ? (options.assignee === "" + ? null + : await resolveUser(client, options.assignee)) + : undefined + + const stateName = options.state ?? options.status + let stateId: string | undefined + if (stateName) { + const state = await issue.state + const team = await state?.team + if (team?.id) { + stateId = await resolveState(client, team.id, stateName) + } + } + + let labelIds: string[] | undefined + const issueTeamId = async () => { + const state = await issue.state + const team = await state?.team + return team?.id ?? "" + } + if (options.label?.length) { + const tid = await issueTeamId() + labelIds = await Promise.all( + options.label.map((l: string) => resolveLabel(client, tid, l)), + ) + } else if (options.addLabel?.length || options.removeLabel?.length) { + const currentLabels = await issue.labels() + const currentIds = currentLabels.nodes.map((l: { id: string }) => l.id) + const tid = await issueTeamId() + let ids = [...currentIds] + if (options.addLabel?.length) { + const addIds = await Promise.all( + options.addLabel.map((l: string) => resolveLabel(client, tid, l)), + ) + ids = [...new Set([...ids, ...addIds])] + } + if (options.removeLabel?.length) { + const removeIds = await Promise.all( + options.removeLabel.map((l: string) => resolveLabel(client, tid, l)), + ) + ids = ids.filter((id) => !removeIds.includes(id)) + } + labelIds = ids + } + + const projectId = options.project + ? await resolveProject(client, options.project) + : undefined + const parentId = options.parent + ? (await resolveIssue(client, options.parent, teamKey)).id + : undefined + + await client.updateIssue(issue.id, { + ...(options.title && { title: options.title }), + ...(description !== undefined && { description }), + ...(options.priority && { priority: resolvePriority(options.priority) }), + ...(assigneeId !== undefined && { assigneeId }), + ...(stateId && { stateId }), + ...(labelIds && { labelIds }), + ...(projectId && { projectId }), + ...(parentId && { parentId }), + }) + + const updated = await client.issue(issue.id) + const updatedState = await updated.state + const updatedAssignee = await updated.assignee + const updatedDelegate = await updated.delegate + + renderMutationOutput({ + format, + result: buildMutationResult({ + id: updated.identifier, + entity: "issue", + action: "update", + status: "success", + url: updated.url, + metadata: { + title: updated.title, + state: updatedState?.name ?? "-", + priority: priorityName(updated.priority), + assignee: updatedAssignee?.name ?? null, + delegate: updatedDelegate?.name ?? null, + }, + }), + }) + }) + +export const deleteCommand = new Command() + .description("Delete (archive) issue") + .example("Delete an issue", "linear issue delete POL-5") + .example("Delete multiple issues", "linear issue delete POL-1 POL-2 POL-3") + .arguments("") + .example("Delete without confirmation", "linear issue delete POL-5 --yes") + .option("-y, --yes", "Skip confirmation prompt") + .action(async (options, ...ids: [string, ...Array]) => { + const { format, client, noInput } = await getCommandContext(options) + const teamKey = (options as { team?: string }).team + + const issues = await Promise.all( + ids.map((id) => resolveIssue(client, id, teamKey)), + ) + const label = issues.length === 1 + ? `${issues[0].identifier} "${issues[0].title}"` + : `${issues.length} issues (${ + issues.map((issue) => issue.identifier).join(", ") + })` + const confirmed = await confirmDangerousAction({ + prompt: `Delete ${label}?`, + skipConfirm: Boolean((options as { yes?: boolean }).yes) || noInput, + }) + if (!confirmed) { + renderMessage(format, "Canceled") + return + } + + await Promise.all(issues.map((issue) => client.archiveIssue(issue.id))) + + const payload = issues.map((issue) => ({ + id: issue.identifier, + status: "success", + url: issue.url, + metadata: { title: issue.title }, + })) + if (format === "json") { + renderJson(payload.length === 1 ? payload[0] : payload) + return + } + + for (const issue of issues) { + renderMutationOutput({ + format, + result: buildMutationResult({ + id: issue.identifier, + entity: "issue", + action: "delete", + status: "success", + url: issue.url, + metadata: { title: issue.title }, + }), + }) + } + }) + +export const closeCommand = new Command() + .alias("done") + .alias("finish") + .alias("complete") + .alias("resolve") + .description("Close issue (set to completed state)") + .example("Close an issue", "linear issue close POL-5") + .example("Close multiple issues", "linear issue close POL-1 POL-2 POL-3") + .arguments("") + .action(async (options, ...ids: [string, ...Array]) => { + const { format, client } = await getCommandContext(options) + const teamKey = (options as unknown as { team?: string }).team + + const completedStateByTeam = new Map() + const closedIssues: Array< + { identifier: string; url: string; state: string } + > = [] + + for (const id of ids) { + const issue = await resolveIssue(client, id, teamKey) + const state = await issue.state + const team = await state?.team + if (!team) throw new CliError("cannot determine team for issue", 1) + + let completed = completedStateByTeam.get(team.id) + if (!completed) { + const states = await team.states() + const stateNode = states.nodes.find((s) => s.type === "completed") + if (!stateNode) { + throw new CliError( + "no completed state found for team", + 1, + "check team workflow settings in Linear", + ) + } + completed = { id: stateNode.id, name: stateNode.name } + completedStateByTeam.set(team.id, completed) + } + + await client.updateIssue(issue.id, { stateId: completed.id }) + closedIssues.push({ + identifier: issue.identifier, + url: issue.url, + state: completed.name, + }) + } + + if (format === "json") { + const payload = closedIssues.map((issue) => ({ + id: issue.identifier, + status: "success", + url: issue.url, + metadata: { state: issue.state }, + })) + renderJson(payload.length === 1 ? payload[0] : payload) + return + } + + for (const issue of closedIssues) { + renderMutationOutput({ + format, + result: buildMutationResult({ + id: issue.identifier, + entity: "issue", + action: "close", + status: "success", + url: issue.url, + metadata: { state: issue.state }, + }), + }) + } + }) + +export const reopenCommand = new Command() + .alias("open") + .description("Reopen issue (set to unstarted state)") + .example("Reopen an issue", "linear issue reopen POL-5") + .example("Reopen multiple issues", "linear issue reopen POL-1 POL-2") + .arguments("") + .action(async (options, ...ids: [string, ...Array]) => { + const { format, client } = await getCommandContext(options) + const teamKey = (options as unknown as { team?: string }).team + + const unstartedStateByTeam = new Map() + const reopenedIssues: Array< + { identifier: string; url: string; state: string } + > = [] + + for (const id of ids) { + const issue = await resolveIssue(client, id, teamKey) + const state = await issue.state + const team = await state?.team + if (!team) throw new CliError("cannot determine team for issue", 1) + + let unstarted = unstartedStateByTeam.get(team.id) + if (!unstarted) { + const states = await team.states() + const stateNode = states.nodes.find((s) => s.type === "unstarted") + if (!stateNode) { + throw new CliError( + "no unstarted state found for team", + 1, + "check team workflow settings in Linear", + ) + } + unstarted = { id: stateNode.id, name: stateNode.name } + unstartedStateByTeam.set(team.id, unstarted) + } + + await client.updateIssue(issue.id, { stateId: unstarted.id }) + reopenedIssues.push({ + identifier: issue.identifier, + url: issue.url, + state: unstarted.name, + }) + renderTableHint( + format, + ` assign: linear issue assign ${issue.identifier}`, + ) + } + + if (format === "json") { + const payload = reopenedIssues.map((issue) => ({ + id: issue.identifier, + status: "success", + url: issue.url, + metadata: { state: issue.state }, + })) + renderJson(payload.length === 1 ? payload[0] : payload) + return + } + + for (const issue of reopenedIssues) { + renderMutationOutput({ + format, + result: buildMutationResult({ + id: issue.identifier, + entity: "issue", + action: "reopen", + status: "success", + url: issue.url, + metadata: { state: issue.state }, + }), + }) + } + }) + +export const startCommand = new Command() + .alias("begin") + .description("Start issue (set to in-progress state)") + .example("Start working on issue", "linear issue start POL-5") + .example("Start multiple issues", "linear issue start POL-1 POL-2") + .arguments("") + .action(async (options, ...ids: [string, ...Array]) => { + const { format, client } = await getCommandContext(options) + const teamKey = (options as unknown as { team?: string }).team + + const startedStateByTeam = new Map() + const startedIssues: Array< + { identifier: string; url: string; state: string } + > = [] + + for (const id of ids) { + const issue = await resolveIssue(client, id, teamKey) + const state = await issue.state + const team = await state?.team + if (!team) throw new CliError("cannot determine team for issue", 1) + + let started = startedStateByTeam.get(team.id) + if (!started) { + const states = await team.states() + const stateNode = states.nodes.find((s) => s.type === "started") + if (!stateNode) { + throw new CliError( + "no started state found for team", + 1, + "check team workflow settings in Linear", + ) + } + started = { id: stateNode.id, name: stateNode.name } + startedStateByTeam.set(team.id, started) + } + + await client.updateIssue(issue.id, { stateId: started.id }) + startedIssues.push({ + identifier: issue.identifier, + url: issue.url, + state: started.name, + }) + renderTableHint( + format, + ` close when done: linear issue close ${issue.identifier}`, + ) + } + + if (format === "json") { + const payload = startedIssues.map((issue) => ({ + id: issue.identifier, + status: "success", + url: issue.url, + metadata: { state: issue.state }, + })) + renderJson(payload.length === 1 ? payload[0] : payload) + return + } + + for (const issue of startedIssues) { + renderMutationOutput({ + format, + result: buildMutationResult({ + id: issue.identifier, + entity: "issue", + action: "start", + status: "success", + url: issue.url, + metadata: { state: issue.state }, + }), + }) + } + }) + +export const assignCommand = new Command() + .description("Assign issue to user (defaults to me)") + .example("Assign to me", "linear issue assign POL-5") + .example("Assign to someone", "linear issue assign POL-5 'Jane Smith'") + .example( + "Assign multiple issues", + "linear issue assign POL-1 POL-2 --user 'Jane Smith'", + ) + .arguments("") + .option("-u, --user ", "Assignee (defaults to me)") + .action(async (options, ...targets: [string, ...Array]) => { + const { format, client } = await getCommandContext(options) + const teamKey = (options as { team?: string }).team + + const ids = [...targets] + let assigneeName = (options as { user?: string }).user + + if ( + !assigneeName && + ids.length > 1 && + !/^[A-Za-z0-9]+-\d+$/.test(ids[ids.length - 1]) + ) { + assigneeName = ids.pop() + } + + if (ids.length === 0) { + throw new CliError( + "at least one issue id is required", + 4, + "issue assign POL-1 [POL-2 ...] [--user ]", + ) + } + + assigneeName = assigneeName ?? "me" + const assigneeId = await resolveUser(client, assigneeName) + const issues = await Promise.all( + ids.map((id) => resolveIssue(client, id, teamKey)), + ) + await Promise.all( + issues.map((issue) => client.updateIssue(issue.id, { assigneeId })), + ) + + let displayName: string + if (assigneeName === "me") { + displayName = (await client.viewer).name + } else { + const updated = await client.issue(issues[0].id) + const assignee = await updated.assignee + displayName = assignee?.name ?? assigneeName + } + + if (format === "json") { + const payload = issues.map((issue) => ({ + id: issue.identifier, + status: "success", + url: issue.url, + metadata: { assignee: displayName }, + })) + renderJson(payload.length === 1 ? payload[0] : payload) + return + } + + for (const issue of issues) { + renderMutationOutput({ + format, + result: buildMutationResult({ + id: issue.identifier, + entity: "issue", + action: "assign", + status: "success", + url: issue.url, + metadata: { assignee: displayName }, + }), + }) + renderTableHint(format, ` start: linear issue start ${issue.identifier}`) + } + }) diff --git a/src/commands/issue/read.ts b/src/commands/issue/read.ts new file mode 100644 index 0000000..52d1348 --- /dev/null +++ b/src/commands/issue/read.ts @@ -0,0 +1,466 @@ +import { Command } from "@cliffy/command" +import { PaginationOrderBy } from "@linear/sdk" +import { CliError } from "../../errors.ts" +import { render, renderMessage } from "../../output/formatter.ts" +import { renderJson } from "../../output/json.ts" +import { + resolveIssue, + resolveLabel, + resolvePriority, + resolveProject, + resolveTeam, + resolveTeamId, + resolveUser, +} from "../../resolve.ts" +import { compactTime, formatDate, relativeTime } from "../../time.ts" +import { renderMarkdown } from "../../output/markdown.ts" +import { getCommandContext } from "../_shared/context.ts" +import { + DEFAULT_ACTIVE_STATES, + fetchIssueAgentSessions, + priorityIndicator, + priorityName, +} from "./shared.ts" + +export const listCommand = new Command() + .description("List issues") + .example("List team issues", "linear issue list --team POL") + .example("List my issues", "linear issue list --team POL --assignee me") + .example("Urgent issues", "linear issue list --team POL --priority urgent") + .example("Current cycle", "linear issue list --team POL --cycle current") + .example("Overdue issues", "linear issue list --team POL --overdue") + .option("-s, --state ", "State type filter", { collect: true }) + .option("--status ", "Alias for --state", { + collect: true, + hidden: true, + }) + .option("-a, --assignee ", "Filter by assignee") + .option("-U, --unassigned", "Show only unassigned") + .option("-l, --label ", "Filter by label", { collect: true }) + .option("-p, --project ", "Filter by project") + .option( + "--priority ", + "Filter by priority: urgent, high, medium, low, none (or 0-4)", + ) + .option( + "--cycle ", + "Filter by cycle: current, next, or cycle number", + ) + .option("--due ", "Issues due on or before date (YYYY-MM-DD)") + .option("--overdue", "Show only overdue issues (past due date)") + .option("--sort ", "Sort: updated, created, priority", { + default: "updatedAt", + }) + .option("--limit ", "Max results", { default: 50 }) + .option("--include-completed", "Include completed/canceled") + .option("--mine", "Only my issues (shorthand for --assignee me)", { + hidden: true, + }) + .action(async (options) => { + const { format, client, teamKey } = await getCommandContext(options, { + requireTeam: true, + }) + + const states = options.state ?? options.status + const stateTypes = states?.length + ? states + : options.includeCompleted + ? undefined + : DEFAULT_ACTIVE_STATES + + const assigneeName = options.assignee ?? (options.mine ? "me" : undefined) + const userId = assigneeName + ? await resolveUser(client, assigneeName) + : undefined + + const teamId = (options.label?.length || options.cycle) + ? await resolveTeamId(client, teamKey) + : undefined + const labelIds = options.label?.length && teamId + ? await Promise.all( + options.label.map((l: string) => resolveLabel(client, teamId, l)), + ) + : undefined + + const projectId = options.project + ? await resolveProject(client, options.project) + : undefined + + let cycleId: string | undefined + if (options.cycle) { + const team = await resolveTeam(client, teamKey) + const cycles = await team.cycles() + const now = new Date() + + if (options.cycle === "current") { + const current = cycles.nodes.find(( + c: { startsAt: Date; endsAt: Date }, + ) => new Date(c.startsAt) <= now && now <= new Date(c.endsAt)) + if (!current) { + throw new CliError( + "no active cycle found", + 3, + "list cycles with: linear cycle list --team " + teamKey, + ) + } + cycleId = current.id + } else if (options.cycle === "next") { + const future = cycles.nodes + .filter((c: { startsAt: Date }) => new Date(c.startsAt) > now) + .sort((a: { startsAt: Date }, b: { startsAt: Date }) => + new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime() + ) + if (future.length === 0) { + throw new CliError( + "no upcoming cycle found", + 3, + "list cycles with: linear cycle list --team " + teamKey, + ) + } + cycleId = future[0].id + } else { + if (!/^\d+$/.test(options.cycle)) { + throw new CliError( + `invalid cycle "${options.cycle}"`, + 4, + "--cycle current, --cycle next, or --cycle ", + ) + } + const num = Number(options.cycle) + const match = cycles.nodes.find((c: { number: number }) => + c.number === num + ) + if (!match) { + throw new CliError( + `cycle #${num} not found`, + 3, + "list cycles with: linear cycle list --team " + teamKey, + ) + } + cycleId = match.id + } + } + + if (options.due && !/^\d{4}-\d{2}-\d{2}$/.test(options.due)) { + throw new CliError(`invalid date "${options.due}"`, 4, "--due YYYY-MM-DD") + } + const dueDate = (options.due || options.overdue) + ? { + ...(options.due && { lte: options.due }), + ...(options.overdue && { lt: new Date().toISOString().slice(0, 10) }), + } + : undefined + + const filter = { + team: { key: { eq: teamKey } }, + ...(stateTypes && { state: { type: { in: stateTypes } } }), + ...(userId && { assignee: { id: { eq: userId } } }), + ...(options.unassigned && !assigneeName && { assignee: { null: true } }), + ...(labelIds && { labels: { id: { in: labelIds } } }), + ...(projectId && { project: { id: { eq: projectId } } }), + ...(options.priority && + { priority: { eq: resolvePriority(options.priority) } }), + ...(cycleId && { cycle: { id: { eq: cycleId } } }), + ...(dueDate && { dueDate }), + } + + const sortMap: Record = { + updated: "updatedAt", + created: "createdAt", + } + const sortField = sortMap[options.sort] ?? options.sort + + const orderBy = sortField === "createdAt" + ? PaginationOrderBy.CreatedAt + : PaginationOrderBy.UpdatedAt + + if (Deno.stderr.isTerminal()) { + Deno.stderr.writeSync(new TextEncoder().encode("Fetching...\r")) + } + + const issues = await client.issues({ + filter, + first: options.limit || undefined, + orderBy, + }) + + const rows = await Promise.all( + issues.nodes.map(async (issue) => { + const state = await issue.state + const assignee = await issue.assignee + const delegate = await issue.delegate + return { + identifier: issue.identifier, + title: issue.title, + priority: issue.priority, + state: state?.name ?? "-", + assignee: assignee?.name ?? "-", + delegate: delegate?.name ?? null, + updatedAt: issue.updatedAt, + url: issue.url, + } + }), + ) + + if (sortField === "priority") { + rows.sort((a, b) => (a.priority ?? 5) - (b.priority ?? 5)) + } + + if (format === "json") { + renderJson(rows) + return + } + + if (rows.length === 0) { + if (options.assignee) { + const viewer = options.assignee === "me" + ? await (async () => (await client.viewer).name)() + : options.assignee + renderMessage( + format, + `No issues found for assignee "${viewer}"${ + options.assignee === "me" ? ' (resolved from "me")' : "" + } in team ${teamKey}`, + ) + } else { + renderMessage(format, `No issues found in team ${teamKey}`) + } + return + } + + const hasDelegate = rows.some((r) => r.delegate) + + if (format === "table") { + const headers = ["\u25CC", "ID", "State", "Assignee"] + if (hasDelegate) headers.push("Delegate") + headers.push("Title", "Updated") + render("table", { + headers, + rows: rows.map((r) => { + const row = [ + priorityIndicator(r.priority), + r.identifier, + r.state, + r.assignee, + ] + if (hasDelegate) row.push(r.delegate ?? "-") + row.push(r.title, relativeTime(r.updatedAt)) + return row + }), + }) + } else { + const headers = ["ID", "State", "Assignee"] + if (hasDelegate) headers.push("Delegate") + headers.push("Title", "Updated") + render("compact", { + headers, + rows: rows.map((r) => { + const row = [r.identifier, r.state, r.assignee] + if (hasDelegate) row.push(r.delegate ?? "-") + row.push(r.title, compactTime(r.updatedAt)) + return row + }), + }) + } + }) + +export const viewCommand = new Command() + .alias("show") + .description("View issue details") + .example("View an issue", "linear issue view POL-5") + .arguments("") + .option("-v, --verbose", "Show full agent activity log") + .action(async (options, id: string) => { + const { format, client } = await getCommandContext(options) + const teamKey = (options as unknown as { team?: string }).team + + const issue = await resolveIssue(client, id, teamKey) + const state = await issue.state + const assignee = await issue.assignee + const delegate = await issue.delegate + const labelsConn = await issue.labels() + const labels = labelsConn.nodes + const project = await issue.project + const cycle = await issue.cycle + const commentsConn = await issue.comments() + const comments = commentsConn.nodes + + const branchName = await issue.branchName + const sessions = await fetchIssueAgentSessions( + client, + issue.id, + Boolean((options as { verbose?: boolean }).verbose), + ) + + const commentData = await Promise.all( + comments.map(async (c) => { + const user = await c.user + return { + author: user?.name ?? "Unknown", + body: c.body, + createdAt: c.createdAt, + } + }), + ) + + const payload = { + id: issue.identifier, + title: issue.title, + state: state?.name ?? "-", + priority: priorityName(issue.priority), + assignee: assignee?.name ?? null, + delegate: delegate?.name ?? null, + labels: labels.map((l) => l.name), + project: project?.name ?? null, + cycle: cycle?.name ?? null, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + url: issue.url, + branchName: branchName ?? null, + description: issue.description ?? null, + comments: commentData, + agentSessions: sessions, + } + + if (format === "json") { + renderJson(payload) + return + } + + if (format === "compact") { + const lines = [ + `id\t${payload.id}`, + `title\t${payload.title}`, + `state\t${payload.state}`, + `priority\t${payload.priority}`, + `assignee\t${payload.assignee ?? "-"}`, + `delegate\t${payload.delegate ?? "-"}`, + `labels\t${payload.labels.length ? payload.labels.join(", ") : "-"}`, + `project\t${payload.project ?? "-"}`, + `cycle\t${payload.cycle ?? "-"}`, + `created\t${new Date(payload.createdAt).toISOString()}`, + `updated\t${new Date(payload.updatedAt).toISOString()}`, + `url\t${payload.url}`, + `branch\t${payload.branchName ?? "-"}`, + `description\t${payload.description ?? "-"}`, + ] + if (payload.agentSessions.length > 0) { + for (const session of payload.agentSessions) { + lines.push( + `agent_session\t${session.agent}\t${session.status}\t${ + session.summary?.replace(/\n/g, " ").slice(0, 200) ?? "-" + }\t${session.externalUrl ?? "-"}`, + ) + } + } + renderMessage(format, lines.join("\n")) + return + } + + render("table", { + title: `${payload.id}: ${payload.title}`, + fields: [ + { label: "State", value: payload.state }, + { label: "Priority", value: payload.priority }, + { label: "Assignee", value: payload.assignee ?? "-" }, + { label: "Delegate", value: payload.delegate ?? "-" }, + { + label: "Labels", + value: payload.labels.length ? payload.labels.join(", ") : "-", + }, + { label: "Project", value: payload.project ?? "-" }, + { label: "Cycle", value: payload.cycle ?? "-" }, + { + label: "Created", + value: `${formatDate(payload.createdAt)} (${ + relativeTime(payload.createdAt) + })`, + }, + { + label: "Updated", + value: `${formatDate(payload.updatedAt)} (${ + relativeTime(payload.updatedAt) + })`, + }, + { label: "URL", value: payload.url }, + { label: "Branch", value: payload.branchName ?? "-" }, + ], + }) + + if (payload.description) { + renderMessage( + format, + `\nDescription:\n${renderMarkdown(payload.description)}`, + ) + } + + if (payload.comments.length > 0) { + const commentsBlock = payload.comments.map((comment) => + `\n${comment.author} (${relativeTime(comment.createdAt)}):\n${ + renderMarkdown(comment.body, { indent: " " }) + }` + ).join("\n") + renderMessage( + format, + `\nComments (${payload.comments.length}):${commentsBlock}`, + ) + } + + if (payload.agentSessions.length > 0) { + const sessionBlocks = payload.agentSessions.map((session) => { + const statusLabel = session.status === "complete" + ? "complete" + : session.status === "awaitingInput" + ? "needs input" + : session.status === "error" + ? "error" + : session.status + const lines = [ + `\n${session.agent} · ${statusLabel} · ${ + relativeTime(session.createdAt) + }`, + ] + if (session.summary) { + lines.push(renderMarkdown(session.summary, { indent: " " })) + } + if (session.externalUrl) { + lines.push(` View task → ${session.externalUrl}`) + } + if (session.activities) { + lines.push(" Activities:") + for (const act of session.activities) { + const raw = act.body.length > 120 + ? act.body.slice(0, 117) + "..." + : act.body + lines.push( + ` [${act.type}] ${renderMarkdown(raw, { indent: " " })}`, + ) + } + } + return lines.join("\n") + }).join("\n") + renderMessage( + format, + `\nAgent Sessions (${payload.agentSessions.length}):${sessionBlocks}`, + ) + } + }) + +export const branchCommand = new Command() + .description("Get git branch name for issue") + .example("Get branch name", "linear issue branch POL-5") + .arguments("") + .action(async (options, id: string) => { + const { format, client } = await getCommandContext(options) + const teamKey = (options as unknown as { team?: string }).team + + const issue = await resolveIssue(client, id, teamKey) + const payload = { branchName: issue.branchName } + + if (format === "json") { + renderJson(payload) + return + } + + renderMessage(format, payload.branchName) + }) diff --git a/src/commands/issue/shared.ts b/src/commands/issue/shared.ts new file mode 100644 index 0000000..d4592f3 --- /dev/null +++ b/src/commands/issue/shared.ts @@ -0,0 +1,130 @@ +import type { LinearClient } from "@linear/sdk" + +export function priorityIndicator(priority: number): string { + switch (priority) { + case 1: + return "!!!" + case 2: + return "!!" + case 3: + return "!" + default: + return "---" + } +} + +export function priorityName(priority: number): string { + switch (priority) { + case 1: + return "Urgent" + case 2: + return "High" + case 3: + return "Medium" + case 4: + return "Low" + default: + return "None" + } +} + +export const DEFAULT_ACTIVE_STATES = [ + "triage", + "backlog", + "unstarted", + "started", +] + +export const TERMINAL_SESSION_STATES = new Set([ + "complete", + "error", + "awaitingInput", +]) + +interface SessionActivityView { + type: string + body: string + ephemeral: boolean + createdAt: string | Date +} + +export interface AgentSessionView { + agent: string + status: string + createdAt: string | Date + summary: string | null + externalUrl: string | null + activities: SessionActivityView[] | null +} + +export async function fetchIssueAgentSessions( + client: LinearClient, + issueId: string, + verbose: boolean, +): Promise { + let connection = await client.agentSessions({ first: 50 }) + const sessions = [...connection.nodes] + + while (connection.pageInfo.hasNextPage) { + connection = await connection.fetchNext() + sessions.push(...connection.nodes) + } + + const filtered = [] + for (const session of sessions) { + const sessionIssue = await session.issue + if (sessionIssue?.id !== issueId) continue + filtered.push(session) + } + + return await Promise.all( + filtered.map(async (session) => { + const appUser = await session.appUser + const activities = await session.activities() + const responseActivity = activities.nodes.find( + (a) => a.content.__typename === "AgentActivityResponseContent", + ) + const summary = responseActivity?.content.__typename === + "AgentActivityResponseContent" + ? responseActivity.content.body + : null + return { + agent: appUser?.name ?? "Unknown", + status: session.status, + createdAt: session.createdAt, + summary, + // externalUrls is typed as Record in SDK but is actually { url: string }[] + externalUrl: session.externalLinks?.[0]?.url ?? + (session.externalUrls as unknown as { url?: string }[] | undefined) + ?.[0]?.url ?? + null, + activities: verbose + ? activities.nodes.map((a) => ({ + type: a.content.__typename + .replace("AgentActivity", "") + .replace("Content", "") + .toLowerCase(), + body: "body" in a.content ? a.content.body : "", + ephemeral: a.ephemeral, + createdAt: a.createdAt, + })) + : null, + } + }), + ) +} + +/** Find the latest agent session for an issue. Returns null if none. */ +export async function getLatestSession( + client: LinearClient, + issueId: string, +): Promise { + const sessions = await fetchIssueAgentSessions(client, issueId, false) + let latest: AgentSessionView | null = null + for (const session of sessions) { + if (!latest || new Date(session.createdAt) > new Date(latest.createdAt)) { + latest = session + } + } + return latest +} diff --git a/src/commands/issue/watch.ts b/src/commands/issue/watch.ts new file mode 100644 index 0000000..50e4600 --- /dev/null +++ b/src/commands/issue/watch.ts @@ -0,0 +1,158 @@ +import { Command } from "@cliffy/command" +import type { Format } from "../../output/formatter.ts" +import { renderMessage } from "../../output/formatter.ts" +import { renderJson } from "../../output/json.ts" +import { resolveIssue } from "../../resolve.ts" +import { renderMarkdown } from "../../output/markdown.ts" +import { getCommandContext } from "../_shared/context.ts" +import { getLatestSession, TERMINAL_SESSION_STATES } from "./shared.ts" + +export { fetchIssueAgentSessions, getLatestSession } from "./shared.ts" + +export interface WatchResult { + issue: string + agent: string + status: string + summary: string | null + externalUrl: string | null + elapsed: number +} + +export interface WatchTimeoutResult { + issue: string + status: "timeout" + lastSessionStatus: string + elapsed: number +} + +function compactField(value: string | null): string { + if (!value) return "-" + const normalized = value.replace(/[\t\r\n]+/g, " ").trim() + return normalized.length > 0 ? normalized : "-" +} + +function renderWatchCompactResult(result: WatchResult): void { + renderMessage( + "compact", + `${compactField(result.issue)}\t${compactField(result.agent)}\t${ + compactField(result.status) + }\t${result.elapsed}s\t${compactField(result.summary)}\t${ + compactField(result.externalUrl) + }`, + ) +} + +function renderWatchTableResult(result: WatchResult): void { + renderMessage( + "table", + `${result.issue}: ${result.agent} → ${result.status} (${result.elapsed}s)`, + ) + if (result.summary) { + renderMessage( + "table", + renderMarkdown(result.summary, { indent: " " }), + ) + } + if (result.externalUrl) { + renderMessage("table", ` View task → ${result.externalUrl}`) + } +} + +function renderWatchTimeoutCompactResult(result: WatchTimeoutResult): void { + renderMessage( + "compact", + `${compactField(result.issue)}\t-\ttimeout\t${result.elapsed}s\t${ + compactField(`timeout waiting for terminal session; last_status=${result.lastSessionStatus}`) + }\t-`, + ) +} + +function renderWatchTimeoutTableResult(result: WatchTimeoutResult): void { + renderMessage( + "table", + `${result.issue}: timeout (${result.elapsed}s)`, + ) + renderMessage("table", ` Last session status: ${result.lastSessionStatus}`) +} + +export function renderWatchResult(format: Format, result: WatchResult): void { + if (format === "json") { + renderJson(result) + } else if (format === "compact") { + renderWatchCompactResult(result) + } else { + renderWatchTableResult(result) + } +} + +export function renderWatchTimeoutResult( + format: Format, + result: WatchTimeoutResult, +): void { + if (format === "json") { + renderJson(result) + } else if (format === "compact") { + renderWatchTimeoutCompactResult(result) + } else { + renderWatchTimeoutTableResult(result) + } +} + +export const watchCommand = new Command() + .description("Watch issue until agent session completes") + .example("Watch until done", "linear issue watch POL-7") + .example( + "Custom interval and timeout", + "linear issue watch POL-7 --interval 30 --timeout 600", + ) + .arguments("") + .option("--interval ", "Poll interval in seconds", { + default: 15, + }) + .option("--timeout ", "Timeout in seconds (0 = no limit)", { + default: 0, + }) + .action(async (options, id: string) => { + const { format, client } = await getCommandContext(options) + const teamKey = (options as unknown as { team?: string }).team + + const issue = await resolveIssue(client, id, teamKey) + const interval = (options.interval ?? 15) * 1000 + const timeout = (options.timeout ?? 0) * 1000 + const start = Date.now() + + while (true) { + const session = await getLatestSession(client, issue.id) + + if (session && TERMINAL_SESSION_STATES.has(session.status)) { + const result: WatchResult = { + issue: issue.identifier, + agent: session.agent, + status: session.status, + summary: session.summary, + externalUrl: session.externalUrl, + elapsed: Math.round((Date.now() - start) / 1000), + } + + renderWatchResult(format, result) + + if (session.status === "error") Deno.exit(1) + if (session.status === "awaitingInput") Deno.exit(2) + return + } + + if (timeout > 0 && Date.now() - start > timeout) { + const status = session ? session.status : "no session" + const timeoutResult: WatchTimeoutResult = { + issue: issue.identifier, + status: "timeout", + lastSessionStatus: status, + elapsed: Math.round((Date.now() - start) / 1000), + } + renderWatchTimeoutResult(format, timeoutResult) + Deno.exit(124) + } + + await new Promise((r) => setTimeout(r, interval)) + } + }) diff --git a/src/commands/project.ts b/src/commands/project.ts index 11d547b..22ef7c2 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,783 +1 @@ -import { Command } from "@cliffy/command" -import { type LinearClient, ProjectUpdateHealthType } from "@linear/sdk" -import { CliError } from "../errors.ts" -import { render, renderMessage } from "../output/formatter.ts" -import { renderJson } from "../output/json.ts" -import { - readStdin, - resolveIssue, - resolveProjectByName, - resolveUser, -} from "../resolve.ts" -import { formatDate, relativeTime } from "../time.ts" -import { confirmDangerousAction } from "./_shared/confirm.ts" -import { getCommandContext } from "./_shared/context.ts" -import { - buildMutationResult, - renderMutationOutput, -} from "./_shared/mutation_output.ts" - -const HEALTH_MAP: Record = { - ontrack: ProjectUpdateHealthType.OnTrack, - atrisk: ProjectUpdateHealthType.AtRisk, - offtrack: ProjectUpdateHealthType.OffTrack, -} - -const listCommand = new Command() - .description("List projects") - .example("List active projects", "linear project list") - .example("Include completed", "linear project list --include-completed") - .example("Filter by lead", "linear project list --lead Alice") - .example("Sort by progress", "linear project list --sort progress") - .option( - "-s, --state ", - "Filter: planned, started, paused, completed, canceled", - { - collect: true, - }, - ) - .option("--include-completed", "Include completed/canceled") - .option("--lead ", "Filter by lead name (substring match)") - .option( - "--sort ", - "Sort: name, created, updated, target-date, progress", - { - default: "name", - }, - ) - .action(async (options) => { - const { format, client } = await getCommandContext(options) - - const projects = await client.projects() - let items = projects.nodes - - // Filter by state - if (options.state?.length) { - items = items.filter((p) => - options.state!.includes(p.state?.toLowerCase() ?? "") - ) - } else if (!options.includeCompleted) { - items = items.filter( - (p) => - !["completed", "canceled"].includes(p.state?.toLowerCase() ?? ""), - ) - } - - let rows = await Promise.all( - items.map(async (p) => { - const lead = await p.lead - return { - name: p.name, - state: p.state ?? "-", - progressNum: p.progress ?? 0, - progress: `${Math.round((p.progress ?? 0) * 100)}%`, - lead: lead?.name ?? "-", - targetDate: p.targetDate ?? "-", - createdAt: p.createdAt, - updatedAt: p.updatedAt, - url: p.url, - } - }), - ) - - // Filter by lead (substring, case-insensitive) - if (options.lead) { - const needle = options.lead.toLowerCase() - rows = rows.filter((r) => r.lead.toLowerCase().includes(needle)) - } - - // Sort - const sortField = options.sort ?? "name" - rows.sort((a, b) => { - switch (sortField) { - case "name": - return a.name.localeCompare(b.name) - case "created": - return new Date(b.createdAt).getTime() - - new Date(a.createdAt).getTime() - case "updated": - return new Date(b.updatedAt).getTime() - - new Date(a.updatedAt).getTime() - case "target-date": { - // Nulls ("-") sort last - if (a.targetDate === "-" && b.targetDate === "-") return 0 - if (a.targetDate === "-") return 1 - if (b.targetDate === "-") return -1 - return a.targetDate.localeCompare(b.targetDate) - } - case "progress": - return b.progressNum - a.progressNum - default: - return 0 - } - }) - - const payload = rows.map((r) => ({ - name: r.name, - state: r.state, - progress: r.progress, - lead: r.lead, - targetDate: r.targetDate, - url: r.url, - })) - - if (format === "json") { - renderJson(payload) - return - } - - render(format, { - headers: ["Name", "State", "Progress", "Lead", "Target"], - rows: payload.map((r) => [ - r.name, - r.state, - r.progress, - r.lead, - r.targetDate, - ]), - }) - }) - -const viewCommand = new Command() - .alias("show") - .description("View project details") - .example("View a project", "linear project view 'My Project'") - .arguments("") - .action(async (options, name: string) => { - const { format, client } = await getCommandContext(options) - - const project = await resolveProjectByName(client, name) - - const lead = await project.lead - const teams = await project.teams() - const issues = await project.issues({ first: 10 }) - - const issueRows = await Promise.all( - issues.nodes.map(async (issue) => { - const state = await issue.state - const assignee = await issue.assignee - return { - identifier: issue.identifier, - state: state?.name ?? "-", - assignee: assignee?.name ?? "-", - title: issue.title, - updatedAt: issue.updatedAt, - } - }), - ) - - const completedCount = Math.round( - (project.progress ?? 0) * (issues.nodes.length || 1), - ) - const totalCount = issues.nodes.length - const payload = { - name: project.name, - description: project.description ?? null, - state: project.state ?? "-", - progressPercent: Math.round((project.progress ?? 0) * 100), - progressSummary: `${ - Math.round((project.progress ?? 0) * 100) - }% (${completedCount}/${totalCount})`, - lead: lead?.name ?? null, - targetDate: project.targetDate ?? null, - teams: teams.nodes.map((t) => t.key), - url: project.url, - createdAt: project.createdAt, - issues: issueRows, - issuePreviewCount: issueRows.length, - issuePreviewLimit: 10, - } - - if (format === "json") { - renderJson(payload) - return - } - - if (format === "compact") { - const lines = [ - `name\t${payload.name}`, - `description\t${payload.description ?? "-"}`, - `state\t${payload.state}`, - `progress\t${payload.progressSummary}`, - `lead\t${payload.lead ?? "-"}`, - `target\t${payload.targetDate ?? "-"}`, - `teams\t${payload.teams.join(", ")}`, - `url\t${payload.url}`, - ] - console.log(lines.join("\n")) - return - } - - render("table", { - title: payload.name, - fields: [ - { label: "Description", value: payload.description ?? "-" }, - { label: "State", value: payload.state }, - { - label: "Progress", - value: `${payload.progressSummary} issues`, - }, - { label: "Lead", value: payload.lead ?? "-" }, - { label: "Target", value: payload.targetDate ?? "-" }, - { label: "Teams", value: payload.teams.join(", ") }, - { - label: "Created", - value: `${formatDate(payload.createdAt)} (${ - relativeTime(payload.createdAt) - })`, - }, - { label: "URL", value: payload.url }, - ], - }) - - if (payload.issues.length > 0) { - console.log("\nRecent Issues:") - for (const r of payload.issues) { - console.log( - ` ${r.identifier} ${r.state} ${r.assignee} ${r.title} ${ - relativeTime(r.updatedAt) - }`, - ) - } - } - }) - -const createCommand = new Command() - .description("Create project") - .example( - "Create a project", - "linear project create --name 'Q1 Roadmap' --target-date 2026-03-31", - ) - .option("--name ", "Project name", { required: true }) - .option("-d, --description ", "Description") - .option("--lead ", "Project lead") - .option("--target-date ", "Target date (YYYY-MM-DD)") - .action(async (options) => { - const { format, client } = await getCommandContext(options) - - const description = options.description ?? await readStdin() - const leadId = options.lead - ? await resolveUser(client, options.lead) - : undefined - - let teamIds: string[] = [] - const teamKey = (options as unknown as { team?: string }).team - if (teamKey) { - const teams = await client.teams() - const team = teams.nodes.find( - (t) => t.key.toLowerCase() === teamKey.toLowerCase(), - ) - if (team) teamIds = [team.id] - } - - const payload = await client.createProject({ - name: options.name, - teamIds, - ...(description && { description }), - ...(options.targetDate && { targetDate: options.targetDate }), - ...(leadId && { leadId }), - }) - const project = await payload.project - - if (!project) { - throw new CliError("failed to create project", 1) - } - - renderMutationOutput({ - format, - result: buildMutationResult({ - id: project.id, - entity: "project", - action: "create", - status: "success", - url: project.url, - metadata: { name: project.name }, - }), - }) - if (format === "table") { - console.error( - ` add issues: linear project add-issue '${project.name}' `, - ) - } - }) - -const updateCommand = new Command() - .description("Update project") - .example( - "Update target date", - "linear project update 'My Project' --target-date 2026-04-01", - ) - .arguments("") - .option("--name ", "New name") - .option("-d, --description ", "New description") - .option("--lead ", "New lead (empty to unassign)") - .option("--target-date ", "Target date (YYYY-MM-DD)") - .option("--start-date ", "Start date (YYYY-MM-DD)") - .option("--color ", "Project color hex") - .option("--status ", "Redirect: use project post instead", { - hidden: true, - }) - .action(async (options, projectName: string) => { - // Intercept --status: agents confuse "project update --status" with project posts - if ((options as { status?: string }).status) { - console.error( - `error: project update does not have a --status flag`, - ) - console.error( - ` try: linear project post "${projectName}" --body --health `, - ) - Deno.exit(4) - } - - const { format, client } = await getCommandContext(options) - - const project = await resolveProjectByName(client, projectName) - - const description = options.description ?? (await readStdin()) - const leadId = options.lead !== undefined - ? (options.lead === "" ? null : await resolveUser(client, options.lead)) - : undefined - - const payload = await client.updateProject(project.id, { - ...(options.name && { name: options.name }), - ...(description !== undefined && { description }), - ...(leadId !== undefined && { leadId }), - ...(options.targetDate && { targetDate: options.targetDate }), - ...(options.startDate && { startDate: options.startDate }), - ...(options.color && { color: options.color }), - }) - const updated = await payload.project - - if (!updated) { - throw new CliError("failed to update project", 1) - } - - const lead = await updated.lead - - renderMutationOutput({ - format, - result: buildMutationResult({ - id: updated.id, - entity: "project", - action: "update", - status: "success", - url: updated.url, - metadata: { - name: updated.name, - state: updated.state ?? "-", - progress: `${Math.round((updated.progress ?? 0) * 100)}%`, - lead: lead?.name ?? null, - targetDate: updated.targetDate ?? null, - }, - }), - }) - }) - -// --- Milestone subcommands --- - -const milestoneListCommand = new Command() - .description("List milestones for a project") - .example("List milestones", "linear project milestone list 'My Project'") - .arguments("") - .action(async (options, name: string) => { - const { format, client } = await getCommandContext(options) - - const project = await resolveProjectByName(client, name) - const milestones = await project.projectMilestones() - - const payload = milestones.nodes.map((m) => ({ - name: m.name, - status: String(m.status ?? "-"), - targetDate: m.targetDate ?? "-", - progress: `${Math.round((m.progress ?? 0) * 100)}%`, - description: m.description ?? "-", - })) - - if (format === "json") { - renderJson(payload) - return - } - - if (format === "table") { - render("table", { - headers: ["Name", "Status", "Target", "Progress", "Description"], - rows: payload.map((r) => [ - r.name, - r.status, - r.targetDate, - r.progress, - r.description.length > 50 - ? r.description.slice(0, 47) + "..." - : r.description, - ]), - }) - } else { - render("compact", { - headers: ["Name", "Status", "Target", "Progress"], - rows: payload.map((r) => [ - r.name, - r.status, - r.targetDate, - r.progress, - ]), - }) - } - }) - -const milestoneCreateCommand = new Command() - .alias("add") - .description("Create milestone on a project") - .example( - "Add milestone", - "linear project milestone create 'Beta launch' --project 'My Project' --target-date 2026-03-15", - ) - .arguments("") - .option("--project ", "Project name", { required: true }) - .option("-d, --description ", "Description") - .option("--target-date ", "Target date (YYYY-MM-DD)") - .option("--date ", "Alias for --target-date", { hidden: true }) - .action(async (options, milestoneName: string) => { - const { format, client } = await getCommandContext(options) - - const project = await resolveProjectByName(client, options.project) - - const targetDate = options.targetDate ?? - (options as { date?: string }).date - - const payload = await client.createProjectMilestone({ - projectId: project.id, - name: milestoneName, - ...(options.description && { description: options.description }), - ...(targetDate && { targetDate }), - }) - const milestone = await payload.projectMilestone - - if (!milestone) { - throw new CliError("failed to create milestone", 1) - } - - renderMutationOutput({ - format, - result: buildMutationResult({ - id: milestone.id, - entity: "projectMilestone", - action: "create", - status: "success", - metadata: { - name: milestone.name, - targetDate: milestone.targetDate ?? null, - description: milestone.description ?? null, - }, - }), - }) - }) - -const milestoneCommand = new Command() - .description("Manage project milestones") - .command("list", milestoneListCommand) - .command("create", milestoneCreateCommand) - -// --- Post (project update) command --- - -const postCommand = new Command() - .description("Create project update (status post)") - .example( - "Post status update", - "linear project post 'My Project' --body 'On track' --health onTrack", - ) - .arguments("") - .option("--body ", "Update body in markdown") - .option( - "--health ", - "Health: onTrack, atRisk, offTrack", - ) - .action(async (options, name: string) => { - const { format, client } = await getCommandContext(options) - - const project = await resolveProjectByName(client, name) - - const body = options.body ?? (await readStdin()) - - let health: ProjectUpdateHealthType | undefined - if (options.health) { - health = HEALTH_MAP[options.health.toLowerCase()] - if (!health) { - throw new CliError( - `invalid health "${options.health}"`, - 4, - "try: onTrack, atRisk, offTrack", - ) - } - } - - const payload = await client.createProjectUpdate({ - projectId: project.id, - ...(body && { body }), - ...(health && { health }), - }) - const update = await payload.projectUpdate - - if (!update) { - throw new CliError("failed to create project update", 1) - } - - renderMutationOutput({ - format, - result: buildMutationResult({ - id: update.id, - entity: "projectUpdate", - action: "create", - status: "success", - url: update.url, - metadata: { - project: project.name, - health: update.health ?? null, - createdAt: update.createdAt, - }, - }), - }) - }) - -// --- Labels command --- - -const labelsCommand = new Command() - .description("List labels for a project") - .example("List project labels", "linear project labels 'My Project'") - .arguments("") - .action(async (options, name: string) => { - const { format, client } = await getCommandContext(options) - - const project = await resolveProjectByName(client, name) - const labels = await project.labels() - - const payload = labels.nodes.map((l) => ({ - name: l.name, - color: l.color, - description: l.description ?? "-", - group: l.isGroup ? "yes" : "no", - })) - - if (format === "json") { - renderJson(payload) - return - } - - if (format === "table") { - render("table", { - headers: ["Name", "Color", "Group", "Description"], - rows: payload.map((r) => [ - r.name, - r.color, - r.group, - r.description.length > 50 - ? r.description.slice(0, 47) + "..." - : r.description, - ]), - }) - } else { - render("compact", { - headers: ["Name", "Color", "Description"], - rows: payload.map((r) => [r.name, r.color, r.description]), - }) - } - }) - -const deleteCommand = new Command() - .description("Delete project") - .example("Delete a project", "linear project delete 'My Project'") - .example( - "Delete without confirmation", - "linear project delete 'My Project' --yes", - ) - .arguments("") - .option("-y, --yes", "Skip confirmation prompt") - .action(async (options, name: string) => { - const { format, client, noInput } = await getCommandContext(options) - - const project = await resolveProjectByName(client, name) - - const confirmed = await confirmDangerousAction({ - prompt: `Delete project "${project.name}"?`, - skipConfirm: Boolean((options as { yes?: boolean }).yes) || noInput, - }) - if (!confirmed) { - renderMessage(format, "Canceled") - return - } - - await client.deleteProject(project.id) - renderMutationOutput({ - format, - result: buildMutationResult({ - id: project.id, - entity: "project", - action: "delete", - status: "success", - url: project.url, - metadata: { name: project.name }, - }), - }) - }) - -// --- Porcelain: state transitions --- - -/** Find project status ID by type (started, paused, completed, canceled). */ -async function resolveProjectStatusId( - client: LinearClient, - statusType: string, -): Promise { - const statuses = await client.projectStatuses() - const match = statuses.nodes.find( - (s) => s.type?.toLowerCase() === statusType.toLowerCase(), - ) - if (!match) { - throw new CliError( - `no project status of type "${statusType}" found`, - 1, - "check project status configuration in Linear settings", - ) - } - return match.id -} - -const startCommand = new Command() - .description("Start project (set state to started)") - .example("Start a project", "linear project start 'My Project'") - .arguments("") - .action(async (options, name: string) => { - const { format, client } = await getCommandContext(options) - const project = await resolveProjectByName(client, name) - const statusId = await resolveProjectStatusId(client, "started") - await client.updateProject(project.id, { statusId }) - renderMutationOutput({ - format, - result: buildMutationResult({ - id: project.id, - entity: "project", - action: "start", - status: "success", - url: project.url, - metadata: { name: project.name }, - }), - }) - if (format === "table") { - console.error( - ` post update: linear project post '${name}' --body ''`, - ) - } - }) - -const pauseCommand = new Command() - .description("Pause project (set state to paused)") - .example("Pause a project", "linear project pause 'My Project'") - .arguments("") - .action(async (options, name: string) => { - const { format, client } = await getCommandContext(options) - const project = await resolveProjectByName(client, name) - const statusId = await resolveProjectStatusId(client, "paused") - await client.updateProject(project.id, { statusId }) - renderMutationOutput({ - format, - result: buildMutationResult({ - id: project.id, - entity: "project", - action: "pause", - status: "success", - url: project.url, - metadata: { name: project.name }, - }), - }) - }) - -const completeCommand = new Command() - .description("Complete project (set state to completed)") - .example("Complete a project", "linear project complete 'My Project'") - .arguments("") - .action(async (options, name: string) => { - const { format, client } = await getCommandContext(options) - const project = await resolveProjectByName(client, name) - const statusId = await resolveProjectStatusId(client, "completed") - await client.updateProject(project.id, { statusId }) - renderMutationOutput({ - format, - result: buildMutationResult({ - id: project.id, - entity: "project", - action: "complete", - status: "success", - url: project.url, - metadata: { name: project.name }, - }), - }) - }) - -const cancelCommand = new Command() - .description("Cancel project (set state to canceled)") - .example("Cancel a project", "linear project cancel 'My Project'") - .arguments("") - .action(async (options, name: string) => { - const { format, client } = await getCommandContext(options) - const project = await resolveProjectByName(client, name) - const statusId = await resolveProjectStatusId(client, "canceled") - await client.updateProject(project.id, { statusId }) - renderMutationOutput({ - format, - result: buildMutationResult({ - id: project.id, - entity: "project", - action: "cancel", - status: "success", - url: project.url, - metadata: { name: project.name }, - }), - }) - }) - -// --- Porcelain: cross-entity actions --- - -const addIssueCommand = new Command() - .description("Add issue to project") - .example( - "Add issue to project", - "linear project add-issue 'My Project' POL-5", - ) - .arguments(" ") - .action(async (options, projectName: string, issueId: string) => { - const { format, client } = await getCommandContext(options) - const teamKey = (options as unknown as { team?: string }).team - - const project = await resolveProjectByName(client, projectName) - const issue = await resolveIssue(client, issueId, teamKey) - - await client.updateIssue(issue.id, { projectId: project.id }) - renderMutationOutput({ - format, - result: buildMutationResult({ - id: issue.identifier, - entity: "issue", - action: "moveToProject", - status: "success", - url: issue.url, - metadata: { project: project.name }, - }), - }) - }) - -export const projectCommand = new Command() - .description("Manage projects") - .alias("projects") - .example("List projects", "linear project list") - .example("View project", "linear project view 'My Project'") - .command("list", listCommand) - .command("view", viewCommand) - .command("create", createCommand) - .command("update", updateCommand) - .command("milestone", milestoneCommand) - .command("post", postCommand) - .command("labels", labelsCommand) - .command("start", startCommand) - .command("pause", pauseCommand) - .command("complete", completeCommand) - .command("cancel", cancelCommand) - .command("delete", deleteCommand) - .command("add-issue", addIssueCommand) +export { projectCommand } from "./project/index.ts" diff --git a/src/commands/project/index.ts b/src/commands/project/index.ts new file mode 100644 index 0000000..2f911d3 --- /dev/null +++ b/src/commands/project/index.ts @@ -0,0 +1,35 @@ +import { Command } from "@cliffy/command" +import { milestoneCommand } from "./milestone.ts" +import { + addIssueCommand, + createCommand, + deleteCommand, + updateCommand, +} from "./mutate.ts" +import { labelsCommand, listCommand, viewCommand } from "./read.ts" +import { + cancelCommand, + completeCommand, + pauseCommand, + postCommand, + startCommand, +} from "./status.ts" + +export const projectCommand = new Command() + .description("Manage projects") + .alias("projects") + .example("List projects", "linear project list") + .example("View project", "linear project view 'My Project'") + .command("list", listCommand) + .command("view", viewCommand) + .command("create", createCommand) + .command("update", updateCommand) + .command("milestone", milestoneCommand) + .command("post", postCommand) + .command("labels", labelsCommand) + .command("start", startCommand) + .command("pause", pauseCommand) + .command("complete", completeCommand) + .command("cancel", cancelCommand) + .command("delete", deleteCommand) + .command("add-issue", addIssueCommand) diff --git a/src/commands/project/milestone.ts b/src/commands/project/milestone.ts new file mode 100644 index 0000000..b848935 --- /dev/null +++ b/src/commands/project/milestone.ts @@ -0,0 +1,104 @@ +import { Command } from "@cliffy/command" +import { CliError } from "../../errors.ts" +import { render } from "../../output/formatter.ts" +import { renderJson } from "../../output/json.ts" +import { resolveProjectByName } from "../../resolve.ts" +import { getCommandContext } from "../_shared/context.ts" +import { + buildMutationResult, + renderMutationOutput, +} from "../_shared/mutation_output.ts" + +export const milestoneListCommand = new Command() + .description("List milestones for a project") + .example("List milestones", "linear project milestone list 'My Project'") + .arguments("") + .action(async (options, name: string) => { + const { format, client } = await getCommandContext(options) + + const project = await resolveProjectByName(client, name) + const milestones = await project.projectMilestones() + + const payload = milestones.nodes.map((m) => ({ + name: m.name, + status: String(m.status ?? "-"), + targetDate: m.targetDate ?? "-", + progress: `${Math.round((m.progress ?? 0) * 100)}%`, + description: m.description ?? "-", + })) + + if (format === "json") { + renderJson(payload) + return + } + + if (format === "table") { + render("table", { + headers: ["Name", "Status", "Target", "Progress", "Description"], + rows: payload.map((r) => [ + r.name, + r.status, + r.targetDate, + r.progress, + r.description.length > 50 + ? r.description.slice(0, 47) + "..." + : r.description, + ]), + }) + } else { + render("compact", { + headers: ["Name", "Status", "Target", "Progress"], + rows: payload.map((r) => [r.name, r.status, r.targetDate, r.progress]), + }) + } + }) + +export const milestoneCreateCommand = new Command() + .alias("add") + .description("Create milestone on a project") + .example( + "Add milestone", + "linear project milestone create 'Beta launch' --project 'My Project' --target-date 2026-03-15", + ) + .arguments("") + .option("--project ", "Project name", { required: true }) + .option("-d, --description ", "Description") + .option("--target-date ", "Target date (YYYY-MM-DD)") + .option("--date ", "Alias for --target-date", { hidden: true }) + .action(async (options, milestoneName: string) => { + const { format, client } = await getCommandContext(options) + const project = await resolveProjectByName(client, options.project) + + const targetDate = options.targetDate ?? (options as { date?: string }).date + const payload = await client.createProjectMilestone({ + projectId: project.id, + name: milestoneName, + ...(options.description && { description: options.description }), + ...(targetDate && { targetDate }), + }) + const milestone = await payload.projectMilestone + + if (!milestone) { + throw new CliError("failed to create milestone", 1) + } + + renderMutationOutput({ + format, + result: buildMutationResult({ + id: milestone.id, + entity: "projectMilestone", + action: "create", + status: "success", + metadata: { + name: milestone.name, + targetDate: milestone.targetDate ?? null, + description: milestone.description ?? null, + }, + }), + }) + }) + +export const milestoneCommand = new Command() + .description("Manage project milestones") + .command("list", milestoneListCommand) + .command("create", milestoneCreateCommand) diff --git a/src/commands/project/mutate.ts b/src/commands/project/mutate.ts new file mode 100644 index 0000000..af321b8 --- /dev/null +++ b/src/commands/project/mutate.ts @@ -0,0 +1,202 @@ +import { Command } from "@cliffy/command" +import { CliError } from "../../errors.ts" +import { renderMessage } from "../../output/formatter.ts" +import { + readStdin, + resolveIssue, + resolveProjectByName, + resolveTeam, + resolveUser, +} from "../../resolve.ts" +import { confirmDangerousAction } from "../_shared/confirm.ts" +import { getCommandContext } from "../_shared/context.ts" +import { + buildMutationResult, + renderMutationOutput, +} from "../_shared/mutation_output.ts" +import { renderTableHint } from "../_shared/streams.ts" + +export const createCommand = new Command() + .description("Create project") + .example( + "Create a project", + "linear project create --name 'Q1 Roadmap' --target-date 2026-03-31", + ) + .option("--name ", "Project name", { required: true }) + .option("-d, --description ", "Description") + .option("--lead ", "Project lead") + .option("--target-date ", "Target date (YYYY-MM-DD)") + .action(async (options) => { + const { format, client } = await getCommandContext(options) + + const description = options.description ?? await readStdin() + const leadId = options.lead + ? await resolveUser(client, options.lead) + : undefined + + let teamIds: string[] = [] + const teamKey = (options as unknown as { team?: string }).team + if (teamKey) { + const team = await resolveTeam(client, teamKey) + teamIds = [team.id] + } + + const payload = await client.createProject({ + name: options.name, + teamIds, + ...(description && { description }), + ...(options.targetDate && { targetDate: options.targetDate }), + ...(leadId && { leadId }), + }) + const project = await payload.project + + if (!project) { + throw new CliError("failed to create project", 1) + } + + renderMutationOutput({ + format, + result: buildMutationResult({ + id: project.id, + entity: "project", + action: "create", + status: "success", + url: project.url, + metadata: { name: project.name }, + }), + }) + renderTableHint( + format, + ` add issues: linear project add-issue '${project.name}' `, + ) + }) + +export const updateCommand = new Command() + .description("Update project") + .example( + "Update target date", + "linear project update 'My Project' --target-date 2026-04-01", + ) + .arguments("") + .option("--name ", "New name") + .option("-d, --description ", "New description") + .option("--lead ", "New lead (empty to unassign)") + .option("--target-date ", "Target date (YYYY-MM-DD)") + .option("--start-date ", "Start date (YYYY-MM-DD)") + .option("--color ", "Project color hex") + .option("--status ", "Redirect: use project post instead", { + hidden: true, + }) + .action(async (options, projectName: string) => { + if ((options as { status?: string }).status) { + throw new CliError( + "project update does not have a --status flag", + 4, + `linear project post "${projectName}" --body --health `, + ) + } + + const { format, client } = await getCommandContext(options) + const project = await resolveProjectByName(client, projectName) + const description = options.description ?? (await readStdin()) + const leadId = options.lead !== undefined + ? (options.lead === "" ? null : await resolveUser(client, options.lead)) + : undefined + + const payload = await client.updateProject(project.id, { + ...(options.name && { name: options.name }), + ...(description !== undefined && { description }), + ...(leadId !== undefined && { leadId }), + ...(options.targetDate && { targetDate: options.targetDate }), + ...(options.startDate && { startDate: options.startDate }), + ...(options.color && { color: options.color }), + }) + const updated = await payload.project + + if (!updated) { + throw new CliError("failed to update project", 1) + } + + const lead = await updated.lead + renderMutationOutput({ + format, + result: buildMutationResult({ + id: updated.id, + entity: "project", + action: "update", + status: "success", + url: updated.url, + metadata: { + name: updated.name, + state: updated.state ?? "-", + progress: `${Math.round((updated.progress ?? 0) * 100)}%`, + lead: lead?.name ?? null, + targetDate: updated.targetDate ?? null, + }, + }), + }) + }) + +export const deleteCommand = new Command() + .description("Delete project") + .example("Delete a project", "linear project delete 'My Project'") + .example( + "Delete without confirmation", + "linear project delete 'My Project' --yes", + ) + .arguments("") + .option("-y, --yes", "Skip confirmation prompt") + .action(async (options, name: string) => { + const { format, client, noInput } = await getCommandContext(options) + const project = await resolveProjectByName(client, name) + + const confirmed = await confirmDangerousAction({ + prompt: `Delete project "${project.name}"?`, + skipConfirm: Boolean((options as { yes?: boolean }).yes) || noInput, + }) + if (!confirmed) { + renderMessage(format, "Canceled") + return + } + + await client.deleteProject(project.id) + renderMutationOutput({ + format, + result: buildMutationResult({ + id: project.id, + entity: "project", + action: "delete", + status: "success", + url: project.url, + metadata: { name: project.name }, + }), + }) + }) + +export const addIssueCommand = new Command() + .description("Add issue to project") + .example( + "Add issue to project", + "linear project add-issue 'My Project' POL-5", + ) + .arguments(" ") + .action(async (options, projectName: string, issueId: string) => { + const { format, client } = await getCommandContext(options) + const teamKey = (options as unknown as { team?: string }).team + + const project = await resolveProjectByName(client, projectName) + const issue = await resolveIssue(client, issueId, teamKey) + + await client.updateIssue(issue.id, { projectId: project.id }) + renderMutationOutput({ + format, + result: buildMutationResult({ + id: issue.identifier, + entity: "issue", + action: "moveToProject", + status: "success", + url: issue.url, + metadata: { project: project.name }, + }), + }) + }) diff --git a/src/commands/project/read.ts b/src/commands/project/read.ts new file mode 100644 index 0000000..88ad80c --- /dev/null +++ b/src/commands/project/read.ts @@ -0,0 +1,268 @@ +import { Command } from "@cliffy/command" +import { render, renderMessage } from "../../output/formatter.ts" +import { renderJson } from "../../output/json.ts" +import { resolveProjectByName } from "../../resolve.ts" +import { formatDate, relativeTime } from "../../time.ts" +import { getCommandContext } from "../_shared/context.ts" + +export const listCommand = new Command() + .description("List projects") + .example("List active projects", "linear project list") + .example("Include completed", "linear project list --include-completed") + .example("Filter by lead", "linear project list --lead Alice") + .example("Sort by progress", "linear project list --sort progress") + .option( + "-s, --state ", + "Filter: planned, started, paused, completed, canceled", + { collect: true }, + ) + .option("--include-completed", "Include completed/canceled") + .option("--lead ", "Filter by lead name (substring match)") + .option( + "--sort ", + "Sort: name, created, updated, target-date, progress", + { default: "name" }, + ) + .action(async (options) => { + const { format, client } = await getCommandContext(options) + + const projects = await client.projects() + let items = projects.nodes + + if (options.state?.length) { + items = items.filter((p) => + options.state!.includes(p.state?.toLowerCase() ?? "") + ) + } else if (!options.includeCompleted) { + items = items.filter((p) => + !["completed", "canceled"].includes(p.state?.toLowerCase() ?? "") + ) + } + + let rows = await Promise.all( + items.map(async (p) => { + const lead = await p.lead + return { + name: p.name, + state: p.state ?? "-", + progressNum: p.progress ?? 0, + progress: `${Math.round((p.progress ?? 0) * 100)}%`, + lead: lead?.name ?? "-", + targetDate: p.targetDate ?? "-", + createdAt: p.createdAt, + updatedAt: p.updatedAt, + url: p.url, + } + }), + ) + + if (options.lead) { + const needle = options.lead.toLowerCase() + rows = rows.filter((r) => r.lead.toLowerCase().includes(needle)) + } + + const sortField = options.sort ?? "name" + rows.sort((a, b) => { + switch (sortField) { + case "name": + return a.name.localeCompare(b.name) + case "created": + return new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime() + case "updated": + return new Date(b.updatedAt).getTime() - + new Date(a.updatedAt).getTime() + case "target-date": { + if (a.targetDate === "-" && b.targetDate === "-") return 0 + if (a.targetDate === "-") return 1 + if (b.targetDate === "-") return -1 + return a.targetDate.localeCompare(b.targetDate) + } + case "progress": + return b.progressNum - a.progressNum + default: + return 0 + } + }) + + const payload = rows.map((r) => ({ + name: r.name, + state: r.state, + progress: r.progress, + lead: r.lead, + targetDate: r.targetDate, + url: r.url, + })) + + if (format === "json") { + renderJson(payload) + return + } + + render(format, { + headers: ["Name", "State", "Progress", "Lead", "Target"], + rows: payload.map(( + r, + ) => [r.name, r.state, r.progress, r.lead, r.targetDate]), + }) + }) + +export const viewCommand = new Command() + .alias("show") + .description("View project details") + .example("View a project", "linear project view 'My Project'") + .arguments("") + .action(async (options, name: string) => { + const { format, client } = await getCommandContext(options) + const previewLimit = 10 + + const project = await resolveProjectByName(client, name) + const lead = await project.lead + const teams = await project.teams() + const issues = await project.issues({ first: previewLimit + 1 }) + const hasMoreIssues = issues.nodes.length > previewLimit + const previewIssues = hasMoreIssues + ? issues.nodes.slice(0, previewLimit) + : issues.nodes + + const issueRows = await Promise.all( + previewIssues.map(async (issue) => { + const state = await issue.state + const assignee = await issue.assignee + return { + identifier: issue.identifier, + state: state?.name ?? "-", + assignee: assignee?.name ?? "-", + title: issue.title, + updatedAt: issue.updatedAt, + } + }), + ) + + const totalCount = + ((project as unknown as { issueCount?: number }).issueCount) ?? + issueRows.length + const completedCount = Math.round( + (project.progress ?? 0) * (totalCount || 1), + ) + const payload = { + name: project.name, + description: project.description ?? null, + state: project.state ?? "-", + progressPercent: Math.round((project.progress ?? 0) * 100), + progressSummary: `${ + Math.round((project.progress ?? 0) * 100) + }% (${completedCount}/${totalCount})`, + lead: lead?.name ?? null, + targetDate: project.targetDate ?? null, + teams: teams.nodes.map((t) => t.key), + url: project.url, + createdAt: project.createdAt, + issues: issueRows, + issuePreviewCount: issueRows.length, + issuePreviewLimit: previewLimit, + issuePreviewHasMore: hasMoreIssues, + issueTotalCount: totalCount, + } + + if (format === "json") { + renderJson(payload) + return + } + + if (format === "compact") { + const lines = [ + `name\t${payload.name}`, + `description\t${payload.description ?? "-"}`, + `state\t${payload.state}`, + `progress\t${payload.progressSummary}`, + `lead\t${payload.lead ?? "-"}`, + `target\t${payload.targetDate ?? "-"}`, + `teams\t${payload.teams.join(", ")}`, + `issue_preview\t${payload.issuePreviewCount}/${payload.issueTotalCount}${ + payload.issuePreviewHasMore ? "+" : "" + }`, + `url\t${payload.url}`, + ] + renderMessage(format, lines.join("\n")) + return + } + + render("table", { + title: payload.name, + fields: [ + { label: "Description", value: payload.description ?? "-" }, + { label: "State", value: payload.state }, + { label: "Progress", value: `${payload.progressSummary} issues` }, + { label: "Lead", value: payload.lead ?? "-" }, + { label: "Target", value: payload.targetDate ?? "-" }, + { label: "Teams", value: payload.teams.join(", ") }, + { + label: "Created", + value: `${formatDate(payload.createdAt)} (${ + relativeTime(payload.createdAt) + })`, + }, + { label: "URL", value: payload.url }, + ], + }) + + if (payload.issues.length > 0) { + const issueLines = payload.issues.map((r) => + ` ${r.identifier} ${r.state} ${r.assignee} ${r.title} ${ + relativeTime(r.updatedAt) + }` + ) + const moreSuffix = payload.issuePreviewHasMore + ? `\n ...and more (${ + payload.issueTotalCount - payload.issuePreviewCount + } additional)` + : "" + renderMessage( + format, + `\nRecent Issues:\n${issueLines.join("\n")}${moreSuffix}`, + ) + } + }) + +export const labelsCommand = new Command() + .description("List labels for a project") + .example("List project labels", "linear project labels 'My Project'") + .arguments("") + .action(async (options, name: string) => { + const { format, client } = await getCommandContext(options) + + const project = await resolveProjectByName(client, name) + const labels = await project.labels() + + const payload = labels.nodes.map((l) => ({ + name: l.name, + color: l.color, + description: l.description ?? "-", + group: l.isGroup ? "yes" : "no", + })) + + if (format === "json") { + renderJson(payload) + return + } + + if (format === "table") { + render("table", { + headers: ["Name", "Color", "Group", "Description"], + rows: payload.map((r) => [ + r.name, + r.color, + r.group, + r.description.length > 50 + ? r.description.slice(0, 47) + "..." + : r.description, + ]), + }) + } else { + render("compact", { + headers: ["Name", "Color", "Description"], + rows: payload.map((r) => [r.name, r.color, r.description]), + }) + } + }) diff --git a/src/commands/project/shared.ts b/src/commands/project/shared.ts new file mode 100644 index 0000000..37059fd --- /dev/null +++ b/src/commands/project/shared.ts @@ -0,0 +1,27 @@ +import { type LinearClient, ProjectUpdateHealthType } from "@linear/sdk" +import { CliError } from "../../errors.ts" + +export const HEALTH_MAP: Record = { + ontrack: ProjectUpdateHealthType.OnTrack, + atrisk: ProjectUpdateHealthType.AtRisk, + offtrack: ProjectUpdateHealthType.OffTrack, +} + +/** Find project status ID by type (started, paused, completed, canceled). */ +export async function resolveProjectStatusId( + client: LinearClient, + statusType: string, +): Promise { + const statuses = await client.projectStatuses() + const match = statuses.nodes.find( + (s) => s.type?.toLowerCase() === statusType.toLowerCase(), + ) + if (!match) { + throw new CliError( + `no project status of type "${statusType}" found`, + 1, + "check project status configuration in Linear settings", + ) + } + return match.id +} diff --git a/src/commands/project/status.ts b/src/commands/project/status.ts new file mode 100644 index 0000000..b3828c3 --- /dev/null +++ b/src/commands/project/status.ts @@ -0,0 +1,156 @@ +import { Command } from "@cliffy/command" +import type { ProjectUpdateHealthType } from "@linear/sdk" +import { CliError } from "../../errors.ts" +import { readStdin, resolveProjectByName } from "../../resolve.ts" +import { getCommandContext } from "../_shared/context.ts" +import { + buildMutationResult, + renderMutationOutput, +} from "../_shared/mutation_output.ts" +import { renderTableHint } from "../_shared/streams.ts" +import { HEALTH_MAP, resolveProjectStatusId } from "./shared.ts" + +export const postCommand = new Command() + .description("Create project update (status post)") + .example( + "Post status update", + "linear project post 'My Project' --body 'On track' --health onTrack", + ) + .arguments("") + .option("--body ", "Update body in markdown") + .option("--health ", "Health: onTrack, atRisk, offTrack") + .action(async (options, name: string) => { + const { format, client } = await getCommandContext(options) + const project = await resolveProjectByName(client, name) + const body = options.body ?? (await readStdin()) + + let health: ProjectUpdateHealthType | undefined + if (options.health) { + health = HEALTH_MAP[options.health.toLowerCase()] + if (!health) { + throw new CliError( + `invalid health "${options.health}"`, + 4, + "try: onTrack, atRisk, offTrack", + ) + } + } + + const payload = await client.createProjectUpdate({ + projectId: project.id, + ...(body && { body }), + ...(health && { health }), + }) + const update = await payload.projectUpdate + if (!update) { + throw new CliError("failed to create project update", 1) + } + + renderMutationOutput({ + format, + result: buildMutationResult({ + id: update.id, + entity: "projectUpdate", + action: "create", + status: "success", + url: update.url, + metadata: { + project: project.name, + health: update.health ?? null, + createdAt: update.createdAt, + }, + }), + }) + }) + +export const startCommand = new Command() + .description("Start project (set state to started)") + .example("Start a project", "linear project start 'My Project'") + .arguments("") + .action(async (options, name: string) => { + const { format, client } = await getCommandContext(options) + const project = await resolveProjectByName(client, name) + const statusId = await resolveProjectStatusId(client, "started") + await client.updateProject(project.id, { statusId }) + renderMutationOutput({ + format, + result: buildMutationResult({ + id: project.id, + entity: "project", + action: "start", + status: "success", + url: project.url, + metadata: { name: project.name }, + }), + }) + renderTableHint( + format, + ` post update: linear project post '${name}' --body ''`, + ) + }) + +export const pauseCommand = new Command() + .description("Pause project (set state to paused)") + .example("Pause a project", "linear project pause 'My Project'") + .arguments("") + .action(async (options, name: string) => { + const { format, client } = await getCommandContext(options) + const project = await resolveProjectByName(client, name) + const statusId = await resolveProjectStatusId(client, "paused") + await client.updateProject(project.id, { statusId }) + renderMutationOutput({ + format, + result: buildMutationResult({ + id: project.id, + entity: "project", + action: "pause", + status: "success", + url: project.url, + metadata: { name: project.name }, + }), + }) + }) + +export const completeCommand = new Command() + .description("Complete project (set state to completed)") + .example("Complete a project", "linear project complete 'My Project'") + .arguments("") + .action(async (options, name: string) => { + const { format, client } = await getCommandContext(options) + const project = await resolveProjectByName(client, name) + const statusId = await resolveProjectStatusId(client, "completed") + await client.updateProject(project.id, { statusId }) + renderMutationOutput({ + format, + result: buildMutationResult({ + id: project.id, + entity: "project", + action: "complete", + status: "success", + url: project.url, + metadata: { name: project.name }, + }), + }) + }) + +export const cancelCommand = new Command() + .description("Cancel project (set state to canceled)") + .example("Cancel a project", "linear project cancel 'My Project'") + .arguments("") + .action(async (options, name: string) => { + const { format, client } = await getCommandContext(options) + const project = await resolveProjectByName(client, name) + const statusId = await resolveProjectStatusId(client, "canceled") + await client.updateProject(project.id, { statusId }) + renderMutationOutput({ + format, + result: buildMutationResult({ + id: project.id, + entity: "project", + action: "cancel", + status: "success", + url: project.url, + metadata: { name: project.name }, + }), + }) + }) diff --git a/src/commands/team.ts b/src/commands/team.ts index ee8bf07..a03acdc 100644 --- a/src/commands/team.ts +++ b/src/commands/team.ts @@ -1,9 +1,8 @@ import { Command } from "@cliffy/command" import type { LinearClient } from "@linear/sdk" -import { CliError } from "../errors.ts" import { render, renderMessage } from "../output/formatter.ts" import { renderJson } from "../output/json.ts" -import { requireTeam } from "../resolve.ts" +import { requireTeam, resolveTeam } from "../resolve.ts" import { getCommandContext } from "./_shared/context.ts" interface TeamSummary { @@ -23,15 +22,25 @@ async function loadTeams(client: LinearClient): Promise { })) } -async function findTeam(client: LinearClient, key: string) { - const teamsConnection = await client.teams() - const teams = teamsConnection.nodes - const target = teams.find((t) => t.key.toLowerCase() === key.toLowerCase()) - if (!target) { - const available = teams.map((t) => t.key).join(", ") - throw new CliError(`team not found: "${key}"`, 3, `available: ${available}`) +export async function fetchTeamOverviewIssues( + client: LinearClient, + teamKey: string, +) { + let issues = await client.issues({ + filter: { + team: { key: { eq: teamKey } }, + state: { + type: { in: ["backlog", "unstarted", "started", "completed"] }, + }, + }, + first: 100, + }) + const allIssues = [...issues.nodes] + while (issues.pageInfo.hasNextPage) { + issues = await issues.fetchNext() + allIssues.push(...issues.nodes) } - return target + return allIssues } export const teamCommand = new Command() @@ -71,7 +80,7 @@ export const teamCommand = new Command() .arguments("") .action(async (options, key: string) => { const { format, client } = await getCommandContext(options) - const target = await findTeam(client, key) + const target = await resolveTeam(client, key) const membersConnection = await target.members() const members = membersConnection.nodes const payload = { @@ -109,7 +118,7 @@ export const teamCommand = new Command() .arguments("") .action(async (options, key: string) => { const { format, client } = await getCommandContext(options) - const target = await findTeam(client, key) + const target = await resolveTeam(client, key) const membersConnection = await target.members() const payload = membersConnection.nodes.map((member) => ({ name: member.name, @@ -142,7 +151,7 @@ export const teamCommand = new Command() const { format, client } = await getCommandContext(options) // Positional arg takes precedence over --team flag const teamKey = key ?? requireTeam(options) - const target = await findTeam(client, teamKey) + const target = await resolveTeam(client, teamKey) // Progress indication for slow overview fetch if (Deno.stderr.isTerminal()) { @@ -150,21 +159,13 @@ export const teamCommand = new Command() } // Fetch all active issues for the team - const issues = await client.issues({ - filter: { - team: { key: { eq: teamKey } }, - state: { - type: { in: ["backlog", "unstarted", "started", "completed"] }, - }, - }, - first: 200, - }) + const allIssues = await fetchTeamOverviewIssues(client, teamKey) // Build assignee × state matrix const stateTypes = ["Backlog", "Todo", "In Progress", "Done"] const matrix: Record> = {} - for (const issue of issues.nodes) { + for (const issue of allIssues) { const assignee = await issue.assignee const state = await issue.state const assigneeName = assignee?.name ?? "Unassigned" @@ -204,7 +205,7 @@ export const teamCommand = new Command() return a.localeCompare(b) }) - const total = issues.nodes.length + const total = allIssues.length const inProgress = Object.values(matrix).reduce( (sum, row) => sum + (row["In Progress"] ?? 0), 0, @@ -257,7 +258,7 @@ export const teamCommand = new Command() .arguments("") .action(async (options, key: string) => { const { format, client } = await getCommandContext(options) - const target = await findTeam(client, key) + const target = await resolveTeam(client, key) const statesConn = await target.states() const payload = statesConn.nodes.map((s) => ({ name: s.name, @@ -290,7 +291,7 @@ export const teamCommand = new Command() .arguments("") .action(async (options, key: string) => { const { format, client } = await getCommandContext(options) - const target = await findTeam(client, key) + const target = await resolveTeam(client, key) const labelsConn = await target.labels() const payload = labelsConn.nodes.map((l) => ({ name: l.name, diff --git a/src/commands/user.ts b/src/commands/user.ts index 967a237..2f4af27 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,8 +1,8 @@ import { Command } from "@cliffy/command" -import { CliError } from "../errors.ts" import { getCommandContext } from "./_shared/context.ts" import { render } from "../output/formatter.ts" import { renderJson } from "../output/json.ts" +import { resolveUserEntity } from "../resolve.ts" export const userCommand = new Command() .description("Manage users") @@ -53,55 +53,7 @@ export const userCommand = new Command() .arguments("") .action(async (options, name: string) => { const { format, client } = await getCommandContext(options) - - let user - - if (name === "me") { - user = await client.viewer - } else { - const usersConnection = await client.users() - const all = usersConnection.nodes - - // Exact name match (case-insensitive) - let found = all.find( - (u) => u.name.toLowerCase() === name.toLowerCase(), - ) - // Exact email match - if (!found) { - found = all.find( - (u) => u.email?.toLowerCase() === name.toLowerCase(), - ) - } - // Substring match - if (!found) { - const partial = all.filter( - (u) => u.name.toLowerCase().includes(name.toLowerCase()), - ) - if (partial.length === 1) { - found = partial[0] - } else if (partial.length > 1) { - const candidates = partial - .map((u) => `${u.name} (${u.email})`) - .join(", ") - throw new CliError( - `ambiguous user "${name}"`, - 4, - `matches: ${candidates}`, - ) - } - } - if (!found) { - const available = all - .map((u) => `${u.name} (${u.email})`) - .join(", ") - throw new CliError( - `user not found: "${name}"`, - 3, - `available: ${available}`, - ) - } - user = found - } + const user = await resolveUserEntity(client, name) const details = { name: user.name, diff --git a/src/main.ts b/src/main.ts index ebf6dfb..f5504de 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,10 +11,8 @@ import { userCommand } from "./commands/user.ts" import { documentCommand } from "./commands/document.ts" import { initiativeCommand } from "./commands/initiative.ts" import { inboxCommand } from "./commands/inbox.ts" +import { getCommandContext } from "./commands/_shared/context.ts" import { buildIndex, suggestCommand } from "./suggest.ts" -import { createClient } from "./client.ts" -import { getAPIKey } from "./auth.ts" -import { getFormat } from "./types.ts" import { render } from "./output/formatter.ts" import { renderJson } from "./output/json.ts" import denoConfig from "../deno.json" with { type: "json" } @@ -76,9 +74,7 @@ const app = new Command() "Show current authenticated user (shorthand for user view me)", ) .action(async (options) => { - const format = getFormat(options) - const apiKey = await getAPIKey() - const client = createClient(apiKey) + const { format, client } = await getCommandContext(options) const user = await client.viewer diff --git a/src/resolve.ts b/src/resolve.ts index ab035e8..fbcbf1e 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -1,4 +1,11 @@ -import type { Document, Initiative, Issue, LinearClient } from "@linear/sdk" +import type { + Document, + Initiative, + Issue, + LinearClient, + Team, + User, +} from "@linear/sdk" import { CliError } from "./errors.ts" // --------------------------------------------------------------------------- @@ -113,11 +120,11 @@ export function requireTeam(options: unknown): string { // Entity resolvers — thin wrappers around resolve() // --------------------------------------------------------------------------- -/** Resolve team key to team ID. */ -export async function resolveTeamId( +/** Resolve team key to Team entity. */ +export async function resolveTeam( client: LinearClient, teamKey: string, -): Promise { +): Promise { const teams = await client.teams() return resolve({ items: teams.nodes, @@ -125,17 +132,24 @@ export async function resolveTeamId( key: (t) => t.key, entity: "team", display: (t) => t.key, - }).id + }) } -/** Resolve user name to ID. Supports "me" for current viewer. */ -export async function resolveUser( +/** Resolve team key to team ID. */ +export async function resolveTeamId( client: LinearClient, - name: string, + teamKey: string, ): Promise { + return (await resolveTeam(client, teamKey)).id +} + +/** Resolve user name to User entity. Supports "me" for current viewer. */ +export async function resolveUserEntity( + client: LinearClient, + name: string, +): Promise { if (name === "me") { - const viewer = await client.viewer - return viewer.id + return await client.viewer } const users = await client.users() @@ -146,7 +160,15 @@ export async function resolveUser( altKeys: (u) => [u.email ?? ""], entity: "user", display: (u) => `${u.name} (${u.email})`, - }).id + }) +} + +/** Resolve user name to ID. Supports "me" for current viewer. */ +export async function resolveUser( + client: LinearClient, + name: string, +): Promise { + return (await resolveUserEntity(client, name)).id } /** Resolve label name to ID within a team. */