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.
This commit is contained in:
Arcline Dev
2026-07-03 21:35:32 -05:00
commit c6dc1a2acf
9 changed files with 673 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@@ -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/

62
README.md Normal file
View File

@@ -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 <id>` | `v` | Show ticket details |
| `tickets update <id>`| `u` | Modify a ticket |
| `tickets delete <id>`| `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.

19
pyproject.toml Normal file
View File

@@ -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"]

2
src/tickets/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Tickets - A lightweight CLI ticket tracker."""

218
src/tickets/cli.py Normal file
View File

@@ -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()

100
src/tickets/models.py Normal file
View File

@@ -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()

97
src/tickets/store.py Normal file
View File

@@ -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

0
tests/__init__.py Normal file
View File

154
tests/test_tickets.py Normal file
View File

@@ -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])