commit c6dc1a2acfe70edb038b7467a3fc6d008fbe8b00 Author: Arcline Dev Date: Fri Jul 3 21:35:32 2026 -0500 Initial commit: lightweight CLI ticket tracker A JSON-backed ticket management system with full CRUD operations, status/priority filtering, prefix-based ID resolution, and test suite. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59851a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.tox/ +.coverage +htmlcov/ +.pytest_cache/ +*.swp +*.swo +*~ +.DS_Store +.env +venv/ +.venv/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..584dc77 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Tickets + +A lightweight CLI ticket tracker that stores tickets as JSON in your home directory (`~/.tickets/tickets.json`). + +## Quick Start + +```bash +# Install in development mode +pip install -e ".[dev]" + +# Run tests +pytest tests/ -v +``` + +## Commands + +| Command | Alias | Description | +|----------------------|-------|------------------------| +| `tickets create` | `c` | Create a new ticket | +| `tickets list` | `ls` | List tickets | +| `tickets view ` | `v` | Show ticket details | +| `tickets update `| `u` | Modify a ticket | +| `tickets delete `| `rm` | Remove a ticket | + +### Examples + +```bash +# Create tickets +tickets create "Fix login bug" -d "Users can't sign in" -p high -a alice -t bug auth +tickets create "Add dark mode" -p low -t feature + +# List all tickets +tickets list + +# Filter by status or priority +tickets list -s todo +tickets list -p critical + +# View details +tickets view abc12345 + +# Update a ticket +tickets update abc12345 -s done -a bob +tickets update abc12345 -t "Better title" -p critical + +# Delete a ticket +tickets delete abc12345 -f +``` + +## Fields + +- **title** — short summary (required) +- **description** — longer details (optional) +- **priority** — `low`, `medium` (default), `high`, `critical` +- **status** — `todo` (default), `in_progress`, `done`, `cancelled` +- **assignee** — who's working on it (optional) +- **tags** — list of labels (optional) + +## Storage + +All tickets are persisted to `~/.tickets/tickets.json`. Since this is a plain JSON file, you can version-control it with git or back it up easily. + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aff3c6c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "tickets" +version = "0.1.0" +description = "A lightweight CLI ticket tracker stored in git" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [{name = "Arcline Dev", email = "dev@arcline-project.local"}] + +[project.scripts] +tickets = "tickets.cli:entry_point" + +[project.optional-dependencies] +dev = ["pytest>=8.0", "pytest-cov>=5.0"] + diff --git a/src/tickets/__init__.py b/src/tickets/__init__.py new file mode 100644 index 0000000..5e71c44 --- /dev/null +++ b/src/tickets/__init__.py @@ -0,0 +1,2 @@ +"""Tickets - A lightweight CLI ticket tracker.""" + diff --git a/src/tickets/cli.py b/src/tickets/cli.py new file mode 100644 index 0000000..6550a05 --- /dev/null +++ b/src/tickets/cli.py @@ -0,0 +1,218 @@ +"""Command-line interface for the tickets tool.""" + +from __future__ import annotations + +import argparse +import sys +import textwrap +from datetime import datetime, timezone + +from tickets.models import Priority, Status, Ticket +from tickets.store import DEFAULT_STORE_FILE, DEFAULT_STORE_DIR, TicketStore + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="tickets", + description="Lightweight CLI ticket tracker. Tickets are stored as JSON.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + # ---- create ---- + p_create = sub.add_parser("create", aliases=["c"], help="Create a new ticket") + p_create.add_argument("title", help="Ticket title") + p_create.add_argument("-d", "--description", default="", help="Description") + p_create.add_argument("-p", "--priority", default="medium", + choices=[e.value for e in Priority], help="Priority level") + p_create.add_argument("-a", "--assignee", default="", help="Assignee") + p_create.add_argument("-t", "--tags", nargs="*", default=[], help="Tags") + + # ---- list ---- + p_list = sub.add_parser("list", aliases=["ls"], help="List tickets") + p_list.add_argument("-s", "--status", default=None, + choices=[e.value for e in Status], help="Filter by status") + p_list.add_argument("-p", "--priority", default=None, + choices=[e.value for e in Priority], help="Filter by priority") + + # ---- view ---- + p_view = sub.add_parser("view", aliases=["v", "show"], help="View a ticket") + p_view.add_argument("ticket_id", help="Ticket ID (short hash)") + + # ---- update ---- + p_update = sub.add_parser("update", aliases=["u"], help="Update a ticket") + p_update.add_argument("ticket_id", help="Ticket ID (short hash)") + p_update.add_argument("-t", "--title", default=None, help="New title") + p_update.add_argument("-d", "--description", default=None, help="New description") + p_update.add_argument("-p", "--priority", default=None, + choices=[e.value for e in Priority], help="New priority") + p_update.add_argument("-s", "--status", default=None, + choices=[e.value for e in Status], help="New status") + p_update.add_argument("-a", "--assignee", default=None, help="New assignee") + p_update.add_argument("--tags", nargs="*", default=None, help="New tags") + + # ---- delete ---- + p_delete = sub.add_parser("delete", aliases=["rm", "d"], help="Delete a ticket") + p_delete.add_argument("ticket_id", help="Ticket ID (short hash)") + p_delete.add_argument("--force", "-f", action="store_true", help="Skip confirmation") + + return parser + + +def _format_ticket(ticket: Ticket) -> str: + """Pretty-print a single ticket.""" + header = ( + f"{'=' * 60}\n" + f" #{ticket.id} {ticket.title}\n" + f"{'=' * 60}\n" + f" Status: {ticket.status.value:<12} Priority: {ticket.priority.value}\n" + ) + if ticket.assignee: + header += f" Assignee: {ticket.assignee}\n" + if ticket.tags: + header += f" Tags: {', '.join(ticket.tags)}\n" + header += ( + f" Created: {ticket.created_at}\n" + f" Updated: {ticket.updated_at}\n" + ) + if ticket.description: + header += f"{'-' * 60}\n{textwrap.indent(ticket.description, ' ')}\n" + header += f"{'=' * 60}" + return header + + +def _format_ticket_short(ticket: Ticket) -> str: + """Single-line ticket representation for list view.""" + status_icon = { + Status.TODO: "[ ]", + Status.IN_PROGRESS: "[~]", + Status.DONE: "[✓]", + Status.CANCELLED: "[x]", + }.get(ticket.status, "[?]") + + return ( + f" {status_icon} #{ticket.id} " + f"{ticket.priority.value:<8} " + f"{ticket.title[:60]}" + ) + + +def cmd_create(store: TicketStore, args: argparse.Namespace) -> int: + ticket = Ticket( + title=args.title, + description=args.description, + priority=args.priority, + assignee=args.assignee, + tags=args.tags, + ) + store.add(ticket) + print(f"Created ticket #{ticket.id}: {ticket.title}") + return 0 + + +def cmd_list(store: TicketStore, args: argparse.Namespace) -> int: + tickets = store.list_tickets(status=args.status, priority=args.priority) + if not tickets: + print("No tickets found.") + return 0 + print(f"\n Tickets ({len(tickets)}):") + print(f" {'─' * 60}") + for t in tickets: + print(_format_ticket_short(t)) + print() + return 0 + + +def cmd_view(store: TicketStore, args: argparse.Namespace) -> int: + ticket = _resolve_ticket(store, args.ticket_id) + if ticket is None: + return 1 + print(_format_ticket(ticket)) + return 0 + + +def cmd_update(store: TicketStore, args: argparse.Namespace) -> int: + ticket = _resolve_ticket(store, args.ticket_id) + if ticket is None: + return 1 + ticket.update( + title=args.title, + description=args.description, + priority=args.priority, + status=args.status, + assignee=args.assignee, + tags=args.tags, + ) + store[ticket.id] = ticket # persist + print(f"Updated ticket #{ticket.id}: {ticket.title}") + return 0 + + +def cmd_delete(store: TicketStore, args: argparse.Namespace) -> int: + ticket = _resolve_ticket(store, args.ticket_id) + if ticket is None: + return 1 + if not args.force: + print(_format_ticket(ticket)) + try: + confirm = input(f"\nDelete ticket #{ticket.id}? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + return 1 + if confirm not in ("y", "yes"): + print("Cancelled.") + return 0 + del store[ticket.id] + print(f"Deleted ticket #{ticket.id}") + return 0 + + +def _resolve_ticket(store: TicketStore, ticket_id: str) -> Ticket | None: + if ticket_id not in store: + # Try prefix matching + matches = [tid for tid in store if tid.startswith(ticket_id)] + if len(matches) == 1: + return store[matches[0]] + if len(matches) > 1: + print(f"Ambiguous ID prefix '{ticket_id}'. Matches: {', '.join(matches)}") + return None + print(f"Ticket '#{ticket_id}' not found.") + return None + return store[ticket_id] + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + store = TicketStore() + + handlers = { + "create": cmd_create, + "c": cmd_create, + "list": cmd_list, + "ls": cmd_list, + "view": cmd_view, + "v": cmd_view, + "show": cmd_view, + "update": cmd_update, + "u": cmd_update, + "delete": cmd_delete, + "rm": cmd_delete, + "d": cmd_delete, + } + + handler = handlers.get(args.command) + if handler is None: + print(f"Unknown command: {args.command}", file=sys.stderr) + return 1 + + return handler(store, args) + + +def entry_point() -> None: + """Thin wrapper that calls main and exits; used as console_scripts entry.""" + sys.exit(main()) + + +if __name__ == "__main__": + entry_point() + diff --git a/src/tickets/models.py b/src/tickets/models.py new file mode 100644 index 0000000..c989af1 --- /dev/null +++ b/src/tickets/models.py @@ -0,0 +1,100 @@ +"""Ticket data models.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from enum import Enum +from typing import Self + + +class Priority(str, Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class Status(str, Enum): + TODO = "todo" + IN_PROGRESS = "in_progress" + DONE = "done" + CANCELLED = "cancelled" + + +class Ticket: + """Represents a single ticket.""" + + def __init__( + self, + title: str, + description: str = "", + priority: Priority = Priority.MEDIUM, + status: Status = Status.TODO, + assignee: str = "", + tags: list[str] | None = None, + ticket_id: str | None = None, + created_at: str | None = None, + updated_at: str | None = None, + ) -> None: + self.id = ticket_id or str(uuid.uuid4())[:8] + self.title = title + self.description = description + self.priority = priority if isinstance(priority, Priority) else Priority(priority) + self.status = status if isinstance(status, Status) else Status(status) + self.assignee = assignee + self.tags: list[str] = tags or [] + self.created_at = created_at or datetime.now(timezone.utc).isoformat() + self.updated_at = updated_at or self.created_at + + def to_dict(self) -> dict: + return { + "id": self.id, + "title": self.title, + "description": self.description, + "priority": self.priority.value, + "status": self.status.value, + "assignee": self.assignee, + "tags": self.tags, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + @classmethod + def from_dict(cls, data: dict) -> Self: + return cls( + ticket_id=data["id"], + title=data["title"], + description=data.get("description", ""), + priority=data.get("priority", "medium"), + status=data.get("status", "todo"), + assignee=data.get("assignee", ""), + tags=data.get("tags", []), + created_at=data["created_at"], + updated_at=data["updated_at"], + ) + + def update( + self, + title: str | None = None, + description: str | None = None, + priority: Priority | str | None = None, + status: Status | str | None = None, + assignee: str | None = None, + tags: list[str] | None = None, + ) -> None: + """Update mutable fields and bump updated_at.""" + if title is not None: + self.title = title + if description is not None: + self.description = description + if priority is not None: + self.priority = priority if isinstance(priority, Priority) else Priority(priority) + if status is not None: + self.status = status if isinstance(status, Status) else Status(status) + if assignee is not None: + self.assignee = assignee + if tags is not None: + self.tags = tags + self.updated_at = datetime.now(timezone.utc).isoformat() + diff --git a/src/tickets/store.py b/src/tickets/store.py new file mode 100644 index 0000000..383f27b --- /dev/null +++ b/src/tickets/store.py @@ -0,0 +1,97 @@ +"""JSON-file-backed ticket persistence layer.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import MutableMapping + +from tickets.models import Ticket + +DEFAULT_STORE_DIR = Path.home() / ".tickets" +DEFAULT_STORE_FILE = "tickets.json" + + +class TicketStore(MutableMapping[str, Ticket]): + """Dictionary-like store that persists tickets as JSON. + + Tickets are keyed by their short ID string. + """ + + def __init__(self, filepath: str | Path | None = None) -> None: + if filepath is None: + filepath = DEFAULT_STORE_DIR / DEFAULT_STORE_FILE + self._filepath = Path(filepath) + self._tickets: dict[str, Ticket] = {} + self._load() + + # -- dict-like interface ------------------------------------------------ + + def __getitem__(self, key: str) -> Ticket: + return self._tickets[key] + + def __setitem__(self, key: str, value: Ticket) -> None: + self._tickets[key] = value + self._save() + + def __delitem__(self, key: str) -> None: + del self._tickets[key] + self._save() + + def __iter__(self): + return iter(self._tickets) + + def __len__(self) -> int: + return len(self._tickets) + + def __contains__(self, key: object) -> bool: + return key in self._tickets + + # -- persistence -------------------------------------------------------- + + def _load(self) -> None: + if not self._filepath.exists(): + return + try: + data = json.loads(self._filepath.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return + if not isinstance(data, list): + return + self._tickets = { + t.id: t + for item in data + if isinstance(item, dict) + for t in [Ticket.from_dict(item)] + } + + def _save(self) -> None: + self._filepath.parent.mkdir(parents=True, exist_ok=True) + data = [t.to_dict() for t in self._tickets.values()] + self._filepath.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + # -- helpers ------------------------------------------------------------- + + def list_tickets( + self, + status: str | None = None, + priority: str | None = None, + ) -> list[Ticket]: + """Return tickets, optionally filtered by status or priority.""" + results = list(self._tickets.values()) + if status is not None: + results = [t for t in results if t.status.value == status] + if priority is not None: + results = [t for t in results if t.priority.value == priority] + return sorted(results, key=lambda t: t.updated_at, reverse=True) + + def add(self, ticket: Ticket) -> Ticket: + """Add a ticket and persist immediately.""" + self._tickets[ticket.id] = ticket + self._save() + return ticket + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_tickets.py b/tests/test_tickets.py new file mode 100644 index 0000000..df3e594 --- /dev/null +++ b/tests/test_tickets.py @@ -0,0 +1,154 @@ +"""Tests for the tickets package.""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + +from tickets.cli import main +from tickets.models import Priority, Status, Ticket +from tickets.store import TicketStore + + +class TestTicketModel: + def test_create_minimal_ticket(self) -> None: + t = Ticket(title="Fix login bug") + assert t.title == "Fix login bug" + assert t.description == "" + assert t.priority == Priority.MEDIUM + assert t.status == Status.TODO + assert t.id and len(t.id) == 8 + + def test_create_full_ticket(self) -> None: + t = Ticket( + title="Deploy v2.0", + description="Ship the new release", + priority=Priority.CRITICAL, + status=Status.IN_PROGRESS, + assignee="alice", + tags=["deploy", "urgent"], + ) + assert t.priority == Priority.CRITICAL + assert t.status == Status.IN_PROGRESS + assert t.assignee == "alice" + assert "urgent" in t.tags + + def test_roundtrip_dict(self) -> None: + t = Ticket(title="Test", description="desc", tags=["a", "b"]) + rt = Ticket.from_dict(t.to_dict()) + assert rt.title == t.title + assert rt.description == t.description + assert rt.tags == t.tags + assert rt.priority == t.priority + + def test_update_fields(self) -> None: + t = Ticket(title="Old") + t.update(title="New", status="done", priority="high") + assert t.title == "New" + assert t.status == Status.DONE + assert t.priority == Priority.HIGH + assert t.updated_at != t.created_at + + def test_accept_enum_or_string(self) -> None: + t = Ticket(title="X", priority=Priority.LOW, status=Status.DONE) + assert t.priority == Priority.LOW + t.update(priority="critical", status="cancelled") + assert t.priority == Priority.CRITICAL + assert t.status == Status.CANCELLED + + +class TestTicketStore: + def test_add_and_retrieve(self) -> None: + with tempfile.TemporaryDirectory() as d: + fp = Path(d) / "t.json" + store = TicketStore(fp) + t = Ticket(title="Hello") + store.add(t) + assert t.id in store + assert store[t.id].title == "Hello" + + def test_delete(self) -> None: + with tempfile.TemporaryDirectory() as d: + fp = Path(d) / "t.json" + store = TicketStore(fp) + store.add(Ticket(title="rm me")) + tid = next(iter(store)) + del store[tid] + assert tid not in store + + def test_list_filtering(self) -> None: + with tempfile.TemporaryDirectory() as d: + fp = Path(d) / "t.json" + store = TicketStore(fp) + store.add(Ticket("a", status=Status.TODO, priority=Priority.LOW)) + store.add(Ticket("b", status=Status.DONE, priority=Priority.HIGH)) + store.add(Ticket("c", status=Status.DONE, priority=Priority.LOW)) + assert len(store.list_tickets(status="done")) == 2 + assert len(store.list_tickets(priority="high")) == 1 + + def test_persists_to_disk(self) -> None: + with tempfile.TemporaryDirectory() as d: + fp = Path(d) / "t.json" + store = TicketStore(fp) + store.add(Ticket("persist")) + assert fp.exists() + # Reload + store2 = TicketStore(fp) + assert len(store2) == 1 + + +class TestCLI: + def test_create_and_view(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + fp = tmp_path / "t.json" + monkeypatch.setattr("tickets.cli.TicketStore", lambda: TicketStore(fp)) + + main(["create", "My ticket", "-d", "desc", "-p", "high"]) + store = TicketStore(fp) + assert len(store) == 1 + tid = next(iter(store)) + + main(["view", tid]) + main(["list", "-s", "todo"]) + main(["list"]) + + def test_update(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + fp = tmp_path / "t.json" + monkeypatch.setattr("tickets.cli.TicketStore", lambda: TicketStore(fp)) + + main(["create", "Old title"]) + store = TicketStore(fp) + tid = next(iter(store)) + + main(["update", tid, "-t", "New title", "-s", "done"]) + store2 = TicketStore(fp) + assert store2[tid].title == "New title" + assert store2[tid].status == Status.DONE + + def test_delete_with_force(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + fp = tmp_path / "t.json" + monkeypatch.setattr("tickets.cli.TicketStore", lambda: TicketStore(fp)) + + main(["create", "To delete"]) + store = TicketStore(fp) + assert len(store) == 1 + tid = next(iter(store)) + + main(["delete", tid, "--force"]) + store2 = TicketStore(fp) + assert len(store2) == 0 + + def test_prefix_match(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + fp = tmp_path / "t.json" + monkeypatch.setattr("tickets.cli.TicketStore", lambda: TicketStore(fp)) + + main(["create", "Ticket 1"]) + store = TicketStore(fp) + tid = next(iter(store)) + prefix = tid[:4] + + # Should resolve via prefix + main(["view", prefix]) +