From 3736fe5bcf6bad5aad1193d3094eb7b90621a4bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 04:39:39 +0000 Subject: [PATCH 01/11] Add codebase improvement recommendations (product + engineering) Research-based analysis with two recommendation sets: - 13 product recommendations (CLI commands, flags, UX) - 12 codebase recommendations (architecture, testing, tooling) Key product items: whoami, doctor, dry-run, cycles, issue comments, bulk ops, stdin piping, column selection, help examples. Key codebase items: split cli.ts/linear-client.ts monoliths, consolidate caching, add unit tests, structured logging, CI/CD. https://claude.ai/code/session_01LE3Rf4kmo1TBCozoF14LCa --- RECOMMENDATIONS.md | 148 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 RECOMMENDATIONS.md diff --git a/RECOMMENDATIONS.md b/RECOMMENDATIONS.md new file mode 100644 index 0000000..22c6278 --- /dev/null +++ b/RECOMMENDATIONS.md @@ -0,0 +1,148 @@ +# agent2linear Codebase Improvement Recommendations + +## Context +Research-based analysis of the agent2linear CLI tool (v0.24.1) — a TypeScript CLI for managing Linear projects and issues, designed for AI agents and automation. The codebase has 78+ command files, 20+ library modules, and ~18K lines of source code. Two recommendation sets follow: product (user-facing) and codebase (internal quality). + +--- + +## Set 1: Product Recommendations (CLI Interface & UX) + +### P1. Add a `whoami` top-level command +Currently there's no quick way to verify your API key and identity. `a2l whoami` should print the authenticated user's name, email, organization, and masked API key. The `testConnection()` and `getCurrentUser()` functions in `linear-client.ts` already exist — this is just a thin wrapper. + +### P2. Add `--dry-run` flag to mutation commands +For AI agent use cases, a `--dry-run` flag on `create` and `update` commands would let agents preview what would be sent to the API without actually mutating state. Print the resolved payload (team name, labels, etc.) and exit. This is especially valuable for automation pipelines where mistakes are costly. + +### P3. Add `cycle` commands (list, view, set-default) +Cycles (sprints) are a core Linear concept. The alias system already supports cycles (`'cycle'` entity type), but there are no `cycle list`, `cycle view`, or `cycle set` commands. Users have to know cycle IDs to use `--cycle` on issue create/update with no way to discover them via the CLI. + +### P4. Add `issue comment` subcommand +Issues support comments in Linear but the CLI has no way to add them. `a2l issue comment --body "text"` or `--body-file` would enable AI agents to post status updates on issues they're working on — a very common automation workflow. + +### P5. Add `issue search` as an explicit command or enhance `issue list --search` +The current `--search` flag on `issue list` is limited. A dedicated `a2l issue search "query"` command (or enhancing the existing flag) with support for Linear's full-text search, filtering by date ranges, and sorting by relevance would be more discoverable and powerful. + +### P6. Support piping and command chaining +Beyond M26's stream separation work, add support for reading input from stdin. For example: `echo "Bug title" | a2l issue create --team backend` or `a2l issue list --format json | a2l issue update --from-stdin`. This enables Unix pipeline workflows that AI agents and scripts depend on. + +### P7. Add `--output-fields` / `--columns` flag for list commands +Let users choose which fields appear in table/TSV output. Currently fields are hardcoded per command. `a2l project list --columns "name,status,lead,url"` would reduce noise for automation and let users customize their view. + +### P8. Add bulk operations +`a2l issue update --bulk "ENG-1,ENG-2,ENG-3" --state done` or `a2l issue create --from-file issues.json` for batch creation. AI agents frequently need to update multiple issues at once and currently must make N sequential calls. + +### P10. Add `a2l doctor` diagnostic command +A diagnostic command that checks: API connectivity, configuration validity (default team/initiative exist), cache health, alias counts per entity type, and version info. Useful for debugging setup issues — especially in CI/CD or when an AI agent's environment is misconfigured. Separate from `whoami` (P1) which handles identity only. + +### P13. No `delete` commands — by design +Delete commands are intentionally omitted for data safety. Document this decision prominently in README.md. Users should use `issue update --trash` for issues and the Linear UI for project deletion. The test cleanup scripts are an acceptable tradeoff. + +### P9. Improve help text with examples +Commander.js supports `addHelpText('after', ...)`. Each command should show 2-3 concrete usage examples in `--help` output. Currently help only shows flags — users (especially AI agents) need to see example invocations to understand usage patterns. + +### P11. Add `--no-color` global flag +For automation and CI environments, stripping ANSI color codes from output makes parsing easier. The output currently uses emojis and potentially ANSI codes that can interfere with machine parsing. + +--- + +## Set 2: Codebase Recommendations (Code Quality & Architecture) + +### C1. Split `cli.ts` (1,700 lines) into per-entity command registration files +`src/cli.ts` is a monolith that registers all 78+ commands. Each entity should register its own commands in a `register.ts` file (e.g., `src/commands/project/register.ts`) and `cli.ts` should just import and mount them. This improves maintainability and makes it easier to add new entities. + +**Files affected**: `src/cli.ts` → split into ~18 registration files + slim orchestrator + +### C2. Split `linear-client.ts` (4,084 lines) into domain-specific modules +This single file handles all Linear API calls for every entity type. Split into: +- `src/lib/api/projects.ts` — project CRUD + dependencies +- `src/lib/api/issues.ts` — issue CRUD + comments +- `src/lib/api/teams.ts` — team queries +- `src/lib/api/labels.ts` — issue + project labels +- `src/lib/api/client.ts` — authentication, connection test, shared utilities +- etc. + +Re-export from a barrel `src/lib/api/index.ts` for backward compatibility. + +**Files affected**: `src/lib/linear-client.ts` → ~8-10 focused modules + +### C3. Consolidate the dual caching systems +There are two overlapping cache implementations: `entity-cache.ts` (session + persistent cache) and `status-cache.ts` (file-based cache with its own TTL logic). They duplicate cache loading, TTL checking, and file I/O patterns. Unify into a single `CacheManager` class with entity-specific methods, reducing ~400 lines of duplication. + +**Files affected**: `src/lib/entity-cache.ts`, `src/lib/status-cache.ts` → unified `src/lib/cache.ts` + +### C4. Extract command boilerplate into shared middleware/helpers +Many commands repeat the same pattern: resolve alias → validate entity → fetch data → format output → handle errors. Extract this into a command runner utility: +```typescript +// Conceptual +await runCommand({ + resolve: { team: options.team, initiative: options.initiative }, + validate: true, + execute: async (resolved) => { /* command logic */ }, + format: options.format, +}); +``` +This would reduce per-command boilerplate by 30-50 lines. + +**Files affected**: New `src/lib/command-runner.ts`, then incremental adoption across commands + +### C5. Add unit tests for core library modules +Only `date-parser.ts` has unit tests (104 tests, 99.1% coverage). Critical modules with zero unit test coverage: +- `aliases.ts` (1,089 lines) — alias resolution, fuzzy matching +- `validators.ts` — input validation logic +- `parsers.ts` — comma/pipe parsing, dependency parsing +- `config.ts` — config priority chain +- `error-handler.ts` — HTTP error handling + +These are pure functions that can be tested without API calls. Adding vitest tests would catch regressions cheaply. + +**Files to create**: `src/lib/aliases.test.ts`, `src/lib/validators.test.ts`, `src/lib/parsers.test.ts`, etc. + +### C6. Create a `LinearClient` singleton instead of calling `getLinearClient()` repeatedly +Every API function calls `getLinearClient()` which creates a new `SDKClient` instance each time. While lightweight, a singleton pattern would be cleaner and enable connection pooling or request queuing in the future. + +**Files affected**: `src/lib/linear-client.ts` + +### C7. Add structured logging instead of scattered `console.log` +Commands and library code use raw `console.log` throughout. A minimal logger (even just a thin wrapper) with levels (debug, info, warn, error) would enable: +- `--verbose` mode for debugging +- `--quiet` mode (M26 plans this but doesn't address the underlying architecture) +- Structured output for machine consumption +- Consistent formatting + +**Files affected**: New `src/lib/logger.ts`, then gradual migration from `console.log`/`console.error` + +### C8. Version mismatch between `package.json` and `cli.ts` +`package.json` says `0.24.1` but `cli.ts:80` has `.version('0.24.0')`. These should be kept in sync — ideally by reading from `package.json` at build time or using a single source of truth. + +**Files affected**: `src/cli.ts:80` + +### C9. Add CI/CD with GitHub Actions +No `.github/` directory exists. A basic CI pipeline should: +- Run `npm run typecheck` and `npm run lint` on every PR +- Run `vitest` unit tests on every PR +- Run integration tests on a schedule (since they need LINEAR_API_KEY) +- Automate npm publishing on tagged releases + +**Files to create**: `.github/workflows/ci.yml`, `.github/workflows/release.yml` + +### C10. Standardize error handling patterns across commands +Some commands use `try/catch` with `process.exit(1)`, others use `handleLinearError()`, and some throw unhandled. Standardize on a single pattern — ideally the command runner from C4 would handle this uniformly. + +### C11. Move Ink components to a consistent location +Interactive UI components are split between `src/ui/components/` (5 files) and `src/components/` (WalkthroughScreen). Consolidate into one location. + +**Files affected**: `src/components/` → move to `src/ui/components/` + +### C12. Add `eslint-plugin-import` or equivalent for import ordering +Imports in `cli.ts` and command files have no consistent ordering. Adding import sorting rules would improve readability and reduce merge conflicts. + +**Files affected**: `.eslintrc.json` + +--- + +## Verification + +These are research recommendations — no code changes to verify. The recommendations can be validated by: +1. Checking the referenced files and line numbers exist +2. Confirming the patterns described match the actual code +3. Reviewing against the existing MILESTONES.md backlog for overlap (M26 overlaps with C7 and P12) From 8fcb64cb05ea937448d84f7b308b748d4c6f8b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 05:43:05 +0000 Subject: [PATCH 02/11] Implement 24 codebase improvements across 7 phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product improvements: - Add whoami command showing authenticated user and organization info - Add doctor command for diagnostic checks (API, config, cache, aliases) - Add cycle list/view/sync-aliases commands - Add issue comment subcommand - Add --dry-run flag to issue/project create/update commands - Add --columns flag to issue/project/members list commands - Add date range filters (--created-after, --created-before, etc.) to issue list - Add stdin piping support for issue create (title from stdin) - Add bulk update support (--bulk) for issue update - Add --no-color global flag to disable emojis - Document no-delete design decision in README Codebase improvements: - Fix version mismatch (0.24.0 → 0.24.1) - Move WalkthroughScreen.tsx to src/ui/components/ - Add GitHub Actions CI/CD workflows - Add ESLint import ordering plugin - Add unit tests for parsers, validators, and error handler (71 new tests) - Add LinearClient singleton caching - Add structured logging (logger.ts) with --quiet/--verbose flags - Add generic fetchWithCache helper to entity cache - Add command-runner.ts middleware for standardized error handling - Begin splitting cli.ts into per-entity register.ts files - Begin splitting linear-client.ts into domain API modules https://claude.ai/code/session_01LE3Rf4kmo1TBCozoF14LCa --- .eslintrc.json | 6 +- .github/workflows/ci.yml | 25 ++ .github/workflows/release.yml | 24 ++ README.md | 14 + package-lock.json | 11 + package.json | 1 + src/cli.ts | 133 +++++- src/commands/cycles/list.ts | 55 +++ src/commands/cycles/sync-aliases.ts | 18 + src/commands/cycles/view.ts | 33 ++ src/commands/doctor.ts | 104 +++++ src/commands/initiatives/register.ts | 87 ++++ src/commands/issue/comment.ts | 61 +++ src/commands/issue/create.ts | 32 +- src/commands/issue/list.ts | 62 ++- src/commands/issue/update.ts | 39 +- src/commands/members/list.tsx | 27 +- src/commands/members/register.ts | 53 +++ src/commands/project-status/register.ts | 71 ++++ src/commands/project/create.tsx | 9 + src/commands/project/list.tsx | 40 +- src/commands/project/register.ts | 392 ++++++++++++++++++ src/commands/project/update.ts | 9 + src/commands/setup.tsx | 2 +- src/commands/teams/register.ts | 83 ++++ src/commands/whoami.ts | 31 ++ src/lib/api/client.ts | 133 ++++++ src/lib/api/initiatives.ts | 110 +++++ src/lib/api/teams.ts | 112 +++++ src/lib/command-runner.ts | 88 ++++ src/lib/entity-cache.ts | 55 +++ src/lib/error-handler.test.ts | 111 +++++ src/lib/linear-client.ts | 115 ++++- src/lib/logger.ts | 58 +++ src/lib/output.ts | 55 ++- src/lib/parsers.test.ts | 154 +++++++ src/lib/types.ts | 6 + src/lib/validators.test.ts | 179 ++++++++ .../components}/WalkthroughScreen.tsx | 0 39 files changed, 2577 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 src/commands/cycles/list.ts create mode 100644 src/commands/cycles/sync-aliases.ts create mode 100644 src/commands/cycles/view.ts create mode 100644 src/commands/doctor.ts create mode 100644 src/commands/initiatives/register.ts create mode 100644 src/commands/issue/comment.ts create mode 100644 src/commands/members/register.ts create mode 100644 src/commands/project-status/register.ts create mode 100644 src/commands/project/register.ts create mode 100644 src/commands/teams/register.ts create mode 100644 src/commands/whoami.ts create mode 100644 src/lib/api/client.ts create mode 100644 src/lib/api/initiatives.ts create mode 100644 src/lib/api/teams.ts create mode 100644 src/lib/command-runner.ts create mode 100644 src/lib/error-handler.test.ts create mode 100644 src/lib/logger.ts create mode 100644 src/lib/parsers.test.ts create mode 100644 src/lib/validators.test.ts rename src/{components/setup => ui/components}/WalkthroughScreen.tsx (100%) diff --git a/.eslintrc.json b/.eslintrc.json index 92b3d5b..8d73f96 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -11,7 +11,7 @@ "eslint:recommended", "plugin:@typescript-eslint/recommended" ], - "plugins": ["@typescript-eslint"], + "plugins": ["@typescript-eslint", "simple-import-sort"], "env": { "node": true, "es2022": true @@ -19,7 +19,9 @@ "rules": { "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-explicit-any": "warn" + "@typescript-eslint/no-explicit-any": "warn", + "simple-import-sort/imports": "warn", + "simple-import-sort/exports": "warn" }, "ignorePatterns": ["dist", "node_modules"] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d225612 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x, 20.x] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npx tsc --noEmit + - run: npx eslint src --ext .ts,.tsx + - run: npx vitest run + - run: npx tsup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..be3698d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,24 @@ +name: Release + +on: + push: + tags: ['v*'] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npx tsc --noEmit + - run: npx vitest run + - run: npx tsup + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index a41b36b..d0e4435 100644 --- a/README.md +++ b/README.md @@ -1119,6 +1119,20 @@ npx agent2linear --version npx a2l --version ``` +## Design Decisions + +### No Delete Commands + +Delete commands are **intentionally omitted** for data safety. This is a deliberate design choice — not a missing feature. Destructive operations like deleting projects or permanently removing issues should be done through the Linear web UI where you can visually confirm what you're deleting. + +For issues, you can use the trash/restore workflow: +```bash +agent2linear issue update ENG-123 --trash # Move to trash (reversible) +agent2linear issue update ENG-123 --untrash # Restore from trash +``` + +Trashed issues can be recovered; deleted entities cannot. + ## Project Status See [MILESTONES.md](./MILESTONES.md) for detailed project milestones and progress. diff --git a/package-lock.json b/package-lock.json index ef5e166..70ed58f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@vitest/coverage-v8": "^4.0.4", "@vitest/ui": "^4.0.4", "eslint": "^8.0.0", + "eslint-plugin-simple-import-sort": "^12.1.1", "np": "^10.2.0", "prettier": "^3.0.0", "tsup": "^8.0.0", @@ -3397,6 +3398,16 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-plugin-simple-import-sort": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-12.1.1.tgz", + "integrity": "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=5.0.0" + } + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", diff --git a/package.json b/package.json index 2a83f67..a72ef31 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@vitest/coverage-v8": "^4.0.4", "@vitest/ui": "^4.0.4", "eslint": "^8.0.0", + "eslint-plugin-simple-import-sort": "^12.1.1", "np": "^10.2.0", "prettier": "^3.0.0", "tsup": "^8.0.0", diff --git a/src/cli.ts b/src/cli.ts index 0bc6fbd..f49b3c2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,10 @@ import { Command, Option, Argument } from 'commander'; +import { whoamiCommand } from './commands/whoami.js'; +import { doctorCommand } from './commands/doctor.js'; +import { listCyclesCommand } from './commands/cycles/list.js'; +import { viewCycleCommand } from './commands/cycles/view.js'; +import { syncCycleAliasesCore } from './commands/cycles/sync-aliases.js'; +import { commentIssueCommand } from './commands/issue/comment.js'; import { listConfig } from './commands/config/list.js'; import { getConfigValue } from './commands/config/get.js'; import type { ConfigKey } from './lib/config.js'; @@ -72,12 +78,24 @@ import { createIssueCommand } from './commands/issue/create.js'; import { updateIssueCommand } from './commands/issue/update.js'; import { registerIssueListCommand } from './commands/issue/list.js'; +import { setLogLevel } from './lib/logger.js'; +import { setNoColor } from './lib/output.js'; + const cli = new Command(); cli .name('agent2linear') .description('Command-line tool for creating Linear issues and projects. Designed for AI agents and automation.') - .version('0.24.0') + .version('0.24.1') + .option('-q, --quiet', 'Suppress progress messages (errors still shown)') + .option('-v, --verbose', 'Show debug output') + .option('--no-color', 'Disable emojis and colored output') + .hook('preAction', (thisCommand) => { + const opts = thisCommand.opts(); + if (opts.quiet) setLogLevel('quiet'); + if (opts.verbose) setLogLevel('verbose'); + if (opts.color === false) setNoColor(true); + }) .action(() => { cli.help(); }); @@ -211,6 +229,7 @@ project .option('--depends-on ', 'Projects this depends on (comma-separated IDs/aliases) - end→start anchor') .option('--blocks ', 'Projects this blocks (comma-separated IDs/aliases) - creates dependencies where other projects depend on this') .option('--dependency ', 'Advanced: "project:myAnchor:theirAnchor" (repeatable)', (value, previous: string[] = []) => [...previous, value], []) + .option('--dry-run', 'Preview the payload without creating the project') .addHelpText('after', ` Examples: Basic (auto-assigns you as lead): @@ -367,6 +386,7 @@ project .option('--remove-blocks ', 'Remove "blocks" relations (comma-separated IDs/aliases)') .option('--remove-dependency ', 'Remove all dependencies with project (repeatable)', (value, previous: string[] = []) => [...previous, value], []) .option('-w, --web', 'Open project in browser after update') + .option('--dry-run', 'Preview the payload without updating the project') .addHelpText('after', ` Examples: $ agent2linear project update "My Project" --status "In Progress" @@ -671,6 +691,7 @@ members .option('--active', 'Show only active members') .option('--inactive', 'Show only inactive members') .option('--admin', 'Show only admin users') + .option('--columns ', 'Comma-separated list of columns to display (e.g., "id,name,email")') .addHelpText('after', ` Examples: $ agent2linear members list # List default team members @@ -1484,6 +1505,7 @@ issue .option('--labels ', 'Comma-separated list of label IDs or aliases') .option('--template ', 'Issue template ID or alias') .option('-w, --web', 'Open created issue in browser') + .option('--dry-run', 'Preview the payload without creating the issue') .addHelpText('after', ` Examples: # Minimal (uses defaultTeam, auto-assigns to you) @@ -1591,6 +1613,8 @@ issue .option('--trash', 'Move issue to trash') .option('--untrash', 'Restore issue from trash') .option('-w, --web', 'Open updated issue in browser') + .option('--dry-run', 'Preview the payload without updating the issue') + .option('--bulk ', 'Apply same update to multiple issues (comma-separated identifiers)') .addHelpText('after', ` Examples: # Update single field @@ -1679,6 +1703,113 @@ Member Resolution: // Register issue list command (M15.5 Phase 1) registerIssueListCommand(issue); +// Issue comment subcommand +issue + .command('comment ') + .description('Add a comment to an issue') + .option('--body ', 'Comment body (markdown)') + .option('--body-file ', 'Read comment body from file') + .addHelpText('after', ` +Examples: + $ agent2linear issue comment ENG-123 --body "This is done" + $ agent2linear issue comment ENG-123 --body-file notes.md + +The identifier can be an issue identifier (ENG-123) or UUID. +Comment body supports markdown formatting. +`) + .action(async (identifier, options) => { + await commentIssueCommand(identifier, options); + }); + +// Cycles commands +const cycles = cli + .command('cycles') + .alias('cycle') + .description('Manage Linear cycles (sprints)') + .action(() => { + cycles.help(); + }); + +cycles + .command('list') + .alias('ls') + .description('List cycles') + .option('--team ', 'Filter by team') + .option('-f, --format ', 'Output format: json, tsv') + .addHelpText('after', ` +Examples: + $ agent2linear cycles list # List cycles for default team + $ agent2linear cycles list --team backend # List cycles for specific team + $ agent2linear cycles list --format json # JSON output +`) + .action(async (options) => { + await listCyclesCommand(options); + }); + +cycles + .command('view ') + .description('View cycle details') + .option('--json', 'Output as JSON') + .addHelpText('after', ` +Examples: + $ agent2linear cycles view cycle_abc123 + $ agent2linear cycles view sprint-1 # Using alias + $ agent2linear cycles view sprint-1 --json # JSON output +`) + .action(async (id, options) => { + await viewCycleCommand(id, options); + }); + +cycles + .command('sync-aliases') + .description('Create aliases for all cycles') + .option('-g, --global', 'Create aliases in global config') + .option('-p, --project', 'Create aliases in project config') + .option('--dry-run', 'Preview aliases without creating them') + .option('-f, --force', 'Overwrite existing aliases') + .option('--no-auto-suffix', 'Disable auto-numbering for duplicate slugs') + .addHelpText('after', ` +Examples: + $ agent2linear cycles sync-aliases --global # Create global aliases + $ agent2linear cycles sync-aliases --dry-run # Preview changes +`) + .action(async (options) => { + await syncCycleAliasesCore(options); + }); + +// Whoami command +cli + .command('whoami') + .description('Display authenticated user info') + .addHelpText('after', ` +Examples: + $ agent2linear whoami # Show your name, email, organization, and API key + +Displays the identity associated with your configured Linear API key. +`) + .action(async () => { + await whoamiCommand(); + }); + +// Doctor command +cli + .command('doctor') + .description('Run diagnostic checks on your agent2linear environment') + .addHelpText('after', ` +Examples: + $ agent2linear doctor # Run all diagnostic checks + +Checks: + • API key configuration + • API connectivity + • Default team/initiative settings + • Cache health + • Alias counts +`) + .action(async () => { + await doctorCommand(); + }); + // Setup command cli .command('setup') diff --git a/src/commands/cycles/list.ts b/src/commands/cycles/list.ts new file mode 100644 index 0000000..39604f8 --- /dev/null +++ b/src/commands/cycles/list.ts @@ -0,0 +1,55 @@ +import { getAllCycles } from '../../lib/linear-client.js'; +import { getConfig } from '../../lib/config.js'; +import { resolveAlias } from '../../lib/aliases.js'; +import { showError, formatListTSV, formatListJSON } from '../../lib/output.js'; + +interface ListCyclesOptions { + team?: string; + format?: string; +} + +/** + * List cycles with optional team filter + */ +export async function listCyclesCommand(options: ListCyclesOptions) { + try { + const config = getConfig(); + let teamId = options.team || config.defaultTeam; + + if (teamId) { + teamId = resolveAlias('team', teamId); + } + + const cycles = await getAllCycles(teamId); + + if (cycles.length === 0) { + console.log('No cycles found.'); + return; + } + + switch (options.format) { + case 'json': + console.log(formatListJSON(cycles)); + break; + case 'tsv': + console.log(formatListTSV(cycles, ['id', 'name', 'number', 'startsAt', 'endsAt', 'teamName'])); + break; + default: { + console.log(`\nCycles${teamId ? '' : ' (all teams)'}:\n`); + for (const cycle of cycles) { + const dates = [cycle.startsAt, cycle.endsAt].filter(Boolean).join(' → '); + const team = cycle.teamName ? ` [${cycle.teamName}]` : ''; + console.log(` ${cycle.name} (#${cycle.number})${team}`); + if (dates) { + console.log(` ${dates}`); + } + } + console.log(`\nTotal: ${cycles.length} cycle(s)`); + break; + } + } + } catch (error) { + showError(error instanceof Error ? error.message : 'Unknown error'); + process.exit(1); + } +} diff --git a/src/commands/cycles/sync-aliases.ts b/src/commands/cycles/sync-aliases.ts new file mode 100644 index 0000000..b4bc749 --- /dev/null +++ b/src/commands/cycles/sync-aliases.ts @@ -0,0 +1,18 @@ +import { getAllCycles } from '../../lib/linear-client.js'; +import { syncAliasesCore, type SyncAliasesOptions } from '../../lib/sync-aliases.js'; + +/** + * Core function to sync cycle aliases + */ +export async function syncCycleAliasesCore(options: SyncAliasesOptions): Promise { + const cycles = await getAllCycles(); + + await syncAliasesCore({ + entityType: 'cycle', + entityTypeName: 'cycle', + entityTypeNamePlural: 'cycles', + entities: cycles, + formatEntityDisplay: (cycle) => `${cycle.name} (#${cycle.number})`, + options, + }); +} diff --git a/src/commands/cycles/view.ts b/src/commands/cycles/view.ts new file mode 100644 index 0000000..2939efb --- /dev/null +++ b/src/commands/cycles/view.ts @@ -0,0 +1,33 @@ +import { getCycleById } from '../../lib/linear-client.js'; +import { resolveAlias } from '../../lib/aliases.js'; +import { showError } from '../../lib/output.js'; + +/** + * View a single cycle's details + */ +export async function viewCycleCommand(idOrAlias: string, options: { json?: boolean }) { + try { + const cycleId = resolveAlias('cycle', idOrAlias); + + const cycle = await getCycleById(cycleId); + if (!cycle) { + showError(`Cycle not found: "${idOrAlias}"`, 'Use "agent2linear cycles list" to see available cycles'); + process.exit(1); + } + + if (options.json) { + console.log(JSON.stringify(cycle, null, 2)); + return; + } + + console.log(`\nCycle: ${cycle.name || `Cycle ${cycle.number}`}`); + console.log(` ID: ${cycle.id}`); + console.log(` Number: ${cycle.number}`); + if (cycle.startsAt) console.log(` Starts: ${cycle.startsAt}`); + if (cycle.endsAt) console.log(` Ends: ${cycle.endsAt}`); + console.log(); + } catch (error) { + showError(error instanceof Error ? error.message : 'Unknown error'); + process.exit(1); + } +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000..cfbf376 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,104 @@ +import { testConnection, getCurrentUser } from '../lib/linear-client.js'; +import { getConfig, getApiKey } from '../lib/config.js'; +import { getEntityCache } from '../lib/entity-cache.js'; +import { getAliasesForType } from '../lib/aliases.js'; +import type { AliasEntityType } from '../lib/types.js'; + +/** + * Run diagnostic checks on the agent2linear environment + */ +export async function doctorCommand() { + console.log('\n🩺 agent2linear Doctor\n'); + + let passed = 0; + let failed = 0; + + // 1. API Key check + const apiKey = getApiKey(); + if (apiKey) { + console.log(' ✓ API key configured'); + passed++; + } else { + console.log(' ✗ API key not configured'); + console.log(' Run: agent2linear config set apiKey '); + console.log(' Or set LINEAR_API_KEY environment variable'); + failed++; + } + + // 2. API connectivity + if (apiKey) { + const result = await testConnection(); + if (result.success) { + const user = await getCurrentUser(); + console.log(` ✓ API connection OK (${user.name})`); + passed++; + } else { + console.log(` ✗ API connection failed: ${result.error}`); + failed++; + } + } else { + console.log(' ✗ API connection (skipped - no API key)'); + failed++; + } + + // 3. Configuration + const config = getConfig(); + console.log(); + console.log('Configuration:'); + + if (config.defaultTeam) { + console.log(` ✓ Default team: ${config.defaultTeam}`); + passed++; + } else { + console.log(' - Default team: not set'); + } + + if (config.defaultInitiative) { + console.log(` ✓ Default initiative: ${config.defaultInitiative}`); + passed++; + } else { + console.log(' - Default initiative: not set'); + } + + if (config.defaultProject) { + console.log(` ✓ Default project: ${config.defaultProject}`); + } else { + console.log(' - Default project: not set'); + } + + // 4. Cache + console.log(); + console.log('Cache:'); + const cache = getEntityCache(); + const stats = cache.getStats(); + const cacheEnabled = config.enableEntityCache !== false; + console.log(` ${cacheEnabled ? '✓' : '✗'} Entity cache: ${cacheEnabled ? 'enabled' : 'disabled'}`); + console.log(` TTL: ${config.entityCacheMinTTL || config.projectCacheMinTTL || 60} minutes`); + console.log(` Teams cached: ${stats.teams.count}, Initiatives: ${stats.initiatives.count}, Members: ${stats.members.count}`); + + // 5. Aliases + console.log(); + console.log('Aliases:'); + const aliasTypes: AliasEntityType[] = [ + 'team', 'initiative', 'project', 'member', 'workflow-state', + 'issue-label', 'project-label', 'project-status', 'cycle', + ]; + let totalAliases = 0; + for (const type of aliasTypes) { + const count = getAliasesForType(type).length; + totalAliases += count; + if (count > 0) { + console.log(` ${type}: ${count}`); + } + } + console.log(` Total: ${totalAliases} aliases`); + + // Summary + console.log(); + if (failed === 0) { + console.log(`✅ All checks passed (${passed} passed)`); + } else { + console.log(`⚠️ ${passed} passed, ${failed} failed`); + } + console.log(); +} diff --git a/src/commands/initiatives/register.ts b/src/commands/initiatives/register.ts new file mode 100644 index 0000000..8e5e736 --- /dev/null +++ b/src/commands/initiatives/register.ts @@ -0,0 +1,87 @@ +import { Command } from 'commander'; +import { listInitiatives } from './list.js'; +import { viewInitiative } from './view.js'; +import { selectInitiative } from './select.js'; +import { setInitiative } from './set.js'; +import { syncInitiativeAliases } from './sync-aliases.js'; + +export function registerInitiativesCommands(cli: Command): void { + const initiatives = cli + .command('initiatives') + .alias('init') + .description('Manage Linear initiatives') + .action(() => { + initiatives.help(); + }); + + initiatives + .command('list') + .alias('ls') + .description('List all initiatives') + .option('-I, --interactive', 'Use interactive mode for browsing') + .option('-w, --web', 'Open Linear in browser to view initiatives') + .option('-f, --format ', 'Output format: tsv, json') + .addHelpText('after', ` +Examples: + $ agent2linear initiatives list # Print list to stdout (default TSV) + $ agent2linear init ls # Same as 'list' (alias) + $ agent2linear initiatives list --interactive # Browse interactively + $ agent2linear initiatives list --web # Open in browser + $ agent2linear initiatives list --format json # Output as JSON + $ agent2linear initiatives list --format tsv # Output as TSV (explicit) + $ agent2linear init list -f json | jq '.[0]' # Pipe to jq +`) + .action(async (options) => { + await listInitiatives(options); + }); + + initiatives + .command('view [id]') + .description('View details of a specific initiative (format: init_xxx)') + .option('-I, --interactive', 'Use interactive mode to select initiative') + .option('-w, --web', 'Open initiative in browser instead of displaying in terminal') + .addHelpText('after', ` +Examples: + $ agent2linear initiatives view init_abc123 + $ agent2linear init view init_abc123 + $ agent2linear initiatives view init_abc123 --web + $ agent2linear init view myalias --web + $ agent2linear init view --interactive # Select from list + $ agent2linear init view -I # Select and view in terminal + $ agent2linear init view -I --web # Select and open in browser +`) + .action(async (id: string | undefined, options) => { + await viewInitiative(id, options); + }); + + initiatives + .command('select') + .description('Interactively select a default initiative') + .option('-g, --global', 'Save to global config (default)') + .option('-p, --project', 'Save to project config') + .addHelpText('after', ` +Examples: + $ agent2linear initiatives select # Interactive selection + $ agent2linear initiatives select --project # Save to project config +`) + .action(async (options) => { + await selectInitiative(options); + }); + + initiatives + .command('set ') + .description('Set default initiative by ID (non-interactive)') + .option('-g, --global', 'Save to global config (default)') + .option('-p, --project', 'Save to project config') + .addHelpText('after', ` +Examples: + $ agent2linear initiatives set init_abc123 + $ agent2linear initiatives set backend # Using alias + $ agent2linear initiatives set init_xyz789 --project +`) + .action(async (id: string, options) => { + await setInitiative(id, options); + }); + + syncInitiativeAliases(initiatives); +} diff --git a/src/commands/issue/comment.ts b/src/commands/issue/comment.ts new file mode 100644 index 0000000..f613474 --- /dev/null +++ b/src/commands/issue/comment.ts @@ -0,0 +1,61 @@ +import { createIssueComment } from '../../lib/linear-client.js'; +import { resolveIssueIdentifier } from '../../lib/issue-resolver.js'; +import { readContentFile } from '../../lib/file-utils.js'; +import { showError, showSuccess } from '../../lib/output.js'; + +interface CommentOptions { + body?: string; + bodyFile?: string; +} + +/** + * Add a comment to an issue + */ +export async function commentIssueCommand(identifier: string, options: CommentOptions) { + try { + // Validate mutual exclusivity + if (options.body && options.bodyFile) { + showError('Cannot use both --body and --body-file'); + process.exit(1); + } + + if (!options.body && !options.bodyFile) { + showError('Either --body or --body-file is required'); + process.exit(1); + } + + // Get comment body + let body = options.body; + if (options.bodyFile) { + const result = await readContentFile(options.bodyFile); + if (!result.success) { + showError(`Error reading file: ${options.bodyFile}`, result.error); + process.exit(1); + } + body = result.content; + } + + if (!body || body.trim().length === 0) { + showError('Comment body cannot be empty'); + process.exit(1); + } + + // Resolve issue identifier + const resolved = await resolveIssueIdentifier(identifier); + if (!resolved) { + showError(`Issue not found: "${identifier}"`, 'Use issue identifier (ENG-123) or UUID'); + process.exit(1); + } + + // Create comment + const comment = await createIssueComment(resolved.issueId, body); + + showSuccess('Comment added', { + 'Issue': identifier, + 'Comment ID': comment.id, + }); + } catch (error) { + showError(error instanceof Error ? error.message : 'Unknown error'); + process.exit(1); + } +} diff --git a/src/commands/issue/create.ts b/src/commands/issue/create.ts index 54e07e3..3334a56 100644 --- a/src/commands/issue/create.ts +++ b/src/commands/issue/create.ts @@ -47,6 +47,7 @@ interface CreateOptions { // Mode web?: boolean; // Open in browser after creation + dryRun?: boolean; // Print payload without creating } /** @@ -80,11 +81,31 @@ async function createIssueNonInteractive(options: CreateOptions) { console.log(`📄 Read description from: ${options.descriptionFile}`); } + // Read title from stdin if piped and --title not provided + if (!options.title && !process.stdin.isTTY) { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + const stdinContent = Buffer.concat(chunks).toString('utf-8').trim(); + if (stdinContent) { + // First line is the title, rest is description + const lines = stdinContent.split('\n'); + options.title = lines[0].trim(); + if (lines.length > 1 && !description) { + description = lines.slice(1).join('\n').trim() || undefined; + } + console.log(`📥 Read title from stdin: "${options.title}"`); + } + } + // Validate required field: title if (!options.title) { console.error('❌ Error: --title is required\n'); console.error('Provide the title:'); console.error(' agent2linear issue create --title "Fix bug"\n'); + console.error('Or pipe from stdin:'); + console.error(' echo "Fix login bug" | agent2linear issue create\n'); console.error('For all options, see:'); console.error(' agent2linear issue create --help\n'); process.exit(1); @@ -492,8 +513,6 @@ async function createIssueNonInteractive(options: CreateOptions) { // PHASE 15: CREATE THE ISSUE // ═══════════════════════════════════════════════════════════════════ - console.log('\n🚀 Creating issue...'); - const issueData: IssueCreateInput = { title, teamId, @@ -511,6 +530,15 @@ async function createIssueNonInteractive(options: CreateOptions) { templateId, }; + // Dry-run mode: print payload and exit without creating + if (options.dryRun) { + console.error('\n[dry-run] Would create issue with:'); + console.log(JSON.stringify(issueData, null, 2)); + return; + } + + console.log('\n🚀 Creating issue...'); + const result = await createIssue(issueData); // Display success message diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 9f257b1..e13a7f0 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -11,7 +11,7 @@ import type { Command } from 'commander'; import { getAllIssues } from '../../lib/linear-client.js'; -import { showError, formatContentPreview } from '../../lib/output.js'; +import { showError, formatContentPreview, filterColumns } from '../../lib/output.js'; import { getConfig } from '../../lib/config.js'; import { resolveAlias } from '../../lib/aliases.js'; import { resolveProjectId } from '../../lib/project-resolver.js'; @@ -131,6 +131,20 @@ async function buildDefaultFilters(options: any): Promise { filters.search = options.search; } + // Date range filters + if (options.createdAfter) { + filters.createdAfter = options.createdAfter; + } + if (options.createdBefore) { + filters.createdBefore = options.createdBefore; + } + if (options.updatedAfter) { + filters.updatedAfter = options.updatedAfter; + } + if (options.updatedBefore) { + filters.updatedBefore = options.updatedBefore; + } + // ======================================== // PHASE 3: SORTING // ======================================== @@ -337,6 +351,9 @@ async function listIssues(options: { desc?: boolean; descLength?: string; descFull?: boolean; + + // Column selection + columns?: string; }): Promise { try { // Build filters with smart defaults @@ -380,9 +397,43 @@ async function listIssues(options: { } : undefined; - // Output based on format + // Column selection: flatten nested objects for filtering const format = options.format || 'table'; + if (options.columns) { + const cols = options.columns.split(',').map(c => c.trim()); + // Flatten issues for column filtering + const flattened = issues.map(issue => ({ + identifier: issue.identifier, + title: issue.title, + state: issue.state?.name || '', + priority: issue.priority !== undefined ? formatPriority(issue.priority) : '', + assignee: issue.assignee?.name || '', + team: issue.team?.key || '', + url: issue.url, + description: issue.description || '', + id: issue.id, + estimate: issue.estimate, + dueDate: (issue as any).dueDate || '', + })); + const filtered = filterColumns(flattened, cols); + + if (format === 'json') { + console.log(JSON.stringify(filtered, null, 2)); + } else { + // TSV/table with custom columns + console.log(cols.join('\t')); + for (const row of filtered) { + console.log(cols.map(c => String(row[c] ?? '')).join('\t')); + } + if (format === 'table') { + console.log(`\nTotal: ${issues.length} issue(s)`); + } + } + return; + } + + // Output based on format switch (format) { case 'json': formatJsonOutput(issues); @@ -440,6 +491,12 @@ export function registerIssueListCommand(program: Command): void { .option('--cycle ', 'Filter by cycle') .option('--search ', 'Full-text search in issue title and description') + // Date range filters + .option('--created-after ', 'Filter issues created after date (YYYY-MM-DD)') + .option('--created-before ', 'Filter issues created before date (YYYY-MM-DD)') + .option('--updated-after ', 'Filter issues updated after date (YYYY-MM-DD)') + .option('--updated-before ', 'Filter issues updated before date (YYYY-MM-DD)') + // Phase 3: Sorting .option('--sort ', 'Sort by field: priority, created, updated, due (default: priority)') .option('--order ', 'Sort order: asc or desc (default: desc)') @@ -455,6 +512,7 @@ export function registerIssueListCommand(program: Command): void { .option('--desc-length ', 'Description preview length in characters (implies --desc)') .option('--desc-full', 'Show full description column (no truncation)') .option('--no-desc', 'Hide description column') + .option('--columns ', 'Comma-separated list of columns to display (e.g., "identifier,title,state")') .addHelpText('after', ` Smart Defaults (applied automatically unless overridden): diff --git a/src/commands/issue/update.ts b/src/commands/issue/update.ts index c9aaf0b..899e494 100644 --- a/src/commands/issue/update.ts +++ b/src/commands/issue/update.ts @@ -60,6 +60,8 @@ interface UpdateOptions { // Mode web?: boolean; // Open in browser after update + dryRun?: boolean; // Print payload without updating + bulk?: string; // Comma-separated identifiers for bulk update } /** @@ -233,8 +235,16 @@ async function updateIssueNonInteractive(identifier: string, options: UpdateOpti process.exit(1); } - // Read description from file if --description-file is provided + // Read description from stdin if --description is "-" let description = options.description; + if (description === '-' && !process.stdin.isTTY) { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + description = Buffer.concat(chunks).toString('utf-8'); + console.log(`📥 Read description from stdin`); + } if (options.descriptionFile) { const result = await readContentFile(options.descriptionFile); if (!result.success) { @@ -826,6 +836,13 @@ async function updateIssueNonInteractive(identifier: string, options: UpdateOpti // PHASE 16: UPDATE THE ISSUE // ═══════════════════════════════════════════════════════════════════ + // Dry-run mode: print payload and exit without updating + if (options.dryRun) { + console.error('\n[dry-run] Would update issue with:'); + console.log(JSON.stringify({ issueId, ...updates }, null, 2)); + return; + } + console.log('\n🚀 Updating issue...'); const result = await updateIssue(issueId, updates); @@ -858,5 +875,23 @@ async function updateIssueNonInteractive(identifier: string, options: UpdateOpti * Main entry point for issue update command */ export async function updateIssueCommand(identifier: string, options: UpdateOptions) { - await updateIssueNonInteractive(identifier, options); + if (options.bulk) { + // Bulk mode: apply same update to multiple issues sequentially + // Note: errors in individual updates will halt the process (due to process.exit in handlers). + // A future refactor (C10) will make error handling non-fatal for bulk operations. + const { parseCommaSeparated } = await import('../../lib/parsers.js'); + const identifiers = [identifier, ...parseCommaSeparated(options.bulk)]; + + console.log(`\n📦 Bulk update: ${identifiers.length} issue(s)\n`); + + for (let i = 0; i < identifiers.length; i++) { + const id = identifiers[i].trim(); + console.log(`\n─── [${i + 1}/${identifiers.length}] ${id} ───`); + await updateIssueNonInteractive(id, { ...options, bulk: undefined, web: undefined }); + } + + console.log(`\n📦 Bulk update complete: ${identifiers.length} issue(s) updated\n`); + } else { + await updateIssueNonInteractive(identifier, options); + } } diff --git a/src/commands/members/list.tsx b/src/commands/members/list.tsx index 2685c5f..b854cd4 100644 --- a/src/commands/members/list.tsx +++ b/src/commands/members/list.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { render, Box, Text } from 'ink'; import { getAllMembers, type Member } from '../../lib/linear-client.js'; import { openInBrowser } from '../../lib/browser.js'; -import { formatListTSV, formatListJSON } from '../../lib/output.js'; +import { formatListTSV, formatListJSON, filterColumns } from '../../lib/output.js'; import { getAliasesForId } from '../../lib/aliases.js'; import { getConfig } from '../../lib/config.js'; import { resolveAlias } from '../../lib/aliases.js'; @@ -18,6 +18,7 @@ interface ListOptions { active?: boolean; inactive?: boolean; admin?: boolean; + columns?: string; } function App({ options }: { options: ListOptions }) { @@ -197,6 +198,30 @@ export async function listMembers(options: ListOptions = {}) { adminStatus: member.admin ? 'Admin' : 'User', })); + // Handle --columns option + if (options.columns) { + const cols = options.columns.split(',').map(c => c.trim()); + const flattened = membersWithAliases.map(m => ({ + id: m.id, + name: m.name, + email: m.email, + active: m.activeStatus, + admin: m.adminStatus, + aliases: m.aliases.map(a => `@${a}`).join(', ') || '(none)', + })); + const filtered = filterColumns(flattened, cols); + + if (options.format === 'json') { + console.log(JSON.stringify(filtered, null, 2)); + } else { + console.log(cols.join('\t')); + for (const row of filtered) { + console.log(cols.map(c => String(row[c] ?? '')).join('\t')); + } + } + return; + } + // Handle format option if (options.format === 'json') { console.log(formatListJSON(membersWithAliases)); diff --git a/src/commands/members/register.ts b/src/commands/members/register.ts new file mode 100644 index 0000000..24882e4 --- /dev/null +++ b/src/commands/members/register.ts @@ -0,0 +1,53 @@ +import { Command } from 'commander'; +import { listMembers } from './list.js'; +import { syncMemberAliases } from './sync-aliases.js'; + +export function registerMembersCommands(cli: Command): void { + const members = cli + .command('members') + .alias('users') + .description('Manage Linear members/users') + .action(() => { + members.help(); + }); + + members + .command('list') + .alias('ls') + .description('List members in your organization or team') + .option('-I, --interactive', 'Use interactive mode for browsing') + .option('-w, --web', 'Open Linear members page in browser') + .option('-f, --format ', 'Output format: tsv, json') + .option('--team ', 'Filter by team (uses default team if not specified)') + .option('--org-wide', 'List all organization members (ignore team filter)') + .option('--name ', 'Filter by name') + .option('--email ', 'Filter by email') + .option('--active', 'Show only active members') + .option('--inactive', 'Show only inactive members') + .option('--admin', 'Show only admin users') + .option('--columns ', 'Comma-separated list of columns to display (e.g., "id,name,email")') + .addHelpText('after', ` +Examples: + $ agent2linear members list # List default team members + $ agent2linear users ls # Same as 'list' (alias) + $ agent2linear members list --org-wide # List all organization members + $ agent2linear members list --team team_abc123 # List specific team members + $ agent2linear members list --name John # Filter by name + $ agent2linear members list --email @acme.com # Filter by email domain + $ agent2linear members list --active # Show only active members + $ agent2linear members list --admin # Show only admins + $ agent2linear members list --interactive # Browse interactively + $ agent2linear members list --web # Open in browser + $ agent2linear members list --format json # Output as JSON + $ agent2linear members list --format tsv # Output as TSV + $ agent2linear members list -f tsv | cut -f1 # Get just member IDs + +Note: By default, uses your configured default team. Use --org-wide to see all members. + $ agent2linear config set defaultTeam team_xxx # Set default team +`) + .action(async (options) => { + await listMembers(options); + }); + + syncMemberAliases(members); +} diff --git a/src/commands/project-status/register.ts b/src/commands/project-status/register.ts new file mode 100644 index 0000000..1c5451b --- /dev/null +++ b/src/commands/project-status/register.ts @@ -0,0 +1,71 @@ +import { Command } from 'commander'; +import { listProjectStatuses } from './list.js'; +import { viewProjectStatus } from './view.js'; +import { syncProjectStatusAliases } from './sync-aliases.js'; + +export function registerProjectStatusCommands(cli: Command): void { + const projectStatus = cli + .command('project-status') + .alias('pstatus') + .description('Manage Linear project statuses') + .action(() => { + projectStatus.help(); + }); + + projectStatus + .command('list') + .alias('ls') + .description('List all project statuses') + .option('-I, --interactive', 'Use interactive mode for browsing') + .option('-w, --web', 'Open Linear project settings in browser') + .option('-f, --format ', 'Output format: tsv, json') + .addHelpText('after', ` +Examples: + $ agent2linear project-status list # Print list to stdout (formatted) + $ agent2linear pstatus ls # Same as 'list' (alias) + $ agent2linear project-status list --interactive # Browse interactively + $ agent2linear project-status list --web # Open in browser + $ agent2linear project-status list --format json # Output as JSON + $ agent2linear project-status list --format tsv # Output as TSV + $ agent2linear pstatus list -f tsv | cut -f1 # Get just status IDs +`) + .action(async (options) => { + await listProjectStatuses(options); + }); + + projectStatus + .command('view ') + .description('View details of a specific project status') + .option('-w, --web', 'Open project settings in browser instead of displaying in terminal') + .addHelpText('after', ` +Examples: + $ agent2linear project-status view "In Progress" + $ agent2linear pstatus view status_abc123 + $ agent2linear project-status view planned --web + $ agent2linear pstatus view active-status --web # Using alias +`) + .action(async (nameOrId: string, options) => { + await viewProjectStatus(nameOrId, options); + }); + + projectStatus + .command('sync-aliases') + .description('Create aliases for all org project statuses') + .option('-g, --global', 'Save to global config (default)') + .option('-p, --project', 'Save to project config') + .option('--dry-run', 'Preview changes without applying them') + .option('--force', 'Override existing aliases') + .addHelpText('after', ` +Examples: + $ agent2linear project-status sync-aliases # Create global aliases + $ agent2linear pstatus sync-aliases --project # Create project-local aliases + $ agent2linear project-status sync-aliases --dry-run # Preview changes + $ agent2linear pstatus sync-aliases --force # Force override existing + +This command will create aliases for all project statuses in your workspace, +using the status name converted to lowercase with hyphens (e.g., "In Progress" → "in-progress"). +`) + .action(async (options) => { + await syncProjectStatusAliases(options); + }); +} diff --git a/src/commands/project/create.tsx b/src/commands/project/create.tsx index 2dd6b63..bdd30c4 100644 --- a/src/commands/project/create.tsx +++ b/src/commands/project/create.tsx @@ -48,6 +48,8 @@ interface CreateOptions { dependsOn?: string; blocks?: string; dependency?: string[]; + // Dry-run mode + dryRun?: boolean; } // Non-interactive mode @@ -392,6 +394,13 @@ async function createProjectNonInteractive(options: CreateOptions) { memberIds, }; + // Dry-run mode: print payload and exit without creating + if (options.dryRun) { + console.error('\n[dry-run] Would create project with:'); + console.log(JSON.stringify(projectData, null, 2)); + return; + } + const result = await createProject(projectData); // Create external links if provided diff --git a/src/commands/project/list.tsx b/src/commands/project/list.tsx index 273bdfd..154894d 100644 --- a/src/commands/project/list.tsx +++ b/src/commands/project/list.tsx @@ -3,7 +3,7 @@ import { render, Box, Text } from 'ink'; import type { Command } from 'commander'; import { getAllProjects } from '../../lib/linear-client.js'; import { getEntityCache } from '../../lib/entity-cache.js'; -import { showError, formatContentPreview } from '../../lib/output.js'; +import { showError, formatContentPreview, filterColumns } from '../../lib/output.js'; import { getConfig } from '../../lib/config.js'; import { resolveAlias } from '../../lib/aliases.js'; import type { ProjectListFilters, ProjectListItem } from '../../lib/types.js'; @@ -362,6 +362,7 @@ export function listProjectsCommand(program: Command): void { .option('--desc-length ', 'Description preview length in characters') .option('--desc-full', 'Show full description column (no truncation)') .option('--no-desc', 'Hide description preview column') + .option('--columns ', 'Comma-separated list of columns to display (e.g., "id,name,status")') .action(async (options) => { try { @@ -410,6 +411,43 @@ export function listProjectsCommand(program: Command): void { hide: options.desc === false, // --no-desc sets this to false }; + // Column selection mode + if (options.columns) { + let projects = await getAllProjects(filters); + projects = applyDependencyFilters(projects, { + hasDependencies: options.hasDependencies, + withoutDependencies: options.withoutDependencies, + dependsOnOthers: options.dependsOnOthers, + blocksOthers: options.blocksOthers, + }); + + const cols = options.columns.split(',').map((c: string) => c.trim()); + const flattened = projects.map(p => ({ + id: p.id, + name: p.name, + status: p.status?.name || p.state || '', + team: p.team?.name || '', + lead: p.lead?.name || '', + description: p.description || '', + priority: p.priority, + url: (p as any).url || '', + dependsOnCount: p.dependsOnCount || 0, + blocksCount: p.blocksCount || 0, + })); + const filtered = filterColumns(flattened, cols); + + if (options.format === 'json') { + console.log(JSON.stringify(filtered, null, 2)); + } else { + console.log(cols.join('\t')); + for (const row of filtered) { + console.log(cols.map((c: string) => String(row[c] ?? '')).join('\t')); + } + console.log(`\nTotal: ${projects.length} project${projects.length !== 1 ? 's' : ''}`); + } + process.exit(0); + } + // Non-interactive formats - handle synchronously before Ink if (options.format !== 'table' && !options.interactive) { let projects = await getAllProjects(filters); diff --git a/src/commands/project/register.ts b/src/commands/project/register.ts new file mode 100644 index 0000000..f645e92 --- /dev/null +++ b/src/commands/project/register.ts @@ -0,0 +1,392 @@ +import { Command, Option } from 'commander'; +import { createProjectCommand } from './create.js'; +import { viewProject } from './view.js'; +import { updateProjectCommand } from './update.js'; +import { listProjectsCommand } from './list.js'; +import { addMilestones } from './add-milestones.js'; + +export function registerProjectCommands(cli: Command): void { + const project = cli + .command('project') + .alias('proj') + .description('Manage Linear projects') + .action(() => { + project.help(); + }); + + project + .command('create') + .alias('new') + .description('Create a new project') + .option('-I, --interactive', 'Use interactive mode') + .option('-w, --web', 'Open Linear in browser to create project') + .option('-t, --title ', 'Project title (minimum 3 characters)') + .option('-d, --description <description>', 'Project description') + .option('-i, --initiative <id>', 'Initiative ID to link project to (format: init_xxx)') + .option('--team <id>', 'Team ID to assign project to (format: team_xxx)') + .option('--template <id>', 'Template ID to use for project creation (format: template_xxx)') + .option('--status <id>', 'Project status ID (format: status_xxx)') + .option('--content <markdown>', 'Project content as markdown') + .option('--content-file <path>', 'Path to file containing project content (markdown)') + .option('--icon <icon>', 'Project icon name (e.g., "Joystick", "Tree", "Skull" - capitalized)') + .option('--color <hex>', 'Project color (hex code like #FF6B6B)') + .option('--lead <id>', 'Project lead user ID (format: user_xxx)') + .option('--no-lead', 'Do not assign a project lead (overrides auto-assign)') + .option('--labels <ids>', 'Comma-separated project label IDs (e.g., label_1,label_2)') + .option('--link <url-and-label>', 'External link as "URL" or "URL|Label" (can be specified multiple times)', (value, previous: string[] = []) => [...previous, value], []) + .option('--converted-from <id>', 'Issue ID this project was converted from (format: issue_xxx)') + .option('--start-date <date>', 'Planned start date. Formats: YYYY-MM-DD, Quarter (2025-Q1, Q1 2025), Month (2025-01, Jan 2025), Half-year (2025-H1), Year (2025). Resolution auto-detected from format.') + .addOption( + new Option('--start-date-resolution <resolution>', 'Override auto-detected resolution (advanced). Only needed when date format doesn\'t match your intent. Example: --start-date 2025-01-15 --start-date-resolution quarter (mid-month date representing Q1)') + .choices(['month', 'quarter', 'halfYear', 'year']) + ) + .option('--target-date <date>', 'Target completion date. Formats: YYYY-MM-DD, Quarter (2025-Q1, Q1 2025), Month (2025-01, Jan 2025), Half-year (2025-H1), Year (2025). Resolution auto-detected from format.') + .addOption( + new Option('--target-date-resolution <resolution>', 'Override auto-detected resolution (advanced). Only needed when date format doesn\'t match your intent. Example: --target-date 2025-01-15 --target-date-resolution quarter (mid-month date representing Q1)') + .choices(['month', 'quarter', 'halfYear', 'year']) + ) + .addOption( + new Option('--priority <priority>', 'Project priority') + .choices(['0', '1', '2', '3', '4']) + .argParser(parseInt) + ) + .option('--members <ids>', 'Comma-separated member user IDs (e.g., user_1,user_2)') + .option('--depends-on <projects>', 'Projects this depends on (comma-separated IDs/aliases) - end→start anchor') + .option('--blocks <projects>', 'Projects this blocks (comma-separated IDs/aliases) - creates dependencies where other projects depend on this') + .option('--dependency <spec>', 'Advanced: "project:myAnchor:theirAnchor" (repeatable)', (value, previous: string[] = []) => [...previous, value], []) + .option('--dry-run', 'Preview the payload without creating the project') + .addHelpText('after', ` +Examples: + Basic (auto-assigns you as lead): + $ agent2linear project create --title "My Project" --team team_xyz789 + $ agent2linear proj new --title "Quick Project" --team team_xyz789 # Same as 'create' (alias) + $ agent2linear project create --title "Q1 Goals" --initiative init_abc123 --team team_xyz789 + + With template: + $ agent2linear project create --title "API Project" --template template_abc123 --team team_xyz789 + + Lead assignment (by default, you are auto-assigned as lead): + $ agent2linear project create --title "My Project" --team team_xyz789 + # Auto-assigns current user as lead + + $ agent2linear project create --title "My Project" --team team_xyz789 --lead user_abc123 + # Assign specific user as lead + + $ agent2linear project create --title "My Project" --team team_xyz789 --no-lead + # No lead assignment + + $ agent2linear config set defaultAutoAssignLead false + # Disable auto-assign globally + + With additional fields: + $ agent2linear project create --title "Website Redesign" --team team_abc123 \\ + --icon "Tree" --color "#FF6B6B" --lead user_xyz789 \\ + --start-date "2025-01-15" \\ + --target-date "2025-03-31" \\ + --priority 2 + + Date formats (flexible, auto-detected resolution): + $ agent2linear project create --title "Q1 Initiative" --team team_abc123 --start-date "2025-Q1" + # Creates project with start date: 2025-01-01, resolution: quarter + + $ agent2linear project create --title "January Sprint" --team team_abc123 --start-date "Jan 2025" + # Creates project with start date: 2025-01-01, resolution: month + + $ agent2linear project create --title "2025 Strategy" --team team_abc123 \\ + --start-date "2025" --target-date "2025-Q4" + # Start: 2025-01-01 (year), Target: 2025-10-01 (quarter) + + With content and labels: + $ agent2linear project create --title "Q1 Planning" --team team_abc123 \\ + --content "# Goals\\n- Improve performance\\n- Add features" \\ + --labels "label_1,label_2" + + With content from file: + $ agent2linear project create --title "API Project" --team team_abc123 \\ + --content-file ./project-plan.md + + With dependencies (simple mode): + $ agent2linear project create --title "Frontend App" --team team_abc123 \\ + --depends-on "api-backend,infrastructure" \\ + --blocks "testing,deployment" + + With dependencies (advanced mode - custom anchors): + $ agent2linear project create --title "API v2" --team team_abc123 \\ + --dependency "backend-infra:end:start" \\ + --dependency "database-migration:start:end" + + Interactive mode: + $ agent2linear project create --interactive + + Open in browser: + $ agent2linear project create --web + +Field Value Formats: + --status status_xxx (Linear status ID) + --content Inline markdown text + --content-file Path to markdown file (mutually exclusive with --content) + --icon Capitalized icon name like "Joystick", "Tree", "Skull", "Email", "Checklist" + --color #FF6B6B (hex color code) + --lead user_xxx (Linear user ID) + --no-lead Flag to disable lead assignment + --labels label_1,label_2,label_3 (comma-separated) + --members user_1,user_2 (comma-separated) + --priority 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low + --depends-on proj1,proj2 (my end waits for their start) + --blocks proj1,proj2 (their end waits for my start) + --dependency project:myAnchor:theirAnchor (advanced: start|end) + +Date Formats (--start-date, --target-date): + Quarters: 2025-Q1, Q1 2025, q1-2025 (case-insensitive) + → Q1: 2025-01-01, Q2: 2025-04-01, Q3: 2025-07-01, Q4: 2025-10-01 + Half-years: 2025-H1, H1 2025, h1-2025 + → H1: 2025-01-01 (Jan-Jun), H2: 2025-07-01 (Jul-Dec) + Months: 2025-01, Jan 2025, January 2025, 2025-Dec + → First day of month (2025-01-01, 2025-12-01) + Years: 2025 + → First day of year (2025-01-01) + ISO dates: 2025-01-15, 2025-03-31 + → Specific day (no auto-detected resolution) + + Note: Resolution is auto-detected from format. The --*-resolution flags are optional + and only needed for advanced use cases where you want to override the auto-detection. + +Note: Set defaults with config: + $ agent2linear config set defaultProjectTemplate template_abc123 + $ agent2linear config set defaultAutoAssignLead true # Enable auto-assign (default) + $ agent2linear config set defaultAutoAssignLead false # Disable auto-assign + $ agent2linear teams select # Set default team +`) + .action(async options => { + await createProjectCommand(options); + }); + + project + .command('view <name-or-id>') + .description('View details of a specific project (by name, ID, or alias)') + .option('-w, --web', 'Open project in browser instead of displaying in terminal') + .option('-a, --auto-alias', 'Automatically create an alias if resolving by name') + .option('--desc', 'Show description preview (default 80 chars)') + .option('--desc-length <n>', 'Description preview length in characters (implies --desc)') + .option('--desc-full', 'Show full description (no truncation)') + .option('--no-desc', 'Hide description') + .addHelpText('after', ` +Examples: + $ agent2linear project view PRJ-123 # By ID + $ agent2linear proj view "My Project Name" # By exact name + $ agent2linear project view proj_abc123 --web # By ID, open in browser + $ agent2linear proj view myalias --web # By alias + $ agent2linear proj view "Project X" --auto-alias # Create alias automatically + $ agent2linear project view PRJ-123 --desc # Show 80-char description preview + $ agent2linear project view PRJ-123 --desc-full # Show full description +`) + .action(async (nameOrId: string, options) => { + await viewProject(nameOrId, options); + }); + + project + .command('update <name-or-id>') + .description('Update project properties') + .option('--status <name-or-id>', 'Project status (name, ID, or alias)') + .option('--name <name>', 'Rename project') + .option('--description <text>', 'Update description') + .option('--content <markdown>', 'Update content as markdown') + .option('--content-file <path>', 'Path to file containing project content (markdown)') + .option('--priority <0-4>', 'Priority level (0-4)', parseInt) + .option('--target-date <date>', 'Target completion date. Formats: YYYY-MM-DD, Quarter (2025-Q1, Q1 2025), Month (2025-01, Jan 2025), Half-year (2025-H1), Year (2025). Resolution auto-detected from format.') + .option('--start-date <date>', 'Estimated start date. Formats: YYYY-MM-DD, Quarter (2025-Q1, Q1 2025), Month (2025-01, Jan 2025), Half-year (2025-H1), Year (2025). Resolution auto-detected from format.') + .option('--color <hex>', 'Project color (hex code like #FF6B6B)') + .option('--icon <icon>', 'Project icon name (passed directly to Linear API)') + .option('--lead <id>', 'Project lead (user ID, alias, or email)') + .option('--members <ids>', 'Comma-separated member IDs, aliases, or emails') + .option('--labels <ids>', 'Comma-separated project label IDs or aliases') + .addOption(new Option('--start-date-resolution <resolution>', 'Override auto-detected resolution (advanced). Can be used alone to update resolution without changing date. Example: --start-date 2025-01-15 --start-date-resolution quarter').choices(['month', 'quarter', 'halfYear', 'year'])) + .addOption(new Option('--target-date-resolution <resolution>', 'Override auto-detected resolution (advanced). Can be used alone to update resolution without changing date. Example: --target-date 2025-01-15 --target-date-resolution quarter').choices(['month', 'quarter', 'halfYear', 'year'])) + .option('--link <url-and-label>', 'Add external link as "URL" or "URL|Label" (repeatable)', (value, previous: string[] = []) => [...previous, value], []) + .option('--remove-link <url>', 'Remove external link by exact URL match (repeatable)', (value, previous: string[] = []) => [...previous, value], []) + .option('--depends-on <projects>', 'Add "depends on" relations (comma-separated IDs/aliases)') + .option('--blocks <projects>', 'Add "blocks" relations (comma-separated IDs/aliases)') + .option('--dependency <spec>', 'Add dependency: "project:myAnchor:theirAnchor" (repeatable)', (value, previous: string[] = []) => [...previous, value], []) + .option('--remove-depends-on <projects>', 'Remove "depends on" relations (comma-separated IDs/aliases)') + .option('--remove-blocks <projects>', 'Remove "blocks" relations (comma-separated IDs/aliases)') + .option('--remove-dependency <project>', 'Remove all dependencies with project (repeatable)', (value, previous: string[] = []) => [...previous, value], []) + .option('-w, --web', 'Open project in browser after update') + .option('--dry-run', 'Preview the payload without updating the project') + .addHelpText('after', ` +Examples: + $ agent2linear project update "My Project" --status "In Progress" + $ agent2linear proj update proj_abc --status done --priority 3 + $ agent2linear proj update myalias --name "New Name" + + Update content from file: + $ agent2linear proj update "My Project" --content-file ./updated-plan.md + + Update with flexible date formats: + $ agent2linear proj update "Q1 Goals" --status in-progress --priority 2 --target-date "2025-Q1" + $ agent2linear proj update "My Project" --start-date "Jan 2025" --target-date "2025-H1" + $ agent2linear proj update "Annual Plan" --start-date "2025" --target-date "2025-12-31" + + Manage external links: + $ agent2linear proj update "My Project" --link "https://github.com/org/repo|GitHub" + $ agent2linear proj update "My Project" --remove-link "https://old-link.com" + $ agent2linear proj update "My Project" --link "https://new.com|New" --remove-link "https://old.com" + + Manage dependencies: + $ agent2linear proj update "My Project" --depends-on "api-backend,infrastructure" + $ agent2linear proj update "My Project" --blocks "frontend-app" + $ agent2linear proj update "My Project" --remove-depends-on "old-dep" + $ agent2linear proj update "My Project" --dependency "backend:end:start" --remove-depends-on "old-project" + + Open in browser after update: + $ agent2linear proj update "My Project" --priority 1 --web +`) + .action(async (nameOrId: string, options) => { + await updateProjectCommand(nameOrId, options); + }); + + project + .command('add-milestones <name-or-id>') + .description('Add milestones to a project using a milestone template') + .option('-t, --template <name>', 'Milestone template name') + .addHelpText('after', ` +Examples: + $ agent2linear project add-milestones PRJ-123 --template basic-sprint + $ agent2linear proj add-milestones "My Project" --template product-launch + $ agent2linear project add-milestones proj_abc123 -t basic-sprint + $ agent2linear project add-milestones myalias # Uses default template from config + +Note: Set default template with: + $ agent2linear config set defaultMilestoneTemplate basic-sprint +`) + .action(async (projectId: string, options) => { + await addMilestones(projectId, options); + }); + + // M23: Project Dependencies subcommands + const projectDeps = project + .command('dependencies') + .alias('deps') + .description('Manage project dependencies (depends-on/blocks relations)') + .action(() => { + projectDeps.help(); + }); + + projectDeps + .command('add <name-or-id>') + .description('Add dependency relations to a project') + .option('--depends-on <projects>', 'Projects this depends on (comma-separated IDs/aliases) - end→start anchor') + .option('--blocks <projects>', 'Projects this blocks (comma-separated IDs/aliases) - start→end anchor') + .option('--dependency <spec>', 'Advanced: "project:myAnchor:theirAnchor" (repeatable)', (value, previous: string[] = []) => [...previous, value], []) + .addHelpText('after', ` +Examples: + Simple mode (default anchors): + $ agent2linear project dependencies add "My Project" --depends-on "backend,database" + $ agent2linear proj deps add PRJ-123 --blocks "frontend,mobile" + + Advanced mode (custom anchors): + $ agent2linear project deps add "API v2" --dependency "backend:end:start" --dependency "db:start:end" + + Mixed mode: + $ agent2linear proj deps add myproject --depends-on "backend" --dependency "db:start:start" + +Note: + - --depends-on: Creates end→start relation (my end waits for their start) + - --blocks: Creates start→end relation (their end waits for my start) + - --dependency: Custom anchors (start|end) + - Supports project IDs, names, and aliases + - Self-referential dependencies are automatically skipped +`) + .action(async (nameOrId: string, options) => { + const { addProjectDependencies } = await import('./dependencies/add.js'); + await addProjectDependencies(nameOrId, options); + }); + + projectDeps + .command('remove <name-or-id>') + .description('Remove dependency relations from a project') + .option('--depends-on <projects>', 'Remove "depends on" relations (comma-separated IDs/aliases)') + .option('--blocks <projects>', 'Remove "blocks" relations (comma-separated IDs/aliases)') + .option('--relation-id <id>', 'Remove by specific relation ID') + .option('--with <project>', 'Remove all relations with specified project') + .addHelpText('after', ` +Examples: + Remove by direction: + $ agent2linear project dependencies remove "My Project" --depends-on "backend" + $ agent2linear proj deps remove PRJ-123 --blocks "frontend,mobile" + + Remove by relation ID: + $ agent2linear proj deps remove "API v2" --relation-id "rel_abc123" + + Remove all relations with a project: + $ agent2linear project deps remove myproject --with "backend" + + Mixed removal: + $ agent2linear proj deps remove PRJ-123 --depends-on "backend" --blocks "frontend" + +Note: + - Provide at least one flag (--depends-on, --blocks, --relation-id, or --with) + - Use "list" command to find relation IDs +`) + .action(async (nameOrId: string, options) => { + const { removeProjectDependencies } = await import('./dependencies/remove.js'); + await removeProjectDependencies(nameOrId, options); + }); + + projectDeps + .command('list <name-or-id>') + .alias('ls') + .description('List all dependency relations for a project') + .option('--direction <type>', 'Filter by direction: depends-on | blocks') + .addHelpText('after', ` +Examples: + List all dependencies: + $ agent2linear project dependencies list "My Project" + $ agent2linear proj deps ls PRJ-123 + + Filter by direction: + $ agent2linear proj deps list "API v2" --direction depends-on + $ agent2linear project deps ls myproject --direction blocks + +Output: + Shows both "depends-on" and "blocks" relations with: + - Related project names and IDs + - Anchor types (start/end) + - Semantic descriptions + - Relation IDs (for removal) +`) + .action(async (nameOrId: string, options) => { + const { listProjectDependencies } = await import('./dependencies/list.js'); + await listProjectDependencies(nameOrId, options); + }); + + projectDeps + .command('clear <name-or-id>') + .description('Remove all dependency relations from a project') + .option('--direction <type>', 'Clear only specified direction: depends-on | blocks') + .option('-y, --yes', 'Skip confirmation prompt') + .addHelpText('after', ` +Examples: + Clear all dependencies (with confirmation): + $ agent2linear project dependencies clear "My Project" + $ agent2linear proj deps clear PRJ-123 + + Clear specific direction: + $ agent2linear proj deps clear "API v2" --direction depends-on + $ agent2linear project deps clear myproject --direction blocks + + Skip confirmation: + $ agent2linear proj deps clear PRJ-123 --yes + $ agent2linear project deps clear myproject --direction depends-on -y + +Warning: + This permanently deletes dependency relations. Use with caution. + Confirmation prompt shown unless --yes flag is provided. +`) + .action(async (nameOrId: string, options) => { + const { clearProjectDependencies } = await import('./dependencies/clear.js'); + await clearProjectDependencies(nameOrId, options); + }); + + // Register project list command (M20) + listProjectsCommand(project); +} diff --git a/src/commands/project/update.ts b/src/commands/project/update.ts index 1961815..ecc9f6e 100644 --- a/src/commands/project/update.ts +++ b/src/commands/project/update.ts @@ -38,6 +38,8 @@ interface UpdateOptions { removeDependsOn?: string; // Remove depends-on relations removeBlocks?: string; // Remove blocks relations removeDependency?: string[]; // Remove all dependencies with project + // Dry-run mode + dryRun?: boolean; } // validateDateFormat removed in M22 Phase 5 - replaced with parseDateForCommand() @@ -353,6 +355,13 @@ export async function updateProjectCommand(nameOrId: string, options: UpdateOpti console.log(`ℹ️ Updating resolution without changing date (resolution-only update)`); } + // Dry-run mode: print payload and exit without updating + if (options.dryRun) { + console.error('\n[dry-run] Would update project with:'); + console.log(JSON.stringify({ projectId, ...updates }, null, 2)); + return; + } + // Update project console.log(`\n📝 Updating project...`); for (const change of changes) { diff --git a/src/commands/setup.tsx b/src/commands/setup.tsx index 1e400f3..9b27f89 100644 --- a/src/commands/setup.tsx +++ b/src/commands/setup.tsx @@ -18,7 +18,7 @@ import { type Team, type Initiative, } from '../lib/linear-client.js'; -import { WalkthroughScreen } from '../components/setup/WalkthroughScreen.js'; +import { WalkthroughScreen } from '../ui/components/WalkthroughScreen.js'; import { syncWorkflowStateAliasesCore } from './workflow-states/sync-aliases.js'; import { syncProjectStatusAliases } from './project-status/sync-aliases.js'; import { syncMemberAliasesCore } from './members/sync-aliases.js'; diff --git a/src/commands/teams/register.ts b/src/commands/teams/register.ts new file mode 100644 index 0000000..47cb6b9 --- /dev/null +++ b/src/commands/teams/register.ts @@ -0,0 +1,83 @@ +import { Command } from 'commander'; +import { listTeams } from './list.js'; +import { selectTeam } from './select.js'; +import { setTeam } from './set.js'; +import { viewTeam } from './view.js'; +import { syncTeamAliases } from './sync-aliases.js'; + +export function registerTeamsCommands(cli: Command): void { + const teams = cli + .command('teams') + .alias('team') + .description('Manage Linear teams') + .action(() => { + teams.help(); + }); + + teams + .command('list') + .alias('ls') + .description('List all teams') + .option('-I, --interactive', 'Use interactive mode for browsing') + .option('-w, --web', 'Open Linear in browser to view teams') + .option('-f, --format <type>', 'Output format: tsv, json') + .addHelpText('after', ` +Examples: + $ agent2linear teams list # Print list to stdout (formatted) + $ agent2linear team ls # Same as 'list' (alias) + $ agent2linear teams list --interactive # Browse interactively + $ agent2linear teams list --web # Open in browser + $ agent2linear teams list --format json # Output as JSON + $ agent2linear teams list --format tsv # Output as TSV + $ agent2linear team list -f tsv | cut -f1 # Get just team IDs +`) + .action(async (options) => { + await listTeams(options); + }); + + teams + .command('select') + .description('Interactively select a default team') + .option('-g, --global', 'Save to global config (default)') + .option('-p, --project', 'Save to project config') + .addHelpText('after', ` +Examples: + $ agent2linear teams select # Interactive selection + $ agent2linear teams select --project # Save to project config +`) + .action(async (options) => { + await selectTeam(options); + }); + + teams + .command('set <id>') + .description('Set default team by ID (non-interactive)') + .option('-g, --global', 'Save to global config (default)') + .option('-p, --project', 'Save to project config') + .addHelpText('after', ` +Examples: + $ agent2linear teams set team_abc123 + $ agent2linear teams set eng # Using alias + $ agent2linear teams set team_abc123 --project +`) + .action(async (id: string, options) => { + await setTeam(id, options); + }); + + teams + .command('view <id>') + .description('View details of a specific team') + .option('-w, --web', 'Open team in browser instead of displaying in terminal') + .addHelpText('after', ` +Examples: + $ agent2linear teams view team_abc123 + $ agent2linear team view team_abc123 + $ agent2linear teams view team_abc123 --web + $ agent2linear team view eng --web +`) + .action(async (id: string, options) => { + await viewTeam(id, options); + }); + + syncTeamAliases(teams); +} diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts new file mode 100644 index 0000000..1f049e0 --- /dev/null +++ b/src/commands/whoami.ts @@ -0,0 +1,31 @@ +import { testConnection, getCurrentUser, getOrganization } from '../lib/linear-client.js'; +import { getApiKey, maskApiKey } from '../lib/config.js'; +import { showError } from '../lib/output.js'; + +/** + * Display authenticated user identity and organization info + */ +export async function whoamiCommand() { + try { + const result = await testConnection(); + if (!result.success) { + showError('Not authenticated', result.error); + process.exit(1); + } + + const user = await getCurrentUser(); + const org = await getOrganization(); + const apiKey = getApiKey() || ''; + const masked = maskApiKey(apiKey); + + console.log(`\nUser: ${user.name}`); + console.log(`Email: ${user.email}`); + console.log(`Organization: ${org.name}`); + console.log(`Workspace: ${org.urlKey}`); + console.log(`API Key: ${masked}`); + console.log(); + } catch (error) { + showError(error instanceof Error ? error.message : 'Unknown error'); + process.exit(1); + } +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts new file mode 100644 index 0000000..2dd9b2e --- /dev/null +++ b/src/lib/api/client.ts @@ -0,0 +1,133 @@ +import { LinearClient as SDKClient } from '@linear/sdk'; +import { getApiKey } from '../config.js'; + +export class LinearClientError extends Error { + constructor(message: string) { + super(message); + this.name = 'LinearClientError'; + } +} + +/** + * Cached singleton Linear client instance + */ +let cachedClient: SDKClient | null = null; + +/** + * Get authenticated Linear client (singleton - reuses same instance) + */ +export function getLinearClient(): SDKClient { + if (cachedClient) return cachedClient; + + const apiKey = getApiKey(); + + if (!apiKey) { + throw new LinearClientError( + 'Linear API key not found. Please set LINEAR_API_KEY environment variable or configure it using the config file.' + ); + } + + // Validate API key format (Linear API keys start with "lin_api_") + if (!apiKey.startsWith('lin_api_')) { + throw new LinearClientError( + 'Invalid Linear API key format. API keys should start with "lin_api_"' + ); + } + + cachedClient = new SDKClient({ apiKey }); + return cachedClient; +} + +/** + * Get the current user's organization + */ +export async function getOrganization(): Promise<{ + id: string; + name: string; + urlKey: string; +}> { + try { + const client = getLinearClient(); + const org = await client.organization; + return { + id: org.id, + name: org.name, + urlKey: org.urlKey, + }; + } catch (error) { + throw new LinearClientError( + `Failed to get organization: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Test the Linear API connection + */ +export async function testConnection(): Promise<{ + success: boolean; + error?: string; + user?: { name: string; email: string }; +}> { + try { + const client = getLinearClient(); + const viewer = await client.viewer; + + return { + success: true, + user: { + name: viewer.name, + email: viewer.email, + }, + }; + } catch (error) { + if (error instanceof LinearClientError) { + return { + success: false, + error: error.message, + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } +} + +/** + * Get current user information + */ +export async function getCurrentUser(): Promise<{ + id: string; + name: string; + email: string; +}> { + try { + const client = getLinearClient(); + const viewer = await client.viewer; + + return { + id: viewer.id, + name: viewer.name, + email: viewer.email, + }; + } catch (error) { + throw new LinearClientError( + `Failed to get current user: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Validate API key by testing connection + */ +export async function validateApiKey(apiKey: string): Promise<boolean> { + try { + const client = new SDKClient({ apiKey }); + await client.viewer; + return true; + } catch { + return false; + } +} diff --git a/src/lib/api/initiatives.ts b/src/lib/api/initiatives.ts new file mode 100644 index 0000000..eef25f7 --- /dev/null +++ b/src/lib/api/initiatives.ts @@ -0,0 +1,110 @@ +import { getLinearClient, LinearClientError } from './client.js'; + +/** + * Initiative data structure + */ +export interface Initiative { + id: string; + name: string; + description?: string; + status?: string; +} + +/** + * Validate initiative ID exists and return its details + */ +export async function validateInitiativeExists( + initiativeId: string +): Promise<{ valid: boolean; name?: string; error?: string }> { + try { + // Use entity cache instead of direct API call + const { getEntityCache } = await import('../entity-cache.js'); + const cache = getEntityCache(); + const initiative = await cache.findInitiativeById(initiativeId); + + if (!initiative) { + return { + valid: false, + error: `Initiative with ID "${initiativeId}" not found`, + }; + } + + return { + valid: true, + name: initiative.name, + }; + } catch (error) { + if (error instanceof LinearClientError) { + return { + valid: false, + error: error.message, + }; + } + + return { + valid: false, + error: error instanceof Error ? error.message : 'Failed to validate initiative', + }; + } +} + +/** + * Get all initiatives from Linear + */ +export async function getAllInitiatives(): Promise<Initiative[]> { + try { + const client = getLinearClient(); + const initiatives = await client.initiatives(); + + const result: Initiative[] = []; + for await (const initiative of initiatives.nodes) { + result.push({ + id: initiative.id, + name: initiative.name, + description: initiative.description, + }); + } + + // Sort by name + return result.sort((a, b) => a.name.localeCompare(b.name)); + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch initiatives: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get a single initiative by ID + */ +export async function getInitiativeById( + initiativeId: string +): Promise<{ id: string; name: string; description?: string; url: string } | null> { + try { + const client = getLinearClient(); + const initiative = await client.initiative(initiativeId); + + if (!initiative) { + return null; + } + + return { + id: initiative.id, + name: initiative.name, + description: initiative.description || undefined, + url: initiative.url, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch initiative: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} diff --git a/src/lib/api/teams.ts b/src/lib/api/teams.ts new file mode 100644 index 0000000..d2f34b3 --- /dev/null +++ b/src/lib/api/teams.ts @@ -0,0 +1,112 @@ +import { getLinearClient, LinearClientError } from './client.js'; + +/** + * Team data structure + */ +export interface Team { + id: string; + name: string; + description?: string; + key: string; +} + +/** + * Validate team ID exists and return its details + */ +export async function validateTeamExists( + teamId: string +): Promise<{ valid: boolean; name?: string; error?: string }> { + try { + // Use entity cache instead of direct API call + const { getEntityCache } = await import('../entity-cache.js'); + const cache = getEntityCache(); + const team = await cache.findTeamById(teamId); + + if (!team) { + return { + valid: false, + error: `Team with ID "${teamId}" not found`, + }; + } + + return { + valid: true, + name: team.name, + }; + } catch (error) { + if (error instanceof LinearClientError) { + return { + valid: false, + error: error.message, + }; + } + + return { + valid: false, + error: error instanceof Error ? error.message : 'Failed to validate team', + }; + } +} + +/** + * Get all teams from Linear + */ +export async function getAllTeams(): Promise<Team[]> { + try { + const client = getLinearClient(); + const teams = await client.teams(); + + const result: Team[] = []; + for await (const team of teams.nodes) { + result.push({ + id: team.id, + name: team.name, + description: team.description || undefined, + key: team.key, + }); + } + + // Sort by name + return result.sort((a, b) => a.name.localeCompare(b.name)); + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch teams: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get a single team by ID + */ +export async function getTeamById( + teamId: string +): Promise<{ id: string; name: string; key: string; description?: string; url: string } | null> { + try { + const client = getLinearClient(); + const team = await client.team(teamId); + + if (!team) { + return null; + } + + return { + id: team.id, + name: team.name, + key: team.key, + description: team.description || undefined, + url: `https://linear.app/team/${team.key.toLowerCase()}`, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch team: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} diff --git a/src/lib/command-runner.ts b/src/lib/command-runner.ts new file mode 100644 index 0000000..e52df77 --- /dev/null +++ b/src/lib/command-runner.ts @@ -0,0 +1,88 @@ +/** + * Command Runner - Shared middleware for command execution (C4) + * + * Provides standardized error handling, alias resolution, and entity validation + * for CLI commands. New commands should use this to avoid boilerplate. + */ + +import { resolveAlias } from './aliases.js'; +import { handleLinearError, isLinearError } from './error-handler.js'; +import { showError } from './output.js'; +import type { AliasEntityType } from './types.js'; + +/** + * Context passed to command execute functions with pre-resolved entities + */ +export interface CommandContext { + /** Resolved entity IDs after alias resolution */ + resolved: Record<string, string>; +} + +/** + * Alias resolution specification + */ +interface AliasSpec { + type: AliasEntityType; + value?: string; // If undefined, skips resolution +} + +/** + * Options for runCommand + */ +interface RunCommandOptions { + /** + * Aliases to resolve before execution. + * Keys become property names in `ctx.resolved`. + */ + resolveAliases?: Record<string, AliasSpec>; + + /** + * The command logic to execute with resolved context + */ + execute: (ctx: CommandContext) => Promise<void>; +} + +/** + * Run a command with standardized error handling and alias resolution. + * + * Usage: + * ```typescript + * await runCommand({ + * resolveAliases: { + * teamId: { type: 'team', value: options.team }, + * initiativeId: { type: 'initiative', value: options.initiative }, + * }, + * execute: async (ctx) => { + * // ctx.resolved.teamId is the resolved team ID + * const team = await validateTeamExists(ctx.resolved.teamId); + * // ... + * }, + * }); + * ``` + */ +export async function runCommand(options: RunCommandOptions): Promise<void> { + try { + // Resolve aliases + const resolved: Record<string, string> = {}; + + if (options.resolveAliases) { + for (const [key, spec] of Object.entries(options.resolveAliases)) { + if (spec.value) { + resolved[key] = resolveAlias(spec.type, spec.value); + } + } + } + + // Execute command with resolved context + await options.execute({ resolved }); + } catch (error) { + // Standardized error handling + if (isLinearError(error)) { + const message = handleLinearError(error); + console.error(message); + } else { + showError(error instanceof Error ? error.message : 'Unknown error'); + } + process.exit(1); + } +} diff --git a/src/lib/entity-cache.ts b/src/lib/entity-cache.ts index 85febc9..86578b5 100644 --- a/src/lib/entity-cache.ts +++ b/src/lib/entity-cache.ts @@ -377,6 +377,61 @@ export class EntityCache { return templates; } + /** + * Generic cache fetch helper - reduces duplication for new entity types. + * + * Checks session cache → persistent cache → API fetch. + * New entity types should use this instead of duplicating the pattern. + */ + async fetchWithCache<T>( + sessionCache: CachedEntity<T> | undefined, + setSessionCache: (entry: CachedEntity<T>) => void, + fetcher: () => Promise<T[]>, + persistentGet?: () => Promise<Array<T & { timestamp: number }> | null>, + persistentSave?: (entries: Array<T & { timestamp: number }>) => void + ): Promise<T[]> { + const config = getConfig(); + + // Check session cache + if (this.isCacheEnabled() && this.isValid(sessionCache)) { + return sessionCache!.data; + } + + // Check persistent cache + if (persistentGet && config.enablePersistentCache !== false) { + try { + const persistent = await persistentGet(); + if (persistent && persistent.length > 0) { + const items = persistent.map(({ timestamp: _ts, ...item }) => item as T); + if (this.isCacheEnabled()) { + setSessionCache({ data: items, timestamp: persistent[0].timestamp }); + } + return items; + } + } catch { + // Fall through to API fetch + } + } + + // Fetch from API + const items = await fetcher(); + + if (this.isCacheEnabled()) { + const timestamp = Date.now(); + setSessionCache({ data: items, timestamp }); + + if (persistentSave && config.enablePersistentCache !== false) { + try { + persistentSave(items.map(item => ({ ...item, timestamp }))); + } catch { + // Ignore save errors + } + } + } + + return items; + } + /** * Find team by ID * diff --git a/src/lib/error-handler.test.ts b/src/lib/error-handler.test.ts new file mode 100644 index 0000000..1c4e20b --- /dev/null +++ b/src/lib/error-handler.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import { handleLinearError, isLinearError, formatLinearErrorForLogging } from './error-handler.js'; + +describe('handleLinearError', () => { + it('handles 401 authentication errors', () => { + const error = { status: 401 }; + const message = handleLinearError(error); + expect(message).toContain('Authentication failed'); + expect(message).toContain('linear.app/settings/api'); + }); + + it('handles 403 permission errors', () => { + const error = { status: 403 }; + const message = handleLinearError(error, 'project'); + expect(message).toContain('Permission denied'); + expect(message).toContain('project'); + }); + + it('handles 404 not found errors', () => { + const error = { status: 404 }; + const message = handleLinearError(error, 'issue'); + expect(message).toContain('not found'); + expect(message).toContain('issue'); + }); + + it('handles 429 rate limiting', () => { + const error = { status: 429, response: { headers: { 'retry-after': '30' } } }; + const message = handleLinearError(error); + expect(message).toContain('Rate limited'); + expect(message).toContain('30'); + }); + + it('handles 429 without retry-after header', () => { + const error = { status: 429 }; + const message = handleLinearError(error); + expect(message).toContain('Rate limited'); + expect(message).toContain('60'); // default + }); + + it('extracts GraphQL extension codes', () => { + const error = { extensions: { code: 'UNAUTHENTICATED' } }; + const message = handleLinearError(error); + expect(message).toContain('Authentication failed'); + }); + + it('extracts validation messages from error.message', () => { + const error = { message: 'Title is required' }; + const message = handleLinearError(error); + expect(message).toContain('Title is required'); + }); + + it('extracts nested GraphQL error messages', () => { + const error = { graphQLErrors: [{ message: 'Invalid input' }] }; + const message = handleLinearError(error); + expect(message).toContain('Invalid input'); + }); + + it('provides generic message for unknown errors', () => { + const error = {}; + const message = handleLinearError(error); + expect(message).toContain('unexpected error'); + }); + + it('extracts status from response.status', () => { + const error = { response: { status: 401 } }; + const message = handleLinearError(error); + expect(message).toContain('Authentication failed'); + }); + + it('extracts status from statusCode', () => { + const error = { statusCode: 404 }; + const message = handleLinearError(error); + expect(message).toContain('not found'); + }); +}); + +describe('isLinearError', () => { + it('identifies errors with status codes', () => { + expect(isLinearError({ status: 401 })).toBe(true); + }); + + it('identifies errors with validation messages', () => { + expect(isLinearError({ message: 'some error' })).toBe(true); + }); + + it('identifies LinearClientError by name', () => { + expect(isLinearError({ name: 'LinearClientError' })).toBe(true); + }); + + it('rejects plain objects without error markers', () => { + expect(isLinearError({ foo: 'bar' })).toBe(false); + }); +}); + +describe('formatLinearErrorForLogging', () => { + it('includes status code when available', () => { + const result = formatLinearErrorForLogging({ status: 401 }); + expect(result).toContain('Status: 401'); + }); + + it('includes message when available', () => { + const result = formatLinearErrorForLogging({ message: 'test error' }); + expect(result).toContain('Message: test error'); + }); + + it('includes stack when available', () => { + const error = new Error('test'); + const result = formatLinearErrorForLogging(error); + expect(result).toContain('Stack:'); + }); +}); diff --git a/src/lib/linear-client.ts b/src/lib/linear-client.ts index 2734739..28c7ef9 100644 --- a/src/lib/linear-client.ts +++ b/src/lib/linear-client.ts @@ -21,9 +21,16 @@ export class LinearClientError extends Error { } /** - * Get authenticated Linear client + * Cached singleton Linear client instance + */ +let cachedClient: SDKClient | null = null; + +/** + * Get authenticated Linear client (singleton - reuses same instance) */ export function getLinearClient(): SDKClient { + if (cachedClient) return cachedClient; + const apiKey = getApiKey(); if (!apiKey) { @@ -39,7 +46,98 @@ export function getLinearClient(): SDKClient { ); } - return new SDKClient({ apiKey }); + cachedClient = new SDKClient({ apiKey }); + return cachedClient; +} + +/** + * Get the current user's organization + */ +export async function getOrganization(): Promise<{ + id: string; + name: string; + urlKey: string; +}> { + try { + const client = getLinearClient(); + const org = await client.organization; + return { + id: org.id, + name: org.name, + urlKey: org.urlKey, + }; + } catch (error) { + throw new LinearClientError( + `Failed to get organization: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Create a comment on an issue + */ +export async function createIssueComment( + issueId: string, + body: string +): Promise<{ id: string; body: string }> { + try { + const client = getLinearClient(); + const result = await client.createComment({ issueId, body }); + const comment = await result.comment; + if (!comment) { + throw new Error('Comment creation returned no comment'); + } + return { id: comment.id, body: comment.body }; + } catch (error) { + if (error instanceof LinearClientError) throw error; + throw new LinearClientError( + `Failed to create comment: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get all cycles, optionally filtered by team + */ +export async function getAllCycles(teamId?: string): Promise<Array<{ + id: string; + name: string; + number: number; + startsAt?: string; + endsAt?: string; + teamId?: string; + teamName?: string; +}>> { + try { + const client = getLinearClient(); + let cycles; + if (teamId) { + const team = await client.team(teamId); + cycles = await team.cycles(); + } else { + cycles = await client.cycles(); + } + + const results = []; + for (const cycle of cycles.nodes) { + const team = await cycle.team; + results.push({ + id: cycle.id, + name: cycle.name || `Cycle ${cycle.number}`, + number: cycle.number, + startsAt: cycle.startsAt instanceof Date ? cycle.startsAt.toISOString().split('T')[0] : undefined, + endsAt: cycle.endsAt instanceof Date ? cycle.endsAt.toISOString().split('T')[0] : undefined, + teamId: team?.id, + teamName: team?.name, + }); + } + return results; + } catch (error) { + if (error instanceof LinearClientError) throw error; + throw new LinearClientError( + `Failed to fetch cycles: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } } /** @@ -1007,6 +1105,19 @@ export async function getAllIssues(filters?: IssueListFilters): Promise<IssueLis graphqlFilter.searchableContent = { contains: filters.search }; } + // Date range filters + if (filters?.createdAfter || filters?.createdBefore) { + graphqlFilter.createdAt = {}; + if (filters.createdAfter) graphqlFilter.createdAt.gte = new Date(filters.createdAfter).toISOString(); + if (filters.createdBefore) graphqlFilter.createdAt.lte = new Date(filters.createdBefore).toISOString(); + } + + if (filters?.updatedAfter || filters?.updatedBefore) { + graphqlFilter.updatedAt = {}; + if (filters.updatedAfter) graphqlFilter.updatedAt.gte = new Date(filters.updatedAfter).toISOString(); + if (filters.updatedBefore) graphqlFilter.updatedAt.lte = new Date(filters.updatedBefore).toISOString(); + } + // Handle status filters if (filters?.includeCompleted === false) { graphqlFilter.completedAt = { null: true }; diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..c497744 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,58 @@ +/** + * Structured logging for agent2linear CLI + * + * All output goes to stderr (aligns with M26 stdout/stderr separation). + * Levels: debug (verbose only), info (normal+), warn (always), error (always). + */ + +type LogLevel = 'quiet' | 'normal' | 'verbose'; + +let currentLevel: LogLevel = 'normal'; + +/** + * Set the global log level. Called by CLI based on --quiet/--verbose flags. + */ +export function setLogLevel(level: LogLevel): void { + currentLevel = level; +} + +/** + * Get the current log level. + */ +export function getLogLevel(): LogLevel { + return currentLevel; +} + +export const logger = { + /** + * Debug output - only shown with --verbose + */ + debug(message: string, ...args: unknown[]): void { + if (currentLevel === 'verbose') { + console.error(`[debug] ${message}`, ...args); + } + }, + + /** + * Info output - shown in normal and verbose modes, suppressed with --quiet + */ + info(message: string, ...args: unknown[]): void { + if (currentLevel !== 'quiet') { + console.error(message, ...args); + } + }, + + /** + * Warning output - always shown + */ + warn(message: string, ...args: unknown[]): void { + console.error(`⚠️ ${message}`, ...args); + }, + + /** + * Error output - always shown + */ + error(message: string, ...args: unknown[]): void { + console.error(`❌ ${message}`, ...args); + }, +}; diff --git a/src/lib/output.ts b/src/lib/output.ts index c306bed..e125d33 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -6,6 +6,47 @@ * and reduce code duplication. */ +/** + * No-color mode flag. When true, emojis are stripped from output messages. + */ +let noColor = false; + +/** + * Enable or disable no-color mode. + * When enabled, emoji prefixes are stripped from all show* output functions. + */ +export function setNoColor(enabled: boolean): void { + noColor = enabled; +} + +/** + * Strip leading emoji from a message if no-color mode is active. + */ +function stripEmoji(message: string): string { + if (!noColor) return message; + // Strip common emoji prefixes used in output + // eslint-disable-next-line no-misleading-character-class + return message.replace(/^[📎🔍✅❌💡⚠️📋📄🔄]\s*/u, '').replace(/^ {3}[✓✗] /u, ' '); +} + +/** + * Filter list items to only include specified columns + */ +export function filterColumns<T extends Record<string, unknown>>( + items: T[], + columns: string[] +): Array<Record<string, unknown>> { + return items.map(item => { + const filtered: Record<string, unknown> = {}; + for (const col of columns) { + if (col in item) { + filtered[col] = item[col]; + } + } + return filtered; + }); +} + /** * Capitalize the first letter of a string */ @@ -23,7 +64,7 @@ function capitalize(str: string): string { * // Output: 📎 Resolved alias "backend" to init_abc123 */ export function showResolvedAlias(alias: string, id: string): void { - console.log(`📎 Resolved alias "${alias}" to ${id}`); + console.log(stripEmoji(`📎 Resolved alias "${alias}" to ${id}`)); } /** @@ -36,7 +77,7 @@ export function showResolvedAlias(alias: string, id: string): void { * // Output: 🔍 Validating team ID: team_abc123... */ export function showValidating(entityType: string, id: string): void { - console.log(`🔍 Validating ${entityType} ID: ${id}...`); + console.log(stripEmoji(`🔍 Validating ${entityType} ID: ${id}...`)); } /** @@ -70,7 +111,7 @@ export function showValidated(entityType: string, name: string): void { * // ID: team_abc123 */ export function showSuccess(message: string, details?: Record<string, string>): void { - console.log(`\n✅ ${message}`); + console.log(stripEmoji(`\n✅ ${message}`)); if (details) { for (const [key, value] of Object.entries(details)) { console.log(` ${key}: ${value}`); @@ -91,7 +132,7 @@ export function showSuccess(message: string, details?: Record<string, string>): * // Use "agent2linear teams list" to see available teams */ export function showError(message: string, hint?: string): void { - console.error(`❌ ${message}`); + console.error(stripEmoji(`❌ ${message}`)); if (hint) { console.error(` ${hint}`); } @@ -107,7 +148,7 @@ export function showError(message: string, hint?: string): void { * // 💡 Use "agent2linear config show" to view your configuration */ export function showInfo(message: string): void { - console.log(`\n💡 ${message}\n`); + console.log(stripEmoji(`\n💡 ${message}\n`)); } /** @@ -119,7 +160,7 @@ export function showInfo(message: string): void { * // Output: ⚠️ This command is deprecated */ export function showWarning(message: string): void { - console.log(`⚠️ ${message}`); + console.log(stripEmoji(`⚠️ ${message}`)); } /** @@ -141,7 +182,7 @@ export function showEntityDetails( entity: Record<string, unknown>, fields: string[] ): void { - console.log(`📋 ${type}: ${entity.name || entity.title || entity.id}`); + console.log(stripEmoji(`📋 ${type}: ${entity.name || entity.title || entity.id}`)); for (const field of fields) { if (entity[field] !== undefined && entity[field] !== null) { const label = capitalize(field); diff --git a/src/lib/parsers.test.ts b/src/lib/parsers.test.ts new file mode 100644 index 0000000..dd30246 --- /dev/null +++ b/src/lib/parsers.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from 'vitest'; +import { + parseCommaSeparated, + parsePipeDelimited, + parseLifecycleDate, + parsePipeDelimitedArray, + parseCommaSeparatedUnique, + validateAnchorType, + parseAdvancedDependency, +} from './parsers.js'; + +describe('parseCommaSeparated', () => { + it('splits basic comma-separated values', () => { + expect(parseCommaSeparated('a,b,c')).toEqual(['a', 'b', 'c']); + }); + + it('trims whitespace around values', () => { + expect(parseCommaSeparated('a, b , c')).toEqual(['a', 'b', 'c']); + }); + + it('filters out empty strings from double commas', () => { + expect(parseCommaSeparated('a,,c')).toEqual(['a', 'c']); + }); + + it('handles trailing comma', () => { + expect(parseCommaSeparated('a,')).toEqual(['a']); + }); + + it('returns empty array for empty string', () => { + expect(parseCommaSeparated('')).toEqual([]); + }); + + it('handles single value', () => { + expect(parseCommaSeparated('hello')).toEqual(['hello']); + }); + + it('handles values with special characters', () => { + expect(parseCommaSeparated('user_1,john@example.com,alias')).toEqual([ + 'user_1', + 'john@example.com', + 'alias', + ]); + }); +}); + +describe('parsePipeDelimited', () => { + it('splits on first pipe character', () => { + expect(parsePipeDelimited('https://example.com|Example Site')).toEqual({ + key: 'https://example.com', + value: 'Example Site', + }); + }); + + it('returns whole string as key when no pipe', () => { + expect(parsePipeDelimited('https://example.com')).toEqual({ + key: 'https://example.com', + value: '', + }); + }); + + it('only splits on first pipe', () => { + expect(parsePipeDelimited('key|value|extra')).toEqual({ + key: 'key', + value: 'value|extra', + }); + }); + + it('trims whitespace', () => { + expect(parsePipeDelimited(' url | label ')).toEqual({ + key: 'url', + value: 'label', + }); + }); +}); + +describe('parseLifecycleDate', () => { + it('handles "now" keyword', () => { + const result = parseLifecycleDate('now'); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('handles "NOW" (case insensitive)', () => { + const result = parseLifecycleDate('NOW'); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('parses valid ISO date', () => { + const result = parseLifecycleDate('2025-01-15'); + expect(result).toBe('2025-01-15T00:00:00.000Z'); + }); + + it('throws for invalid date format', () => { + expect(() => parseLifecycleDate('01/15/2025')).toThrow('Invalid date format'); + }); + + it('throws for non-date string', () => { + expect(() => parseLifecycleDate('not-a-date')).toThrow('Invalid date format'); + }); + + it('trims whitespace for "now"', () => { + const result = parseLifecycleDate(' now '); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); +}); + +describe('parsePipeDelimitedArray', () => { + it('parses array of pipe-delimited values', () => { + const result = parsePipeDelimitedArray([ + 'https://github.com/repo|GitHub', + 'https://docs.site.com', + ]); + expect(result).toEqual([ + { key: 'https://github.com/repo', value: 'GitHub' }, + { key: 'https://docs.site.com', value: '' }, + ]); + }); + + it('returns empty array for empty input', () => { + expect(parsePipeDelimitedArray([])).toEqual([]); + }); +}); + +describe('parseCommaSeparatedUnique', () => { + it('deduplicates values', () => { + expect(parseCommaSeparatedUnique('a,b,a,c')).toEqual(['a', 'b', 'c']); + }); + + it('is case-sensitive', () => { + expect(parseCommaSeparatedUnique('a,A,a')).toEqual(['a', 'A']); + }); +}); + +describe('validateAnchorType', () => { + it('accepts "start"', () => { + expect(validateAnchorType('start')).toBe('start'); + }); + + it('accepts "end"', () => { + expect(validateAnchorType('end')).toBe('end'); + }); + + it('is case insensitive', () => { + expect(validateAnchorType('START')).toBe('start'); + expect(validateAnchorType('End')).toBe('end'); + }); + + it('throws for invalid values', () => { + expect(() => validateAnchorType('middle')).toThrow('Invalid anchor type'); + }); + + it('trims whitespace', () => { + expect(validateAnchorType(' start ')).toBe('start'); + }); +}); diff --git a/src/lib/types.ts b/src/lib/types.ts index 765a10c..cf50b60 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -387,6 +387,12 @@ export interface IssueListFilters { // Search search?: string; // Full-text search in title and description + // Date range filters + createdAfter?: string; // ISO date string + createdBefore?: string; // ISO date string + updatedAfter?: string; // ISO date string + updatedBefore?: string; // ISO date string + // Pagination limit?: number; // Max results (default: 50, max: 250) fetchAll?: boolean; // Fetch all pages with cursor pagination diff --git a/src/lib/validators.test.ts b/src/lib/validators.test.ts new file mode 100644 index 0000000..bf08b3f --- /dev/null +++ b/src/lib/validators.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from 'vitest'; +import { + validatePriority, + validateAndNormalizeColor, + validateISODate, + validateEnumValue, + validateNonEmpty, + formatEntityNotFoundError, +} from './validators.js'; + +describe('validatePriority', () => { + it('accepts valid numeric priorities 0-4', () => { + for (let i = 0; i <= 4; i++) { + const result = validatePriority(i); + expect(result.valid).toBe(true); + expect(result.value).toBe(i); + } + }); + + it('accepts string priorities', () => { + const result = validatePriority('2'); + expect(result.valid).toBe(true); + expect(result.value).toBe(2); + }); + + it('rejects priority below 0', () => { + const result = validatePriority(-1); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid priority'); + }); + + it('rejects priority above 4', () => { + const result = validatePriority(5); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid priority'); + }); + + it('rejects non-numeric strings', () => { + const result = validatePriority('abc'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid priority'); + }); +}); + +describe('validateAndNormalizeColor', () => { + it('accepts valid hex color with #', () => { + const result = validateAndNormalizeColor('#FF6B6B'); + expect(result.valid).toBe(true); + expect(result.value).toBe('#FF6B6B'); + }); + + it('normalizes color without # prefix', () => { + const result = validateAndNormalizeColor('FF6B6B'); + expect(result.valid).toBe(true); + expect(result.value).toBe('#FF6B6B'); + }); + + it('normalizes to uppercase', () => { + const result = validateAndNormalizeColor('ff6b6b'); + expect(result.valid).toBe(true); + expect(result.value).toBe('#FF6B6B'); + }); + + it('rejects invalid hex characters', () => { + const result = validateAndNormalizeColor('ZZZZZZ'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid color format'); + }); + + it('rejects short hex codes', () => { + const result = validateAndNormalizeColor('#FFF'); + expect(result.valid).toBe(false); + }); + + it('rejects too-long hex codes', () => { + const result = validateAndNormalizeColor('#FF6B6B00'); + expect(result.valid).toBe(false); + }); +}); + +describe('validateISODate', () => { + it('accepts valid ISO date', () => { + const result = validateISODate('2025-01-15'); + expect(result.valid).toBe(true); + expect(result.value).toBe('2025-01-15'); + }); + + it('rejects US date format', () => { + const result = validateISODate('01/15/2025'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid date format'); + }); + + it('rejects invalid calendar date', () => { + const result = validateISODate('2025-02-30'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid date'); + }); + + it('rejects month 13', () => { + const result = validateISODate('2025-13-01'); + expect(result.valid).toBe(false); + }); + + it('accepts leap year date', () => { + const result = validateISODate('2024-02-29'); + expect(result.valid).toBe(true); + }); + + it('rejects non-leap year Feb 29', () => { + const result = validateISODate('2025-02-29'); + expect(result.valid).toBe(false); + }); +}); + +describe('validateEnumValue', () => { + const allowed = ['month', 'quarter', 'halfYear', 'year']; + + it('accepts valid enum value', () => { + const result = validateEnumValue('quarter', allowed, 'resolution'); + expect(result.valid).toBe(true); + expect(result.value).toBe('quarter'); + }); + + it('rejects invalid enum value', () => { + const result = validateEnumValue('week', allowed, 'resolution'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid resolution'); + expect(result.error).toContain('month, quarter, halfYear, year'); + }); + + it('uses generic name when fieldName not provided', () => { + const result = validateEnumValue('invalid', allowed); + expect(result.error).toContain('Invalid value'); + }); +}); + +describe('validateNonEmpty', () => { + it('accepts non-empty string', () => { + const result = validateNonEmpty('hello', 'name'); + expect(result.valid).toBe(true); + expect(result.value).toBe('hello'); + }); + + it('trims whitespace', () => { + const result = validateNonEmpty(' hello ', 'name'); + expect(result.valid).toBe(true); + expect(result.value).toBe('hello'); + }); + + it('rejects empty string', () => { + const result = validateNonEmpty('', 'name'); + expect(result.valid).toBe(false); + expect(result.error).toContain('name cannot be empty'); + }); + + it('rejects whitespace-only string', () => { + const result = validateNonEmpty(' ', 'name'); + expect(result.valid).toBe(false); + }); + + it('uses generic message without fieldName', () => { + const result = validateNonEmpty(''); + expect(result.error).toContain('Value cannot be empty'); + }); +}); + +describe('formatEntityNotFoundError', () => { + it('formats error with entity type and input', () => { + const error = formatEntityNotFoundError('team', 'backend', 'teams list'); + expect(error).toContain('Team not found: "backend"'); + expect(error).toContain('agent2linear teams list'); + }); + + it('capitalizes entity type', () => { + const error = formatEntityNotFoundError('workflow state', 'todo', 'workflow-states list'); + expect(error).toContain('Workflow state not found'); + }); +}); diff --git a/src/components/setup/WalkthroughScreen.tsx b/src/ui/components/WalkthroughScreen.tsx similarity index 100% rename from src/components/setup/WalkthroughScreen.tsx rename to src/ui/components/WalkthroughScreen.tsx From d5fd549f9cab1eac3593f9f6f4838749c600fbbb Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 11 Mar 2026 05:43:46 +0000 Subject: [PATCH 03/11] Add register.ts files for alias and config command groups Continuation of cli.ts split refactoring (C1) - adds registration modules for alias and config commands. https://claude.ai/code/session_01LE3Rf4kmo1TBCozoF14LCa --- src/commands/alias/register.ts | 213 ++++++++++++++++++++++++++++++++ src/commands/config/register.ts | 135 ++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 src/commands/alias/register.ts create mode 100644 src/commands/config/register.ts diff --git a/src/commands/alias/register.ts b/src/commands/alias/register.ts new file mode 100644 index 0000000..63d3f48 --- /dev/null +++ b/src/commands/alias/register.ts @@ -0,0 +1,213 @@ +import { Command, Argument } from 'commander'; +import { addAliasCommand } from './add.js'; +import { listAliasCommand } from './list.js'; +import { removeAliasCommand } from './remove.js'; +import { getAliasCommand } from './get.js'; +import { editAlias } from './edit.js'; +import { aliasSyncCommand } from './sync.js'; +import { clearAliasCommand } from './clear.js'; + +export function registerAliasCommands(cli: Command): void { + const alias = cli + .command('alias') + .description('Manage aliases for initiatives, teams, projects, project statuses, templates, and members') + .action(() => { + alias.help(); + }); + + alias + .command('add') + .addArgument( + new Argument('<type>', 'Entity type') + .choices(['initiative', 'team', 'project', 'project-status', 'issue-template', 'project-template', 'member', 'user', 'issue-label', 'project-label', 'workflow-state']) + ) + .addArgument(new Argument('<alias>', 'Alias name (no spaces)')) + .addArgument(new Argument('[id]', 'Linear ID (optional if using --email or --name for members)')) + .description('Add a new alias') + .option('-g, --global', 'Save to global config (default)') + .option('-p, --project', 'Save to project config') + .option('--skip-validation', 'Skip entity validation (faster)') + .option('--email <email>', 'Look up member by email (exact or partial match, member/user only)') + .option('--name <name>', 'Look up member by name (partial match, member/user only)') + .option('-I, --interactive', 'Enable interactive selection when multiple matches found') + .addHelpText('after', ` +Examples: + Basic (with ID): + $ agent2linear alias add initiative backend init_abc123xyz + $ agent2linear alias add team frontend team_def456uvw --project + $ agent2linear alias add project api proj_ghi789rst + $ agent2linear alias add project-status in-progress status_abc123 + $ agent2linear alias add issue-template bug-report template_abc123 + $ agent2linear alias add project-template sprint-template template_xyz789 + $ agent2linear alias add issue-label bug label_abc123def + $ agent2linear alias add project-label release label_ghi456jkl + $ agent2linear alias add workflow-state done state_mno789pqr + $ agent2linear alias add member john user_abc123def + + Member by exact email (auto-select): + $ agent2linear alias add member john --email john.doe@acme.com + $ agent2linear alias add user jane --email jane@acme.com + + Member by partial email (error if multiple matches): + $ agent2linear alias add member john --email @acme.com + # Error: Multiple members found. Use --interactive to select. + + Member by email with interactive selection: + $ agent2linear alias add member john --email @acme.com --interactive + $ agent2linear alias add member john --email john@ --interactive + + Member by name with interactive selection: + $ agent2linear alias add member john --name John --interactive + $ agent2linear alias add member jane --name "Jane Smith" --interactive + +Note: --email, --name, and --interactive flags are only valid for member/user type +`) + .action(async (type: string, alias: string, id: string | undefined, options) => { + await addAliasCommand(type, alias, id, options); + }); + + alias + .command('list [type]') + .alias('ls') + .description('List all aliases or aliases for a specific type') + .option('--validate', 'Validate that aliases point to existing entities') + .addHelpText('after', ` +Examples: + $ agent2linear alias list # List all aliases + $ agent2linear alias ls # Same as 'list' (alias) + $ agent2linear alias list initiative # List only initiative aliases + $ agent2linear alias list team # List only team aliases + $ agent2linear alias list project # List only project aliases + $ agent2linear alias list project-status # List only project status aliases + $ agent2linear alias list issue-template # List only issue template aliases + $ agent2linear alias list project-template # List only project template aliases + $ agent2linear alias list issue-label # List only issue label aliases + $ agent2linear alias list project-label # List only project label aliases + $ agent2linear alias list workflow-state # List only workflow state aliases + $ agent2linear alias list member # List only member aliases + $ agent2linear alias list user # List only user/member aliases + $ agent2linear alias list --validate # Validate all aliases +`) + .action(async (type?: string, options?: { validate?: boolean }) => { + await listAliasCommand(type, options); + }); + + alias + .command('remove') + .alias('rm') + .addArgument( + new Argument('<type>', 'Entity type') + .choices(['initiative', 'team', 'project', 'project-status', 'issue-template', 'project-template', 'member', 'user', 'issue-label', 'project-label', 'workflow-state']) + ) + .addArgument(new Argument('<alias>', 'Alias name to remove')) + .description('Remove an alias') + .option('-g, --global', 'Remove from global config (default)') + .option('-p, --project', 'Remove from project config') + .addHelpText('after', ` +Examples: + $ agent2linear alias remove initiative backend + $ agent2linear alias rm team frontend --project + $ agent2linear alias remove project-status in-progress + $ agent2linear alias remove issue-template bug-report + $ agent2linear alias rm project-template sprint-template + $ agent2linear alias remove issue-label bug + $ agent2linear alias remove project-label release + $ agent2linear alias remove workflow-state done + $ agent2linear alias remove member john + $ agent2linear alias rm user jane +`) + .action((type: string, alias: string, options) => { + removeAliasCommand(type, alias, options); + }); + + alias + .command('get') + .addArgument( + new Argument('<type>', 'Entity type') + .choices(['initiative', 'team', 'project', 'project-status', 'issue-template', 'project-template', 'member', 'user', 'issue-label', 'project-label', 'workflow-state']) + ) + .addArgument(new Argument('<alias>', 'Alias name')) + .description('Get the ID for an alias') + .addHelpText('after', ` +Examples: + $ agent2linear alias get initiative backend + $ agent2linear alias get team frontend + $ agent2linear alias get project-status in-progress + $ agent2linear alias get issue-template bug-report + $ agent2linear alias get project-template sprint-template + $ agent2linear alias get issue-label bug + $ agent2linear alias get project-label release + $ agent2linear alias get workflow-state done + $ agent2linear alias get member john + $ agent2linear alias get user jane +`) + .action((type: string, alias: string) => { + getAliasCommand(type, alias); + }); + + alias + .command('edit') + .description('Interactively edit aliases (add, rename, change ID, or delete)') + .option('-g, --global', 'Edit global aliases') + .option('-p, --project', 'Edit project aliases') + .addHelpText('after', ` +This is an interactive command that guides you through editing aliases. + +Supported entity types: + - Initiatives - Teams - Projects + - Project Statuses - Issue Templates - Project Templates + - Members/Users - Issue Labels - Project Labels + - Workflow States + +Available operations: + - Add new alias: Create a new alias by selecting from available entities + - Rename alias: Change the alias name while keeping the same entity ID + - Change ID: Update the entity ID that an alias points to + - Delete alias: Remove an alias entirely + +Examples: + $ agent2linear alias edit # Interactive mode (choose scope interactively) + $ agent2linear alias edit --global # Edit global aliases directly + $ agent2linear alias edit --project # Edit project aliases directly + +Note: This command is fully interactive. For non-interactive editing, + use 'alias add' and 'alias remove' commands instead. +`) + .action(async (options) => { + await editAlias(options); + }); + + alias + .command('clear') + .addArgument( + new Argument('<type>', 'Entity type') + .choices(['initiative', 'team', 'project', 'project-status', 'issue-template', 'project-template', 'member', 'user', 'issue-label', 'project-label', 'workflow-state']) + ) + .description('Clear all aliases of a specific type') + .option('-g, --global', 'Clear from global config (default)') + .option('-p, --project', 'Clear from project config') + .option('-f, --force', 'Skip confirmation prompt') + .option('--dry-run', 'Preview what would be cleared without actually clearing') + .addHelpText('after', ` +Examples: + # Preview what would be cleared + $ agent2linear alias clear team --dry-run + $ agent2linear alias clear member --project --dry-run + + # Clear with confirmation + $ agent2linear alias clear team --global + $ agent2linear alias clear project-status --project + + # Clear without confirmation + $ agent2linear alias clear initiative --force + $ agent2linear alias clear member --project --force + +Warning: This will remove ALL aliases of the specified type from the chosen scope. + Use --dry-run first to preview what will be removed. +`) + .action(async (type: string, options) => { + await clearAliasCommand(type, options); + }); + + aliasSyncCommand(alias); +} diff --git a/src/commands/config/register.ts b/src/commands/config/register.ts new file mode 100644 index 0000000..9d249d0 --- /dev/null +++ b/src/commands/config/register.ts @@ -0,0 +1,135 @@ +import { Command, Argument } from 'commander'; +import { listConfig } from './list.js'; +import { getConfigValue } from './get.js'; +import type { ConfigKey } from '../../lib/config.js'; +import { setConfig } from './set.js'; +import { unsetConfig } from './unset.js'; +import { editConfig } from './edit.js'; + +export function registerConfigCommands(cli: Command): void { + const config = cli + .command('config') + .alias('cfg') + .description('Manage configuration settings for agent2linear') + .addHelpText('before', ` +Current respected settings: +- \`apiKey\`: Linear API authentication key (get yours at linear.app/settings/api) +- \`defaultInitiative\`: Default initiative ID for project creation (format: init_xxx) +- \`defaultTeam\`: Default team ID for project creation (format: team_xxx) +- \`defaultIssueTemplate\`: Default template ID for issue creation (format: template_xxx) +- \`defaultProjectTemplate\`: Default template ID for project creation (format: template_xxx) +- \`defaultMilestoneTemplate\`: Default milestone template name for project milestones +- \`projectCacheMinTTL\`: Cache time-to-live in minutes (default: 60, range: 1-1440) + +Configuration files: +- Global: ~/.config/agent2linear/config.json +- Project: .agent2linear/config.json +- Priority: environment > project > global (for apiKey) + project > global (for other settings) +`) + .addHelpText('after', ` +Related Commands: + $ agent2linear initiatives select # Interactive initiative picker + $ agent2linear teams select # Interactive team picker + +Learn More: + Get your Linear API key at: https://linear.app/settings/api +`) + .action(() => { + config.help(); + }); + + config + .command('list') + .alias('show') + .description('List all configuration values') + .addHelpText('after', ` +Examples: + $ agent2linear config list # Display all config values and sources + $ agent2linear cfg show # Same as 'list' (alias for backward compatibility) +`) + .action(async () => { + await listConfig(); + }); + + config + .command('get') + .addArgument( + new Argument('<key>', 'Configuration key') + .choices(['apiKey', 'defaultInitiative', 'defaultTeam', 'defaultIssueTemplate', 'defaultProjectTemplate', 'defaultMilestoneTemplate', 'projectCacheMinTTL']) + ) + .description('Get a single configuration value') + .addHelpText('after', ` +Examples: + $ agent2linear config get apiKey + $ agent2linear cfg get defaultInitiative + $ agent2linear cfg get defaultProjectTemplate + $ agent2linear cfg get defaultMilestoneTemplate + $ agent2linear cfg get projectCacheMinTTL +`) + .action(async (key: string) => { + await getConfigValue(key as ConfigKey); + }); + + config + .command('set') + .addArgument( + new Argument('<key>', 'Configuration key') + .choices(['apiKey', 'defaultInitiative', 'defaultTeam', 'defaultIssueTemplate', 'defaultProjectTemplate', 'defaultMilestoneTemplate', 'projectCacheMinTTL']) + ) + .addArgument(new Argument('<value>', 'Configuration value')) + .description('Set a configuration value') + .option('-g, --global', 'Set in global config (default)') + .option('-p, --project', 'Set in project config') + .addHelpText('after', ` +Examples: + $ agent2linear config set apiKey lin_api_xxx... + $ agent2linear config set defaultInitiative init_abc123 --global + $ agent2linear config set defaultTeam team_xyz789 --project + $ agent2linear config set defaultProjectTemplate template_abc123 + $ agent2linear config set defaultMilestoneTemplate basic-sprint + $ agent2linear config set projectCacheMinTTL 120 # Cache for 2 hours +`) + .action(async (key: string, value: string, options) => { + await setConfig(key, value, options); + }); + + config + .command('unset') + .addArgument( + new Argument('<key>', 'Configuration key') + .choices(['apiKey', 'defaultInitiative', 'defaultTeam', 'defaultIssueTemplate', 'defaultProjectTemplate', 'defaultMilestoneTemplate', 'projectCacheMinTTL']) + ) + .description('Remove a configuration value') + .option('-g, --global', 'Remove from global config (default)') + .option('-p, --project', 'Remove from project config') + .addHelpText('after', ` +Examples: + $ agent2linear config unset apiKey --global + $ agent2linear config unset defaultTeam --project + $ agent2linear config unset defaultProjectTemplate + $ agent2linear config unset defaultMilestoneTemplate + $ agent2linear config unset projectCacheMinTTL +`) + .action(async (key: string, options) => { + await unsetConfig(key, options); + }); + + config + .command('edit') + .description('Edit configuration interactively') + .option('-g, --global', 'Edit global config (skip scope prompt)') + .option('-p, --project', 'Edit project config (skip scope prompt)') + .option('--key <key>', 'Configuration key to edit (non-interactive)') + .option('--value <value>', 'Configuration value (requires --key, non-interactive)') + .addHelpText('after', ` +Examples: + $ agent2linear config edit # Interactive multi-value editing + $ agent2linear config edit --global # Edit global config interactively + $ agent2linear config edit --key apiKey --value lin_api_xxx # Non-interactive single value + $ agent2linear cfg edit # Same as 'config edit' (alias) +`) + .action(async (options) => { + await editConfig(options); + }); +} From da720e36fec0b52084be40adfa85896f6ae88e78 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 11 Mar 2026 05:44:05 +0000 Subject: [PATCH 04/11] Add issue register.ts and API projects module Continuation of C1 (cli.ts split) and C2 (linear-client.ts split) refactoring - adds issue command registration and projects API module. https://claude.ai/code/session_01LE3Rf4kmo1TBCozoF14LCa --- src/commands/issue/register.ts | 289 ++++++ src/lib/api/projects.ts | 1557 ++++++++++++++++++++++++++++++++ 2 files changed, 1846 insertions(+) create mode 100644 src/commands/issue/register.ts create mode 100644 src/lib/api/projects.ts diff --git a/src/commands/issue/register.ts b/src/commands/issue/register.ts new file mode 100644 index 0000000..9783734 --- /dev/null +++ b/src/commands/issue/register.ts @@ -0,0 +1,289 @@ +import { Command } from 'commander'; +import { viewIssue } from './view.js'; +import { createIssueCommand } from './create.js'; +import { updateIssueCommand } from './update.js'; +import { registerIssueListCommand } from './list.js'; +import { commentIssueCommand } from './comment.js'; + +export function registerIssueCommands(cli: Command): void { + const issue = cli + .command('issue') + .description('Manage Linear issues') + .action(() => { + issue.help(); + }); + + issue + .command('view <identifier>') + .description('View an issue by identifier (e.g., ENG-123) or UUID') + .option('--json', 'Output in JSON format') + .option('-w, --web', 'Open issue in web browser') + .option('--show-comments', 'Display issue comments') + .option('--show-history', 'Display issue history') + .option('--desc', 'Show truncated description preview (default 80 chars)') + .option('--desc-length <n>', 'Description preview length in characters (implies --desc)') + .option('--desc-full', 'Show full description (default behavior, explicit)') + .option('--no-desc', 'Hide description from output') + .addHelpText('after', ` +Examples: + $ agent2linear issue view ENG-123 # View issue by identifier + $ agent2linear issue view <uuid> # View issue by UUID + $ agent2linear issue view ENG-123 --json # Output as JSON + $ agent2linear issue view ENG-123 --web # Open in browser + $ agent2linear issue view ENG-123 --show-comments # Include comments + $ agent2linear issue view ENG-123 --show-history # Include history + $ agent2linear issue view ENG-123 --desc # Show 80-char description preview + $ agent2linear issue view ENG-123 --no-desc # Hide description + $ agent2linear issue view ENG-123 --desc-length 120 # Show 120-char description preview + +The view command displays comprehensive issue information including: + • Core details: title, description, status, priority + • Assignment: assignee, subscribers + • Organization: team, project, cycle, labels + • Dates: created, updated, due, completed + • Relationships: parent issue, sub-issues + • Creator information + +Use --show-comments to see all comments on the issue. +Use --show-history to see the change history. +`) + .action(async (identifier, options) => { + await viewIssue(identifier, options); + }); + + issue + .command('create') + .description('Create a new Linear issue') + .option('--title <string>', 'Issue title (required)') + .option('--team <id|alias>', 'Team ID or alias (required unless defaultTeam configured)') + .option('--description <string>', 'Issue description (markdown)') + .option('--description-file <path>', 'Read description from file (mutually exclusive with --description)') + .option('--priority <0-4>', 'Priority: 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low') + .option('--estimate <number>', 'Story points or time estimate') + .option('--state <id|alias>', 'Workflow state ID or alias (must belong to team)') + .option('--due-date <YYYY-MM-DD>', 'Due date in ISO format') + .option('--assignee <id|alias|email|name>', 'Assign to user (ID, alias, email, or display name)') + .option('--no-assignee', 'Create unassigned (overrides default auto-assignment)') + .option('--subscribers <list>', 'Comma-separated list of subscriber IDs, aliases, or emails') + .option('--project <id|alias|name>', 'Project ID, alias, or name (must belong to same team)') + .option('--cycle <uuid|alias>', 'Cycle UUID or alias') + .option('--parent <identifier>', 'Parent issue identifier (ENG-123 or UUID) for sub-issues') + .option('--labels <list>', 'Comma-separated list of label IDs or aliases') + .option('--template <id|alias>', 'Issue template ID or alias') + .option('-w, --web', 'Open created issue in browser') + .option('--dry-run', 'Preview the payload without creating the issue') + .addHelpText('after', ` +Examples: + # Minimal (uses defaultTeam, auto-assigns to you) + $ agent2linear issue create --title "Fix login bug" + + # Standard creation + $ agent2linear issue create \\ + --title "Add OAuth support" \\ + --team backend \\ + --priority 2 \\ + --estimate 8 + + # Full-featured creation + $ agent2linear issue create \\ + --title "Implement authentication" \\ + --team backend \\ + --description "Add OAuth2 with Google and GitHub providers" \\ + --priority 1 \\ + --estimate 13 \\ + --state in-progress \\ + --assignee john@company.com \\ + --subscribers "jane@company.com,bob@company.com" \\ + --labels "feature,security" \\ + --project "Q1 Goals" \\ + --due-date 2025-02-15 \\ + --web + + # Create sub-issue + $ agent2linear issue create \\ + --title "Write unit tests" \\ + --parent ENG-123 \\ + --team backend + + # Read description from file + $ agent2linear issue create \\ + --title "API Documentation" \\ + --team backend \\ + --description-file docs/api-spec.md + + # Create unassigned + $ agent2linear issue create \\ + --title "Research task" \\ + --team backend \\ + --no-assignee + +Field Details: + • Title: Required. The issue title. + • Team: Required (unless defaultTeam configured). The team this issue belongs to. + • Auto-assignment: By default, issues are assigned to you. Use --assignee to assign + to someone else, or --no-assignee to create an unassigned issue. + • Priority: 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low + • State: Must belong to the same team. Use workflow state ID or alias. + • Project: Must belong to the same team. Supports ID, alias, or name lookup. + • Cycle: Must be a valid UUID or cycle alias. + • Labels: Comma-separated list. Supports label IDs or aliases. + • Subscribers: Comma-separated list. Supports member IDs, aliases, emails, or display names. + • Parent: Creates a sub-issue. Use issue identifier (ENG-123) or UUID. + +Member Resolution: + The --assignee and --subscribers options support multiple resolution methods: + • Linear ID: user_abc123 + • Alias: john (from your aliases.json) + • Email: john@company.com (exact match lookup) + • Display name: "John Doe" (with disambiguation if multiple matches) + +Config Defaults: + • defaultTeam: If set, team becomes optional + • defaultProject: Used if --project not specified (must belong to same team) + + Set defaults with: + $ agent2linear config set defaultTeam <team-id> + $ agent2linear config set defaultProject <project-id> +`) + .action(async (options) => { + await createIssueCommand(options); + }); + + issue + .command('update <identifier>') + .description('Update an existing Linear issue by identifier (ENG-123) or UUID') + .option('--title <string>', 'Update issue title') + .option('--description <string>', 'Update description (markdown)') + .option('--description-file <path>', 'Read description from file (mutually exclusive with --description)') + .option('--priority <0-4>', 'Update priority: 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low', parseInt) + .option('--estimate <number>', 'Update estimate', parseFloat) + .option('--no-estimate', 'Clear estimate') + .option('--state <id|alias>', 'Update workflow state (must belong to team)') + .option('--due-date <YYYY-MM-DD>', 'Set/update due date') + .option('--no-due-date', 'Clear due date') + .option('--assignee <id|alias|email|name>', 'Change assignee') + .option('--no-assignee', 'Remove assignee') + .option('--team <id|alias>', 'Move to different team (requires compatible state)') + .option('--project <id|alias|name>', 'Assign to project') + .option('--no-project', 'Remove from project') + .option('--cycle <uuid|alias>', 'Assign to cycle') + .option('--no-cycle', 'Remove from cycle') + .option('--parent <identifier>', 'Set/change parent issue (ENG-123 or UUID)') + .option('--no-parent', 'Remove parent (make root issue)') + .option('--labels <list>', 'Replace ALL labels (comma-separated)') + .option('--add-labels <list>', 'Add labels (comma-separated)') + .option('--remove-labels <list>', 'Remove labels (comma-separated)') + .option('--subscribers <list>', 'Replace ALL subscribers (comma-separated)') + .option('--add-subscribers <list>', 'Add subscribers (comma-separated)') + .option('--remove-subscribers <list>', 'Remove subscribers (comma-separated)') + .option('--trash', 'Move issue to trash') + .option('--untrash', 'Restore issue from trash') + .option('-w, --web', 'Open updated issue in browser') + .option('--dry-run', 'Preview the payload without updating the issue') + .option('--bulk <identifiers>', 'Apply same update to multiple issues (comma-separated identifiers)') + .addHelpText('after', ` +Examples: + # Update single field + $ agent2linear issue update ENG-123 --title "New title" + $ agent2linear issue update ENG-123 --priority 1 + $ agent2linear issue update ENG-123 --state done + + # Update multiple fields + $ agent2linear issue update ENG-123 \\ + --title "Updated title" \\ + --priority 2 \\ + --estimate 5 \\ + --due-date 2025-12-31 + + # Change assignment + $ agent2linear issue update ENG-123 --assignee john@company.com + $ agent2linear issue update ENG-123 --no-assignee + + # Label management (3 modes) + $ agent2linear issue update ENG-123 --labels "bug,urgent" # Replace all + $ agent2linear issue update ENG-123 --add-labels "feature" # Add to existing + $ agent2linear issue update ENG-123 --remove-labels "wontfix" # Remove specific + $ agent2linear issue update ENG-123 --add-labels "new" --remove-labels "old" # Add + remove + + # Subscriber management (3 modes) + $ agent2linear issue update ENG-123 --subscribers "user1,user2" # Replace all + $ agent2linear issue update ENG-123 --add-subscribers "user3" # Add to existing + $ agent2linear issue update ENG-123 --remove-subscribers "user1" # Remove specific + + # Clear fields + $ agent2linear issue update ENG-123 --no-assignee --no-due-date --no-estimate + $ agent2linear issue update ENG-123 --no-project --no-cycle --no-parent + + # Parent relationship + $ agent2linear issue update ENG-123 --parent ENG-100 # Make sub-issue + $ agent2linear issue update ENG-123 --no-parent # Make root issue + + # Move between teams + $ agent2linear issue update ENG-123 --team frontend --state todo + + # Lifecycle operations + $ agent2linear issue update ENG-123 --trash # Move to trash + $ agent2linear issue update ENG-123 --untrash # Restore from trash + +Field Details: + • Identifier: Use issue identifier (ENG-123) or UUID + • At least one update field required (--web alone is not enough) + • Priority: 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low + • State: Must belong to the issue's team (or new team if also using --team) + +Mutual Exclusivity Rules: + • Cannot use --description with --description-file + • Cannot use --labels with --add-labels or --remove-labels + • Cannot use --subscribers with --add-subscribers or --remove-subscribers + • Cannot use --assignee with --no-assignee + • Cannot use --due-date with --no-due-date + • Cannot use --estimate with --no-estimate + • Cannot use --project with --no-project + • Cannot use --cycle with --no-cycle + • Cannot use --parent with --no-parent + • Cannot use --trash with --untrash + +Label/Subscriber Patterns: + • Replace mode: --labels or --subscribers replaces ALL items + • Add mode: --add-labels or --add-subscribers adds to existing + • Remove mode: --remove-labels or --remove-subscribers removes specific items + • Add + Remove: Can use --add-labels AND --remove-labels together (add first, then remove) + +Team Changes: + When changing teams (--team), the workflow state must be compatible: + • If also providing --state, it must belong to the NEW team + • If NOT providing --state, current state must be compatible with new team + • Linear will reject invalid team-state combinations + +Member Resolution: + --assignee and --subscribers support multiple resolution methods: + • Linear ID: user_abc123 + • Alias: john (from aliases.json) + • Email: john@company.com + • Display name: "John Doe" +`) + .action(async (identifier, options) => { + await updateIssueCommand(identifier, options); + }); + + // Register issue list command (M15.5 Phase 1) + registerIssueListCommand(issue); + + // Issue comment subcommand + issue + .command('comment <identifier>') + .description('Add a comment to an issue') + .option('--body <text>', 'Comment body (markdown)') + .option('--body-file <path>', 'Read comment body from file') + .addHelpText('after', ` +Examples: + $ agent2linear issue comment ENG-123 --body "This is done" + $ agent2linear issue comment ENG-123 --body-file notes.md + +The identifier can be an issue identifier (ENG-123) or UUID. +Comment body supports markdown formatting. +`) + .action(async (identifier, options) => { + await commentIssueCommand(identifier, options); + }); +} diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts new file mode 100644 index 0000000..fade043 --- /dev/null +++ b/src/lib/api/projects.ts @@ -0,0 +1,1557 @@ +import type { LinearClient as SDKClient } from '@linear/sdk'; +import { getLinearClient, LinearClientError } from './client.js'; +import { getRelationDirection } from '../parsers.js'; +import type { + ProjectListFilters, + ProjectListItem, + ProjectRelation, + ProjectRelationCreateInput, +} from '../types.js'; + +/** + * Project creation input + */ +export interface ProjectCreateInput { + name: string; + description?: string; + initiativeId?: string; + teamId?: string; + templateId?: string; + // Additional Linear SDK fields + statusId?: string; + content?: string; + icon?: string; + color?: string; + leadId?: string; + labelIds?: string[]; + convertedFromIssueId?: string; + startDate?: string; + startDateResolution?: 'month' | 'quarter' | 'halfYear' | 'year'; + targetDate?: string; + targetDateResolution?: 'month' | 'quarter' | 'halfYear' | 'year'; + priority?: number; + memberIds?: string[]; +} + +/** + * Project result with metadata + */ +export interface ProjectResult { + id: string; + name: string; + description?: string; + content?: string; + url: string; + state: string; + initiative?: { + id: string; + name: string; + }; + team?: { + id: string; + name: string; + }; +} + +/** + * Project data structure (for listing/selection) + */ +export interface Project { + id: string; + name: string; + description?: string; + icon?: string; +} + +/** + * Project Update Input + */ +export interface ProjectUpdateInput { + statusId?: string; + name?: string; + description?: string; + content?: string; + priority?: number; + startDate?: string; + targetDate?: string; + // M15 Phase 1: Visual & Ownership Fields + color?: string; + icon?: string; + leadId?: string; + // M15 Phase 2: Collaboration & Organization Fields + memberIds?: string[]; + labelIds?: string[]; + // M15 Phase 3: Date Resolutions + startDateResolution?: 'month' | 'quarter' | 'halfYear' | 'year'; + targetDateResolution?: 'month' | 'quarter' | 'halfYear' | 'year'; +} + +/** + * Project Status types + */ +export interface ProjectStatus { + id: string; + name: string; + type: 'planned' | 'started' | 'paused' | 'completed' | 'canceled'; + color: string; + description?: string; + position: number; +} + +/** + * Milestone-related types + */ +export interface ProjectMilestone { + id: string; + name: string; + description?: string; + targetDate?: string; +} + +export interface MilestoneCreateInput { + name: string; + description?: string; + targetDate?: Date; +} + +/** + * External Link types + */ +export interface ExternalLink { + id: string; + url: string; + label: string; + sortOrder: number; + creatorId: string; +} + +export interface ExternalLinkCreateInput { + url: string; + label: string; + projectId?: string; + initiativeId?: string; + sortOrder?: number; +} + +/** + * Get all projects with optional filtering + * + * Performance Optimization (M21 Extended): + * - Conditional fetching: Only fetch labels/members if used for filtering + * - Two-query approach: Minimal query + optional batch query for labels/members + * - In-code join to combine results + * - API call reduction: + * - No filters: 1 call (was 1+N) + * - With label/member filters: 2 calls (was 1+N) + * - Overall: 92-98% reduction in API calls + * + * @param filters - Optional filters for projects + * @returns Array of project list items + */ +export async function getAllProjects(filters?: ProjectListFilters): Promise<ProjectListItem[]> { + try { + const client = getLinearClient(); + + // Build GraphQL filter object + const graphqlFilter: any = {}; + + if (filters?.teamId) { + graphqlFilter.accessibleTeams = { some: { id: { eq: filters.teamId } } }; + } + + if (filters?.initiativeId) { + graphqlFilter.initiatives = { some: { id: { eq: filters.initiativeId } } }; + } + + if (filters?.statusId) { + graphqlFilter.status = { id: { eq: filters.statusId } }; + } + + if (filters?.priority !== undefined) { + graphqlFilter.priority = { eq: filters.priority }; + } + + if (filters?.leadId) { + graphqlFilter.lead = { id: { eq: filters.leadId } }; + } + + if (filters?.memberIds && filters.memberIds.length > 0) { + graphqlFilter.members = { some: { id: { in: filters.memberIds } } }; + } + + if (filters?.labelIds && filters.labelIds.length > 0) { + graphqlFilter.labels = { some: { id: { in: filters.labelIds } } }; + } + + // Date range filters + if (filters?.startDateAfter || filters?.startDateBefore) { + graphqlFilter.startDate = {}; + if (filters.startDateAfter) { + graphqlFilter.startDate.gte = filters.startDateAfter; + } + if (filters.startDateBefore) { + graphqlFilter.startDate.lte = filters.startDateBefore; + } + } + + if (filters?.targetDateAfter || filters?.targetDateBefore) { + graphqlFilter.targetDate = {}; + if (filters.targetDateAfter) { + graphqlFilter.targetDate.gte = filters.targetDateAfter; + } + if (filters.targetDateBefore) { + graphqlFilter.targetDate.lte = filters.targetDateBefore; + } + } + + // Text search (search in name, description, content) + if (filters?.search) { + const searchTerm = filters.search.trim(); + if (searchTerm.length > 0) { + graphqlFilter.or = [ + { name: { containsIgnoreCase: searchTerm } }, + { slugId: { containsIgnoreCase: searchTerm } }, + { searchableContent: { contains: searchTerm } } + ]; + } + } + + if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { + console.error('[agent2linear] Project filter:', JSON.stringify(graphqlFilter, null, 2)); + } + + // Determine what data needs to be fetched based on filters + const needsLabels = !!filters?.labelIds && filters.labelIds.length > 0; + const needsMembers = !!filters?.memberIds && filters.memberIds.length > 0; + const needsDependencies = !!filters?.includeDependencies; // M23 + const needsAdditionalData = needsLabels || needsMembers; + + if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { + console.error('[agent2linear] Conditional fetch:', { needsLabels, needsMembers, needsAdditionalData }); + } + + // ======================================== + // PAGINATION SETUP (M21.1) + // ======================================== + const pageSize = filters?.fetchAll ? 250 : Math.min(filters?.limit || 50, 250); + const fetchAll = filters?.fetchAll || false; + const targetLimit = filters?.limit || 50; + + if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { + console.error('[agent2linear] Pagination:', { pageSize, fetchAll, targetLimit }); + } + + // QUERY 1: Minimal - Always fetch core project data (projects, teams, leads) + // M23: Conditionally include relations if dependencies are requested + const relationsFragment = needsDependencies ? ` + relations { + nodes { + id + type + anchorType + relatedAnchorType + project { id } + relatedProject { id } + } + }` : ''; + + const minimalQuery = ` + query GetMinimalProjects($filter: ProjectFilter, $includeArchived: Boolean, $first: Int, $after: String) { + projects(filter: $filter, includeArchived: $includeArchived, first: $first, after: $after) { + nodes { + id + name + description + content + icon + color + state + priority + startDate + targetDate + completedAt + url + createdAt + updatedAt + + teams { + nodes { + id + name + key + } + } + + lead { + id + name + email + } +${relationsFragment} + } + pageInfo { + hasNextPage + endCursor + } + } + } + `; + + // ======================================== + // PAGINATION LOOP (M21.1) + // ======================================== + let rawProjects: any[] = []; + let cursor: string | null = null; + let hasNextPage = true; + let pageCount = 0; + + while (hasNextPage && (fetchAll || rawProjects.length < targetLimit)) { + pageCount++; + + const variables = { + filter: Object.keys(graphqlFilter).length > 0 ? graphqlFilter : null, + includeArchived: false, + first: pageSize, + after: cursor + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const minimalResponse: any = await client.client.rawRequest(minimalQuery, variables); + + const nodes = minimalResponse.data?.projects?.nodes || []; + const pageInfo = minimalResponse.data?.projects?.pageInfo; + + rawProjects.push(...nodes); + + hasNextPage = pageInfo?.hasNextPage || false; + cursor = pageInfo?.endCursor || null; + + if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { + console.error(`[agent2linear] Page ${pageCount}: fetched ${nodes.length} projects (total: ${rawProjects.length}, hasNextPage: ${hasNextPage})`); + } + + // If not fetching all, stop when we have enough + if (!fetchAll && rawProjects.length >= targetLimit) { + break; + } + } + + // ======================================== + // QUERY 2: CONDITIONAL - Batch fetch labels+members IF filters use them + // ======================================== + const labelsMap: Map<string, any[]> = new Map(); + const membersMap: Map<string, any[]> = new Map(); + + if (needsAdditionalData && rawProjects.length > 0) { + const projectIds = rawProjects.map((p: any) => p.id); + + const batchQuery = ` + query GetProjectsLabelsAndMembers($ids: [String!]!) { + ${projectIds.map((id: string, index: number) => ` + project${index}: project(id: "${id}") { + id + labels { + nodes { + id + name + color + } + } + members { + nodes { + id + name + email + } + } + } + `).join('\n')} + } + `; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const batchResponse: any = await client.client.rawRequest(batchQuery, {}); + + if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { + console.error('[agent2linear] Batch query fetched labels+members for', projectIds.length, 'projects'); + } + + // Parse batch response and build maps for in-code join + projectIds.forEach((projectId: string, index: number) => { + const projectData = batchResponse.data?.[`project${index}`]; + if (projectData) { + labelsMap.set(projectId, projectData.labels?.nodes || []); + membersMap.set(projectId, projectData.members?.nodes || []); + } + }); + } + + // ======================================== + // TRUNCATION (M21.1) + // ======================================== + if (!fetchAll && rawProjects.length > targetLimit) { + if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { + console.error(`[agent2linear] Truncating from ${rawProjects.length} to ${targetLimit} projects`); + } + rawProjects = rawProjects.slice(0, targetLimit); + } + + // ======================================== + // BUILD FINAL PROJECT LIST (IN-CODE JOIN) + // ======================================== + const projectList: ProjectListItem[] = rawProjects.map((project: any) => { + const labels = labelsMap.get(project.id) || []; + const members = membersMap.get(project.id) || []; + + // M23: Calculate dependency counts if relations were fetched + let dependsOnCount: number | undefined = undefined; + let blocksCount: number | undefined = undefined; + + if (needsDependencies) { + // Initialize to 0 when fetching dependencies + dependsOnCount = 0; + blocksCount = 0; + + // Count relations if present + if (project.relations?.nodes) { + const relations = project.relations.nodes; + + dependsOnCount = relations.filter((rel: any) => { + try { + return getRelationDirection(rel, project.id) === 'depends-on'; + } catch { + return false; + } + }).length; + + blocksCount = relations.filter((rel: any) => { + try { + return getRelationDirection(rel, project.id) === 'blocks'; + } catch { + return false; + } + }).length; + } + } + + return { + id: project.id, + name: project.name, + description: project.description || undefined, + content: project.content || undefined, + icon: project.icon || undefined, + color: project.color || undefined, + state: project.state, + priority: project.priority !== undefined ? project.priority : undefined, + + status: undefined, // Project status is not available in Linear SDK v27+ + + lead: project.lead ? { + id: project.lead.id, + name: project.lead.name, + email: project.lead.email + } : undefined, + + team: project.teams?.nodes?.[0] ? { + id: project.teams.nodes[0].id, + name: project.teams.nodes[0].name, + key: project.teams.nodes[0].key + } : undefined, + + initiative: undefined, // Initiative relationship needs to be fetched differently + + labels: labels.map((label: any) => ({ + id: label.id, + name: label.name, + color: label.color || undefined + })), + + members: members.map((member: any) => ({ + id: member.id, + name: member.name, + email: member.email + })), + + startDate: project.startDate || undefined, + targetDate: project.targetDate || undefined, + completedAt: project.completedAt || undefined, + + url: project.url, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + + // M23: Include dependency counts if fetched + dependsOnCount, + blocksCount + }; + }); + + return projectList; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch projects: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Check if a project with the given name already exists (legacy - returns boolean) + */ +export async function getProjectByName(name: string): Promise<boolean> { + try { + const project = await findProjectByName(name); + return project !== null; + } catch (error) { + // If we can't check, allow creation to proceed + return false; + } +} + +/** + * Find a project by its exact name and return full project details + */ +export async function findProjectByName(name: string): Promise<ProjectResult | null> { + try { + const client = getLinearClient(); + const projects = await client.projects({ + filter: { + name: { eq: name }, + }, + }); + + const projectsList = await projects.nodes; + if (projectsList.length === 0) { + return null; + } + + const project = projectsList[0]; + + // Fetch initiative details if linked + let initiative; + try { + const projectInitiatives = await project.initiatives(); + const initiativesList = await projectInitiatives.nodes; + if (initiativesList && initiativesList.length > 0) { + const firstInitiative = initiativesList[0]; + initiative = { + id: firstInitiative.id, + name: firstInitiative.name, + }; + } + } catch { + // Initiative fetch failed or not linked + } + + // Fetch team details if set + let team; + try { + const teams = await project.teams(); + const teamsList = await teams.nodes; + if (teamsList && teamsList.length > 0) { + const firstTeam = teamsList[0]; + team = { + id: firstTeam.id, + name: firstTeam.name, + }; + } + } catch { + // Team fetch failed + } + + return { + id: project.id, + name: project.name, + url: project.url, + state: project.state, + initiative, + team, + }; + } catch (error) { + return null; + } +} + +/** + * Create a new project in Linear + */ +export async function createProject(input: ProjectCreateInput): Promise<ProjectResult> { + try { + const client = getLinearClient(); + + // Prepare the creation input + const createInput = { + name: input.name, + description: input.description, + ...(input.teamId && { teamIds: [input.teamId] }), + ...(input.templateId && { lastAppliedTemplateId: input.templateId }), + // Additional optional fields + ...(input.statusId && { statusId: input.statusId }), + ...(input.content && { content: input.content }), + ...(input.icon && { icon: input.icon }), + ...(input.color && { color: input.color }), + ...(input.leadId && { leadId: input.leadId }), + ...(input.labelIds && input.labelIds.length > 0 && { labelIds: input.labelIds }), + ...(input.convertedFromIssueId && { convertedFromIssueId: input.convertedFromIssueId }), + ...(input.startDate && { startDate: input.startDate }), + ...(input.startDateResolution && { startDateResolution: input.startDateResolution }), + ...(input.targetDate && { targetDate: input.targetDate }), + ...(input.targetDateResolution && { targetDateResolution: input.targetDateResolution }), + ...(input.priority !== undefined && { priority: input.priority }), + ...(input.memberIds && input.memberIds.length > 0 && { memberIds: input.memberIds }), + } as const; + + // Debug: log what we're sending to the API + if (process.env.DEBUG) { + console.log('DEBUG: createInput =', JSON.stringify(createInput, null, 2)); + } + + // Create the project + const projectPayload = await client.createProject(createInput as Parameters<typeof client.createProject>[0]); + + const project = await projectPayload.project; + + if (!project) { + throw new Error('Failed to create project: No project returned from API'); + } + + // Debug: Check if template was applied + if (process.env.DEBUG && input.templateId) { + try { + const lastAppliedTemplate = await (project as { lastAppliedTemplate?: { id: string; name: string } }).lastAppliedTemplate; + if (lastAppliedTemplate) { + console.log(`DEBUG: Template applied - ID: ${lastAppliedTemplate.id}, Name: ${lastAppliedTemplate.name}`); + } else { + console.log('DEBUG: No template was applied to the project'); + } + } catch (err) { + console.log('DEBUG: Could not check lastAppliedTemplate:', err instanceof Error ? err.message : err); + } + } + + // Link project to initiative if specified + let initiative; + if (input.initiativeId) { + try { + // First fetch initiative details + const initiativeData = await client.initiative(input.initiativeId); + initiative = { + id: initiativeData.id, + name: initiativeData.name, + }; + + // Link project to initiative using initiativeToProjectCreate + await client.createInitiativeToProject({ + initiativeId: input.initiativeId, + projectId: project.id, + }); + + if (process.env.DEBUG) { + console.log(`DEBUG: Successfully linked project ${project.id} to initiative ${input.initiativeId}`); + } + } catch (err) { + // Initiative link failed - log in debug mode + if (process.env.DEBUG) { + console.log('DEBUG: Failed to link initiative:', err); + } + // Don't throw - project was still created successfully + } + } + + // Fetch team details if set + let team; + if (input.teamId) { + try { + const teamData = await client.team(input.teamId); + team = { + id: teamData.id, + name: teamData.name, + }; + } catch { + // Team fetch failed + } + } + + return { + id: project.id, + name: project.name, + url: project.url, + state: project.state, + initiative, + team, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to create project: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Update an existing project + */ +export async function updateProject( + projectId: string, + updates: ProjectUpdateInput +): Promise<ProjectResult> { + try { + const client = getLinearClient(); + + // Prepare the update input + const updateInput: Partial<{ + statusId: string; + name: string; + description: string; + content: string; + priority: number; + startDate: string; + targetDate: string; + color: string; + icon: string; + leadId: string; + memberIds: string[]; + labelIds: string[]; + startDateResolution: 'month' | 'quarter' | 'halfYear' | 'year'; + targetDateResolution: 'month' | 'quarter' | 'halfYear' | 'year'; + }> = {}; + + if (updates.statusId !== undefined) { + updateInput.statusId = updates.statusId; + } + if (updates.name !== undefined) { + updateInput.name = updates.name; + } + if (updates.description !== undefined) { + updateInput.description = updates.description; + } + if (updates.content !== undefined) { + updateInput.content = updates.content; + } + if (updates.priority !== undefined) { + updateInput.priority = updates.priority; + } + if (updates.startDate !== undefined) { + updateInput.startDate = updates.startDate; + } + if (updates.targetDate !== undefined) { + updateInput.targetDate = updates.targetDate; + } + // M15 Phase 1: Visual & Ownership Fields + if (updates.color !== undefined) { + updateInput.color = updates.color; + } + if (updates.icon !== undefined) { + updateInput.icon = updates.icon; + } + if (updates.leadId !== undefined) { + updateInput.leadId = updates.leadId; + } + // M15 Phase 2: Collaboration & Organization Fields + if (updates.memberIds !== undefined) { + updateInput.memberIds = updates.memberIds; + } + if (updates.labelIds !== undefined) { + updateInput.labelIds = updates.labelIds; + } + // M15 Phase 3: Date Resolutions + if (updates.startDateResolution !== undefined) { + updateInput.startDateResolution = updates.startDateResolution; + } + if (updates.targetDateResolution !== undefined) { + updateInput.targetDateResolution = updates.targetDateResolution; + } + + // Update the project + const projectPayload = await client.updateProject(projectId, updateInput as Parameters<typeof client.updateProject>[1]); + const project = await projectPayload.project; + + if (!project) { + throw new Error('Failed to update project: No project returned from API'); + } + + // Fetch initiative details if linked + let initiative; + try { + const projectInitiatives = await project.initiatives(); + const initiativesList = await projectInitiatives.nodes; + if (initiativesList && initiativesList.length > 0) { + const firstInitiative = initiativesList[0]; + initiative = { + id: firstInitiative.id, + name: firstInitiative.name, + }; + } + } catch { + // Initiative fetch failed or not linked + } + + // Fetch team details if set + let team; + try { + const teams = await project.teams(); + const teamsList = await teams.nodes; + if (teamsList && teamsList.length > 0) { + const firstTeam = teamsList[0]; + team = { + id: firstTeam.id, + name: firstTeam.name, + }; + } + } catch { + // Team fetch failed + } + + return { + id: project.id, + name: project.name, + url: project.url, + state: project.state, + initiative, + team, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to update project: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get a single project by ID + * + * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): + * Uses custom GraphQL query to avoid N+1 pattern (3 API calls -> 1 call) + * Fetches project with initiatives and teams upfront instead of lazy loading via SDK + */ +export async function getProjectById( + projectId: string +): Promise<ProjectResult | null> { + try { + const client = getLinearClient(); + + // Custom GraphQL query - fetch project with initiatives and teams in one request + const projectQuery = ` + query GetProject($projectId: String!) { + project(id: $projectId) { + id + name + url + state + + initiatives { + nodes { + id + name + } + } + + teams { + nodes { + id + name + } + } + } + } + `; + + const response: any = await client.client.rawRequest(projectQuery, { projectId }); + const project = response.data?.project; + + if (!project) { + return null; + } + + // Get first initiative if exists + const initiative = project.initiatives?.nodes?.[0] + ? { + id: project.initiatives.nodes[0].id, + name: project.initiatives.nodes[0].name, + } + : undefined; + + // Get first team if exists + const team = project.teams?.nodes?.[0] + ? { + id: project.teams.nodes[0].id, + name: project.teams.nodes[0].name, + } + : undefined; + + return { + id: project.id, + name: project.name, + url: project.url, + state: project.state, + initiative, + team, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch project: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get project milestones and issues for validation + */ +export async function getProjectDetails(projectId: string): Promise<{ + project: ProjectResult; + lastAppliedTemplate?: { id: string; name: string }; + milestones: Array<{ id: string; name: string }>; + issues: Array<{ id: string; identifier: string; title: string }>; +} | null> { + try { + const client = getLinearClient(); + const project = await client.project(projectId); + + if (!project) { + return null; + } + + // Get basic project info + const projectResult = await getProjectById(projectId); + if (!projectResult) { + return null; + } + + // Get last applied template + let lastAppliedTemplate; + try { + const template = await (project as { lastAppliedTemplate?: { id: string; name: string } }).lastAppliedTemplate; + if (template) { + lastAppliedTemplate = { + id: template.id, + name: template.name, + }; + } + } catch { + // Template not available + } + + // Get milestones + const milestones: Array<{ id: string; name: string }> = []; + try { + const projectMilestones = await project.projectMilestones(); + const milestonesList = await projectMilestones.nodes; + for (const milestone of milestonesList) { + milestones.push({ + id: milestone.id, + name: milestone.name, + }); + } + } catch { + // Milestones not available + } + + // Get issues + const issues: Array<{ id: string; identifier: string; title: string }> = []; + try { + const projectIssues = await project.issues(); + const issuesList = await projectIssues.nodes; + for (const issue of issuesList) { + issues.push({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + }); + } + } catch { + // Issues not available + } + + return { + project: projectResult, + lastAppliedTemplate, + milestones, + issues, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch project details: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get full project details with all relationships (OPTIMIZED) + * + * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): + * Uses custom GraphQL query to avoid N+1 pattern (~10 API calls -> 1 call) + * Fetches all project data upfront instead of lazy loading via SDK + * + * @param projectId - Project UUID + * @returns Complete project details or null if not found + */ +export async function getFullProjectDetails(projectId: string): Promise<{ + project: ProjectResult; + lastAppliedTemplate?: { id: string; name: string }; + milestones: Array<{ id: string; name: string }>; + issues: Array<{ id: string; identifier: string; title: string }>; +} | null> { + try { + const client = getLinearClient(); + + // ======================================== + // CUSTOM GRAPHQL QUERY - ALL RELATIONS UPFRONT + // ======================================== + const projectQuery = ` + query GetFullProject($projectId: String!) { + project(id: $projectId) { + id + name + description + content + url + state + + initiatives { + nodes { + id + name + } + } + + teams { + nodes { + id + name + } + } + + lastAppliedTemplate { + id + name + } + + projectMilestones { + nodes { + id + name + } + } + + issues { + nodes { + id + identifier + title + } + } + } + } + `; + + const response: any = await client.client.rawRequest(projectQuery, { projectId }); + const projectData = response.data?.project; + + if (!projectData) { + return null; + } + + // Map GraphQL response to ProjectResult and related data (no awaits needed!) + const initiative = projectData.initiatives?.nodes?.[0] + ? { + id: projectData.initiatives.nodes[0].id, + name: projectData.initiatives.nodes[0].name, + } + : undefined; + + const team = projectData.teams?.nodes?.[0] + ? { + id: projectData.teams.nodes[0].id, + name: projectData.teams.nodes[0].name, + } + : undefined; + + const lastAppliedTemplate = projectData.lastAppliedTemplate + ? { + id: projectData.lastAppliedTemplate.id, + name: projectData.lastAppliedTemplate.name, + } + : undefined; + + const milestones = (projectData.projectMilestones?.nodes || []).map((milestone: any) => ({ + id: milestone.id, + name: milestone.name, + })); + + const issues = (projectData.issues?.nodes || []).map((issue: any) => ({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + })); + + return { + project: { + id: projectData.id, + name: projectData.name, + description: projectData.description || undefined, + content: projectData.content || undefined, + url: projectData.url, + state: projectData.state, + initiative, + team, + }, + lastAppliedTemplate, + milestones, + issues, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch project details: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get all project statuses from the organization + */ +export async function getAllProjectStatuses(): Promise<ProjectStatus[]> { + try { + const client = getLinearClient(); + const organization = await client.organization; + const statuses = await organization.projectStatuses; + + return statuses.map((status: { id: string; name: string; type: string; color: string; description?: string; position: number }) => ({ + id: status.id, + name: status.name, + type: status.type as 'planned' | 'started' | 'paused' | 'completed' | 'canceled', + color: status.color, + description: status.description || undefined, + position: status.position, + })); + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch project statuses: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get a single project status by ID + */ +export async function getProjectStatusById(statusId: string): Promise<ProjectStatus | null> { + try { + const client = getLinearClient(); + const status = await client.projectStatus(statusId); + + if (!status) { + return null; + } + + return { + id: status.id, + name: status.name, + type: status.type as 'planned' | 'started' | 'paused' | 'completed' | 'canceled', + color: status.color, + description: status.description || undefined, + position: status.position, + }; + } catch (error) { + return null; + } +} + +/** + * Validate that a project exists + */ +export async function validateProjectExists( + projectId: string +): Promise<{ valid: boolean; name?: string; error?: string }> { + try { + const client = getLinearClient(); + const project = await client.project(projectId); + + if (!project) { + return { + valid: false, + error: `Project with ID "${projectId}" not found`, + }; + } + + return { + valid: true, + name: project.name, + }; + } catch (error) { + return { + valid: false, + error: `Failed to validate project: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } +} + +/** + * Create a project milestone + */ +export async function createProjectMilestone( + projectId: string, + input: MilestoneCreateInput +): Promise<{ id: string; name: string }> { + try { + const client = getLinearClient(); + + // Format target date if provided + const targetDate = input.targetDate ? input.targetDate.toISOString() : undefined; + + const payload = await client.createProjectMilestone({ + projectId, + name: input.name, + description: input.description, + targetDate, + }); + + const milestone = await payload.projectMilestone; + if (!milestone) { + throw new Error('Failed to create milestone: No milestone returned from API'); + } + + return { + id: milestone.id, + name: milestone.name, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to create milestone: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get all milestones for a project + */ +export async function getProjectMilestones(projectId: string): Promise<ProjectMilestone[]> { + try { + const client = getLinearClient(); + const project = await client.project(projectId); + + if (!project) { + throw new Error(`Project not found: ${projectId}`); + } + + const milestones = await project.projectMilestones(); + const result: ProjectMilestone[] = []; + + for (const milestone of milestones.nodes) { + result.push({ + id: milestone.id, + name: milestone.name, + description: milestone.description || undefined, + targetDate: milestone.targetDate || undefined, + }); + } + + return result; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch project milestones: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Create an external link for a project or initiative + */ +export async function createExternalLink(input: ExternalLinkCreateInput): Promise<ExternalLink> { + try { + const client = getLinearClient(); + + const payload = await client.createEntityExternalLink({ + url: input.url, + label: input.label, + projectId: input.projectId, + initiativeId: input.initiativeId, + sortOrder: input.sortOrder, + }); + + const link = await payload.entityExternalLink; + if (!link) { + throw new Error('Failed to create external link: No link returned from API'); + } + + const creator = await link.creator; + + return { + id: link.id, + url: link.url, + label: link.label, + sortOrder: link.sortOrder, + creatorId: creator?.id ?? '', + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to create external link: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get all external links for a project + */ +export async function getProjectExternalLinks(projectId: string): Promise<ExternalLink[]> { + try { + const client = getLinearClient(); + const project = await client.project(projectId); + + if (!project) { + throw new Error(`Project not found: ${projectId}`); + } + + const links = await project.externalLinks(); + const result: ExternalLink[] = []; + + for (const link of links.nodes) { + const creator = await link.creator; + result.push({ + id: link.id, + url: link.url, + label: link.label, + sortOrder: link.sortOrder, + creatorId: creator?.id ?? '', + }); + } + + // Sort by sort order + return result.sort((a, b) => a.sortOrder - b.sortOrder); + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch external links: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Delete an external link + */ +export async function deleteExternalLink(id: string): Promise<boolean> { + try { + const client = getLinearClient(); + const result = await client.deleteEntityExternalLink(id); + return result.success; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to delete external link: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * M23: Project Dependency Management + * + * Create a project relation (dependency) + * Note: Linear API uses type: "dependency" with anchor-based semantics + * - anchorType: which part of source project ("start" or "end") + * - relatedAnchorType: which part of target project ("start" or "end") + */ +export async function createProjectRelation( + client: SDKClient, + input: ProjectRelationCreateInput +): Promise<ProjectRelation> { + try { + // GraphQL mutation with inline fragment for ProjectRelation fields + const mutation = ` + mutation CreateProjectRelation($input: ProjectRelationCreateInput!) { + projectRelationCreate(input: $input) { + success + projectRelation { + id + type + anchorType + relatedAnchorType + createdAt + updatedAt + project { + id + name + } + relatedProject { + id + name + } + } + } + } + `; + + const result = await client.client.rawRequest(mutation, { + input: { + type: 'dependency', // Always "dependency" (only valid value) + projectId: input.projectId, + relatedProjectId: input.relatedProjectId, + anchorType: input.anchorType, + relatedAnchorType: input.relatedAnchorType, + }, + }); + + const data = result.data as { + projectRelationCreate: { + success: boolean; + projectRelation: ProjectRelation; + }; + }; + + if (!data.projectRelationCreate.success) { + throw new Error('Failed to create project relation'); + } + + return data.projectRelationCreate.projectRelation; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to create project relation: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Delete a project relation by ID + */ +export async function deleteProjectRelation( + client: SDKClient, + relationId: string +): Promise<boolean> { + try { + const mutation = ` + mutation DeleteProjectRelation($id: String!) { + projectRelationDelete(id: $id) { + success + } + } + `; + + const result = await client.client.rawRequest(mutation, { + id: relationId, + }); + + const data = result.data as { + projectRelationDelete: { + success: boolean; + }; + }; + + return data.projectRelationDelete.success; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to delete project relation: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Fetch all project relations for a given project + * Returns both "depends on" and "blocks" relations + */ +export async function getProjectRelations( + client: SDKClient, + projectId: string +): Promise<ProjectRelation[]> { + try { + // Query to fetch project relations using the .relations() method + const query = ` + query GetProjectRelations($projectId: String!) { + project(id: $projectId) { + id + name + relations { + nodes { + id + type + anchorType + relatedAnchorType + createdAt + updatedAt + project { + id + name + } + relatedProject { + id + name + } + } + } + } + } + `; + + const result = await client.client.rawRequest(query, { + projectId, + }); + + const data = result.data as { + project: { + id: string; + name: string; + relations: { + nodes: ProjectRelation[]; + }; + }; + }; + + return data.project.relations.nodes; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch project relations: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} From 8cdaef0f72ee0e911e6324fb09a746c583127f2f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 11 Mar 2026 05:44:19 +0000 Subject: [PATCH 05/11] Add milestone-templates register.ts Continuation of C1 cli.ts split refactoring. https://claude.ai/code/session_01LE3Rf4kmo1TBCozoF14LCa --- src/commands/milestone-templates/register.ts | 127 +++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/commands/milestone-templates/register.ts diff --git a/src/commands/milestone-templates/register.ts b/src/commands/milestone-templates/register.ts new file mode 100644 index 0000000..080be41 --- /dev/null +++ b/src/commands/milestone-templates/register.ts @@ -0,0 +1,127 @@ +import { Command } from 'commander'; +import { listMilestoneTemplates } from './list.js'; +import { viewMilestoneTemplate } from './view.js'; +import { createTemplate } from './create.js'; +import { createTemplateInteractive } from './create-interactive.js'; +import { removeTemplate } from './remove.js'; +import { editTemplateInteractive } from './edit-interactive.js'; + +export function registerMilestoneTemplatesCommands(cli: Command): void { + const milestoneTemplates = cli + .command('milestone-templates') + .alias('mtmpl') + .description('Manage milestone templates for projects') + .action(() => { + milestoneTemplates.help(); + }); + + milestoneTemplates + .command('list') + .alias('ls') + .description('List all available milestone templates') + .option('-f, --format <type>', 'Output format: tsv, json') + .addHelpText('after', ` +Examples: + $ agent2linear milestone-templates list # List all templates (grouped by source) + $ agent2linear mtmpl ls # Same as 'list' (alias) + $ agent2linear milestone-templates list --format json # Output as JSON (flat list) + $ agent2linear milestone-templates list --format tsv # Output as TSV (flat list) + $ agent2linear mtmpl list -f tsv | cut -f1 # Get just template names +`) + .action(async (options?: { format?: 'tsv' | 'json' }) => { + await listMilestoneTemplates(options || {}); + }); + + milestoneTemplates + .command('view <name>') + .description('View details of a specific milestone template') + .addHelpText('after', ` +Examples: + $ agent2linear milestone-templates view basic-sprint + $ agent2linear mtmpl view product-launch +`) + .action(async (name: string) => { + await viewMilestoneTemplate(name); + }); + + milestoneTemplates + .command('create [name]') + .description('Create a new milestone template') + .option('-g, --global', 'Create in global scope (default)') + .option('-p, --project', 'Create in project scope') + .option('-d, --description <text>', 'Template description') + .option('-m, --milestone <spec>', 'Milestone spec (name:targetDate:description)', (value, previous: string[] = []) => [...previous, value], []) + .option('-I, --interactive', 'Use interactive mode') + .addHelpText('after', ` +Examples: + # Interactive mode (recommended) - name collected interactively + $ agent2linear milestone-templates create --interactive + $ agent2linear mtmpl create -I + + # Non-interactive mode - name required as argument + $ agent2linear milestone-templates create basic-sprint \\ + --description "Simple 2-week sprint" \\ + --milestone "Planning:+1d:Define sprint goals" \\ + --milestone "Development:+10d:Implementation phase" \\ + --milestone "Review:+14d:Code review and deployment" + + # Project scope + $ agent2linear milestone-templates create --project --interactive + +Note: Milestone spec format is "name:targetDate:description" + - name: Required + - targetDate: Optional (+7d, +2w, +1m, or ISO date) + - description: Optional (markdown supported) +`) + .action(async (name: string | undefined, options) => { + if (options.interactive) { + // In interactive mode, name is collected interactively + await createTemplateInteractive(options); + } else { + if (!name) { + console.error('❌ Error: Template name is required in non-interactive mode\n'); + console.error('Provide a name:'); + console.error(' $ agent2linear milestone-templates create my-template --milestone ...\n'); + console.error('Or use interactive mode:'); + console.error(' $ agent2linear milestone-templates create --interactive\n'); + process.exit(1); + } + await createTemplate(name, options); + } + }); + + milestoneTemplates + .command('edit <name>') + .description('Edit an existing milestone template (interactive only)') + .option('-g, --global', 'Edit in global scope') + .option('-p, --project', 'Edit in project scope') + .addHelpText('after', ` +Examples: + $ agent2linear milestone-templates edit basic-sprint + $ agent2linear mtmpl edit product-launch --global + +Note: If no scope is specified, the template will be edited in its current scope. +`) + .action(async (name: string, options) => { + await editTemplateInteractive(name, options); + }); + + milestoneTemplates + .command('remove <name>') + .alias('rm') + .description('Remove a milestone template') + .option('-g, --global', 'Remove from global scope') + .option('-p, --project', 'Remove from project scope') + .option('-y, --yes', 'Skip confirmation prompt') + .addHelpText('after', ` +Examples: + $ agent2linear milestone-templates remove basic-sprint + $ agent2linear mtmpl rm product-launch --yes + $ agent2linear milestone-templates remove my-sprint --project + +Note: If no scope is specified, the template will be removed from its current scope. +`) + .action(async (name: string, options) => { + await removeTemplate(name, options); + }); +} From 52509484bde9529521d8feb193e19d328fd0663e Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 11 Mar 2026 05:44:33 +0000 Subject: [PATCH 06/11] Add remaining register.ts files for command groups Adds registration modules for: colors, icons, issue-labels, project-labels, templates, and workflow-states commands. Completes C1 cli.ts split refactoring. https://claude.ai/code/session_01LE3Rf4kmo1TBCozoF14LCa --- src/commands/colors/register.ts | 14 +++++++ src/commands/icons/register.ts | 14 +++++++ src/commands/issue-labels/register.ts | 21 ++++++++++ src/commands/project-labels/register.ts | 21 ++++++++++ src/commands/templates/register.ts | 51 ++++++++++++++++++++++++ src/commands/workflow-states/register.ts | 22 ++++++++++ 6 files changed, 143 insertions(+) create mode 100644 src/commands/colors/register.ts create mode 100644 src/commands/icons/register.ts create mode 100644 src/commands/issue-labels/register.ts create mode 100644 src/commands/project-labels/register.ts create mode 100644 src/commands/templates/register.ts create mode 100644 src/commands/workflow-states/register.ts diff --git a/src/commands/colors/register.ts b/src/commands/colors/register.ts new file mode 100644 index 0000000..aa6d1ef --- /dev/null +++ b/src/commands/colors/register.ts @@ -0,0 +1,14 @@ +import { Command } from 'commander'; +import { listColors } from './list.js'; +import { viewColor } from './view.js'; +import { extractColors } from './extract.js'; + +export function registerColorsCommands(cli: Command): void { + const colors = cli + .command('colors') + .description('Browse and manage colors'); + + listColors(colors); + viewColor(colors); + extractColors(colors); +} diff --git a/src/commands/icons/register.ts b/src/commands/icons/register.ts new file mode 100644 index 0000000..2797309 --- /dev/null +++ b/src/commands/icons/register.ts @@ -0,0 +1,14 @@ +import { Command } from 'commander'; +import { listIcons } from './list.js'; +import { viewIcon } from './view.js'; +import { extractIcons } from './extract.js'; + +export function registerIconsCommands(cli: Command): void { + const icons = cli + .command('icons') + .description('Browse and manage icons'); + + listIcons(icons); + viewIcon(icons); + extractIcons(icons); +} diff --git a/src/commands/issue-labels/register.ts b/src/commands/issue-labels/register.ts new file mode 100644 index 0000000..2cfa107 --- /dev/null +++ b/src/commands/issue-labels/register.ts @@ -0,0 +1,21 @@ +import { Command } from 'commander'; +import { listIssueLabels } from './list.js'; +import { viewIssueLabel } from './view.js'; +import { createIssueLabelCommand } from './create.js'; +import { updateIssueLabelCommand } from './update.js'; +import { deleteIssueLabelCommand } from './delete.js'; +import { syncIssueLabelAliases } from './sync-aliases.js'; + +export function registerIssueLabelsCommands(cli: Command): void { + const issueLabels = cli + .command('issue-labels') + .alias('ilbl') + .description('Manage issue labels'); + + listIssueLabels(issueLabels); + viewIssueLabel(issueLabels); + createIssueLabelCommand(issueLabels); + updateIssueLabelCommand(issueLabels); + deleteIssueLabelCommand(issueLabels); + syncIssueLabelAliases(issueLabels); +} diff --git a/src/commands/project-labels/register.ts b/src/commands/project-labels/register.ts new file mode 100644 index 0000000..9b01fe7 --- /dev/null +++ b/src/commands/project-labels/register.ts @@ -0,0 +1,21 @@ +import { Command } from 'commander'; +import { listProjectLabels } from './list.js'; +import { viewProjectLabel } from './view.js'; +import { createProjectLabelCommand } from './create.js'; +import { updateProjectLabelCommand } from './update.js'; +import { deleteProjectLabelCommand } from './delete.js'; +import { syncProjectLabelAliases } from './sync-aliases.js'; + +export function registerProjectLabelsCommands(cli: Command): void { + const projectLabels = cli + .command('project-labels') + .alias('plbl') + .description('Manage project labels'); + + listProjectLabels(projectLabels); + viewProjectLabel(projectLabels); + createProjectLabelCommand(projectLabels); + updateProjectLabelCommand(projectLabels); + deleteProjectLabelCommand(projectLabels); + syncProjectLabelAliases(projectLabels); +} diff --git a/src/commands/templates/register.ts b/src/commands/templates/register.ts new file mode 100644 index 0000000..0c4da24 --- /dev/null +++ b/src/commands/templates/register.ts @@ -0,0 +1,51 @@ +import { Command } from 'commander'; +import { listTemplates } from './list.js'; +import { viewTemplate } from './view.js'; + +export function registerTemplatesCommands(cli: Command): void { + const templates = cli + .command('templates') + .alias('tmpl') + .description('Manage Linear templates') + .action(() => { + templates.help(); + }); + + templates + .command('list [type]') + .alias('ls') + .description('List all templates or filter by type (issue/project)') + .option('-I, --interactive', 'Use interactive mode for browsing') + .option('-w, --web', 'Open Linear templates page in browser') + .option('-f, --format <type>', 'Output format: tsv, json') + .addHelpText('after', ` +Examples: + $ agent2linear templates list # List all templates (grouped by type) + $ agent2linear tmpl ls # Same as 'list' (alias) + $ agent2linear templates list issues # List only issue templates + $ agent2linear templates list projects # List only project templates + $ agent2linear templates list --interactive # Browse interactively + $ agent2linear templates list --web # Open in browser + $ agent2linear templates list --format json # Output as JSON (flat list) + $ agent2linear templates list --format tsv # Output as TSV (flat list) + $ agent2linear tmpl list -f tsv | grep issue # Filter issue templates +`) + .action(async (type?: string, options?: { interactive?: boolean; web?: boolean; format?: 'tsv' | 'json' }) => { + await listTemplates(type, options || {}); + }); + + templates + .command('view <id>') + .description('View details of a specific template') + .option('-w, --web', 'Open templates page in browser (templates do not have individual URLs)') + .addHelpText('after', ` +Examples: + $ agent2linear templates view template_abc123 + $ agent2linear tmpl view template_xyz789 + $ agent2linear templates view template_abc123 --web + $ agent2linear tmpl view mytemplate --web +`) + .action(async (id: string, options) => { + await viewTemplate(id, options); + }); +} diff --git a/src/commands/workflow-states/register.ts b/src/commands/workflow-states/register.ts new file mode 100644 index 0000000..6b652c7 --- /dev/null +++ b/src/commands/workflow-states/register.ts @@ -0,0 +1,22 @@ +import { Command } from 'commander'; +import { listWorkflowStates } from './list.js'; +import { viewWorkflowState } from './view.js'; +import { createWorkflowStateCommand } from './create.js'; +import { updateWorkflowStateCommand } from './update.js'; +import { deleteWorkflowStateCommand } from './delete.js'; +import { syncWorkflowStateAliases } from './sync-aliases.js'; + +export function registerWorkflowStatesCommands(cli: Command): void { + const workflowStates = cli + .command('workflow-states') + .alias('wstate') + .alias('ws') + .description('Manage workflow states (issue statuses)'); + + listWorkflowStates(workflowStates); + viewWorkflowState(workflowStates); + createWorkflowStateCommand(workflowStates); + updateWorkflowStateCommand(workflowStates); + deleteWorkflowStateCommand(workflowStates); + syncWorkflowStateAliases(workflowStates); +} From 73f1ffd41d33be029d5d8c4963c11d73606d11d2 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 11 Mar 2026 05:44:46 +0000 Subject: [PATCH 07/11] Add cache register.ts https://claude.ai/code/session_01LE3Rf4kmo1TBCozoF14LCa --- src/commands/cache/register.ts | 69 ++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/commands/cache/register.ts diff --git a/src/commands/cache/register.ts b/src/commands/cache/register.ts new file mode 100644 index 0000000..0b13373 --- /dev/null +++ b/src/commands/cache/register.ts @@ -0,0 +1,69 @@ +import { Command } from 'commander'; +import { showCacheStats } from './stats.js'; +import { clearCache } from './clear.js'; + +export function registerCacheCommands(cli: Command): void { + const cache = cli + .command('cache') + .description('Manage entity cache') + .addHelpText('before', ` +The cache system reduces API calls by storing frequently accessed entities in memory: +- Teams +- Initiatives +- Members +- Templates +- Issue Labels +- Project Labels + +Cache configuration can be managed via config commands: +- \`entityCacheMinTTL\`: Cache time-to-live in minutes (default: 60) +- \`enableEntityCache\`: Enable/disable entity caching (default: true) +- \`enableSessionCache\`: Enable/disable in-memory cache (default: true) +- \`enableBatchFetching\`: Enable/disable parallel API calls (default: true) +- \`prewarmCacheOnCreate\`: Auto-prewarm on project create (default: true) +`) + .addHelpText('after', ` +Examples: + $ agent2linear cache stats # View cache statistics + $ agent2linear cache clear # Clear all cached entities + $ agent2linear cache clear --entity teams # Clear specific entity type + +Related Commands: + $ agent2linear config set entityCacheMinTTL 120 # Set 2-hour cache TTL + $ agent2linear config set enableEntityCache false # Disable caching +`) + .action(() => { + cache.help(); + }); + + cache + .command('stats') + .description('Show cache statistics') + .addHelpText('after', ` +Examples: + $ agent2linear cache stats # Display cache status and configuration + +This will show: + • Cache configuration (enabled/disabled features) + • Entity cache status (cached items, age) + • Cache TTL settings +`) + .action(async () => { + await showCacheStats(); + }); + + cache + .command('clear') + .description('Clear cached entities') + .option('--entity <type>', 'Clear specific entity type (teams, initiatives, members, templates, issue-labels, project-labels)') + .addHelpText('after', ` +Examples: + $ agent2linear cache clear # Clear all cached entities + $ agent2linear cache clear --entity teams # Clear only teams cache + +Cache will be automatically repopulated on next access. +`) + .action(async (options) => { + await clearCache(options); + }); +} From 507badb39fd44e479a0bd4103a362005c29c3e55 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 11 Mar 2026 05:44:59 +0000 Subject: [PATCH 08/11] Add cycles register.ts https://claude.ai/code/session_01LE3Rf4kmo1TBCozoF14LCa --- src/commands/cycles/register.ts | 61 +++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/commands/cycles/register.ts diff --git a/src/commands/cycles/register.ts b/src/commands/cycles/register.ts new file mode 100644 index 0000000..27b980d --- /dev/null +++ b/src/commands/cycles/register.ts @@ -0,0 +1,61 @@ +import { Command } from 'commander'; +import { listCyclesCommand } from './list.js'; +import { viewCycleCommand } from './view.js'; +import { syncCycleAliasesCore } from './sync-aliases.js'; + +export function registerCyclesCommands(cli: Command): void { + const cycles = cli + .command('cycles') + .alias('cycle') + .description('Manage Linear cycles (sprints)') + .action(() => { + cycles.help(); + }); + + cycles + .command('list') + .alias('ls') + .description('List cycles') + .option('--team <id|alias>', 'Filter by team') + .option('-f, --format <type>', 'Output format: json, tsv') + .addHelpText('after', ` +Examples: + $ agent2linear cycles list # List cycles for default team + $ agent2linear cycles list --team backend # List cycles for specific team + $ agent2linear cycles list --format json # JSON output +`) + .action(async (options) => { + await listCyclesCommand(options); + }); + + cycles + .command('view <id>') + .description('View cycle details') + .option('--json', 'Output as JSON') + .addHelpText('after', ` +Examples: + $ agent2linear cycles view cycle_abc123 + $ agent2linear cycles view sprint-1 # Using alias + $ agent2linear cycles view sprint-1 --json # JSON output +`) + .action(async (id, options) => { + await viewCycleCommand(id, options); + }); + + cycles + .command('sync-aliases') + .description('Create aliases for all cycles') + .option('-g, --global', 'Create aliases in global config') + .option('-p, --project', 'Create aliases in project config') + .option('--dry-run', 'Preview aliases without creating them') + .option('-f, --force', 'Overwrite existing aliases') + .option('--no-auto-suffix', 'Disable auto-numbering for duplicate slugs') + .addHelpText('after', ` +Examples: + $ agent2linear cycles sync-aliases --global # Create global aliases + $ agent2linear cycles sync-aliases --dry-run # Preview changes +`) + .action(async (options) => { + await syncCycleAliasesCore(options); + }); +} From b955b9fe354ef73a9ec26b4d90de0fd8db23ffa3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 11 Mar 2026 05:46:15 +0000 Subject: [PATCH 09/11] Refactor cli.ts: split into per-entity register.ts files (C1) Reduces cli.ts from 1831 to 176 lines by extracting command registration into 17 dedicated register.ts files under each command directory. Global options and top-level commands remain in cli.ts. https://claude.ai/code/session_01LE3Rf4kmo1TBCozoF14LCa --- src/cli.ts | 1737 ++-------------------------------------------------- 1 file changed, 41 insertions(+), 1696 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f49b3c2..0c6660e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,86 +1,30 @@ -import { Command, Option, Argument } from 'commander'; +import { Command } from 'commander'; import { whoamiCommand } from './commands/whoami.js'; import { doctorCommand } from './commands/doctor.js'; -import { listCyclesCommand } from './commands/cycles/list.js'; -import { viewCycleCommand } from './commands/cycles/view.js'; -import { syncCycleAliasesCore } from './commands/cycles/sync-aliases.js'; -import { commentIssueCommand } from './commands/issue/comment.js'; -import { listConfig } from './commands/config/list.js'; -import { getConfigValue } from './commands/config/get.js'; -import type { ConfigKey } from './lib/config.js'; -import { setConfig } from './commands/config/set.js'; -import { unsetConfig } from './commands/config/unset.js'; -import { editConfig } from './commands/config/edit.js'; -import { listInitiatives } from './commands/initiatives/list.js'; -import { viewInitiative } from './commands/initiatives/view.js'; -import { selectInitiative } from './commands/initiatives/select.js'; -import { setInitiative } from './commands/initiatives/set.js'; -import { syncInitiativeAliases } from './commands/initiatives/sync-aliases.js'; -import { createProjectCommand } from './commands/project/create.js'; -import { viewProject } from './commands/project/view.js'; -import { updateProjectCommand } from './commands/project/update.js'; -import { listProjectsCommand } from './commands/project/list.js'; -import { listTeams } from './commands/teams/list.js'; -import { selectTeam } from './commands/teams/select.js'; -import { setTeam } from './commands/teams/set.js'; -import { viewTeam } from './commands/teams/view.js'; -import { syncTeamAliases } from './commands/teams/sync-aliases.js'; -import { listProjectStatuses } from './commands/project-status/list.js'; -import { viewProjectStatus } from './commands/project-status/view.js'; -import { syncProjectStatusAliases } from './commands/project-status/sync-aliases.js'; -import { addAliasCommand } from './commands/alias/add.js'; -import { listAliasCommand } from './commands/alias/list.js'; -import { removeAliasCommand } from './commands/alias/remove.js'; -import { getAliasCommand } from './commands/alias/get.js'; -import { editAlias } from './commands/alias/edit.js'; -import { aliasSyncCommand } from './commands/alias/sync.js'; -import { clearAliasCommand } from './commands/alias/clear.js'; -import { listTemplates } from './commands/templates/list.js'; -import { viewTemplate } from './commands/templates/view.js'; -import { listMembers } from './commands/members/list.js'; -import { syncMemberAliases } from './commands/members/sync-aliases.js'; -import { listMilestoneTemplates } from './commands/milestone-templates/list.js'; -import { viewMilestoneTemplate } from './commands/milestone-templates/view.js'; -import { createTemplate } from './commands/milestone-templates/create.js'; -import { createTemplateInteractive } from './commands/milestone-templates/create-interactive.js'; -import { removeTemplate } from './commands/milestone-templates/remove.js'; -import { editTemplateInteractive } from './commands/milestone-templates/edit-interactive.js'; -import { addMilestones } from './commands/project/add-milestones.js'; -import { listWorkflowStates } from './commands/workflow-states/list.js'; -import { viewWorkflowState } from './commands/workflow-states/view.js'; -import { createWorkflowStateCommand } from './commands/workflow-states/create.js'; -import { updateWorkflowStateCommand } from './commands/workflow-states/update.js'; -import { deleteWorkflowStateCommand } from './commands/workflow-states/delete.js'; -import { syncWorkflowStateAliases } from './commands/workflow-states/sync-aliases.js'; -import { listIssueLabels } from './commands/issue-labels/list.js'; -import { viewIssueLabel } from './commands/issue-labels/view.js'; -import { createIssueLabelCommand } from './commands/issue-labels/create.js'; -import { updateIssueLabelCommand } from './commands/issue-labels/update.js'; -import { deleteIssueLabelCommand } from './commands/issue-labels/delete.js'; -import { syncIssueLabelAliases } from './commands/issue-labels/sync-aliases.js'; -import { listProjectLabels } from './commands/project-labels/list.js'; -import { viewProjectLabel } from './commands/project-labels/view.js'; -import { createProjectLabelCommand } from './commands/project-labels/create.js'; -import { updateProjectLabelCommand } from './commands/project-labels/update.js'; -import { deleteProjectLabelCommand } from './commands/project-labels/delete.js'; -import { syncProjectLabelAliases } from './commands/project-labels/sync-aliases.js'; -import { listIcons } from './commands/icons/list.js'; -import { viewIcon } from './commands/icons/view.js'; -import { extractIcons } from './commands/icons/extract.js'; -import { listColors } from './commands/colors/list.js'; -import { viewColor } from './commands/colors/view.js'; -import { extractColors } from './commands/colors/extract.js'; -import { showCacheStats } from './commands/cache/stats.js'; -import { clearCache } from './commands/cache/clear.js'; import { setup } from './commands/setup.js'; -import { viewIssue } from './commands/issue/view.js'; -import { createIssueCommand } from './commands/issue/create.js'; -import { updateIssueCommand } from './commands/issue/update.js'; -import { registerIssueListCommand } from './commands/issue/list.js'; import { setLogLevel } from './lib/logger.js'; import { setNoColor } from './lib/output.js'; +// Per-entity command registrations +import { registerInitiativesCommands } from './commands/initiatives/register.js'; +import { registerProjectCommands } from './commands/project/register.js'; +import { registerIssueCommands } from './commands/issue/register.js'; +import { registerTeamsCommands } from './commands/teams/register.js'; +import { registerMembersCommands } from './commands/members/register.js'; +import { registerProjectStatusCommands } from './commands/project-status/register.js'; +import { registerAliasCommands } from './commands/alias/register.js'; +import { registerMilestoneTemplatesCommands } from './commands/milestone-templates/register.js'; +import { registerTemplatesCommands } from './commands/templates/register.js'; +import { registerConfigCommands } from './commands/config/register.js'; +import { registerWorkflowStatesCommands } from './commands/workflow-states/register.js'; +import { registerIssueLabelsCommands } from './commands/issue-labels/register.js'; +import { registerProjectLabelsCommands } from './commands/project-labels/register.js'; +import { registerIconsCommands } from './commands/icons/register.js'; +import { registerColorsCommands } from './commands/colors/register.js'; +import { registerCacheCommands } from './commands/cache/register.js'; +import { registerCyclesCommands } from './commands/cycles/register.js'; + const cli = new Command(); cli @@ -100,472 +44,26 @@ cli cli.help(); }); -// Initiatives commands -const initiatives = cli - .command('initiatives') - .alias('init') - .description('Manage Linear initiatives') - .action(() => { - initiatives.help(); - }); - -initiatives - .command('list') - .alias('ls') - .description('List all initiatives') - .option('-I, --interactive', 'Use interactive mode for browsing') - .option('-w, --web', 'Open Linear in browser to view initiatives') - .option('-f, --format <type>', 'Output format: tsv, json') - .addHelpText('after', ` -Examples: - $ agent2linear initiatives list # Print list to stdout (default TSV) - $ agent2linear init ls # Same as 'list' (alias) - $ agent2linear initiatives list --interactive # Browse interactively - $ agent2linear initiatives list --web # Open in browser - $ agent2linear initiatives list --format json # Output as JSON - $ agent2linear initiatives list --format tsv # Output as TSV (explicit) - $ agent2linear init list -f json | jq '.[0]' # Pipe to jq -`) - .action(async (options) => { - await listInitiatives(options); - }); - -initiatives - .command('view [id]') - .description('View details of a specific initiative (format: init_xxx)') - .option('-I, --interactive', 'Use interactive mode to select initiative') - .option('-w, --web', 'Open initiative in browser instead of displaying in terminal') - .addHelpText('after', ` -Examples: - $ agent2linear initiatives view init_abc123 - $ agent2linear init view init_abc123 - $ agent2linear initiatives view init_abc123 --web - $ agent2linear init view myalias --web - $ agent2linear init view --interactive # Select from list - $ agent2linear init view -I # Select and view in terminal - $ agent2linear init view -I --web # Select and open in browser -`) - .action(async (id: string | undefined, options) => { - await viewInitiative(id, options); - }); - -initiatives - .command('select') - .description('Interactively select a default initiative') - .option('-g, --global', 'Save to global config (default)') - .option('-p, --project', 'Save to project config') - .addHelpText('after', ` -Examples: - $ agent2linear initiatives select # Interactive selection - $ agent2linear initiatives select --project # Save to project config -`) - .action(async (options) => { - await selectInitiative(options); - }); - -initiatives - .command('set <id>') - .description('Set default initiative by ID (non-interactive)') - .option('-g, --global', 'Save to global config (default)') - .option('-p, --project', 'Save to project config') - .addHelpText('after', ` -Examples: - $ agent2linear initiatives set init_abc123 - $ agent2linear initiatives set backend # Using alias - $ agent2linear initiatives set init_xyz789 --project -`) - .action(async (id: string, options) => { - await setInitiative(id, options); - }); - -syncInitiativeAliases(initiatives); - -// Project commands -const project = cli - .command('project') - .alias('proj') - .description('Manage Linear projects') - .action(() => { - project.help(); - }); - -project - .command('create') - .alias('new') - .description('Create a new project') - .option('-I, --interactive', 'Use interactive mode') - .option('-w, --web', 'Open Linear in browser to create project') - .option('-t, --title <title>', 'Project title (minimum 3 characters)') - .option('-d, --description <description>', 'Project description') - .option('-i, --initiative <id>', 'Initiative ID to link project to (format: init_xxx)') - .option('--team <id>', 'Team ID to assign project to (format: team_xxx)') - .option('--template <id>', 'Template ID to use for project creation (format: template_xxx)') - .option('--status <id>', 'Project status ID (format: status_xxx)') - .option('--content <markdown>', 'Project content as markdown') - .option('--content-file <path>', 'Path to file containing project content (markdown)') - .option('--icon <icon>', 'Project icon name (e.g., "Joystick", "Tree", "Skull" - capitalized)') - .option('--color <hex>', 'Project color (hex code like #FF6B6B)') - .option('--lead <id>', 'Project lead user ID (format: user_xxx)') - .option('--no-lead', 'Do not assign a project lead (overrides auto-assign)') - .option('--labels <ids>', 'Comma-separated project label IDs (e.g., label_1,label_2)') - .option('--link <url-and-label>', 'External link as "URL" or "URL|Label" (can be specified multiple times)', (value, previous: string[] = []) => [...previous, value], []) - .option('--converted-from <id>', 'Issue ID this project was converted from (format: issue_xxx)') - .option('--start-date <date>', 'Planned start date. Formats: YYYY-MM-DD, Quarter (2025-Q1, Q1 2025), Month (2025-01, Jan 2025), Half-year (2025-H1), Year (2025). Resolution auto-detected from format.') - .addOption( - new Option('--start-date-resolution <resolution>', 'Override auto-detected resolution (advanced). Only needed when date format doesn\'t match your intent. Example: --start-date 2025-01-15 --start-date-resolution quarter (mid-month date representing Q1)') - .choices(['month', 'quarter', 'halfYear', 'year']) - ) - .option('--target-date <date>', 'Target completion date. Formats: YYYY-MM-DD, Quarter (2025-Q1, Q1 2025), Month (2025-01, Jan 2025), Half-year (2025-H1), Year (2025). Resolution auto-detected from format.') - .addOption( - new Option('--target-date-resolution <resolution>', 'Override auto-detected resolution (advanced). Only needed when date format doesn\'t match your intent. Example: --target-date 2025-01-15 --target-date-resolution quarter (mid-month date representing Q1)') - .choices(['month', 'quarter', 'halfYear', 'year']) - ) - .addOption( - new Option('--priority <priority>', 'Project priority') - .choices(['0', '1', '2', '3', '4']) - .argParser(parseInt) - ) - .option('--members <ids>', 'Comma-separated member user IDs (e.g., user_1,user_2)') - .option('--depends-on <projects>', 'Projects this depends on (comma-separated IDs/aliases) - end→start anchor') - .option('--blocks <projects>', 'Projects this blocks (comma-separated IDs/aliases) - creates dependencies where other projects depend on this') - .option('--dependency <spec>', 'Advanced: "project:myAnchor:theirAnchor" (repeatable)', (value, previous: string[] = []) => [...previous, value], []) - .option('--dry-run', 'Preview the payload without creating the project') - .addHelpText('after', ` -Examples: - Basic (auto-assigns you as lead): - $ agent2linear project create --title "My Project" --team team_xyz789 - $ agent2linear proj new --title "Quick Project" --team team_xyz789 # Same as 'create' (alias) - $ agent2linear project create --title "Q1 Goals" --initiative init_abc123 --team team_xyz789 - - With template: - $ agent2linear project create --title "API Project" --template template_abc123 --team team_xyz789 - - Lead assignment (by default, you are auto-assigned as lead): - $ agent2linear project create --title "My Project" --team team_xyz789 - # Auto-assigns current user as lead - - $ agent2linear project create --title "My Project" --team team_xyz789 --lead user_abc123 - # Assign specific user as lead - - $ agent2linear project create --title "My Project" --team team_xyz789 --no-lead - # No lead assignment - - $ agent2linear config set defaultAutoAssignLead false - # Disable auto-assign globally - - With additional fields: - $ agent2linear project create --title "Website Redesign" --team team_abc123 \\ - --icon "Tree" --color "#FF6B6B" --lead user_xyz789 \\ - --start-date "2025-01-15" \\ - --target-date "2025-03-31" \\ - --priority 2 - - Date formats (flexible, auto-detected resolution): - $ agent2linear project create --title "Q1 Initiative" --team team_abc123 --start-date "2025-Q1" - # Creates project with start date: 2025-01-01, resolution: quarter - - $ agent2linear project create --title "January Sprint" --team team_abc123 --start-date "Jan 2025" - # Creates project with start date: 2025-01-01, resolution: month - - $ agent2linear project create --title "2025 Strategy" --team team_abc123 \\ - --start-date "2025" --target-date "2025-Q4" - # Start: 2025-01-01 (year), Target: 2025-10-01 (quarter) - - With content and labels: - $ agent2linear project create --title "Q1 Planning" --team team_abc123 \\ - --content "# Goals\\n- Improve performance\\n- Add features" \\ - --labels "label_1,label_2" - - With content from file: - $ agent2linear project create --title "API Project" --team team_abc123 \\ - --content-file ./project-plan.md - - With dependencies (simple mode): - $ agent2linear project create --title "Frontend App" --team team_abc123 \\ - --depends-on "api-backend,infrastructure" \\ - --blocks "testing,deployment" - - With dependencies (advanced mode - custom anchors): - $ agent2linear project create --title "API v2" --team team_abc123 \\ - --dependency "backend-infra:end:start" \\ - --dependency "database-migration:start:end" - - Interactive mode: - $ agent2linear project create --interactive - - Open in browser: - $ agent2linear project create --web - -Field Value Formats: - --status status_xxx (Linear status ID) - --content Inline markdown text - --content-file Path to markdown file (mutually exclusive with --content) - --icon Capitalized icon name like "Joystick", "Tree", "Skull", "Email", "Checklist" - --color #FF6B6B (hex color code) - --lead user_xxx (Linear user ID) - --no-lead Flag to disable lead assignment - --labels label_1,label_2,label_3 (comma-separated) - --members user_1,user_2 (comma-separated) - --priority 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low - --depends-on proj1,proj2 (my end waits for their start) - --blocks proj1,proj2 (their end waits for my start) - --dependency project:myAnchor:theirAnchor (advanced: start|end) - -Date Formats (--start-date, --target-date): - Quarters: 2025-Q1, Q1 2025, q1-2025 (case-insensitive) - → Q1: 2025-01-01, Q2: 2025-04-01, Q3: 2025-07-01, Q4: 2025-10-01 - Half-years: 2025-H1, H1 2025, h1-2025 - → H1: 2025-01-01 (Jan-Jun), H2: 2025-07-01 (Jul-Dec) - Months: 2025-01, Jan 2025, January 2025, 2025-Dec - → First day of month (2025-01-01, 2025-12-01) - Years: 2025 - → First day of year (2025-01-01) - ISO dates: 2025-01-15, 2025-03-31 - → Specific day (no auto-detected resolution) - - Note: Resolution is auto-detected from format. The --*-resolution flags are optional - and only needed for advanced use cases where you want to override the auto-detection. - -Note: Set defaults with config: - $ agent2linear config set defaultProjectTemplate template_abc123 - $ agent2linear config set defaultAutoAssignLead true # Enable auto-assign (default) - $ agent2linear config set defaultAutoAssignLead false # Disable auto-assign - $ agent2linear teams select # Set default team -`) - .action(async options => { - await createProjectCommand(options); - }); - -project - .command('view <name-or-id>') - .description('View details of a specific project (by name, ID, or alias)') - .option('-w, --web', 'Open project in browser instead of displaying in terminal') - .option('-a, --auto-alias', 'Automatically create an alias if resolving by name') - .option('--desc', 'Show description preview (default 80 chars)') - .option('--desc-length <n>', 'Description preview length in characters (implies --desc)') - .option('--desc-full', 'Show full description (no truncation)') - .option('--no-desc', 'Hide description') - .addHelpText('after', ` -Examples: - $ agent2linear project view PRJ-123 # By ID - $ agent2linear proj view "My Project Name" # By exact name - $ agent2linear project view proj_abc123 --web # By ID, open in browser - $ agent2linear proj view myalias --web # By alias - $ agent2linear proj view "Project X" --auto-alias # Create alias automatically - $ agent2linear project view PRJ-123 --desc # Show 80-char description preview - $ agent2linear project view PRJ-123 --desc-full # Show full description -`) - .action(async (nameOrId: string, options) => { - await viewProject(nameOrId, options); - }); - -project - .command('update <name-or-id>') - .description('Update project properties') - .option('--status <name-or-id>', 'Project status (name, ID, or alias)') - .option('--name <name>', 'Rename project') - .option('--description <text>', 'Update description') - .option('--content <markdown>', 'Update content as markdown') - .option('--content-file <path>', 'Path to file containing project content (markdown)') - .option('--priority <0-4>', 'Priority level (0-4)', parseInt) - .option('--target-date <date>', 'Target completion date. Formats: YYYY-MM-DD, Quarter (2025-Q1, Q1 2025), Month (2025-01, Jan 2025), Half-year (2025-H1), Year (2025). Resolution auto-detected from format.') - .option('--start-date <date>', 'Estimated start date. Formats: YYYY-MM-DD, Quarter (2025-Q1, Q1 2025), Month (2025-01, Jan 2025), Half-year (2025-H1), Year (2025). Resolution auto-detected from format.') - .option('--color <hex>', 'Project color (hex code like #FF6B6B)') - .option('--icon <icon>', 'Project icon name (passed directly to Linear API)') - .option('--lead <id>', 'Project lead (user ID, alias, or email)') - .option('--members <ids>', 'Comma-separated member IDs, aliases, or emails') - .option('--labels <ids>', 'Comma-separated project label IDs or aliases') - .addOption(new Option('--start-date-resolution <resolution>', 'Override auto-detected resolution (advanced). Can be used alone to update resolution without changing date. Example: --start-date 2025-01-15 --start-date-resolution quarter').choices(['month', 'quarter', 'halfYear', 'year'])) - .addOption(new Option('--target-date-resolution <resolution>', 'Override auto-detected resolution (advanced). Can be used alone to update resolution without changing date. Example: --target-date 2025-01-15 --target-date-resolution quarter').choices(['month', 'quarter', 'halfYear', 'year'])) - .option('--link <url-and-label>', 'Add external link as "URL" or "URL|Label" (repeatable)', (value, previous: string[] = []) => [...previous, value], []) - .option('--remove-link <url>', 'Remove external link by exact URL match (repeatable)', (value, previous: string[] = []) => [...previous, value], []) - .option('--depends-on <projects>', 'Add "depends on" relations (comma-separated IDs/aliases)') - .option('--blocks <projects>', 'Add "blocks" relations (comma-separated IDs/aliases)') - .option('--dependency <spec>', 'Add dependency: "project:myAnchor:theirAnchor" (repeatable)', (value, previous: string[] = []) => [...previous, value], []) - .option('--remove-depends-on <projects>', 'Remove "depends on" relations (comma-separated IDs/aliases)') - .option('--remove-blocks <projects>', 'Remove "blocks" relations (comma-separated IDs/aliases)') - .option('--remove-dependency <project>', 'Remove all dependencies with project (repeatable)', (value, previous: string[] = []) => [...previous, value], []) - .option('-w, --web', 'Open project in browser after update') - .option('--dry-run', 'Preview the payload without updating the project') - .addHelpText('after', ` -Examples: - $ agent2linear project update "My Project" --status "In Progress" - $ agent2linear proj update proj_abc --status done --priority 3 - $ agent2linear proj update myalias --name "New Name" - - Update content from file: - $ agent2linear proj update "My Project" --content-file ./updated-plan.md - - Update with flexible date formats: - $ agent2linear proj update "Q1 Goals" --status in-progress --priority 2 --target-date "2025-Q1" - $ agent2linear proj update "My Project" --start-date "Jan 2025" --target-date "2025-H1" - $ agent2linear proj update "Annual Plan" --start-date "2025" --target-date "2025-12-31" - - Manage external links: - $ agent2linear proj update "My Project" --link "https://github.com/org/repo|GitHub" - $ agent2linear proj update "My Project" --remove-link "https://old-link.com" - $ agent2linear proj update "My Project" --link "https://new.com|New" --remove-link "https://old.com" - - Manage dependencies: - $ agent2linear proj update "My Project" --depends-on "api-backend,infrastructure" - $ agent2linear proj update "My Project" --blocks "frontend-app" - $ agent2linear proj update "My Project" --remove-depends-on "old-dep" - $ agent2linear proj update "My Project" --dependency "backend:end:start" --remove-depends-on "old-project" - - Open in browser after update: - $ agent2linear proj update "My Project" --priority 1 --web -`) - .action(async (nameOrId: string, options) => { - await updateProjectCommand(nameOrId, options); - }); - -project - .command('add-milestones <name-or-id>') - .description('Add milestones to a project using a milestone template') - .option('-t, --template <name>', 'Milestone template name') - .addHelpText('after', ` -Examples: - $ agent2linear project add-milestones PRJ-123 --template basic-sprint - $ agent2linear proj add-milestones "My Project" --template product-launch - $ agent2linear project add-milestones proj_abc123 -t basic-sprint - $ agent2linear project add-milestones myalias # Uses default template from config - -Note: Set default template with: - $ agent2linear config set defaultMilestoneTemplate basic-sprint -`) - .action(async (projectId: string, options) => { - await addMilestones(projectId, options); - }); - -// M23: Project Dependencies subcommands -const projectDeps = project - .command('dependencies') - .alias('deps') - .description('Manage project dependencies (depends-on/blocks relations)') - .action(() => { - projectDeps.help(); - }); - -projectDeps - .command('add <name-or-id>') - .description('Add dependency relations to a project') - .option('--depends-on <projects>', 'Projects this depends on (comma-separated IDs/aliases) - end→start anchor') - .option('--blocks <projects>', 'Projects this blocks (comma-separated IDs/aliases) - start→end anchor') - .option('--dependency <spec>', 'Advanced: "project:myAnchor:theirAnchor" (repeatable)', (value, previous: string[] = []) => [...previous, value], []) - .addHelpText('after', ` -Examples: - Simple mode (default anchors): - $ agent2linear project dependencies add "My Project" --depends-on "backend,database" - $ agent2linear proj deps add PRJ-123 --blocks "frontend,mobile" - - Advanced mode (custom anchors): - $ agent2linear project deps add "API v2" --dependency "backend:end:start" --dependency "db:start:end" - - Mixed mode: - $ agent2linear proj deps add myproject --depends-on "backend" --dependency "db:start:start" - -Note: - - --depends-on: Creates end→start relation (my end waits for their start) - - --blocks: Creates start→end relation (their end waits for my start) - - --dependency: Custom anchors (start|end) - - Supports project IDs, names, and aliases - - Self-referential dependencies are automatically skipped -`) - .action(async (nameOrId: string, options) => { - const { addProjectDependencies } = await import('./commands/project/dependencies/add.js'); - await addProjectDependencies(nameOrId, options); - }); - -projectDeps - .command('remove <name-or-id>') - .description('Remove dependency relations from a project') - .option('--depends-on <projects>', 'Remove "depends on" relations (comma-separated IDs/aliases)') - .option('--blocks <projects>', 'Remove "blocks" relations (comma-separated IDs/aliases)') - .option('--relation-id <id>', 'Remove by specific relation ID') - .option('--with <project>', 'Remove all relations with specified project') - .addHelpText('after', ` -Examples: - Remove by direction: - $ agent2linear project dependencies remove "My Project" --depends-on "backend" - $ agent2linear proj deps remove PRJ-123 --blocks "frontend,mobile" - - Remove by relation ID: - $ agent2linear proj deps remove "API v2" --relation-id "rel_abc123" - - Remove all relations with a project: - $ agent2linear project deps remove myproject --with "backend" - - Mixed removal: - $ agent2linear proj deps remove PRJ-123 --depends-on "backend" --blocks "frontend" - -Note: - - Provide at least one flag (--depends-on, --blocks, --relation-id, or --with) - - Use "list" command to find relation IDs -`) - .action(async (nameOrId: string, options) => { - const { removeProjectDependencies } = await import('./commands/project/dependencies/remove.js'); - await removeProjectDependencies(nameOrId, options); - }); - -projectDeps - .command('list <name-or-id>') - .alias('ls') - .description('List all dependency relations for a project') - .option('--direction <type>', 'Filter by direction: depends-on | blocks') - .addHelpText('after', ` -Examples: - List all dependencies: - $ agent2linear project dependencies list "My Project" - $ agent2linear proj deps ls PRJ-123 - - Filter by direction: - $ agent2linear proj deps list "API v2" --direction depends-on - $ agent2linear project deps ls myproject --direction blocks - -Output: - Shows both "depends-on" and "blocks" relations with: - - Related project names and IDs - - Anchor types (start/end) - - Semantic descriptions - - Relation IDs (for removal) -`) - .action(async (nameOrId: string, options) => { - const { listProjectDependencies } = await import('./commands/project/dependencies/list.js'); - await listProjectDependencies(nameOrId, options); - }); - -projectDeps - .command('clear <name-or-id>') - .description('Remove all dependency relations from a project') - .option('--direction <type>', 'Clear only specified direction: depends-on | blocks') - .option('-y, --yes', 'Skip confirmation prompt') - .addHelpText('after', ` -Examples: - Clear all dependencies (with confirmation): - $ agent2linear project dependencies clear "My Project" - $ agent2linear proj deps clear PRJ-123 - - Clear specific direction: - $ agent2linear proj deps clear "API v2" --direction depends-on - $ agent2linear project deps clear myproject --direction blocks - - Skip confirmation: - $ agent2linear proj deps clear PRJ-123 --yes - $ agent2linear project deps clear myproject --direction depends-on -y - -Warning: - This permanently deletes dependency relations. Use with caution. - Confirmation prompt shown unless --yes flag is provided. -`) - .action(async (nameOrId: string, options) => { - const { clearProjectDependencies } = await import('./commands/project/dependencies/clear.js'); - await clearProjectDependencies(nameOrId, options); - }); - -// Register project list command (M20) -listProjectsCommand(project); - -// Issues commands (stub - coming in v0.5.0) +// Register all entity command groups +registerInitiativesCommands(cli); +registerProjectCommands(cli); +registerTeamsCommands(cli); +registerMembersCommands(cli); +registerProjectStatusCommands(cli); +registerAliasCommands(cli); +registerMilestoneTemplatesCommands(cli); +registerTemplatesCommands(cli); +registerConfigCommands(cli); +registerWorkflowStatesCommands(cli); +registerIssueLabelsCommands(cli); +registerProjectLabelsCommands(cli); +registerIconsCommands(cli); +registerColorsCommands(cli); +registerCacheCommands(cli); +registerIssueCommands(cli); +registerCyclesCommands(cli); + +// Stub command groups (future releases) const issues = cli .command('issues') .alias('iss') @@ -592,402 +90,6 @@ issues console.log(' See MILESTONES.md for planned features and timeline.'); }); -// Teams commands -const teams = cli - .command('teams') - .alias('team') - .description('Manage Linear teams') - .action(() => { - teams.help(); - }); - -teams - .command('list') - .alias('ls') - .description('List all teams') - .option('-I, --interactive', 'Use interactive mode for browsing') - .option('-w, --web', 'Open Linear in browser to view teams') - .option('-f, --format <type>', 'Output format: tsv, json') - .addHelpText('after', ` -Examples: - $ agent2linear teams list # Print list to stdout (formatted) - $ agent2linear team ls # Same as 'list' (alias) - $ agent2linear teams list --interactive # Browse interactively - $ agent2linear teams list --web # Open in browser - $ agent2linear teams list --format json # Output as JSON - $ agent2linear teams list --format tsv # Output as TSV - $ agent2linear team list -f tsv | cut -f1 # Get just team IDs -`) - .action(async (options) => { - await listTeams(options); - }); - -teams - .command('select') - .description('Interactively select a default team') - .option('-g, --global', 'Save to global config (default)') - .option('-p, --project', 'Save to project config') - .addHelpText('after', ` -Examples: - $ agent2linear teams select # Interactive selection - $ agent2linear teams select --project # Save to project config -`) - .action(async (options) => { - await selectTeam(options); - }); - -teams - .command('set <id>') - .description('Set default team by ID (non-interactive)') - .option('-g, --global', 'Save to global config (default)') - .option('-p, --project', 'Save to project config') - .addHelpText('after', ` -Examples: - $ agent2linear teams set team_abc123 - $ agent2linear teams set eng # Using alias - $ agent2linear teams set team_abc123 --project -`) - .action(async (id: string, options) => { - await setTeam(id, options); - }); - -teams - .command('view <id>') - .description('View details of a specific team') - .option('-w, --web', 'Open team in browser instead of displaying in terminal') - .addHelpText('after', ` -Examples: - $ agent2linear teams view team_abc123 - $ agent2linear team view team_abc123 - $ agent2linear teams view team_abc123 --web - $ agent2linear team view eng --web -`) - .action(async (id: string, options) => { - await viewTeam(id, options); - }); - -syncTeamAliases(teams); - -// Members commands -const members = cli - .command('members') - .alias('users') - .description('Manage Linear members/users') - .action(() => { - members.help(); - }); - -members - .command('list') - .alias('ls') - .description('List members in your organization or team') - .option('-I, --interactive', 'Use interactive mode for browsing') - .option('-w, --web', 'Open Linear members page in browser') - .option('-f, --format <type>', 'Output format: tsv, json') - .option('--team <id>', 'Filter by team (uses default team if not specified)') - .option('--org-wide', 'List all organization members (ignore team filter)') - .option('--name <search>', 'Filter by name') - .option('--email <search>', 'Filter by email') - .option('--active', 'Show only active members') - .option('--inactive', 'Show only inactive members') - .option('--admin', 'Show only admin users') - .option('--columns <fields>', 'Comma-separated list of columns to display (e.g., "id,name,email")') - .addHelpText('after', ` -Examples: - $ agent2linear members list # List default team members - $ agent2linear users ls # Same as 'list' (alias) - $ agent2linear members list --org-wide # List all organization members - $ agent2linear members list --team team_abc123 # List specific team members - $ agent2linear members list --name John # Filter by name - $ agent2linear members list --email @acme.com # Filter by email domain - $ agent2linear members list --active # Show only active members - $ agent2linear members list --admin # Show only admins - $ agent2linear members list --interactive # Browse interactively - $ agent2linear members list --web # Open in browser - $ agent2linear members list --format json # Output as JSON - $ agent2linear members list --format tsv # Output as TSV - $ agent2linear members list -f tsv | cut -f1 # Get just member IDs - -Note: By default, uses your configured default team. Use --org-wide to see all members. - $ agent2linear config set defaultTeam team_xxx # Set default team -`) - .action(async (options) => { - await listMembers(options); - }); - -syncMemberAliases(members); - -// Project Status commands -const projectStatus = cli - .command('project-status') - .alias('pstatus') - .description('Manage Linear project statuses') - .action(() => { - projectStatus.help(); - }); - -projectStatus - .command('list') - .alias('ls') - .description('List all project statuses') - .option('-I, --interactive', 'Use interactive mode for browsing') - .option('-w, --web', 'Open Linear project settings in browser') - .option('-f, --format <type>', 'Output format: tsv, json') - .addHelpText('after', ` -Examples: - $ agent2linear project-status list # Print list to stdout (formatted) - $ agent2linear pstatus ls # Same as 'list' (alias) - $ agent2linear project-status list --interactive # Browse interactively - $ agent2linear project-status list --web # Open in browser - $ agent2linear project-status list --format json # Output as JSON - $ agent2linear project-status list --format tsv # Output as TSV - $ agent2linear pstatus list -f tsv | cut -f1 # Get just status IDs -`) - .action(async (options) => { - await listProjectStatuses(options); - }); - -projectStatus - .command('view <name-or-id>') - .description('View details of a specific project status') - .option('-w, --web', 'Open project settings in browser instead of displaying in terminal') - .addHelpText('after', ` -Examples: - $ agent2linear project-status view "In Progress" - $ agent2linear pstatus view status_abc123 - $ agent2linear project-status view planned --web - $ agent2linear pstatus view active-status --web # Using alias -`) - .action(async (nameOrId: string, options) => { - await viewProjectStatus(nameOrId, options); - }); - -projectStatus - .command('sync-aliases') - .description('Create aliases for all org project statuses') - .option('-g, --global', 'Save to global config (default)') - .option('-p, --project', 'Save to project config') - .option('--dry-run', 'Preview changes without applying them') - .option('--force', 'Override existing aliases') - .addHelpText('after', ` -Examples: - $ agent2linear project-status sync-aliases # Create global aliases - $ agent2linear pstatus sync-aliases --project # Create project-local aliases - $ agent2linear project-status sync-aliases --dry-run # Preview changes - $ agent2linear pstatus sync-aliases --force # Force override existing - -This command will create aliases for all project statuses in your workspace, -using the status name converted to lowercase with hyphens (e.g., "In Progress" → "in-progress"). -`) - .action(async (options) => { - await syncProjectStatusAliases(options); - }); - -// Alias commands -const alias = cli - .command('alias') - .description('Manage aliases for initiatives, teams, projects, project statuses, templates, and members') - .action(() => { - alias.help(); - }); - -alias - .command('add') - .addArgument( - new Argument('<type>', 'Entity type') - .choices(['initiative', 'team', 'project', 'project-status', 'issue-template', 'project-template', 'member', 'user', 'issue-label', 'project-label', 'workflow-state']) - ) - .addArgument(new Argument('<alias>', 'Alias name (no spaces)')) - .addArgument(new Argument('[id]', 'Linear ID (optional if using --email or --name for members)')) - .description('Add a new alias') - .option('-g, --global', 'Save to global config (default)') - .option('-p, --project', 'Save to project config') - .option('--skip-validation', 'Skip entity validation (faster)') - .option('--email <email>', 'Look up member by email (exact or partial match, member/user only)') - .option('--name <name>', 'Look up member by name (partial match, member/user only)') - .option('-I, --interactive', 'Enable interactive selection when multiple matches found') - .addHelpText('after', ` -Examples: - Basic (with ID): - $ agent2linear alias add initiative backend init_abc123xyz - $ agent2linear alias add team frontend team_def456uvw --project - $ agent2linear alias add project api proj_ghi789rst - $ agent2linear alias add project-status in-progress status_abc123 - $ agent2linear alias add issue-template bug-report template_abc123 - $ agent2linear alias add project-template sprint-template template_xyz789 - $ agent2linear alias add issue-label bug label_abc123def - $ agent2linear alias add project-label release label_ghi456jkl - $ agent2linear alias add workflow-state done state_mno789pqr - $ agent2linear alias add member john user_abc123def - - Member by exact email (auto-select): - $ agent2linear alias add member john --email john.doe@acme.com - $ agent2linear alias add user jane --email jane@acme.com - - Member by partial email (error if multiple matches): - $ agent2linear alias add member john --email @acme.com - # Error: Multiple members found. Use --interactive to select. - - Member by email with interactive selection: - $ agent2linear alias add member john --email @acme.com --interactive - $ agent2linear alias add member john --email john@ --interactive - - Member by name with interactive selection: - $ agent2linear alias add member john --name John --interactive - $ agent2linear alias add member jane --name "Jane Smith" --interactive - -Note: --email, --name, and --interactive flags are only valid for member/user type -`) - .action(async (type: string, alias: string, id: string | undefined, options) => { - await addAliasCommand(type, alias, id, options); - }); - -alias - .command('list [type]') - .alias('ls') - .description('List all aliases or aliases for a specific type') - .option('--validate', 'Validate that aliases point to existing entities') - .addHelpText('after', ` -Examples: - $ agent2linear alias list # List all aliases - $ agent2linear alias ls # Same as 'list' (alias) - $ agent2linear alias list initiative # List only initiative aliases - $ agent2linear alias list team # List only team aliases - $ agent2linear alias list project # List only project aliases - $ agent2linear alias list project-status # List only project status aliases - $ agent2linear alias list issue-template # List only issue template aliases - $ agent2linear alias list project-template # List only project template aliases - $ agent2linear alias list issue-label # List only issue label aliases - $ agent2linear alias list project-label # List only project label aliases - $ agent2linear alias list workflow-state # List only workflow state aliases - $ agent2linear alias list member # List only member aliases - $ agent2linear alias list user # List only user/member aliases - $ agent2linear alias list --validate # Validate all aliases -`) - .action(async (type?: string, options?: { validate?: boolean }) => { - await listAliasCommand(type, options); - }); - -alias - .command('remove') - .alias('rm') - .addArgument( - new Argument('<type>', 'Entity type') - .choices(['initiative', 'team', 'project', 'project-status', 'issue-template', 'project-template', 'member', 'user', 'issue-label', 'project-label', 'workflow-state']) - ) - .addArgument(new Argument('<alias>', 'Alias name to remove')) - .description('Remove an alias') - .option('-g, --global', 'Remove from global config (default)') - .option('-p, --project', 'Remove from project config') - .addHelpText('after', ` -Examples: - $ agent2linear alias remove initiative backend - $ agent2linear alias rm team frontend --project - $ agent2linear alias remove project-status in-progress - $ agent2linear alias remove issue-template bug-report - $ agent2linear alias rm project-template sprint-template - $ agent2linear alias remove issue-label bug - $ agent2linear alias remove project-label release - $ agent2linear alias remove workflow-state done - $ agent2linear alias remove member john - $ agent2linear alias rm user jane -`) - .action((type: string, alias: string, options) => { - removeAliasCommand(type, alias, options); - }); - -alias - .command('get') - .addArgument( - new Argument('<type>', 'Entity type') - .choices(['initiative', 'team', 'project', 'project-status', 'issue-template', 'project-template', 'member', 'user', 'issue-label', 'project-label', 'workflow-state']) - ) - .addArgument(new Argument('<alias>', 'Alias name')) - .description('Get the ID for an alias') - .addHelpText('after', ` -Examples: - $ agent2linear alias get initiative backend - $ agent2linear alias get team frontend - $ agent2linear alias get project-status in-progress - $ agent2linear alias get issue-template bug-report - $ agent2linear alias get project-template sprint-template - $ agent2linear alias get issue-label bug - $ agent2linear alias get project-label release - $ agent2linear alias get workflow-state done - $ agent2linear alias get member john - $ agent2linear alias get user jane -`) - .action((type: string, alias: string) => { - getAliasCommand(type, alias); - }); - -alias - .command('edit') - .description('Interactively edit aliases (add, rename, change ID, or delete)') - .option('-g, --global', 'Edit global aliases') - .option('-p, --project', 'Edit project aliases') - .addHelpText('after', ` -This is an interactive command that guides you through editing aliases. - -Supported entity types: - - Initiatives - Teams - Projects - - Project Statuses - Issue Templates - Project Templates - - Members/Users - Issue Labels - Project Labels - - Workflow States - -Available operations: - - Add new alias: Create a new alias by selecting from available entities - - Rename alias: Change the alias name while keeping the same entity ID - - Change ID: Update the entity ID that an alias points to - - Delete alias: Remove an alias entirely - -Examples: - $ agent2linear alias edit # Interactive mode (choose scope interactively) - $ agent2linear alias edit --global # Edit global aliases directly - $ agent2linear alias edit --project # Edit project aliases directly - -Note: This command is fully interactive. For non-interactive editing, - use 'alias add' and 'alias remove' commands instead. -`) - .action(async (options) => { - await editAlias(options); - }); - -alias - .command('clear') - .addArgument( - new Argument('<type>', 'Entity type') - .choices(['initiative', 'team', 'project', 'project-status', 'issue-template', 'project-template', 'member', 'user', 'issue-label', 'project-label', 'workflow-state']) - ) - .description('Clear all aliases of a specific type') - .option('-g, --global', 'Clear from global config (default)') - .option('-p, --project', 'Clear from project config') - .option('-f, --force', 'Skip confirmation prompt') - .option('--dry-run', 'Preview what would be cleared without actually clearing') - .addHelpText('after', ` -Examples: - # Preview what would be cleared - $ agent2linear alias clear team --dry-run - $ agent2linear alias clear member --project --dry-run - - # Clear with confirmation - $ agent2linear alias clear team --global - $ agent2linear alias clear project-status --project - - # Clear without confirmation - $ agent2linear alias clear initiative --force - $ agent2linear alias clear member --project --force - -Warning: This will remove ALL aliases of the specified type from the chosen scope. - Use --dry-run first to preview what will be removed. -`) - .action(async (type: string, options) => { - await clearAliasCommand(type, options); - }); - -aliasSyncCommand(alias); - -// Milestones commands (stub - future release) const milestones = cli .command('milestones') .alias('mile') @@ -1005,7 +107,6 @@ milestones console.log(' See MILESTONES.md for planned features and timeline.'); }); -// Labels commands (stub - future release) const labels = cli .command('labels') .alias('lbl') @@ -1023,761 +124,7 @@ labels console.log(' See MILESTONES.md for planned features and timeline.'); }); -// Milestone Templates commands -const milestoneTemplates = cli - .command('milestone-templates') - .alias('mtmpl') - .description('Manage milestone templates for projects') - .action(() => { - milestoneTemplates.help(); - }); - -milestoneTemplates - .command('list') - .alias('ls') - .description('List all available milestone templates') - .option('-f, --format <type>', 'Output format: tsv, json') - .addHelpText('after', ` -Examples: - $ agent2linear milestone-templates list # List all templates (grouped by source) - $ agent2linear mtmpl ls # Same as 'list' (alias) - $ agent2linear milestone-templates list --format json # Output as JSON (flat list) - $ agent2linear milestone-templates list --format tsv # Output as TSV (flat list) - $ agent2linear mtmpl list -f tsv | cut -f1 # Get just template names -`) - .action(async (options?: { format?: 'tsv' | 'json' }) => { - await listMilestoneTemplates(options || {}); - }); - -milestoneTemplates - .command('view <name>') - .description('View details of a specific milestone template') - .addHelpText('after', ` -Examples: - $ agent2linear milestone-templates view basic-sprint - $ agent2linear mtmpl view product-launch -`) - .action(async (name: string) => { - await viewMilestoneTemplate(name); - }); - -milestoneTemplates - .command('create [name]') - .description('Create a new milestone template') - .option('-g, --global', 'Create in global scope (default)') - .option('-p, --project', 'Create in project scope') - .option('-d, --description <text>', 'Template description') - .option('-m, --milestone <spec>', 'Milestone spec (name:targetDate:description)', (value, previous: string[] = []) => [...previous, value], []) - .option('-I, --interactive', 'Use interactive mode') - .addHelpText('after', ` -Examples: - # Interactive mode (recommended) - name collected interactively - $ agent2linear milestone-templates create --interactive - $ agent2linear mtmpl create -I - - # Non-interactive mode - name required as argument - $ agent2linear milestone-templates create basic-sprint \\ - --description "Simple 2-week sprint" \\ - --milestone "Planning:+1d:Define sprint goals" \\ - --milestone "Development:+10d:Implementation phase" \\ - --milestone "Review:+14d:Code review and deployment" - - # Project scope - $ agent2linear milestone-templates create --project --interactive - -Note: Milestone spec format is "name:targetDate:description" - - name: Required - - targetDate: Optional (+7d, +2w, +1m, or ISO date) - - description: Optional (markdown supported) -`) - .action(async (name: string | undefined, options) => { - if (options.interactive) { - // In interactive mode, name is collected interactively - await createTemplateInteractive(options); - } else { - if (!name) { - console.error('❌ Error: Template name is required in non-interactive mode\n'); - console.error('Provide a name:'); - console.error(' $ agent2linear milestone-templates create my-template --milestone ...\n'); - console.error('Or use interactive mode:'); - console.error(' $ agent2linear milestone-templates create --interactive\n'); - process.exit(1); - } - await createTemplate(name, options); - } - }); - -milestoneTemplates - .command('edit <name>') - .description('Edit an existing milestone template (interactive only)') - .option('-g, --global', 'Edit in global scope') - .option('-p, --project', 'Edit in project scope') - .addHelpText('after', ` -Examples: - $ agent2linear milestone-templates edit basic-sprint - $ agent2linear mtmpl edit product-launch --global - -Note: If no scope is specified, the template will be edited in its current scope. -`) - .action(async (name: string, options) => { - await editTemplateInteractive(name, options); - }); - -milestoneTemplates - .command('remove <name>') - .alias('rm') - .description('Remove a milestone template') - .option('-g, --global', 'Remove from global scope') - .option('-p, --project', 'Remove from project scope') - .option('-y, --yes', 'Skip confirmation prompt') - .addHelpText('after', ` -Examples: - $ agent2linear milestone-templates remove basic-sprint - $ agent2linear mtmpl rm product-launch --yes - $ agent2linear milestone-templates remove my-sprint --project - -Note: If no scope is specified, the template will be removed from its current scope. -`) - .action(async (name: string, options) => { - await removeTemplate(name, options); - }); - -// Templates commands -const templates = cli - .command('templates') - .alias('tmpl') - .description('Manage Linear templates') - .action(() => { - templates.help(); - }); - -templates - .command('list [type]') - .alias('ls') - .description('List all templates or filter by type (issue/project)') - .option('-I, --interactive', 'Use interactive mode for browsing') - .option('-w, --web', 'Open Linear templates page in browser') - .option('-f, --format <type>', 'Output format: tsv, json') - .addHelpText('after', ` -Examples: - $ agent2linear templates list # List all templates (grouped by type) - $ agent2linear tmpl ls # Same as 'list' (alias) - $ agent2linear templates list issues # List only issue templates - $ agent2linear templates list projects # List only project templates - $ agent2linear templates list --interactive # Browse interactively - $ agent2linear templates list --web # Open in browser - $ agent2linear templates list --format json # Output as JSON (flat list) - $ agent2linear templates list --format tsv # Output as TSV (flat list) - $ agent2linear tmpl list -f tsv | grep issue # Filter issue templates -`) - .action(async (type?: string, options?: { interactive?: boolean; web?: boolean; format?: 'tsv' | 'json' }) => { - await listTemplates(type, options || {}); - }); - -templates - .command('view <id>') - .description('View details of a specific template') - .option('-w, --web', 'Open templates page in browser (templates do not have individual URLs)') - .addHelpText('after', ` -Examples: - $ agent2linear templates view template_abc123 - $ agent2linear tmpl view template_xyz789 - $ agent2linear templates view template_abc123 --web - $ agent2linear tmpl view mytemplate --web -`) - .action(async (id: string, options) => { - await viewTemplate(id, options); - }); - -// Config commands -const config = cli - .command('config') - .alias('cfg') - .description('Manage configuration settings for agent2linear') - .addHelpText('before', ` -Current respected settings: -- \`apiKey\`: Linear API authentication key (get yours at linear.app/settings/api) -- \`defaultInitiative\`: Default initiative ID for project creation (format: init_xxx) -- \`defaultTeam\`: Default team ID for project creation (format: team_xxx) -- \`defaultIssueTemplate\`: Default template ID for issue creation (format: template_xxx) -- \`defaultProjectTemplate\`: Default template ID for project creation (format: template_xxx) -- \`defaultMilestoneTemplate\`: Default milestone template name for project milestones -- \`projectCacheMinTTL\`: Cache time-to-live in minutes (default: 60, range: 1-1440) - -Configuration files: -- Global: ~/.config/agent2linear/config.json -- Project: .agent2linear/config.json -- Priority: environment > project > global (for apiKey) - project > global (for other settings) -`) - .addHelpText('after', ` -Related Commands: - $ agent2linear initiatives select # Interactive initiative picker - $ agent2linear teams select # Interactive team picker - -Learn More: - Get your Linear API key at: https://linear.app/settings/api -`) - .action(() => { - config.help(); - }); - -config - .command('list') - .alias('show') - .description('List all configuration values') - .addHelpText('after', ` -Examples: - $ agent2linear config list # Display all config values and sources - $ agent2linear cfg show # Same as 'list' (alias for backward compatibility) -`) - .action(async () => { - await listConfig(); - }); - -config - .command('get') - .addArgument( - new Argument('<key>', 'Configuration key') - .choices(['apiKey', 'defaultInitiative', 'defaultTeam', 'defaultIssueTemplate', 'defaultProjectTemplate', 'defaultMilestoneTemplate', 'projectCacheMinTTL']) - ) - .description('Get a single configuration value') - .addHelpText('after', ` -Examples: - $ agent2linear config get apiKey - $ agent2linear cfg get defaultInitiative - $ agent2linear cfg get defaultProjectTemplate - $ agent2linear cfg get defaultMilestoneTemplate - $ agent2linear cfg get projectCacheMinTTL -`) - .action(async (key: string) => { - await getConfigValue(key as ConfigKey); - }); - -config - .command('set') - .addArgument( - new Argument('<key>', 'Configuration key') - .choices(['apiKey', 'defaultInitiative', 'defaultTeam', 'defaultIssueTemplate', 'defaultProjectTemplate', 'defaultMilestoneTemplate', 'projectCacheMinTTL']) - ) - .addArgument(new Argument('<value>', 'Configuration value')) - .description('Set a configuration value') - .option('-g, --global', 'Set in global config (default)') - .option('-p, --project', 'Set in project config') - .addHelpText('after', ` -Examples: - $ agent2linear config set apiKey lin_api_xxx... - $ agent2linear config set defaultInitiative init_abc123 --global - $ agent2linear config set defaultTeam team_xyz789 --project - $ agent2linear config set defaultProjectTemplate template_abc123 - $ agent2linear config set defaultMilestoneTemplate basic-sprint - $ agent2linear config set projectCacheMinTTL 120 # Cache for 2 hours -`) - .action(async (key: string, value: string, options) => { - await setConfig(key, value, options); - }); - -config - .command('unset') - .addArgument( - new Argument('<key>', 'Configuration key') - .choices(['apiKey', 'defaultInitiative', 'defaultTeam', 'defaultIssueTemplate', 'defaultProjectTemplate', 'defaultMilestoneTemplate', 'projectCacheMinTTL']) - ) - .description('Remove a configuration value') - .option('-g, --global', 'Remove from global config (default)') - .option('-p, --project', 'Remove from project config') - .addHelpText('after', ` -Examples: - $ agent2linear config unset apiKey --global - $ agent2linear config unset defaultTeam --project - $ agent2linear config unset defaultProjectTemplate - $ agent2linear config unset defaultMilestoneTemplate - $ agent2linear config unset projectCacheMinTTL -`) - .action(async (key: string, options) => { - await unsetConfig(key, options); - }); - -config - .command('edit') - .description('Edit configuration interactively') - .option('-g, --global', 'Edit global config (skip scope prompt)') - .option('-p, --project', 'Edit project config (skip scope prompt)') - .option('--key <key>', 'Configuration key to edit (non-interactive)') - .option('--value <value>', 'Configuration value (requires --key, non-interactive)') - .addHelpText('after', ` -Examples: - $ agent2linear config edit # Interactive multi-value editing - $ agent2linear config edit --global # Edit global config interactively - $ agent2linear config edit --key apiKey --value lin_api_xxx # Non-interactive single value - $ agent2linear cfg edit # Same as 'config edit' (alias) -`) - .action(async (options) => { - await editConfig(options); - }); - -// Workflow States commands -const workflowStates = cli - .command('workflow-states') - .alias('wstate') - .alias('ws') - .description('Manage workflow states (issue statuses)'); - -listWorkflowStates(workflowStates); -viewWorkflowState(workflowStates); -createWorkflowStateCommand(workflowStates); -updateWorkflowStateCommand(workflowStates); -deleteWorkflowStateCommand(workflowStates); -syncWorkflowStateAliases(workflowStates); - -// Issue Labels commands -const issueLabels = cli - .command('issue-labels') - .alias('ilbl') - .description('Manage issue labels'); - -listIssueLabels(issueLabels); -viewIssueLabel(issueLabels); -createIssueLabelCommand(issueLabels); -updateIssueLabelCommand(issueLabels); -deleteIssueLabelCommand(issueLabels); -syncIssueLabelAliases(issueLabels); - -// Project Labels commands -const projectLabels = cli - .command('project-labels') - .alias('plbl') - .description('Manage project labels'); - -listProjectLabels(projectLabels); -viewProjectLabel(projectLabels); -createProjectLabelCommand(projectLabels); -updateProjectLabelCommand(projectLabels); -deleteProjectLabelCommand(projectLabels); -syncProjectLabelAliases(projectLabels); - -// Icons commands -const icons = cli - .command('icons') - .description('Browse and manage icons'); - -listIcons(icons); -viewIcon(icons); -extractIcons(icons); - -// Colors commands -const colors = cli - .command('colors') - .description('Browse and manage colors'); - -listColors(colors); -viewColor(colors); -extractColors(colors); - -// Cache commands -const cache = cli - .command('cache') - .description('Manage entity cache') - .addHelpText('before', ` -The cache system reduces API calls by storing frequently accessed entities in memory: -- Teams -- Initiatives -- Members -- Templates -- Issue Labels -- Project Labels - -Cache configuration can be managed via config commands: -- \`entityCacheMinTTL\`: Cache time-to-live in minutes (default: 60) -- \`enableEntityCache\`: Enable/disable entity caching (default: true) -- \`enableSessionCache\`: Enable/disable in-memory cache (default: true) -- \`enableBatchFetching\`: Enable/disable parallel API calls (default: true) -- \`prewarmCacheOnCreate\`: Auto-prewarm on project create (default: true) -`) - .addHelpText('after', ` -Examples: - $ agent2linear cache stats # View cache statistics - $ agent2linear cache clear # Clear all cached entities - $ agent2linear cache clear --entity teams # Clear specific entity type - -Related Commands: - $ agent2linear config set entityCacheMinTTL 120 # Set 2-hour cache TTL - $ agent2linear config set enableEntityCache false # Disable caching -`) - .action(() => { - cache.help(); - }); - -cache - .command('stats') - .description('Show cache statistics') - .addHelpText('after', ` -Examples: - $ agent2linear cache stats # Display cache status and configuration - -This will show: - • Cache configuration (enabled/disabled features) - • Entity cache status (cached items, age) - • Cache TTL settings -`) - .action(async () => { - await showCacheStats(); - }); - -cache - .command('clear') - .description('Clear cached entities') - .option('--entity <type>', 'Clear specific entity type (teams, initiatives, members, templates, issue-labels, project-labels)') - .addHelpText('after', ` -Examples: - $ agent2linear cache clear # Clear all cached entities - $ agent2linear cache clear --entity teams # Clear only teams cache - -Cache will be automatically repopulated on next access. -`) - .action(async (options) => { - await clearCache(options); - }); - -// Issue commands (M15.2) -const issue = cli - .command('issue') - .description('Manage Linear issues') - .action(() => { - issue.help(); - }); - -issue - .command('view <identifier>') - .description('View an issue by identifier (e.g., ENG-123) or UUID') - .option('--json', 'Output in JSON format') - .option('-w, --web', 'Open issue in web browser') - .option('--show-comments', 'Display issue comments') - .option('--show-history', 'Display issue history') - .option('--desc', 'Show truncated description preview (default 80 chars)') - .option('--desc-length <n>', 'Description preview length in characters (implies --desc)') - .option('--desc-full', 'Show full description (default behavior, explicit)') - .option('--no-desc', 'Hide description from output') - .addHelpText('after', ` -Examples: - $ agent2linear issue view ENG-123 # View issue by identifier - $ agent2linear issue view <uuid> # View issue by UUID - $ agent2linear issue view ENG-123 --json # Output as JSON - $ agent2linear issue view ENG-123 --web # Open in browser - $ agent2linear issue view ENG-123 --show-comments # Include comments - $ agent2linear issue view ENG-123 --show-history # Include history - $ agent2linear issue view ENG-123 --desc # Show 80-char description preview - $ agent2linear issue view ENG-123 --no-desc # Hide description - $ agent2linear issue view ENG-123 --desc-length 120 # Show 120-char description preview - -The view command displays comprehensive issue information including: - • Core details: title, description, status, priority - • Assignment: assignee, subscribers - • Organization: team, project, cycle, labels - • Dates: created, updated, due, completed - • Relationships: parent issue, sub-issues - • Creator information - -Use --show-comments to see all comments on the issue. -Use --show-history to see the change history. -`) - .action(async (identifier, options) => { - await viewIssue(identifier, options); - }); - -issue - .command('create') - .description('Create a new Linear issue') - .option('--title <string>', 'Issue title (required)') - .option('--team <id|alias>', 'Team ID or alias (required unless defaultTeam configured)') - .option('--description <string>', 'Issue description (markdown)') - .option('--description-file <path>', 'Read description from file (mutually exclusive with --description)') - .option('--priority <0-4>', 'Priority: 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low') - .option('--estimate <number>', 'Story points or time estimate') - .option('--state <id|alias>', 'Workflow state ID or alias (must belong to team)') - .option('--due-date <YYYY-MM-DD>', 'Due date in ISO format') - .option('--assignee <id|alias|email|name>', 'Assign to user (ID, alias, email, or display name)') - .option('--no-assignee', 'Create unassigned (overrides default auto-assignment)') - .option('--subscribers <list>', 'Comma-separated list of subscriber IDs, aliases, or emails') - .option('--project <id|alias|name>', 'Project ID, alias, or name (must belong to same team)') - .option('--cycle <uuid|alias>', 'Cycle UUID or alias') - .option('--parent <identifier>', 'Parent issue identifier (ENG-123 or UUID) for sub-issues') - .option('--labels <list>', 'Comma-separated list of label IDs or aliases') - .option('--template <id|alias>', 'Issue template ID or alias') - .option('-w, --web', 'Open created issue in browser') - .option('--dry-run', 'Preview the payload without creating the issue') - .addHelpText('after', ` -Examples: - # Minimal (uses defaultTeam, auto-assigns to you) - $ agent2linear issue create --title "Fix login bug" - - # Standard creation - $ agent2linear issue create \\ - --title "Add OAuth support" \\ - --team backend \\ - --priority 2 \\ - --estimate 8 - - # Full-featured creation - $ agent2linear issue create \\ - --title "Implement authentication" \\ - --team backend \\ - --description "Add OAuth2 with Google and GitHub providers" \\ - --priority 1 \\ - --estimate 13 \\ - --state in-progress \\ - --assignee john@company.com \\ - --subscribers "jane@company.com,bob@company.com" \\ - --labels "feature,security" \\ - --project "Q1 Goals" \\ - --due-date 2025-02-15 \\ - --web - - # Create sub-issue - $ agent2linear issue create \\ - --title "Write unit tests" \\ - --parent ENG-123 \\ - --team backend - - # Read description from file - $ agent2linear issue create \\ - --title "API Documentation" \\ - --team backend \\ - --description-file docs/api-spec.md - - # Create unassigned - $ agent2linear issue create \\ - --title "Research task" \\ - --team backend \\ - --no-assignee - -Field Details: - • Title: Required. The issue title. - • Team: Required (unless defaultTeam configured). The team this issue belongs to. - • Auto-assignment: By default, issues are assigned to you. Use --assignee to assign - to someone else, or --no-assignee to create an unassigned issue. - • Priority: 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low - • State: Must belong to the same team. Use workflow state ID or alias. - • Project: Must belong to the same team. Supports ID, alias, or name lookup. - • Cycle: Must be a valid UUID or cycle alias. - • Labels: Comma-separated list. Supports label IDs or aliases. - • Subscribers: Comma-separated list. Supports member IDs, aliases, emails, or display names. - • Parent: Creates a sub-issue. Use issue identifier (ENG-123) or UUID. - -Member Resolution: - The --assignee and --subscribers options support multiple resolution methods: - • Linear ID: user_abc123 - • Alias: john (from your aliases.json) - • Email: john@company.com (exact match lookup) - • Display name: "John Doe" (with disambiguation if multiple matches) - -Config Defaults: - • defaultTeam: If set, team becomes optional - • defaultProject: Used if --project not specified (must belong to same team) - - Set defaults with: - $ agent2linear config set defaultTeam <team-id> - $ agent2linear config set defaultProject <project-id> -`) - .action(async (options) => { - await createIssueCommand(options); - }); - -issue - .command('update <identifier>') - .description('Update an existing Linear issue by identifier (ENG-123) or UUID') - .option('--title <string>', 'Update issue title') - .option('--description <string>', 'Update description (markdown)') - .option('--description-file <path>', 'Read description from file (mutually exclusive with --description)') - .option('--priority <0-4>', 'Update priority: 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low', parseInt) - .option('--estimate <number>', 'Update estimate', parseFloat) - .option('--no-estimate', 'Clear estimate') - .option('--state <id|alias>', 'Update workflow state (must belong to team)') - .option('--due-date <YYYY-MM-DD>', 'Set/update due date') - .option('--no-due-date', 'Clear due date') - .option('--assignee <id|alias|email|name>', 'Change assignee') - .option('--no-assignee', 'Remove assignee') - .option('--team <id|alias>', 'Move to different team (requires compatible state)') - .option('--project <id|alias|name>', 'Assign to project') - .option('--no-project', 'Remove from project') - .option('--cycle <uuid|alias>', 'Assign to cycle') - .option('--no-cycle', 'Remove from cycle') - .option('--parent <identifier>', 'Set/change parent issue (ENG-123 or UUID)') - .option('--no-parent', 'Remove parent (make root issue)') - .option('--labels <list>', 'Replace ALL labels (comma-separated)') - .option('--add-labels <list>', 'Add labels (comma-separated)') - .option('--remove-labels <list>', 'Remove labels (comma-separated)') - .option('--subscribers <list>', 'Replace ALL subscribers (comma-separated)') - .option('--add-subscribers <list>', 'Add subscribers (comma-separated)') - .option('--remove-subscribers <list>', 'Remove subscribers (comma-separated)') - .option('--trash', 'Move issue to trash') - .option('--untrash', 'Restore issue from trash') - .option('-w, --web', 'Open updated issue in browser') - .option('--dry-run', 'Preview the payload without updating the issue') - .option('--bulk <identifiers>', 'Apply same update to multiple issues (comma-separated identifiers)') - .addHelpText('after', ` -Examples: - # Update single field - $ agent2linear issue update ENG-123 --title "New title" - $ agent2linear issue update ENG-123 --priority 1 - $ agent2linear issue update ENG-123 --state done - - # Update multiple fields - $ agent2linear issue update ENG-123 \\ - --title "Updated title" \\ - --priority 2 \\ - --estimate 5 \\ - --due-date 2025-12-31 - - # Change assignment - $ agent2linear issue update ENG-123 --assignee john@company.com - $ agent2linear issue update ENG-123 --no-assignee - - # Label management (3 modes) - $ agent2linear issue update ENG-123 --labels "bug,urgent" # Replace all - $ agent2linear issue update ENG-123 --add-labels "feature" # Add to existing - $ agent2linear issue update ENG-123 --remove-labels "wontfix" # Remove specific - $ agent2linear issue update ENG-123 --add-labels "new" --remove-labels "old" # Add + remove - - # Subscriber management (3 modes) - $ agent2linear issue update ENG-123 --subscribers "user1,user2" # Replace all - $ agent2linear issue update ENG-123 --add-subscribers "user3" # Add to existing - $ agent2linear issue update ENG-123 --remove-subscribers "user1" # Remove specific - - # Clear fields - $ agent2linear issue update ENG-123 --no-assignee --no-due-date --no-estimate - $ agent2linear issue update ENG-123 --no-project --no-cycle --no-parent - - # Parent relationship - $ agent2linear issue update ENG-123 --parent ENG-100 # Make sub-issue - $ agent2linear issue update ENG-123 --no-parent # Make root issue - - # Move between teams - $ agent2linear issue update ENG-123 --team frontend --state todo - - # Lifecycle operations - $ agent2linear issue update ENG-123 --trash # Move to trash - $ agent2linear issue update ENG-123 --untrash # Restore from trash - -Field Details: - • Identifier: Use issue identifier (ENG-123) or UUID - • At least one update field required (--web alone is not enough) - • Priority: 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low - • State: Must belong to the issue's team (or new team if also using --team) - -Mutual Exclusivity Rules: - • Cannot use --description with --description-file - • Cannot use --labels with --add-labels or --remove-labels - • Cannot use --subscribers with --add-subscribers or --remove-subscribers - • Cannot use --assignee with --no-assignee - • Cannot use --due-date with --no-due-date - • Cannot use --estimate with --no-estimate - • Cannot use --project with --no-project - • Cannot use --cycle with --no-cycle - • Cannot use --parent with --no-parent - • Cannot use --trash with --untrash - -Label/Subscriber Patterns: - • Replace mode: --labels or --subscribers replaces ALL items - • Add mode: --add-labels or --add-subscribers adds to existing - • Remove mode: --remove-labels or --remove-subscribers removes specific items - • Add + Remove: Can use --add-labels AND --remove-labels together (add first, then remove) - -Team Changes: - When changing teams (--team), the workflow state must be compatible: - • If also providing --state, it must belong to the NEW team - • If NOT providing --state, current state must be compatible with new team - • Linear will reject invalid team-state combinations - -Member Resolution: - --assignee and --subscribers support multiple resolution methods: - • Linear ID: user_abc123 - • Alias: john (from aliases.json) - • Email: john@company.com - • Display name: "John Doe" -`) - .action(async (identifier, options) => { - await updateIssueCommand(identifier, options); - }); - -// Register issue list command (M15.5 Phase 1) -registerIssueListCommand(issue); - -// Issue comment subcommand -issue - .command('comment <identifier>') - .description('Add a comment to an issue') - .option('--body <text>', 'Comment body (markdown)') - .option('--body-file <path>', 'Read comment body from file') - .addHelpText('after', ` -Examples: - $ agent2linear issue comment ENG-123 --body "This is done" - $ agent2linear issue comment ENG-123 --body-file notes.md - -The identifier can be an issue identifier (ENG-123) or UUID. -Comment body supports markdown formatting. -`) - .action(async (identifier, options) => { - await commentIssueCommand(identifier, options); - }); - -// Cycles commands -const cycles = cli - .command('cycles') - .alias('cycle') - .description('Manage Linear cycles (sprints)') - .action(() => { - cycles.help(); - }); - -cycles - .command('list') - .alias('ls') - .description('List cycles') - .option('--team <id|alias>', 'Filter by team') - .option('-f, --format <type>', 'Output format: json, tsv') - .addHelpText('after', ` -Examples: - $ agent2linear cycles list # List cycles for default team - $ agent2linear cycles list --team backend # List cycles for specific team - $ agent2linear cycles list --format json # JSON output -`) - .action(async (options) => { - await listCyclesCommand(options); - }); - -cycles - .command('view <id>') - .description('View cycle details') - .option('--json', 'Output as JSON') - .addHelpText('after', ` -Examples: - $ agent2linear cycles view cycle_abc123 - $ agent2linear cycles view sprint-1 # Using alias - $ agent2linear cycles view sprint-1 --json # JSON output -`) - .action(async (id, options) => { - await viewCycleCommand(id, options); - }); - -cycles - .command('sync-aliases') - .description('Create aliases for all cycles') - .option('-g, --global', 'Create aliases in global config') - .option('-p, --project', 'Create aliases in project config') - .option('--dry-run', 'Preview aliases without creating them') - .option('-f, --force', 'Overwrite existing aliases') - .option('--no-auto-suffix', 'Disable auto-numbering for duplicate slugs') - .addHelpText('after', ` -Examples: - $ agent2linear cycles sync-aliases --global # Create global aliases - $ agent2linear cycles sync-aliases --dry-run # Preview changes -`) - .action(async (options) => { - await syncCycleAliasesCore(options); - }); - -// Whoami command +// Top-level commands cli .command('whoami') .description('Display authenticated user info') @@ -1791,7 +138,6 @@ Displays the identity associated with your configured Linear API key. await whoamiCommand(); }); -// Doctor command cli .command('doctor') .description('Run diagnostic checks on your agent2linear environment') @@ -1810,7 +156,6 @@ Checks: await doctorCommand(); }); -// Setup command cli .command('setup') .description('Interactive first-time setup wizard') From 4f76db31cb8ca58c980ee18d335f31cf2b08ef8c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 11 Mar 2026 05:49:59 +0000 Subject: [PATCH 10/11] Refactor linear-client.ts: split into domain API modules (C2) Reduces linear-client.ts from 4195 to 3 lines (thin re-export) by extracting all API functions into 10 domain modules under src/lib/api/: client, projects, issues, teams, initiatives, members, labels, workflow-states, templates, cycles. Barrel re-export in index.ts ensures backward compatibility for all existing imports. https://claude.ai/code/session_01LE3Rf4kmo1TBCozoF14LCa --- src/lib/api/cycles.ts | 122 + src/lib/api/index.ts | 13 + src/lib/api/issues.ts | 977 ++++++++ src/lib/api/labels.ts | 431 ++++ src/lib/api/members.ts | 342 +++ src/lib/api/templates.ts | 106 + src/lib/api/workflow-states.ts | 247 ++ src/lib/linear-client.ts | 4198 +------------------------------- 8 files changed, 2241 insertions(+), 4195 deletions(-) create mode 100644 src/lib/api/cycles.ts create mode 100644 src/lib/api/index.ts create mode 100644 src/lib/api/issues.ts create mode 100644 src/lib/api/labels.ts create mode 100644 src/lib/api/members.ts create mode 100644 src/lib/api/templates.ts create mode 100644 src/lib/api/workflow-states.ts diff --git a/src/lib/api/cycles.ts b/src/lib/api/cycles.ts new file mode 100644 index 0000000..6684806 --- /dev/null +++ b/src/lib/api/cycles.ts @@ -0,0 +1,122 @@ +import { getLinearClient, LinearClientError } from './client.js'; + +/** + * Get all cycles, optionally filtered by team + */ +export async function getAllCycles(teamId?: string): Promise<Array<{ + id: string; + name: string; + number: number; + startsAt?: string; + endsAt?: string; + teamId?: string; + teamName?: string; +}>> { + try { + const client = getLinearClient(); + let cycles; + if (teamId) { + const team = await client.team(teamId); + cycles = await team.cycles(); + } else { + cycles = await client.cycles(); + } + + const results = []; + for (const cycle of cycles.nodes) { + const team = await cycle.team; + results.push({ + id: cycle.id, + name: cycle.name || `Cycle ${cycle.number}`, + number: cycle.number, + startsAt: cycle.startsAt instanceof Date ? cycle.startsAt.toISOString().split('T')[0] : undefined, + endsAt: cycle.endsAt instanceof Date ? cycle.endsAt.toISOString().split('T')[0] : undefined, + teamId: team?.id, + teamName: team?.name, + }); + } + return results; + } catch (error) { + if (error instanceof LinearClientError) throw error; + throw new LinearClientError( + `Failed to fetch cycles: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get cycle by ID (M15.1) + * @param cycleId - Cycle UUID + * @returns Cycle details or null if not found + */ +export async function getCycleById(cycleId: string): Promise<{ + id: string; + name: string; + number: number; + startsAt?: string; + endsAt?: string; +} | null> { + try { + const client = getLinearClient(); + const cycle = await client.cycle(cycleId); + + if (!cycle) { + return null; + } + + return { + id: cycle.id, + name: cycle.name || `Cycle #${cycle.number}`, + number: cycle.number, + startsAt: cycle.startsAt?.toString(), + endsAt: cycle.endsAt?.toString(), + }; + } catch (error) { + return null; + } +} + +/** + * Resolve cycle identifier (UUID or alias) to cycle ID (M15.1) + * Supports both UUID format and alias resolution via the alias system + * + * @param identifier - Cycle ID (UUID) or alias + * @param resolveAliasFn - Optional alias resolver function + * @returns Cycle ID (UUID) or null if not found + */ +export async function resolveCycleIdentifier( + identifier: string, + resolveAliasFn?: (type: 'cycle', value: string) => string +): Promise<string | null> { + try { + const trimmedId = identifier.trim(); + + // Try alias resolution first (if resolver provided) + let resolvedId = trimmedId; + if (resolveAliasFn) { + const aliasResolved = resolveAliasFn('cycle', trimmedId); + if (aliasResolved !== trimmedId) { + resolvedId = aliasResolved; + // Alias was found, now validate the resolved ID + const cycle = await getCycleById(resolvedId); + if (cycle) { + return cycle.id; + } + } + } + + // Try direct ID lookup if it looks like a UUID + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (uuidPattern.test(resolvedId)) { + const cycle = await getCycleById(resolvedId); + if (cycle) { + return cycle.id; + } + } + + // Not found by any method + return null; + } catch (error) { + return null; + } +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 0000000..234c613 --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,13 @@ +// Barrel re-export of all API domain modules +// This file aggregates all domain-specific API modules for convenient importing + +export * from './client.js'; +export * from './projects.js'; +export * from './issues.js'; +export * from './teams.js'; +export * from './initiatives.js'; +export * from './members.js'; +export * from './labels.js'; +export * from './workflow-states.js'; +export * from './templates.js'; +export * from './cycles.js'; diff --git a/src/lib/api/issues.ts b/src/lib/api/issues.ts new file mode 100644 index 0000000..f0165c2 --- /dev/null +++ b/src/lib/api/issues.ts @@ -0,0 +1,977 @@ +import { getLinearClient, LinearClientError } from './client.js'; +import type { + IssueCreateInput, + IssueUpdateInput, + IssueListFilters, + IssueListItem, + IssueViewData, +} from '../types.js'; + +/** + * Create a comment on an issue + */ +export async function createIssueComment( + issueId: string, + body: string +): Promise<{ id: string; body: string }> { + try { + const client = getLinearClient(); + const result = await client.createComment({ issueId, body }); + const comment = await result.comment; + if (!comment) { + throw new Error('Comment creation returned no comment'); + } + return { id: comment.id, body: comment.body }; + } catch (error) { + if (error instanceof LinearClientError) throw error; + throw new LinearClientError( + `Failed to create comment: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Create a new issue (M15.1) + * @param input - Issue creation data + * @returns Created issue details + */ +export async function createIssue(input: IssueCreateInput): Promise<{ + id: string; + identifier: string; + title: string; + url: string; +}> { + try { + const client = getLinearClient(); + + // Create the issue with all provided fields + const issue = await client.createIssue({ + title: input.title, + teamId: input.teamId, + description: input.description, + descriptionData: input.descriptionData, + priority: input.priority, + estimate: input.estimate, + stateId: input.stateId, + assigneeId: input.assigneeId, + subscriberIds: input.subscriberIds, + projectId: input.projectId, + cycleId: input.cycleId, + parentId: input.parentId, + labelIds: input.labelIds, + dueDate: input.dueDate, + templateId: input.templateId, + }); + + const createdIssue = await issue.issue; + + if (!createdIssue) { + throw new Error('Issue creation failed - no issue returned'); + } + + return { + id: createdIssue.id, + identifier: createdIssue.identifier, + title: createdIssue.title, + url: createdIssue.url, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Update an existing issue (M15.1) + * @param issueId - Issue UUID + * @param input - Issue update data + * @returns Updated issue details + */ +export async function updateIssue(issueId: string, input: IssueUpdateInput): Promise<{ + id: string; + identifier: string; + title: string; + url: string; +}> { + try { + const client = getLinearClient(); + + // Update the issue with all provided fields + const issue = await client.updateIssue(issueId, { + title: input.title, + description: input.description, + descriptionData: input.descriptionData, + priority: input.priority, + estimate: input.estimate, + stateId: input.stateId, + assigneeId: input.assigneeId, + subscriberIds: input.subscriberIds, + teamId: input.teamId, + projectId: input.projectId, + cycleId: input.cycleId, + parentId: input.parentId, + labelIds: input.labelIds, + dueDate: input.dueDate, + trashed: input.trashed, + }); + + const updatedIssue = await issue.issue; + + if (!updatedIssue) { + throw new Error('Issue update failed - no issue returned'); + } + + return { + id: updatedIssue.id, + identifier: updatedIssue.identifier, + title: updatedIssue.title, + url: updatedIssue.url, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to update issue: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get issue by UUID (M15.1) + * @param issueId - Issue UUID + * @returns Issue details or null if not found + */ +export async function getIssueById(issueId: string): Promise<{ + id: string; + identifier: string; + title: string; + description?: string; + url: string; +} | null> { + try { + const client = getLinearClient(); + const issue = await client.issue(issueId); + + if (!issue) { + return null; + } + + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description || undefined, + url: issue.url, + }; + } catch (error) { + return null; + } +} + +/** + * Get issue by identifier (ENG-123 format) (M15.1) + * @param identifier - Issue identifier (e.g., "ENG-123") + * @returns Issue details or null if not found + */ +export async function getIssueByIdentifier(identifier: string): Promise<{ + id: string; + identifier: string; + title: string; + url: string; +} | null> { + try { + // Use the issue resolver to convert identifier to UUID + const { resolveIssueId } = await import('../issue-resolver.js'); + const issueId = await resolveIssueId(identifier); + + if (!issueId) { + return null; + } + + return await getIssueById(issueId); + } catch (error) { + return null; + } +} + +/** + * Get all issues with comprehensive filtering and pagination (M15.5) + * + * PERFORMANCE CRITICAL: This function uses a custom GraphQL query to fetch + * ALL issue data and relations in a SINGLE request to avoid N+1 query patterns. + * + * Pattern follows getAllProjects() optimization from M20/M21. + * + * @param filters - Optional filters for issues + * @returns Array of issues with all display data + */ +export async function getAllIssues(filters?: IssueListFilters): Promise<IssueListItem[]> { + try { + const client = getLinearClient(); + const startTime = Date.now(); + + // Track API call if tracking is enabled + const { isTracking, logCall } = await import('../api-call-tracker.js'); + const tracking = isTracking(); + + // ======================================== + // BUILD GRAPHQL FILTER + // ======================================== + const graphqlFilter: any = {}; + + if (filters?.teamId) { + graphqlFilter.team = { id: { eq: filters.teamId } }; + } + + if (filters?.assigneeId) { + graphqlFilter.assignee = { id: { eq: filters.assigneeId } }; + } + + if (filters?.projectId) { + graphqlFilter.project = { id: { eq: filters.projectId } }; + } + + if (filters?.initiativeId) { + graphqlFilter.initiative = { id: { eq: filters.initiativeId } }; + } + + if (filters?.stateId) { + graphqlFilter.state = { id: { eq: filters.stateId } }; + } + + if (filters?.priority !== undefined) { + graphqlFilter.priority = { eq: filters.priority }; + } + + if (filters?.parentId) { + graphqlFilter.parent = { id: { eq: filters.parentId } }; + } + + if (filters?.cycleId) { + graphqlFilter.cycle = { id: { eq: filters.cycleId } }; + } + + if (filters?.hasParent !== undefined) { + graphqlFilter.parent = filters.hasParent ? { null: false } : { null: true }; + } + + if (filters?.labelIds && filters.labelIds.length > 0) { + // Issues with ALL of these labels + graphqlFilter.labels = { some: { id: { in: filters.labelIds } } }; + } + + if (filters?.search) { + graphqlFilter.searchableContent = { contains: filters.search }; + } + + // Date range filters + if (filters?.createdAfter || filters?.createdBefore) { + graphqlFilter.createdAt = {}; + if (filters.createdAfter) graphqlFilter.createdAt.gte = new Date(filters.createdAfter).toISOString(); + if (filters.createdBefore) graphqlFilter.createdAt.lte = new Date(filters.createdBefore).toISOString(); + } + + if (filters?.updatedAfter || filters?.updatedBefore) { + graphqlFilter.updatedAt = {}; + if (filters.updatedAfter) graphqlFilter.updatedAt.gte = new Date(filters.updatedAfter).toISOString(); + if (filters.updatedBefore) graphqlFilter.updatedAt.lte = new Date(filters.updatedBefore).toISOString(); + } + + // Handle status filters + if (filters?.includeCompleted === false) { + graphqlFilter.completedAt = { null: true }; + } + + if (filters?.includeCanceled === false) { + graphqlFilter.canceledAt = { null: true }; + } + + if (filters?.includeArchived === false) { + graphqlFilter.archivedAt = { null: true }; + } + + if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { + console.error('[agent2linear] Issue filters:', JSON.stringify(graphqlFilter, null, 2)); + } + + // ======================================== + // PAGINATION SETUP (M15.5 Phase 1) + // ======================================== + const pageSize = filters?.fetchAll ? 250 : Math.min(filters?.limit || 50, 250); + const fetchAll = filters?.fetchAll || false; + const targetLimit = filters?.limit || 50; + + if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { + console.error('[agent2linear] Pagination:', { pageSize, fetchAll, targetLimit }); + } + + // ======================================== + // CUSTOM GRAPHQL QUERY - ALL RELATIONS UPFRONT + // ======================================== + const issuesQuery = ` + query GetIssues($filter: IssueFilter, $first: Int, $after: String) { + issues(filter: $filter, first: $first, after: $after) { + nodes { + id + identifier + title + description + priority + estimate + dueDate + createdAt + updatedAt + completedAt + canceledAt + archivedAt + url + + assignee { + id + name + email + } + + team { + id + key + name + } + + state { + id + name + type + } + + project { + id + name + } + + cycle { + id + name + number + } + + labels { + nodes { + id + name + color + } + } + + parent { + id + identifier + title + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + `; + + // ======================================== + // PAGINATION LOOP (M15.5 Phase 1) + // ======================================== + let rawIssues: any[] = []; + let cursor: string | null = null; + let hasNextPage = true; + let pageCount = 0; + + while (hasNextPage && (fetchAll || rawIssues.length < targetLimit)) { + pageCount++; + + const variables = { + filter: Object.keys(graphqlFilter).length > 0 ? graphqlFilter : null, + first: pageSize, + after: cursor + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const response: any = await client.client.rawRequest(issuesQuery, variables); + + // Track API call if tracking enabled + if (tracking) { + logCall('IssueList', 'query', 'main', Date.now() - startTime, variables); + } + + const nodes = response.data?.issues?.nodes || []; + const pageInfo = response.data?.issues?.pageInfo; + + rawIssues.push(...nodes); + + hasNextPage = pageInfo?.hasNextPage || false; + cursor = pageInfo?.endCursor || null; + + if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { + console.error(`[agent2linear] Page ${pageCount}: fetched ${nodes.length} issues (total: ${rawIssues.length}, hasNextPage: ${hasNextPage})`); + } + + // If not fetching all, stop when we have enough + if (!fetchAll && rawIssues.length >= targetLimit) { + break; + } + } + + // ======================================== + // TRUNCATION (M15.5 Phase 1) + // ======================================== + if (!fetchAll && rawIssues.length > targetLimit) { + if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { + console.error(`[agent2linear] Truncating from ${rawIssues.length} to ${targetLimit} issues`); + } + rawIssues = rawIssues.slice(0, targetLimit); + } + + // ======================================== + // SORTING (M15.5 Phase 3) + // ======================================== + if (filters?.sortField && filters?.sortOrder) { + const sortField = filters.sortField; + const sortOrder = filters.sortOrder; + const ascending = sortOrder === 'asc'; + + rawIssues.sort((a: any, b: any) => { + let aVal: any; + let bVal: any; + + switch (sortField) { + case 'priority': + aVal = a.priority !== undefined && a.priority !== null ? a.priority : 999; + bVal = b.priority !== undefined && b.priority !== null ? b.priority : 999; + break; + case 'created': + aVal = new Date(a.createdAt).getTime(); + bVal = new Date(b.createdAt).getTime(); + break; + case 'updated': + aVal = new Date(a.updatedAt).getTime(); + bVal = new Date(b.updatedAt).getTime(); + break; + case 'due': + aVal = a.dueDate ? new Date(a.dueDate).getTime() : Number.MAX_SAFE_INTEGER; + bVal = b.dueDate ? new Date(b.dueDate).getTime() : Number.MAX_SAFE_INTEGER; + break; + default: + return 0; + } + + // Apply sort order + if (ascending) { + return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; + } else { + return aVal < bVal ? 1 : aVal > bVal ? -1 : 0; + } + }); + + if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { + console.error(`[agent2linear] Sorted ${rawIssues.length} issues by ${sortField} ${sortOrder}`); + } + } + + // ======================================== + // BUILD FINAL ISSUE LIST + // ======================================== + const issueList: IssueListItem[] = rawIssues.map((issue: any) => ({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description || undefined, + priority: issue.priority !== undefined ? issue.priority : undefined, + estimate: issue.estimate || undefined, + dueDate: issue.dueDate || undefined, + + assignee: issue.assignee ? { + id: issue.assignee.id, + name: issue.assignee.name, + email: issue.assignee.email + } : undefined, + + team: issue.team ? { + id: issue.team.id, + key: issue.team.key, + name: issue.team.name + } : undefined, + + state: issue.state ? { + id: issue.state.id, + name: issue.state.name, + type: issue.state.type + } : undefined, + + project: issue.project ? { + id: issue.project.id, + name: issue.project.name + } : undefined, + + cycle: issue.cycle ? { + id: issue.cycle.id, + name: issue.cycle.name, + number: issue.cycle.number + } : undefined, + + labels: (issue.labels?.nodes || []).map((label: any) => ({ + id: label.id, + name: label.name, + color: label.color || undefined + })), + + parent: issue.parent ? { + id: issue.parent.id, + identifier: issue.parent.identifier, + title: issue.parent.title + } : undefined, + + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + completedAt: issue.completedAt || undefined, + canceledAt: issue.canceledAt || undefined, + archivedAt: issue.archivedAt || undefined, + url: issue.url + })); + + return issueList; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to get issues: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get issues assigned to the current user (M15.1) + * Helper function for default list behavior + * @returns Array of issues assigned to current user + */ +export async function getCurrentUserIssues(): Promise<Array<{ + id: string; + identifier: string; + title: string; + priority?: number; + url: string; +}>> { + try { + const client = getLinearClient(); + const viewer = await client.viewer; + + return await getAllIssues({ + assigneeId: viewer.id, + }); + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to get current user issues: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get full issue details for display (M15.2) + * Returns comprehensive issue data including relationships, metadata, and dates + * + * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): + * Uses custom GraphQL query to avoid N+1 pattern (11+ API calls -> 1 call) + * Fetches all relationships upfront instead of lazy loading via SDK + * + * @param issueId - Issue UUID + * @returns Full issue data or null if not found + */ +export async function getFullIssueById(issueId: string): Promise<IssueViewData | null> { + try { + const client = getLinearClient(); + + // ======================================== + // CUSTOM GRAPHQL QUERY - ALL RELATIONS UPFRONT + // ======================================== + const issueQuery = ` + query GetFullIssue($issueId: String!) { + issue(id: $issueId) { + id + identifier + title + description + url + priority + estimate + dueDate + createdAt + updatedAt + completedAt + canceledAt + archivedAt + + state { + id + name + type + color + } + + team { + id + key + name + } + + assignee { + id + name + email + } + + project { + id + name + } + + cycle { + id + name + number + } + + parent { + id + identifier + title + } + + children { + nodes { + id + identifier + title + state { + id + name + } + } + } + + labels { + nodes { + id + name + color + } + } + + subscribers { + nodes { + id + name + email + } + } + + creator { + id + name + email + } + } + } + `; + + const response: any = await client.client.rawRequest(issueQuery, { issueId }); + const issueData = response.data?.issue; + + if (!issueData) { + return null; + } + + // Map GraphQL response to IssueViewData (no awaits needed - all data already fetched!) + return { + // Core identification + id: issueData.id, + identifier: issueData.identifier, + title: issueData.title, + url: issueData.url, + + // Content + description: issueData.description || undefined, + + // Workflow + state: issueData.state + ? { + id: issueData.state.id, + name: issueData.state.name, + type: issueData.state.type as 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled', + color: issueData.state.color, + } + : { id: '', name: 'Unknown', type: 'backlog' as const, color: '#95a2b3' }, + priority: issueData.priority, + estimate: issueData.estimate || undefined, + + // Assignment + assignee: issueData.assignee + ? { + id: issueData.assignee.id, + name: issueData.assignee.name, + email: issueData.assignee.email, + } + : undefined, + subscribers: issueData.subscribers.nodes.map((sub: any) => ({ + id: sub.id, + name: sub.name, + email: sub.email, + })), + + // Organization + team: issueData.team + ? { + id: issueData.team.id, + key: issueData.team.key, + name: issueData.team.name, + } + : { id: '', key: '', name: 'Unknown' }, + project: issueData.project + ? { + id: issueData.project.id, + name: issueData.project.name, + } + : undefined, + cycle: issueData.cycle + ? { + id: issueData.cycle.id, + name: issueData.cycle.name || `Cycle #${issueData.cycle.number}`, + number: issueData.cycle.number, + } + : undefined, + parent: issueData.parent + ? { + id: issueData.parent.id, + identifier: issueData.parent.identifier, + title: issueData.parent.title, + } + : undefined, + children: issueData.children.nodes.map((child: any) => ({ + id: child.id, + identifier: child.identifier, + title: child.title, + state: child.state?.name || 'Unknown', + })), + labels: issueData.labels.nodes.map((label: any) => ({ + id: label.id, + name: label.name, + color: label.color, + })), + + // Dates + createdAt: issueData.createdAt, + updatedAt: issueData.updatedAt, + completedAt: issueData.completedAt, + canceledAt: issueData.canceledAt, + dueDate: issueData.dueDate, + archivedAt: issueData.archivedAt, + + // Creator + creator: issueData.creator + ? { + id: issueData.creator.id, + name: issueData.creator.name, + email: issueData.creator.email, + } + : { id: '', name: 'Unknown', email: '' }, + }; + } catch (error) { + return null; + } +} + +/** + * Get issue comments (M15.2) + * + * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): + * Uses custom GraphQL query to avoid N+1 pattern (2 + N API calls -> 1 call) + * Fetches all comment users upfront instead of lazy loading via SDK + * + * @param issueId - Issue UUID + * @returns Array of comments + */ +export async function getIssueComments(issueId: string): Promise< + Array<{ + id: string; + body: string; + createdAt: string; + updatedAt: string; + user: { + id: string; + name: string; + email: string; + }; + }> +> { + try { + const client = getLinearClient(); + + // Custom GraphQL query - fetch comments with user data in one request + const commentsQuery = ` + query GetIssueComments($issueId: String!) { + issue(id: $issueId) { + id + comments { + nodes { + id + body + createdAt + updatedAt + user { + id + name + email + } + } + } + } + } + `; + + const response: any = await client.client.rawRequest(commentsQuery, { issueId }); + const issueData = response.data?.issue; + + if (!issueData || !issueData.comments) { + return []; + } + + return issueData.comments.nodes.map((comment: any) => ({ + id: comment.id, + body: comment.body, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + user: comment.user + ? { + id: comment.user.id, + name: comment.user.name, + email: comment.user.email, + } + : { id: '', name: 'Unknown', email: '' }, + })); + } catch (error) { + return []; + } +} + +/** + * Get issue history (M15.2) + * + * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): + * Uses custom GraphQL query to avoid N+1 pattern (2 + 7N API calls -> 1 call) + * Fetches all history relationships upfront instead of lazy loading via SDK + * + * @param issueId - Issue UUID + * @returns Array of history entries + */ +export async function getIssueHistory(issueId: string): Promise< + Array<{ + id: string; + createdAt: string; + actor?: { + id: string; + name: string; + email: string; + }; + fromState?: string; + toState?: string; + fromAssignee?: string; + toAssignee?: string; + addedLabels?: string[]; + removedLabels?: string[]; + }> +> { + try { + const client = getLinearClient(); + + // Custom GraphQL query - fetch history with all relationships in one request + const historyQuery = ` + query GetIssueHistory($issueId: String!) { + issue(id: $issueId) { + id + history { + nodes { + id + createdAt + actor { + id + name + email + } + fromState { + id + name + } + toState { + id + name + } + fromAssignee { + id + name + } + toAssignee { + id + name + } + addedLabels { + id + name + } + removedLabels { + id + name + } + } + } + } + } + `; + + const response: any = await client.client.rawRequest(historyQuery, { issueId }); + const issueData = response.data?.issue; + + if (!issueData || !issueData.history) { + return []; + } + + return issueData.history.nodes.map((entry: any) => ({ + id: entry.id, + createdAt: entry.createdAt, + actor: entry.actor + ? { + id: entry.actor.id, + name: entry.actor.name, + email: entry.actor.email, + } + : undefined, + fromState: entry.fromState?.name, + toState: entry.toState?.name, + fromAssignee: entry.fromAssignee?.name, + toAssignee: entry.toAssignee?.name, + addedLabels: entry.addedLabels ? entry.addedLabels.map((l: any) => l.name) : undefined, + removedLabels: entry.removedLabels ? entry.removedLabels.map((l: any) => l.name) : undefined, + })); + } catch (error) { + return []; + } +} diff --git a/src/lib/api/labels.ts b/src/lib/api/labels.ts new file mode 100644 index 0000000..8c883ce --- /dev/null +++ b/src/lib/api/labels.ts @@ -0,0 +1,431 @@ +import { getLinearClient, LinearClientError } from './client.js'; +import type { IssueLabel, ProjectLabel } from '../types.js'; + +/** + * Issue Label types + */ +export interface IssueLabelCreateInput { + name: string; + color: string; + description?: string; + teamId?: string; // undefined for workspace-level labels +} + +export interface IssueLabelUpdateInput { + name?: string; + color?: string; + description?: string; +} + +/** + * Project Label types + */ +export interface ProjectLabelCreateInput { + name: string; + color: string; + description?: string; +} + +export interface ProjectLabelUpdateInput { + name?: string; + color?: string; + description?: string; +} + +/** + * Get all issue labels (workspace-level and/or team-level) + * + * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): + * Uses custom GraphQL query to avoid N+1 pattern when fetching all labels + * Previous: 1 + N API calls (1 for labels + N for teams) + * Optimized: 1 API call with nested team data + */ +export async function getAllIssueLabels(teamId?: string): Promise<IssueLabel[]> { + try { + const client = getLinearClient(); + const result: IssueLabel[] = []; + + if (teamId) { + // Get labels for a specific team (already efficient - 2 calls) + const team = await client.team(teamId); + if (!team) { + throw new Error(`Team not found: ${teamId}`); + } + + const labels = await team.labels(); + for (const label of labels.nodes) { + result.push({ + id: label.id, + name: label.name, + color: label.color, + description: label.description || undefined, + teamId: team.id, + }); + } + } else { + // Get all labels (workspace + all teams) - OPTIMIZED with custom GraphQL + const labelsQuery = ` + query GetAllIssueLabels { + issueLabels { + nodes { + id + name + color + description + team { + id + } + } + } + } + `; + + const response: any = await client.client.rawRequest(labelsQuery); + const labelsData = response.data?.issueLabels?.nodes || []; + + for (const label of labelsData) { + result.push({ + id: label.id, + name: label.name, + color: label.color, + description: label.description || undefined, + teamId: label.team?.id, + }); + } + } + + // Sort by name + return result.sort((a, b) => a.name.localeCompare(b.name)); + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch issue labels: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get a single issue label by ID + */ +export async function getIssueLabelById(id: string): Promise<IssueLabel | null> { + try { + const client = getLinearClient(); + const label = await client.issueLabel(id); + + if (!label) { + return null; + } + + const team = await label.team; + + return { + id: label.id, + name: label.name, + color: label.color, + description: label.description || undefined, + teamId: team?.id, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch issue label: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Create a new issue label + */ +export async function createIssueLabel(input: IssueLabelCreateInput): Promise<IssueLabel> { + try { + const client = getLinearClient(); + + const payload = await client.createIssueLabel({ + name: input.name, + color: input.color, + description: input.description, + teamId: input.teamId, + }); + + const label = await payload.issueLabel; + if (!label) { + throw new Error('Failed to create issue label: No label returned from API'); + } + + return { + id: label.id, + name: label.name, + color: label.color, + description: label.description || undefined, + teamId: input.teamId, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to create issue label: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Update an issue label + */ +export async function updateIssueLabel(id: string, input: IssueLabelUpdateInput): Promise<IssueLabel> { + try { + const client = getLinearClient(); + + const payload = await client.updateIssueLabel(id, { + name: input.name, + color: input.color, + description: input.description, + }); + + const label = await payload.issueLabel; + if (!label) { + throw new Error('Failed to update issue label: No label returned from API'); + } + + const team = await label.team; + + return { + id: label.id, + name: label.name, + color: label.color, + description: label.description || undefined, + teamId: team?.id, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to update issue label: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Delete an issue label + */ +export async function deleteIssueLabel(id: string): Promise<boolean> { + try { + const client = getLinearClient(); + const payload = await client.deleteIssueLabel(id); + return payload.success; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to delete issue label: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get all project labels (workspace-level only) + * @param includeAll - If true, fetches ALL labels including ones never applied to projects + */ +export async function getAllProjectLabels(includeAll?: boolean): Promise<ProjectLabel[]> { + try { + const client = getLinearClient(); + const result: ProjectLabel[] = []; + + if (includeAll) { + // Use raw GraphQL query to fetch ALL project labels including unused ones + const query = ` + query GetAllProjectLabels { + organization { + projectLabels { + nodes { + id + name + color + description + lastAppliedAt + } + } + } + } + `; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const response: any = await client.client.rawRequest(query); + + if (process.env.DEBUG) { + console.log(`DEBUG: Raw GraphQL response:`, JSON.stringify(response.data, null, 2)); + } + + const labels = response.data?.organization?.projectLabels?.nodes || []; + + for (const label of labels) { + result.push({ + id: label.id, + name: label.name, + color: label.color, + description: label.description || undefined, + }); + } + + if (process.env.DEBUG) { + console.log(`DEBUG: Fetched ${result.length} labels via raw GraphQL query`); + } + } else { + // Default: use SDK method which may only return labels that have been applied + const labels = await client.projectLabels(); + + for (const label of labels.nodes) { + result.push({ + id: label.id, + name: label.name, + color: label.color, + description: label.description || undefined, + }); + } + + if (process.env.DEBUG) { + console.log(`DEBUG: Fetched ${result.length} labels from client.projectLabels()`); + } + } + + // Sort by name + return result.sort((a, b) => a.name.localeCompare(b.name)); + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch project labels: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get a single project label by ID + */ +export async function getProjectLabelById(id: string): Promise<ProjectLabel | null> { + try { + const client = getLinearClient(); + const label = await client.projectLabel(id); + + if (!label) { + return null; + } + + return { + id: label.id, + name: label.name, + color: label.color, + description: label.description || undefined, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch project label: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Create a new project label + */ +export async function createProjectLabel(input: ProjectLabelCreateInput): Promise<ProjectLabel> { + try { + const client = getLinearClient(); + + const payload = await client.createProjectLabel({ + name: input.name, + color: input.color, + description: input.description, + }); + + const label = await payload.projectLabel; + if (!label) { + throw new Error('Failed to create project label: No label returned from API'); + } + + return { + id: label.id, + name: label.name, + color: label.color, + description: label.description || undefined, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to create project label: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Update a project label + */ +export async function updateProjectLabel(id: string, input: ProjectLabelUpdateInput): Promise<ProjectLabel> { + try { + const client = getLinearClient(); + + const payload = await client.updateProjectLabel(id, { + name: input.name, + color: input.color, + description: input.description, + }); + + const label = await payload.projectLabel; + if (!label) { + throw new Error('Failed to update project label: No label returned from API'); + } + + return { + id: label.id, + name: label.name, + color: label.color, + description: label.description || undefined, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to update project label: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Delete a project label + */ +export async function deleteProjectLabel(id: string): Promise<boolean> { + try { + const client = getLinearClient(); + const payload = await client.deleteProjectLabel(id); + return payload.success; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to delete project label: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} diff --git a/src/lib/api/members.ts b/src/lib/api/members.ts new file mode 100644 index 0000000..2ecfa2b --- /dev/null +++ b/src/lib/api/members.ts @@ -0,0 +1,342 @@ +import { getLinearClient, LinearClientError } from './client.js'; + +/** + * Member/User data structure + */ +export interface Member { + id: string; + name: string; + email: string; + active: boolean; + admin: boolean; + avatarUrl?: string; + displayName?: string; +} + +/** + * Get all members from Linear organization + * @param options - Optional filtering options + */ +export async function getAllMembers(options?: { + teamId?: string; + activeOnly?: boolean; + inactiveOnly?: boolean; + adminOnly?: boolean; + nameFilter?: string; + emailFilter?: string; +}): Promise<Member[]> { + try { + const client = getLinearClient(); + + // If team filter is specified, get team members + if (options?.teamId) { + const team = await client.team(options.teamId); + if (!team) { + throw new Error(`Team with ID "${options.teamId}" not found`); + } + const teamMembers = await team.members(); + + const result: Member[] = []; + for await (const member of teamMembers.nodes) { + result.push({ + id: member.id, + name: member.name, + email: member.email, + active: member.active, + admin: member.admin, + avatarUrl: member.avatarUrl || undefined, + displayName: member.displayName || undefined, + }); + } + + // Apply additional filters + return applyMemberFilters(result, options); + } + + // Otherwise get all organization users + const users = await client.users(); + + const result: Member[] = []; + for await (const user of users.nodes) { + result.push({ + id: user.id, + name: user.name, + email: user.email, + active: user.active, + admin: user.admin, + avatarUrl: user.avatarUrl || undefined, + displayName: user.displayName || undefined, + }); + } + + // Apply filters and sort + const filtered = applyMemberFilters(result, options); + return filtered.sort((a, b) => a.name.localeCompare(b.name)); + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch members: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Apply filters to member list + */ +function applyMemberFilters( + members: Member[], + options?: { + activeOnly?: boolean; + inactiveOnly?: boolean; + adminOnly?: boolean; + nameFilter?: string; + emailFilter?: string; + } +): Member[] { + let filtered = members; + + // Filter by active status + if (options?.activeOnly) { + filtered = filtered.filter(m => m.active); + } else if (options?.inactiveOnly) { + filtered = filtered.filter(m => !m.active); + } + + // Filter by admin status + if (options?.adminOnly) { + filtered = filtered.filter(m => m.admin); + } + + // Filter by name (case-insensitive partial match) + if (options?.nameFilter) { + const nameLower = options.nameFilter.toLowerCase(); + filtered = filtered.filter(m => + m.name.toLowerCase().includes(nameLower) || + (m.displayName && m.displayName.toLowerCase().includes(nameLower)) + ); + } + + // Filter by email (case-insensitive partial match) + if (options?.emailFilter) { + const emailLower = options.emailFilter.toLowerCase(); + filtered = filtered.filter(m => m.email.toLowerCase().includes(emailLower)); + } + + return filtered; +} + +/** + * Get a single member by ID + */ +export async function getMemberById( + userId: string +): Promise<{ id: string; name: string; email: string; active: boolean; admin: boolean } | null> { + try { + // Use entity cache instead of direct API call + const { getEntityCache } = await import('../entity-cache.js'); + const cache = getEntityCache(); + const member = await cache.findMemberById(userId); + + if (!member) { + return null; + } + + return { + id: member.id, + name: member.name, + email: member.email, + active: member.active, + admin: member.admin, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch member: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get member by exact email match (case-insensitive) + */ +export async function getMemberByEmail(email: string): Promise<Member | null> { + try { + // Use entity cache instead of fetching all members + const { getEntityCache } = await import('../entity-cache.js'); + const cache = getEntityCache(); + const member = await cache.findMemberByEmail(email); + return member; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to search member by email: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Search members by email or name filter + * Returns array of matching members (active members only by default) + */ +export async function searchMembers(options: { + emailFilter?: string; + nameFilter?: string; + activeOnly?: boolean; +}): Promise<Member[]> { + try { + return await getAllMembers({ + emailFilter: options.emailFilter, + nameFilter: options.nameFilter, + activeOnly: options.activeOnly !== false, // Default to true + }); + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to search members: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get member by display name (M15.1) + * Returns single member or throws error for disambiguation if multiple matches + * + * @param displayName - The display name to search for (case-insensitive) + * @returns Member details or null if not found + * @throws Error if multiple members match (requires disambiguation) + */ +export async function getMemberByDisplayName(displayName: string): Promise<Member | null> { + try { + // Use entity cache to get all members + const { getEntityCache } = await import('../entity-cache.js'); + const cache = getEntityCache(); + const members = await cache.getMembers(); + + // Filter by display name (case-insensitive exact match) + const normalizedName = displayName.trim().toLowerCase(); + const matches = members.filter(m => + m.displayName?.toLowerCase() === normalizedName || + m.name.toLowerCase() === normalizedName + ); + + if (matches.length === 0) { + return null; + } + + if (matches.length === 1) { + return matches[0]; + } + + // Multiple matches - require disambiguation + const matchList = matches.map(m => ` - ${m.name} (${m.email})`).join('\n'); + throw new Error( + `Multiple users match "${displayName}":\n${matchList}\n\nPlease use email or ID to specify which user.` + ); + } catch (error) { + // Re-throw disambiguation errors + if (error instanceof Error && error.message.includes('Multiple users match')) { + throw error; + } + + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to search member by display name: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Resolve a member identifier (ID, alias, email, or display name) to a member (M15.1 Enhanced) + * Tries multiple lookup strategies in order: + * 1. Alias resolution (if configured) + * 2. Direct ID lookup (if looks like a UUID) + * 3. Email lookup (if contains @) + * 4. Display name lookup (fallback) + * + * @param identifier - The member identifier (ID, alias, email, or display name) + * @param resolveAliasFn - Optional alias resolver function + * @returns Member details or null if not found + * @throws Error if display name matches multiple users (disambiguation required) + */ +export async function resolveMemberIdentifier( + identifier: string, + resolveAliasFn?: (type: 'member', value: string) => string +): Promise<{ id: string; name: string; email: string } | null> { + try { + const trimmedId = identifier.trim(); + + // Try alias resolution first (if resolver provided) + let resolvedId = trimmedId; + if (resolveAliasFn) { + const aliasResolved = resolveAliasFn('member', trimmedId); + if (aliasResolved !== trimmedId) { + resolvedId = aliasResolved; + // Alias was found, now validate the resolved ID + const member = await getMemberById(resolvedId); + if (member) { + return member; + } + } + } + + // Try direct ID lookup if it looks like a UUID + // Linear UUIDs are lowercase hex with dashes (e.g., "a1b2c3d4-...") + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (uuidPattern.test(resolvedId)) { + const member = await getMemberById(resolvedId); + if (member) { + return member; + } + } + + // Try email lookup if it contains @ + if (trimmedId.includes('@')) { + const member = await getMemberByEmail(trimmedId); + if (member) { + return { + id: member.id, + name: member.name, + email: member.email, + }; + } + } + + // Try display name lookup as fallback (M15.1) + // This may throw an error if multiple matches (disambiguation required) + const memberByName = await getMemberByDisplayName(trimmedId); + if (memberByName) { + return { + id: memberByName.id, + name: memberByName.name, + email: memberByName.email, + }; + } + + // Not found by any method + return null; + } catch (error) { + // Re-throw disambiguation errors so caller can show them to user + if (error instanceof Error && error.message.includes('Multiple users match')) { + throw error; + } + + // For other errors, return null + // The caller will handle the error messaging + return null; + } +} diff --git a/src/lib/api/templates.ts b/src/lib/api/templates.ts new file mode 100644 index 0000000..f0d1044 --- /dev/null +++ b/src/lib/api/templates.ts @@ -0,0 +1,106 @@ +import { getLinearClient, LinearClientError } from './client.js'; + +/** + * Template data structure + */ +export interface Template { + id: string; + name: string; + type: 'issue' | 'project'; + description?: string; +} + +/** + * Get all templates from Linear + */ +export async function getAllTemplates(typeFilter?: 'issue' | 'project'): Promise<Template[]> { + try { + const client = getLinearClient(); + const result: Template[] = []; + + // Fetch all templates from Linear + try { + // client.templates returns LinearFetch<Template[]> which is Promise<Template[]> + const templates = await client.templates; + + for (const template of templates) { + // Determine template type based on the 'type' field from Linear + let templateType: 'issue' | 'project'; + + if (template.type.toLowerCase().includes('project')) { + templateType = 'project'; + } else { + // Default to issue template (most common case) + templateType = 'issue'; + } + + // Apply filter if specified + if (typeFilter && templateType !== typeFilter) { + continue; + } + + result.push({ + id: template.id, + name: template.name, + type: templateType, + description: template.description || undefined, + }); + } + } catch (err) { + // Templates may not be available - log the error for debugging + if (process.env.DEBUG) { + console.error('Error fetching templates:', err); + } + throw err; // Re-throw to let caller know there was an error + } + + // Sort by type then name + return result.sort((a, b) => { + if (a.type !== b.type) { + return a.type.localeCompare(b.type); + } + return a.name.localeCompare(b.name); + }); + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch templates: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get a single template by ID + */ +export async function getTemplateById( + templateId: string +): Promise<{ id: string; name: string; type: 'issue' | 'project'; description?: string } | null> { + try { + // Use entity cache instead of direct API call + const { getEntityCache } = await import('../entity-cache.js'); + const cache = getEntityCache(); + const template = await cache.findTemplateById(templateId); + + if (!template) { + return null; + } + + return { + id: template.id, + name: template.name, + type: template.type, + description: template.description || undefined, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch template: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} diff --git a/src/lib/api/workflow-states.ts b/src/lib/api/workflow-states.ts new file mode 100644 index 0000000..7b6553b --- /dev/null +++ b/src/lib/api/workflow-states.ts @@ -0,0 +1,247 @@ +import { getLinearClient, LinearClientError } from './client.js'; +import type { WorkflowState } from '../types.js'; + +/** + * Workflow State input types + */ +export interface WorkflowStateCreateInput { + name: string; + teamId: string; + type: 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled'; + color: string; + description?: string; + position?: number; +} + +export interface WorkflowStateUpdateInput { + name?: string; + type?: 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled'; + color?: string; + description?: string; + position?: number; +} + +/** + * Get all workflow states for a team (or all teams) + * + * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): + * Uses custom GraphQL query to avoid N+1 pattern when fetching all workflow states + * Previous: 1 + N API calls (1 for teams + N for states per team) + * Optimized: 1 API call with nested states data + */ +export async function getAllWorkflowStates(teamId?: string): Promise<WorkflowState[]> { + try { + const client = getLinearClient(); + const result: WorkflowState[] = []; + + if (teamId) { + // Get workflow states for a specific team (already efficient - 2 calls) + const team = await client.team(teamId); + if (!team) { + throw new Error(`Team not found: ${teamId}`); + } + + const states = await team.states(); + for (const state of states.nodes) { + result.push({ + id: state.id, + name: state.name, + type: state.type as 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled', + color: state.color, + description: state.description || undefined, + position: state.position, + teamId: team.id, + }); + } + } else { + // Get workflow states for all teams - OPTIMIZED with custom GraphQL + const statesQuery = ` + query GetAllWorkflowStates { + teams { + nodes { + id + states { + nodes { + id + name + type + color + description + position + } + } + } + } + } + `; + + const response: any = await client.client.rawRequest(statesQuery); + const teamsData = response.data?.teams?.nodes || []; + + for (const team of teamsData) { + for (const state of team.states.nodes) { + result.push({ + id: state.id, + name: state.name, + type: state.type as 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled', + color: state.color, + description: state.description || undefined, + position: state.position, + teamId: team.id, + }); + } + } + } + + // Sort by team, then position + return result.sort((a, b) => { + if (a.teamId !== b.teamId) { + return a.teamId.localeCompare(b.teamId); + } + return a.position - b.position; + }); + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch workflow states: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get a single workflow state by ID + */ +export async function getWorkflowStateById(id: string): Promise<WorkflowState | null> { + try { + const client = getLinearClient(); + const state = await client.workflowState(id); + + if (!state) { + return null; + } + + const team = await state.team; + + return { + id: state.id, + name: state.name, + type: state.type as 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled', + color: state.color, + description: state.description || undefined, + position: state.position, + teamId: team?.id || '', + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to fetch workflow state: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Create a new workflow state + */ +export async function createWorkflowState(input: WorkflowStateCreateInput): Promise<WorkflowState> { + try { + const client = getLinearClient(); + + const payload = await client.createWorkflowState({ + name: input.name, + teamId: input.teamId, + type: input.type, + color: input.color, + description: input.description, + position: input.position, + }); + + const state = await payload.workflowState; + if (!state) { + throw new Error('Failed to create workflow state: No state returned from API'); + } + + return { + id: state.id, + name: state.name, + type: state.type as 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled', + color: state.color, + description: state.description || undefined, + position: state.position, + teamId: input.teamId, + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to create workflow state: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Update a workflow state + */ +export async function updateWorkflowState(id: string, input: WorkflowStateUpdateInput): Promise<WorkflowState> { + try { + const client = getLinearClient(); + + const payload = await client.updateWorkflowState(id, { + name: input.name, + color: input.color, + description: input.description, + position: input.position, + }); + + const state = await payload.workflowState; + if (!state) { + throw new Error('Failed to update workflow state: No state returned from API'); + } + + const team = await state.team; + + return { + id: state.id, + name: state.name, + type: state.type as 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled', + color: state.color, + description: state.description || undefined, + position: state.position, + teamId: team?.id || '', + }; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to update workflow state: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Delete a workflow state (archives it in Linear) + */ +export async function deleteWorkflowState(id: string): Promise<boolean> { + try { + const client = getLinearClient(); + const payload = await client.archiveWorkflowState(id); + return payload.success; + } catch (error) { + if (error instanceof LinearClientError) { + throw error; + } + + throw new Error( + `Failed to delete workflow state: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} diff --git a/src/lib/linear-client.ts b/src/lib/linear-client.ts index 28c7ef9..5bd2f24 100644 --- a/src/lib/linear-client.ts +++ b/src/lib/linear-client.ts @@ -1,4195 +1,3 @@ -import { LinearClient as SDKClient } from '@linear/sdk'; -import { getApiKey } from './config.js'; -import type { - ProjectListFilters, - ProjectListItem, - ProjectRelation, - ProjectRelationCreateInput, - IssueCreateInput, - IssueUpdateInput, - IssueListFilters, - IssueListItem, - IssueViewData, -} from './types.js'; -import { getRelationDirection } from './parsers.js'; - -export class LinearClientError extends Error { - constructor(message: string) { - super(message); - this.name = 'LinearClientError'; - } -} - -/** - * Cached singleton Linear client instance - */ -let cachedClient: SDKClient | null = null; - -/** - * Get authenticated Linear client (singleton - reuses same instance) - */ -export function getLinearClient(): SDKClient { - if (cachedClient) return cachedClient; - - const apiKey = getApiKey(); - - if (!apiKey) { - throw new LinearClientError( - 'Linear API key not found. Please set LINEAR_API_KEY environment variable or configure it using the config file.' - ); - } - - // Validate API key format (Linear API keys start with "lin_api_") - if (!apiKey.startsWith('lin_api_')) { - throw new LinearClientError( - 'Invalid Linear API key format. API keys should start with "lin_api_"' - ); - } - - cachedClient = new SDKClient({ apiKey }); - return cachedClient; -} - -/** - * Get the current user's organization - */ -export async function getOrganization(): Promise<{ - id: string; - name: string; - urlKey: string; -}> { - try { - const client = getLinearClient(); - const org = await client.organization; - return { - id: org.id, - name: org.name, - urlKey: org.urlKey, - }; - } catch (error) { - throw new LinearClientError( - `Failed to get organization: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Create a comment on an issue - */ -export async function createIssueComment( - issueId: string, - body: string -): Promise<{ id: string; body: string }> { - try { - const client = getLinearClient(); - const result = await client.createComment({ issueId, body }); - const comment = await result.comment; - if (!comment) { - throw new Error('Comment creation returned no comment'); - } - return { id: comment.id, body: comment.body }; - } catch (error) { - if (error instanceof LinearClientError) throw error; - throw new LinearClientError( - `Failed to create comment: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get all cycles, optionally filtered by team - */ -export async function getAllCycles(teamId?: string): Promise<Array<{ - id: string; - name: string; - number: number; - startsAt?: string; - endsAt?: string; - teamId?: string; - teamName?: string; -}>> { - try { - const client = getLinearClient(); - let cycles; - if (teamId) { - const team = await client.team(teamId); - cycles = await team.cycles(); - } else { - cycles = await client.cycles(); - } - - const results = []; - for (const cycle of cycles.nodes) { - const team = await cycle.team; - results.push({ - id: cycle.id, - name: cycle.name || `Cycle ${cycle.number}`, - number: cycle.number, - startsAt: cycle.startsAt instanceof Date ? cycle.startsAt.toISOString().split('T')[0] : undefined, - endsAt: cycle.endsAt instanceof Date ? cycle.endsAt.toISOString().split('T')[0] : undefined, - teamId: team?.id, - teamName: team?.name, - }); - } - return results; - } catch (error) { - if (error instanceof LinearClientError) throw error; - throw new LinearClientError( - `Failed to fetch cycles: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Test the Linear API connection - */ -export async function testConnection(): Promise<{ - success: boolean; - error?: string; - user?: { name: string; email: string }; -}> { - try { - const client = getLinearClient(); - const viewer = await client.viewer; - - return { - success: true, - user: { - name: viewer.name, - email: viewer.email, - }, - }; - } catch (error) { - if (error instanceof LinearClientError) { - return { - success: false, - error: error.message, - }; - } - - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } -} - -/** - * Get current user information - */ -export async function getCurrentUser(): Promise<{ - id: string; - name: string; - email: string; -}> { - try { - const client = getLinearClient(); - const viewer = await client.viewer; - - return { - id: viewer.id, - name: viewer.name, - email: viewer.email, - }; - } catch (error) { - throw new LinearClientError( - `Failed to get current user: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Validate API key by testing connection - */ -export async function validateApiKey(apiKey: string): Promise<boolean> { - try { - const client = new SDKClient({ apiKey }); - await client.viewer; - return true; - } catch { - return false; - } -} - -/** - * Validate initiative ID exists and return its details - */ -export async function validateInitiativeExists( - initiativeId: string -): Promise<{ valid: boolean; name?: string; error?: string }> { - try { - // Use entity cache instead of direct API call - const { getEntityCache } = await import('./entity-cache.js'); - const cache = getEntityCache(); - const initiative = await cache.findInitiativeById(initiativeId); - - if (!initiative) { - return { - valid: false, - error: `Initiative with ID "${initiativeId}" not found`, - }; - } - - return { - valid: true, - name: initiative.name, - }; - } catch (error) { - if (error instanceof LinearClientError) { - return { - valid: false, - error: error.message, - }; - } - - return { - valid: false, - error: error instanceof Error ? error.message : 'Failed to validate initiative', - }; - } -} - -/** - * Validate team ID exists and return its details - */ -export async function validateTeamExists( - teamId: string -): Promise<{ valid: boolean; name?: string; error?: string }> { - try { - // Use entity cache instead of direct API call - const { getEntityCache } = await import('./entity-cache.js'); - const cache = getEntityCache(); - const team = await cache.findTeamById(teamId); - - if (!team) { - return { - valid: false, - error: `Team with ID "${teamId}" not found`, - }; - } - - return { - valid: true, - name: team.name, - }; - } catch (error) { - if (error instanceof LinearClientError) { - return { - valid: false, - error: error.message, - }; - } - - return { - valid: false, - error: error instanceof Error ? error.message : 'Failed to validate team', - }; - } -} - -/** - * Initiative data structure - */ -export interface Initiative { - id: string; - name: string; - description?: string; - status?: string; -} - -/** - * Team data structure - */ -export interface Team { - id: string; - name: string; - description?: string; - key: string; -} - -/** - * Project data structure (for listing/selection) - */ -export interface Project { - id: string; - name: string; - description?: string; - icon?: string; -} - -/** - * Member/User data structure - */ -export interface Member { - id: string; - name: string; - email: string; - active: boolean; - admin: boolean; - avatarUrl?: string; - displayName?: string; -} - -/** - * Get all initiatives from Linear - */ -export async function getAllInitiatives(): Promise<Initiative[]> { - try { - const client = getLinearClient(); - const initiatives = await client.initiatives(); - - const result: Initiative[] = []; - for await (const initiative of initiatives.nodes) { - result.push({ - id: initiative.id, - name: initiative.name, - description: initiative.description, - }); - } - - // Sort by name - return result.sort((a, b) => a.name.localeCompare(b.name)); - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch initiatives: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get a single initiative by ID - */ -export async function getInitiativeById( - initiativeId: string -): Promise<{ id: string; name: string; description?: string; url: string } | null> { - try { - const client = getLinearClient(); - const initiative = await client.initiative(initiativeId); - - if (!initiative) { - return null; - } - - return { - id: initiative.id, - name: initiative.name, - description: initiative.description || undefined, - url: initiative.url, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch initiative: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get all teams from Linear - */ -export async function getAllTeams(): Promise<Team[]> { - try { - const client = getLinearClient(); - const teams = await client.teams(); - - const result: Team[] = []; - for await (const team of teams.nodes) { - result.push({ - id: team.id, - name: team.name, - description: team.description || undefined, - key: team.key, - }); - } - - // Sort by name - return result.sort((a, b) => a.name.localeCompare(b.name)); - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch teams: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get a single team by ID - */ -export async function getTeamById( - teamId: string -): Promise<{ id: string; name: string; key: string; description?: string; url: string } | null> { - try { - const client = getLinearClient(); - const team = await client.team(teamId); - - if (!team) { - return null; - } - - return { - id: team.id, - name: team.name, - key: team.key, - description: team.description || undefined, - url: `https://linear.app/team/${team.key.toLowerCase()}`, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch team: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get all members from Linear organization - * @param options - Optional filtering options - */ -export async function getAllMembers(options?: { - teamId?: string; - activeOnly?: boolean; - inactiveOnly?: boolean; - adminOnly?: boolean; - nameFilter?: string; - emailFilter?: string; -}): Promise<Member[]> { - try { - const client = getLinearClient(); - - // If team filter is specified, get team members - if (options?.teamId) { - const team = await client.team(options.teamId); - if (!team) { - throw new Error(`Team with ID "${options.teamId}" not found`); - } - const teamMembers = await team.members(); - - const result: Member[] = []; - for await (const member of teamMembers.nodes) { - result.push({ - id: member.id, - name: member.name, - email: member.email, - active: member.active, - admin: member.admin, - avatarUrl: member.avatarUrl || undefined, - displayName: member.displayName || undefined, - }); - } - - // Apply additional filters - return applyMemberFilters(result, options); - } - - // Otherwise get all organization users - const users = await client.users(); - - const result: Member[] = []; - for await (const user of users.nodes) { - result.push({ - id: user.id, - name: user.name, - email: user.email, - active: user.active, - admin: user.admin, - avatarUrl: user.avatarUrl || undefined, - displayName: user.displayName || undefined, - }); - } - - // Apply filters and sort - const filtered = applyMemberFilters(result, options); - return filtered.sort((a, b) => a.name.localeCompare(b.name)); - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch members: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Apply filters to member list - */ -function applyMemberFilters( - members: Member[], - options?: { - activeOnly?: boolean; - inactiveOnly?: boolean; - adminOnly?: boolean; - nameFilter?: string; - emailFilter?: string; - } -): Member[] { - let filtered = members; - - // Filter by active status - if (options?.activeOnly) { - filtered = filtered.filter(m => m.active); - } else if (options?.inactiveOnly) { - filtered = filtered.filter(m => !m.active); - } - - // Filter by admin status - if (options?.adminOnly) { - filtered = filtered.filter(m => m.admin); - } - - // Filter by name (case-insensitive partial match) - if (options?.nameFilter) { - const nameLower = options.nameFilter.toLowerCase(); - filtered = filtered.filter(m => - m.name.toLowerCase().includes(nameLower) || - (m.displayName && m.displayName.toLowerCase().includes(nameLower)) - ); - } - - // Filter by email (case-insensitive partial match) - if (options?.emailFilter) { - const emailLower = options.emailFilter.toLowerCase(); - filtered = filtered.filter(m => m.email.toLowerCase().includes(emailLower)); - } - - return filtered; -} - -/** - * Get a single member by ID - */ -export async function getMemberById( - userId: string -): Promise<{ id: string; name: string; email: string; active: boolean; admin: boolean } | null> { - try { - // Use entity cache instead of direct API call - const { getEntityCache } = await import('./entity-cache.js'); - const cache = getEntityCache(); - const member = await cache.findMemberById(userId); - - if (!member) { - return null; - } - - return { - id: member.id, - name: member.name, - email: member.email, - active: member.active, - admin: member.admin, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch member: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get member by exact email match (case-insensitive) - */ -export async function getMemberByEmail(email: string): Promise<Member | null> { - try { - // Use entity cache instead of fetching all members - const { getEntityCache } = await import('./entity-cache.js'); - const cache = getEntityCache(); - const member = await cache.findMemberByEmail(email); - return member; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to search member by email: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Search members by email or name filter - * Returns array of matching members (active members only by default) - */ -export async function searchMembers(options: { - emailFilter?: string; - nameFilter?: string; - activeOnly?: boolean; -}): Promise<Member[]> { - try { - return await getAllMembers({ - emailFilter: options.emailFilter, - nameFilter: options.nameFilter, - activeOnly: options.activeOnly !== false, // Default to true - }); - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to search members: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get member by display name (M15.1) - * Returns single member or throws error for disambiguation if multiple matches - * - * @param displayName - The display name to search for (case-insensitive) - * @returns Member details or null if not found - * @throws Error if multiple members match (requires disambiguation) - */ -export async function getMemberByDisplayName(displayName: string): Promise<Member | null> { - try { - // Use entity cache to get all members - const { getEntityCache } = await import('./entity-cache.js'); - const cache = getEntityCache(); - const members = await cache.getMembers(); - - // Filter by display name (case-insensitive exact match) - const normalizedName = displayName.trim().toLowerCase(); - const matches = members.filter(m => - m.displayName?.toLowerCase() === normalizedName || - m.name.toLowerCase() === normalizedName - ); - - if (matches.length === 0) { - return null; - } - - if (matches.length === 1) { - return matches[0]; - } - - // Multiple matches - require disambiguation - const matchList = matches.map(m => ` - ${m.name} (${m.email})`).join('\n'); - throw new Error( - `Multiple users match "${displayName}":\n${matchList}\n\nPlease use email or ID to specify which user.` - ); - } catch (error) { - // Re-throw disambiguation errors - if (error instanceof Error && error.message.includes('Multiple users match')) { - throw error; - } - - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to search member by display name: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Resolve a member identifier (ID, alias, email, or display name) to a member (M15.1 Enhanced) - * Tries multiple lookup strategies in order: - * 1. Alias resolution (if configured) - * 2. Direct ID lookup (if looks like a UUID) - * 3. Email lookup (if contains @) - * 4. Display name lookup (fallback) - * - * @param identifier - The member identifier (ID, alias, email, or display name) - * @param resolveAliasFn - Optional alias resolver function - * @returns Member details or null if not found - * @throws Error if display name matches multiple users (disambiguation required) - */ -export async function resolveMemberIdentifier( - identifier: string, - resolveAliasFn?: (type: 'member', value: string) => string -): Promise<{ id: string; name: string; email: string } | null> { - try { - const trimmedId = identifier.trim(); - - // Try alias resolution first (if resolver provided) - let resolvedId = trimmedId; - if (resolveAliasFn) { - const aliasResolved = resolveAliasFn('member', trimmedId); - if (aliasResolved !== trimmedId) { - resolvedId = aliasResolved; - // Alias was found, now validate the resolved ID - const member = await getMemberById(resolvedId); - if (member) { - return member; - } - } - } - - // Try direct ID lookup if it looks like a UUID - // Linear UUIDs are lowercase hex with dashes (e.g., "a1b2c3d4-...") - const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (uuidPattern.test(resolvedId)) { - const member = await getMemberById(resolvedId); - if (member) { - return member; - } - } - - // Try email lookup if it contains @ - if (trimmedId.includes('@')) { - const member = await getMemberByEmail(trimmedId); - if (member) { - return { - id: member.id, - name: member.name, - email: member.email, - }; - } - } - - // Try display name lookup as fallback (M15.1) - // This may throw an error if multiple matches (disambiguation required) - const memberByName = await getMemberByDisplayName(trimmedId); - if (memberByName) { - return { - id: memberByName.id, - name: memberByName.name, - email: memberByName.email, - }; - } - - // Not found by any method - return null; - } catch (error) { - // Re-throw disambiguation errors so caller can show them to user - if (error instanceof Error && error.message.includes('Multiple users match')) { - throw error; - } - - // For other errors, return null - // The caller will handle the error messaging - return null; - } -} - -/** - * Get cycle by ID (M15.1) - * @param cycleId - Cycle UUID - * @returns Cycle details or null if not found - */ -export async function getCycleById(cycleId: string): Promise<{ - id: string; - name: string; - number: number; - startsAt?: string; - endsAt?: string; -} | null> { - try { - const client = getLinearClient(); - const cycle = await client.cycle(cycleId); - - if (!cycle) { - return null; - } - - return { - id: cycle.id, - name: cycle.name || `Cycle #${cycle.number}`, - number: cycle.number, - startsAt: cycle.startsAt?.toString(), - endsAt: cycle.endsAt?.toString(), - }; - } catch (error) { - return null; - } -} - -/** - * Resolve cycle identifier (UUID or alias) to cycle ID (M15.1) - * Supports both UUID format and alias resolution via the alias system - * - * @param identifier - Cycle ID (UUID) or alias - * @param resolveAliasFn - Optional alias resolver function - * @returns Cycle ID (UUID) or null if not found - */ -export async function resolveCycleIdentifier( - identifier: string, - resolveAliasFn?: (type: 'cycle', value: string) => string -): Promise<string | null> { - try { - const trimmedId = identifier.trim(); - - // Try alias resolution first (if resolver provided) - let resolvedId = trimmedId; - if (resolveAliasFn) { - const aliasResolved = resolveAliasFn('cycle', trimmedId); - if (aliasResolved !== trimmedId) { - resolvedId = aliasResolved; - // Alias was found, now validate the resolved ID - const cycle = await getCycleById(resolvedId); - if (cycle) { - return cycle.id; - } - } - } - - // Try direct ID lookup if it looks like a UUID - const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (uuidPattern.test(resolvedId)) { - const cycle = await getCycleById(resolvedId); - if (cycle) { - return cycle.id; - } - } - - // Not found by any method - return null; - } catch (error) { - return null; - } -} - -/** - * Create a new issue (M15.1) - * @param input - Issue creation data - * @returns Created issue details - */ -export async function createIssue(input: IssueCreateInput): Promise<{ - id: string; - identifier: string; - title: string; - url: string; -}> { - try { - const client = getLinearClient(); - - // Create the issue with all provided fields - const issue = await client.createIssue({ - title: input.title, - teamId: input.teamId, - description: input.description, - descriptionData: input.descriptionData, - priority: input.priority, - estimate: input.estimate, - stateId: input.stateId, - assigneeId: input.assigneeId, - subscriberIds: input.subscriberIds, - projectId: input.projectId, - cycleId: input.cycleId, - parentId: input.parentId, - labelIds: input.labelIds, - dueDate: input.dueDate, - templateId: input.templateId, - }); - - const createdIssue = await issue.issue; - - if (!createdIssue) { - throw new Error('Issue creation failed - no issue returned'); - } - - return { - id: createdIssue.id, - identifier: createdIssue.identifier, - title: createdIssue.title, - url: createdIssue.url, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Update an existing issue (M15.1) - * @param issueId - Issue UUID - * @param input - Issue update data - * @returns Updated issue details - */ -export async function updateIssue(issueId: string, input: IssueUpdateInput): Promise<{ - id: string; - identifier: string; - title: string; - url: string; -}> { - try { - const client = getLinearClient(); - - // Update the issue with all provided fields - const issue = await client.updateIssue(issueId, { - title: input.title, - description: input.description, - descriptionData: input.descriptionData, - priority: input.priority, - estimate: input.estimate, - stateId: input.stateId, - assigneeId: input.assigneeId, - subscriberIds: input.subscriberIds, - teamId: input.teamId, - projectId: input.projectId, - cycleId: input.cycleId, - parentId: input.parentId, - labelIds: input.labelIds, - dueDate: input.dueDate, - trashed: input.trashed, - }); - - const updatedIssue = await issue.issue; - - if (!updatedIssue) { - throw new Error('Issue update failed - no issue returned'); - } - - return { - id: updatedIssue.id, - identifier: updatedIssue.identifier, - title: updatedIssue.title, - url: updatedIssue.url, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to update issue: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get issue by UUID (M15.1) - * @param issueId - Issue UUID - * @returns Issue details or null if not found - */ -export async function getIssueById(issueId: string): Promise<{ - id: string; - identifier: string; - title: string; - description?: string; - url: string; -} | null> { - try { - const client = getLinearClient(); - const issue = await client.issue(issueId); - - if (!issue) { - return null; - } - - return { - id: issue.id, - identifier: issue.identifier, - title: issue.title, - description: issue.description || undefined, - url: issue.url, - }; - } catch (error) { - return null; - } -} - -/** - * Get issue by identifier (ENG-123 format) (M15.1) - * @param identifier - Issue identifier (e.g., "ENG-123") - * @returns Issue details or null if not found - */ -export async function getIssueByIdentifier(identifier: string): Promise<{ - id: string; - identifier: string; - title: string; - url: string; -} | null> { - try { - // Use the issue resolver to convert identifier to UUID - const { resolveIssueId } = await import('./issue-resolver.js'); - const issueId = await resolveIssueId(identifier); - - if (!issueId) { - return null; - } - - return await getIssueById(issueId); - } catch (error) { - return null; - } -} - -/** - * Get all issues with optional filtering (M15.1) - * @param filters - Optional filters for issues - * @returns Array of issues matching the filters - */ -/** - * Get all issues with comprehensive filtering and pagination (M15.5) - * - * PERFORMANCE CRITICAL: This function uses a custom GraphQL query to fetch - * ALL issue data and relations in a SINGLE request to avoid N+1 query patterns. - * - * Pattern follows getAllProjects() optimization from M20/M21. - * - * @param filters - Optional filters for issues - * @returns Array of issues with all display data - */ -export async function getAllIssues(filters?: IssueListFilters): Promise<IssueListItem[]> { - try { - const client = getLinearClient(); - const startTime = Date.now(); - - // Track API call if tracking is enabled - const { isTracking, logCall } = await import('./api-call-tracker.js'); - const tracking = isTracking(); - - // ======================================== - // BUILD GRAPHQL FILTER - // ======================================== - const graphqlFilter: any = {}; - - if (filters?.teamId) { - graphqlFilter.team = { id: { eq: filters.teamId } }; - } - - if (filters?.assigneeId) { - graphqlFilter.assignee = { id: { eq: filters.assigneeId } }; - } - - if (filters?.projectId) { - graphqlFilter.project = { id: { eq: filters.projectId } }; - } - - if (filters?.initiativeId) { - graphqlFilter.initiative = { id: { eq: filters.initiativeId } }; - } - - if (filters?.stateId) { - graphqlFilter.state = { id: { eq: filters.stateId } }; - } - - if (filters?.priority !== undefined) { - graphqlFilter.priority = { eq: filters.priority }; - } - - if (filters?.parentId) { - graphqlFilter.parent = { id: { eq: filters.parentId } }; - } - - if (filters?.cycleId) { - graphqlFilter.cycle = { id: { eq: filters.cycleId } }; - } - - if (filters?.hasParent !== undefined) { - graphqlFilter.parent = filters.hasParent ? { null: false } : { null: true }; - } - - if (filters?.labelIds && filters.labelIds.length > 0) { - // Issues with ALL of these labels - graphqlFilter.labels = { some: { id: { in: filters.labelIds } } }; - } - - if (filters?.search) { - graphqlFilter.searchableContent = { contains: filters.search }; - } - - // Date range filters - if (filters?.createdAfter || filters?.createdBefore) { - graphqlFilter.createdAt = {}; - if (filters.createdAfter) graphqlFilter.createdAt.gte = new Date(filters.createdAfter).toISOString(); - if (filters.createdBefore) graphqlFilter.createdAt.lte = new Date(filters.createdBefore).toISOString(); - } - - if (filters?.updatedAfter || filters?.updatedBefore) { - graphqlFilter.updatedAt = {}; - if (filters.updatedAfter) graphqlFilter.updatedAt.gte = new Date(filters.updatedAfter).toISOString(); - if (filters.updatedBefore) graphqlFilter.updatedAt.lte = new Date(filters.updatedBefore).toISOString(); - } - - // Handle status filters - if (filters?.includeCompleted === false) { - graphqlFilter.completedAt = { null: true }; - } - - if (filters?.includeCanceled === false) { - graphqlFilter.canceledAt = { null: true }; - } - - if (filters?.includeArchived === false) { - graphqlFilter.archivedAt = { null: true }; - } - - if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { - console.error('[agent2linear] Issue filters:', JSON.stringify(graphqlFilter, null, 2)); - } - - // ======================================== - // PAGINATION SETUP (M15.5 Phase 1) - // ======================================== - // Determine page size based on fetchAll flag: - // - If --all: use 250 (max) for optimal performance (5x faster) - // - Otherwise: use limit (capped at 250) - const pageSize = filters?.fetchAll ? 250 : Math.min(filters?.limit || 50, 250); - const fetchAll = filters?.fetchAll || false; - const targetLimit = filters?.limit || 50; - - if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { - console.error('[agent2linear] Pagination:', { pageSize, fetchAll, targetLimit }); - } - - // ======================================== - // CUSTOM GRAPHQL QUERY - ALL RELATIONS UPFRONT - // ======================================== - // This query fetches ALL display data in ONE request to avoid N+1 patterns. - // Includes: assignee, team, state, project, cycle, labels, parent - const issuesQuery = ` - query GetIssues($filter: IssueFilter, $first: Int, $after: String) { - issues(filter: $filter, first: $first, after: $after) { - nodes { - id - identifier - title - description - priority - estimate - dueDate - createdAt - updatedAt - completedAt - canceledAt - archivedAt - url - - assignee { - id - name - email - } - - team { - id - key - name - } - - state { - id - name - type - } - - project { - id - name - } - - cycle { - id - name - number - } - - labels { - nodes { - id - name - color - } - } - - parent { - id - identifier - title - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - `; - - // ======================================== - // PAGINATION LOOP (M15.5 Phase 1) - // ======================================== - // Fetch pages until: - // - No more pages (hasNextPage = false), OR - // - Reached target limit (if not fetchAll) - let rawIssues: any[] = []; - let cursor: string | null = null; - let hasNextPage = true; - let pageCount = 0; - - while (hasNextPage && (fetchAll || rawIssues.length < targetLimit)) { - pageCount++; - - const variables = { - filter: Object.keys(graphqlFilter).length > 0 ? graphqlFilter : null, - first: pageSize, - after: cursor - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const response: any = await client.client.rawRequest(issuesQuery, variables); - - // Track API call if tracking enabled - if (tracking) { - logCall('IssueList', 'query', 'main', Date.now() - startTime, variables); - } - - const nodes = response.data?.issues?.nodes || []; - const pageInfo = response.data?.issues?.pageInfo; - - rawIssues.push(...nodes); - - hasNextPage = pageInfo?.hasNextPage || false; - cursor = pageInfo?.endCursor || null; - - if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { - console.error(`[agent2linear] Page ${pageCount}: fetched ${nodes.length} issues (total: ${rawIssues.length}, hasNextPage: ${hasNextPage})`); - } - - // If not fetching all, stop when we have enough - if (!fetchAll && rawIssues.length >= targetLimit) { - break; - } - } - - // ======================================== - // TRUNCATION (M15.5 Phase 1) - // ======================================== - // If not fetching all pages, truncate to target limit - if (!fetchAll && rawIssues.length > targetLimit) { - if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { - console.error(`[agent2linear] Truncating from ${rawIssues.length} to ${targetLimit} issues`); - } - rawIssues = rawIssues.slice(0, targetLimit); - } - - // ======================================== - // SORTING (M15.5 Phase 3) - // ======================================== - // Client-side sorting after fetch (negligible performance impact for <1000 issues) - // Future enhancement: Use Linear's GraphQL orderBy if/when syntax is confirmed - if (filters?.sortField && filters?.sortOrder) { - const sortField = filters.sortField; - const sortOrder = filters.sortOrder; - const ascending = sortOrder === 'asc'; - - rawIssues.sort((a: any, b: any) => { - let aVal: any; - let bVal: any; - - switch (sortField) { - case 'priority': - // Priority: 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low - // For desc: Urgent (1) before Low (4) - // For asc: Low (4) before Urgent (1) - aVal = a.priority !== undefined && a.priority !== null ? a.priority : 999; - bVal = b.priority !== undefined && b.priority !== null ? b.priority : 999; - break; - case 'created': - aVal = new Date(a.createdAt).getTime(); - bVal = new Date(b.createdAt).getTime(); - break; - case 'updated': - aVal = new Date(a.updatedAt).getTime(); - bVal = new Date(b.updatedAt).getTime(); - break; - case 'due': - // Issues without due dates go to the end - aVal = a.dueDate ? new Date(a.dueDate).getTime() : Number.MAX_SAFE_INTEGER; - bVal = b.dueDate ? new Date(b.dueDate).getTime() : Number.MAX_SAFE_INTEGER; - break; - default: - return 0; - } - - // Apply sort order - if (ascending) { - return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; - } else { - return aVal < bVal ? 1 : aVal > bVal ? -1 : 0; - } - }); - - if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { - console.error(`[agent2linear] Sorted ${rawIssues.length} issues by ${sortField} ${sortOrder}`); - } - } - - // ======================================== - // BUILD FINAL ISSUE LIST - // ======================================== - // All data already fetched in single query, just map to final format - const issueList: IssueListItem[] = rawIssues.map((issue: any) => ({ - id: issue.id, - identifier: issue.identifier, - title: issue.title, - description: issue.description || undefined, - priority: issue.priority !== undefined ? issue.priority : undefined, - estimate: issue.estimate || undefined, - dueDate: issue.dueDate || undefined, - - assignee: issue.assignee ? { - id: issue.assignee.id, - name: issue.assignee.name, - email: issue.assignee.email - } : undefined, - - team: issue.team ? { - id: issue.team.id, - key: issue.team.key, - name: issue.team.name - } : undefined, - - state: issue.state ? { - id: issue.state.id, - name: issue.state.name, - type: issue.state.type - } : undefined, - - project: issue.project ? { - id: issue.project.id, - name: issue.project.name - } : undefined, - - cycle: issue.cycle ? { - id: issue.cycle.id, - name: issue.cycle.name, - number: issue.cycle.number - } : undefined, - - labels: (issue.labels?.nodes || []).map((label: any) => ({ - id: label.id, - name: label.name, - color: label.color || undefined - })), - - parent: issue.parent ? { - id: issue.parent.id, - identifier: issue.parent.identifier, - title: issue.parent.title - } : undefined, - - createdAt: issue.createdAt, - updatedAt: issue.updatedAt, - completedAt: issue.completedAt || undefined, - canceledAt: issue.canceledAt || undefined, - archivedAt: issue.archivedAt || undefined, - url: issue.url - })); - - return issueList; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to get issues: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get issues assigned to the current user (M15.1) - * Helper function for default list behavior - * @returns Array of issues assigned to current user - */ -export async function getCurrentUserIssues(): Promise<Array<{ - id: string; - identifier: string; - title: string; - priority?: number; - url: string; -}>> { - try { - const client = getLinearClient(); - const viewer = await client.viewer; - - return await getAllIssues({ - assigneeId: viewer.id, - }); - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to get current user issues: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get full issue details for display (M15.2) - * Returns comprehensive issue data including relationships, metadata, and dates - * - * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): - * Uses custom GraphQL query to avoid N+1 pattern (11+ API calls → 1 call) - * Fetches all relationships upfront instead of lazy loading via SDK - * - * @param issueId - Issue UUID - * @returns Full issue data or null if not found - */ -export async function getFullIssueById(issueId: string): Promise<IssueViewData | null> { - try { - const client = getLinearClient(); - - // ======================================== - // CUSTOM GRAPHQL QUERY - ALL RELATIONS UPFRONT - // ======================================== - // This query fetches ALL display data in ONE request to avoid N+1 patterns. - // Includes: state, team, assignee, project, cycle, parent, creator - // Collections: children (with states!), labels, subscribers - const issueQuery = ` - query GetFullIssue($issueId: String!) { - issue(id: $issueId) { - id - identifier - title - description - url - priority - estimate - dueDate - createdAt - updatedAt - completedAt - canceledAt - archivedAt - - state { - id - name - type - color - } - - team { - id - key - name - } - - assignee { - id - name - email - } - - project { - id - name - } - - cycle { - id - name - number - } - - parent { - id - identifier - title - } - - children { - nodes { - id - identifier - title - state { - id - name - } - } - } - - labels { - nodes { - id - name - color - } - } - - subscribers { - nodes { - id - name - email - } - } - - creator { - id - name - email - } - } - } - `; - - const response: any = await client.client.rawRequest(issueQuery, { issueId }); - const issueData = response.data?.issue; - - if (!issueData) { - return null; - } - - // Map GraphQL response to IssueViewData (no awaits needed - all data already fetched!) - return { - // Core identification - id: issueData.id, - identifier: issueData.identifier, - title: issueData.title, - url: issueData.url, - - // Content - description: issueData.description || undefined, - - // Workflow - state: issueData.state - ? { - id: issueData.state.id, - name: issueData.state.name, - type: issueData.state.type as 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled', - color: issueData.state.color, - } - : { id: '', name: 'Unknown', type: 'backlog' as const, color: '#95a2b3' }, - priority: issueData.priority, - estimate: issueData.estimate || undefined, - - // Assignment - assignee: issueData.assignee - ? { - id: issueData.assignee.id, - name: issueData.assignee.name, - email: issueData.assignee.email, - } - : undefined, - subscribers: issueData.subscribers.nodes.map((sub: any) => ({ - id: sub.id, - name: sub.name, - email: sub.email, - })), - - // Organization - team: issueData.team - ? { - id: issueData.team.id, - key: issueData.team.key, - name: issueData.team.name, - } - : { id: '', key: '', name: 'Unknown' }, - project: issueData.project - ? { - id: issueData.project.id, - name: issueData.project.name, - } - : undefined, - cycle: issueData.cycle - ? { - id: issueData.cycle.id, - name: issueData.cycle.name || `Cycle #${issueData.cycle.number}`, - number: issueData.cycle.number, - } - : undefined, - parent: issueData.parent - ? { - id: issueData.parent.id, - identifier: issueData.parent.identifier, - title: issueData.parent.title, - } - : undefined, - children: issueData.children.nodes.map((child: any) => ({ - id: child.id, - identifier: child.identifier, - title: child.title, - state: child.state?.name || 'Unknown', - })), - labels: issueData.labels.nodes.map((label: any) => ({ - id: label.id, - name: label.name, - color: label.color, - })), - - // Dates - createdAt: issueData.createdAt, - updatedAt: issueData.updatedAt, - completedAt: issueData.completedAt, - canceledAt: issueData.canceledAt, - dueDate: issueData.dueDate, - archivedAt: issueData.archivedAt, - - // Creator - creator: issueData.creator - ? { - id: issueData.creator.id, - name: issueData.creator.name, - email: issueData.creator.email, - } - : { id: '', name: 'Unknown', email: '' }, - }; - } catch (error) { - return null; - } -} - -/** - * Get issue comments (M15.2) - * - * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): - * Uses custom GraphQL query to avoid N+1 pattern (2 + N API calls → 1 call) - * Fetches all comment users upfront instead of lazy loading via SDK - * - * @param issueId - Issue UUID - * @returns Array of comments - */ -export async function getIssueComments(issueId: string): Promise< - Array<{ - id: string; - body: string; - createdAt: string; - updatedAt: string; - user: { - id: string; - name: string; - email: string; - }; - }> -> { - try { - const client = getLinearClient(); - - // Custom GraphQL query - fetch comments with user data in one request - const commentsQuery = ` - query GetIssueComments($issueId: String!) { - issue(id: $issueId) { - id - comments { - nodes { - id - body - createdAt - updatedAt - user { - id - name - email - } - } - } - } - } - `; - - const response: any = await client.client.rawRequest(commentsQuery, { issueId }); - const issueData = response.data?.issue; - - if (!issueData || !issueData.comments) { - return []; - } - - return issueData.comments.nodes.map((comment: any) => ({ - id: comment.id, - body: comment.body, - createdAt: comment.createdAt, - updatedAt: comment.updatedAt, - user: comment.user - ? { - id: comment.user.id, - name: comment.user.name, - email: comment.user.email, - } - : { id: '', name: 'Unknown', email: '' }, - })); - } catch (error) { - return []; - } -} - -/** - * Get issue history (M15.2) - * - * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): - * Uses custom GraphQL query to avoid N+1 pattern (2 + 7N API calls → 1 call) - * Fetches all history relationships upfront instead of lazy loading via SDK - * Previous: 7 awaits per entry (actor, fromState, toState, fromAssignee, toAssignee, addedLabels, removedLabels) - * - * @param issueId - Issue UUID - * @returns Array of history entries - */ -export async function getIssueHistory(issueId: string): Promise< - Array<{ - id: string; - createdAt: string; - actor?: { - id: string; - name: string; - email: string; - }; - fromState?: string; - toState?: string; - fromAssignee?: string; - toAssignee?: string; - addedLabels?: string[]; - removedLabels?: string[]; - }> -> { - try { - const client = getLinearClient(); - - // Custom GraphQL query - fetch history with all relationships in one request - const historyQuery = ` - query GetIssueHistory($issueId: String!) { - issue(id: $issueId) { - id - history { - nodes { - id - createdAt - actor { - id - name - email - } - fromState { - id - name - } - toState { - id - name - } - fromAssignee { - id - name - } - toAssignee { - id - name - } - addedLabels { - id - name - } - removedLabels { - id - name - } - } - } - } - } - `; - - const response: any = await client.client.rawRequest(historyQuery, { issueId }); - const issueData = response.data?.issue; - - if (!issueData || !issueData.history) { - return []; - } - - return issueData.history.nodes.map((entry: any) => ({ - id: entry.id, - createdAt: entry.createdAt, - actor: entry.actor - ? { - id: entry.actor.id, - name: entry.actor.name, - email: entry.actor.email, - } - : undefined, - fromState: entry.fromState?.name, - toState: entry.toState?.name, - fromAssignee: entry.fromAssignee?.name, - toAssignee: entry.toAssignee?.name, - addedLabels: entry.addedLabels ? entry.addedLabels.map((l: any) => l.name) : undefined, - removedLabels: entry.removedLabels ? entry.removedLabels.map((l: any) => l.name) : undefined, - })); - } catch (error) { - return []; - } -} - -/** - * Get all projects from Linear with comprehensive filtering (M20) - * @param filters - Optional filters to apply (team, initiative, status, priority, lead, members, labels, dates, search) - */ -/** - * Get all projects with optional filtering - * - * Performance Optimization (M21 Extended): - * - Conditional fetching: Only fetch labels/members if used for filtering - * - Two-query approach: Minimal query + optional batch query for labels/members - * - In-code join to combine results - * - API call reduction: - * - No filters: 1 call (was 1+N) - * - With label/member filters: 2 calls (was 1+N) - * - Overall: 92-98% reduction in API calls - * - * @param filters - Optional filters for projects - * @returns Array of project list items - */ -export async function getAllProjects(filters?: ProjectListFilters): Promise<ProjectListItem[]> { - try { - const client = getLinearClient(); - - // Build GraphQL filter object - const graphqlFilter: any = {}; - - if (filters?.teamId) { - graphqlFilter.accessibleTeams = { some: { id: { eq: filters.teamId } } }; - } - - if (filters?.initiativeId) { - graphqlFilter.initiatives = { some: { id: { eq: filters.initiativeId } } }; - } - - if (filters?.statusId) { - graphqlFilter.status = { id: { eq: filters.statusId } }; - } - - if (filters?.priority !== undefined) { - graphqlFilter.priority = { eq: filters.priority }; - } - - if (filters?.leadId) { - graphqlFilter.lead = { id: { eq: filters.leadId } }; - } - - if (filters?.memberIds && filters.memberIds.length > 0) { - graphqlFilter.members = { some: { id: { in: filters.memberIds } } }; - } - - if (filters?.labelIds && filters.labelIds.length > 0) { - graphqlFilter.labels = { some: { id: { in: filters.labelIds } } }; - } - - // Date range filters - if (filters?.startDateAfter || filters?.startDateBefore) { - graphqlFilter.startDate = {}; - if (filters.startDateAfter) { - graphqlFilter.startDate.gte = filters.startDateAfter; - } - if (filters.startDateBefore) { - graphqlFilter.startDate.lte = filters.startDateBefore; - } - } - - if (filters?.targetDateAfter || filters?.targetDateBefore) { - graphqlFilter.targetDate = {}; - if (filters.targetDateAfter) { - graphqlFilter.targetDate.gte = filters.targetDateAfter; - } - if (filters.targetDateBefore) { - graphqlFilter.targetDate.lte = filters.targetDateBefore; - } - } - - // Text search (search in name, description, content) - if (filters?.search) { - const searchTerm = filters.search.trim(); - if (searchTerm.length > 0) { - graphqlFilter.or = [ - { name: { containsIgnoreCase: searchTerm } }, - { slugId: { containsIgnoreCase: searchTerm } }, - { searchableContent: { contains: searchTerm } } - ]; - } - } - - if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { - console.error('[agent2linear] Project filter:', JSON.stringify(graphqlFilter, null, 2)); - } - - // Determine what data needs to be fetched based on filters - // CURRENT: Only fetch labels/members if they're used in FILTERS - // FUTURE ENHANCEMENT: Also conditionally fetch based on OUTPUT format - // - If output doesn't need members/labels, skip Query 2 entirely - // - Would require passing output format context to this function - const needsLabels = !!filters?.labelIds && filters.labelIds.length > 0; - const needsMembers = !!filters?.memberIds && filters.memberIds.length > 0; - const needsDependencies = !!filters?.includeDependencies; // M23 - const needsAdditionalData = needsLabels || needsMembers; - - if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { - console.error('[agent2linear] Conditional fetch:', { needsLabels, needsMembers, needsAdditionalData }); - } - - // ======================================== - // PAGINATION SETUP (M21.1) - // ======================================== - // Determine page size based on fetchAll flag: - // - If --all: use 250 (max) for optimal performance (5x faster) - // - Otherwise: use limit (capped at 250) - const pageSize = filters?.fetchAll ? 250 : Math.min(filters?.limit || 50, 250); - const fetchAll = filters?.fetchAll || false; - const targetLimit = filters?.limit || 50; - - if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { - console.error('[agent2linear] Pagination:', { pageSize, fetchAll, targetLimit }); - } - - // QUERY 1: Minimal - Always fetch core project data (projects, teams, leads) - // M23: Conditionally include relations if dependencies are requested - const relationsFragment = needsDependencies ? ` - relations { - nodes { - id - type - anchorType - relatedAnchorType - project { id } - relatedProject { id } - } - }` : ''; - - const minimalQuery = ` - query GetMinimalProjects($filter: ProjectFilter, $includeArchived: Boolean, $first: Int, $after: String) { - projects(filter: $filter, includeArchived: $includeArchived, first: $first, after: $after) { - nodes { - id - name - description - content - icon - color - state - priority - startDate - targetDate - completedAt - url - createdAt - updatedAt - - teams { - nodes { - id - name - key - } - } - - lead { - id - name - email - } -${relationsFragment} - } - pageInfo { - hasNextPage - endCursor - } - } - } - `; - - // ======================================== - // PAGINATION LOOP (M21.1) - // ======================================== - // Fetch pages until: - // - No more pages (hasNextPage = false), OR - // - Reached target limit (if not fetchAll) - let rawProjects: any[] = []; - let cursor: string | null = null; - let hasNextPage = true; - let pageCount = 0; - - while (hasNextPage && (fetchAll || rawProjects.length < targetLimit)) { - pageCount++; - - const variables = { - filter: Object.keys(graphqlFilter).length > 0 ? graphqlFilter : null, - includeArchived: false, - first: pageSize, - after: cursor - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const minimalResponse: any = await client.client.rawRequest(minimalQuery, variables); - - const nodes = minimalResponse.data?.projects?.nodes || []; - const pageInfo = minimalResponse.data?.projects?.pageInfo; - - rawProjects.push(...nodes); - - hasNextPage = pageInfo?.hasNextPage || false; - cursor = pageInfo?.endCursor || null; - - if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { - console.error(`[agent2linear] Page ${pageCount}: fetched ${nodes.length} projects (total: ${rawProjects.length}, hasNextPage: ${hasNextPage})`); - } - - // If not fetching all, stop when we have enough - if (!fetchAll && rawProjects.length >= targetLimit) { - break; - } - } - - // ======================================== - // QUERY 2: CONDITIONAL - Batch fetch labels+members IF filters use them - // ======================================== - // This query only runs if: - // - filters.labelIds is set (filtering by labels), OR - // - filters.memberIds is set (filtering by members) - // - // Strategy: - // 1. Build a single batch GraphQL query for ALL projects - // 2. Fetch both labels AND members in ONE API call (not N calls) - // 3. Store results in Maps keyed by project ID - // 4. Perform in-code join when building final project list - // - // Result: If no label/member filters → 1 API call total (was 1+N) - // If label/member filters → 2 API calls total (was 1+N) - // - // FUTURE ENHANCEMENT: Also check if output format needs labels/members - // - e.g., if --format=table doesn't show members column, skip fetching - // - Would save Query 2 even more often - // ======================================== - const labelsMap: Map<string, any[]> = new Map(); - const membersMap: Map<string, any[]> = new Map(); - - if (needsAdditionalData && rawProjects.length > 0) { - const projectIds = rawProjects.map((p: any) => p.id); - - // Build batch query for all projects - // Note: We fetch both labels AND members in ONE query to minimize API calls - const batchQuery = ` - query GetProjectsLabelsAndMembers($ids: [String!]!) { - ${projectIds.map((id: string, index: number) => ` - project${index}: project(id: "${id}") { - id - labels { - nodes { - id - name - color - } - } - members { - nodes { - id - name - email - } - } - } - `).join('\n')} - } - `; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const batchResponse: any = await client.client.rawRequest(batchQuery, {}); - - if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { - console.error('[agent2linear] Batch query fetched labels+members for', projectIds.length, 'projects'); - } - - // Parse batch response and build maps for in-code join - projectIds.forEach((projectId: string, index: number) => { - const projectData = batchResponse.data?.[`project${index}`]; - if (projectData) { - labelsMap.set(projectId, projectData.labels?.nodes || []); - membersMap.set(projectId, projectData.members?.nodes || []); - } - }); - } - - // ======================================== - // IN-CODE JOIN: Merge Query 1 (projects) + Query 2 (labels/members) - // ======================================== - // TRUNCATION (M21.1) - // ======================================== - // If not fetching all pages, truncate to target limit - if (!fetchAll && rawProjects.length > targetLimit) { - if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { - console.error(`[agent2linear] Truncating from ${rawProjects.length} to ${targetLimit} projects`); - } - rawProjects = rawProjects.slice(0, targetLimit); - } - - // ======================================== - // BUILD FINAL PROJECT LIST (IN-CODE JOIN) - // ======================================== - // Build final project list by joining data from both queries - const projectList: ProjectListItem[] = rawProjects.map((project: any) => { - const labels = labelsMap.get(project.id) || []; - const members = membersMap.get(project.id) || []; - - // M23: Calculate dependency counts if relations were fetched - let dependsOnCount: number | undefined = undefined; - let blocksCount: number | undefined = undefined; - - if (needsDependencies) { - // Initialize to 0 when fetching dependencies - dependsOnCount = 0; - blocksCount = 0; - - // Count relations if present - if (project.relations?.nodes) { - const relations = project.relations.nodes; - - dependsOnCount = relations.filter((rel: any) => { - try { - return getRelationDirection(rel, project.id) === 'depends-on'; - } catch { - return false; - } - }).length; - - blocksCount = relations.filter((rel: any) => { - try { - return getRelationDirection(rel, project.id) === 'blocks'; - } catch { - return false; - } - }).length; - } - } - - return { - id: project.id, - name: project.name, - description: project.description || undefined, - content: project.content || undefined, - icon: project.icon || undefined, - color: project.color || undefined, - state: project.state, - priority: project.priority !== undefined ? project.priority : undefined, - - status: undefined, // Project status is not available in Linear SDK v27+ - - lead: project.lead ? { - id: project.lead.id, - name: project.lead.name, - email: project.lead.email - } : undefined, - - team: project.teams?.nodes?.[0] ? { - id: project.teams.nodes[0].id, - name: project.teams.nodes[0].name, - key: project.teams.nodes[0].key - } : undefined, - - initiative: undefined, // Initiative relationship needs to be fetched differently - - labels: labels.map((label: any) => ({ - id: label.id, - name: label.name, - color: label.color || undefined - })), - - members: members.map((member: any) => ({ - id: member.id, - name: member.name, - email: member.email - })), - - startDate: project.startDate || undefined, - targetDate: project.targetDate || undefined, - completedAt: project.completedAt || undefined, - - url: project.url, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - - // M23: Include dependency counts if fetched - dependsOnCount, - blocksCount - }; - }); - - return projectList; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch projects: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Project creation input - */ -export interface ProjectCreateInput { - name: string; - description?: string; - initiativeId?: string; - teamId?: string; - templateId?: string; - // Additional Linear SDK fields - statusId?: string; - content?: string; - icon?: string; - color?: string; - leadId?: string; - labelIds?: string[]; - convertedFromIssueId?: string; - startDate?: string; - startDateResolution?: 'month' | 'quarter' | 'halfYear' | 'year'; - targetDate?: string; - targetDateResolution?: 'month' | 'quarter' | 'halfYear' | 'year'; - priority?: number; - memberIds?: string[]; -} - -/** - * Project result with metadata - */ -export interface ProjectResult { - id: string; - name: string; - description?: string; - content?: string; - url: string; - state: string; - initiative?: { - id: string; - name: string; - }; - team?: { - id: string; - name: string; - }; -} - -/** - * Check if a project with the given name already exists (legacy - returns boolean) - */ -export async function getProjectByName(name: string): Promise<boolean> { - try { - const project = await findProjectByName(name); - return project !== null; - } catch (error) { - // If we can't check, allow creation to proceed - return false; - } -} - -/** - * Find a project by its exact name and return full project details - */ -export async function findProjectByName(name: string): Promise<ProjectResult | null> { - try { - const client = getLinearClient(); - const projects = await client.projects({ - filter: { - name: { eq: name }, - }, - }); - - const projectsList = await projects.nodes; - if (projectsList.length === 0) { - return null; - } - - const project = projectsList[0]; - - // Fetch initiative details if linked - let initiative; - try { - const projectInitiatives = await project.initiatives(); - const initiativesList = await projectInitiatives.nodes; - if (initiativesList && initiativesList.length > 0) { - const firstInitiative = initiativesList[0]; - initiative = { - id: firstInitiative.id, - name: firstInitiative.name, - }; - } - } catch { - // Initiative fetch failed or not linked - } - - // Fetch team details if set - let team; - try { - const teams = await project.teams(); - const teamsList = await teams.nodes; - if (teamsList && teamsList.length > 0) { - const firstTeam = teamsList[0]; - team = { - id: firstTeam.id, - name: firstTeam.name, - }; - } - } catch { - // Team fetch failed - } - - return { - id: project.id, - name: project.name, - url: project.url, - state: project.state, - initiative, - team, - }; - } catch (error) { - return null; - } -} - -/** - * Create a new project in Linear - */ -export async function createProject(input: ProjectCreateInput): Promise<ProjectResult> { - try { - const client = getLinearClient(); - - // Prepare the creation input - const createInput = { - name: input.name, - description: input.description, - ...(input.teamId && { teamIds: [input.teamId] }), - ...(input.templateId && { lastAppliedTemplateId: input.templateId }), - // Additional optional fields - ...(input.statusId && { statusId: input.statusId }), - ...(input.content && { content: input.content }), - ...(input.icon && { icon: input.icon }), - ...(input.color && { color: input.color }), - ...(input.leadId && { leadId: input.leadId }), - ...(input.labelIds && input.labelIds.length > 0 && { labelIds: input.labelIds }), - ...(input.convertedFromIssueId && { convertedFromIssueId: input.convertedFromIssueId }), - ...(input.startDate && { startDate: input.startDate }), - ...(input.startDateResolution && { startDateResolution: input.startDateResolution }), - ...(input.targetDate && { targetDate: input.targetDate }), - ...(input.targetDateResolution && { targetDateResolution: input.targetDateResolution }), - ...(input.priority !== undefined && { priority: input.priority }), - ...(input.memberIds && input.memberIds.length > 0 && { memberIds: input.memberIds }), - } as const; - - // Debug: log what we're sending to the API - if (process.env.DEBUG) { - console.log('DEBUG: createInput =', JSON.stringify(createInput, null, 2)); - } - - // Create the project - const projectPayload = await client.createProject(createInput as Parameters<typeof client.createProject>[0]); - - const project = await projectPayload.project; - - if (!project) { - throw new Error('Failed to create project: No project returned from API'); - } - - // Debug: Check if template was applied - if (process.env.DEBUG && input.templateId) { - try { - const lastAppliedTemplate = await (project as { lastAppliedTemplate?: { id: string; name: string } }).lastAppliedTemplate; - if (lastAppliedTemplate) { - console.log(`DEBUG: Template applied - ID: ${lastAppliedTemplate.id}, Name: ${lastAppliedTemplate.name}`); - } else { - console.log('DEBUG: No template was applied to the project'); - } - } catch (err) { - console.log('DEBUG: Could not check lastAppliedTemplate:', err instanceof Error ? err.message : err); - } - } - - // Link project to initiative if specified - let initiative; - if (input.initiativeId) { - try { - // First fetch initiative details - const initiativeData = await client.initiative(input.initiativeId); - initiative = { - id: initiativeData.id, - name: initiativeData.name, - }; - - // Link project to initiative using initiativeToProjectCreate - await client.createInitiativeToProject({ - initiativeId: input.initiativeId, - projectId: project.id, - }); - - if (process.env.DEBUG) { - console.log(`DEBUG: Successfully linked project ${project.id} to initiative ${input.initiativeId}`); - } - } catch (err) { - // Initiative link failed - log in debug mode - if (process.env.DEBUG) { - console.log('DEBUG: Failed to link initiative:', err); - } - // Don't throw - project was still created successfully - } - } - - // Fetch team details if set - let team; - if (input.teamId) { - try { - const teamData = await client.team(input.teamId); - team = { - id: teamData.id, - name: teamData.name, - }; - } catch { - // Team fetch failed - } - } - - return { - id: project.id, - name: project.name, - url: project.url, - state: project.state, - initiative, - team, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to create project: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Project Update Input - */ -export interface ProjectUpdateInput { - statusId?: string; - name?: string; - description?: string; - content?: string; - priority?: number; - startDate?: string; - targetDate?: string; - // M15 Phase 1: Visual & Ownership Fields - color?: string; - icon?: string; - leadId?: string; - // M15 Phase 2: Collaboration & Organization Fields - memberIds?: string[]; - labelIds?: string[]; - // M15 Phase 3: Date Resolutions - startDateResolution?: 'month' | 'quarter' | 'halfYear' | 'year'; - targetDateResolution?: 'month' | 'quarter' | 'halfYear' | 'year'; -} - -/** - * Update an existing project - */ -export async function updateProject( - projectId: string, - updates: ProjectUpdateInput -): Promise<ProjectResult> { - try { - const client = getLinearClient(); - - // Prepare the update input - const updateInput: Partial<{ - statusId: string; - name: string; - description: string; - content: string; - priority: number; - startDate: string; - targetDate: string; - color: string; - icon: string; - leadId: string; - memberIds: string[]; - labelIds: string[]; - startDateResolution: 'month' | 'quarter' | 'halfYear' | 'year'; - targetDateResolution: 'month' | 'quarter' | 'halfYear' | 'year'; - }> = {}; - - if (updates.statusId !== undefined) { - updateInput.statusId = updates.statusId; - } - if (updates.name !== undefined) { - updateInput.name = updates.name; - } - if (updates.description !== undefined) { - updateInput.description = updates.description; - } - if (updates.content !== undefined) { - updateInput.content = updates.content; - } - if (updates.priority !== undefined) { - updateInput.priority = updates.priority; - } - if (updates.startDate !== undefined) { - updateInput.startDate = updates.startDate; - } - if (updates.targetDate !== undefined) { - updateInput.targetDate = updates.targetDate; - } - // M15 Phase 1: Visual & Ownership Fields - if (updates.color !== undefined) { - updateInput.color = updates.color; - } - if (updates.icon !== undefined) { - updateInput.icon = updates.icon; - } - if (updates.leadId !== undefined) { - updateInput.leadId = updates.leadId; - } - // M15 Phase 2: Collaboration & Organization Fields - if (updates.memberIds !== undefined) { - updateInput.memberIds = updates.memberIds; - } - if (updates.labelIds !== undefined) { - updateInput.labelIds = updates.labelIds; - } - // M15 Phase 3: Date Resolutions - if (updates.startDateResolution !== undefined) { - updateInput.startDateResolution = updates.startDateResolution; - } - if (updates.targetDateResolution !== undefined) { - updateInput.targetDateResolution = updates.targetDateResolution; - } - - // Update the project - const projectPayload = await client.updateProject(projectId, updateInput as Parameters<typeof client.updateProject>[1]); - const project = await projectPayload.project; - - if (!project) { - throw new Error('Failed to update project: No project returned from API'); - } - - // Fetch initiative details if linked - let initiative; - try { - const projectInitiatives = await project.initiatives(); - const initiativesList = await projectInitiatives.nodes; - if (initiativesList && initiativesList.length > 0) { - const firstInitiative = initiativesList[0]; - initiative = { - id: firstInitiative.id, - name: firstInitiative.name, - }; - } - } catch { - // Initiative fetch failed or not linked - } - - // Fetch team details if set - let team; - try { - const teams = await project.teams(); - const teamsList = await teams.nodes; - if (teamsList && teamsList.length > 0) { - const firstTeam = teamsList[0]; - team = { - id: firstTeam.id, - name: firstTeam.name, - }; - } - } catch { - // Team fetch failed - } - - return { - id: project.id, - name: project.name, - url: project.url, - state: project.state, - initiative, - team, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to update project: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get a single project by ID - * - * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): - * Uses custom GraphQL query to avoid N+1 pattern (3 API calls → 1 call) - * Fetches project with initiatives and teams upfront instead of lazy loading via SDK - */ -export async function getProjectById( - projectId: string -): Promise<ProjectResult | null> { - try { - const client = getLinearClient(); - - // Custom GraphQL query - fetch project with initiatives and teams in one request - const projectQuery = ` - query GetProject($projectId: String!) { - project(id: $projectId) { - id - name - url - state - - initiatives { - nodes { - id - name - } - } - - teams { - nodes { - id - name - } - } - } - } - `; - - const response: any = await client.client.rawRequest(projectQuery, { projectId }); - const project = response.data?.project; - - if (!project) { - return null; - } - - // Get first initiative if exists - const initiative = project.initiatives?.nodes?.[0] - ? { - id: project.initiatives.nodes[0].id, - name: project.initiatives.nodes[0].name, - } - : undefined; - - // Get first team if exists - const team = project.teams?.nodes?.[0] - ? { - id: project.teams.nodes[0].id, - name: project.teams.nodes[0].name, - } - : undefined; - - return { - id: project.id, - name: project.name, - url: project.url, - state: project.state, - initiative, - team, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch project: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get project milestones and issues for validation - */ -export async function getProjectDetails(projectId: string): Promise<{ - project: ProjectResult; - lastAppliedTemplate?: { id: string; name: string }; - milestones: Array<{ id: string; name: string }>; - issues: Array<{ id: string; identifier: string; title: string }>; -} | null> { - try { - const client = getLinearClient(); - const project = await client.project(projectId); - - if (!project) { - return null; - } - - // Get basic project info - const projectResult = await getProjectById(projectId); - if (!projectResult) { - return null; - } - - // Get last applied template - let lastAppliedTemplate; - try { - const template = await (project as { lastAppliedTemplate?: { id: string; name: string } }).lastAppliedTemplate; - if (template) { - lastAppliedTemplate = { - id: template.id, - name: template.name, - }; - } - } catch { - // Template not available - } - - // Get milestones - const milestones: Array<{ id: string; name: string }> = []; - try { - const projectMilestones = await project.projectMilestones(); - const milestonesList = await projectMilestones.nodes; - for (const milestone of milestonesList) { - milestones.push({ - id: milestone.id, - name: milestone.name, - }); - } - } catch { - // Milestones not available - } - - // Get issues - const issues: Array<{ id: string; identifier: string; title: string }> = []; - try { - const projectIssues = await project.issues(); - const issuesList = await projectIssues.nodes; - for (const issue of issuesList) { - issues.push({ - id: issue.id, - identifier: issue.identifier, - title: issue.title, - }); - } - } catch { - // Issues not available - } - - return { - project: projectResult, - lastAppliedTemplate, - milestones, - issues, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch project details: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get full project details with all relationships (OPTIMIZED) - * - * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): - * Uses custom GraphQL query to avoid N+1 pattern (~10 API calls → 1 call) - * Fetches all project data upfront instead of lazy loading via SDK - * - * @param projectId - Project UUID - * @returns Complete project details or null if not found - */ -export async function getFullProjectDetails(projectId: string): Promise<{ - project: ProjectResult; - lastAppliedTemplate?: { id: string; name: string }; - milestones: Array<{ id: string; name: string }>; - issues: Array<{ id: string; identifier: string; title: string }>; -} | null> { - try { - const client = getLinearClient(); - - // ======================================== - // CUSTOM GRAPHQL QUERY - ALL RELATIONS UPFRONT - // ======================================== - // This query fetches ALL project data in ONE request to avoid N+1 patterns. - // Includes: basic info, initiatives, teams, template, milestones, issues - const projectQuery = ` - query GetFullProject($projectId: String!) { - project(id: $projectId) { - id - name - description - content - url - state - - initiatives { - nodes { - id - name - } - } - - teams { - nodes { - id - name - } - } - - lastAppliedTemplate { - id - name - } - - projectMilestones { - nodes { - id - name - } - } - - issues { - nodes { - id - identifier - title - } - } - } - } - `; - - const response: any = await client.client.rawRequest(projectQuery, { projectId }); - const projectData = response.data?.project; - - if (!projectData) { - return null; - } - - // Map GraphQL response to ProjectResult and related data (no awaits needed!) - const initiative = projectData.initiatives?.nodes?.[0] - ? { - id: projectData.initiatives.nodes[0].id, - name: projectData.initiatives.nodes[0].name, - } - : undefined; - - const team = projectData.teams?.nodes?.[0] - ? { - id: projectData.teams.nodes[0].id, - name: projectData.teams.nodes[0].name, - } - : undefined; - - const lastAppliedTemplate = projectData.lastAppliedTemplate - ? { - id: projectData.lastAppliedTemplate.id, - name: projectData.lastAppliedTemplate.name, - } - : undefined; - - const milestones = (projectData.projectMilestones?.nodes || []).map((milestone: any) => ({ - id: milestone.id, - name: milestone.name, - })); - - const issues = (projectData.issues?.nodes || []).map((issue: any) => ({ - id: issue.id, - identifier: issue.identifier, - title: issue.title, - })); - - return { - project: { - id: projectData.id, - name: projectData.name, - description: projectData.description || undefined, - content: projectData.content || undefined, - url: projectData.url, - state: projectData.state, - initiative, - team, - }, - lastAppliedTemplate, - milestones, - issues, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch project details: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Template data structure (from types.ts) - */ -export interface Template { - id: string; - name: string; - type: 'issue' | 'project'; - description?: string; -} - -/** - * Get all templates from Linear - */ -export async function getAllTemplates(typeFilter?: 'issue' | 'project'): Promise<Template[]> { - try { - const client = getLinearClient(); - const result: Template[] = []; - - // Fetch all templates from Linear - // Linear uses a single Template type with a 'type' field to distinguish between issue and project templates - try { - // client.templates returns LinearFetch<Template[]> which is Promise<Template[]> - const templates = await client.templates; - - for (const template of templates) { - // Determine template type based on the 'type' field from Linear - // Linear uses different type values, but we normalize to 'issue' or 'project' - let templateType: 'issue' | 'project'; - - if (template.type.toLowerCase().includes('project')) { - templateType = 'project'; - } else { - // Default to issue template (most common case) - templateType = 'issue'; - } - - // Apply filter if specified - if (typeFilter && templateType !== typeFilter) { - continue; - } - - result.push({ - id: template.id, - name: template.name, - type: templateType, - description: template.description || undefined, - }); - } - } catch (err) { - // Templates may not be available - log the error for debugging - if (process.env.DEBUG) { - console.error('Error fetching templates:', err); - } - throw err; // Re-throw to let caller know there was an error - } - - // Sort by type then name - return result.sort((a, b) => { - if (a.type !== b.type) { - return a.type.localeCompare(b.type); - } - return a.name.localeCompare(b.name); - }); - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch templates: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get a single template by ID - */ -export async function getTemplateById( - templateId: string -): Promise<{ id: string; name: string; type: 'issue' | 'project'; description?: string } | null> { - try { - // Use entity cache instead of direct API call - const { getEntityCache } = await import('./entity-cache.js'); - const cache = getEntityCache(); - const template = await cache.findTemplateById(templateId); - - if (!template) { - return null; - } - - return { - id: template.id, - name: template.name, - type: template.type, - description: template.description || undefined, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch template: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Project Status types - */ -export interface ProjectStatus { - id: string; - name: string; - type: 'planned' | 'started' | 'paused' | 'completed' | 'canceled'; - color: string; - description?: string; - position: number; -} - -/** - * Get all project statuses from the organization - */ -export async function getAllProjectStatuses(): Promise<ProjectStatus[]> { - try { - const client = getLinearClient(); - const organization = await client.organization; - const statuses = await organization.projectStatuses; - - return statuses.map((status: { id: string; name: string; type: string; color: string; description?: string; position: number }) => ({ - id: status.id, - name: status.name, - type: status.type as 'planned' | 'started' | 'paused' | 'completed' | 'canceled', - color: status.color, - description: status.description || undefined, - position: status.position, - })); - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch project statuses: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get a single project status by ID - */ -export async function getProjectStatusById(statusId: string): Promise<ProjectStatus | null> { - try { - const client = getLinearClient(); - const status = await client.projectStatus(statusId); - - if (!status) { - return null; - } - - return { - id: status.id, - name: status.name, - type: status.type as 'planned' | 'started' | 'paused' | 'completed' | 'canceled', - color: status.color, - description: status.description || undefined, - position: status.position, - }; - } catch (error) { - return null; - } -} - -/** - * Milestone-related types - */ -export interface ProjectMilestone { - id: string; - name: string; - description?: string; - targetDate?: string; -} - -export interface MilestoneCreateInput { - name: string; - description?: string; - targetDate?: Date; -} - -/** - * Validate that a project exists - */ -export async function validateProjectExists( - projectId: string -): Promise<{ valid: boolean; name?: string; error?: string }> { - try { - const client = getLinearClient(); - const project = await client.project(projectId); - - if (!project) { - return { - valid: false, - error: `Project with ID "${projectId}" not found`, - }; - } - - return { - valid: true, - name: project.name, - }; - } catch (error) { - return { - valid: false, - error: `Failed to validate project: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } -} - -/** - * Create a project milestone - */ -export async function createProjectMilestone( - projectId: string, - input: MilestoneCreateInput -): Promise<{ id: string; name: string }> { - try { - const client = getLinearClient(); - - // Format target date if provided - const targetDate = input.targetDate ? input.targetDate.toISOString() : undefined; - - const payload = await client.createProjectMilestone({ - projectId, - name: input.name, - description: input.description, - targetDate, - }); - - const milestone = await payload.projectMilestone; - if (!milestone) { - throw new Error('Failed to create milestone: No milestone returned from API'); - } - - return { - id: milestone.id, - name: milestone.name, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to create milestone: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get all milestones for a project - */ -export async function getProjectMilestones(projectId: string): Promise<ProjectMilestone[]> { - try { - const client = getLinearClient(); - const project = await client.project(projectId); - - if (!project) { - throw new Error(`Project not found: ${projectId}`); - } - - const milestones = await project.projectMilestones(); - const result: ProjectMilestone[] = []; - - for (const milestone of milestones.nodes) { - result.push({ - id: milestone.id, - name: milestone.name, - description: milestone.description || undefined, - targetDate: milestone.targetDate || undefined, - }); - } - - return result; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch project milestones: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Workflow State (Issue Status) API Methods - */ - -export interface WorkflowStateCreateInput { - name: string; - teamId: string; - type: 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled'; - color: string; - description?: string; - position?: number; -} - -export interface WorkflowStateUpdateInput { - name?: string; - type?: 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled'; - color?: string; - description?: string; - position?: number; -} - -/** - * Get all workflow states for a team (or all teams) - */ -/** - * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): - * Uses custom GraphQL query to avoid N+1 pattern when fetching all workflow states - * Previous: 1 + N API calls (1 for teams + N for states per team) - * Optimized: 1 API call with nested states data - */ -export async function getAllWorkflowStates(teamId?: string): Promise<import('./types.js').WorkflowState[]> { - try { - const client = getLinearClient(); - const result: import('./types.js').WorkflowState[] = []; - - if (teamId) { - // Get workflow states for a specific team (already efficient - 2 calls) - const team = await client.team(teamId); - if (!team) { - throw new Error(`Team not found: ${teamId}`); - } - - const states = await team.states(); - for (const state of states.nodes) { - result.push({ - id: state.id, - name: state.name, - type: state.type as 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled', - color: state.color, - description: state.description || undefined, - position: state.position, - teamId: team.id, - }); - } - } else { - // Get workflow states for all teams - OPTIMIZED with custom GraphQL - const statesQuery = ` - query GetAllWorkflowStates { - teams { - nodes { - id - states { - nodes { - id - name - type - color - description - position - } - } - } - } - } - `; - - const response: any = await client.client.rawRequest(statesQuery); - const teamsData = response.data?.teams?.nodes || []; - - for (const team of teamsData) { - for (const state of team.states.nodes) { - result.push({ - id: state.id, - name: state.name, - type: state.type as 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled', - color: state.color, - description: state.description || undefined, - position: state.position, - teamId: team.id, - }); - } - } - } - - // Sort by team, then position - return result.sort((a, b) => { - if (a.teamId !== b.teamId) { - return a.teamId.localeCompare(b.teamId); - } - return a.position - b.position; - }); - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch workflow states: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get a single workflow state by ID - */ -export async function getWorkflowStateById(id: string): Promise<import('./types.js').WorkflowState | null> { - try { - const client = getLinearClient(); - const state = await client.workflowState(id); - - if (!state) { - return null; - } - - const team = await state.team; - - return { - id: state.id, - name: state.name, - type: state.type as 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled', - color: state.color, - description: state.description || undefined, - position: state.position, - teamId: team?.id || '', - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch workflow state: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Create a new workflow state - */ -export async function createWorkflowState(input: WorkflowStateCreateInput): Promise<import('./types.js').WorkflowState> { - try { - const client = getLinearClient(); - - const payload = await client.createWorkflowState({ - name: input.name, - teamId: input.teamId, - type: input.type, - color: input.color, - description: input.description, - position: input.position, - }); - - const state = await payload.workflowState; - if (!state) { - throw new Error('Failed to create workflow state: No state returned from API'); - } - - return { - id: state.id, - name: state.name, - type: state.type as 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled', - color: state.color, - description: state.description || undefined, - position: state.position, - teamId: input.teamId, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to create workflow state: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Update a workflow state - */ -export async function updateWorkflowState(id: string, input: WorkflowStateUpdateInput): Promise<import('./types.js').WorkflowState> { - try { - const client = getLinearClient(); - - const payload = await client.updateWorkflowState(id, { - name: input.name, - color: input.color, - description: input.description, - position: input.position, - }); - - const state = await payload.workflowState; - if (!state) { - throw new Error('Failed to update workflow state: No state returned from API'); - } - - const team = await state.team; - - return { - id: state.id, - name: state.name, - type: state.type as 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled', - color: state.color, - description: state.description || undefined, - position: state.position, - teamId: team?.id || '', - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to update workflow state: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Delete a workflow state (archives it in Linear) - */ -export async function deleteWorkflowState(id: string): Promise<boolean> { - try { - const client = getLinearClient(); - const payload = await client.archiveWorkflowState(id); - return payload.success; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to delete workflow state: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Issue Label API Methods - */ - -export interface IssueLabelCreateInput { - name: string; - color: string; - description?: string; - teamId?: string; // undefined for workspace-level labels -} - -export interface IssueLabelUpdateInput { - name?: string; - color?: string; - description?: string; -} - -/** - * Get all issue labels (workspace-level and/or team-level) - */ -/** - * PERFORMANCE OPTIMIZATION (v0.24.0-alpha.2.1): - * Uses custom GraphQL query to avoid N+1 pattern when fetching all labels - * Previous: 1 + N API calls (1 for labels + N for teams) - * Optimized: 1 API call with nested team data - */ -export async function getAllIssueLabels(teamId?: string): Promise<import('./types.js').IssueLabel[]> { - try { - const client = getLinearClient(); - const result: import('./types.js').IssueLabel[] = []; - - if (teamId) { - // Get labels for a specific team (already efficient - 2 calls) - const team = await client.team(teamId); - if (!team) { - throw new Error(`Team not found: ${teamId}`); - } - - const labels = await team.labels(); - for (const label of labels.nodes) { - result.push({ - id: label.id, - name: label.name, - color: label.color, - description: label.description || undefined, - teamId: team.id, - }); - } - } else { - // Get all labels (workspace + all teams) - OPTIMIZED with custom GraphQL - const labelsQuery = ` - query GetAllIssueLabels { - issueLabels { - nodes { - id - name - color - description - team { - id - } - } - } - } - `; - - const response: any = await client.client.rawRequest(labelsQuery); - const labelsData = response.data?.issueLabels?.nodes || []; - - for (const label of labelsData) { - result.push({ - id: label.id, - name: label.name, - color: label.color, - description: label.description || undefined, - teamId: label.team?.id, - }); - } - } - - // Sort by name - return result.sort((a, b) => a.name.localeCompare(b.name)); - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch issue labels: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get a single issue label by ID - */ -export async function getIssueLabelById(id: string): Promise<import('./types.js').IssueLabel | null> { - try { - const client = getLinearClient(); - const label = await client.issueLabel(id); - - if (!label) { - return null; - } - - const team = await label.team; - - return { - id: label.id, - name: label.name, - color: label.color, - description: label.description || undefined, - teamId: team?.id, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch issue label: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Create a new issue label - */ -export async function createIssueLabel(input: IssueLabelCreateInput): Promise<import('./types.js').IssueLabel> { - try { - const client = getLinearClient(); - - const payload = await client.createIssueLabel({ - name: input.name, - color: input.color, - description: input.description, - teamId: input.teamId, - }); - - const label = await payload.issueLabel; - if (!label) { - throw new Error('Failed to create issue label: No label returned from API'); - } - - return { - id: label.id, - name: label.name, - color: label.color, - description: label.description || undefined, - teamId: input.teamId, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to create issue label: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Update an issue label - */ -export async function updateIssueLabel(id: string, input: IssueLabelUpdateInput): Promise<import('./types.js').IssueLabel> { - try { - const client = getLinearClient(); - - const payload = await client.updateIssueLabel(id, { - name: input.name, - color: input.color, - description: input.description, - }); - - const label = await payload.issueLabel; - if (!label) { - throw new Error('Failed to update issue label: No label returned from API'); - } - - const team = await label.team; - - return { - id: label.id, - name: label.name, - color: label.color, - description: label.description || undefined, - teamId: team?.id, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to update issue label: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Delete an issue label - */ -export async function deleteIssueLabel(id: string): Promise<boolean> { - try { - const client = getLinearClient(); - const payload = await client.deleteIssueLabel(id); - return payload.success; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to delete issue label: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Project Label API Methods - */ - -export interface ProjectLabelCreateInput { - name: string; - color: string; - description?: string; -} - -export interface ProjectLabelUpdateInput { - name?: string; - color?: string; - description?: string; -} - -/** - * Get all project labels (workspace-level only) - * @param includeAll - If true, fetches ALL labels including ones never applied to projects - */ -export async function getAllProjectLabels(includeAll?: boolean): Promise<import('./types.js').ProjectLabel[]> { - try { - const client = getLinearClient(); - const result: import('./types.js').ProjectLabel[] = []; - - if (includeAll) { - // Use raw GraphQL query to fetch ALL project labels including unused ones - const query = ` - query GetAllProjectLabels { - organization { - projectLabels { - nodes { - id - name - color - description - lastAppliedAt - } - } - } - } - `; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const response: any = await client.client.rawRequest(query); - - if (process.env.DEBUG) { - console.log(`DEBUG: Raw GraphQL response:`, JSON.stringify(response.data, null, 2)); - } - - const labels = response.data?.organization?.projectLabels?.nodes || []; - - for (const label of labels) { - result.push({ - id: label.id, - name: label.name, - color: label.color, - description: label.description || undefined, - }); - } - - if (process.env.DEBUG) { - console.log(`DEBUG: Fetched ${result.length} labels via raw GraphQL query`); - } - } else { - // Default: use SDK method which may only return labels that have been applied - const labels = await client.projectLabels(); - - for (const label of labels.nodes) { - result.push({ - id: label.id, - name: label.name, - color: label.color, - description: label.description || undefined, - }); - } - - if (process.env.DEBUG) { - console.log(`DEBUG: Fetched ${result.length} labels from client.projectLabels()`); - } - } - - // Sort by name - return result.sort((a, b) => a.name.localeCompare(b.name)); - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch project labels: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get a single project label by ID - */ -export async function getProjectLabelById(id: string): Promise<import('./types.js').ProjectLabel | null> { - try { - const client = getLinearClient(); - const label = await client.projectLabel(id); - - if (!label) { - return null; - } - - return { - id: label.id, - name: label.name, - color: label.color, - description: label.description || undefined, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch project label: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Create a new project label - */ -export async function createProjectLabel(input: ProjectLabelCreateInput): Promise<import('./types.js').ProjectLabel> { - try { - const client = getLinearClient(); - - const payload = await client.createProjectLabel({ - name: input.name, - color: input.color, - description: input.description, - }); - - const label = await payload.projectLabel; - if (!label) { - throw new Error('Failed to create project label: No label returned from API'); - } - - return { - id: label.id, - name: label.name, - color: label.color, - description: label.description || undefined, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to create project label: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Update a project label - */ -export async function updateProjectLabel(id: string, input: ProjectLabelUpdateInput): Promise<import('./types.js').ProjectLabel> { - try { - const client = getLinearClient(); - - const payload = await client.updateProjectLabel(id, { - name: input.name, - color: input.color, - description: input.description, - }); - - const label = await payload.projectLabel; - if (!label) { - throw new Error('Failed to update project label: No label returned from API'); - } - - return { - id: label.id, - name: label.name, - color: label.color, - description: label.description || undefined, - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to update project label: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Delete a project label - */ -export async function deleteProjectLabel(id: string): Promise<boolean> { - try { - const client = getLinearClient(); - const payload = await client.deleteProjectLabel(id); - return payload.success; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to delete project label: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * External Link API Methods - */ - -export interface ExternalLink { - id: string; - url: string; - label: string; - sortOrder: number; - creatorId: string; -} - -export interface ExternalLinkCreateInput { - url: string; - label: string; - projectId?: string; - initiativeId?: string; - sortOrder?: number; -} - -/** - * Create an external link for a project or initiative - */ -export async function createExternalLink(input: ExternalLinkCreateInput): Promise<ExternalLink> { - try { - const client = getLinearClient(); - - const payload = await client.createEntityExternalLink({ - url: input.url, - label: input.label, - projectId: input.projectId, - initiativeId: input.initiativeId, - sortOrder: input.sortOrder, - }); - - const link = await payload.entityExternalLink; - if (!link) { - throw new Error('Failed to create external link: No link returned from API'); - } - - const creator = await link.creator; - - return { - id: link.id, - url: link.url, - label: link.label, - sortOrder: link.sortOrder, - creatorId: creator?.id ?? '', - }; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to create external link: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Get all external links for a project - */ -export async function getProjectExternalLinks(projectId: string): Promise<ExternalLink[]> { - try { - const client = getLinearClient(); - const project = await client.project(projectId); - - if (!project) { - throw new Error(`Project not found: ${projectId}`); - } - - const links = await project.externalLinks(); - const result: ExternalLink[] = []; - - for (const link of links.nodes) { - const creator = await link.creator; - result.push({ - id: link.id, - url: link.url, - label: link.label, - sortOrder: link.sortOrder, - creatorId: creator?.id ?? '', - }); - } - - // Sort by sort order - return result.sort((a, b) => a.sortOrder - b.sortOrder); - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch external links: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Delete an external link - */ -export async function deleteExternalLink(id: string): Promise<boolean> { - try { - const client = getLinearClient(); - const result = await client.deleteEntityExternalLink(id); - return result.success; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to delete external link: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * M23: Project Dependency Management - * - * Create a project relation (dependency) - * Note: Linear API uses type: "dependency" with anchor-based semantics - * - anchorType: which part of source project ("start" or "end") - * - relatedAnchorType: which part of target project ("start" or "end") - */ -export async function createProjectRelation( - client: SDKClient, - input: ProjectRelationCreateInput -): Promise<ProjectRelation> { - try { - // GraphQL mutation with inline fragment for ProjectRelation fields - const mutation = ` - mutation CreateProjectRelation($input: ProjectRelationCreateInput!) { - projectRelationCreate(input: $input) { - success - projectRelation { - id - type - anchorType - relatedAnchorType - createdAt - updatedAt - project { - id - name - } - relatedProject { - id - name - } - } - } - } - `; - - const result = await client.client.rawRequest(mutation, { - input: { - type: 'dependency', // Always "dependency" (only valid value) - projectId: input.projectId, - relatedProjectId: input.relatedProjectId, - anchorType: input.anchorType, - relatedAnchorType: input.relatedAnchorType, - }, - }); - - const data = result.data as { - projectRelationCreate: { - success: boolean; - projectRelation: ProjectRelation; - }; - }; - - if (!data.projectRelationCreate.success) { - throw new Error('Failed to create project relation'); - } - - return data.projectRelationCreate.projectRelation; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to create project relation: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Delete a project relation by ID - */ -export async function deleteProjectRelation( - client: SDKClient, - relationId: string -): Promise<boolean> { - try { - const mutation = ` - mutation DeleteProjectRelation($id: String!) { - projectRelationDelete(id: $id) { - success - } - } - `; - - const result = await client.client.rawRequest(mutation, { - id: relationId, - }); - - const data = result.data as { - projectRelationDelete: { - success: boolean; - }; - }; - - return data.projectRelationDelete.success; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to delete project relation: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Fetch all project relations for a given project - * Returns both "depends on" and "blocks" relations - */ -export async function getProjectRelations( - client: SDKClient, - projectId: string -): Promise<ProjectRelation[]> { - try { - // Query to fetch project relations using the .relations() method - const query = ` - query GetProjectRelations($projectId: String!) { - project(id: $projectId) { - id - name - relations { - nodes { - id - type - anchorType - relatedAnchorType - createdAt - updatedAt - project { - id - name - } - relatedProject { - id - name - } - } - } - } - } - `; - - const result = await client.client.rawRequest(query, { - projectId, - }); - - const data = result.data as { - project: { - id: string; - name: string; - relations: { - nodes: ProjectRelation[]; - }; - }; - }; - - return data.project.relations.nodes; - } catch (error) { - if (error instanceof LinearClientError) { - throw error; - } - - throw new Error( - `Failed to fetch project relations: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} +// Re-export all API functions for backward compatibility +// All implementations have been split into domain-specific modules under ./api/ +export * from './api/index.js'; From a213faef694c93dcac1be6848195497659862196 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 11 Mar 2026 06:27:08 +0000 Subject: [PATCH 11/11] Fix all ESLint errors and warnings (0 problems) - Auto-fix 122 import/export sorting warnings (simple-import-sort) - Remove unused parseAdvancedDependency import in parsers.test.ts - Replace all 55 no-explicit-any warnings with proper types: - Use unknown for error handler catch parameters - Use specific interfaces for Commander.js options (IssueListCommandOptions, ProjectListCommandOptions) - Use Record<string, unknown> for generic API response objects - Use proper GraphQL response interfaces for raw queries - Type API call tracker and issue resolver parameters https://claude.ai/code/session_01LE3Rf4kmo1TBCozoF14LCa --- src/cli.ts | 33 +++--- src/commands/alias/add.ts | 7 +- src/commands/alias/clear.ts | 7 +- src/commands/alias/edit.tsx | 47 ++++---- src/commands/alias/list.ts | 10 +- src/commands/alias/register.ts | 9 +- src/commands/alias/remove.ts | 2 +- src/commands/alias/sync.ts | 9 +- src/commands/cache/clear.ts | 16 +-- src/commands/cache/register.ts | 3 +- src/commands/cache/stats.ts | 2 +- src/commands/colors/extract.ts | 1 + src/commands/colors/list.tsx | 5 +- src/commands/colors/register.ts | 3 +- src/commands/colors/view.ts | 3 +- src/commands/config/edit.tsx | 13 +- src/commands/config/get.ts | 2 +- src/commands/config/list.ts | 2 +- src/commands/config/register.ts | 9 +- src/commands/config/set.ts | 6 +- src/commands/config/unset.ts | 10 +- src/commands/cycles/list.ts | 6 +- src/commands/cycles/register.ts | 3 +- src/commands/cycles/view.ts | 2 +- src/commands/doctor.ts | 6 +- src/commands/icons/extract.ts | 5 +- src/commands/icons/list.tsx | 5 +- src/commands/icons/register.ts | 3 +- src/commands/icons/view.ts | 3 +- src/commands/initiatives/list.tsx | 11 +- src/commands/initiatives/register.ts | 3 +- src/commands/initiatives/select.tsx | 7 +- src/commands/initiatives/set.ts | 6 +- src/commands/initiatives/sync-aliases.ts | 1 + src/commands/initiatives/view.tsx | 9 +- src/commands/issue-labels/create.ts | 3 +- src/commands/issue-labels/delete.ts | 5 +- src/commands/issue-labels/list.tsx | 11 +- src/commands/issue-labels/register.ts | 7 +- src/commands/issue-labels/sync-aliases.ts | 3 +- src/commands/issue-labels/update.ts | 3 +- src/commands/issue-labels/view.ts | 3 +- src/commands/issue/comment.ts | 4 +- src/commands/issue/create.ts | 12 +- src/commands/issue/list.ts | 49 ++++++-- src/commands/issue/register.ts | 7 +- src/commands/issue/update.ts | 12 +- src/commands/issue/view.ts | 4 +- src/commands/members/list.tsx | 11 +- src/commands/members/register.ts | 1 + src/commands/members/sync-aliases.ts | 5 +- .../create-interactive.tsx | 7 +- src/commands/milestone-templates/create.ts | 4 +- .../milestone-templates/edit-interactive.tsx | 7 +- src/commands/milestone-templates/list.ts | 2 +- src/commands/milestone-templates/register.ts | 7 +- src/commands/milestone-templates/remove.ts | 7 +- src/commands/project-labels/create.ts | 1 + src/commands/project-labels/delete.ts | 5 +- src/commands/project-labels/list.tsx | 7 +- src/commands/project-labels/register.ts | 7 +- src/commands/project-labels/sync-aliases.ts | 1 + src/commands/project-labels/update.ts | 3 +- src/commands/project-labels/view.ts | 3 +- src/commands/project-status/list.tsx | 9 +- src/commands/project-status/register.ts | 3 +- src/commands/project-status/view.ts | 6 +- src/commands/project/add-milestones.ts | 6 +- src/commands/project/create.tsx | 25 ++-- src/commands/project/dependencies/add.ts | 6 +- src/commands/project/dependencies/clear.ts | 9 +- src/commands/project/dependencies/list.ts | 4 +- src/commands/project/dependencies/remove.ts | 6 +- src/commands/project/list.tsx | 51 ++++++-- src/commands/project/register.ts | 7 +- src/commands/project/update.ts | 7 +- src/commands/project/view.ts | 4 +- src/commands/setup.tsx | 21 ++-- src/commands/teams/list.tsx | 9 +- src/commands/teams/register.ts | 3 +- src/commands/teams/select.tsx | 7 +- src/commands/teams/set.ts | 6 +- src/commands/teams/sync-aliases.ts | 1 + src/commands/teams/view.ts | 4 +- src/commands/templates/list.tsx | 9 +- src/commands/templates/register.ts | 1 + src/commands/templates/view.ts | 2 +- src/commands/whoami.ts | 2 +- src/commands/workflow-states/create.ts | 3 +- src/commands/workflow-states/delete.ts | 5 +- src/commands/workflow-states/list.tsx | 11 +- src/commands/workflow-states/register.ts | 7 +- src/commands/workflow-states/sync-aliases.ts | 5 +- src/commands/workflow-states/update.ts | 3 +- src/commands/workflow-states/view.ts | 3 +- src/lib/aliases.ts | 17 +-- src/lib/api-call-tracker.ts | 4 +- src/lib/api/client.ts | 1 + src/lib/api/index.ts | 12 +- src/lib/api/issues.ts | 111 ++++++++++++++---- src/lib/api/labels.ts | 7 +- src/lib/api/projects.ts | 97 ++++++++++++--- src/lib/api/workflow-states.ts | 4 +- src/lib/batch-fetcher.ts | 4 +- src/lib/colors.ts | 2 +- src/lib/config.ts | 1 + src/lib/date-parser.test.ts | 9 +- src/lib/entity-cache.ts | 14 +-- src/lib/error-handler.test.ts | 5 +- src/lib/error-handler.ts | 72 +++++++----- src/lib/icons.ts | 2 +- src/lib/issue-resolver.ts | 4 +- src/lib/milestone-templates.ts | 5 +- src/lib/parsers.test.ts | 8 +- src/lib/project-resolver.ts | 5 +- src/lib/smoke.test.ts | 2 +- src/lib/status-cache.ts | 18 +-- src/lib/types.ts | 4 +- src/lib/validators.test.ts | 9 +- src/ui/components/InitiativeList.tsx | 5 +- src/ui/components/MemberList.tsx | 5 +- src/ui/components/MemberSelector.tsx | 3 +- src/ui/components/ProjectForm.tsx | 5 +- src/ui/components/TeamList.tsx | 3 +- 124 files changed, 713 insertions(+), 440 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 0c6660e..7e3fb09 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,29 +1,28 @@ import { Command } from 'commander'; -import { whoamiCommand } from './commands/whoami.js'; -import { doctorCommand } from './commands/doctor.js'; -import { setup } from './commands/setup.js'; - -import { setLogLevel } from './lib/logger.js'; -import { setNoColor } from './lib/output.js'; +import { registerAliasCommands } from './commands/alias/register.js'; +import { registerCacheCommands } from './commands/cache/register.js'; +import { registerColorsCommands } from './commands/colors/register.js'; +import { registerConfigCommands } from './commands/config/register.js'; +import { registerCyclesCommands } from './commands/cycles/register.js'; +import { doctorCommand } from './commands/doctor.js'; +import { registerIconsCommands } from './commands/icons/register.js'; // Per-entity command registrations import { registerInitiativesCommands } from './commands/initiatives/register.js'; -import { registerProjectCommands } from './commands/project/register.js'; import { registerIssueCommands } from './commands/issue/register.js'; -import { registerTeamsCommands } from './commands/teams/register.js'; +import { registerIssueLabelsCommands } from './commands/issue-labels/register.js'; import { registerMembersCommands } from './commands/members/register.js'; -import { registerProjectStatusCommands } from './commands/project-status/register.js'; -import { registerAliasCommands } from './commands/alias/register.js'; import { registerMilestoneTemplatesCommands } from './commands/milestone-templates/register.js'; +import { registerProjectCommands } from './commands/project/register.js'; +import { registerProjectLabelsCommands } from './commands/project-labels/register.js'; +import { registerProjectStatusCommands } from './commands/project-status/register.js'; +import { setup } from './commands/setup.js'; +import { registerTeamsCommands } from './commands/teams/register.js'; import { registerTemplatesCommands } from './commands/templates/register.js'; -import { registerConfigCommands } from './commands/config/register.js'; +import { whoamiCommand } from './commands/whoami.js'; import { registerWorkflowStatesCommands } from './commands/workflow-states/register.js'; -import { registerIssueLabelsCommands } from './commands/issue-labels/register.js'; -import { registerProjectLabelsCommands } from './commands/project-labels/register.js'; -import { registerIconsCommands } from './commands/icons/register.js'; -import { registerColorsCommands } from './commands/colors/register.js'; -import { registerCacheCommands } from './commands/cache/register.js'; -import { registerCyclesCommands } from './commands/cycles/register.js'; +import { setLogLevel } from './lib/logger.js'; +import { setNoColor } from './lib/output.js'; const cli = new Command(); diff --git a/src/commands/alias/add.ts b/src/commands/alias/add.ts index 806cacb..089210a 100644 --- a/src/commands/alias/add.ts +++ b/src/commands/alias/add.ts @@ -1,9 +1,10 @@ -import React from 'react'; import { render } from 'ink'; +import React from 'react'; + import { addAlias, normalizeEntityType } from '../../lib/aliases.js'; -import { showSuccess, showError, showInfo } from '../../lib/output.js'; +import { getMemberByEmail, type Member,searchMembers } from '../../lib/linear-client.js'; +import { showError, showInfo,showSuccess } from '../../lib/output.js'; import { getScopeInfo } from '../../lib/scope.js'; -import { getMemberByEmail, searchMembers, type Member } from '../../lib/linear-client.js'; import { MemberSelector } from '../../ui/components/MemberSelector.js'; interface AddAliasOptions { diff --git a/src/commands/alias/clear.ts b/src/commands/alias/clear.ts index ae5b369..3cdfdcc 100644 --- a/src/commands/alias/clear.ts +++ b/src/commands/alias/clear.ts @@ -1,8 +1,9 @@ -import { normalizeEntityType, clearAliases } from '../../lib/aliases.js'; -import { getScopeInfo } from '../../lib/scope.js'; -import { showSuccess, showError, showInfo } from '../../lib/output.js'; import * as readline from 'readline'; +import { clearAliases,normalizeEntityType } from '../../lib/aliases.js'; +import { showError, showInfo,showSuccess } from '../../lib/output.js'; +import { getScopeInfo } from '../../lib/scope.js'; + interface ClearAliasOptions { global?: boolean; project?: boolean; diff --git a/src/commands/alias/edit.tsx b/src/commands/alias/edit.tsx index ff240f0..0d5a3d7 100644 --- a/src/commands/alias/edit.tsx +++ b/src/commands/alias/edit.tsx @@ -1,41 +1,42 @@ -import React, { useState } from 'react'; -import { render, Box, Text } from 'ink'; +import { Box, render, Text } from 'ink'; import SelectInput from 'ink-select-input'; import TextInput from 'ink-text-input'; +import React, { useState } from 'react'; + import { + addAlias, loadAliases, - updateAliasId, - renameAlias, removeAlias, - addAlias, + renameAlias, + updateAliasId, } from '../../lib/aliases.js'; -import type { AliasEntityType } from '../../lib/types.js'; import { - validateInitiativeExists, - validateTeamExists, - getProjectById, - getTemplateById, - getProjectStatusById, - getMemberById, - getIssueLabelById, - getProjectLabelById, - getWorkflowStateById, getAllInitiatives, - getAllTeams, - getAllProjects, - getAllTemplates, - getAllProjectStatuses, - getAllMembers, getAllIssueLabels, + getAllMembers, getAllProjectLabels, + getAllProjects, + getAllProjectStatuses, + getAllTeams, + getAllTemplates, getAllWorkflowStates, + getIssueLabelById, + getMemberById, + getProjectById, + getProjectLabelById, + getProjectStatusById, + getTemplateById, + getWorkflowStateById, type Initiative, - type Team, + type Member, type Project, - type Template, type ProjectStatus, - type Member, + type Team, + type Template, + validateInitiativeExists, + validateTeamExists, } from '../../lib/linear-client.js'; +import type { AliasEntityType } from '../../lib/types.js'; interface EditOptions { global?: boolean; diff --git a/src/commands/alias/list.ts b/src/commands/alias/list.ts index 950cfbe..9cc945b 100644 --- a/src/commands/alias/list.ts +++ b/src/commands/alias/list.ts @@ -1,17 +1,17 @@ import { + getGlobalAliasesPath, + getProjectAliasesPath, + hasGlobalAliases, + hasProjectAliases, listAliases, normalizeEntityType, validateAllAliases, - hasGlobalAliases, - hasProjectAliases, - getGlobalAliasesPath, - getProjectAliasesPath, } from '../../lib/aliases.js'; import { getApiKey } from '../../lib/config.js'; import { + getProjectById, validateInitiativeExists, validateTeamExists, - getProjectById, } from '../../lib/linear-client.js'; import type { AliasEntityType, ResolvedAliases } from '../../lib/types.js'; diff --git a/src/commands/alias/register.ts b/src/commands/alias/register.ts index 63d3f48..15fc038 100644 --- a/src/commands/alias/register.ts +++ b/src/commands/alias/register.ts @@ -1,11 +1,12 @@ -import { Command, Argument } from 'commander'; +import { Argument,Command } from 'commander'; + import { addAliasCommand } from './add.js'; +import { clearAliasCommand } from './clear.js'; +import { editAlias } from './edit.js'; +import { getAliasCommand } from './get.js'; import { listAliasCommand } from './list.js'; import { removeAliasCommand } from './remove.js'; -import { getAliasCommand } from './get.js'; -import { editAlias } from './edit.js'; import { aliasSyncCommand } from './sync.js'; -import { clearAliasCommand } from './clear.js'; export function registerAliasCommands(cli: Command): void { const alias = cli diff --git a/src/commands/alias/remove.ts b/src/commands/alias/remove.ts index 437ca03..83634b0 100644 --- a/src/commands/alias/remove.ts +++ b/src/commands/alias/remove.ts @@ -1,4 +1,4 @@ -import { removeAlias, normalizeEntityType } from '../../lib/aliases.js'; +import { normalizeEntityType,removeAlias } from '../../lib/aliases.js'; import { getScopeInfo } from '../../lib/scope.js'; interface RemoveAliasOptions { diff --git a/src/commands/alias/sync.ts b/src/commands/alias/sync.ts index be9339f..d9970cc 100644 --- a/src/commands/alias/sync.ts +++ b/src/commands/alias/sync.ts @@ -1,12 +1,13 @@ -import { Command, Argument } from 'commander'; +import { Argument,Command } from 'commander'; + import { normalizeEntityType } from '../../lib/aliases.js'; import { syncInitiativeAliasesCore } from '../initiatives/sync-aliases.js'; -import { syncTeamAliasesCore } from '../teams/sync-aliases.js'; -import { syncMemberAliasesCore, type SyncMemberAliasesOptions } from '../members/sync-aliases.js'; -import { syncWorkflowStateAliasesCore, type SyncWorkflowStateAliasesOptions } from '../workflow-states/sync-aliases.js'; import { syncIssueLabelAliasesCore, type SyncIssueLabelAliasesOptions } from '../issue-labels/sync-aliases.js'; +import { syncMemberAliasesCore, type SyncMemberAliasesOptions } from '../members/sync-aliases.js'; import { syncProjectLabelAliasesCore } from '../project-labels/sync-aliases.js'; import { syncProjectStatusAliases } from '../project-status/sync-aliases.js'; +import { syncTeamAliasesCore } from '../teams/sync-aliases.js'; +import { syncWorkflowStateAliasesCore, type SyncWorkflowStateAliasesOptions } from '../workflow-states/sync-aliases.js'; /** * Register the centralized sync command for aliases diff --git a/src/commands/cache/clear.ts b/src/commands/cache/clear.ts index 6d43f8d..657c8aa 100644 --- a/src/commands/cache/clear.ts +++ b/src/commands/cache/clear.ts @@ -1,15 +1,15 @@ -import { getEntityCache, clearGlobalCache } from '../../lib/entity-cache.js'; +import type { CacheableEntityType } from '../../lib/entity-cache.js'; +import { clearGlobalCache,getEntityCache } from '../../lib/entity-cache.js'; import { clearAllCache, - clearTeamsCache, clearInitiativesCache, + clearIssueLabelsCache, clearMembersCache, - clearTemplatesCache, + clearProjectLabelsCache, clearStatusCache, - clearWorkflowStatesCache, - clearIssueLabelsCache, - clearProjectLabelsCache -} from '../../lib/status-cache.js'; + clearTeamsCache, + clearTemplatesCache, + clearWorkflowStatesCache} from '../../lib/status-cache.js'; /** * Clear all cached entities (both session and persistent) @@ -31,7 +31,7 @@ export async function clearCache(options?: { entity?: string }) { console.log(`🗑️ Clearing ${options.entity} cache...`); // Clear session cache (in-memory) - cache.clearEntity(options.entity as any); + cache.clearEntity(options.entity as CacheableEntityType); // Clear persistent cache (file-based) switch (options.entity) { diff --git a/src/commands/cache/register.ts b/src/commands/cache/register.ts index 0b13373..848e24f 100644 --- a/src/commands/cache/register.ts +++ b/src/commands/cache/register.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; -import { showCacheStats } from './stats.js'; + import { clearCache } from './clear.js'; +import { showCacheStats } from './stats.js'; export function registerCacheCommands(cli: Command): void { const cache = cli diff --git a/src/commands/cache/stats.ts b/src/commands/cache/stats.ts index 94adf2f..8863054 100644 --- a/src/commands/cache/stats.ts +++ b/src/commands/cache/stats.ts @@ -1,5 +1,5 @@ -import { getEntityCache } from '../../lib/entity-cache.js'; import { getConfig } from '../../lib/config.js'; +import { getEntityCache } from '../../lib/entity-cache.js'; /** * Display cache statistics diff --git a/src/commands/colors/extract.ts b/src/commands/colors/extract.ts index 9038dc9..2c47438 100644 --- a/src/commands/colors/extract.ts +++ b/src/commands/colors/extract.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; + import { extractColorsFromEntities, formatColorPreview } from '../../lib/colors.js'; export function extractColors(program: Command) { diff --git a/src/commands/colors/list.tsx b/src/commands/colors/list.tsx index a69a77c..075a5f0 100644 --- a/src/commands/colors/list.tsx +++ b/src/commands/colors/list.tsx @@ -1,6 +1,7 @@ -import React from 'react'; -import { render, Text, Box } from 'ink'; import { Command } from 'commander'; +import { Box,render, Text } from 'ink'; +import React from 'react'; + import { CURATED_COLORS, extractColorsFromEntities, formatColorPreview } from '../../lib/colors.js'; interface ColorsListProps { diff --git a/src/commands/colors/register.ts b/src/commands/colors/register.ts index aa6d1ef..ab422e8 100644 --- a/src/commands/colors/register.ts +++ b/src/commands/colors/register.ts @@ -1,7 +1,8 @@ import { Command } from 'commander'; + +import { extractColors } from './extract.js'; import { listColors } from './list.js'; import { viewColor } from './view.js'; -import { extractColors } from './extract.js'; export function registerColorsCommands(cli: Command): void { const colors = cli diff --git a/src/commands/colors/view.ts b/src/commands/colors/view.ts index bec1def..07a7509 100644 --- a/src/commands/colors/view.ts +++ b/src/commands/colors/view.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; -import { getColorUsage, findColorByHex, formatColorPreview } from '../../lib/colors.js'; + +import { findColorByHex, formatColorPreview,getColorUsage } from '../../lib/colors.js'; export function viewColor(program: Command) { program diff --git a/src/commands/config/edit.tsx b/src/commands/config/edit.tsx index 3ae4d67..7665fe2 100644 --- a/src/commands/config/edit.tsx +++ b/src/commands/config/edit.tsx @@ -1,17 +1,15 @@ -import React, { useState } from 'react'; -import { render, Box, Text } from 'ink'; +import { Box, render, Text } from 'ink'; import SelectInput from 'ink-select-input'; import TextInput from 'ink-text-input'; +import React, { useState } from 'react'; + import { getConfig, + maskApiKey, setConfigValue, unsetConfigValue, - maskApiKey, } from '../../lib/config.js'; import { - validateApiKey, - validateInitiativeExists, - validateTeamExists, getAllInitiatives, getAllTeams, getAllTemplates, @@ -19,6 +17,9 @@ import { type Initiative, type Team, type Template, + validateApiKey, + validateInitiativeExists, + validateTeamExists, } from '../../lib/linear-client.js'; import { getScopeInfo } from '../../lib/scope.js'; diff --git a/src/commands/config/get.ts b/src/commands/config/get.ts index 4e1f4d5..633e540 100644 --- a/src/commands/config/get.ts +++ b/src/commands/config/get.ts @@ -1,4 +1,4 @@ -import { getConfig, maskApiKey, type ConfigKey } from '../../lib/config.js'; +import { type ConfigKey,getConfig, maskApiKey } from '../../lib/config.js'; export async function getConfigValue(key: ConfigKey) { try { diff --git a/src/commands/config/list.ts b/src/commands/config/list.ts index 6998c7e..479c784 100644 --- a/src/commands/config/list.ts +++ b/src/commands/config/list.ts @@ -7,9 +7,9 @@ import { maskApiKey, } from '../../lib/config.js'; import { + getTemplateById, validateInitiativeExists, validateTeamExists, - getTemplateById, } from '../../lib/linear-client.js'; import { getMilestoneTemplate } from '../../lib/milestone-templates.js'; diff --git a/src/commands/config/register.ts b/src/commands/config/register.ts index 9d249d0..9321c3f 100644 --- a/src/commands/config/register.ts +++ b/src/commands/config/register.ts @@ -1,10 +1,11 @@ -import { Command, Argument } from 'commander'; -import { listConfig } from './list.js'; -import { getConfigValue } from './get.js'; +import { Argument,Command } from 'commander'; + import type { ConfigKey } from '../../lib/config.js'; +import { editConfig } from './edit.js'; +import { getConfigValue } from './get.js'; +import { listConfig } from './list.js'; import { setConfig } from './set.js'; import { unsetConfig } from './unset.js'; -import { editConfig } from './edit.js'; export function registerConfigCommands(cli: Command): void { const config = cli diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts index 8afbcca..ded4781 100644 --- a/src/commands/config/set.ts +++ b/src/commands/config/set.ts @@ -1,12 +1,12 @@ -import { isValidConfigKey, setConfigValue, type ConfigKey } from '../../lib/config.js'; +import { type ConfigKey,isValidConfigKey, setConfigValue } from '../../lib/config.js'; import { + getTemplateById, validateApiKey, validateInitiativeExists, validateTeamExists, - getTemplateById, } from '../../lib/linear-client.js'; import { getMilestoneTemplate } from '../../lib/milestone-templates.js'; -import { showValidated, showSuccess, showError } from '../../lib/output.js'; +import { showError,showSuccess, showValidated } from '../../lib/output.js'; import { getScopeInfo } from '../../lib/scope.js'; interface SetConfigOptions { diff --git a/src/commands/config/unset.ts b/src/commands/config/unset.ts index c072866..9a5b7c0 100644 --- a/src/commands/config/unset.ts +++ b/src/commands/config/unset.ts @@ -1,11 +1,11 @@ import { - isValidConfigKey, - unsetConfigValue, - hasGlobalConfig, - hasProjectConfig, + type ConfigKey, getGlobalConfigPath, getProjectConfigPath, - type ConfigKey, + hasGlobalConfig, + hasProjectConfig, + isValidConfigKey, + unsetConfigValue, } from '../../lib/config.js'; import { getScopeInfo } from '../../lib/scope.js'; diff --git a/src/commands/cycles/list.ts b/src/commands/cycles/list.ts index 39604f8..11aa479 100644 --- a/src/commands/cycles/list.ts +++ b/src/commands/cycles/list.ts @@ -1,7 +1,7 @@ -import { getAllCycles } from '../../lib/linear-client.js'; -import { getConfig } from '../../lib/config.js'; import { resolveAlias } from '../../lib/aliases.js'; -import { showError, formatListTSV, formatListJSON } from '../../lib/output.js'; +import { getConfig } from '../../lib/config.js'; +import { getAllCycles } from '../../lib/linear-client.js'; +import { formatListJSON,formatListTSV, showError } from '../../lib/output.js'; interface ListCyclesOptions { team?: string; diff --git a/src/commands/cycles/register.ts b/src/commands/cycles/register.ts index 27b980d..565ef00 100644 --- a/src/commands/cycles/register.ts +++ b/src/commands/cycles/register.ts @@ -1,7 +1,8 @@ import { Command } from 'commander'; + import { listCyclesCommand } from './list.js'; -import { viewCycleCommand } from './view.js'; import { syncCycleAliasesCore } from './sync-aliases.js'; +import { viewCycleCommand } from './view.js'; export function registerCyclesCommands(cli: Command): void { const cycles = cli diff --git a/src/commands/cycles/view.ts b/src/commands/cycles/view.ts index 2939efb..de89cee 100644 --- a/src/commands/cycles/view.ts +++ b/src/commands/cycles/view.ts @@ -1,5 +1,5 @@ -import { getCycleById } from '../../lib/linear-client.js'; import { resolveAlias } from '../../lib/aliases.js'; +import { getCycleById } from '../../lib/linear-client.js'; import { showError } from '../../lib/output.js'; /** diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index cfbf376..c56fae4 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,7 +1,7 @@ -import { testConnection, getCurrentUser } from '../lib/linear-client.js'; -import { getConfig, getApiKey } from '../lib/config.js'; -import { getEntityCache } from '../lib/entity-cache.js'; import { getAliasesForType } from '../lib/aliases.js'; +import { getApiKey,getConfig } from '../lib/config.js'; +import { getEntityCache } from '../lib/entity-cache.js'; +import { getCurrentUser,testConnection } from '../lib/linear-client.js'; import type { AliasEntityType } from '../lib/types.js'; /** diff --git a/src/commands/icons/extract.ts b/src/commands/icons/extract.ts index db60322..09e5196 100644 --- a/src/commands/icons/extract.ts +++ b/src/commands/icons/extract.ts @@ -1,8 +1,9 @@ import { Command } from 'commander'; -import { extractIconsFromEntities } from '../../lib/icons.js'; + import { resolveAlias } from '../../lib/aliases.js'; -import { validateTeamExists } from '../../lib/linear-client.js'; import { getConfig } from '../../lib/config.js'; +import { extractIconsFromEntities } from '../../lib/icons.js'; +import { validateTeamExists } from '../../lib/linear-client.js'; export function extractIcons(program: Command) { program diff --git a/src/commands/icons/list.tsx b/src/commands/icons/list.tsx index 69fa9a9..a08339c 100644 --- a/src/commands/icons/list.tsx +++ b/src/commands/icons/list.tsx @@ -1,6 +1,7 @@ -import React from 'react'; -import { render, Text, Box } from 'ink'; import { Command } from 'commander'; +import { Box,render, Text } from 'ink'; +import React from 'react'; + import { CURATED_ICONS, getIconsByCategory, searchIcons } from '../../lib/icons.js'; interface IconsListProps { diff --git a/src/commands/icons/register.ts b/src/commands/icons/register.ts index 2797309..4ceae7f 100644 --- a/src/commands/icons/register.ts +++ b/src/commands/icons/register.ts @@ -1,7 +1,8 @@ import { Command } from 'commander'; + +import { extractIcons } from './extract.js'; import { listIcons } from './list.js'; import { viewIcon } from './view.js'; -import { extractIcons } from './extract.js'; export function registerIconsCommands(cli: Command): void { const icons = cli diff --git a/src/commands/icons/view.ts b/src/commands/icons/view.ts index 3557efc..8a5ae25 100644 --- a/src/commands/icons/view.ts +++ b/src/commands/icons/view.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; -import { findIconByName, findIconByEmoji } from '../../lib/icons.js'; + +import { findIconByEmoji,findIconByName } from '../../lib/icons.js'; export function viewIcon(program: Command) { program diff --git a/src/commands/initiatives/list.tsx b/src/commands/initiatives/list.tsx index af3e257..fda317f 100644 --- a/src/commands/initiatives/list.tsx +++ b/src/commands/initiatives/list.tsx @@ -1,10 +1,11 @@ +import { Box, render, Text } from 'ink'; import React, { useEffect, useState } from 'react'; -import { render, Box, Text } from 'ink'; -import { InitiativeList } from '../../ui/components/InitiativeList.js'; -import { getAllInitiatives, type Initiative } from '../../lib/linear-client.js'; -import { openInBrowser } from '../../lib/browser.js'; -import { formatListTSV, formatListJSON } from '../../lib/output.js'; + import { getAliasesForId } from '../../lib/aliases.js'; +import { openInBrowser } from '../../lib/browser.js'; +import { getAllInitiatives, type Initiative } from '../../lib/linear-client.js'; +import { formatListJSON,formatListTSV } from '../../lib/output.js'; +import { InitiativeList } from '../../ui/components/InitiativeList.js'; interface ListOptions { interactive?: boolean; diff --git a/src/commands/initiatives/register.ts b/src/commands/initiatives/register.ts index 8e5e736..809a4ac 100644 --- a/src/commands/initiatives/register.ts +++ b/src/commands/initiatives/register.ts @@ -1,9 +1,10 @@ import { Command } from 'commander'; + import { listInitiatives } from './list.js'; -import { viewInitiative } from './view.js'; import { selectInitiative } from './select.js'; import { setInitiative } from './set.js'; import { syncInitiativeAliases } from './sync-aliases.js'; +import { viewInitiative } from './view.js'; export function registerInitiativesCommands(cli: Command): void { const initiatives = cli diff --git a/src/commands/initiatives/select.tsx b/src/commands/initiatives/select.tsx index df7430a..8c5a372 100644 --- a/src/commands/initiatives/select.tsx +++ b/src/commands/initiatives/select.tsx @@ -1,9 +1,10 @@ +import { Box, render, Text } from 'ink'; import React, { useEffect, useState } from 'react'; -import { render, Box, Text } from 'ink'; -import { InitiativeList } from '../../ui/components/InitiativeList.js'; -import { getAllInitiatives, type Initiative } from '../../lib/linear-client.js'; + import { setConfigValue } from '../../lib/config.js'; +import { getAllInitiatives, type Initiative } from '../../lib/linear-client.js'; import { getScopeInfo } from '../../lib/scope.js'; +import { InitiativeList } from '../../ui/components/InitiativeList.js'; interface SelectOptions { global?: boolean; diff --git a/src/commands/initiatives/set.ts b/src/commands/initiatives/set.ts index 20b4bed..6cf8d1c 100644 --- a/src/commands/initiatives/set.ts +++ b/src/commands/initiatives/set.ts @@ -1,7 +1,7 @@ -import { validateInitiativeExists } from '../../lib/linear-client.js'; -import { setConfigValue } from '../../lib/config.js'; import { resolveAlias } from '../../lib/aliases.js'; -import { showResolvedAlias, showValidating, showValidated, showSuccess, showError, showInfo } from '../../lib/output.js'; +import { setConfigValue } from '../../lib/config.js'; +import { validateInitiativeExists } from '../../lib/linear-client.js'; +import { showError, showInfo,showResolvedAlias, showSuccess, showValidated, showValidating } from '../../lib/output.js'; import { getScopeInfo } from '../../lib/scope.js'; interface SetInitiativeOptions { diff --git a/src/commands/initiatives/sync-aliases.ts b/src/commands/initiatives/sync-aliases.ts index ef7e87e..f17863f 100644 --- a/src/commands/initiatives/sync-aliases.ts +++ b/src/commands/initiatives/sync-aliases.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; + import { getAllInitiatives } from '../../lib/linear-client.js'; import { syncAliasesCore, type SyncAliasesOptions } from '../../lib/sync-aliases.js'; diff --git a/src/commands/initiatives/view.tsx b/src/commands/initiatives/view.tsx index 847cdd8..1a777e4 100644 --- a/src/commands/initiatives/view.tsx +++ b/src/commands/initiatives/view.tsx @@ -1,10 +1,11 @@ +import { Box, render, Text } from 'ink'; import React, { useEffect, useState } from 'react'; -import { render, Box, Text } from 'ink'; -import { InitiativeList } from '../../ui/components/InitiativeList.js'; -import { getInitiativeById, getAllInitiatives, type Initiative } from '../../lib/linear-client.js'; + import { resolveAlias } from '../../lib/aliases.js'; -import { showResolvedAlias, showEntityNotFound } from '../../lib/output.js'; import { openInBrowser } from '../../lib/browser.js'; +import { getAllInitiatives, getInitiativeById, type Initiative } from '../../lib/linear-client.js'; +import { showEntityNotFound,showResolvedAlias } from '../../lib/output.js'; +import { InitiativeList } from '../../ui/components/InitiativeList.js'; interface ViewOptions { interactive?: boolean; diff --git a/src/commands/issue-labels/create.ts b/src/commands/issue-labels/create.ts index 121266b..913cb08 100644 --- a/src/commands/issue-labels/create.ts +++ b/src/commands/issue-labels/create.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; -import { createIssueLabel } from '../../lib/linear-client.js'; + import { resolveAlias } from '../../lib/aliases.js'; +import { createIssueLabel } from '../../lib/linear-client.js'; export function createIssueLabelCommand(program: Command) { program diff --git a/src/commands/issue-labels/delete.ts b/src/commands/issue-labels/delete.ts index f5685ec..4d32e8d 100644 --- a/src/commands/issue-labels/delete.ts +++ b/src/commands/issue-labels/delete.ts @@ -1,8 +1,9 @@ import { Command } from 'commander'; -import { getIssueLabelById, deleteIssueLabel } from '../../lib/linear-client.js'; -import { resolveAlias } from '../../lib/aliases.js'; import * as readline from 'readline'; +import { resolveAlias } from '../../lib/aliases.js'; +import { deleteIssueLabel,getIssueLabelById } from '../../lib/linear-client.js'; + async function confirm(message: string): Promise<boolean> { const rl = readline.createInterface({ input: process.stdin, diff --git a/src/commands/issue-labels/list.tsx b/src/commands/issue-labels/list.tsx index 48a5427..263f024 100644 --- a/src/commands/issue-labels/list.tsx +++ b/src/commands/issue-labels/list.tsx @@ -1,10 +1,11 @@ -import React from 'react'; -import { render, Text, Box } from 'ink'; import { Command } from 'commander'; -import { getAllIssueLabels } from '../../lib/linear-client.js'; -import { getConfig } from '../../lib/config.js'; -import { formatColorPreview } from '../../lib/colors.js'; +import { Box,render, Text } from 'ink'; +import React from 'react'; + import { resolveAlias } from '../../lib/aliases.js'; +import { formatColorPreview } from '../../lib/colors.js'; +import { getConfig } from '../../lib/config.js'; +import { getAllIssueLabels } from '../../lib/linear-client.js'; interface IssueLabelsListProps { teamId?: string; diff --git a/src/commands/issue-labels/register.ts b/src/commands/issue-labels/register.ts index 2cfa107..48aecd4 100644 --- a/src/commands/issue-labels/register.ts +++ b/src/commands/issue-labels/register.ts @@ -1,10 +1,11 @@ import { Command } from 'commander'; -import { listIssueLabels } from './list.js'; -import { viewIssueLabel } from './view.js'; + import { createIssueLabelCommand } from './create.js'; -import { updateIssueLabelCommand } from './update.js'; import { deleteIssueLabelCommand } from './delete.js'; +import { listIssueLabels } from './list.js'; import { syncIssueLabelAliases } from './sync-aliases.js'; +import { updateIssueLabelCommand } from './update.js'; +import { viewIssueLabel } from './view.js'; export function registerIssueLabelsCommands(cli: Command): void { const issueLabels = cli diff --git a/src/commands/issue-labels/sync-aliases.ts b/src/commands/issue-labels/sync-aliases.ts index 74bca45..6eab001 100644 --- a/src/commands/issue-labels/sync-aliases.ts +++ b/src/commands/issue-labels/sync-aliases.ts @@ -1,7 +1,8 @@ import { Command } from 'commander'; + +import { resolveAlias } from '../../lib/aliases.js'; import { getAllIssueLabels } from '../../lib/linear-client.js'; import { syncAliasesCore, type SyncAliasesOptions } from '../../lib/sync-aliases.js'; -import { resolveAlias } from '../../lib/aliases.js'; /** * Extended options for issue-label sync (includes team filtering) diff --git a/src/commands/issue-labels/update.ts b/src/commands/issue-labels/update.ts index c10f5fd..d124b08 100644 --- a/src/commands/issue-labels/update.ts +++ b/src/commands/issue-labels/update.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; -import { getIssueLabelById, updateIssueLabel } from '../../lib/linear-client.js'; + import { resolveAlias } from '../../lib/aliases.js'; +import { getIssueLabelById, updateIssueLabel } from '../../lib/linear-client.js'; export function updateIssueLabelCommand(program: Command) { program diff --git a/src/commands/issue-labels/view.ts b/src/commands/issue-labels/view.ts index 49c0143..4c57797 100644 --- a/src/commands/issue-labels/view.ts +++ b/src/commands/issue-labels/view.ts @@ -1,7 +1,8 @@ import { Command } from 'commander'; -import { getIssueLabelById } from '../../lib/linear-client.js'; + import { resolveAlias } from '../../lib/aliases.js'; import { formatColorPreview } from '../../lib/colors.js'; +import { getIssueLabelById } from '../../lib/linear-client.js'; export function viewIssueLabel(program: Command) { program diff --git a/src/commands/issue/comment.ts b/src/commands/issue/comment.ts index f613474..c53a100 100644 --- a/src/commands/issue/comment.ts +++ b/src/commands/issue/comment.ts @@ -1,6 +1,6 @@ -import { createIssueComment } from '../../lib/linear-client.js'; -import { resolveIssueIdentifier } from '../../lib/issue-resolver.js'; import { readContentFile } from '../../lib/file-utils.js'; +import { resolveIssueIdentifier } from '../../lib/issue-resolver.js'; +import { createIssueComment } from '../../lib/linear-client.js'; import { showError, showSuccess } from '../../lib/output.js'; interface CommentOptions { diff --git a/src/commands/issue/create.ts b/src/commands/issue/create.ts index 3334a56..af69ea4 100644 --- a/src/commands/issue/create.ts +++ b/src/commands/issue/create.ts @@ -1,16 +1,16 @@ +import { resolveAlias } from '../../lib/aliases.js'; +import { openInBrowser } from '../../lib/browser.js'; +import { getConfig } from '../../lib/config.js'; +import { readContentFile } from '../../lib/file-utils.js'; +import { resolveIssueId } from '../../lib/issue-resolver.js'; import { createIssue, getCurrentUser, - resolveMemberIdentifier, getTemplateById, + resolveMemberIdentifier, validateTeamExists, } from '../../lib/linear-client.js'; import type { IssueCreateInput } from '../../lib/types.js'; -import { getConfig } from '../../lib/config.js'; -import { openInBrowser } from '../../lib/browser.js'; -import { resolveAlias } from '../../lib/aliases.js'; -import { resolveIssueId } from '../../lib/issue-resolver.js'; -import { readContentFile } from '../../lib/file-utils.js'; interface CreateOptions { // Required diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index e13a7f0..ca1829c 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -10,20 +10,49 @@ */ import type { Command } from 'commander'; -import { getAllIssues } from '../../lib/linear-client.js'; -import { showError, formatContentPreview, filterColumns } from '../../lib/output.js'; -import { getConfig } from '../../lib/config.js'; + import { resolveAlias } from '../../lib/aliases.js'; -import { resolveProjectId } from '../../lib/project-resolver.js'; -import { resolveIssueIdentifier } from '../../lib/issue-resolver.js'; -import { getEntityCache } from '../../lib/entity-cache.js'; import { openInBrowser } from '../../lib/browser.js'; +import { getConfig } from '../../lib/config.js'; +import { getEntityCache } from '../../lib/entity-cache.js'; +import { resolveIssueIdentifier } from '../../lib/issue-resolver.js'; +import { getAllIssues } from '../../lib/linear-client.js'; +import { filterColumns,formatContentPreview, showError } from '../../lib/output.js'; +import { resolveProjectId } from '../../lib/project-resolver.js'; import type { IssueListFilters, IssueListItem } from '../../lib/types.js'; +interface IssueListCommandOptions { + allAssignees?: boolean; + assignee?: string; + team?: string; + completed?: boolean; + canceled?: boolean; + allStates?: boolean; + archived?: boolean; + project?: string; + state?: string; + priority?: string; + label?: string | string[]; + parent?: string; + rootOnly?: boolean; + cycle?: string; + search?: string; + createdAfter?: string; + createdBefore?: string; + updatedAfter?: string; + updatedBefore?: string; + sort?: string; + order?: string; + limit?: string; + format?: string; + web?: boolean; + columns?: string; +} + // ======================================== // HELPER: Build filters with smart defaults // ======================================== -async function buildDefaultFilters(options: any): Promise<IssueListFilters> { +async function buildDefaultFilters(options: IssueListCommandOptions): Promise<IssueListFilters> { const config = getConfig(); const filters: IssueListFilters = {}; @@ -155,7 +184,7 @@ async function buildDefaultFilters(options: any): Promise<IssueListFilters> { `Invalid sort field: ${options.sort}. Valid options: ${validSortFields.join(', ')}` ); } - filters.sortField = options.sort as any; + filters.sortField = options.sort as IssueListFilters['sortField']; } else { // Default sort: priority descending filters.sortField = 'priority'; @@ -274,7 +303,7 @@ function formatPriority(priority?: number): string { // ======================================== // HELPER: Build Linear web URL with filters // ======================================== -async function buildLinearWebUrl(filters: IssueListFilters, options: any): Promise<string> { +async function buildLinearWebUrl(filters: IssueListFilters, options: IssueListCommandOptions): Promise<string> { // For now, construct a basic URL to the team's active issues view // Linear's URL structure for filtered views is complex and not fully documented // We'll open to the team view which will show filtered results @@ -414,7 +443,7 @@ async function listIssues(options: { description: issue.description || '', id: issue.id, estimate: issue.estimate, - dueDate: (issue as any).dueDate || '', + dueDate: issue.dueDate || '', })); const filtered = filterColumns(flattened, cols); diff --git a/src/commands/issue/register.ts b/src/commands/issue/register.ts index 9783734..246c1d3 100644 --- a/src/commands/issue/register.ts +++ b/src/commands/issue/register.ts @@ -1,9 +1,10 @@ import { Command } from 'commander'; -import { viewIssue } from './view.js'; + +import { commentIssueCommand } from './comment.js'; import { createIssueCommand } from './create.js'; -import { updateIssueCommand } from './update.js'; import { registerIssueListCommand } from './list.js'; -import { commentIssueCommand } from './comment.js'; +import { updateIssueCommand } from './update.js'; +import { viewIssue } from './view.js'; export function registerIssueCommands(cli: Command): void { const issue = cli diff --git a/src/commands/issue/update.ts b/src/commands/issue/update.ts index 899e494..1635723 100644 --- a/src/commands/issue/update.ts +++ b/src/commands/issue/update.ts @@ -1,15 +1,15 @@ +import { resolveAlias } from '../../lib/aliases.js'; +import { openInBrowser } from '../../lib/browser.js'; +import { readContentFile } from '../../lib/file-utils.js'; import { - updateIssue, + findProjectByName, getFullIssueById, + getLinearClient, resolveMemberIdentifier, + updateIssue, validateTeamExists, - getLinearClient, - findProjectByName, } from '../../lib/linear-client.js'; import type { IssueUpdateInput } from '../../lib/types.js'; -import { openInBrowser } from '../../lib/browser.js'; -import { resolveAlias } from '../../lib/aliases.js'; -import { readContentFile } from '../../lib/file-utils.js'; interface UpdateOptions { // Basic Fields diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 783ff16..7ac8606 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -1,7 +1,7 @@ -import { getFullIssueById, getIssueComments, getIssueHistory } from '../../lib/linear-client.js'; -import { resolveIssueIdentifier } from '../../lib/issue-resolver.js'; import { openInBrowser } from '../../lib/browser.js'; import { handleLinearError, isLinearError } from '../../lib/error-handler.js'; +import { resolveIssueIdentifier } from '../../lib/issue-resolver.js'; +import { getFullIssueById, getIssueComments, getIssueHistory } from '../../lib/linear-client.js'; import { formatContentPreview } from '../../lib/output.js'; interface ViewOptions { diff --git a/src/commands/members/list.tsx b/src/commands/members/list.tsx index b854cd4..4d4f677 100644 --- a/src/commands/members/list.tsx +++ b/src/commands/members/list.tsx @@ -1,11 +1,12 @@ +import { Box, render, Text } from 'ink'; import React, { useEffect, useState } from 'react'; -import { render, Box, Text } from 'ink'; -import { getAllMembers, type Member } from '../../lib/linear-client.js'; -import { openInBrowser } from '../../lib/browser.js'; -import { formatListTSV, formatListJSON, filterColumns } from '../../lib/output.js'; + import { getAliasesForId } from '../../lib/aliases.js'; -import { getConfig } from '../../lib/config.js'; import { resolveAlias } from '../../lib/aliases.js'; +import { openInBrowser } from '../../lib/browser.js'; +import { getConfig } from '../../lib/config.js'; +import { getAllMembers, type Member } from '../../lib/linear-client.js'; +import { filterColumns,formatListJSON, formatListTSV } from '../../lib/output.js'; interface ListOptions { interactive?: boolean; diff --git a/src/commands/members/register.ts b/src/commands/members/register.ts index 24882e4..5e6eec6 100644 --- a/src/commands/members/register.ts +++ b/src/commands/members/register.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; + import { listMembers } from './list.js'; import { syncMemberAliases } from './sync-aliases.js'; diff --git a/src/commands/members/sync-aliases.ts b/src/commands/members/sync-aliases.ts index 3cc5827..bdfb826 100644 --- a/src/commands/members/sync-aliases.ts +++ b/src/commands/members/sync-aliases.ts @@ -1,8 +1,9 @@ import { Command } from 'commander'; -import { getAllMembers } from '../../lib/linear-client.js'; -import { syncAliasesCore, type SyncAliasesOptions } from '../../lib/sync-aliases.js'; + import { resolveAlias } from '../../lib/aliases.js'; import { getConfig } from '../../lib/config.js'; +import { getAllMembers } from '../../lib/linear-client.js'; +import { syncAliasesCore, type SyncAliasesOptions } from '../../lib/sync-aliases.js'; /** * Extended options for member sync (includes team filtering) diff --git a/src/commands/milestone-templates/create-interactive.tsx b/src/commands/milestone-templates/create-interactive.tsx index f087798..d517ebd 100644 --- a/src/commands/milestone-templates/create-interactive.tsx +++ b/src/commands/milestone-templates/create-interactive.tsx @@ -1,9 +1,10 @@ -import React, { useState } from 'react'; -import { render, Box, Text } from 'ink'; +import { Box, render, Text } from 'ink'; import TextInput from 'ink-text-input'; +import React, { useState } from 'react'; + import { createMilestoneTemplate } from '../../lib/milestone-templates.js'; import { getScopeInfo } from '../../lib/scope.js'; -import type { MilestoneTemplate, MilestoneDefinition } from '../../lib/types.js'; +import type { MilestoneDefinition,MilestoneTemplate } from '../../lib/types.js'; interface CreateInteractiveOptions { global?: boolean; diff --git a/src/commands/milestone-templates/create.ts b/src/commands/milestone-templates/create.ts index d677b49..beb8969 100644 --- a/src/commands/milestone-templates/create.ts +++ b/src/commands/milestone-templates/create.ts @@ -1,7 +1,7 @@ import { createMilestoneTemplate, parseDateOffset } from '../../lib/milestone-templates.js'; -import { showSuccess, showError } from '../../lib/output.js'; +import { showError,showSuccess } from '../../lib/output.js'; import { getScopeInfo } from '../../lib/scope.js'; -import type { MilestoneTemplate, MilestoneDefinition } from '../../lib/types.js'; +import type { MilestoneDefinition,MilestoneTemplate } from '../../lib/types.js'; interface CreateTemplateOptions { global?: boolean; diff --git a/src/commands/milestone-templates/edit-interactive.tsx b/src/commands/milestone-templates/edit-interactive.tsx index 46d05f6..dc843b9 100644 --- a/src/commands/milestone-templates/edit-interactive.tsx +++ b/src/commands/milestone-templates/edit-interactive.tsx @@ -1,9 +1,10 @@ -import React, { useState } from 'react'; -import { render, Box, Text } from 'ink'; +import { Box, render, Text } from 'ink'; import TextInput from 'ink-text-input'; +import React, { useState } from 'react'; + import { getMilestoneTemplate, updateMilestoneTemplate } from '../../lib/milestone-templates.js'; import { getScopeInfo } from '../../lib/scope.js'; -import type { MilestoneTemplate, MilestoneDefinition } from '../../lib/types.js'; +import type { MilestoneDefinition,MilestoneTemplate } from '../../lib/types.js'; interface EditInteractiveOptions { global?: boolean; diff --git a/src/commands/milestone-templates/list.ts b/src/commands/milestone-templates/list.ts index 0d25410..7a0c6fb 100644 --- a/src/commands/milestone-templates/list.ts +++ b/src/commands/milestone-templates/list.ts @@ -1,4 +1,4 @@ -import { loadMilestoneTemplates, hasGlobalTemplates, hasProjectTemplates } from '../../lib/milestone-templates.js'; +import { hasGlobalTemplates, hasProjectTemplates,loadMilestoneTemplates } from '../../lib/milestone-templates.js'; import { formatListJSON } from '../../lib/output.js'; interface ListOptions { diff --git a/src/commands/milestone-templates/register.ts b/src/commands/milestone-templates/register.ts index 080be41..b0dbd19 100644 --- a/src/commands/milestone-templates/register.ts +++ b/src/commands/milestone-templates/register.ts @@ -1,10 +1,11 @@ import { Command } from 'commander'; -import { listMilestoneTemplates } from './list.js'; -import { viewMilestoneTemplate } from './view.js'; + import { createTemplate } from './create.js'; import { createTemplateInteractive } from './create-interactive.js'; -import { removeTemplate } from './remove.js'; import { editTemplateInteractive } from './edit-interactive.js'; +import { listMilestoneTemplates } from './list.js'; +import { removeTemplate } from './remove.js'; +import { viewMilestoneTemplate } from './view.js'; export function registerMilestoneTemplatesCommands(cli: Command): void { const milestoneTemplates = cli diff --git a/src/commands/milestone-templates/remove.ts b/src/commands/milestone-templates/remove.ts index 5b8fb98..a2600c5 100644 --- a/src/commands/milestone-templates/remove.ts +++ b/src/commands/milestone-templates/remove.ts @@ -1,8 +1,9 @@ -import { removeMilestoneTemplate, getMilestoneTemplate } from '../../lib/milestone-templates.js'; -import { showSuccess, showError } from '../../lib/output.js'; -import { getScopeInfo } from '../../lib/scope.js'; import readline from 'readline'; +import { getMilestoneTemplate,removeMilestoneTemplate } from '../../lib/milestone-templates.js'; +import { showError,showSuccess } from '../../lib/output.js'; +import { getScopeInfo } from '../../lib/scope.js'; + interface RemoveTemplateOptions { global?: boolean; project?: boolean; diff --git a/src/commands/project-labels/create.ts b/src/commands/project-labels/create.ts index fd4cfa5..185417c 100644 --- a/src/commands/project-labels/create.ts +++ b/src/commands/project-labels/create.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; + import { createProjectLabel } from '../../lib/linear-client.js'; export function createProjectLabelCommand(program: Command) { diff --git a/src/commands/project-labels/delete.ts b/src/commands/project-labels/delete.ts index 689fa93..f75bb73 100644 --- a/src/commands/project-labels/delete.ts +++ b/src/commands/project-labels/delete.ts @@ -1,8 +1,9 @@ import { Command } from 'commander'; -import { getProjectLabelById, deleteProjectLabel } from '../../lib/linear-client.js'; -import { resolveAlias } from '../../lib/aliases.js'; import * as readline from 'readline'; +import { resolveAlias } from '../../lib/aliases.js'; +import { deleteProjectLabel,getProjectLabelById } from '../../lib/linear-client.js'; + async function confirm(message: string): Promise<boolean> { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { diff --git a/src/commands/project-labels/list.tsx b/src/commands/project-labels/list.tsx index 5419d1a..09b8e11 100644 --- a/src/commands/project-labels/list.tsx +++ b/src/commands/project-labels/list.tsx @@ -1,8 +1,9 @@ -import React from 'react'; -import { render, Text, Box } from 'ink'; import { Command } from 'commander'; -import { getAllProjectLabels } from '../../lib/linear-client.js'; +import { Box,render, Text } from 'ink'; +import React from 'react'; + import { formatColorPreview } from '../../lib/colors.js'; +import { getAllProjectLabels } from '../../lib/linear-client.js'; interface ProjectLabelsListProps { colorFilter?: string; diff --git a/src/commands/project-labels/register.ts b/src/commands/project-labels/register.ts index 9b01fe7..df231fe 100644 --- a/src/commands/project-labels/register.ts +++ b/src/commands/project-labels/register.ts @@ -1,10 +1,11 @@ import { Command } from 'commander'; -import { listProjectLabels } from './list.js'; -import { viewProjectLabel } from './view.js'; + import { createProjectLabelCommand } from './create.js'; -import { updateProjectLabelCommand } from './update.js'; import { deleteProjectLabelCommand } from './delete.js'; +import { listProjectLabels } from './list.js'; import { syncProjectLabelAliases } from './sync-aliases.js'; +import { updateProjectLabelCommand } from './update.js'; +import { viewProjectLabel } from './view.js'; export function registerProjectLabelsCommands(cli: Command): void { const projectLabels = cli diff --git a/src/commands/project-labels/sync-aliases.ts b/src/commands/project-labels/sync-aliases.ts index 81a1f9a..18699d9 100644 --- a/src/commands/project-labels/sync-aliases.ts +++ b/src/commands/project-labels/sync-aliases.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; + import { getAllProjectLabels } from '../../lib/linear-client.js'; import { syncAliasesCore, type SyncAliasesOptions } from '../../lib/sync-aliases.js'; diff --git a/src/commands/project-labels/update.ts b/src/commands/project-labels/update.ts index c935f89..b371b78 100644 --- a/src/commands/project-labels/update.ts +++ b/src/commands/project-labels/update.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; -import { getProjectLabelById, updateProjectLabel } from '../../lib/linear-client.js'; + import { resolveAlias } from '../../lib/aliases.js'; +import { getProjectLabelById, updateProjectLabel } from '../../lib/linear-client.js'; export function updateProjectLabelCommand(program: Command) { program diff --git a/src/commands/project-labels/view.ts b/src/commands/project-labels/view.ts index c82a877..c8cf386 100644 --- a/src/commands/project-labels/view.ts +++ b/src/commands/project-labels/view.ts @@ -1,7 +1,8 @@ import { Command } from 'commander'; -import { getProjectLabelById } from '../../lib/linear-client.js'; + import { resolveAlias } from '../../lib/aliases.js'; import { formatColorPreview } from '../../lib/colors.js'; +import { getProjectLabelById } from '../../lib/linear-client.js'; export function viewProjectLabel(program: Command) { program diff --git a/src/commands/project-status/list.tsx b/src/commands/project-status/list.tsx index 8013173..63f4f1d 100644 --- a/src/commands/project-status/list.tsx +++ b/src/commands/project-status/list.tsx @@ -1,9 +1,10 @@ +import { Box, render, Text } from 'ink'; import React, { useEffect, useState } from 'react'; -import { render, Box, Text } from 'ink'; -import { getAllProjectStatuses, type ProjectStatus } from '../../lib/linear-client.js'; -import { openInBrowser } from '../../lib/browser.js'; -import { formatListTSV, formatListJSON } from '../../lib/output.js'; + import { getAliasesForId } from '../../lib/aliases.js'; +import { openInBrowser } from '../../lib/browser.js'; +import { getAllProjectStatuses, type ProjectStatus } from '../../lib/linear-client.js'; +import { formatListJSON,formatListTSV } from '../../lib/output.js'; interface ListOptions { interactive?: boolean; diff --git a/src/commands/project-status/register.ts b/src/commands/project-status/register.ts index 1c5451b..0257489 100644 --- a/src/commands/project-status/register.ts +++ b/src/commands/project-status/register.ts @@ -1,7 +1,8 @@ import { Command } from 'commander'; + import { listProjectStatuses } from './list.js'; -import { viewProjectStatus } from './view.js'; import { syncProjectStatusAliases } from './sync-aliases.js'; +import { viewProjectStatus } from './view.js'; export function registerProjectStatusCommands(cli: Command): void { const projectStatus = cli diff --git a/src/commands/project-status/view.ts b/src/commands/project-status/view.ts index 0a7b463..c349196 100644 --- a/src/commands/project-status/view.ts +++ b/src/commands/project-status/view.ts @@ -1,8 +1,8 @@ -import { getProjectStatusById } from '../../lib/linear-client.js'; import { resolveAlias } from '../../lib/aliases.js'; -import { resolveProjectStatusId } from '../../lib/status-cache.js'; -import { showResolvedAlias, showEntityNotFound } from '../../lib/output.js'; import { openInBrowser } from '../../lib/browser.js'; +import { getProjectStatusById } from '../../lib/linear-client.js'; +import { showEntityNotFound,showResolvedAlias } from '../../lib/output.js'; +import { resolveProjectStatusId } from '../../lib/status-cache.js'; export async function viewProjectStatus(nameOrId: string, options: { web?: boolean } = {}) { try { diff --git a/src/commands/project/add-milestones.ts b/src/commands/project/add-milestones.ts index c9704e4..2806878 100644 --- a/src/commands/project/add-milestones.ts +++ b/src/commands/project/add-milestones.ts @@ -1,8 +1,8 @@ +import { getConfig } from '../../lib/config.js'; +import { createProjectMilestone,validateProjectExists } from '../../lib/linear-client.js'; import { getMilestoneTemplate, resolveMilestoneDates } from '../../lib/milestone-templates.js'; -import { validateProjectExists, createProjectMilestone } from '../../lib/linear-client.js'; +import { showEntityNotFound,showError, showResolvedAlias, showSuccess, showValidated, showValidating } from '../../lib/output.js'; import { resolveProject } from '../../lib/project-resolver.js'; -import { getConfig } from '../../lib/config.js'; -import { showResolvedAlias, showValidating, showValidated, showSuccess, showError, showEntityNotFound } from '../../lib/output.js'; interface AddMilestonesOptions { template?: string; diff --git a/src/commands/project/create.tsx b/src/commands/project/create.tsx index bdd30c4..d57c9f9 100644 --- a/src/commands/project/create.tsx +++ b/src/commands/project/create.tsx @@ -1,23 +1,24 @@ -import React, { useState, useEffect } from 'react'; -import { render, Box, Text } from 'ink'; +import { Box, render, Text } from 'ink'; +import React, { useEffect,useState } from 'react'; + +import { resolveAlias } from '../../lib/aliases.js'; +import { openInBrowser } from '../../lib/browser.js'; +import { getConfig } from '../../lib/config.js'; +import { parseDateForCommand, validateResolutionOverride } from '../../lib/date-parser.js'; import { readContentFile } from '../../lib/file-utils.js'; -import { ProjectForm } from '../../ui/components/ProjectForm.js'; import { + createExternalLink, createProject, + getCurrentUser, getProjectByName, - validateInitiativeExists, - validateTeamExists, getTemplateById, - getCurrentUser, - resolveMemberIdentifier, - createExternalLink, type ProjectCreateInput, type ProjectResult, + resolveMemberIdentifier, + validateInitiativeExists, + validateTeamExists, } from '../../lib/linear-client.js'; -import { getConfig } from '../../lib/config.js'; -import { openInBrowser } from '../../lib/browser.js'; -import { resolveAlias } from '../../lib/aliases.js'; -import { parseDateForCommand, validateResolutionOverride } from '../../lib/date-parser.js'; +import { ProjectForm } from '../../ui/components/ProjectForm.js'; interface CreateOptions { title?: string; diff --git a/src/commands/project/dependencies/add.ts b/src/commands/project/dependencies/add.ts index c093c26..2a97784 100644 --- a/src/commands/project/dependencies/add.ts +++ b/src/commands/project/dependencies/add.ts @@ -4,10 +4,10 @@ * Add dependency relations to a project */ -import { resolveProject } from '../../../lib/project-resolver.js'; -import { getLinearClient, createProjectRelation } from '../../../lib/linear-client.js'; -import { resolveDependencyProjects, parseAdvancedDependency } from '../../../lib/parsers.js'; +import { createProjectRelation,getLinearClient } from '../../../lib/linear-client.js'; import { showError, showSuccess } from '../../../lib/output.js'; +import { parseAdvancedDependency,resolveDependencyProjects } from '../../../lib/parsers.js'; +import { resolveProject } from '../../../lib/project-resolver.js'; interface AddDependenciesOptions { dependsOn?: string; diff --git a/src/commands/project/dependencies/clear.ts b/src/commands/project/dependencies/clear.ts index 9166317..c550874 100644 --- a/src/commands/project/dependencies/clear.ts +++ b/src/commands/project/dependencies/clear.ts @@ -4,12 +4,13 @@ * Remove all dependency relations from a project with confirmation */ -import { resolveProject } from '../../../lib/project-resolver.js'; -import { getLinearClient, getProjectRelations, deleteProjectRelation } from '../../../lib/linear-client.js'; -import { getRelationDirection } from '../../../lib/parsers.js'; -import { showError, showSuccess } from '../../../lib/output.js'; import * as readline from 'readline'; +import { deleteProjectRelation,getLinearClient, getProjectRelations } from '../../../lib/linear-client.js'; +import { showError, showSuccess } from '../../../lib/output.js'; +import { getRelationDirection } from '../../../lib/parsers.js'; +import { resolveProject } from '../../../lib/project-resolver.js'; + interface ClearDependenciesOptions { direction?: 'depends-on' | 'blocks'; yes?: boolean; diff --git a/src/commands/project/dependencies/list.ts b/src/commands/project/dependencies/list.ts index c848699..e79d74a 100644 --- a/src/commands/project/dependencies/list.ts +++ b/src/commands/project/dependencies/list.ts @@ -4,10 +4,10 @@ * List all dependency relations for a project */ -import { resolveProject } from '../../../lib/project-resolver.js'; import { getLinearClient, getProjectRelations } from '../../../lib/linear-client.js'; -import { getRelationDirection } from '../../../lib/parsers.js'; import { showError } from '../../../lib/output.js'; +import { getRelationDirection } from '../../../lib/parsers.js'; +import { resolveProject } from '../../../lib/project-resolver.js'; interface ListDependenciesOptions { direction?: 'depends-on' | 'blocks'; diff --git a/src/commands/project/dependencies/remove.ts b/src/commands/project/dependencies/remove.ts index 46ea12c..f84cf08 100644 --- a/src/commands/project/dependencies/remove.ts +++ b/src/commands/project/dependencies/remove.ts @@ -4,11 +4,11 @@ * Remove dependency relations from a project */ -import { resolveProject } from '../../../lib/project-resolver.js'; -import { getLinearClient, getProjectRelations, deleteProjectRelation } from '../../../lib/linear-client.js'; -import { resolveDependencyProjects, getRelationDirection } from '../../../lib/parsers.js'; import { resolveAlias } from '../../../lib/aliases.js'; +import { deleteProjectRelation,getLinearClient, getProjectRelations } from '../../../lib/linear-client.js'; import { showError, showSuccess } from '../../../lib/output.js'; +import { getRelationDirection,resolveDependencyProjects } from '../../../lib/parsers.js'; +import { resolveProject } from '../../../lib/project-resolver.js'; interface RemoveDependenciesOptions { dependsOn?: string; diff --git a/src/commands/project/list.tsx b/src/commands/project/list.tsx index 154894d..a42a8cc 100644 --- a/src/commands/project/list.tsx +++ b/src/commands/project/list.tsx @@ -1,17 +1,44 @@ -import React from 'react'; -import { render, Box, Text } from 'ink'; import type { Command } from 'commander'; -import { getAllProjects } from '../../lib/linear-client.js'; -import { getEntityCache } from '../../lib/entity-cache.js'; -import { showError, formatContentPreview, filterColumns } from '../../lib/output.js'; -import { getConfig } from '../../lib/config.js'; +import { Box, render, Text } from 'ink'; +import React from 'react'; + import { resolveAlias } from '../../lib/aliases.js'; +import { getConfig } from '../../lib/config.js'; +import { getEntityCache } from '../../lib/entity-cache.js'; +import { getAllProjects } from '../../lib/linear-client.js'; +import { filterColumns,formatContentPreview, showError } from '../../lib/output.js'; import type { ProjectListFilters, ProjectListItem } from '../../lib/types.js'; +interface ProjectListCommandOptions { + allLeads?: boolean; + lead?: string; + allTeams?: boolean; + team?: string; + allInitiatives?: boolean; + initiative?: string; + status?: string; + priority?: string; + member?: string; + label?: string; + startAfter?: string; + startBefore?: string; + targetAfter?: string; + targetBefore?: string; + search?: string; + limit?: string; + format?: string; + web?: boolean; + columns?: string; + hasDependencies?: boolean; + withoutDependencies?: boolean; + dependsOnOthers?: boolean; + blocksOthers?: boolean; +} + // ======================================== // HELPER: Build filters from options with smart defaults // ======================================== -async function buildDefaultFilters(options: any): Promise<ProjectListFilters> { +async function buildDefaultFilters(options: ProjectListCommandOptions): Promise<ProjectListFilters> { const config = getConfig(); const filters: ProjectListFilters = {}; @@ -260,8 +287,8 @@ function ProjectList({ filters }: ProjectListProps): React.ReactElement { const projectList = await getAllProjects(filters); setProjects(projectList); setLoading(false); - } catch (err: any) { - setError(err.message); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); setLoading(false); } } @@ -430,7 +457,7 @@ export function listProjectsCommand(program: Command): void { lead: p.lead?.name || '', description: p.description || '', priority: p.priority, - url: (p as any).url || '', + url: p.url || '', dependsOnCount: p.dependsOnCount || 0, blocksCount: p.blocksCount || 0, })); @@ -491,8 +518,8 @@ export function listProjectsCommand(program: Command): void { // Interactive mode with Ink UI render(<ProjectList filters={filters} format={options.format} />); - } catch (error: any) { - showError(error.message); + } catch (error: unknown) { + showError(error instanceof Error ? error.message : String(error)); process.exit(1); } }); diff --git a/src/commands/project/register.ts b/src/commands/project/register.ts index f645e92..77e8a11 100644 --- a/src/commands/project/register.ts +++ b/src/commands/project/register.ts @@ -1,9 +1,10 @@ import { Command, Option } from 'commander'; + +import { addMilestones } from './add-milestones.js'; import { createProjectCommand } from './create.js'; -import { viewProject } from './view.js'; -import { updateProjectCommand } from './update.js'; import { listProjectsCommand } from './list.js'; -import { addMilestones } from './add-milestones.js'; +import { updateProjectCommand } from './update.js'; +import { viewProject } from './view.js'; export function registerProjectCommands(cli: Command): void { const project = cli diff --git a/src/commands/project/update.ts b/src/commands/project/update.ts index ecc9f6e..6a0eb54 100644 --- a/src/commands/project/update.ts +++ b/src/commands/project/update.ts @@ -1,9 +1,10 @@ import { readFileSync } from 'fs'; -import { resolveProject } from '../../lib/project-resolver.js'; -import { updateProject } from '../../lib/linear-client.js'; -import { showEntityNotFound, showError, showSuccess } from '../../lib/output.js'; + import { resolveAlias } from '../../lib/aliases.js'; import { parseDateForCommand, validateResolutionOverride } from '../../lib/date-parser.js'; +import { updateProject } from '../../lib/linear-client.js'; +import { showEntityNotFound, showError, showSuccess } from '../../lib/output.js'; +import { resolveProject } from '../../lib/project-resolver.js'; interface UpdateOptions { status?: string; diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 001cec0..0801491 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -1,7 +1,7 @@ +import { openInBrowser } from '../../lib/browser.js'; import { getFullProjectDetails } from '../../lib/linear-client.js'; +import { formatContentPreview,showEntityNotFound, showResolvedAlias } from '../../lib/output.js'; import { resolveProject } from '../../lib/project-resolver.js'; -import { showResolvedAlias, showEntityNotFound, formatContentPreview } from '../../lib/output.js'; -import { openInBrowser } from '../../lib/browser.js'; export async function viewProject(nameOrId: string, options: { web?: boolean; autoAlias?: boolean; desc?: boolean; descLength?: string; descFull?: boolean; noDesc?: boolean } = {}) { // Use smart resolver to handle ID, alias, or name diff --git a/src/commands/setup.tsx b/src/commands/setup.tsx index 9b27f89..881910d 100644 --- a/src/commands/setup.tsx +++ b/src/commands/setup.tsx @@ -1,27 +1,28 @@ -import React, { useState, useEffect } from 'react'; -import { render, Box, Text, useInput } from 'ink'; +import { Box, render, Text, useInput } from 'ink'; import SelectInput from 'ink-select-input'; import TextInput from 'ink-text-input'; +import React, { useEffect,useState } from 'react'; + import { getConfig, - setConfigValue, - hasGlobalConfig, - hasProjectConfig, getGlobalConfigPath, getProjectConfigPath, + hasGlobalConfig, + hasProjectConfig, maskApiKey, + setConfigValue, } from '../lib/config.js'; import { - validateApiKey, - getAllTeams, getAllInitiatives, - type Team, + getAllTeams, type Initiative, + type Team, + validateApiKey, } from '../lib/linear-client.js'; import { WalkthroughScreen } from '../ui/components/WalkthroughScreen.js'; -import { syncWorkflowStateAliasesCore } from './workflow-states/sync-aliases.js'; -import { syncProjectStatusAliases } from './project-status/sync-aliases.js'; import { syncMemberAliasesCore } from './members/sync-aliases.js'; +import { syncProjectStatusAliases } from './project-status/sync-aliases.js'; +import { syncWorkflowStateAliasesCore } from './workflow-states/sync-aliases.js'; type SetupStep = | 'welcome' diff --git a/src/commands/teams/list.tsx b/src/commands/teams/list.tsx index 78f7dc2..c416baa 100644 --- a/src/commands/teams/list.tsx +++ b/src/commands/teams/list.tsx @@ -1,9 +1,10 @@ +import { Box, render, Text } from 'ink'; import React, { useEffect, useState } from 'react'; -import { render, Box, Text } from 'ink'; -import { getAllTeams, type Team } from '../../lib/linear-client.js'; -import { openInBrowser } from '../../lib/browser.js'; -import { formatListTSV, formatListJSON } from '../../lib/output.js'; + import { getAliasesForId } from '../../lib/aliases.js'; +import { openInBrowser } from '../../lib/browser.js'; +import { getAllTeams, type Team } from '../../lib/linear-client.js'; +import { formatListJSON,formatListTSV } from '../../lib/output.js'; interface ListOptions { interactive?: boolean; diff --git a/src/commands/teams/register.ts b/src/commands/teams/register.ts index 47cb6b9..68f0ffe 100644 --- a/src/commands/teams/register.ts +++ b/src/commands/teams/register.ts @@ -1,9 +1,10 @@ import { Command } from 'commander'; + import { listTeams } from './list.js'; import { selectTeam } from './select.js'; import { setTeam } from './set.js'; -import { viewTeam } from './view.js'; import { syncTeamAliases } from './sync-aliases.js'; +import { viewTeam } from './view.js'; export function registerTeamsCommands(cli: Command): void { const teams = cli diff --git a/src/commands/teams/select.tsx b/src/commands/teams/select.tsx index d37ddb5..1288af7 100644 --- a/src/commands/teams/select.tsx +++ b/src/commands/teams/select.tsx @@ -1,9 +1,10 @@ +import { Box, render, Text } from 'ink'; import React, { useEffect, useState } from 'react'; -import { render, Box, Text } from 'ink'; -import { TeamList } from '../../ui/components/TeamList.js'; -import { getAllTeams, type Team } from '../../lib/linear-client.js'; + import { setConfigValue } from '../../lib/config.js'; +import { getAllTeams, type Team } from '../../lib/linear-client.js'; import { getScopeInfo } from '../../lib/scope.js'; +import { TeamList } from '../../ui/components/TeamList.js'; interface SelectOptions { global?: boolean; diff --git a/src/commands/teams/set.ts b/src/commands/teams/set.ts index 5833d00..cd2e1fd 100644 --- a/src/commands/teams/set.ts +++ b/src/commands/teams/set.ts @@ -1,7 +1,7 @@ -import { validateTeamExists } from '../../lib/linear-client.js'; -import { setConfigValue } from '../../lib/config.js'; import { resolveAlias } from '../../lib/aliases.js'; -import { showResolvedAlias, showValidating, showValidated, showSuccess, showError, showInfo } from '../../lib/output.js'; +import { setConfigValue } from '../../lib/config.js'; +import { validateTeamExists } from '../../lib/linear-client.js'; +import { showError, showInfo,showResolvedAlias, showSuccess, showValidated, showValidating } from '../../lib/output.js'; import { getScopeInfo } from '../../lib/scope.js'; interface SetTeamOptions { diff --git a/src/commands/teams/sync-aliases.ts b/src/commands/teams/sync-aliases.ts index dae053a..a484be0 100644 --- a/src/commands/teams/sync-aliases.ts +++ b/src/commands/teams/sync-aliases.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; + import { getAllTeams } from '../../lib/linear-client.js'; import { syncAliasesCore, type SyncAliasesOptions } from '../../lib/sync-aliases.js'; diff --git a/src/commands/teams/view.ts b/src/commands/teams/view.ts index 3d6a4e9..47e4b0c 100644 --- a/src/commands/teams/view.ts +++ b/src/commands/teams/view.ts @@ -1,7 +1,7 @@ -import { getTeamById } from '../../lib/linear-client.js'; import { resolveAlias } from '../../lib/aliases.js'; -import { showResolvedAlias, showEntityNotFound } from '../../lib/output.js'; import { openInBrowser } from '../../lib/browser.js'; +import { getTeamById } from '../../lib/linear-client.js'; +import { showEntityNotFound,showResolvedAlias } from '../../lib/output.js'; export async function viewTeam(id: string, options: { web?: boolean } = {}) { try { diff --git a/src/commands/templates/list.tsx b/src/commands/templates/list.tsx index d0872b7..be45141 100644 --- a/src/commands/templates/list.tsx +++ b/src/commands/templates/list.tsx @@ -1,9 +1,10 @@ +import { Box, render, Text } from 'ink'; import React, { useEffect, useState } from 'react'; -import { render, Box, Text } from 'ink'; -import { getAllTemplates, type Template } from '../../lib/linear-client.js'; -import { openInBrowser } from '../../lib/browser.js'; -import { formatListTSV, formatListJSON } from '../../lib/output.js'; + import { getAliasesForId } from '../../lib/aliases.js'; +import { openInBrowser } from '../../lib/browser.js'; +import { getAllTemplates, type Template } from '../../lib/linear-client.js'; +import { formatListJSON,formatListTSV } from '../../lib/output.js'; interface ListOptions { interactive?: boolean; diff --git a/src/commands/templates/register.ts b/src/commands/templates/register.ts index 0c4da24..d851466 100644 --- a/src/commands/templates/register.ts +++ b/src/commands/templates/register.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; + import { listTemplates } from './list.js'; import { viewTemplate } from './view.js'; diff --git a/src/commands/templates/view.ts b/src/commands/templates/view.ts index 8054fab..02018a8 100644 --- a/src/commands/templates/view.ts +++ b/src/commands/templates/view.ts @@ -1,6 +1,6 @@ -import { getTemplateById } from '../../lib/linear-client.js'; import { resolveAlias } from '../../lib/aliases.js'; import { openInBrowser } from '../../lib/browser.js'; +import { getTemplateById } from '../../lib/linear-client.js'; export async function viewTemplate(templateId: string, options: { web?: boolean } = {}) { // Resolve alias to ID if needed diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 1f049e0..6c721e4 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -1,5 +1,5 @@ -import { testConnection, getCurrentUser, getOrganization } from '../lib/linear-client.js'; import { getApiKey, maskApiKey } from '../lib/config.js'; +import { getCurrentUser, getOrganization,testConnection } from '../lib/linear-client.js'; import { showError } from '../lib/output.js'; /** diff --git a/src/commands/workflow-states/create.ts b/src/commands/workflow-states/create.ts index 08063c1..ed43c78 100644 --- a/src/commands/workflow-states/create.ts +++ b/src/commands/workflow-states/create.ts @@ -1,7 +1,8 @@ import { Command } from 'commander'; -import { createWorkflowState } from '../../lib/linear-client.js'; + import { resolveAlias } from '../../lib/aliases.js'; import { getConfig } from '../../lib/config.js'; +import { createWorkflowState } from '../../lib/linear-client.js'; export function createWorkflowStateCommand(program: Command) { program diff --git a/src/commands/workflow-states/delete.ts b/src/commands/workflow-states/delete.ts index c3fe9ff..f4ae92f 100644 --- a/src/commands/workflow-states/delete.ts +++ b/src/commands/workflow-states/delete.ts @@ -1,8 +1,9 @@ import { Command } from 'commander'; -import { getWorkflowStateById, deleteWorkflowState } from '../../lib/linear-client.js'; -import { resolveAlias } from '../../lib/aliases.js'; import * as readline from 'readline'; +import { resolveAlias } from '../../lib/aliases.js'; +import { deleteWorkflowState,getWorkflowStateById } from '../../lib/linear-client.js'; + async function confirm(message: string): Promise<boolean> { const rl = readline.createInterface({ input: process.stdin, diff --git a/src/commands/workflow-states/list.tsx b/src/commands/workflow-states/list.tsx index bd9a748..73bfd5d 100644 --- a/src/commands/workflow-states/list.tsx +++ b/src/commands/workflow-states/list.tsx @@ -1,10 +1,11 @@ -import React from 'react'; -import { render, Text, Box } from 'ink'; import { Command } from 'commander'; -import { getAllWorkflowStates } from '../../lib/linear-client.js'; -import { getConfig } from '../../lib/config.js'; -import { formatColorPreview } from '../../lib/colors.js'; +import { Box,render, Text } from 'ink'; +import React from 'react'; + import { resolveAlias } from '../../lib/aliases.js'; +import { formatColorPreview } from '../../lib/colors.js'; +import { getConfig } from '../../lib/config.js'; +import { getAllWorkflowStates } from '../../lib/linear-client.js'; interface WorkflowStatesListProps { teamId?: string; diff --git a/src/commands/workflow-states/register.ts b/src/commands/workflow-states/register.ts index 6b652c7..f6cdc74 100644 --- a/src/commands/workflow-states/register.ts +++ b/src/commands/workflow-states/register.ts @@ -1,10 +1,11 @@ import { Command } from 'commander'; -import { listWorkflowStates } from './list.js'; -import { viewWorkflowState } from './view.js'; + import { createWorkflowStateCommand } from './create.js'; -import { updateWorkflowStateCommand } from './update.js'; import { deleteWorkflowStateCommand } from './delete.js'; +import { listWorkflowStates } from './list.js'; import { syncWorkflowStateAliases } from './sync-aliases.js'; +import { updateWorkflowStateCommand } from './update.js'; +import { viewWorkflowState } from './view.js'; export function registerWorkflowStatesCommands(cli: Command): void { const workflowStates = cli diff --git a/src/commands/workflow-states/sync-aliases.ts b/src/commands/workflow-states/sync-aliases.ts index edf61fa..7a2a9a7 100644 --- a/src/commands/workflow-states/sync-aliases.ts +++ b/src/commands/workflow-states/sync-aliases.ts @@ -1,8 +1,9 @@ import { Command } from 'commander'; + +import { resolveAlias } from '../../lib/aliases.js'; +import { getConfig } from '../../lib/config.js'; import { getAllWorkflowStates } from '../../lib/linear-client.js'; import { syncAliasesCore, type SyncAliasesOptions } from '../../lib/sync-aliases.js'; -import { getConfig } from '../../lib/config.js'; -import { resolveAlias } from '../../lib/aliases.js'; /** * Extended options for workflow-state sync (includes team filtering) diff --git a/src/commands/workflow-states/update.ts b/src/commands/workflow-states/update.ts index 3d351c6..01b6c16 100644 --- a/src/commands/workflow-states/update.ts +++ b/src/commands/workflow-states/update.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; -import { getWorkflowStateById, updateWorkflowState } from '../../lib/linear-client.js'; + import { resolveAlias } from '../../lib/aliases.js'; +import { getWorkflowStateById, updateWorkflowState } from '../../lib/linear-client.js'; export function updateWorkflowStateCommand(program: Command) { program diff --git a/src/commands/workflow-states/view.ts b/src/commands/workflow-states/view.ts index 789a08f..da4da6d 100644 --- a/src/commands/workflow-states/view.ts +++ b/src/commands/workflow-states/view.ts @@ -1,7 +1,8 @@ import { Command } from 'commander'; -import { getWorkflowStateById } from '../../lib/linear-client.js'; + import { resolveAlias } from '../../lib/aliases.js'; import { formatColorPreview } from '../../lib/colors.js'; +import { getWorkflowStateById } from '../../lib/linear-client.js'; export function viewWorkflowState(program: Command) { program diff --git a/src/lib/aliases.ts b/src/lib/aliases.ts index d542901..6b4fdd8 100644 --- a/src/lib/aliases.ts +++ b/src/lib/aliases.ts @@ -1,19 +1,20 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { homedir } from 'os'; import { dirname, join } from 'path'; -import type { Aliases, AliasEntityType, ResolvedAliases, AliasLocation } from './types.js'; + import { - validateInitiativeExists, - validateTeamExists, + getCycleById, + getIssueLabelById, + getMemberById, getProjectById, + getProjectLabelById, getProjectStatusById, getTemplateById, - getMemberById, - getIssueLabelById, - getProjectLabelById, getWorkflowStateById, - getCycleById, + validateInitiativeExists, + validateTeamExists, } from './linear-client.js'; +import type { AliasEntityType, Aliases, AliasLocation,ResolvedAliases } from './types.js'; const GLOBAL_ALIASES_DIR = join(homedir(), '.config', 'agent2linear'); const GLOBAL_ALIASES_FILE = join(GLOBAL_ALIASES_DIR, 'aliases.json'); @@ -1085,4 +1086,4 @@ export function getAliasSuggestionError( /** * Export for testing and advanced usage */ -export { normalizeEntityType, getAliasesKey }; +export { getAliasesKey,normalizeEntityType }; diff --git a/src/lib/api-call-tracker.ts b/src/lib/api-call-tracker.ts index ad43250..d05347d 100644 --- a/src/lib/api-call-tracker.ts +++ b/src/lib/api-call-tracker.ts @@ -19,7 +19,7 @@ export interface ApiCallRecord { operationType: 'query' | 'mutation' | 'unknown'; source: 'main' | 'validation' | 'cache' | 'unknown'; durationMs?: number; - variables?: Record<string, any>; + variables?: Record<string, unknown>; error?: string; } @@ -82,7 +82,7 @@ export function logCall( operationType: 'query' | 'mutation' | 'unknown' = 'unknown', source: 'main' | 'validation' | 'cache' | 'unknown' = 'unknown', durationMs?: number, - variables?: Record<string, any>, + variables?: Record<string, unknown>, error?: string ): void { if (!trackingEnabled) return; diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 2dd9b2e..f499940 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,4 +1,5 @@ import { LinearClient as SDKClient } from '@linear/sdk'; + import { getApiKey } from '../config.js'; export class LinearClientError extends Error { diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 234c613..ffe983e 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -2,12 +2,12 @@ // This file aggregates all domain-specific API modules for convenient importing export * from './client.js'; -export * from './projects.js'; -export * from './issues.js'; -export * from './teams.js'; +export * from './cycles.js'; export * from './initiatives.js'; -export * from './members.js'; +export * from './issues.js'; export * from './labels.js'; -export * from './workflow-states.js'; +export * from './members.js'; +export * from './projects.js'; +export * from './teams.js'; export * from './templates.js'; -export * from './cycles.js'; +export * from './workflow-states.js'; diff --git a/src/lib/api/issues.ts b/src/lib/api/issues.ts index f0165c2..53d2479 100644 --- a/src/lib/api/issues.ts +++ b/src/lib/api/issues.ts @@ -1,11 +1,11 @@ -import { getLinearClient, LinearClientError } from './client.js'; import type { IssueCreateInput, - IssueUpdateInput, IssueListFilters, IssueListItem, + IssueUpdateInput, IssueViewData, } from '../types.js'; +import { getLinearClient, LinearClientError } from './client.js'; /** * Create a comment on an issue @@ -224,7 +224,8 @@ export async function getAllIssues(filters?: IssueListFilters): Promise<IssueLis // ======================================== // BUILD GRAPHQL FILTER // ======================================== - const graphqlFilter: any = {}; + interface GraphQLDateFilter { gte?: string; lte?: string } + const graphqlFilter: Record<string, unknown> & { createdAt?: GraphQLDateFilter; updatedAt?: GraphQLDateFilter } = {}; if (filters?.teamId) { graphqlFilter.team = { id: { eq: filters.teamId } }; @@ -387,7 +388,30 @@ export async function getAllIssues(filters?: IssueListFilters): Promise<IssueLis // ======================================== // PAGINATION LOOP (M15.5 Phase 1) // ======================================== - let rawIssues: any[] = []; + interface RawIssue { + id: string; + identifier: string; + title: string; + description?: string; + priority?: number; + estimate?: number; + dueDate?: string; + createdAt: string; + updatedAt: string; + completedAt?: string; + canceledAt?: string; + archivedAt?: string; + url: string; + assignee?: { id: string; name: string; email: string }; + team?: { id: string; key: string; name: string }; + state?: { id: string; name: string; type: 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled' }; + project?: { id: string; name: string }; + cycle?: { id: string; name: string; number: number }; + labels?: { nodes: Array<{ id: string; name: string; color?: string }> }; + parent?: { id: string; identifier: string; title: string }; + } + + let rawIssues: RawIssue[] = []; let cursor: string | null = null; let hasNextPage = true; let pageCount = 0; @@ -401,8 +425,7 @@ export async function getAllIssues(filters?: IssueListFilters): Promise<IssueLis after: cursor }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const response: any = await client.client.rawRequest(issuesQuery, variables); + const response = await client.client.rawRequest(issuesQuery, variables) as { data?: { issues?: { nodes?: RawIssue[]; pageInfo?: { hasNextPage?: boolean; endCursor?: string } } } }; // Track API call if tracking enabled if (tracking) { @@ -445,9 +468,9 @@ export async function getAllIssues(filters?: IssueListFilters): Promise<IssueLis const sortOrder = filters.sortOrder; const ascending = sortOrder === 'asc'; - rawIssues.sort((a: any, b: any) => { - let aVal: any; - let bVal: any; + rawIssues.sort((a: RawIssue, b: RawIssue) => { + let aVal: number; + let bVal: number; switch (sortField) { case 'priority': @@ -486,7 +509,7 @@ export async function getAllIssues(filters?: IssueListFilters): Promise<IssueLis // ======================================== // BUILD FINAL ISSUE LIST // ======================================== - const issueList: IssueListItem[] = rawIssues.map((issue: any) => ({ + const issueList: IssueListItem[] = rawIssues.map((issue: RawIssue) => ({ id: issue.id, identifier: issue.identifier, title: issue.title, @@ -524,7 +547,7 @@ export async function getAllIssues(filters?: IssueListFilters): Promise<IssueLis number: issue.cycle.number } : undefined, - labels: (issue.labels?.nodes || []).map((label: any) => ({ + labels: (issue.labels?.nodes || []).map((label: { id: string; name: string; color?: string }) => ({ id: label.id, name: label.name, color: label.color || undefined @@ -694,7 +717,33 @@ export async function getFullIssueById(issueId: string): Promise<IssueViewData | } `; - const response: any = await client.client.rawRequest(issueQuery, { issueId }); + interface RawFullIssue { + id: string; + identifier: string; + title: string; + description?: string; + url: string; + priority?: number; + estimate?: number; + dueDate?: string; + createdAt: string; + updatedAt: string; + completedAt?: string; + canceledAt?: string; + archivedAt?: string; + state?: { id: string; name: string; type: string; color: string }; + team?: { id: string; key: string; name: string }; + assignee?: { id: string; name: string; email: string }; + project?: { id: string; name: string }; + cycle?: { id: string; name: string; number: number }; + parent?: { id: string; identifier: string; title: string }; + children: { nodes: Array<{ id: string; identifier: string; title: string; state?: { id: string; name: string } }> }; + labels: { nodes: Array<{ id: string; name: string; color: string }> }; + subscribers: { nodes: Array<{ id: string; name: string; email: string }> }; + creator?: { id: string; name: string; email: string }; + } + + const response = await client.client.rawRequest(issueQuery, { issueId }) as { data?: { issue?: RawFullIssue } }; const issueData = response.data?.issue; if (!issueData) { @@ -732,7 +781,7 @@ export async function getFullIssueById(issueId: string): Promise<IssueViewData | email: issueData.assignee.email, } : undefined, - subscribers: issueData.subscribers.nodes.map((sub: any) => ({ + subscribers: issueData.subscribers.nodes.map((sub: { id: string; name: string; email: string }) => ({ id: sub.id, name: sub.name, email: sub.email, @@ -766,13 +815,13 @@ export async function getFullIssueById(issueId: string): Promise<IssueViewData | title: issueData.parent.title, } : undefined, - children: issueData.children.nodes.map((child: any) => ({ + children: issueData.children.nodes.map((child: { id: string; identifier: string; title: string; state?: { id: string; name: string } }) => ({ id: child.id, identifier: child.identifier, title: child.title, state: child.state?.name || 'Unknown', })), - labels: issueData.labels.nodes.map((label: any) => ({ + labels: issueData.labels.nodes.map((label: { id: string; name: string; color: string }) => ({ id: label.id, name: label.name, color: label.color, @@ -848,14 +897,22 @@ export async function getIssueComments(issueId: string): Promise< } `; - const response: any = await client.client.rawRequest(commentsQuery, { issueId }); + interface RawComment { + id: string; + body: string; + createdAt: string; + updatedAt: string; + user?: { id: string; name: string; email: string }; + } + + const response = await client.client.rawRequest(commentsQuery, { issueId }) as { data?: { issue?: { id: string; comments?: { nodes: RawComment[] } } } }; const issueData = response.data?.issue; if (!issueData || !issueData.comments) { return []; } - return issueData.comments.nodes.map((comment: any) => ({ + return issueData.comments.nodes.map((comment: RawComment) => ({ id: comment.id, body: comment.body, createdAt: comment.createdAt, @@ -947,14 +1004,26 @@ export async function getIssueHistory(issueId: string): Promise< } `; - const response: any = await client.client.rawRequest(historyQuery, { issueId }); + interface RawHistoryEntry { + id: string; + createdAt: string; + actor?: { id: string; name: string; email: string }; + fromState?: { id: string; name: string }; + toState?: { id: string; name: string }; + fromAssignee?: { id: string; name: string }; + toAssignee?: { id: string; name: string }; + addedLabels?: Array<{ id: string; name: string }>; + removedLabels?: Array<{ id: string; name: string }>; + } + + const response = await client.client.rawRequest(historyQuery, { issueId }) as { data?: { issue?: { id: string; history?: { nodes: RawHistoryEntry[] } } } }; const issueData = response.data?.issue; if (!issueData || !issueData.history) { return []; } - return issueData.history.nodes.map((entry: any) => ({ + return issueData.history.nodes.map((entry: RawHistoryEntry) => ({ id: entry.id, createdAt: entry.createdAt, actor: entry.actor @@ -968,8 +1037,8 @@ export async function getIssueHistory(issueId: string): Promise< toState: entry.toState?.name, fromAssignee: entry.fromAssignee?.name, toAssignee: entry.toAssignee?.name, - addedLabels: entry.addedLabels ? entry.addedLabels.map((l: any) => l.name) : undefined, - removedLabels: entry.removedLabels ? entry.removedLabels.map((l: any) => l.name) : undefined, + addedLabels: entry.addedLabels ? entry.addedLabels.map((l: { id: string; name: string }) => l.name) : undefined, + removedLabels: entry.removedLabels ? entry.removedLabels.map((l: { id: string; name: string }) => l.name) : undefined, })); } catch (error) { return []; diff --git a/src/lib/api/labels.ts b/src/lib/api/labels.ts index 8c883ce..9885062 100644 --- a/src/lib/api/labels.ts +++ b/src/lib/api/labels.ts @@ -1,5 +1,5 @@ -import { getLinearClient, LinearClientError } from './client.js'; import type { IssueLabel, ProjectLabel } from '../types.js'; +import { getLinearClient, LinearClientError } from './client.js'; /** * Issue Label types @@ -80,7 +80,7 @@ export async function getAllIssueLabels(teamId?: string): Promise<IssueLabel[]> } `; - const response: any = await client.client.rawRequest(labelsQuery); + const response = await client.client.rawRequest(labelsQuery) as { data?: { issueLabels?: { nodes?: Array<{ id: string; name: string; color: string; description?: string; team?: { id: string } }> } } }; const labelsData = response.data?.issueLabels?.nodes || []; for (const label of labelsData) { @@ -260,8 +260,7 @@ export async function getAllProjectLabels(includeAll?: boolean): Promise<Project } `; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const response: any = await client.client.rawRequest(query); + const response = await client.client.rawRequest(query) as { data?: { organization?: { projectLabels?: { nodes?: Array<{ id: string; name: string; color: string; description?: string; lastAppliedAt?: string }> } } } }; if (process.env.DEBUG) { console.log(`DEBUG: Raw GraphQL response:`, JSON.stringify(response.data, null, 2)); diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index fade043..389ff60 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -1,5 +1,5 @@ import type { LinearClient as SDKClient } from '@linear/sdk'; -import { getLinearClient, LinearClientError } from './client.js'; + import { getRelationDirection } from '../parsers.js'; import type { ProjectListFilters, @@ -7,6 +7,7 @@ import type { ProjectRelation, ProjectRelationCreateInput, } from '../types.js'; +import { getLinearClient, LinearClientError } from './client.js'; /** * Project creation input @@ -153,7 +154,8 @@ export async function getAllProjects(filters?: ProjectListFilters): Promise<Proj const client = getLinearClient(); // Build GraphQL filter object - const graphqlFilter: any = {}; + interface DateRangeFilter { gte?: string; lte?: string } + const graphqlFilter: Record<string, unknown> & { startDate?: DateRangeFilter; targetDate?: DateRangeFilter } = {}; if (filters?.teamId) { graphqlFilter.accessibleTeams = { some: { id: { eq: filters.teamId } } }; @@ -300,7 +302,38 @@ ${relationsFragment} // ======================================== // PAGINATION LOOP (M21.1) // ======================================== - let rawProjects: any[] = []; + interface RawProjectRelation { + id: string; + type: 'dependency'; + anchorType: 'start' | 'end'; + relatedAnchorType: 'start' | 'end'; + project: { id: string; name: string }; + relatedProject: { id: string; name: string }; + createdAt: string; + updatedAt: string; + } + + interface RawProject { + id: string; + name: string; + description?: string; + content?: string; + icon?: string; + color?: string; + state: string; + priority?: number; + startDate?: string; + targetDate?: string; + completedAt?: string; + url: string; + createdAt: string; + updatedAt: string; + teams?: { nodes: Array<{ id: string; name: string; key: string }> }; + lead?: { id: string; name: string; email: string }; + relations?: { nodes: RawProjectRelation[] }; + } + + let rawProjects: RawProject[] = []; let cursor: string | null = null; let hasNextPage = true; let pageCount = 0; @@ -315,8 +348,7 @@ ${relationsFragment} after: cursor }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const minimalResponse: any = await client.client.rawRequest(minimalQuery, variables); + const minimalResponse = await client.client.rawRequest(minimalQuery, variables) as { data?: { projects?: { nodes?: RawProject[]; pageInfo?: { hasNextPage?: boolean; endCursor?: string } } } }; const nodes = minimalResponse.data?.projects?.nodes || []; const pageInfo = minimalResponse.data?.projects?.pageInfo; @@ -339,11 +371,14 @@ ${relationsFragment} // ======================================== // QUERY 2: CONDITIONAL - Batch fetch labels+members IF filters use them // ======================================== - const labelsMap: Map<string, any[]> = new Map(); - const membersMap: Map<string, any[]> = new Map(); + interface RawLabel { id: string; name: string; color?: string } + interface RawMember { id: string; name: string; email: string } + + const labelsMap: Map<string, RawLabel[]> = new Map(); + const membersMap: Map<string, RawMember[]> = new Map(); if (needsAdditionalData && rawProjects.length > 0) { - const projectIds = rawProjects.map((p: any) => p.id); + const projectIds = rawProjects.map((p: RawProject) => p.id); const batchQuery = ` query GetProjectsLabelsAndMembers($ids: [String!]!) { @@ -369,8 +404,7 @@ ${relationsFragment} } `; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const batchResponse: any = await client.client.rawRequest(batchQuery, {}); + const batchResponse = await client.client.rawRequest(batchQuery, {}) as { data?: Record<string, { id: string; labels?: { nodes: RawLabel[] }; members?: { nodes: RawMember[] } }> }; if (process.env.LINEAR_CREATE_DEBUG_FILTERS === '1') { console.error('[agent2linear] Batch query fetched labels+members for', projectIds.length, 'projects'); @@ -399,7 +433,7 @@ ${relationsFragment} // ======================================== // BUILD FINAL PROJECT LIST (IN-CODE JOIN) // ======================================== - const projectList: ProjectListItem[] = rawProjects.map((project: any) => { + const projectList: ProjectListItem[] = rawProjects.map((project: RawProject) => { const labels = labelsMap.get(project.id) || []; const members = membersMap.get(project.id) || []; @@ -416,7 +450,7 @@ ${relationsFragment} if (project.relations?.nodes) { const relations = project.relations.nodes; - dependsOnCount = relations.filter((rel: any) => { + dependsOnCount = relations.filter((rel: RawProjectRelation) => { try { return getRelationDirection(rel, project.id) === 'depends-on'; } catch { @@ -424,7 +458,7 @@ ${relationsFragment} } }).length; - blocksCount = relations.filter((rel: any) => { + blocksCount = relations.filter((rel: RawProjectRelation) => { try { return getRelationDirection(rel, project.id) === 'blocks'; } catch { @@ -460,13 +494,13 @@ ${relationsFragment} initiative: undefined, // Initiative relationship needs to be fetched differently - labels: labels.map((label: any) => ({ + labels: labels.map((label: RawLabel) => ({ id: label.id, name: label.name, color: label.color || undefined })), - members: members.map((member: any) => ({ + members: members.map((member: RawMember) => ({ id: member.id, name: member.name, email: member.email @@ -866,7 +900,18 @@ export async function getProjectById( } `; - const response: any = await client.client.rawRequest(projectQuery, { projectId }); + interface RawProjectById { + id: string; + name: string; + description?: string; + content?: string; + url: string; + state: string; + initiatives?: { nodes?: Array<{ id: string; name: string }> }; + teams?: { nodes?: Array<{ id: string; name: string }> }; + } + + const response = await client.client.rawRequest(projectQuery, { projectId }) as { data?: { project?: RawProjectById } }; const project = response.data?.project; if (!project) { @@ -1062,7 +1107,21 @@ export async function getFullProjectDetails(projectId: string): Promise<{ } `; - const response: any = await client.client.rawRequest(projectQuery, { projectId }); + interface RawProjectDetails { + id: string; + name: string; + description?: string; + content?: string; + url: string; + state: string; + initiatives?: { nodes?: Array<{ id: string; name: string }> }; + teams?: { nodes?: Array<{ id: string; name: string }> }; + lastAppliedTemplate?: { id: string; name: string }; + projectMilestones?: { nodes?: Array<{ id: string; name: string }> }; + issues?: { nodes?: Array<{ id: string; identifier: string; title: string }> }; + } + + const response = await client.client.rawRequest(projectQuery, { projectId }) as { data?: { project?: RawProjectDetails } }; const projectData = response.data?.project; if (!projectData) { @@ -1091,12 +1150,12 @@ export async function getFullProjectDetails(projectId: string): Promise<{ } : undefined; - const milestones = (projectData.projectMilestones?.nodes || []).map((milestone: any) => ({ + const milestones = (projectData.projectMilestones?.nodes || []).map((milestone: { id: string; name: string }) => ({ id: milestone.id, name: milestone.name, })); - const issues = (projectData.issues?.nodes || []).map((issue: any) => ({ + const issues = (projectData.issues?.nodes || []).map((issue: { id: string; identifier: string; title: string }) => ({ id: issue.id, identifier: issue.identifier, title: issue.title, diff --git a/src/lib/api/workflow-states.ts b/src/lib/api/workflow-states.ts index 7b6553b..6e6c9e7 100644 --- a/src/lib/api/workflow-states.ts +++ b/src/lib/api/workflow-states.ts @@ -1,5 +1,5 @@ -import { getLinearClient, LinearClientError } from './client.js'; import type { WorkflowState } from '../types.js'; +import { getLinearClient, LinearClientError } from './client.js'; /** * Workflow State input types @@ -75,7 +75,7 @@ export async function getAllWorkflowStates(teamId?: string): Promise<WorkflowSta } `; - const response: any = await client.client.rawRequest(statesQuery); + const response = await client.client.rawRequest(statesQuery) as { data?: { teams?: { nodes?: Array<{ id: string; states: { nodes: Array<{ id: string; name: string; type: string; color: string; description?: string; position: number }> } }> } } }; const teamsData = response.data?.teams?.nodes || []; for (const team of teamsData) { diff --git a/src/lib/batch-fetcher.ts b/src/lib/batch-fetcher.ts index edb77bf..b330c8f 100644 --- a/src/lib/batch-fetcher.ts +++ b/src/lib/batch-fetcher.ts @@ -11,9 +11,9 @@ * - Prewarm functions for common operations (create, update) */ -import { getEntityCache } from './entity-cache.js'; -import { Team, Initiative, Member, Template } from './linear-client.js'; import { getConfig } from './config.js'; +import { getEntityCache } from './entity-cache.js'; +import { Initiative, Member, Team, Template } from './linear-client.js'; /** * Batch fetch options diff --git a/src/lib/colors.ts b/src/lib/colors.ts index 486eb4d..971a424 100644 --- a/src/lib/colors.ts +++ b/src/lib/colors.ts @@ -1,5 +1,5 @@ +import { getAllIssueLabels, getAllProjectLabels, getAllProjectStatuses,getAllWorkflowStates } from './linear-client.js'; import type { Color } from './types.js'; -import { getAllIssueLabels, getAllProjectLabels, getAllWorkflowStates, getAllProjectStatuses } from './linear-client.js'; /** * Curated Linear color palette diff --git a/src/lib/config.ts b/src/lib/config.ts index b55d4b0..901a3cb 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { homedir } from 'os'; import { dirname, join } from 'path'; + import type { Config, ResolvedConfig } from './types.js'; const GLOBAL_CONFIG_DIR = join(homedir(), '.config', 'agent2linear'); diff --git a/src/lib/date-parser.test.ts b/src/lib/date-parser.test.ts index 873420e..cbcdbcd 100644 --- a/src/lib/date-parser.test.ts +++ b/src/lib/date-parser.test.ts @@ -1,11 +1,12 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + import { - parseProjectDate, - parseDateForCommand, - getQuarterStartDate, getHalfYearStartDate, getMonthStartDate, + getQuarterStartDate, + parseDateForCommand, parseMonthName, + parseProjectDate, } from './date-parser.js'; describe('parseProjectDate', () => { diff --git a/src/lib/entity-cache.ts b/src/lib/entity-cache.ts index 86578b5..be88f7d 100644 --- a/src/lib/entity-cache.ts +++ b/src/lib/entity-cache.ts @@ -14,14 +14,14 @@ import { getConfig } from './config.js'; import { - getAllTeams, getAllInitiatives, getAllMembers, + getAllTeams, getAllTemplates, getCurrentUser as getLinearCurrentUser, - Team, Initiative, Member, + Team, Template } from './linear-client.js'; @@ -33,17 +33,17 @@ export interface User { name: string; email: string; } -import { IssueLabel, ProjectLabel } from './types.js'; import { - getCachedTeams, - saveTeamsCache, getCachedInitiatives, - saveInitiativesCache, getCachedMembers, - saveMembersCache, + getCachedTeams, getCachedTemplates, + saveInitiativesCache, + saveMembersCache, + saveTeamsCache, saveTemplatesCache, } from './status-cache.js'; +import { IssueLabel, ProjectLabel } from './types.js'; /** * Cached entity with timestamp diff --git a/src/lib/error-handler.test.ts b/src/lib/error-handler.test.ts index 1c4e20b..f853f84 100644 --- a/src/lib/error-handler.test.ts +++ b/src/lib/error-handler.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest'; -import { handleLinearError, isLinearError, formatLinearErrorForLogging } from './error-handler.js'; +import { describe, expect,it } from 'vitest'; + +import { formatLinearErrorForLogging,handleLinearError, isLinearError } from './error-handler.js'; describe('handleLinearError', () => { it('handles 401 authentication errors', () => { diff --git a/src/lib/error-handler.ts b/src/lib/error-handler.ts index 0bfcba8..5a4ed13 100644 --- a/src/lib/error-handler.ts +++ b/src/lib/error-handler.ts @@ -7,14 +7,17 @@ * Extract HTTP status code from error object * Works with various error formats from Linear SDK */ -function getStatusCode(error: any): number | null { +function getStatusCode(error: unknown): number | null { + const err = error as Record<string, unknown>; // Try common locations for status code - if (error.status) return error.status; - if (error.response?.status) return error.response.status; - if (error.statusCode) return error.statusCode; - if (error.extensions?.code) { + if (typeof err.status === 'number') return err.status; + const response = err.response as Record<string, unknown> | undefined; + if (typeof response?.status === 'number') return response.status; + if (typeof err.statusCode === 'number') return err.statusCode; + const extensions = err.extensions as Record<string, unknown> | undefined; + if (extensions?.code) { // GraphQL errors sometimes use extensions.code - const code = error.extensions.code; + const code = extensions.code; if (code === 'UNAUTHENTICATED') return 401; if (code === 'FORBIDDEN') return 403; if (code === 'NOT_FOUND') return 404; @@ -26,12 +29,15 @@ function getStatusCode(error: any): number | null { /** * Extract retry-after header value for rate limiting */ -function getRetryAfter(error: any): string | null { - if (error.response?.headers?.['retry-after']) { - return error.response.headers['retry-after']; +function getRetryAfter(error: unknown): string | null { + const err = error as Record<string, unknown>; + const response = err.response as Record<string, unknown> | undefined; + const headers = response?.headers as Record<string, string> | undefined; + if (headers?.['retry-after']) { + return headers['retry-after']; } - if (error.retryAfter) { - return error.retryAfter.toString(); + if (err.retryAfter) { + return String(err.retryAfter); } return null; } @@ -39,15 +45,20 @@ function getRetryAfter(error: any): string | null { /** * Extract Linear validation error message if available */ -function getValidationMessage(error: any): string | null { +function getValidationMessage(error: unknown): string | null { + const err = error as Record<string, unknown>; // Try common locations for validation messages - if (error.message) return error.message; - if (error.response?.data?.message) return error.response.data.message; - if (error.response?.data?.errors?.[0]?.message) { - return error.response.data.errors[0].message; + if (typeof err.message === 'string') return err.message; + const response = err.response as Record<string, unknown> | undefined; + const data = response?.data as Record<string, unknown> | undefined; + if (typeof data?.message === 'string') return data.message; + const errors = data?.errors as Array<{ message?: string }> | undefined; + if (typeof errors?.[0]?.message === 'string') { + return errors[0].message; } - if (error.graphQLErrors?.[0]?.message) { - return error.graphQLErrors[0].message; + const graphQLErrors = err.graphQLErrors as Array<{ message?: string }> | undefined; + if (typeof graphQLErrors?.[0]?.message === 'string') { + return graphQLErrors[0].message; } return null; } @@ -76,7 +87,7 @@ function getValidationMessage(error: any): string | null { * } * ``` */ -export function handleLinearError(error: any, context?: string): string { +export function handleLinearError(error: unknown, context?: string): string { const statusCode = getStatusCode(error); const entityContext = context ? ` ${context}` : ' resource'; @@ -135,8 +146,9 @@ export function handleLinearError(error: any, context?: string): string { } // Generic error fallback - if (error.message) { - return `❌ Error: ${error.message}`; + const errObj = error as Record<string, unknown>; + if (typeof errObj.message === 'string') { + return `❌ Error: ${errObj.message}`; } return '❌ An unexpected error occurred while communicating with Linear'; @@ -148,13 +160,14 @@ export function handleLinearError(error: any, context?: string): string { * Check if an error is a Linear API error * Useful for determining if handleLinearError should be used */ -export function isLinearError(error: any): boolean { +export function isLinearError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const err = error as Record<string, unknown>; return ( - error && - (error.name === 'LinearClientError' || - error.constructor?.name === 'LinearClientError' || + err.name === 'LinearClientError' || + (err.constructor as { name?: string } | undefined)?.name === 'LinearClientError' || getStatusCode(error) !== null || - getValidationMessage(error) !== null) + getValidationMessage(error) !== null ); } @@ -162,7 +175,7 @@ export function isLinearError(error: any): boolean { * Format a Linear API error for logging/debugging * Includes more technical details than handleLinearError */ -export function formatLinearErrorForLogging(error: any): string { +export function formatLinearErrorForLogging(error: unknown): string { const parts: string[] = ['Linear API Error:']; const statusCode = getStatusCode(error); @@ -175,8 +188,9 @@ export function formatLinearErrorForLogging(error: any): string { parts.push(` Message: ${message}`); } - if (error.stack) { - parts.push(` Stack: ${error.stack}`); + const errObj = error as Record<string, unknown>; + if (typeof errObj.stack === 'string') { + parts.push(` Stack: ${errObj.stack}`); } return parts.join('\n'); diff --git a/src/lib/icons.ts b/src/lib/icons.ts index 173fc3a..45d507e 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -1,5 +1,5 @@ -import type { Icon } from './types.js'; import { getAllIssueLabels, getAllProjectLabels, getAllWorkflowStates } from './linear-client.js'; +import type { Icon } from './types.js'; /** * Curated list of common Linear icons/emojis diff --git a/src/lib/issue-resolver.ts b/src/lib/issue-resolver.ts index d7d868b..ad05424 100644 --- a/src/lib/issue-resolver.ts +++ b/src/lib/issue-resolver.ts @@ -10,7 +10,7 @@ import { getLinearClient } from './linear-client.js'; */ export interface IssueResolveResult { issueId: string; - issue?: any; // Linear Issue object + issue?: unknown; // Linear SDK Issue object resolvedBy: 'uuid' | 'identifier'; originalInput: string; } @@ -124,7 +124,7 @@ async function resolveIdentifierToUUID(identifier: string): Promise<string | nul * @param uuid - Issue UUID * @returns Issue object or null if not found */ -async function fetchIssueByUUID(uuid: string): Promise<any | null> { +async function fetchIssueByUUID(uuid: string): Promise<unknown> { try { const client = getLinearClient(); const issue = await client.issue(uuid); diff --git a/src/lib/milestone-templates.ts b/src/lib/milestone-templates.ts index 0990774..0d840f4 100644 --- a/src/lib/milestone-templates.ts +++ b/src/lib/milestone-templates.ts @@ -1,7 +1,8 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { existsSync, mkdirSync,readFileSync, writeFileSync } from 'fs'; import { homedir } from 'os'; import { dirname, join } from 'path'; -import type { MilestoneTemplates, MilestoneTemplate, MilestoneDefinition } from './types.js'; + +import type { MilestoneDefinition,MilestoneTemplate, MilestoneTemplates } from './types.js'; const GLOBAL_TEMPLATES_DIR = join(homedir(), '.config', 'agent2linear'); const GLOBAL_TEMPLATES_FILE = join(GLOBAL_TEMPLATES_DIR, 'milestone-templates.json'); diff --git a/src/lib/parsers.test.ts b/src/lib/parsers.test.ts index dd30246..5ada6db 100644 --- a/src/lib/parsers.test.ts +++ b/src/lib/parsers.test.ts @@ -1,12 +1,12 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect,it } from 'vitest'; + import { parseCommaSeparated, - parsePipeDelimited, + parseCommaSeparatedUnique, parseLifecycleDate, + parsePipeDelimited, parsePipeDelimitedArray, - parseCommaSeparatedUnique, validateAnchorType, - parseAdvancedDependency, } from './parsers.js'; describe('parseCommaSeparated', () => { diff --git a/src/lib/project-resolver.ts b/src/lib/project-resolver.ts index 81f83d1..5c7c08d 100644 --- a/src/lib/project-resolver.ts +++ b/src/lib/project-resolver.ts @@ -1,9 +1,10 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { existsSync, mkdirSync,readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; + import { resolveAlias } from './aliases.js'; -import { findProjectByName, getProjectById } from './linear-client.js'; import { getConfig } from './config.js'; import type { ProjectResult } from './linear-client.js'; +import { findProjectByName, getProjectById } from './linear-client.js'; const PROJECT_CACHE_DIR = '.agent2linear'; const PROJECT_CACHE_FILE = join(PROJECT_CACHE_DIR, 'project-cache.json'); diff --git a/src/lib/smoke.test.ts b/src/lib/smoke.test.ts index 6a88b73..b71d303 100644 --- a/src/lib/smoke.test.ts +++ b/src/lib/smoke.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect,it } from 'vitest'; describe('Vitest setup', () => { it('should run basic assertions', () => { diff --git a/src/lib/status-cache.ts b/src/lib/status-cache.ts index 2d7a64c..f4e0764 100644 --- a/src/lib/status-cache.ts +++ b/src/lib/status-cache.ts @@ -1,25 +1,25 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { existsSync, mkdirSync,readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; + import { getConfig } from './config.js'; import { - getAllProjectStatuses, - getAllTeams, getAllInitiatives, + getAllIssueLabels, getAllMembers, + getAllProjectLabels, + getAllProjectStatuses, + getAllTeams, getAllTemplates, getAllWorkflowStates, - getAllIssueLabels, - getAllProjectLabels, - type Team, type Initiative, type Member, + type Team, type Template } from './linear-client.js'; import type { - WorkflowState, IssueLabel, - ProjectLabel -} from './types.js'; + ProjectLabel, + WorkflowState} from './types.js'; const CACHE_DIR = '.agent2linear'; const CACHE_FILE = join(CACHE_DIR, 'cache.json'); diff --git a/src/lib/types.ts b/src/lib/types.ts index cf50b60..a2f1d00 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -290,7 +290,7 @@ export interface IssueCreateInput { // Content fields description?: string; - descriptionData?: any; // Linear's Prosemirror JSON format + descriptionData?: Record<string, unknown>; // Linear's Prosemirror JSON format // Priority & estimation priority?: number; // 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low @@ -324,7 +324,7 @@ export interface IssueUpdateInput { // Basic fields title?: string; description?: string; - descriptionData?: any; + descriptionData?: Record<string, unknown>; // Priority & estimation priority?: number; diff --git a/src/lib/validators.test.ts b/src/lib/validators.test.ts index bf08b3f..a2e8c95 100644 --- a/src/lib/validators.test.ts +++ b/src/lib/validators.test.ts @@ -1,11 +1,12 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect,it } from 'vitest'; + import { - validatePriority, + formatEntityNotFoundError, validateAndNormalizeColor, - validateISODate, validateEnumValue, + validateISODate, validateNonEmpty, - formatEntityNotFoundError, + validatePriority, } from './validators.js'; describe('validatePriority', () => { diff --git a/src/ui/components/InitiativeList.tsx b/src/ui/components/InitiativeList.tsx index ad08e59..f2d6e44 100644 --- a/src/ui/components/InitiativeList.tsx +++ b/src/ui/components/InitiativeList.tsx @@ -1,8 +1,9 @@ -import React, { useState } from 'react'; import { Box, Text } from 'ink'; import SelectInput from 'ink-select-input'; -import type { Initiative } from '../../lib/linear-client.js'; +import React, { useState } from 'react'; + import { getAliasesForId } from '../../lib/aliases.js'; +import type { Initiative } from '../../lib/linear-client.js'; interface InitiativeListProps { initiatives: Initiative[]; diff --git a/src/ui/components/MemberList.tsx b/src/ui/components/MemberList.tsx index 7f3d1a2..fd60f0b 100644 --- a/src/ui/components/MemberList.tsx +++ b/src/ui/components/MemberList.tsx @@ -1,8 +1,9 @@ -import React, { useState } from 'react'; import { Box, Text } from 'ink'; import SelectInput from 'ink-select-input'; -import type { Member } from '../../lib/linear-client.js'; +import React, { useState } from 'react'; + import { getAliasesForId } from '../../lib/aliases.js'; +import type { Member } from '../../lib/linear-client.js'; interface MemberListProps { members: Member[]; diff --git a/src/ui/components/MemberSelector.tsx b/src/ui/components/MemberSelector.tsx index ef9f885..07cedaf 100644 --- a/src/ui/components/MemberSelector.tsx +++ b/src/ui/components/MemberSelector.tsx @@ -1,6 +1,7 @@ -import React from 'react'; import { Box, Text } from 'ink'; import SelectInput from 'ink-select-input'; +import React from 'react'; + import type { Member } from '../../lib/linear-client.js'; interface MemberSelectorProps { diff --git a/src/ui/components/ProjectForm.tsx b/src/ui/components/ProjectForm.tsx index 0bdac86..d03437d 100644 --- a/src/ui/components/ProjectForm.tsx +++ b/src/ui/components/ProjectForm.tsx @@ -1,7 +1,8 @@ -import React, { useState } from 'react'; import { Box, Text } from 'ink'; -import TextInput from 'ink-text-input'; import SelectInput from 'ink-select-input'; +import TextInput from 'ink-text-input'; +import React, { useState } from 'react'; + import type { ProjectCreateInput } from '../../lib/linear-client.js'; type Step = 'title' | 'description' | 'state' | 'complete'; diff --git a/src/ui/components/TeamList.tsx b/src/ui/components/TeamList.tsx index c380707..712900b 100644 --- a/src/ui/components/TeamList.tsx +++ b/src/ui/components/TeamList.tsx @@ -1,6 +1,7 @@ -import React, { useState } from 'react'; import { Box, Text } from 'ink'; import SelectInput from 'ink-select-input'; +import React, { useState } from 'react'; + import type { Team } from '../../lib/linear-client.js'; interface TeamListProps {