chore: 1.0 release prep
Metadata: - Bump dew CLI version 0.0.1 → 1.0.0 - Add repository + issue_tracker URLs to all pubspec.yaml files - Switch inter-package path deps to versioned deps (^1.0.0) - Remove publish_to: none from all packages - Add MIT LICENSE to root and all packages - Confirm all four pub.dev names available (dew, dew_core, dew_kanban, dew_mcp) Documentation: - Add CHANGELOG.md (Keep a Changelog format, full 1.0.0 feature history) - Overhaul README.md (pitch, pub.dev badge, quick-start, feature sections) - Add TUI section + full keybinding tables to docs/features/kanban.md - Add CONTRIBUTING.md (setup, test, lint, branch strategy, command guide) Tests: - Add packages/cli/test/cli_test.dart (6 smoke tests) - Add packages/kanban/test/integration_test.dart (6 TicketStore e2e tests) - Expand packages/mcp/test/mcp_test.dart (5 tool registration tests) - Add dew_kanban as dev dependency in packages/mcp/pubspec.yaml - 57/57 tests passing Code quality: - dart format applied across all 23 changed source files - dart analyze: zero errors, zero warnings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
4193282325
commit
0ad1fae213
51 changed files with 1682 additions and 337 deletions
20
.project/kanban/backlog/DEW-0016.md
Normal file
20
.project/kanban/backlog/DEW-0016.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
id: DEW-0016
|
||||||
|
title: Verify pub.dev name availability for all four packages
|
||||||
|
type: spike
|
||||||
|
created: 2026-04-25T19:51:54.776993Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
- metadata
|
||||||
|
---
|
||||||
|
|
||||||
|
Check that all four package names are available on pub.dev before we invest in publishing prep:
|
||||||
|
|
||||||
|
- https://pub.dev/packages/dew
|
||||||
|
- https://pub.dev/packages/dew_core
|
||||||
|
- https://pub.dev/packages/dew_kanban
|
||||||
|
- https://pub.dev/packages/dew_mcp
|
||||||
|
|
||||||
|
If any name is taken, we need to decide on alternatives (e.g. `dew_cli`, `dew_board`, etc.) and update package names + imports throughout the codebase.
|
||||||
23
.project/kanban/backlog/DEW-0017.md
Normal file
23
.project/kanban/backlog/DEW-0017.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
id: DEW-0017
|
||||||
|
title: Bump CLI version + fill repository URLs in all pubspec.yaml
|
||||||
|
type: task
|
||||||
|
created: 2026-04-25T19:52:12.849559Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
- metadata
|
||||||
|
---
|
||||||
|
|
||||||
|
Update all pubspec.yaml files for the 1.0 release:
|
||||||
|
|
||||||
|
- `packages/cli/pubspec.yaml`: bump version from `0.0.1` → `1.0.0`
|
||||||
|
- All four packages: uncomment and set `repository: https://github.com/artificery-dev/dew`
|
||||||
|
- All four packages: add `issue_tracker: https://github.com/artificery-dev/dew/issues`
|
||||||
|
|
||||||
|
Files to edit:
|
||||||
|
- packages/cli/pubspec.yaml
|
||||||
|
- packages/core/pubspec.yaml
|
||||||
|
- packages/kanban/pubspec.yaml
|
||||||
|
- packages/mcp/pubspec.yaml
|
||||||
31
.project/kanban/backlog/DEW-0018.md
Normal file
31
.project/kanban/backlog/DEW-0018.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
id: DEW-0018
|
||||||
|
title: Switch path deps to versioned deps + add dependency_overrides for local dev
|
||||||
|
type: task
|
||||||
|
created: 2026-04-25T19:52:18.589466Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
- metadata
|
||||||
|
---
|
||||||
|
|
||||||
|
pub.dev rejects path dependencies. Switch all inter-package deps to versioned, and use dependency_overrides in the workspace root for local development.
|
||||||
|
|
||||||
|
Changes needed:
|
||||||
|
- `packages/cli/pubspec.yaml`: change dew_core, dew_kanban, dew_mcp from `path: ../x` to `^1.0.0`
|
||||||
|
- `packages/kanban/pubspec.yaml`: change dew_core from `path: ../core` to `^1.0.0`
|
||||||
|
- `packages/mcp/pubspec.yaml`: change dew_core from `path: ../core` to `^1.0.0`
|
||||||
|
|
||||||
|
Add to root `pubspec.yaml`:
|
||||||
|
```yaml
|
||||||
|
dependency_overrides:
|
||||||
|
dew_core:
|
||||||
|
path: packages/core
|
||||||
|
dew_kanban:
|
||||||
|
path: packages/kanban
|
||||||
|
dew_mcp:
|
||||||
|
path: packages/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
After changes, run `dart pub get` at root and verify it resolves cleanly.
|
||||||
21
.project/kanban/backlog/DEW-0019.md
Normal file
21
.project/kanban/backlog/DEW-0019.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
id: DEW-0019
|
||||||
|
title: "Remove publish_to: none from all packages"
|
||||||
|
type: task
|
||||||
|
created: 2026-04-25T19:52:22.448428Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
- metadata
|
||||||
|
---
|
||||||
|
|
||||||
|
Remove `publish_to: none` from all four package pubspec.yaml files so they can be published to pub.dev.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- packages/cli/pubspec.yaml
|
||||||
|
- packages/core/pubspec.yaml
|
||||||
|
- packages/kanban/pubspec.yaml
|
||||||
|
- packages/mcp/pubspec.yaml
|
||||||
|
|
||||||
|
After removing, run `dart pub publish --dry-run` on each package to surface any remaining blocking issues.
|
||||||
24
.project/kanban/backlog/DEW-0020.md
Normal file
24
.project/kanban/backlog/DEW-0020.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
id: DEW-0020
|
||||||
|
title: Write CHANGELOG.md
|
||||||
|
type: task
|
||||||
|
created: 2026-04-25T19:52:30.491758Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
- docs
|
||||||
|
---
|
||||||
|
|
||||||
|
Write /CHANGELOG.md at the repo root covering the full 0.x → 1.0.0 history.
|
||||||
|
|
||||||
|
Format: Keep a Changelog (https://keepachangelog.com/en/1.0.0/)
|
||||||
|
|
||||||
|
The 1.0.0 section should cover all major features shipped:
|
||||||
|
- dew init command
|
||||||
|
- Kanban CLI: create, list, get, update, delete, move, search, comment, archive, unarchive, link, unlink, stats, config
|
||||||
|
- Kanban TUI: interactive board, column nav, ticket detail, editor modal, search/filter, delete, link, type picker, help overlay, auto-refresh, SIGWINCH resize
|
||||||
|
- MCP server: all kanban tools exposed as MCP tools via DewToolCommand mixin
|
||||||
|
- File-based ticket storage with YAML frontmatter + Markdown body
|
||||||
|
|
||||||
|
Reference `git log --oneline` for the full commit history.
|
||||||
27
.project/kanban/backlog/DEW-0021.md
Normal file
27
.project/kanban/backlog/DEW-0021.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
id: DEW-0021
|
||||||
|
title: Overhaul README.md for 1.0 release
|
||||||
|
type: task
|
||||||
|
created: 2026-04-25T19:52:37.218209Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
- docs
|
||||||
|
---
|
||||||
|
|
||||||
|
Overhaul /README.md for a compelling 1.0 release page.
|
||||||
|
|
||||||
|
Must include:
|
||||||
|
- Elevator pitch (1-2 sentences: what dew is, who it's for)
|
||||||
|
- Feature highlights with brief descriptions (Kanban CLI, TUI, MCP server)
|
||||||
|
- Installation: `dart pub global activate dew`
|
||||||
|
- Quick-start walkthrough:
|
||||||
|
1. `dew init .`
|
||||||
|
2. `dew kanban create --title "My first ticket" --type task`
|
||||||
|
3. `dew kanban tui`
|
||||||
|
- Configuration overview + link to docs/config.md
|
||||||
|
- Links to full docs (docs/index.md, docs/features/kanban.md, docs/features/mcp.md)
|
||||||
|
- Badge for pub.dev version + license
|
||||||
|
|
||||||
|
Current README is 37 lines and very thin — needs significant expansion.
|
||||||
25
.project/kanban/backlog/DEW-0022.md
Normal file
25
.project/kanban/backlog/DEW-0022.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
id: DEW-0022
|
||||||
|
title: Add TUI section + update command list in docs/features/kanban.md
|
||||||
|
type: task
|
||||||
|
created: 2026-04-25T19:52:46.530001Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
- docs
|
||||||
|
---
|
||||||
|
|
||||||
|
docs/features/kanban.md currently has zero coverage of `dew kanban tui`. Add a full TUI section.
|
||||||
|
|
||||||
|
The section should include:
|
||||||
|
- What the TUI is and how to launch it (`dew kanban tui`)
|
||||||
|
- Overview of the three modes: Board, Detail, Editor
|
||||||
|
- Full keybinding reference table (mirrors the F1 help overlay):
|
||||||
|
- Board mode: ↑↓ nav, ←→ cols, </> move, Enter detail, n/e/a/D/c/L/? actions, F1, q
|
||||||
|
- Detail mode: ↑↓ scroll, e edit, b/Esc back, F1, q
|
||||||
|
- Editor mode: ↑↓ fields, ←→ values, Enter edit, d del item, s save, Esc discard, F1
|
||||||
|
- Note on auto-refresh (watches .project/kanban/ for file changes)
|
||||||
|
- Note on external editor for body (respects $VISUAL / $EDITOR)
|
||||||
|
|
||||||
|
Also update the command list at the top of the file — it currently lists 12 commands but we have 16 (tui, board, link, unlink are missing or incomplete).
|
||||||
40
.project/kanban/backlog/DEW-0023.md
Normal file
40
.project/kanban/backlog/DEW-0023.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
id: DEW-0023
|
||||||
|
title: Write CONTRIBUTING.md
|
||||||
|
type: task
|
||||||
|
created: 2026-04-25T19:52:53.733289Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
- docs
|
||||||
|
---
|
||||||
|
|
||||||
|
Create /CONTRIBUTING.md at the repo root.
|
||||||
|
|
||||||
|
Should cover:
|
||||||
|
- Prerequisites: Dart SDK ^3.11.4
|
||||||
|
- Clone & setup:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/artificery-dev/dew.git
|
||||||
|
cd dew
|
||||||
|
dart pub get
|
||||||
|
```
|
||||||
|
- Running the CLI locally:
|
||||||
|
```bash
|
||||||
|
dart run packages/cli/bin/dew.dart kanban list
|
||||||
|
```
|
||||||
|
- Running tests:
|
||||||
|
```bash
|
||||||
|
dart test packages/core/test/
|
||||||
|
dart test packages/kanban/test/
|
||||||
|
dart test packages/mcp/test/
|
||||||
|
```
|
||||||
|
- Linting and formatting:
|
||||||
|
```bash
|
||||||
|
dart analyze
|
||||||
|
dart format .
|
||||||
|
```
|
||||||
|
- Branch strategy: feature branches off `develop`, PRs into `develop`, releases merge to `main`
|
||||||
|
- Commit message conventions (reference existing commit style in git log)
|
||||||
|
- How to add a new kanban command (implement DewToolCommand, register in dew_kanban_base.dart)
|
||||||
22
.project/kanban/backlog/DEW-0024.md
Normal file
22
.project/kanban/backlog/DEW-0024.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
id: DEW-0024
|
||||||
|
title: Add CLI package smoke tests
|
||||||
|
type: task
|
||||||
|
created: 2026-04-25T19:53:00.484523Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
- tests
|
||||||
|
---
|
||||||
|
|
||||||
|
packages/cli has no tests at all. Add packages/cli/test/cli_test.dart with:
|
||||||
|
|
||||||
|
1. Smoke test: CommandRunner instantiates without throwing
|
||||||
|
2. Help output: running with `--help` exits 0 and output contains 'kanban', 'init', 'mcp'
|
||||||
|
3. Version: running with `--version` exits 0 and output matches version in pubspec.yaml
|
||||||
|
4. Unknown command: running with an unknown subcommand exits non-zero
|
||||||
|
|
||||||
|
Use dart:io Process.run to invoke `dart run packages/cli/bin/dew.dart` with appropriate args, or instantiate the runner directly if that's cleaner.
|
||||||
|
|
||||||
|
Also add a `test` script to packages/cli/pubspec.yaml if not present.
|
||||||
26
.project/kanban/backlog/DEW-0025.md
Normal file
26
.project/kanban/backlog/DEW-0025.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
id: DEW-0025
|
||||||
|
title: Add integration tests (init → create → list → move → link → delete)
|
||||||
|
type: task
|
||||||
|
created: 2026-04-25T19:53:08.295571Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
- tests
|
||||||
|
---
|
||||||
|
|
||||||
|
Add end-to-end integration tests that exercise the full CLI against real temp directories.
|
||||||
|
|
||||||
|
Create packages/cli/test/integration_test.dart (or packages/kanban/test/integration_test.dart).
|
||||||
|
|
||||||
|
Test flows:
|
||||||
|
1. `dew init <tmpdir>` → .project/dew.yaml and .project/kanban/ are created
|
||||||
|
2. `dew kanban create --title "Test" --type task` → ticket file appears in backlog
|
||||||
|
3. `dew kanban list` → returns the created ticket
|
||||||
|
4. `dew kanban move --id <id> --column doing` → ticket moves
|
||||||
|
5. `dew kanban link --id <id> --target <id2> --type relates_to` → link appears in both tickets
|
||||||
|
6. `dew kanban unlink --id <id> --target <id2>` → link removed from both
|
||||||
|
7. `dew kanban delete --id <id>` → ticket file removed
|
||||||
|
|
||||||
|
Use setUp/tearDown with dart:io Directory.systemTemp for isolation. Invoke commands via the Dart API (instantiate CommandRunner directly with a MemoryFileSystem or real temp dir), not via subprocess, for speed.
|
||||||
19
.project/kanban/backlog/DEW-0026.md
Normal file
19
.project/kanban/backlog/DEW-0026.md
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
id: DEW-0026
|
||||||
|
title: Expand MCP server tool registration tests
|
||||||
|
type: task
|
||||||
|
created: 2026-04-25T19:53:14.810785Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
- tests
|
||||||
|
---
|
||||||
|
|
||||||
|
The MCP serve command and server startup are not covered by tests. Extend packages/mcp/test/mcp_test.dart:
|
||||||
|
|
||||||
|
1. McpServer registers all expected tool names (currently partially tested — confirm all 14 kanban tools are present)
|
||||||
|
2. Tool schemas have correct required fields (e.g. kanban_create_ticket requires `title`)
|
||||||
|
3. Tool handlers return expected shape (mock TicketStore, call handler, verify output)
|
||||||
|
|
||||||
|
Reference packages/mcp/lib/src/dew_mcp_server.dart and the DewToolCommand mixin in dew_core.
|
||||||
23
.project/kanban/backlog/DEW-0027.md
Normal file
23
.project/kanban/backlog/DEW-0027.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
id: DEW-0027
|
||||||
|
title: "Final validation: analyze + format + tests + dry-run publish"
|
||||||
|
type: task
|
||||||
|
created: 2026-04-25T19:53:20.587728Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
---
|
||||||
|
|
||||||
|
Final validation gate before publishing. Must pass all of:
|
||||||
|
|
||||||
|
1. `dart analyze` — zero errors, zero warnings across all packages
|
||||||
|
2. `dart format --set-exit-if-changed .` — all files correctly formatted
|
||||||
|
3. Full test suite green:
|
||||||
|
- dart test packages/core/test/
|
||||||
|
- dart test packages/kanban/test/
|
||||||
|
- dart test packages/mcp/test/
|
||||||
|
- dart test packages/cli/test/
|
||||||
|
4. `dart pub publish --dry-run` passes for all four packages
|
||||||
|
|
||||||
|
Fix any issues found. Do not proceed to publish until all four checks are clean.
|
||||||
29
.project/kanban/backlog/DEW-0028.md
Normal file
29
.project/kanban/backlog/DEW-0028.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
id: DEW-0028
|
||||||
|
title: Publish packages to pub.dev and tag v1.0.0
|
||||||
|
type: task
|
||||||
|
created: 2026-04-25T19:53:27.907063Z
|
||||||
|
milestones:
|
||||||
|
- 1.0.0
|
||||||
|
labels:
|
||||||
|
- 1.0
|
||||||
|
---
|
||||||
|
|
||||||
|
Publish all four packages to pub.dev in dependency order, then tag the release.
|
||||||
|
|
||||||
|
Order:
|
||||||
|
1. `cd packages/core && dart pub publish`
|
||||||
|
2. `cd packages/kanban && dart pub publish`
|
||||||
|
3. `cd packages/mcp && dart pub publish`
|
||||||
|
4. `cd packages/cli && dart pub publish`
|
||||||
|
|
||||||
|
Wait for each to appear on pub.dev before publishing the next (pub.dev propagation can take a few minutes).
|
||||||
|
|
||||||
|
After all four are published:
|
||||||
|
1. `git checkout main`
|
||||||
|
2. `git merge develop`
|
||||||
|
3. `git tag v1.0.0`
|
||||||
|
4. `git push origin main --tags`
|
||||||
|
5. Create GitHub release at https://github.com/artificery-dev/dew/releases with CHANGELOG.md 1.0.0 section as body.
|
||||||
|
|
||||||
|
Prerequisite: DEW-0027 (final validation) must be done first.
|
||||||
90
CHANGELOG.md
Normal file
90
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-04-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### `dew init`
|
||||||
|
|
||||||
|
- `dew init <path>` scaffolds a new project, generating `.project/dew.yaml` with
|
||||||
|
sensible defaults and running all module init hooks (kanban directory layout, etc.).
|
||||||
|
|
||||||
|
#### Kanban CLI (`dew kanban`)
|
||||||
|
|
||||||
|
Full set of kanban subcommands, each also registered as an MCP tool automatically:
|
||||||
|
|
||||||
|
| Subcommand | Description |
|
||||||
|
| ------------ | ------------------------------------------------------------------------ |
|
||||||
|
| `create` | Create a new ticket with title, type, column, body, labels, and milestones |
|
||||||
|
| `list` | List tickets filtered by column, type, label, milestone, or archived state |
|
||||||
|
| `get` | Fetch a single ticket by ID |
|
||||||
|
| `update` | Update any field on a ticket (title, type, column, body, labels, milestones) |
|
||||||
|
| `delete` | Permanently delete a ticket |
|
||||||
|
| `move` | Move a ticket to a different column (validates column transition rules) |
|
||||||
|
| `search` | Full-text search across ticket titles, bodies, and comments |
|
||||||
|
| `comment` | Append a comment to a ticket |
|
||||||
|
| `archive` | Soft-delete a ticket by moving it to the archive column |
|
||||||
|
| `unarchive` | Restore an archived ticket to a column |
|
||||||
|
| `link` | Create a typed bidirectional link between two tickets |
|
||||||
|
| `unlink` | Remove a link between two tickets |
|
||||||
|
| `stats` | Display ticket counts grouped by column and type |
|
||||||
|
| `board` | Print an ASCII representation of the board |
|
||||||
|
| `config` | Print the current kanban configuration |
|
||||||
|
| `tui` | Launch the interactive terminal UI |
|
||||||
|
|
||||||
|
- File-based storage: each ticket is a Markdown file with YAML frontmatter for
|
||||||
|
metadata and inline `---` separators for comments. Column is derived from the
|
||||||
|
containing subdirectory — not duplicated in the file.
|
||||||
|
- Typed, bidirectional ticket links: `blocks`/`is_blocked_by`, `relates_to`,
|
||||||
|
`duplicates`/`is_duplicated_by`, `parent_of`/`child_of`. Writing one side
|
||||||
|
automatically writes the inverse on the target ticket.
|
||||||
|
- Labels and milestones as first-class ticket fields.
|
||||||
|
- Column transition validation: enforces allowed moves between configured columns.
|
||||||
|
- `--include-archived` flag on `list` and `search` to include soft-deleted tickets.
|
||||||
|
|
||||||
|
#### Interactive TUI (`dew kanban tui`)
|
||||||
|
|
||||||
|
- Full Trello-style terminal board rendered with ANSI box-drawing characters.
|
||||||
|
- **Board mode**: pill/tab column headers, scrollable ticket list per column,
|
||||||
|
live `?` filter/search overlay, `n` create, `e` edit, `a` archive, `D` delete,
|
||||||
|
`c` comment, `L` link, `<`/`>` move ticket between columns, Enter open detail.
|
||||||
|
- **Detail mode**: scrollable ticket detail view with formatted body and comments.
|
||||||
|
- **Editor modal**: in-terminal overlay for editing all ticket fields — title,
|
||||||
|
type (picker), column (picker), labels, milestones, and body. Arrow keys cycle
|
||||||
|
selector values; Enter edits text fields or opens `$VISUAL`/`$EDITOR` for body.
|
||||||
|
- F1 help overlay in every mode showing context-sensitive keybindings.
|
||||||
|
- Auto-refresh via filesystem watching: board reloads live when ticket files
|
||||||
|
change on disk (e.g. from another terminal or AI agent).
|
||||||
|
- SIGWINCH handling for correct redraws on terminal resize.
|
||||||
|
|
||||||
|
#### MCP Server (`dew mcp serve`)
|
||||||
|
|
||||||
|
- `dew mcp serve` starts an MCP-compliant stdio server.
|
||||||
|
- All kanban commands that implement the `DewToolCommand` mixin are automatically
|
||||||
|
exposed as MCP tools — no separate registration required.
|
||||||
|
- JSON Schema for each tool's input is derived directly from the command's
|
||||||
|
`ArgParser`, so argument definitions are written exactly once.
|
||||||
|
- Compiled CLI binary at `.project/toolchain/bin/dew` used by the MCP server to
|
||||||
|
avoid stdin conflicts.
|
||||||
|
|
||||||
|
#### Core Architecture
|
||||||
|
|
||||||
|
- `DewToolCommand` mixin: mix into any `DewCommand` to register it simultaneously
|
||||||
|
as a CLI subcommand and an MCP tool.
|
||||||
|
- `CommandRegistry`: central registry collected at startup; MCP server reads from
|
||||||
|
this registry to enumerate available tools.
|
||||||
|
- `DewConfig` with typed extension pattern: `core` holds the raw YAML map; feature
|
||||||
|
packages (`kanban`, `mcp`) add typed accessors via Dart extensions, keeping
|
||||||
|
feature-specific config out of `core`.
|
||||||
|
- `ProjectDirs` with injectable filesystem abstraction (`package:file`) for
|
||||||
|
testable path resolution.
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/artificery-dev/dew/compare/v1.0.0...HEAD
|
||||||
|
[1.0.0]: https://github.com/artificery-dev/dew/releases/tag/v1.0.0
|
||||||
115
CONTRIBUTING.md
Normal file
115
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Contributing to Dew
|
||||||
|
|
||||||
|
Thank you for your interest in contributing! This guide covers everything you need to get set up and start making changes.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Dart SDK ^3.11.4** — verify with `dart --version`
|
||||||
|
- **Melos** (optional, for workspace scripts) — `dart pub global activate melos`
|
||||||
|
|
||||||
|
## Clone & setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/artificery-dev/dew.git
|
||||||
|
cd dew
|
||||||
|
dart pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the CLI locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart run packages/cli/bin/dew.dart kanban --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart test packages/core/test/
|
||||||
|
dart test packages/kanban/test/
|
||||||
|
dart test packages/mcp/test/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Linting and formatting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart analyze
|
||||||
|
dart format .
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix any analysis warnings before opening a PR. The project uses the rules defined in `analysis_options.yaml`.
|
||||||
|
|
||||||
|
## Branch strategy
|
||||||
|
|
||||||
|
| Branch | Purpose |
|
||||||
|
| --------- | ---------------------------------------------------------- |
|
||||||
|
| `main` | Stable, released code. Only merged into from `develop`. |
|
||||||
|
| `develop` | Integration branch. All PRs target this branch. |
|
||||||
|
| `feat/*` | Feature branches cut from `develop`. |
|
||||||
|
| `fix/*` | Bug-fix branches cut from `develop`. |
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
|
||||||
|
1. Cut a feature branch from `develop`: `git checkout -b feat/my-feature develop`
|
||||||
|
2. Make your changes, add tests, run `dart analyze && dart format .`
|
||||||
|
3. Open a PR targeting `develop`
|
||||||
|
4. Releases are prepared on `develop` and merged to `main`
|
||||||
|
|
||||||
|
## Adding a new kanban command
|
||||||
|
|
||||||
|
Follow these three steps to add a command that is simultaneously a CLI subcommand and an MCP tool:
|
||||||
|
|
||||||
|
### 1. Create the command class
|
||||||
|
|
||||||
|
Create `packages/kanban/lib/src/commands/my_command.dart` implementing `DewToolCommand`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:args/command_runner.dart';
|
||||||
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
|
||||||
|
class MyCommand extends DewCommand with DewToolCommand {
|
||||||
|
@override
|
||||||
|
final String name = 'my-command';
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String description = 'Does something useful.';
|
||||||
|
|
||||||
|
MyCommand() {
|
||||||
|
argParser.addOption('id', mandatory: true, help: 'Ticket ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> run() async {
|
||||||
|
final id = argResults!['id'] as String;
|
||||||
|
// implementation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `DewToolCommand` mixin automatically derives the MCP tool JSON Schema from
|
||||||
|
the `ArgParser` — no extra registration work needed.
|
||||||
|
|
||||||
|
### 2. Register in the kanban base
|
||||||
|
|
||||||
|
Add your command in `packages/kanban/lib/src/dew_kanban_base.dart` alongside the existing commands:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
addSubcommand(MyCommand());
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add tests
|
||||||
|
|
||||||
|
Add tests in `packages/kanban/test/my_command_test.dart`. Use the existing command tests as a reference for how to set up a `ProjectContext` with an in-memory filesystem.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart test packages/kanban/test/my_command_test.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/
|
||||||
|
├── cli/ — Entry point; wires all packages together
|
||||||
|
├── core/ — DewCommand, DewToolCommand, CommandRegistry, DewConfig
|
||||||
|
├── kanban/ — All kanban commands and storage logic
|
||||||
|
└── mcp/ — MCP server; reads tools from CommandRegistry
|
||||||
|
```
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 artificery-dev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
65
README.md
65
README.md
|
|
@ -1,36 +1,65 @@
|
||||||
# Dew Project Management Tool
|
# Dew
|
||||||
|
|
||||||
Dew is a project management tool built in Dart, designed to help developers organize and manage their projects efficiently. It provides a simple command-line interface for creating, managing, and tracking projects, tasks, and deadlines.
|
[](https://pub.dev/packages/dew)
|
||||||
|
|
||||||
|
**Dew** is a git-native project management CLI for developers who want a kanban board that lives in their repository and talks to AI agents. Tickets are plain Markdown files with YAML frontmatter — diff-friendly, grep-able, and version-controlled alongside your code.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### Kanban CLI
|
||||||
|
|
||||||
|
16 subcommands cover the full lifecycle of a ticket:
|
||||||
|
|
||||||
|
```
|
||||||
|
create list get update delete move
|
||||||
|
search comment archive unarchive link unlink
|
||||||
|
stats board config tui
|
||||||
|
```
|
||||||
|
|
||||||
|
Tickets are stored as `.project/kanban/<column>/<ID>.md` files. Labels, milestones, typed bidirectional links, and inline comments are all first-class citizens. See the [Kanban documentation](./docs/features/kanban.md) for the full command reference.
|
||||||
|
|
||||||
|
### Interactive TUI
|
||||||
|
|
||||||
|
`dew kanban tui` opens a full Trello-style terminal board with three modes:
|
||||||
|
|
||||||
|
- **Board** — navigate columns and tickets, create/edit/move/archive/delete, live search filter
|
||||||
|
- **Detail** — scrollable view of a ticket's body and comments
|
||||||
|
- **Editor** — in-terminal modal for editing all ticket fields; opens `$VISUAL`/`$EDITOR` for the body
|
||||||
|
|
||||||
|
The TUI auto-refreshes when ticket files change on disk, so it stays in sync whether you're editing files directly or an AI agent is updating them.
|
||||||
|
|
||||||
### MCP Server
|
### MCP Server
|
||||||
|
|
||||||
Dew includes an MCP server for AI agents to interact with your project. This allows you to integrate AI capabilities into your project management workflow, enabling features like automated task creation, progress tracking, and more. Read more about the MCP server in the [Dew MCP Feature Documentation](./docs/features/mcp.md).
|
`dew mcp serve` starts an MCP-compliant stdio server that exposes every kanban command as an MCP tool. AI agents (GitHub Copilot, Claude, etc.) can create tickets, move cards, search, and comment — using the exact same logic as the CLI. No separate tool definitions needed: every command that mixes in `DewToolCommand` is registered automatically. See the [MCP documentation](./docs/features/mcp.md).
|
||||||
|
|
||||||
### Kanban Board
|
## Installation
|
||||||
|
|
||||||
Dew includes a Kanban board feature that allows you to visualize your tasks and their progress. You can create columns for different stages of your workflow (e.g., To Do, In Progress, Done) and move tasks between them as you work on them. Read more about the Kanban board in the [Dew Kanban Feature Documentation](./docs/features/kanban.md).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Dew's configuration is managed with the [dew.yaml](.project/dew.yaml) file, which allows you to customize various aspects of the tool to fit your workflow. You can specify project settings, task templates, and other preferences in this file. Read more about configuring Dew in the [Dew Configuration Documentation](./docs/config.md).
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
To get started with Dew, follow these steps:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Activate the Dew CLI
|
|
||||||
dart pub global activate dew
|
dart pub global activate dew
|
||||||
```
|
```
|
||||||
|
|
||||||
Once you have the CLI installed, you can create a new project with:
|
Requires Dart SDK ^3.11.4.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Scaffold a new project (creates .project/dew.yaml)
|
||||||
dew init .
|
dew init .
|
||||||
|
|
||||||
|
# Create your first ticket
|
||||||
|
dew kanban create --title "My first ticket" --type task
|
||||||
|
|
||||||
|
# Open the interactive board
|
||||||
|
dew kanban tui
|
||||||
```
|
```
|
||||||
|
|
||||||
This will set up the necessary files and directories for your project. You can then start adding tasks and managing your project using the Dew CLI.
|
## Configuration
|
||||||
|
|
||||||
For more detailed instructions and documentation, please refer to the [Dew Documentation](./docs/index.md).
|
Dew reads `.project/dew.yaml` for board columns, ticket types, ID prefix, and MCP server settings. Running `dew init .` generates this file with defaults. See the [Configuration documentation](./docs/config.md) for the full schema reference.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Full documentation index](./docs/index.md)
|
||||||
|
- [Kanban board](./docs/features/kanban.md) — CLI commands, TUI keybindings, ticket format
|
||||||
|
- [MCP server](./docs/features/mcp.md) — AI agent integration
|
||||||
|
- [Configuration reference](./docs/config.md)
|
||||||
|
|
|
||||||
|
|
@ -51,20 +51,24 @@ Second comment.
|
||||||
|
|
||||||
All commands are available under `dew kanban <subcommand>`.
|
All commands are available under `dew kanban <subcommand>`.
|
||||||
|
|
||||||
| Subcommand | Description |
|
| Subcommand | Description |
|
||||||
| ---------- | --------------------------------------------------------------- |
|
| ------------- | ------------------------------------------------------------------------------ |
|
||||||
| `create` | Create a new ticket (`--title`, `--type`, `--column`, `--body`) |
|
| `create` | Create a new ticket (`--title`, `--type`, `--column`, `--body`) |
|
||||||
| `list` | List tickets (`--column`, `--type`) |
|
| `list` | List tickets (`--column`, `--type`, `--label`, `--milestone`, `--include-archived`) |
|
||||||
| `get` | Get a ticket by ID (`--id`) |
|
| `get` | Get a ticket by ID (`--id`) |
|
||||||
| `update` | Update fields on a ticket (`--id`, `--title`, `--type`, `--column`, `--body`) |
|
| `update` | Update fields on a ticket (`--id`, `--title`, `--type`, `--column`, `--body`) |
|
||||||
| `delete` | Delete a ticket permanently (`--id`) |
|
| `delete` | Delete a ticket permanently (`--id`) |
|
||||||
| `move` | Move a ticket to a different column (`--id`, `--column`) |
|
| `move` | Move a ticket to a different column (`--id`, `--column`) |
|
||||||
| `search` | Full-text search across all ticket content (`--query`) |
|
| `search` | Full-text search across all ticket content (`--query`, `--include-archived`) |
|
||||||
| `comment` | Append a comment to a ticket (`--id`, `--comment`) |
|
| `comment` | Append a comment to a ticket (`--id`, `--comment`) |
|
||||||
| `config` | Print the current kanban configuration |
|
| `archive` | Soft-delete a ticket by moving it to the archive column (`--id`) |
|
||||||
| `stats` | Show ticket counts by column and type |
|
| `unarchive` | Restore an archived ticket to a column (`--id`, `--column`) |
|
||||||
| `link` | Link two tickets with a typed relationship (`--id`, `--target`, `--type`) |
|
| `link` | Link two tickets with a typed relationship (`--id`, `--target`, `--type`) |
|
||||||
| `unlink` | Remove a link between two tickets (`--id`, `--target`) |
|
| `unlink` | Remove a link between two tickets (`--id`, `--target`) |
|
||||||
|
| `stats` | Show ticket counts by column and type |
|
||||||
|
| `board` | Print an ASCII representation of the board |
|
||||||
|
| `config` | Print the current kanban configuration |
|
||||||
|
| `tui` | Launch the interactive terminal UI |
|
||||||
|
|
||||||
## Ticket links
|
## Ticket links
|
||||||
|
|
||||||
|
|
@ -103,3 +107,74 @@ dew:
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [Configuration documentation](../config.md) for the full schema reference.
|
See the [Configuration documentation](../config.md) for the full schema reference.
|
||||||
|
|
||||||
|
## Interactive TUI
|
||||||
|
|
||||||
|
Launch the interactive terminal board with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dew kanban tui
|
||||||
|
```
|
||||||
|
|
||||||
|
The TUI has three modes. Press **F1** in any mode for a context-sensitive help overlay.
|
||||||
|
|
||||||
|
### Modes
|
||||||
|
|
||||||
|
| Mode | How to enter | How to leave |
|
||||||
|
| ---------- | ----------------------------------- | ------------------ |
|
||||||
|
| **Board** | Default on launch | `q` to quit |
|
||||||
|
| **Detail** | Press `Enter` on a ticket in Board | `b` or `Esc` |
|
||||||
|
| **Editor** | Press `e` in Board or Detail mode | `s` save / `Esc` discard |
|
||||||
|
|
||||||
|
### Keybinding reference
|
||||||
|
|
||||||
|
#### Board mode
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --------- | ----------------------------------------------- |
|
||||||
|
| `↑` / `↓` | Navigate tickets within the current column |
|
||||||
|
| `←` / `→` | Switch to the previous / next column |
|
||||||
|
| `<` / `>` | Move the selected ticket to the previous / next column |
|
||||||
|
| `Enter` | Open Detail view for the selected ticket |
|
||||||
|
| `n` | Create a new ticket (opens Editor) |
|
||||||
|
| `e` | Edit the selected ticket (opens Editor) |
|
||||||
|
| `a` | Archive the selected ticket |
|
||||||
|
| `D` | Delete the selected ticket (with confirmation) |
|
||||||
|
| `c` | Append a comment to the selected ticket |
|
||||||
|
| `L` | Link the selected ticket to another ticket |
|
||||||
|
| `?` | Open the live filter / search overlay |
|
||||||
|
| `F1` | Toggle the help overlay |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
|
#### Detail mode
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --------- | ----------------------------- |
|
||||||
|
| `↑` / `↓` | Scroll the ticket content |
|
||||||
|
| `e` | Edit the ticket (opens Editor) |
|
||||||
|
| `b` / `Esc` | Return to Board mode |
|
||||||
|
| `F1` | Toggle the help overlay |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
|
#### Editor mode
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --------- | ------------------------------------------------------------------------ |
|
||||||
|
| `↑` / `↓` | Move between fields |
|
||||||
|
| `←` / `→` | Cycle through selector values (type, column) |
|
||||||
|
| `Enter` | Edit the focused text field inline, or open `$VISUAL`/`$EDITOR` for body |
|
||||||
|
| `d` | Delete the current item (e.g. remove a label) |
|
||||||
|
| `s` | Save changes and return to the previous mode |
|
||||||
|
| `Esc` | Discard changes and return to the previous mode |
|
||||||
|
| `F1` | Toggle the help overlay |
|
||||||
|
|
||||||
|
> **Body editing:** pressing `Enter` on the body field suspends the TUI and opens
|
||||||
|
> `$VISUAL` or `$EDITOR` (falling back to `vi`) so you can write long-form content
|
||||||
|
> in your preferred editor. The TUI resumes once you save and exit.
|
||||||
|
|
||||||
|
### Auto-refresh
|
||||||
|
|
||||||
|
The board watches the `.project/kanban/` directory for file-system events and
|
||||||
|
reloads automatically when ticket files are created, modified, or deleted — so
|
||||||
|
the view stays live whether changes come from another terminal session, a
|
||||||
|
`git pull`, or an AI agent running `dew mcp serve`.
|
||||||
|
|
|
||||||
21
packages/cli/LICENSE
Normal file
21
packages/cli/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 artificery-dev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
name: dew
|
name: dew
|
||||||
description: Command-line interface for the Dew project management tool.
|
description: Command-line interface for the Dew project management tool.
|
||||||
version: 0.0.1
|
version: 1.0.0
|
||||||
# repository: https://github.com/my_org/my_repo
|
repository: https://github.com/artificery-dev/dew
|
||||||
publish_to: none
|
issue_tracker: https://github.com/artificery-dev/dew/issues
|
||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -10,12 +10,9 @@ environment:
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
args: ^2.7.0
|
args: ^2.7.0
|
||||||
dew_core:
|
dew_core: ^1.0.0
|
||||||
path: ../core
|
dew_kanban: ^1.0.0
|
||||||
dew_kanban:
|
dew_mcp: ^1.0.0
|
||||||
path: ../kanban
|
|
||||||
dew_mcp:
|
|
||||||
path: ../mcp
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: ^6.0.0
|
lints: ^6.0.0
|
||||||
|
|
|
||||||
55
packages/cli/test/cli_test.dart
Normal file
55
packages/cli/test/cli_test.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import 'package:args/command_runner.dart';
|
||||||
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
import 'package:dew_kanban/dew_kanban.dart' as kanban;
|
||||||
|
import 'package:dew_mcp/dew_mcp.dart' as mcp;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// Builds the same CommandRunner as bin/dew.dart without actually running it.
|
||||||
|
CommandRunner<void> buildRunner() {
|
||||||
|
final commandRegistry = CommandRegistry();
|
||||||
|
kanban.registerCommands(commandRegistry);
|
||||||
|
mcp.registerCommands(commandRegistry);
|
||||||
|
|
||||||
|
final runner = CommandRunner<void>('dew', 'A project management tool.');
|
||||||
|
runner.addCommand(InitCommand(commandRegistry.initHooks));
|
||||||
|
for (final command in commandRegistry.commands) {
|
||||||
|
runner.addCommand(command);
|
||||||
|
}
|
||||||
|
return runner;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CLI CommandRunner', () {
|
||||||
|
test('builds without throwing', () {
|
||||||
|
expect(buildRunner, returnsNormally);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has kanban, init, and mcp commands registered', () {
|
||||||
|
final runner = buildRunner();
|
||||||
|
expect(runner.commands.keys, containsAll(['kanban', 'init', 'mcp']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--help flag does not throw', () async {
|
||||||
|
final runner = buildRunner();
|
||||||
|
await expectLater(runner.run(['--help']), completes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown command throws UsageException', () async {
|
||||||
|
final runner = buildRunner();
|
||||||
|
await expectLater(
|
||||||
|
runner.run(['totally-unknown-command']),
|
||||||
|
throwsA(isA<UsageException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kanban subcommand --help does not throw', () async {
|
||||||
|
final runner = buildRunner();
|
||||||
|
await expectLater(runner.run(['help', 'kanban']), completes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mcp subcommand --help does not throw', () async {
|
||||||
|
final runner = buildRunner();
|
||||||
|
await expectLater(runner.run(['help', 'mcp']), completes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
21
packages/core/LICENSE
Normal file
21
packages/core/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 artificery-dev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -35,7 +35,11 @@ class ProjectContext {
|
||||||
final DewConfig config;
|
final DewConfig config;
|
||||||
final FileSystem fs;
|
final FileSystem fs;
|
||||||
|
|
||||||
const ProjectContext({required this.root, required this.config, required this.fs});
|
const ProjectContext({
|
||||||
|
required this.root,
|
||||||
|
required this.config,
|
||||||
|
required this.fs,
|
||||||
|
});
|
||||||
|
|
||||||
/// Typed path helpers for this project's well-known directories.
|
/// Typed path helpers for this project's well-known directories.
|
||||||
ProjectDirs get dirs => ProjectDirs(root);
|
ProjectDirs get dirs => ProjectDirs(root);
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,7 @@ class CommandRegistry {
|
||||||
collect(sub);
|
collect(sub);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final cmd in _commands) {
|
for (final cmd in _commands) {
|
||||||
collect(cmd);
|
collect(cmd);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,8 @@ class InitCommand extends Command<void> {
|
||||||
final List<DewInitHook> _hooks;
|
final List<DewInitHook> _hooks;
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
InitCommand(this._hooks, {FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
InitCommand(this._hooks, {FileSystem fs = const LocalFileSystem()})
|
||||||
|
: _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'path',
|
'path',
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
name: dew_core
|
name: dew_core
|
||||||
description: Core shared types, interfaces, and configuration for the Dew project management tool.
|
description: Core shared types, interfaces, and configuration for the Dew project management tool.
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
# repository: https://github.com/my_org/my_repo
|
repository: https://github.com/artificery-dev/dew
|
||||||
publish_to: none
|
issue_tracker: https://github.com/artificery-dev/dew/issues
|
||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
21
packages/kanban/LICENSE
Normal file
21
packages/kanban/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 artificery-dev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -10,8 +10,18 @@ class AddCommentCommand extends DewCommand with DewToolCommand {
|
||||||
|
|
||||||
AddCommentCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
AddCommentCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID (e.g. DEW-0001).')
|
..addOption(
|
||||||
..addOption('comment', abbr: 'm', mandatory: true, help: 'Comment text to append.');
|
'id',
|
||||||
|
abbr: 'i',
|
||||||
|
mandatory: true,
|
||||||
|
help: 'Ticket ID (e.g. DEW-0001).',
|
||||||
|
)
|
||||||
|
..addOption(
|
||||||
|
'comment',
|
||||||
|
abbr: 'm',
|
||||||
|
mandatory: true,
|
||||||
|
help: 'Comment text to append.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,20 @@ class ArchiveCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
ArchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
ArchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser.addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to archive.');
|
argParser.addOption(
|
||||||
|
'id',
|
||||||
|
abbr: 'i',
|
||||||
|
mandatory: true,
|
||||||
|
help: 'Ticket ID to archive.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String name = 'archive';
|
final String name = 'archive';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String description = 'Archive a ticket (moves it to the archive column).';
|
final String description =
|
||||||
|
'Archive a ticket (moves it to the archive column).';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String toolName = 'kanban_archive_ticket';
|
final String toolName = 'kanban_archive_ticket';
|
||||||
|
|
@ -30,7 +36,11 @@ class ArchiveCommand extends DewCommand with DewToolCommand {
|
||||||
final config = context.config.kanban;
|
final config = context.config.kanban;
|
||||||
final kanbanDir = context.dirs.kanban;
|
final kanbanDir = context.dirs.kanban;
|
||||||
|
|
||||||
final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix, fs: context.fs);
|
final store = TicketStore(
|
||||||
|
kanbanDir: kanbanDir,
|
||||||
|
prefix: config.prefix,
|
||||||
|
fs: context.fs,
|
||||||
|
);
|
||||||
final ticket = await store.findById(id);
|
final ticket = await store.findById(id);
|
||||||
if (ticket == null) throw ArgumentError('Ticket $id not found.');
|
if (ticket == null) throw ArgumentError('Ticket $id not found.');
|
||||||
if (ticket.column == 'archive') return '$id is already archived.';
|
if (ticket.column == 'archive') return '$id is already archived.';
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,9 @@ class BoardCommand extends DewCommand with DewToolCommand {
|
||||||
tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList();
|
tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList();
|
||||||
}
|
}
|
||||||
if (milestoneFilter != null) {
|
if (milestoneFilter != null) {
|
||||||
tickets = tickets.where((t) => t.milestones.contains(milestoneFilter)).toList();
|
tickets = tickets
|
||||||
|
.where((t) => t.milestones.contains(milestoneFilter))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index by column, preserving config order.
|
// Index by column, preserving config order.
|
||||||
|
|
@ -80,8 +82,10 @@ class BoardCommand extends DewCommand with DewToolCommand {
|
||||||
final col = entry.key;
|
final col = entry.key;
|
||||||
final header = ' $col (${entry.value.length}) ';
|
final header = ' $col (${entry.value.length}) ';
|
||||||
final colLines = lines[col]!;
|
final colLines = lines[col]!;
|
||||||
final contentWidth = [header.length, ...colLines.map((l) => l.length + 2)]
|
final contentWidth = [
|
||||||
.reduce((a, b) => a > b ? a : b);
|
header.length,
|
||||||
|
...colLines.map((l) => l.length + 2),
|
||||||
|
].reduce((a, b) => a > b ? a : b);
|
||||||
final divider = '─' * contentWidth;
|
final divider = '─' * contentWidth;
|
||||||
buf.writeln('┌$divider┐');
|
buf.writeln('┌$divider┐');
|
||||||
buf.writeln('│${header.padRight(contentWidth)}│');
|
buf.writeln('│${header.padRight(contentWidth)}│');
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ class GetConfigCommand extends DewCommand with DewToolCommand {
|
||||||
final String name = 'config';
|
final String name = 'config';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String description = 'Show the kanban configuration (columns and ticket types).';
|
final String description =
|
||||||
|
'Show the kanban configuration (columns and ticket types).';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String toolName = 'kanban_get_config';
|
final String toolName = 'kanban_get_config';
|
||||||
|
|
@ -22,7 +23,9 @@ class GetConfigCommand extends DewCommand with DewToolCommand {
|
||||||
final config = context.config.kanban;
|
final config = context.config.kanban;
|
||||||
|
|
||||||
final columns = config.columns.map((c) => '${c.id} (${c.name})').join(', ');
|
final columns = config.columns.map((c) => '${c.id} (${c.name})').join(', ');
|
||||||
final types = config.ticketTypes.map((t) => '${t.id} (${t.name})').join(', ');
|
final types = config.ticketTypes
|
||||||
|
.map((t) => '${t.id} (${t.name})')
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
return 'Columns: $columns\nTypes: $types';
|
return 'Columns: $columns\nTypes: $types';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,12 @@ class LinkCommand extends DewCommand with DewToolCommand {
|
||||||
LinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
LinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
|
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
|
||||||
..addOption('target', abbr: 't', mandatory: true, help: 'Target ticket ID.')
|
..addOption(
|
||||||
|
'target',
|
||||||
|
abbr: 't',
|
||||||
|
mandatory: true,
|
||||||
|
help: 'Target ticket ID.',
|
||||||
|
)
|
||||||
..addOption(
|
..addOption(
|
||||||
'type',
|
'type',
|
||||||
abbr: 'y',
|
abbr: 'y',
|
||||||
|
|
@ -39,7 +44,8 @@ class LinkCommand extends DewCommand with DewToolCommand {
|
||||||
final targetId = (args['target'] as String).toUpperCase();
|
final targetId = (args['target'] as String).toUpperCase();
|
||||||
final type = args['type'] as String;
|
final type = args['type'] as String;
|
||||||
|
|
||||||
if (id == targetId) throw ArgumentError('A ticket cannot be linked to itself.');
|
if (id == targetId)
|
||||||
|
throw ArgumentError('A ticket cannot be linked to itself.');
|
||||||
|
|
||||||
final context = await ProjectContext.find(fs: _fs);
|
final context = await ProjectContext.find(fs: _fs);
|
||||||
final store = TicketStore(
|
final store = TicketStore(
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,27 @@ class ListCommand extends DewCommand with DewToolCommand {
|
||||||
|
|
||||||
ListCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
ListCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption('column', abbr: 'c', help: 'Filter to tickets in this column.')
|
..addOption(
|
||||||
|
'column',
|
||||||
|
abbr: 'c',
|
||||||
|
help: 'Filter to tickets in this column.',
|
||||||
|
)
|
||||||
..addOption('type', abbr: 't', help: 'Filter to tickets of this type.')
|
..addOption('type', abbr: 't', help: 'Filter to tickets of this type.')
|
||||||
..addOption('label', help: 'Filter to tickets with this label.')
|
..addOption('label', help: 'Filter to tickets with this label.')
|
||||||
..addOption('milestone', help: 'Filter to tickets in this milestone.')
|
..addOption('milestone', help: 'Filter to tickets in this milestone.')
|
||||||
..addFlag('include-archived', help: 'Include archived tickets.', negatable: false);
|
..addFlag(
|
||||||
|
'include-archived',
|
||||||
|
help: 'Include archived tickets.',
|
||||||
|
negatable: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String name = 'list';
|
final String name = 'list';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String description = 'List kanban tickets, optionally filtered by column or type.';
|
final String description =
|
||||||
|
'List kanban tickets, optionally filtered by column or type.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String toolName = 'kanban_list_tickets';
|
final String toolName = 'kanban_list_tickets';
|
||||||
|
|
@ -53,7 +62,9 @@ class ListCommand extends DewCommand with DewToolCommand {
|
||||||
tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList();
|
tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList();
|
||||||
}
|
}
|
||||||
if (milestoneFilter != null) {
|
if (milestoneFilter != null) {
|
||||||
tickets = tickets.where((t) => t.milestones.contains(milestoneFilter)).toList();
|
tickets = tickets
|
||||||
|
.where((t) => t.milestones.contains(milestoneFilter))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tickets.isEmpty) return 'No tickets found.';
|
if (tickets.isEmpty) return 'No tickets found.';
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,20 @@ class MoveCommand extends DewCommand with DewToolCommand {
|
||||||
MoveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
MoveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.')
|
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.')
|
||||||
..addOption('column', abbr: 'c', mandatory: true, help: 'Target column ID.');
|
..addOption(
|
||||||
|
'column',
|
||||||
|
abbr: 'c',
|
||||||
|
mandatory: true,
|
||||||
|
help: 'Target column ID.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String name = 'move';
|
final String name = 'move';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String description = 'Move a ticket to a different column (validates against config).';
|
final String description =
|
||||||
|
'Move a ticket to a different column (validates against config).';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String toolName = 'kanban_move_ticket';
|
final String toolName = 'kanban_move_ticket';
|
||||||
|
|
@ -50,7 +56,8 @@ class MoveCommand extends DewCommand with DewToolCommand {
|
||||||
// Check allowed_transitions if configured on the current column.
|
// Check allowed_transitions if configured on the current column.
|
||||||
final currentColConfig = config.columns.firstWhere(
|
final currentColConfig = config.columns.firstWhere(
|
||||||
(c) => c.id == ticket.column,
|
(c) => c.id == ticket.column,
|
||||||
orElse: () => ColumnConfig(id: ticket.column, name: ticket.column, color: ''),
|
orElse: () =>
|
||||||
|
ColumnConfig(id: ticket.column, name: ticket.column, color: ''),
|
||||||
);
|
);
|
||||||
if (currentColConfig.allowedTransitions.isNotEmpty &&
|
if (currentColConfig.allowedTransitions.isNotEmpty &&
|
||||||
!currentColConfig.allowedTransitions.contains(column)) {
|
!currentColConfig.allowedTransitions.contains(column)) {
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,29 @@ class SearchCommand extends DewCommand with DewToolCommand {
|
||||||
help: 'Search query (matches title, body, and comments).',
|
help: 'Search query (matches title, body, and comments).',
|
||||||
)
|
)
|
||||||
..addOption('column', abbr: 'c', help: 'Restrict search to this column.')
|
..addOption('column', abbr: 'c', help: 'Restrict search to this column.')
|
||||||
..addOption('type', abbr: 't', help: 'Restrict search to this ticket type.')
|
..addOption(
|
||||||
|
'type',
|
||||||
|
abbr: 't',
|
||||||
|
help: 'Restrict search to this ticket type.',
|
||||||
|
)
|
||||||
..addOption('label', help: 'Restrict search to tickets with this label.')
|
..addOption('label', help: 'Restrict search to tickets with this label.')
|
||||||
..addOption('milestone', help: 'Restrict search to tickets in this milestone.')
|
..addOption(
|
||||||
..addFlag('include-archived', help: 'Include archived tickets.', negatable: false);
|
'milestone',
|
||||||
|
help: 'Restrict search to tickets in this milestone.',
|
||||||
|
)
|
||||||
|
..addFlag(
|
||||||
|
'include-archived',
|
||||||
|
help: 'Include archived tickets.',
|
||||||
|
negatable: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String name = 'search';
|
final String name = 'search';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String description = 'Search tickets by text across title, body, and comments.';
|
final String description =
|
||||||
|
'Search tickets by text across title, body, and comments.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String toolName = 'kanban_search_tickets';
|
final String toolName = 'kanban_search_tickets';
|
||||||
|
|
@ -59,7 +71,9 @@ class SearchCommand extends DewCommand with DewToolCommand {
|
||||||
tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList();
|
tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList();
|
||||||
}
|
}
|
||||||
if (milestoneFilter != null) {
|
if (milestoneFilter != null) {
|
||||||
tickets = tickets.where((t) => t.milestones.contains(milestoneFilter)).toList();
|
tickets = tickets
|
||||||
|
.where((t) => t.milestones.contains(milestoneFilter))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
final matches = tickets.where((t) {
|
final matches = tickets.where((t) {
|
||||||
|
|
|
||||||
|
|
@ -30,21 +30,34 @@ final class _TuiRefresh extends _TuiEvent {
|
||||||
|
|
||||||
// ── Inline prompt ────────────────────────────────────────────────────────────
|
// ── Inline prompt ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
enum _PromptKind { newTitle, editTitle, addComment, archiveConfirm, deleteConfirm, linkId, linkType }
|
enum _PromptKind {
|
||||||
|
newTitle,
|
||||||
|
editTitle,
|
||||||
|
addComment,
|
||||||
|
archiveConfirm,
|
||||||
|
deleteConfirm,
|
||||||
|
linkId,
|
||||||
|
linkType,
|
||||||
|
}
|
||||||
|
|
||||||
const _linkRelations = [
|
const _linkRelations = [
|
||||||
'blocks', 'is_blocked_by', 'relates_to',
|
'blocks',
|
||||||
'parent_of', 'child_of', 'duplicates', 'is_duplicated_by',
|
'is_blocked_by',
|
||||||
|
'relates_to',
|
||||||
|
'parent_of',
|
||||||
|
'child_of',
|
||||||
|
'duplicates',
|
||||||
|
'is_duplicated_by',
|
||||||
];
|
];
|
||||||
|
|
||||||
class _Prompt {
|
class _Prompt {
|
||||||
final _PromptKind kind;
|
final _PromptKind kind;
|
||||||
final String? ticketId;
|
final String? ticketId;
|
||||||
String input;
|
String input = '';
|
||||||
int typeIdx; // newTitle: selected ticket type index
|
int typeIdx = 0; // newTitle: selected ticket type index
|
||||||
int relationIdx; // linkType: selected relation index
|
int relationIdx = 0; // linkType: selected relation index
|
||||||
String? linkTargetId; // linkType: resolved target ticket id
|
String? linkTargetId; // linkType: resolved target ticket id
|
||||||
_Prompt(this.kind, {this.input = '', this.ticketId, this.typeIdx = 0, this.relationIdx = 0, this.linkTargetId});
|
_Prompt(this.kind, {this.ticketId, this.linkTargetId});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ticket editor ─────────────────────────────────────────────────────────────
|
// ── Ticket editor ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -65,17 +78,17 @@ class _EditorState {
|
||||||
String textInput;
|
String textInput;
|
||||||
|
|
||||||
_EditorState.from(Ticket t)
|
_EditorState.from(Ticket t)
|
||||||
: ticket = t,
|
: ticket = t,
|
||||||
title = t.title,
|
title = t.title,
|
||||||
type = t.type,
|
type = t.type,
|
||||||
column = t.column,
|
column = t.column,
|
||||||
labels = [...t.labels],
|
labels = [...t.labels],
|
||||||
milestones = [...t.milestones],
|
milestones = [...t.milestones],
|
||||||
body = t.body,
|
body = t.body,
|
||||||
focus = _EditorField.title,
|
focus = _EditorField.title,
|
||||||
itemCursor = 0,
|
itemCursor = 0,
|
||||||
textEditing = false,
|
textEditing = false,
|
||||||
textInput = '';
|
textInput = '';
|
||||||
|
|
||||||
bool get isDirty =>
|
bool get isDirty =>
|
||||||
title != ticket.title ||
|
title != ticket.title ||
|
||||||
|
|
@ -259,7 +272,13 @@ class TuiCommand extends DewCommand {
|
||||||
if (mode == _Mode.help) {
|
if (mode == _Mode.help) {
|
||||||
_renderHelp(console: console, w: w, h: h);
|
_renderHelp(console: console, w: w, h: h);
|
||||||
} else if (mode == _Mode.editor && editorState != null) {
|
} else if (mode == _Mode.editor && editorState != null) {
|
||||||
_renderEditor(console: console, config: config, es: editorState, w: w, h: h);
|
_renderEditor(
|
||||||
|
console: console,
|
||||||
|
config: config,
|
||||||
|
es: editorState,
|
||||||
|
w: w,
|
||||||
|
h: h,
|
||||||
|
);
|
||||||
} else if (mode == _Mode.board) {
|
} else if (mode == _Mode.board) {
|
||||||
_renderBoard(
|
_renderBoard(
|
||||||
console: console,
|
console: console,
|
||||||
|
|
@ -347,7 +366,8 @@ class TuiCommand extends DewCommand {
|
||||||
// ── Prompt mode (inline action input) ─────────────────────────────
|
// ── Prompt mode (inline action input) ─────────────────────────────
|
||||||
if (prompt != null) {
|
if (prompt != null) {
|
||||||
final p = prompt;
|
final p = prompt;
|
||||||
if (p.kind == _PromptKind.archiveConfirm || p.kind == _PromptKind.deleteConfirm) {
|
if (p.kind == _PromptKind.archiveConfirm ||
|
||||||
|
p.kind == _PromptKind.deleteConfirm) {
|
||||||
if (!key.isControl) {
|
if (!key.isControl) {
|
||||||
if (key.char == 'y' || key.char == 'Y') {
|
if (key.char == 'y' || key.char == 'Y') {
|
||||||
try {
|
try {
|
||||||
|
|
@ -361,7 +381,10 @@ class TuiCommand extends DewCommand {
|
||||||
tickets = await store.list();
|
tickets = await store.list();
|
||||||
byColumn = _groupByColumn(tickets, config);
|
byColumn = _groupByColumn(tickets, config);
|
||||||
final col = config.columns[colIdx];
|
final col = config.columns[colIdx];
|
||||||
final remaining = _filtered(byColumn[col.id] ?? [], searchQuery);
|
final remaining = _filtered(
|
||||||
|
byColumn[col.id] ?? [],
|
||||||
|
searchQuery,
|
||||||
|
);
|
||||||
ticketIdx = ticketIdx.clamp(0, max(0, remaining.length - 1));
|
ticketIdx = ticketIdx.clamp(0, max(0, remaining.length - 1));
|
||||||
} on ArgumentError catch (e) {
|
} on ArgumentError catch (e) {
|
||||||
statusMsg = 'Error: ${e.message ?? e}';
|
statusMsg = 'Error: ${e.message ?? e}';
|
||||||
|
|
@ -387,13 +410,19 @@ class TuiCommand extends DewCommand {
|
||||||
case ControlCharacter.arrowLeft:
|
case ControlCharacter.arrowLeft:
|
||||||
if (p.relationIdx > 0) p.relationIdx--;
|
if (p.relationIdx > 0) p.relationIdx--;
|
||||||
case ControlCharacter.arrowRight:
|
case ControlCharacter.arrowRight:
|
||||||
if (p.relationIdx < _linkRelations.length - 1) p.relationIdx++;
|
if (p.relationIdx < _linkRelations.length - 1)
|
||||||
|
p.relationIdx++;
|
||||||
case ControlCharacter.enter:
|
case ControlCharacter.enter:
|
||||||
try {
|
try {
|
||||||
await store.linkTickets(p.ticketId!, p.linkTargetId!, _linkRelations[p.relationIdx]);
|
await store.linkTickets(
|
||||||
|
p.ticketId!,
|
||||||
|
p.linkTargetId!,
|
||||||
|
_linkRelations[p.relationIdx],
|
||||||
|
);
|
||||||
tickets = await store.list();
|
tickets = await store.list();
|
||||||
byColumn = _groupByColumn(tickets, config);
|
byColumn = _groupByColumn(tickets, config);
|
||||||
statusMsg = 'Linked ${p.ticketId} → ${p.linkTargetId} (${_linkRelations[p.relationIdx]}).';
|
statusMsg =
|
||||||
|
'Linked ${p.ticketId} → ${p.linkTargetId} (${_linkRelations[p.relationIdx]}).';
|
||||||
} on ArgumentError catch (e) {
|
} on ArgumentError catch (e) {
|
||||||
statusMsg = 'Error: ${e.message ?? e}';
|
statusMsg = 'Error: ${e.message ?? e}';
|
||||||
}
|
}
|
||||||
|
|
@ -415,9 +444,12 @@ class TuiCommand extends DewCommand {
|
||||||
p.input = p.input.substring(0, p.input.length - 1);
|
p.input = p.input.substring(0, p.input.length - 1);
|
||||||
}
|
}
|
||||||
case ControlCharacter.arrowLeft:
|
case ControlCharacter.arrowLeft:
|
||||||
if (p.kind == _PromptKind.newTitle && p.typeIdx > 0) p.typeIdx--;
|
if (p.kind == _PromptKind.newTitle && p.typeIdx > 0)
|
||||||
|
p.typeIdx--;
|
||||||
case ControlCharacter.arrowRight:
|
case ControlCharacter.arrowRight:
|
||||||
if (p.kind == _PromptKind.newTitle && p.typeIdx < config.ticketTypes.length - 1) p.typeIdx++;
|
if (p.kind == _PromptKind.newTitle &&
|
||||||
|
p.typeIdx < config.ticketTypes.length - 1)
|
||||||
|
p.typeIdx++;
|
||||||
case ControlCharacter.enter:
|
case ControlCharacter.enter:
|
||||||
final trimmed = p.input.trim();
|
final trimmed = p.input.trim();
|
||||||
if (p.kind == _PromptKind.linkId) {
|
if (p.kind == _PromptKind.linkId) {
|
||||||
|
|
@ -426,7 +458,11 @@ class TuiCommand extends DewCommand {
|
||||||
if (!exists || trimmed.isEmpty) {
|
if (!exists || trimmed.isEmpty) {
|
||||||
statusMsg = 'Ticket "$trimmed" not found.';
|
statusMsg = 'Ticket "$trimmed" not found.';
|
||||||
} else {
|
} else {
|
||||||
prompt = _Prompt(_PromptKind.linkType, ticketId: p.ticketId, linkTargetId: trimmed);
|
prompt = _Prompt(
|
||||||
|
_PromptKind.linkType,
|
||||||
|
ticketId: p.ticketId,
|
||||||
|
linkTargetId: trimmed,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (trimmed.isEmpty) {
|
} else if (trimmed.isEmpty) {
|
||||||
prompt = null;
|
prompt = null;
|
||||||
|
|
@ -438,10 +474,17 @@ class TuiCommand extends DewCommand {
|
||||||
final type = config.ticketTypes.isNotEmpty
|
final type = config.ticketTypes.isNotEmpty
|
||||||
? config.ticketTypes[p.typeIdx].id
|
? config.ticketTypes[p.typeIdx].id
|
||||||
: 'task';
|
: 'task';
|
||||||
await store.create(title: trimmed, type: type, column: col.id);
|
await store.create(
|
||||||
|
title: trimmed,
|
||||||
|
type: type,
|
||||||
|
column: col.id,
|
||||||
|
);
|
||||||
tickets = await store.list();
|
tickets = await store.list();
|
||||||
byColumn = _groupByColumn(tickets, config);
|
byColumn = _groupByColumn(tickets, config);
|
||||||
final created = _filtered(byColumn[col.id] ?? [], searchQuery);
|
final created = _filtered(
|
||||||
|
byColumn[col.id] ?? [],
|
||||||
|
searchQuery,
|
||||||
|
);
|
||||||
ticketIdx = max(0, created.length - 1);
|
ticketIdx = max(0, created.length - 1);
|
||||||
statusMsg = 'Created in ${col.name}.';
|
statusMsg = 'Created in ${col.name}.';
|
||||||
case _PromptKind.editTitle:
|
case _PromptKind.editTitle:
|
||||||
|
|
@ -489,7 +532,10 @@ class TuiCommand extends DewCommand {
|
||||||
ticketIdx = 0;
|
ticketIdx = 0;
|
||||||
case ControlCharacter.backspace:
|
case ControlCharacter.backspace:
|
||||||
if (searchQuery.isNotEmpty) {
|
if (searchQuery.isNotEmpty) {
|
||||||
searchQuery = searchQuery.substring(0, searchQuery.length - 1);
|
searchQuery = searchQuery.substring(
|
||||||
|
0,
|
||||||
|
searchQuery.length - 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
continue loop; // skip redraw
|
continue loop; // skip redraw
|
||||||
|
|
@ -542,7 +588,10 @@ class TuiCommand extends DewCommand {
|
||||||
es.textInput = '';
|
es.textInput = '';
|
||||||
case ControlCharacter.backspace:
|
case ControlCharacter.backspace:
|
||||||
if (es.textInput.isNotEmpty) {
|
if (es.textInput.isNotEmpty) {
|
||||||
es.textInput = es.textInput.substring(0, es.textInput.length - 1);
|
es.textInput = es.textInput.substring(
|
||||||
|
0,
|
||||||
|
es.textInput.length - 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
continue loop;
|
continue loop;
|
||||||
|
|
@ -564,13 +613,23 @@ class TuiCommand extends DewCommand {
|
||||||
switch (es.focus) {
|
switch (es.focus) {
|
||||||
case _EditorField.labels:
|
case _EditorField.labels:
|
||||||
if (es.labels.isNotEmpty) {
|
if (es.labels.isNotEmpty) {
|
||||||
es.labels.removeAt(es.itemCursor.clamp(0, es.labels.length - 1));
|
es.labels.removeAt(
|
||||||
es.itemCursor = es.itemCursor.clamp(0, max(0, es.labels.length - 1));
|
es.itemCursor.clamp(0, es.labels.length - 1),
|
||||||
|
);
|
||||||
|
es.itemCursor = es.itemCursor.clamp(
|
||||||
|
0,
|
||||||
|
max(0, es.labels.length - 1),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
case _EditorField.milestones:
|
case _EditorField.milestones:
|
||||||
if (es.milestones.isNotEmpty) {
|
if (es.milestones.isNotEmpty) {
|
||||||
es.milestones.removeAt(es.itemCursor.clamp(0, es.milestones.length - 1));
|
es.milestones.removeAt(
|
||||||
es.itemCursor = es.itemCursor.clamp(0, max(0, es.milestones.length - 1));
|
es.itemCursor.clamp(0, es.milestones.length - 1),
|
||||||
|
);
|
||||||
|
es.itemCursor = es.itemCursor.clamp(
|
||||||
|
0,
|
||||||
|
max(0, es.milestones.length - 1),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|
@ -593,7 +652,10 @@ class TuiCommand extends DewCommand {
|
||||||
colIdx = config.columns.indexWhere((c) => c.id == es.column);
|
colIdx = config.columns.indexWhere((c) => c.id == es.column);
|
||||||
if (colIdx < 0) colIdx = 0;
|
if (colIdx < 0) colIdx = 0;
|
||||||
final destTickets = byColumn[es.column] ?? [];
|
final destTickets = byColumn[es.column] ?? [];
|
||||||
ticketIdx = max(0, destTickets.indexWhere((x) => x.id == es.ticket.id));
|
ticketIdx = max(
|
||||||
|
0,
|
||||||
|
destTickets.indexWhere((x) => x.id == es.ticket.id),
|
||||||
|
);
|
||||||
statusMsg = 'Ticket updated.';
|
statusMsg = 'Ticket updated.';
|
||||||
} on ArgumentError catch (e) {
|
} on ArgumentError catch (e) {
|
||||||
statusMsg = 'Error: ${e.message ?? e}';
|
statusMsg = 'Error: ${e.message ?? e}';
|
||||||
|
|
@ -614,14 +676,15 @@ class TuiCommand extends DewCommand {
|
||||||
editorState = null;
|
editorState = null;
|
||||||
mode = _Mode.board;
|
mode = _Mode.board;
|
||||||
case ControlCharacter.arrowUp:
|
case ControlCharacter.arrowUp:
|
||||||
es.focus = _EditorField.values[
|
es.focus =
|
||||||
(es.focus.index - 1 + _EditorField.values.length) % _EditorField.values.length
|
_EditorField.values[(es.focus.index -
|
||||||
];
|
1 +
|
||||||
|
_EditorField.values.length) %
|
||||||
|
_EditorField.values.length];
|
||||||
es.itemCursor = 0;
|
es.itemCursor = 0;
|
||||||
case ControlCharacter.arrowDown:
|
case ControlCharacter.arrowDown:
|
||||||
es.focus = _EditorField.values[
|
es.focus = _EditorField
|
||||||
(es.focus.index + 1) % _EditorField.values.length
|
.values[(es.focus.index + 1) % _EditorField.values.length];
|
||||||
];
|
|
||||||
es.itemCursor = 0;
|
es.itemCursor = 0;
|
||||||
case ControlCharacter.arrowLeft:
|
case ControlCharacter.arrowLeft:
|
||||||
switch (es.focus) {
|
switch (es.focus) {
|
||||||
|
|
@ -649,7 +712,8 @@ class TuiCommand extends DewCommand {
|
||||||
case _EditorField.labels:
|
case _EditorField.labels:
|
||||||
if (es.itemCursor < es.labels.length - 1) es.itemCursor++;
|
if (es.itemCursor < es.labels.length - 1) es.itemCursor++;
|
||||||
case _EditorField.milestones:
|
case _EditorField.milestones:
|
||||||
if (es.itemCursor < es.milestones.length - 1) es.itemCursor++;
|
if (es.itemCursor < es.milestones.length - 1)
|
||||||
|
es.itemCursor++;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -664,7 +728,8 @@ class TuiCommand extends DewCommand {
|
||||||
es.textEditing = true;
|
es.textEditing = true;
|
||||||
case _EditorField.body:
|
case _EditorField.body:
|
||||||
// Launch external editor
|
// Launch external editor
|
||||||
final editor = io.Platform.environment['VISUAL'] ??
|
final editor =
|
||||||
|
io.Platform.environment['VISUAL'] ??
|
||||||
io.Platform.environment['EDITOR'] ??
|
io.Platform.environment['EDITOR'] ??
|
||||||
'vi';
|
'vi';
|
||||||
final tmpFile = io.File(
|
final tmpFile = io.File(
|
||||||
|
|
@ -675,11 +740,9 @@ class TuiCommand extends DewCommand {
|
||||||
console.rawMode = false;
|
console.rawMode = false;
|
||||||
console.showCursor();
|
console.showCursor();
|
||||||
console.clearScreen();
|
console.clearScreen();
|
||||||
final proc = await io.Process.start(
|
final proc = await io.Process.start(editor, [
|
||||||
editor,
|
tmpFile.path,
|
||||||
[tmpFile.path],
|
], mode: io.ProcessStartMode.inheritStdio);
|
||||||
mode: io.ProcessStartMode.inheritStdio,
|
|
||||||
);
|
|
||||||
await proc.exitCode;
|
await proc.exitCode;
|
||||||
es.body = await tmpFile.readAsString();
|
es.body = await tmpFile.readAsString();
|
||||||
await tmpFile.delete();
|
await tmpFile.delete();
|
||||||
|
|
@ -726,13 +789,15 @@ class TuiCommand extends DewCommand {
|
||||||
colIdx--;
|
colIdx--;
|
||||||
final nt = _filtered(byColumn[newColId] ?? [], searchQuery);
|
final nt = _filtered(byColumn[newColId] ?? [], searchQuery);
|
||||||
ticketIdx = max(0, nt.indexWhere((x) => x.id == t.id));
|
ticketIdx = max(0, nt.indexWhere((x) => x.id == t.id));
|
||||||
statusMsg = 'Moved ${t.id} → ${config.columns[colIdx].name}';
|
statusMsg =
|
||||||
|
'Moved ${t.id} → ${config.columns[colIdx].name}';
|
||||||
} on ArgumentError catch (e) {
|
} on ArgumentError catch (e) {
|
||||||
statusMsg = 'Error: ${e.message ?? e}';
|
statusMsg = 'Error: ${e.message ?? e}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case '>':
|
case '>':
|
||||||
if (colIdx < config.columns.length - 1 && colTickets.isNotEmpty) {
|
if (colIdx < config.columns.length - 1 &&
|
||||||
|
colTickets.isNotEmpty) {
|
||||||
final t = colTickets[ticketIdx];
|
final t = colTickets[ticketIdx];
|
||||||
final newColId = config.columns[colIdx + 1].id;
|
final newColId = config.columns[colIdx + 1].id;
|
||||||
try {
|
try {
|
||||||
|
|
@ -742,7 +807,8 @@ class TuiCommand extends DewCommand {
|
||||||
colIdx++;
|
colIdx++;
|
||||||
final nt = _filtered(byColumn[newColId] ?? [], searchQuery);
|
final nt = _filtered(byColumn[newColId] ?? [], searchQuery);
|
||||||
ticketIdx = max(0, nt.indexWhere((x) => x.id == t.id));
|
ticketIdx = max(0, nt.indexWhere((x) => x.id == t.id));
|
||||||
statusMsg = 'Moved ${t.id} → ${config.columns[colIdx].name}';
|
statusMsg =
|
||||||
|
'Moved ${t.id} → ${config.columns[colIdx].name}';
|
||||||
} on ArgumentError catch (e) {
|
} on ArgumentError catch (e) {
|
||||||
statusMsg = 'Error: ${e.message ?? e}';
|
statusMsg = 'Error: ${e.message ?? e}';
|
||||||
}
|
}
|
||||||
|
|
@ -831,7 +897,10 @@ class TuiCommand extends DewCommand {
|
||||||
detailScroll = 0;
|
detailScroll = 0;
|
||||||
case 'e':
|
case 'e':
|
||||||
final col2 = config.columns[colIdx];
|
final col2 = config.columns[colIdx];
|
||||||
final colTickets2 = _filtered(byColumn[col2.id] ?? [], searchQuery);
|
final colTickets2 = _filtered(
|
||||||
|
byColumn[col2.id] ?? [],
|
||||||
|
searchQuery,
|
||||||
|
);
|
||||||
if (colTickets2.isNotEmpty) {
|
if (colTickets2.isNotEmpty) {
|
||||||
editorState = _EditorState.from(
|
editorState = _EditorState.from(
|
||||||
colTickets2[ticketIdx.clamp(0, colTickets2.length - 1)],
|
colTickets2[ticketIdx.clamp(0, colTickets2.length - 1)],
|
||||||
|
|
@ -898,7 +967,10 @@ class TuiCommand extends DewCommand {
|
||||||
|
|
||||||
final numCols = config.columns.length;
|
final numCols = config.columns.length;
|
||||||
// How many columns fit side-by-side?
|
// How many columns fit side-by-side?
|
||||||
final numVisible = max(1, min(numCols, (w + _colSep) ~/ (_minColW + _colSep)));
|
final numVisible = max(
|
||||||
|
1,
|
||||||
|
min(numCols, (w + _colSep) ~/ (_minColW + _colSep)),
|
||||||
|
);
|
||||||
final colW = (w - (numVisible - 1) * _colSep) ~/ numVisible;
|
final colW = (w - (numVisible - 1) * _colSep) ~/ numVisible;
|
||||||
|
|
||||||
// Keep selected column in viewport
|
// Keep selected column in viewport
|
||||||
|
|
@ -908,7 +980,10 @@ class TuiCommand extends DewCommand {
|
||||||
if (colIdx >= viewStart + numVisible) viewStart = colIdx - numVisible + 1;
|
if (colIdx >= viewStart + numVisible) viewStart = colIdx - numVisible + 1;
|
||||||
viewStart = viewStart.clamp(0, max(0, numCols - numVisible));
|
viewStart = viewStart.clamp(0, max(0, numCols - numVisible));
|
||||||
|
|
||||||
final viewCols = config.columns.sublist(viewStart, min(viewStart + numVisible, numCols));
|
final viewCols = config.columns.sublist(
|
||||||
|
viewStart,
|
||||||
|
min(viewStart + numVisible, numCols),
|
||||||
|
);
|
||||||
|
|
||||||
// h - 2 (header+blank) - 2 (footer separator+help) = content height
|
// h - 2 (header+blank) - 2 (footer separator+help) = content height
|
||||||
final colAreaH = h - 4;
|
final colAreaH = h - 4;
|
||||||
|
|
@ -978,17 +1053,18 @@ class TuiCommand extends DewCommand {
|
||||||
final nameRaw = isSelected
|
final nameRaw = isSelected
|
||||||
? ' ▌ ${col.name.toUpperCase()} ($count) ▐'
|
? ' ▌ ${col.name.toUpperCase()} ($count) ▐'
|
||||||
: ' ${col.name} ($count) ';
|
: ' ${col.name} ($count) ';
|
||||||
cells.add(_Cell(
|
cells.add(
|
||||||
_trunc(nameRaw, colW).padRight(colW),
|
_Cell(
|
||||||
fg: isSelected ? color : ConsoleColor.white,
|
_trunc(nameRaw, colW).padRight(colW),
|
||||||
bold: isSelected,
|
fg: isSelected ? color : ConsoleColor.white,
|
||||||
));
|
bold: isSelected,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Top border of the ticket box — proper corners so the box closes cleanly
|
// Top border of the ticket box — proper corners so the box closes cleanly
|
||||||
cells.add(_Cell(
|
cells.add(
|
||||||
'┌${'─' * innerW}┐',
|
_Cell('┌${'─' * innerW}┐', fg: isSelected ? color : ConsoleColor.white),
|
||||||
fg: isSelected ? color : ConsoleColor.white,
|
);
|
||||||
));
|
|
||||||
|
|
||||||
// ── Ticket area ────────────────────────────────────────────────────────
|
// ── Ticket area ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -998,25 +1074,38 @@ class TuiCommand extends DewCommand {
|
||||||
// Compute scroll to keep selectedIdx in view
|
// Compute scroll to keep selectedIdx in view
|
||||||
var scroll = 0;
|
var scroll = 0;
|
||||||
if (isSelected && selectedIdx >= 0 && tickets.isNotEmpty) {
|
if (isSelected && selectedIdx >= 0 && tickets.isNotEmpty) {
|
||||||
scroll = (selectedIdx - maxVisible + 1).clamp(0, max(0, tickets.length - maxVisible));
|
scroll = (selectedIdx - maxVisible + 1).clamp(
|
||||||
|
0,
|
||||||
|
max(0, tickets.length - maxVisible),
|
||||||
|
);
|
||||||
if (selectedIdx < scroll) scroll = selectedIdx;
|
if (selectedIdx < scroll) scroll = selectedIdx;
|
||||||
}
|
}
|
||||||
|
|
||||||
final showAbove = scroll > 0;
|
final showAbove = scroll > 0;
|
||||||
final showBelow = tickets.isNotEmpty && (scroll + maxVisible) < tickets.length;
|
final showBelow =
|
||||||
|
tickets.isNotEmpty && (scroll + maxVisible) < tickets.length;
|
||||||
|
|
||||||
// "More above" indicator
|
// "More above" indicator
|
||||||
if (showAbove) {
|
if (showAbove) {
|
||||||
final msg = _trunc(' ↑ $scroll above', innerW);
|
final msg = _trunc(' ↑ $scroll above', innerW);
|
||||||
cells.add(_Cell('│${msg.padRight(innerW)}│', fg: ConsoleColor.brightYellow));
|
cells.add(
|
||||||
|
_Cell('│${msg.padRight(innerW)}│', fg: ConsoleColor.brightYellow),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
cells.add(_Cell('│${' ' * innerW}│', fg: isSelected ? color : ConsoleColor.white));
|
cells.add(
|
||||||
|
_Cell('│${' ' * innerW}│', fg: isSelected ? color : ConsoleColor.white),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visible tickets
|
// Visible tickets
|
||||||
final visEnd = min(scroll + maxVisible, tickets.length);
|
final visEnd = min(scroll + maxVisible, tickets.length);
|
||||||
for (int ti = scroll; ti < visEnd; ti++) {
|
for (int ti = scroll; ti < visEnd; ti++) {
|
||||||
_addTicketCells(cells, tickets[ti], innerW, ti == selectedIdx && isSelected);
|
_addTicketCells(
|
||||||
|
cells,
|
||||||
|
tickets[ti],
|
||||||
|
innerW,
|
||||||
|
ti == selectedIdx && isSelected,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
|
|
@ -1024,11 +1113,13 @@ class TuiCommand extends DewCommand {
|
||||||
final borderFg = isSelected ? color : ConsoleColor.white;
|
final borderFg = isSelected ? color : ConsoleColor.white;
|
||||||
cells.add(_Cell('│${' ' * innerW}│', fg: borderFg));
|
cells.add(_Cell('│${' ' * innerW}│', fg: borderFg));
|
||||||
final hint = _trunc(' ··· empty ···', innerW).padRight(innerW);
|
final hint = _trunc(' ··· empty ···', innerW).padRight(innerW);
|
||||||
cells.add(_Cell(
|
cells.add(
|
||||||
'│$hint│',
|
_Cell(
|
||||||
fg: isSelected ? color : ConsoleColor.white,
|
'│$hint│',
|
||||||
bold: isSelected,
|
fg: isSelected ? color : ConsoleColor.white,
|
||||||
));
|
bold: isSelected,
|
||||||
|
),
|
||||||
|
);
|
||||||
cells.add(_Cell('│${' ' * innerW}│', fg: borderFg));
|
cells.add(_Cell('│${' ' * innerW}│', fg: borderFg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1036,22 +1127,30 @@ class TuiCommand extends DewCommand {
|
||||||
if (showBelow) {
|
if (showBelow) {
|
||||||
final remaining = tickets.length - scroll - maxVisible;
|
final remaining = tickets.length - scroll - maxVisible;
|
||||||
final msg = _trunc(' ↓ $remaining below', innerW);
|
final msg = _trunc(' ↓ $remaining below', innerW);
|
||||||
cells.add(_Cell('│${msg.padRight(innerW)}│', fg: ConsoleColor.brightYellow));
|
cells.add(
|
||||||
|
_Cell('│${msg.padRight(innerW)}│', fg: ConsoleColor.brightYellow),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
cells.add(_Cell('│${' ' * innerW}│', fg: isSelected ? color : ConsoleColor.white));
|
cells.add(
|
||||||
|
_Cell('│${' ' * innerW}│', fg: isSelected ? color : ConsoleColor.white),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill remaining space before bottom border
|
// Fill remaining space before bottom border
|
||||||
while (cells.length < areaH - 1) {
|
while (cells.length < areaH - 1) {
|
||||||
cells.add(_Cell('│${' ' * innerW}│', fg: isSelected ? color : ConsoleColor.white));
|
cells.add(
|
||||||
|
_Cell('│${' ' * innerW}│', fg: isSelected ? color : ConsoleColor.white),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom border (always at areaH - 1)
|
// Bottom border (always at areaH - 1)
|
||||||
if (cells.length > areaH - 1) cells.length = areaH - 1;
|
if (cells.length > areaH - 1) cells.length = areaH - 1;
|
||||||
cells.add(_Cell(
|
cells.add(
|
||||||
isSelected ? '╘${'═' * innerW}╛' : '└${'─' * innerW}┘',
|
_Cell(
|
||||||
fg: isSelected ? color : ConsoleColor.white,
|
isSelected ? '╘${'═' * innerW}╛' : '└${'─' * innerW}┘',
|
||||||
));
|
fg: isSelected ? color : ConsoleColor.white,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Pad to exact height
|
// Pad to exact height
|
||||||
while (cells.length < areaH) {
|
while (cells.length < areaH) {
|
||||||
|
|
@ -1061,7 +1160,12 @@ class TuiCommand extends DewCommand {
|
||||||
return cells;
|
return cells;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void _addTicketCells(List<_Cell> cells, Ticket ticket, int innerW, bool isSel) {
|
static void _addTicketCells(
|
||||||
|
List<_Cell> cells,
|
||||||
|
Ticket ticket,
|
||||||
|
int innerW,
|
||||||
|
bool isSel,
|
||||||
|
) {
|
||||||
final bg = isSel ? ConsoleColor.blue : null;
|
final bg = isSel ? ConsoleColor.blue : null;
|
||||||
final titleFg = isSel ? ConsoleColor.brightWhite : null;
|
final titleFg = isSel ? ConsoleColor.brightWhite : null;
|
||||||
final bullet = isSel ? '▶' : ' ';
|
final bullet = isSel ? '▶' : ' ';
|
||||||
|
|
@ -1070,20 +1174,18 @@ class TuiCommand extends DewCommand {
|
||||||
// Row 1: bullet + ID + type badge
|
// Row 1: bullet + ID + type badge
|
||||||
final badge = ' [${_trunc(ticket.type, 7)}]';
|
final badge = ' [${_trunc(ticket.type, 7)}]';
|
||||||
final idLine = '$bullet ${ticket.id}$badge';
|
final idLine = '$bullet ${ticket.id}$badge';
|
||||||
cells.add(_Cell(
|
cells.add(
|
||||||
'│${_trunc(idLine, innerW).padRight(innerW)}│',
|
_Cell(
|
||||||
fg: isSel ? ConsoleColor.brightWhite : typeColor,
|
'│${_trunc(idLine, innerW).padRight(innerW)}│',
|
||||||
bg: bg,
|
fg: isSel ? ConsoleColor.brightWhite : typeColor,
|
||||||
bold: isSel,
|
bg: bg,
|
||||||
));
|
bold: isSel,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Row 2: title
|
// Row 2: title
|
||||||
final titleLine = ' ${_trunc(ticket.title, innerW - 2)}';
|
final titleLine = ' ${_trunc(ticket.title, innerW - 2)}';
|
||||||
cells.add(_Cell(
|
cells.add(_Cell('│${titleLine.padRight(innerW)}│', fg: titleFg, bg: bg));
|
||||||
'│${titleLine.padRight(innerW)}│',
|
|
||||||
fg: titleFg,
|
|
||||||
bg: bg,
|
|
||||||
));
|
|
||||||
|
|
||||||
// Row 3: labels / milestone / blank
|
// Row 3: labels / milestone / blank
|
||||||
final String tagLine;
|
final String tagLine;
|
||||||
|
|
@ -1094,11 +1196,13 @@ class TuiCommand extends DewCommand {
|
||||||
} else {
|
} else {
|
||||||
tagLine = '';
|
tagLine = '';
|
||||||
}
|
}
|
||||||
cells.add(_Cell(
|
cells.add(
|
||||||
'│${_trunc(tagLine, innerW).padRight(innerW)}│',
|
_Cell(
|
||||||
fg: isSel ? ConsoleColor.brightCyan : ConsoleColor.white,
|
'│${_trunc(tagLine, innerW).padRight(innerW)}│',
|
||||||
bg: bg,
|
fg: isSel ? ConsoleColor.brightCyan : ConsoleColor.white,
|
||||||
));
|
bg: bg,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void _writeHeaderBar(
|
static void _writeHeaderBar(
|
||||||
|
|
@ -1169,7 +1273,8 @@ class TuiCommand extends DewCommand {
|
||||||
line = ' Link ${prompt.ticketId} to ticket ID: ${prompt.input}▌';
|
line = ' Link ${prompt.ticketId} to ticket ID: ${prompt.input}▌';
|
||||||
case _PromptKind.linkType:
|
case _PromptKind.linkType:
|
||||||
final rel = _linkRelations[prompt.relationIdx];
|
final rel = _linkRelations[prompt.relationIdx];
|
||||||
line = ' ${prompt.ticketId} [◀ $rel ▶] ${prompt.linkTargetId} (Enter to confirm)';
|
line =
|
||||||
|
' ${prompt.ticketId} [◀ $rel ▶] ${prompt.linkTargetId} (Enter to confirm)';
|
||||||
}
|
}
|
||||||
console.write(_trunc(line, w).padRight(w));
|
console.write(_trunc(line, w).padRight(w));
|
||||||
console.resetColorAttributes();
|
console.resetColorAttributes();
|
||||||
|
|
@ -1181,7 +1286,8 @@ class TuiCommand extends DewCommand {
|
||||||
// Column position indicator + help
|
// Column position indicator + help
|
||||||
console.setForegroundColor(ConsoleColor.white);
|
console.setForegroundColor(ConsoleColor.white);
|
||||||
final pos = numCols > numVisible ? ' [${colIdx + 1}/$numCols cols]' : '';
|
final pos = numCols > numVisible ? ' [${colIdx + 1}/$numCols cols]' : '';
|
||||||
const help = ' [↑↓] nav [←→] col [</>] move [↵] detail [n] new [e] edit [D] del [a] archive [c] comment [L] link [?] filter [F1] help [q] quit';
|
const help =
|
||||||
|
' [↑↓] nav [←→] col [</>] move [↵] detail [n] new [e] edit [D] del [a] archive [c] comment [L] link [?] filter [F1] help [q] quit';
|
||||||
console.write(_trunc('$pos$help', w).padRight(w));
|
console.write(_trunc('$pos$help', w).padRight(w));
|
||||||
console.resetColorAttributes();
|
console.resetColorAttributes();
|
||||||
}
|
}
|
||||||
|
|
@ -1206,7 +1312,8 @@ class TuiCommand extends DewCommand {
|
||||||
console.setBackgroundColor(ConsoleColor.blue);
|
console.setBackgroundColor(ConsoleColor.blue);
|
||||||
console.setForegroundColor(ConsoleColor.brightWhite);
|
console.setForegroundColor(ConsoleColor.brightWhite);
|
||||||
console.setTextStyle(bold: true);
|
console.setTextStyle(bold: true);
|
||||||
final titleBar = ' ${ticket.id} ${_trunc(ticket.title, w - ticket.id.length - 6)}';
|
final titleBar =
|
||||||
|
' ${ticket.id} ${_trunc(ticket.title, w - ticket.id.length - 6)}';
|
||||||
console.write(titleBar.padRight(w));
|
console.write(titleBar.padRight(w));
|
||||||
console.resetColorAttributes();
|
console.resetColorAttributes();
|
||||||
console.writeLine();
|
console.writeLine();
|
||||||
|
|
@ -1237,7 +1344,10 @@ class TuiCommand extends DewCommand {
|
||||||
final scrollInfo = lines.isNotEmpty
|
final scrollInfo = lines.isNotEmpty
|
||||||
? ' [${s + 1}-${min(s + contentH, lines.length)}/${lines.length}]'
|
? ' [${s + 1}-${min(s + contentH, lines.length)}/${lines.length}]'
|
||||||
: '';
|
: '';
|
||||||
console.write(' [↑↓] scroll$scrollInfo [e] edit [b/Esc] back [F1] help [q] quit'.padRight(w));
|
console.write(
|
||||||
|
' [↑↓] scroll$scrollInfo [e] edit [b/Esc] back [F1] help [q] quit'
|
||||||
|
.padRight(w),
|
||||||
|
);
|
||||||
console.resetColorAttributes();
|
console.resetColorAttributes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1248,7 +1358,13 @@ class TuiCommand extends DewCommand {
|
||||||
if (label != null) {
|
if (label != null) {
|
||||||
final tag = ' $label ';
|
final tag = ' $label ';
|
||||||
final dashes = max(0, w - tag.length + 2);
|
final dashes = max(0, w - tag.length + 2);
|
||||||
lines.add(_Cell(' ─$tag${'─' * dashes}', fg: ConsoleColor.brightBlue, bold: true));
|
lines.add(
|
||||||
|
_Cell(
|
||||||
|
' ─$tag${'─' * dashes}',
|
||||||
|
fg: ConsoleColor.brightBlue,
|
||||||
|
bold: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
lines.add(_Cell(' ${'─' * w}', fg: ConsoleColor.white));
|
lines.add(_Cell(' ${'─' * w}', fg: ConsoleColor.white));
|
||||||
}
|
}
|
||||||
|
|
@ -1270,8 +1386,10 @@ class TuiCommand extends DewCommand {
|
||||||
kv('Type', ticket.type);
|
kv('Type', ticket.type);
|
||||||
kv('Column', ticket.column);
|
kv('Column', ticket.column);
|
||||||
kv('Created', _fmtDate(ticket.created));
|
kv('Created', _fmtDate(ticket.created));
|
||||||
if (ticket.milestones.isNotEmpty) kv('Milestones', ticket.milestones.join(', '));
|
if (ticket.milestones.isNotEmpty)
|
||||||
if (ticket.labels.isNotEmpty) kv('Labels', ticket.labels.map((l) => '#$l').join(' '));
|
kv('Milestones', ticket.milestones.join(', '));
|
||||||
|
if (ticket.labels.isNotEmpty)
|
||||||
|
kv('Labels', ticket.labels.map((l) => '#$l').join(' '));
|
||||||
if (ticket.links.isNotEmpty) {
|
if (ticket.links.isNotEmpty) {
|
||||||
for (final link in ticket.links) {
|
for (final link in ticket.links) {
|
||||||
kv(link.type, link.targetId);
|
kv(link.type, link.targetId);
|
||||||
|
|
@ -1398,36 +1516,45 @@ class TuiCommand extends DewCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
('Board', [
|
(
|
||||||
('↑ / ↓', 'Navigate tickets'),
|
'Board',
|
||||||
('← / →', 'Switch columns'),
|
[
|
||||||
('< / >', 'Move ticket left / right'),
|
('↑ / ↓', 'Navigate tickets'),
|
||||||
('Enter', 'Open ticket detail'),
|
('← / →', 'Switch columns'),
|
||||||
('n', 'New ticket'),
|
('< / >', 'Move ticket left / right'),
|
||||||
('e', 'Edit ticket'),
|
('Enter', 'Open ticket detail'),
|
||||||
('a', 'Archive ticket'),
|
('n', 'New ticket'),
|
||||||
('D', 'Delete ticket'),
|
('e', 'Edit ticket'),
|
||||||
('c', 'Add comment'),
|
('a', 'Archive ticket'),
|
||||||
('L', 'Link ticket'),
|
('D', 'Delete ticket'),
|
||||||
('?', 'Filter / search'),
|
('c', 'Add comment'),
|
||||||
('q / Esc', 'Quit'),
|
('L', 'Link ticket'),
|
||||||
('F1', 'This help'),
|
('?', 'Filter / search'),
|
||||||
]),
|
('q / Esc', 'Quit'),
|
||||||
('Detail', [
|
('F1', 'This help'),
|
||||||
('↑ / ↓', 'Scroll'),
|
],
|
||||||
('e', 'Edit ticket'),
|
),
|
||||||
('b / Esc', 'Back to board'),
|
(
|
||||||
('q', 'Quit'),
|
'Detail',
|
||||||
('F1', 'This help'),
|
[
|
||||||
]),
|
('↑ / ↓', 'Scroll'),
|
||||||
('Editor', [
|
('e', 'Edit ticket'),
|
||||||
('↑ / ↓', 'Navigate fields'),
|
('b / Esc', 'Back to board'),
|
||||||
('← / →', 'Cycle selector values'),
|
('q', 'Quit'),
|
||||||
('Enter', 'Edit text / open body editor'),
|
('F1', 'This help'),
|
||||||
('d', 'Delete selected item'),
|
],
|
||||||
('s', 'Save changes'),
|
),
|
||||||
('Esc', 'Discard & close'),
|
(
|
||||||
]),
|
'Editor',
|
||||||
|
[
|
||||||
|
('↑ / ↓', 'Navigate fields'),
|
||||||
|
('← / →', 'Cycle selector values'),
|
||||||
|
('Enter', 'Edit text / open body editor'),
|
||||||
|
('d', 'Delete selected item'),
|
||||||
|
('s', 'Save changes'),
|
||||||
|
('Esc', 'Discard & close'),
|
||||||
|
],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Compute modal size
|
// Compute modal size
|
||||||
|
|
@ -1435,7 +1562,10 @@ class TuiCommand extends DewCommand {
|
||||||
const descW = 36;
|
const descW = 36;
|
||||||
const innerW = labelW + 3 + descW; // "key desc"
|
const innerW = labelW + 3 + descW; // "key desc"
|
||||||
final modalW = min(w - 4, innerW + 4);
|
final modalW = min(w - 4, innerW + 4);
|
||||||
final totalRows = sections.fold(0, (s, sec) => s + sec.$2.length + 2); // +2 per section: header + blank
|
final totalRows = sections.fold(
|
||||||
|
0,
|
||||||
|
(s, sec) => s + sec.$2.length + 2,
|
||||||
|
); // +2 per section: header + blank
|
||||||
final modalH = min(h - 4, totalRows + 4);
|
final modalH = min(h - 4, totalRows + 4);
|
||||||
final modalLeft = max(0, (w - modalW) ~/ 2);
|
final modalLeft = max(0, (w - modalW) ~/ 2);
|
||||||
final modalTop = max(0, (h - modalH) ~/ 2);
|
final modalTop = max(0, (h - modalH) ~/ 2);
|
||||||
|
|
@ -1450,7 +1580,9 @@ class TuiCommand extends DewCommand {
|
||||||
console.cursorPosition = Coordinate(modalTop + 1, modalLeft);
|
console.cursorPosition = Coordinate(modalTop + 1, modalLeft);
|
||||||
console.setBackgroundColor(ConsoleColor.cyan);
|
console.setBackgroundColor(ConsoleColor.cyan);
|
||||||
console.setForegroundColor(ConsoleColor.black);
|
console.setForegroundColor(ConsoleColor.black);
|
||||||
console.write('║${title.padRight(innerWActual).substring(0, innerWActual)}║');
|
console.write(
|
||||||
|
'║${title.padRight(innerWActual).substring(0, innerWActual)}║',
|
||||||
|
);
|
||||||
console.resetColorAttributes();
|
console.resetColorAttributes();
|
||||||
// Second header row
|
// Second header row
|
||||||
console.setForegroundColor(ConsoleColor.brightCyan);
|
console.setForegroundColor(ConsoleColor.brightCyan);
|
||||||
|
|
@ -1464,7 +1596,9 @@ class TuiCommand extends DewCommand {
|
||||||
console.cursorPosition = Coordinate(row, modalLeft);
|
console.cursorPosition = Coordinate(row, modalLeft);
|
||||||
console.setForegroundColor(ConsoleColor.brightCyan);
|
console.setForegroundColor(ConsoleColor.brightCyan);
|
||||||
final secLine = ' ▸ $secName';
|
final secLine = ' ▸ $secName';
|
||||||
console.write('║${secLine.padRight(innerWActual).substring(0, innerWActual)}║');
|
console.write(
|
||||||
|
'║${secLine.padRight(innerWActual).substring(0, innerWActual)}║',
|
||||||
|
);
|
||||||
row++;
|
row++;
|
||||||
|
|
||||||
for (final (key, desc) in bindings) {
|
for (final (key, desc) in bindings) {
|
||||||
|
|
@ -1473,7 +1607,9 @@ class TuiCommand extends DewCommand {
|
||||||
final keyPart = key.padLeft(labelW);
|
final keyPart = key.padLeft(labelW);
|
||||||
final line = ' $keyPart $desc';
|
final line = ' $keyPart $desc';
|
||||||
console.setForegroundColor(ConsoleColor.white);
|
console.setForegroundColor(ConsoleColor.white);
|
||||||
console.write('║${line.padRight(innerWActual).substring(0, innerWActual)}║');
|
console.write(
|
||||||
|
'║${line.padRight(innerWActual).substring(0, innerWActual)}║',
|
||||||
|
);
|
||||||
row++;
|
row++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1493,7 +1629,10 @@ class TuiCommand extends DewCommand {
|
||||||
|
|
||||||
// Footer hint
|
// Footer hint
|
||||||
const hint = ' Press any key to close ';
|
const hint = ' Press any key to close ';
|
||||||
console.cursorPosition = Coordinate(modalTop + modalH - 1, modalLeft + (modalW - hint.length) ~/ 2);
|
console.cursorPosition = Coordinate(
|
||||||
|
modalTop + modalH - 1,
|
||||||
|
modalLeft + (modalW - hint.length) ~/ 2,
|
||||||
|
);
|
||||||
console.setForegroundColor(ConsoleColor.white);
|
console.setForegroundColor(ConsoleColor.white);
|
||||||
console.write(hint);
|
console.write(hint);
|
||||||
|
|
||||||
|
|
@ -1576,8 +1715,16 @@ class TuiCommand extends DewCommand {
|
||||||
|
|
||||||
final textColor = ConsoleColor.white;
|
final textColor = ConsoleColor.white;
|
||||||
|
|
||||||
void fieldRow(int relRow, _EditorField field, String label, String value,
|
void fieldRow(
|
||||||
{bool isSelector = false, bool isMulti = false, List<String> items = const [], int itemCursor = 0}) {
|
int relRow,
|
||||||
|
_EditorField field,
|
||||||
|
String label,
|
||||||
|
String value, {
|
||||||
|
bool isSelector = false,
|
||||||
|
bool isMulti = false,
|
||||||
|
List<String> items = const [],
|
||||||
|
int itemCursor = 0,
|
||||||
|
}) {
|
||||||
final focused = es.focus == field;
|
final focused = es.focus == field;
|
||||||
final prefix = focused ? '❯ ' : ' ';
|
final prefix = focused ? '❯ ' : ' ';
|
||||||
at(modalTop + 3 + relRow, modalLeft + 1, () {
|
at(modalTop + 3 + relRow, modalLeft + 1, () {
|
||||||
|
|
@ -1628,7 +1775,9 @@ class TuiCommand extends DewCommand {
|
||||||
console.write(' ${items[i]} ');
|
console.write(' ${items[i]} ');
|
||||||
console.resetColorAttributes();
|
console.resetColorAttributes();
|
||||||
} else {
|
} else {
|
||||||
console.setForegroundColor(focused ? textColor : ConsoleColor.white);
|
console.setForegroundColor(
|
||||||
|
focused ? textColor : ConsoleColor.white,
|
||||||
|
);
|
||||||
console.write('• ${items[i]} ');
|
console.write('• ${items[i]} ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1643,7 +1792,9 @@ class TuiCommand extends DewCommand {
|
||||||
console.setForegroundColor(focused ? accentColor : textColor);
|
console.setForegroundColor(focused ? accentColor : textColor);
|
||||||
final disp = value.isNotEmpty ? value : '(empty)';
|
final disp = value.isNotEmpty ? value : '(empty)';
|
||||||
final hint = focused
|
final hint = focused
|
||||||
? (field == _EditorField.body ? ' [Enter → \$EDITOR]' : ' [Enter to edit]')
|
? (field == _EditorField.body
|
||||||
|
? ' [Enter → \$EDITOR]'
|
||||||
|
: ' [Enter to edit]')
|
||||||
: '';
|
: '';
|
||||||
final maxLen = innerW - 15 - hint.length;
|
final maxLen = innerW - 15 - hint.length;
|
||||||
console.write(_trunc(disp, maxLen));
|
console.write(_trunc(disp, maxLen));
|
||||||
|
|
@ -1659,18 +1810,33 @@ class TuiCommand extends DewCommand {
|
||||||
fieldRow(0, _EditorField.title, 'Title', es.title);
|
fieldRow(0, _EditorField.title, 'Title', es.title);
|
||||||
fieldRow(1, _EditorField.type, 'Type', es.type, isSelector: true);
|
fieldRow(1, _EditorField.type, 'Type', es.type, isSelector: true);
|
||||||
fieldRow(2, _EditorField.column, 'Column', es.column, isSelector: true);
|
fieldRow(2, _EditorField.column, 'Column', es.column, isSelector: true);
|
||||||
fieldRow(3, _EditorField.labels, 'Labels', '', isMulti: true, items: es.labels, itemCursor: es.itemCursor);
|
fieldRow(
|
||||||
fieldRow(4, _EditorField.milestones, 'Milestones', '', isMulti: true, items: es.milestones, itemCursor: es.itemCursor);
|
3,
|
||||||
|
_EditorField.labels,
|
||||||
|
'Labels',
|
||||||
|
'',
|
||||||
|
isMulti: true,
|
||||||
|
items: es.labels,
|
||||||
|
itemCursor: es.itemCursor,
|
||||||
|
);
|
||||||
|
fieldRow(
|
||||||
|
4,
|
||||||
|
_EditorField.milestones,
|
||||||
|
'Milestones',
|
||||||
|
'',
|
||||||
|
isMulti: true,
|
||||||
|
items: es.milestones,
|
||||||
|
itemCursor: es.itemCursor,
|
||||||
|
);
|
||||||
|
|
||||||
// Body row — show first line preview
|
// Body row — show first line preview
|
||||||
final bodyPreview = es.body.isNotEmpty
|
final bodyPreview = es.body.isNotEmpty ? es.body.split('\n').first : '';
|
||||||
? es.body.split('\n').first
|
|
||||||
: '';
|
|
||||||
fieldRow(5, _EditorField.body, 'Body', bodyPreview);
|
fieldRow(5, _EditorField.body, 'Body', bodyPreview);
|
||||||
|
|
||||||
// Footer hints
|
// Footer hints
|
||||||
final dirtyMarker = es.isDirty ? ' ● unsaved' : '';
|
final dirtyMarker = es.isDirty ? ' ● unsaved' : '';
|
||||||
final footerHints = '[↑↓] field [←→] value [Enter] edit [d] del [s] save [Esc] discard [F1] help$dirtyMarker';
|
final footerHints =
|
||||||
|
'[↑↓] field [←→] value [Enter] edit [d] del [s] save [Esc] discard [F1] help$dirtyMarker';
|
||||||
at(modalTop + modalH - 2, modalLeft + 1, () {
|
at(modalTop + modalH - 2, modalLeft + 1, () {
|
||||||
console.setForegroundColor(ConsoleColor.white);
|
console.setForegroundColor(ConsoleColor.white);
|
||||||
console.write(_trunc(footerHints, innerW));
|
console.write(_trunc(footerHints, innerW));
|
||||||
|
|
@ -1681,7 +1847,6 @@ class TuiCommand extends DewCommand {
|
||||||
static String _fmtDate(DateTime dt) =>
|
static String _fmtDate(DateTime dt) =>
|
||||||
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
|
||||||
static List<String> _wordWrap(String text, int width) {
|
static List<String> _wordWrap(String text, int width) {
|
||||||
if (text.isEmpty) return [''];
|
if (text.isEmpty) return [''];
|
||||||
if (text.length <= width) return [text];
|
if (text.length <= width) return [text];
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,12 @@ class UnarchiveCommand extends DewCommand with DewToolCommand {
|
||||||
|
|
||||||
UnarchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
UnarchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to unarchive.')
|
..addOption(
|
||||||
|
'id',
|
||||||
|
abbr: 'i',
|
||||||
|
mandatory: true,
|
||||||
|
help: 'Ticket ID to unarchive.',
|
||||||
|
)
|
||||||
..addOption(
|
..addOption(
|
||||||
'column',
|
'column',
|
||||||
abbr: 'c',
|
abbr: 'c',
|
||||||
|
|
@ -36,7 +41,11 @@ class UnarchiveCommand extends DewCommand with DewToolCommand {
|
||||||
final config = context.config.kanban;
|
final config = context.config.kanban;
|
||||||
final kanbanDir = context.dirs.kanban;
|
final kanbanDir = context.dirs.kanban;
|
||||||
|
|
||||||
final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix, fs: context.fs);
|
final store = TicketStore(
|
||||||
|
kanbanDir: kanbanDir,
|
||||||
|
prefix: config.prefix,
|
||||||
|
fs: context.fs,
|
||||||
|
);
|
||||||
final ticket = await store.findById(id);
|
final ticket = await store.findById(id);
|
||||||
if (ticket == null) throw ArgumentError('Ticket $id not found.');
|
if (ticket == null) throw ArgumentError('Ticket $id not found.');
|
||||||
if (ticket.column != 'archive') return '$id is not archived.';
|
if (ticket.column != 'archive') return '$id is not archived.';
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,12 @@ class UnlinkCommand extends DewCommand with DewToolCommand {
|
||||||
UnlinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
UnlinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
|
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
|
||||||
..addOption('target', abbr: 't', mandatory: true, help: 'Target ticket ID to remove link to.');
|
..addOption(
|
||||||
|
'target',
|
||||||
|
abbr: 't',
|
||||||
|
mandatory: true,
|
||||||
|
help: 'Target ticket ID to remove link to.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ class UpdateCommand extends DewCommand with DewToolCommand {
|
||||||
final String name = 'update';
|
final String name = 'update';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String description = 'Update one or more fields on an existing kanban ticket.';
|
final String description =
|
||||||
|
'Update one or more fields on an existing kanban ticket.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String toolName = 'kanban_update_ticket';
|
final String toolName = 'kanban_update_ticket';
|
||||||
|
|
|
||||||
|
|
@ -43,24 +43,28 @@ extension KanbanDewConfig on DewConfig {
|
||||||
final kanbanYaml = (raw['dew'] as YamlMap)['kanban'] as YamlMap;
|
final kanbanYaml = (raw['dew'] as YamlMap)['kanban'] as YamlMap;
|
||||||
return KanbanConfig(
|
return KanbanConfig(
|
||||||
prefix: kanbanYaml['prefix'] as String,
|
prefix: kanbanYaml['prefix'] as String,
|
||||||
ticketTypes:
|
ticketTypes: (kanbanYaml['ticket_types'] as YamlList)
|
||||||
(kanbanYaml['ticket_types'] as YamlList)
|
.map(
|
||||||
.map((t) => TicketTypeConfig(id: t['id'] as String, name: t['name'] as String))
|
(t) => TicketTypeConfig(
|
||||||
.toList(),
|
id: t['id'] as String,
|
||||||
columns:
|
name: t['name'] as String,
|
||||||
(kanbanYaml['columns'] as YamlList)
|
),
|
||||||
.map(
|
)
|
||||||
(c) => ColumnConfig(
|
.toList(),
|
||||||
id: c['id'] as String,
|
columns: (kanbanYaml['columns'] as YamlList)
|
||||||
name: c['name'] as String,
|
.map(
|
||||||
color: c['color'] as String,
|
(c) => ColumnConfig(
|
||||||
allowedTransitions: (c['allowed_transitions'] as YamlList?)
|
id: c['id'] as String,
|
||||||
?.map((t) => t as String)
|
name: c['name'] as String,
|
||||||
.toList() ??
|
color: c['color'] as String,
|
||||||
const [],
|
allowedTransitions:
|
||||||
),
|
(c['allowed_transitions'] as YamlList?)
|
||||||
)
|
?.map((t) => t as String)
|
||||||
.toList(),
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,26 +142,30 @@ class Ticket {
|
||||||
}
|
}
|
||||||
final fmEnd = content.indexOf('\n---\n', 4);
|
final fmEnd = content.indexOf('\n---\n', 4);
|
||||||
if (fmEnd == -1) {
|
if (fmEnd == -1) {
|
||||||
throw FormatException('Ticket file $id is missing closing frontmatter ---');
|
throw FormatException(
|
||||||
|
'Ticket file $id is missing closing frontmatter ---',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final fm = loadYaml(content.substring(4, fmEnd)) as YamlMap;
|
final fm = loadYaml(content.substring(4, fmEnd)) as YamlMap;
|
||||||
|
|
||||||
// Everything after the closing \n---\n, split into body + comments.
|
// Everything after the closing \n---\n, split into body + comments.
|
||||||
final rest = content.substring(fmEnd + 5);
|
final rest = content.substring(fmEnd + 5);
|
||||||
final sections =
|
final sections = rest
|
||||||
rest.split('\n---\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
.split('\n---\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
final rawLinks = fm['links'] as YamlList?;
|
final rawLinks = fm['links'] as YamlList?;
|
||||||
final links = rawLinks
|
final links =
|
||||||
?.map((entry) {
|
rawLinks?.map((entry) {
|
||||||
final map = entry as YamlMap;
|
final map = entry as YamlMap;
|
||||||
return TicketLink(
|
return TicketLink(
|
||||||
targetId: map['id'] as String,
|
targetId: map['id'] as String,
|
||||||
type: map['type'] as String,
|
type: map['type'] as String,
|
||||||
);
|
);
|
||||||
})
|
}).toList() ??
|
||||||
.toList() ??
|
|
||||||
const [];
|
const [];
|
||||||
|
|
||||||
List<String> parseStringList(String key) {
|
List<String> parseStringList(String key) {
|
||||||
|
|
@ -186,7 +190,8 @@ class Ticket {
|
||||||
/// Wraps [value] in double quotes if it contains characters that would
|
/// Wraps [value] in double quotes if it contains characters that would
|
||||||
/// confuse a YAML parser (colon-space, leading/trailing whitespace, etc.).
|
/// confuse a YAML parser (colon-space, leading/trailing whitespace, etc.).
|
||||||
static String _yamlQuote(String value) {
|
static String _yamlQuote(String value) {
|
||||||
final needsQuoting = value.contains(': ') ||
|
final needsQuoting =
|
||||||
|
value.contains(': ') ||
|
||||||
value.contains(' #') ||
|
value.contains(' #') ||
|
||||||
value.startsWith('"') ||
|
value.startsWith('"') ||
|
||||||
value.startsWith("'") ||
|
value.startsWith("'") ||
|
||||||
|
|
@ -196,4 +201,3 @@ class Ticket {
|
||||||
return '"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"';
|
return '"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,14 +41,20 @@ class TicketStore {
|
||||||
milestones: milestones,
|
milestones: milestones,
|
||||||
labels: labels,
|
labels: labels,
|
||||||
);
|
);
|
||||||
await fs.file(p.join(columnDir.path, '$id.md')).writeAsString(ticket.toFileContent());
|
await fs
|
||||||
|
.file(p.join(columnDir.path, '$id.md'))
|
||||||
|
.writeAsString(ticket.toFileContent());
|
||||||
return ticket;
|
return ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Ticket?> findById(String id) async {
|
Future<Ticket?> findById(String id) async {
|
||||||
final found = await _findTicketFile(id);
|
final found = await _findTicketFile(id);
|
||||||
if (found == null) return null;
|
if (found == null) return null;
|
||||||
return Ticket.fromFileContent(id, await found.file.readAsString(), found.column);
|
return Ticket.fromFileContent(
|
||||||
|
id,
|
||||||
|
await found.file.readAsString(),
|
||||||
|
found.column,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Ticket>> list({bool includeArchived = false}) async {
|
Future<List<Ticket>> list({bool includeArchived = false}) async {
|
||||||
|
|
@ -76,7 +82,11 @@ class TicketStore {
|
||||||
Future<Ticket> addComment(String id, String comment) async {
|
Future<Ticket> addComment(String id, String comment) async {
|
||||||
final found = await _findTicketFile(id);
|
final found = await _findTicketFile(id);
|
||||||
if (found == null) throw ArgumentError('Ticket $id not found.');
|
if (found == null) throw ArgumentError('Ticket $id not found.');
|
||||||
final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column);
|
final ticket = Ticket.fromFileContent(
|
||||||
|
id,
|
||||||
|
await found.file.readAsString(),
|
||||||
|
found.column,
|
||||||
|
);
|
||||||
final updated = ticket.copyWith(comments: [...ticket.comments, comment]);
|
final updated = ticket.copyWith(comments: [...ticket.comments, comment]);
|
||||||
await found.file.writeAsString(updated.toFileContent());
|
await found.file.writeAsString(updated.toFileContent());
|
||||||
return updated;
|
return updated;
|
||||||
|
|
@ -97,7 +107,10 @@ class TicketStore {
|
||||||
// Forward link (idempotent — skip if already linked to same target).
|
// Forward link (idempotent — skip if already linked to same target).
|
||||||
if (!ticket.links.any((l) => l.targetId == targetId)) {
|
if (!ticket.links.any((l) => l.targetId == targetId)) {
|
||||||
final updated = ticket.copyWith(
|
final updated = ticket.copyWith(
|
||||||
links: [...ticket.links, TicketLink(targetId: targetId, type: type)],
|
links: [
|
||||||
|
...ticket.links,
|
||||||
|
TicketLink(targetId: targetId, type: type),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
final found = (await _findTicketFile(id))!;
|
final found = (await _findTicketFile(id))!;
|
||||||
await found.file.writeAsString(updated.toFileContent());
|
await found.file.writeAsString(updated.toFileContent());
|
||||||
|
|
@ -107,7 +120,10 @@ class TicketStore {
|
||||||
final inverseType = linkTypeInverses[type]!;
|
final inverseType = linkTypeInverses[type]!;
|
||||||
if (!target.links.any((l) => l.targetId == id)) {
|
if (!target.links.any((l) => l.targetId == id)) {
|
||||||
final updatedTarget = target.copyWith(
|
final updatedTarget = target.copyWith(
|
||||||
links: [...target.links, TicketLink(targetId: id, type: inverseType)],
|
links: [
|
||||||
|
...target.links,
|
||||||
|
TicketLink(targetId: id, type: inverseType),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
final foundTarget = (await _findTicketFile(targetId))!;
|
final foundTarget = (await _findTicketFile(targetId))!;
|
||||||
await foundTarget.file.writeAsString(updatedTarget.toFileContent());
|
await foundTarget.file.writeAsString(updatedTarget.toFileContent());
|
||||||
|
|
@ -119,7 +135,11 @@ class TicketStore {
|
||||||
Future<Ticket> unlinkTickets(String id, String targetId) async {
|
Future<Ticket> unlinkTickets(String id, String targetId) async {
|
||||||
final found = await _findTicketFile(id);
|
final found = await _findTicketFile(id);
|
||||||
if (found == null) throw ArgumentError('Ticket $id not found.');
|
if (found == null) throw ArgumentError('Ticket $id not found.');
|
||||||
final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column);
|
final ticket = Ticket.fromFileContent(
|
||||||
|
id,
|
||||||
|
await found.file.readAsString(),
|
||||||
|
found.column,
|
||||||
|
);
|
||||||
final updated = ticket.copyWith(
|
final updated = ticket.copyWith(
|
||||||
links: ticket.links.where((l) => l.targetId != targetId).toList(),
|
links: ticket.links.where((l) => l.targetId != targetId).toList(),
|
||||||
);
|
);
|
||||||
|
|
@ -165,7 +185,11 @@ class TicketStore {
|
||||||
}) async {
|
}) async {
|
||||||
final found = await _findTicketFile(id);
|
final found = await _findTicketFile(id);
|
||||||
if (found == null) throw ArgumentError('Ticket $id not found.');
|
if (found == null) throw ArgumentError('Ticket $id not found.');
|
||||||
final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column);
|
final ticket = Ticket.fromFileContent(
|
||||||
|
id,
|
||||||
|
await found.file.readAsString(),
|
||||||
|
found.column,
|
||||||
|
);
|
||||||
final updated = ticket.copyWith(
|
final updated = ticket.copyWith(
|
||||||
title: title,
|
title: title,
|
||||||
type: type,
|
type: type,
|
||||||
|
|
@ -179,7 +203,9 @@ class TicketStore {
|
||||||
await found.file.delete();
|
await found.file.delete();
|
||||||
final newColDir = fs.directory(p.join(kanbanDir, column));
|
final newColDir = fs.directory(p.join(kanbanDir, column));
|
||||||
await newColDir.create(recursive: true);
|
await newColDir.create(recursive: true);
|
||||||
await fs.file(p.join(newColDir.path, '$id.md')).writeAsString(updated.toFileContent());
|
await fs
|
||||||
|
.file(p.join(newColDir.path, '$id.md'))
|
||||||
|
.writeAsString(updated.toFileContent());
|
||||||
} else {
|
} else {
|
||||||
await found.file.writeAsString(updated.toFileContent());
|
await found.file.writeAsString(updated.toFileContent());
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +218,8 @@ class TicketStore {
|
||||||
await found.file.delete();
|
await found.file.delete();
|
||||||
// Clean up per-ticket attachment directory if present.
|
// Clean up per-ticket attachment directory if present.
|
||||||
final attachmentsDir = fs.directory(p.join(kanbanDir, 'attachments', id));
|
final attachmentsDir = fs.directory(p.join(kanbanDir, 'attachments', id));
|
||||||
if (await attachmentsDir.exists()) await attachmentsDir.delete(recursive: true);
|
if (await attachmentsDir.exists())
|
||||||
|
await attachmentsDir.delete(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Searches all column subdirectories (one level deep) for a ticket file.
|
/// Searches all column subdirectories (one level deep) for a ticket file.
|
||||||
|
|
@ -204,7 +231,8 @@ class TicketStore {
|
||||||
if (entity is! Directory) continue;
|
if (entity is! Directory) continue;
|
||||||
if (p.basename(entity.path) == 'attachments') continue;
|
if (p.basename(entity.path) == 'attachments') continue;
|
||||||
final file = fs.file(p.join(entity.path, '$id.md'));
|
final file = fs.file(p.join(entity.path, '$id.md'));
|
||||||
if (await file.exists()) return (file: file, column: p.basename(entity.path));
|
if (await file.exists())
|
||||||
|
return (file: file, column: p.basename(entity.path));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +243,8 @@ class TicketStore {
|
||||||
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$');
|
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$');
|
||||||
var max = 0;
|
var max = 0;
|
||||||
await for (final entity in dir.list()) {
|
await for (final entity in dir.list()) {
|
||||||
if (entity is! Directory || p.basename(entity.path) == 'attachments') continue;
|
if (entity is! Directory || p.basename(entity.path) == 'attachments')
|
||||||
|
continue;
|
||||||
await for (final file in entity.list()) {
|
await for (final file in entity.list()) {
|
||||||
final match = pattern.firstMatch(p.basename(file.path));
|
final match = pattern.firstMatch(p.basename(file.path));
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
name: dew_kanban
|
name: dew_kanban
|
||||||
description: Kanban board feature for the Dew project management tool. Implements McpToolProvider to expose board tools to the MCP server.
|
description: Kanban board feature for the Dew project management tool. Implements McpToolProvider to expose board tools to the MCP server.
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
# repository: https://github.com/my_org/my_repo
|
repository: https://github.com/artificery-dev/dew
|
||||||
publish_to: none
|
issue_tracker: https://github.com/artificery-dev/dew/issues
|
||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -10,8 +10,7 @@ environment:
|
||||||
|
|
||||||
# Add regular dependencies here.
|
# Add regular dependencies here.
|
||||||
dependencies:
|
dependencies:
|
||||||
dew_core:
|
dew_core: ^1.0.0
|
||||||
path: ../core
|
|
||||||
dart_console: ^4.1.2
|
dart_console: ^4.1.2
|
||||||
file: ^7.0.1
|
file: ^7.0.1
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,21 @@ void main() {
|
||||||
expect(
|
expect(
|
||||||
cmd.subcommands.keys,
|
cmd.subcommands.keys,
|
||||||
containsAll([
|
containsAll([
|
||||||
'create', 'list', 'board', 'get', 'update', 'delete', 'archive', 'unarchive',
|
'create',
|
||||||
'move', 'search', 'comment', 'config', 'stats', 'link', 'unlink',
|
'list',
|
||||||
|
'board',
|
||||||
|
'get',
|
||||||
|
'update',
|
||||||
|
'delete',
|
||||||
|
'archive',
|
||||||
|
'unarchive',
|
||||||
|
'move',
|
||||||
|
'search',
|
||||||
|
'comment',
|
||||||
|
'config',
|
||||||
|
'stats',
|
||||||
|
'link',
|
||||||
|
'unlink',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -82,15 +95,25 @@ void main() {
|
||||||
final registry = CommandRegistry();
|
final registry = CommandRegistry();
|
||||||
registerCommands(registry);
|
registerCommands(registry);
|
||||||
for (final tool in registry.mcpTools) {
|
for (final tool in registry.mcpTools) {
|
||||||
expect(tool.description, isNotEmpty, reason: '${tool.name} description');
|
expect(
|
||||||
expect(tool.inputSchema['type'], 'object', reason: '${tool.name} schema type');
|
tool.description,
|
||||||
|
isNotEmpty,
|
||||||
|
reason: '${tool.name} description',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tool.inputSchema['type'],
|
||||||
|
'object',
|
||||||
|
reason: '${tool.name} schema type',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('schema derived from argParser — create tool has required fields', () {
|
test('schema derived from argParser — create tool has required fields', () {
|
||||||
final registry = CommandRegistry();
|
final registry = CommandRegistry();
|
||||||
registerCommands(registry);
|
registerCommands(registry);
|
||||||
final create = registry.mcpTools.firstWhere((t) => t.name == 'kanban_create_ticket');
|
final create = registry.mcpTools.firstWhere(
|
||||||
|
(t) => t.name == 'kanban_create_ticket',
|
||||||
|
);
|
||||||
final required = create.inputSchema['required'] as List;
|
final required = create.inputSchema['required'] as List;
|
||||||
expect(required, containsAll(['title', 'type']));
|
expect(required, containsAll(['title', 'type']));
|
||||||
});
|
});
|
||||||
|
|
@ -110,12 +133,19 @@ void main() {
|
||||||
final listResult = await tools['kanban_list_tickets']!.handler({});
|
final listResult = await tools['kanban_list_tickets']!.handler({});
|
||||||
expect(listResult, contains('T-0001'));
|
expect(listResult, contains('T-0001'));
|
||||||
|
|
||||||
final searchResult = await tools['kanban_search_tickets']!.handler({'query': 'Hello'});
|
final searchResult = await tools['kanban_search_tickets']!.handler({
|
||||||
|
'query': 'Hello',
|
||||||
|
});
|
||||||
expect(searchResult, contains('T-0001'));
|
expect(searchResult, contains('T-0001'));
|
||||||
|
|
||||||
await tools['kanban_add_comment']!.handler({'id': 'T-0001', 'comment': 'Nice ticket.'});
|
await tools['kanban_add_comment']!.handler({
|
||||||
|
'id': 'T-0001',
|
||||||
|
'comment': 'Nice ticket.',
|
||||||
|
});
|
||||||
|
|
||||||
final getResult = await tools['kanban_get_ticket']!.handler({'id': 'T-0001'});
|
final getResult = await tools['kanban_get_ticket']!.handler({
|
||||||
|
'id': 'T-0001',
|
||||||
|
});
|
||||||
expect(getResult, contains('Nice ticket.'));
|
expect(getResult, contains('Nice ticket.'));
|
||||||
|
|
||||||
final configResult = await tools['kanban_get_config']!.handler({});
|
final configResult = await tools['kanban_get_config']!.handler({});
|
||||||
|
|
@ -170,22 +200,37 @@ dew:
|
||||||
|
|
||||||
// Allowed: backlog → doing
|
// Allowed: backlog → doing
|
||||||
await expectLater(
|
await expectLater(
|
||||||
tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'}),
|
tools['kanban_move_ticket']!.handler({
|
||||||
|
'id': 'T-0001',
|
||||||
|
'column': 'doing',
|
||||||
|
}),
|
||||||
completes,
|
completes,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset to backlog first.
|
// Reset to backlog first.
|
||||||
await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'backlog'});
|
await tools['kanban_move_ticket']!.handler({
|
||||||
|
'id': 'T-0001',
|
||||||
|
'column': 'backlog',
|
||||||
|
});
|
||||||
|
|
||||||
// backlog → done should throw (not in allowed_transitions).
|
// backlog → done should throw (not in allowed_transitions).
|
||||||
await expectLater(
|
await expectLater(
|
||||||
tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}),
|
tools['kanban_move_ticket']!.handler({
|
||||||
|
'id': 'T-0001',
|
||||||
|
'column': 'done',
|
||||||
|
}),
|
||||||
throwsA(isA<ArgumentError>()),
|
throwsA(isA<ArgumentError>()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Unconstrained column (done) — any target is valid.
|
// Unconstrained column (done) — any target is valid.
|
||||||
await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'});
|
await tools['kanban_move_ticket']!.handler({
|
||||||
await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'});
|
'id': 'T-0001',
|
||||||
|
'column': 'doing',
|
||||||
|
});
|
||||||
|
await tools['kanban_move_ticket']!.handler({
|
||||||
|
'id': 'T-0001',
|
||||||
|
'column': 'done',
|
||||||
|
});
|
||||||
// done → backlog: done has no constraints, so it's allowed.
|
// done → backlog: done has no constraints, so it's allowed.
|
||||||
final result = await tools['kanban_move_ticket']!.handler({
|
final result = await tools['kanban_move_ticket']!.handler({
|
||||||
'id': 'T-0001',
|
'id': 'T-0001',
|
||||||
|
|
@ -300,17 +345,22 @@ dew:
|
||||||
});
|
});
|
||||||
|
|
||||||
group('TicketStore', () {
|
group('TicketStore', () {
|
||||||
TicketStore makeStore(MemoryFileSystem fs) => TicketStore(
|
TicketStore makeStore(MemoryFileSystem fs) =>
|
||||||
kanbanDir: '/kanban',
|
TicketStore(kanbanDir: '/kanban', prefix: 'TEST', fs: fs);
|
||||||
prefix: 'TEST',
|
|
||||||
fs: fs,
|
|
||||||
);
|
|
||||||
|
|
||||||
test('create assigns incrementing IDs', () async {
|
test('create assigns incrementing IDs', () async {
|
||||||
final fs = MemoryFileSystem();
|
final fs = MemoryFileSystem();
|
||||||
final store = makeStore(fs);
|
final store = makeStore(fs);
|
||||||
final t1 = await store.create(title: 'First', type: 'task', column: 'todo');
|
final t1 = await store.create(
|
||||||
final t2 = await store.create(title: 'Second', type: 'bug', column: 'todo');
|
title: 'First',
|
||||||
|
type: 'task',
|
||||||
|
column: 'todo',
|
||||||
|
);
|
||||||
|
final t2 = await store.create(
|
||||||
|
title: 'Second',
|
||||||
|
type: 'bug',
|
||||||
|
column: 'todo',
|
||||||
|
);
|
||||||
expect(t1.id, 'TEST-0001');
|
expect(t1.id, 'TEST-0001');
|
||||||
expect(t2.id, 'TEST-0002');
|
expect(t2.id, 'TEST-0002');
|
||||||
});
|
});
|
||||||
|
|
@ -407,35 +457,35 @@ dew:
|
||||||
test('delete throws for missing ticket', () async {
|
test('delete throws for missing ticket', () async {
|
||||||
final fs = MemoryFileSystem();
|
final fs = MemoryFileSystem();
|
||||||
final store = makeStore(fs);
|
final store = makeStore(fs);
|
||||||
expect(
|
expect(() => store.delete('TEST-0099'), throwsA(isA<ArgumentError>()));
|
||||||
() => store.delete('TEST-0099'),
|
|
||||||
throwsA(isA<ArgumentError>()),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('linkTickets adds typed link bidirectionally and is idempotent', () async {
|
test(
|
||||||
final fs = MemoryFileSystem();
|
'linkTickets adds typed link bidirectionally and is idempotent',
|
||||||
final store = makeStore(fs);
|
() async {
|
||||||
await store.create(title: 'A', type: 'task', column: 'todo');
|
final fs = MemoryFileSystem();
|
||||||
await store.create(title: 'B', type: 'task', column: 'todo');
|
final store = makeStore(fs);
|
||||||
await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks');
|
await store.create(title: 'A', type: 'task', column: 'todo');
|
||||||
|
await store.create(title: 'B', type: 'task', column: 'todo');
|
||||||
|
await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks');
|
||||||
|
|
||||||
final a = await store.findById('TEST-0001');
|
final a = await store.findById('TEST-0001');
|
||||||
expect(a!.links, hasLength(1));
|
expect(a!.links, hasLength(1));
|
||||||
expect(a.links.first.targetId, 'TEST-0002');
|
expect(a.links.first.targetId, 'TEST-0002');
|
||||||
expect(a.links.first.type, 'blocks');
|
expect(a.links.first.type, 'blocks');
|
||||||
|
|
||||||
// Inverse written on target.
|
// Inverse written on target.
|
||||||
final b = await store.findById('TEST-0002');
|
final b = await store.findById('TEST-0002');
|
||||||
expect(b!.links, hasLength(1));
|
expect(b!.links, hasLength(1));
|
||||||
expect(b.links.first.targetId, 'TEST-0001');
|
expect(b.links.first.targetId, 'TEST-0001');
|
||||||
expect(b.links.first.type, 'is_blocked_by');
|
expect(b.links.first.type, 'is_blocked_by');
|
||||||
|
|
||||||
// Idempotent — calling again doesn't add duplicates.
|
// Idempotent — calling again doesn't add duplicates.
|
||||||
await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks');
|
await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks');
|
||||||
final a2 = await store.findById('TEST-0001');
|
final a2 = await store.findById('TEST-0001');
|
||||||
expect(a2!.links, hasLength(1));
|
expect(a2!.links, hasLength(1));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('linkTickets relates_to is symmetric', () async {
|
test('linkTickets relates_to is symmetric', () async {
|
||||||
final fs = MemoryFileSystem();
|
final fs = MemoryFileSystem();
|
||||||
|
|
|
||||||
146
packages/kanban/test/integration_test.dart
Normal file
146
packages/kanban/test/integration_test.dart
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import 'package:dew_kanban/dew_kanban.dart';
|
||||||
|
import 'package:file/memory.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
const _testConfig = '''
|
||||||
|
dew:
|
||||||
|
mcp:
|
||||||
|
host: localhost
|
||||||
|
port: 9090
|
||||||
|
kanban:
|
||||||
|
prefix: T
|
||||||
|
ticket_types:
|
||||||
|
- id: task
|
||||||
|
name: Task
|
||||||
|
columns:
|
||||||
|
- id: todo
|
||||||
|
name: To Do
|
||||||
|
color: blue
|
||||||
|
- id: doing
|
||||||
|
name: Doing
|
||||||
|
color: yellow
|
||||||
|
- id: done
|
||||||
|
name: Done
|
||||||
|
color: green
|
||||||
|
''';
|
||||||
|
|
||||||
|
TicketStore _makeStore(MemoryFileSystem fs) {
|
||||||
|
fs.directory('/.project/kanban').createSync(recursive: true);
|
||||||
|
fs.file('/.project/dew.yaml').writeAsStringSync(_testConfig);
|
||||||
|
return TicketStore(kanbanDir: '/.project/kanban', prefix: 'T', fs: fs);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('TicketStore integration', () {
|
||||||
|
late MemoryFileSystem fs;
|
||||||
|
late TicketStore store;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
fs = MemoryFileSystem();
|
||||||
|
store = _makeStore(fs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'create() writes file at correct path with YAML frontmatter',
|
||||||
|
() async {
|
||||||
|
final ticket = await store.create(
|
||||||
|
title: 'My ticket',
|
||||||
|
type: 'task',
|
||||||
|
column: 'todo',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ticket.id, 'T-0001');
|
||||||
|
final file = fs.file('/.project/kanban/todo/T-0001.md');
|
||||||
|
expect(await file.exists(), isTrue);
|
||||||
|
final content = await file.readAsString();
|
||||||
|
expect(content, contains('title: My ticket'));
|
||||||
|
expect(content, contains('type: task'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('list() returns ticket created via create()', () async {
|
||||||
|
await store.create(title: 'Alpha', type: 'task', column: 'todo');
|
||||||
|
final tickets = await store.list();
|
||||||
|
expect(tickets, hasLength(1));
|
||||||
|
expect(tickets.first.id, 'T-0001');
|
||||||
|
expect(tickets.first.title, 'Alpha');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update(column:) moves ticket to correct directory', () async {
|
||||||
|
await store.create(title: 'Move me', type: 'task', column: 'todo');
|
||||||
|
|
||||||
|
await store.update('T-0001', column: 'doing');
|
||||||
|
|
||||||
|
// File removed from old location.
|
||||||
|
expect(
|
||||||
|
await fs.file('/.project/kanban/todo/T-0001.md').exists(),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
// File present in new location.
|
||||||
|
expect(
|
||||||
|
await fs.file('/.project/kanban/doing/T-0001.md').exists(),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
final ticket = await store.findById('T-0001');
|
||||||
|
expect(ticket!.column, 'doing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('linkTickets() adds link to both ticket files', () async {
|
||||||
|
await store.create(title: 'Source', type: 'task', column: 'todo');
|
||||||
|
await store.create(title: 'Target', type: 'task', column: 'todo');
|
||||||
|
|
||||||
|
await store.linkTickets('T-0001', 'T-0002', 'relates_to');
|
||||||
|
|
||||||
|
final sourceContent = await fs
|
||||||
|
.file('/.project/kanban/todo/T-0001.md')
|
||||||
|
.readAsString();
|
||||||
|
final targetContent = await fs
|
||||||
|
.file('/.project/kanban/todo/T-0002.md')
|
||||||
|
.readAsString();
|
||||||
|
|
||||||
|
expect(sourceContent, contains('T-0002'));
|
||||||
|
expect(targetContent, contains('T-0001'));
|
||||||
|
|
||||||
|
final source = await store.findById('T-0001');
|
||||||
|
final target = await store.findById('T-0002');
|
||||||
|
expect(source!.links.any((l) => l.targetId == 'T-0002'), isTrue);
|
||||||
|
expect(target!.links.any((l) => l.targetId == 'T-0001'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unlinkTickets() removes link from both ticket files', () async {
|
||||||
|
await store.create(title: 'Source', type: 'task', column: 'todo');
|
||||||
|
await store.create(title: 'Target', type: 'task', column: 'todo');
|
||||||
|
await store.linkTickets('T-0001', 'T-0002', 'relates_to');
|
||||||
|
|
||||||
|
await store.unlinkTickets('T-0001', 'T-0002');
|
||||||
|
|
||||||
|
final source = await store.findById('T-0001');
|
||||||
|
final target = await store.findById('T-0002');
|
||||||
|
expect(source!.links, isEmpty);
|
||||||
|
expect(target!.links, isEmpty);
|
||||||
|
|
||||||
|
final sourceContent = await fs
|
||||||
|
.file('/.project/kanban/todo/T-0001.md')
|
||||||
|
.readAsString();
|
||||||
|
final targetContent = await fs
|
||||||
|
.file('/.project/kanban/todo/T-0002.md')
|
||||||
|
.readAsString();
|
||||||
|
expect(sourceContent, isNot(contains('T-0002')));
|
||||||
|
expect(targetContent, isNot(contains('T-0001')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete() removes ticket file from disk', () async {
|
||||||
|
await store.create(title: 'Doomed', type: 'task', column: 'todo');
|
||||||
|
expect(await fs.file('/.project/kanban/todo/T-0001.md').exists(), isTrue);
|
||||||
|
|
||||||
|
await store.delete('T-0001');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await fs.file('/.project/kanban/todo/T-0001.md').exists(),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(await store.findById('T-0001'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
21
packages/mcp/LICENSE
Normal file
21
packages/mcp/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 artificery-dev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -29,9 +29,6 @@ class ServeCommand extends DewCommand {
|
||||||
|
|
||||||
// stdioChannel subscribes to stdin; do not touch stdin after this point.
|
// stdioChannel subscribes to stdin; do not touch stdin after this point.
|
||||||
// The Dart event loop keeps the process alive until the client disconnects.
|
// The Dart event loop keeps the process alive until the client disconnects.
|
||||||
DewMcpServer(
|
DewMcpServer(stdioChannel(input: io.stdin, output: io.stdout), tools);
|
||||||
stdioChannel(input: io.stdin, output: io.stdout),
|
|
||||||
tools,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
name: dew_mcp
|
name: dew_mcp
|
||||||
description: MCP server for the Dew project management tool. Collects and serves tools registered by feature packages via the McpToolProvider interface.
|
description: MCP server for the Dew project management tool. Collects and serves tools registered by feature packages via the McpToolProvider interface.
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
# repository: https://github.com/my_org/my_repo
|
repository: https://github.com/artificery-dev/dew
|
||||||
publish_to: none
|
issue_tracker: https://github.com/artificery-dev/dew/issues
|
||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -10,11 +10,11 @@ environment:
|
||||||
|
|
||||||
# Add regular dependencies here.
|
# Add regular dependencies here.
|
||||||
dependencies:
|
dependencies:
|
||||||
dew_core:
|
dew_core: ^1.0.0
|
||||||
path: ../core
|
|
||||||
dart_mcp: ^0.5.0
|
dart_mcp: ^0.5.0
|
||||||
yaml: ^3.1.0
|
yaml: ^3.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: ^6.0.0
|
lints: ^6.0.0
|
||||||
test: ^1.25.6
|
test: ^1.25.6
|
||||||
|
dew_kanban: ^1.0.0
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
import 'package:dew_kanban/dew_kanban.dart' as kanban;
|
||||||
import 'package:dew_mcp/dew_mcp.dart';
|
import 'package:dew_mcp/dew_mcp.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
|
@ -34,6 +35,66 @@ void main() {
|
||||||
expect(registry.mcpTools.first.name, 'stub_tool');
|
expect(registry.mcpTools.first.name, 'stub_tool');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('Kanban MCP tools via CommandRegistry', () {
|
||||||
|
late List<McpTool> tools;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
final registry = CommandRegistry();
|
||||||
|
kanban.registerCommands(registry);
|
||||||
|
tools = registry.mcpTools;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all 15 expected tool names are registered', () {
|
||||||
|
expect(tools, hasLength(15));
|
||||||
|
expect(tools.map((t) => t.name).toSet(), {
|
||||||
|
'kanban_create_ticket',
|
||||||
|
'kanban_list_tickets',
|
||||||
|
'kanban_board',
|
||||||
|
'kanban_get_ticket',
|
||||||
|
'kanban_update_ticket',
|
||||||
|
'kanban_delete_ticket',
|
||||||
|
'kanban_archive_ticket',
|
||||||
|
'kanban_unarchive_ticket',
|
||||||
|
'kanban_move_ticket',
|
||||||
|
'kanban_search_tickets',
|
||||||
|
'kanban_add_comment',
|
||||||
|
'kanban_get_config',
|
||||||
|
'kanban_stats',
|
||||||
|
'kanban_link_tickets',
|
||||||
|
'kanban_unlink_tickets',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kanban_create_ticket schema requires title and type', () {
|
||||||
|
final create = tools.firstWhere((t) => t.name == 'kanban_create_ticket');
|
||||||
|
final required = create.inputSchema['required'] as List;
|
||||||
|
expect(required, containsAll(['title', 'type']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kanban_add_comment schema requires id and comment', () {
|
||||||
|
final comment = tools.firstWhere((t) => t.name == 'kanban_add_comment');
|
||||||
|
final required = comment.inputSchema['required'] as List;
|
||||||
|
expect(required, containsAll(['id', 'comment']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kanban_search_tickets schema requires query', () {
|
||||||
|
final search = tools.firstWhere((t) => t.name == 'kanban_search_tickets');
|
||||||
|
final required = search.inputSchema['required'] as List;
|
||||||
|
expect(required, contains('query'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all tool names follow the snake_case kanban_ prefix pattern', () {
|
||||||
|
final pattern = RegExp(r'^kanban_[a-z]+(_[a-z]+)*$');
|
||||||
|
for (final tool in tools) {
|
||||||
|
expect(
|
||||||
|
pattern.hasMatch(tool.name),
|
||||||
|
isTrue,
|
||||||
|
reason: '${tool.name} does not match snake_case kanban_ pattern',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StubToolCommand extends DewCommand with DewToolCommand {
|
class _StubToolCommand extends DewCommand with DewToolCommand {
|
||||||
|
|
@ -64,4 +125,3 @@ class _StubParentCommand extends DewCommand {
|
||||||
@override
|
@override
|
||||||
Future<void> run() async => printUsage();
|
Future<void> run() async => printUsage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ dev_dependencies:
|
||||||
lints: ^6.0.0
|
lints: ^6.0.0
|
||||||
melos: ^7.0.0
|
melos: ^7.0.0
|
||||||
|
|
||||||
|
|
||||||
melos:
|
melos:
|
||||||
scripts:
|
scripts:
|
||||||
analyze:
|
analyze:
|
||||||
|
|
|
||||||
|
|
@ -55,22 +55,26 @@ void main(List<String> args) async {
|
||||||
protocolLogSink: protocolLog.sink,
|
protocolLogSink: protocolLog.sink,
|
||||||
);
|
);
|
||||||
|
|
||||||
unawaited(connection.done.then((_) {
|
unawaited(
|
||||||
stderr.writeln('[client] Connection closed.');
|
connection.done.then((_) {
|
||||||
process.kill();
|
stderr.writeln('[client] Connection closed.');
|
||||||
}));
|
process.kill();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// --- Initialise ---
|
// --- Initialise ---
|
||||||
print('Sending initialize...');
|
print('Sending initialize...');
|
||||||
late InitializeResult initResult;
|
late InitializeResult initResult;
|
||||||
try {
|
try {
|
||||||
initResult = await connection.initialize(
|
initResult = await connection
|
||||||
InitializeRequest(
|
.initialize(
|
||||||
protocolVersion: ProtocolVersion.latestSupported,
|
InitializeRequest(
|
||||||
capabilities: client.capabilities,
|
protocolVersion: ProtocolVersion.latestSupported,
|
||||||
clientInfo: client.implementation,
|
capabilities: client.capabilities,
|
||||||
),
|
clientInfo: client.implementation,
|
||||||
).timeout(const Duration(seconds: 5));
|
),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 5));
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
stderr.writeln('[client] Timed out waiting for initialize response.');
|
stderr.writeln('[client] Timed out waiting for initialize response.');
|
||||||
process.kill();
|
process.kill();
|
||||||
|
|
@ -81,7 +85,9 @@ void main(List<String> args) async {
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
print('Server: ${initResult.serverInfo.name} ${initResult.serverInfo.version}');
|
print(
|
||||||
|
'Server: ${initResult.serverInfo.name} ${initResult.serverInfo.version}',
|
||||||
|
);
|
||||||
print('Protocol: ${initResult.protocolVersion}');
|
print('Protocol: ${initResult.protocolVersion}');
|
||||||
print('');
|
print('');
|
||||||
|
|
||||||
|
|
@ -114,7 +120,9 @@ void main(List<String> args) async {
|
||||||
CallToolRequest(name: 'kanban_list_tickets', arguments: {}),
|
CallToolRequest(name: 'kanban_list_tickets', arguments: {}),
|
||||||
);
|
);
|
||||||
if (result.isError == true) {
|
if (result.isError == true) {
|
||||||
print(' Error: ${result.content.map((c) => (c as TextContent).text).join()}');
|
print(
|
||||||
|
' Error: ${result.content.map((c) => (c as TextContent).text).join()}',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
print(' Result:');
|
print(' Result:');
|
||||||
for (final c in result.content) {
|
for (final c in result.content) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue