diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5c7ba56 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: "๐Ÿ› Bug Report" +about: "Report something that is broken or not working as expected." +title: "[Bug] " +labels: ["bug", "triage"] +assignees: [] +--- + +## Description + + + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + + + +## Actual Behavior + + + +## Environment + +- **OS / Distro:** +- **Browser / Runtime version:** +- **Commit / Release:** + +## Additional Context + + + diff --git a/.gitea/ISSUE_TEMPLATE/config.yml b/.gitea/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1e2f090 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,10 @@ +blank_issues_enabled: true + +contact_links: + - name: "โ“ Ask a Question" + url: https://gitea.arcline-project.local/ + about: "Use the discussions or chat instead." + - name: "๐Ÿ“– Project Docs" + url: https://docs.arcline-project.local/ + about: "Read the documentation before filing an issue." + diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..d1b733a --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,28 @@ +--- +name: "โœจ Feature Request" +about: "Propose a new feature or enhancement." +title: "[Feature] " +labels: ["enhancement", "triage"] +assignees: [] +--- + +## Problem Statement + + + +## Proposed Solution + + + +## Alternatives Considered + + + +## Acceptance Criteria + +- [ ] + +## Additional Context + + + diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..84e5d14 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,26 @@ +--- +name: "๐Ÿ“‹ Task / Epic" +about: "Track a general task, investigation, or epic." +title: "[Task] " +labels: ["task"] +assignees: [] +--- + +## Summary + + + +## Subtasks + +- [ ] +- [ ] +- [ ] + +## Dependencies + + + +## Notes + + + diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 0000000..2c958c0 --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,310 @@ +# Initial Ticket Backlog + +Curated list of tickets to file in Gitea Issues. Copy each block into a new issue using the matching template. + +--- + +## ๐Ÿ—๏ธ Project Foundation + +### 1. Repository bootstrapping + +> **Template:** ๐Ÿ“‹ Task / Epic +> **Labels:** `task` +> **Priority:** P0 + +**Title:** `[P0] [Task] Bootstrap repo structure and tooling` + +- [ ] Add `.gitignore`, `.editorconfig`, `CONTRIBUTING.md` +- [ ] Set up branch protection on `main` +- [ ] Configure CI linting (shellcheck, markdownlint, yamllint) +- [ ] Add pre-commit hooks + +--- + +### 2. README & onboarding docs + +> **Template:** ๐Ÿ“‹ Task / Epic +> **Labels:** `documentation` +> **Priority:** P0 + +**Title:** `[P0] [Task] Write project README and onboarding guide` + +- [ ] High-level architecture overview +- [ ] Local development setup instructions +- [ ] Contribution workflow (branching, commits, reviews) +- [ ] Link to this tickets repo + +--- + +### 3. CI pipeline + +> **Template:** ๐Ÿ“‹ Task / Epic +> **Labels:** `task` +> **Priority:** P0 + +**Title:** `[P0] [Task] Set up CI pipeline` + +- [ ] Build step (compile / install deps) +- [ ] Lint step (static analysis) +- [ ] Test step (unit + integration) +- [ ] Status badge in README + +--- + +## ๐Ÿงช Testing + +### 4. Unit-test scaffolding + +> **Template:** ๐Ÿ“‹ Task / Epic +> **Labels:** `task` +> **Priority:** P1 + +**Title:** `[P1] [Task] Scaffold unit-test framework` + +- [ ] Choose and wire up a test runner +- [ ] Add a single passing smoke test +- [ ] Document how to run tests locally + +--- + +### 5. Integration tests + +> **Template:** ๐Ÿ“‹ Task / Epic +> **Labels:** `task` +> **Priority:** P1 + +**Title:** `[P1] [Task] Add integration / end-to-end test harness` + +- [ ] Spin up a local environment for tests +- [ ] Write 2-3 critical-path integration tests +- [ ] Wire into CI + +--- + +### 6. Test coverage reporting + +> **Template:** โœจ Feature Request +> **Labels:** `enhancement` +> **Priority:** P2 + +**Title:** `[P2] [Feature] Add test coverage reporting` + +> **Problem:** No visibility into what code paths have tests. +> **Solution:** Generate coverage reports in CI and block merges below a threshold (e.g. 80โ€ฏ%). + +--- + +## ๐Ÿ”’ Security & Quality + +### 7. Dependency scanning + +> **Template:** โœจ Feature Request +> **Labels:** `enhancement` +> **Priority:** P1 + +**Title:** `[P1] [Feature] Automated dependency vulnerability scanning` + +> **Problem:** Outdated or vulnerable dependencies slip through. +> **Solution:** Add [Dependabot](https://github.com/dependabot) (or Renovate) + a SAST scanner in CI. + +--- + +### 8. Secrets management + +> **Template:** ๐Ÿ“‹ Task / Epic +> **Labels:** `task` +> **Priority:** P0 + +**Title:** `[P0] [Task] Audit and secure secrets management` + +- [ ] Verify no secrets are committed to the repo +- [ ] Set up `.env` conventions +- [ ] Document how to inject secrets in dev and prod + +--- + +### 9. Linting & formatting + +> **Template:** ๐Ÿ“‹ Task / Epic +> **Labels:** `task` +> **Priority:** P1 + +**Title:** `[P1] [Task] Enforce consistent linting and formatting` + +- [ ] Choose and configure formatters / linters for each language in the stack +- [ ] Add pre-commit hook (or CI check) that blocks violations +- [ ] Format the existing codebase + +--- + +## ๐Ÿ“– Documentation + +### 10. API / interface docs + +> **Template:** ๐Ÿ“‹ Task / Epic +> **Labels:** `documentation` +> **Priority:** P1 + +**Title:** `[P1] [Task] Generate and publish API documentation` + +- [ ] Select a doc generator (e.g. OpenAPI, Sphinx, JSDoc) +- [ ] Add docstrings / annotations to public interfaces +- [ ] Publish to a static site or docs host + +--- + +### 11. Architecture Decision Records + +> **Template:** โœจ Feature Request +> **Labels:** `enhancement`, `documentation` +> **Priority:** P2 + +**Title:** `[P2] [Feature] Adopt Architecture Decision Records (ADRs)` + +> **Problem:** Design decisions are made in chat and forgotten. +> **Solution:** Maintain a `docs/adr/` directory with lightweight decision records. + +--- + +## ๐Ÿ› Placeholder Bugs (for testing the flow) + +### 12. Template: Bug Report + +> **Template:** ๐Ÿ› Bug Report +> **Labels:** `bug` +> **Priority:** P2 + +**Title:** `[P2] [Bug] Example bug โ€” page title wraps on small viewports` + +| Field | Value | +|---|---| +| **Steps** | Resize browser to 320โ€ฏpx wide, open the landing page. | +| **Expected** | Title stays on one line or truncates gracefully. | +| **Actual** | Title wraps and overlaps the nav bar. | + +--- + +### 13. Template: Bug Report + +> **Template:** ๐Ÿ› Bug Report +> **Labels:** `bug` +> **Priority:** P3 + +**Title:** `[P3] [Bug] Example bug โ€” console warns about missing key prop` + +| Field | Value | +|---|---| +| **Steps** | Open dev tools, navigate to the dashboard. | +| **Expected** | No React key warnings. | +| **Actual** | `Warning: Each child in a list should have a unique "key" prop.` | + +--- + +## ๐Ÿš€ Features + +### 14. Health-check endpoint + +> **Template:** โœจ Feature Request +> **Labels:** `enhancement` +> **Priority:** P1 + +**Title:** `[P1] [Feature] Add health-check endpoint` + +> **Problem:** No way for load balancers or monitoring to know if the service is alive. +> **Solution:** Expose `GET /health` returning `{"status":"ok"}` and a quick DB ping. + +--- + +### 15. Structured logging + +> **Template:** โœจ Feature Request +> **Labels:** `enhancement` +> **Priority:** P1 + +**Title:** `[P1] [Feature] Switch to structured (JSON) logging` + +> **Problem:** Logs are plain-text and hard to query. +> **Solution:** Emit JSON log lines with consistent keys (`level`, `message`, `timestamp`, `trace_id`). + +--- + +### 16. Feature flags + +> **Template:** โœจ Feature Request +> **Labels:** `enhancement` +> **Priority:** P2 + +**Title:** `[P2] [Feature] Introduce feature-flag system` + +> **Problem:** Can't safely ship partial or experimental features. +> **Solution:** Use a simple flag provider (env vars initially) to gate features. + +--- + +### 17. Observability / metrics + +> **Template:** โœจ Feature Request +> **Labels:** `enhancement` +> **Priority:** P2 + +**Title:** `[P2] [Feature] Export app metrics for monitoring` + +> **Problem:** Can't answer "is the system healthy?" without looking at logs. +> **Solution:** Export request duration, error rate, and DB query metrics. + +--- + +## ๐Ÿค Community & Maintenance + +### 18. CONTRIBUTING guide + +> **Template:** ๐Ÿ“‹ Task / Epic +> **Labels:** `documentation`, `good first issue` +> **Priority:** P1 + +**Title:** `[P1] [Task] Write CONTRIBUTING.md` + +- [ ] Code style and conventions +- [ ] PR template and review expectations +- [ ] Local development quick-start + +--- + +### 19. Issue triage process + +> **Template:** ๐Ÿ“‹ Task / Epic +> **Labels:** `task` +> **Priority:** P2 + +**Title:** `[P2] [Task] Define and document the issue triage process` + +- [ ] Weekly triage meeting cadence +- [ ] SLA for first response on new issues +- [ ] How issues move from `triage` โ†’ `accepted` + +--- + +### 20. Release checklist + +> **Template:** ๐Ÿ“‹ Task / Epic +> **Labels:** `task` +> **Priority:** P2 + +**Title:** `[P2] [Task] Create a release checklist** + +- [ ] Version bump convention +- [ ] CHANGELOG update step +- [ ] Tagging and release notes +- [ ] Post-deploy smoke-test commands + +--- + +## Filing Instructions + +1. Open the **Issues** tab in this repo. +2. Pick the template that matches the ticket above. +3. Copy-paste the title, description, labels, and priority. +4. Submit. + +Repeat until the entire backlog has been filed. Triage and assign each ticket in a follow-up pass. + diff --git a/README.md b/README.md index 584dc77..89cad13 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,62 @@ # Tickets -A lightweight CLI ticket tracker that stores tickets as JSON in your home directory (`~/.tickets/tickets.json`). +Central issue tracker and ticket backlog for the **Arcline Project**. + +All work is tracked as **Gitea Issues** in this repository. No external tooling required โ€” just open an issue. + +--- ## Quick Start -```bash -# Install in development mode -pip install -e ".[dev]" +### Creating a Ticket -# Run tests -pytest tests/ -v -``` +1. Go to the **Issues** tab in this repo. +2. Click **New Issue**. +3. Pick a template: + - ๐Ÿ› **Bug Report** โ€” something is broken. + - โœจ **Feature Request** โ€” propose something new. + - ๐Ÿ“‹ **Task / Epic** โ€” a general task, spike, or umbrella issue. +4. Fill in the fields, attach labels, and submit. -## Commands +> The templates live in `.gitea/ISSUE_TEMPLATE/` if you want to tweak them. -| 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 | +### Labels -### Examples +| Label | Purpose | +|----------------|----------------------------------------------| +| `bug` | Defect or unexpected behaviour | +| `enhancement` | New capability or improvement | +| `task` | General work item | +| `epic` | Large body of work spanning multiple issues | +| `triage` | Needs prioritisation / hasn't been reviewed | +| `documentation`| Docs, READMEs, comments | +| `good first issue` | Beginner-friendly starting point | +| `blocked` | Waiting on something else | +| `wontfix` | Acknowledged but not planned | -```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 +### Prioritisation -# List all tickets -tickets list +Prefix issue titles to signal urgency at a glance: -# Filter by status or priority -tickets list -s todo -tickets list -p critical +- `[P0]` โ€” Drop everything. Critical / blocking. +- `[P1]` โ€” Important. Should be in the current iteration. +- `[P2]` โ€” Nice to have. Backlog. +- `[P3]` โ€” Someday / maybe. -# View details -tickets view abc12345 +Example: `[P1] [Bug] Login page returns 500 on `\n` in username` -# 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 -``` +## Workflow -## Fields +1. **Open** an issue using a template. +2. **Triage** โ€” assign labels, priority, milestone, and an owner. +3. **Work** โ€” branch off `main`, reference the issue in commits (`#42`). +4. **Close** โ€” when the work lands, close the issue (or let a PR close it automatically with `Closes #42`). -- **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 +## Backlog -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. +See [`ISSUES.md`](./ISSUES.md) for the initial curated backlog of tickets ready to be filed. diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index aff3c6c..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -[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 deleted file mode 100644 index 5e71c44..0000000 --- a/src/tickets/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Tickets - A lightweight CLI ticket tracker.""" - diff --git a/src/tickets/cli.py b/src/tickets/cli.py deleted file mode 100644 index 6550a05..0000000 --- a/src/tickets/cli.py +++ /dev/null @@ -1,218 +0,0 @@ -"""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 deleted file mode 100644 index c989af1..0000000 --- a/src/tickets/models.py +++ /dev/null @@ -1,100 +0,0 @@ -"""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 deleted file mode 100644 index 383f27b..0000000 --- a/src/tickets/store.py +++ /dev/null @@ -1,97 +0,0 @@ -"""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 deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_tickets.py b/tests/test_tickets.py deleted file mode 100644 index df3e594..0000000 --- a/tests/test_tickets.py +++ /dev/null @@ -1,154 +0,0 @@ -"""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]) -