devtodo 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
devtodo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: devtodo
3
+ Version: 0.1.0
4
+ Summary: A developer-focused CLI todo tool
5
+ Author: jakok
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jakok/devtodo
8
+ Project-URL: Repository, https://github.com/jakok/devtodo
9
+ Keywords: todo,cli,developer,terminal,productivity
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Environment :: Console
13
+ Classifier: Topic :: Utilities
14
+ Requires-Python: >=3.12
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: typer>=0.12
17
+ Requires-Dist: rich>=13
18
+ Requires-Dist: sqlmodel>=0.0.19
19
+ Requires-Dist: textual>=0.80
20
+ Requires-Dist: httpx>=0.27
21
+
22
+ # devtodo 📝
23
+
24
+ A fast, developer-focused CLI todo tool with a beautiful TUI.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pipx install devtodo
30
+ ```
31
+
32
+ > Requires Python 3.12+. Install `pipx` first: `pip install pipx`
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ dt add "Fix login bug" --priority high --project backend
38
+ dt list
39
+ dt list --project backend
40
+ dt done 1
41
+ dt edit 2 --priority low --tags "bug,ui"
42
+ dt remove 3
43
+ dt clear-completed
44
+ dt today # high priority + due today
45
+ dt start # interactive TUI
46
+ ```
47
+
48
+ ## GitHub Integration
49
+
50
+ ```bash
51
+ export GITHUB_TOKEN=ghp_yourtoken
52
+
53
+ dt github sync --repo owner/repo # import open issues as todos
54
+ dt github push 5 --repo owner/repo # push todo #5 as a GitHub issue
55
+ dt github list # show todos linked to GitHub
56
+ ```
57
+
58
+ ## Keyboard shortcuts (TUI)
59
+
60
+ | Key | Action |
61
+ |-----|--------|
62
+ | `a` | Add todo |
63
+ | `e` | Edit selected |
64
+ | `d` | Mark done |
65
+ | `Del` | Remove |
66
+ | `f` / `F` | Filter open / show all |
67
+ | `q` | Quit |
68
+
69
+ ## Data
70
+
71
+ Todos are stored locally at `~/.devtodo/devtodo.db` (SQLite).
@@ -0,0 +1,50 @@
1
+ # devtodo 📝
2
+
3
+ A fast, developer-focused CLI todo tool with a beautiful TUI.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pipx install devtodo
9
+ ```
10
+
11
+ > Requires Python 3.12+. Install `pipx` first: `pip install pipx`
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ dt add "Fix login bug" --priority high --project backend
17
+ dt list
18
+ dt list --project backend
19
+ dt done 1
20
+ dt edit 2 --priority low --tags "bug,ui"
21
+ dt remove 3
22
+ dt clear-completed
23
+ dt today # high priority + due today
24
+ dt start # interactive TUI
25
+ ```
26
+
27
+ ## GitHub Integration
28
+
29
+ ```bash
30
+ export GITHUB_TOKEN=ghp_yourtoken
31
+
32
+ dt github sync --repo owner/repo # import open issues as todos
33
+ dt github push 5 --repo owner/repo # push todo #5 as a GitHub issue
34
+ dt github list # show todos linked to GitHub
35
+ ```
36
+
37
+ ## Keyboard shortcuts (TUI)
38
+
39
+ | Key | Action |
40
+ |-----|--------|
41
+ | `a` | Add todo |
42
+ | `e` | Edit selected |
43
+ | `d` | Mark done |
44
+ | `Del` | Remove |
45
+ | `f` / `F` | Filter open / show all |
46
+ | `q` | Quit |
47
+
48
+ ## Data
49
+
50
+ Todos are stored locally at `~/.devtodo/devtodo.db` (SQLite).
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "devtodo"
7
+ version = "0.1.0"
8
+ description = "A developer-focused CLI todo tool"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "jakok" }]
13
+ keywords = ["todo", "cli", "developer", "terminal", "productivity"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Environment :: Console",
18
+ "Topic :: Utilities",
19
+ ]
20
+ dependencies = [
21
+ "typer>=0.12",
22
+ "rich>=13",
23
+ "sqlmodel>=0.0.19",
24
+ "textual>=0.80",
25
+ "httpx>=0.27",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/jakok/devtodo"
30
+ Repository = "https://github.com/jakok/devtodo"
31
+
32
+ [project.scripts]
33
+ devtodo = "devtodo.main:app"
34
+ dt = "devtodo.main:app"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """devtodo – a developer-focused CLI todo tool."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,44 @@
1
+ from typing import Optional
2
+ import typer
3
+ from devtodo.db import init_db, get_session
4
+ from devtodo.services.todo_service import TodoService
5
+ from devtodo.utils.output import success, error, print_todo
6
+ from devtodo.utils.dates import parse_date
7
+
8
+ app = typer.Typer(help="Add a new todo.")
9
+
10
+
11
+ @app.callback(invoke_without_command=True)
12
+ def add(
13
+ title: str = typer.Argument(..., help="Title of the todo"),
14
+ description: Optional[str] = typer.Option(None, "--desc", "-d", help="Optional description"),
15
+ priority: str = typer.Option("medium", "--priority", "-p", help="Priority: low, medium, high"),
16
+ project: Optional[str] = typer.Option(None, "--project", "-P", help="Project name"),
17
+ tags: Optional[str] = typer.Option(None, "--tags", "-t", help="Comma-separated tags"),
18
+ due: Optional[str] = typer.Option(None, "--due", help="Due date (YYYY-MM-DD)"),
19
+ ) -> None:
20
+ if priority not in ("low", "medium", "high"):
21
+ error(f"Invalid priority '{priority}'. Use: low, medium, high")
22
+ raise typer.Exit(1)
23
+
24
+ due_date = None
25
+ if due:
26
+ try:
27
+ due_date = parse_date(due)
28
+ except ValueError as e:
29
+ error(str(e))
30
+ raise typer.Exit(1)
31
+
32
+ engine = init_db()
33
+ with get_session(engine) as session:
34
+ service = TodoService(session)
35
+ todo = service.add(
36
+ title=title,
37
+ description=description,
38
+ priority=priority,
39
+ project=project,
40
+ tags=tags,
41
+ due_date=due_date,
42
+ )
43
+ success(f"Added todo [bold]#{todo.id}[/bold]: {todo.title}")
44
+ print_todo(todo)
@@ -0,0 +1,22 @@
1
+ import typer
2
+ from devtodo.db import init_db, get_session
3
+ from devtodo.services.todo_service import TodoService
4
+ from devtodo.utils.output import success, error
5
+
6
+ app = typer.Typer(help="Mark a todo as done.")
7
+
8
+
9
+ @app.callback(invoke_without_command=True)
10
+ def done(
11
+ todo_id: int = typer.Argument(..., help="ID of the todo to mark as done"),
12
+ ) -> None:
13
+ engine = init_db()
14
+ with get_session(engine) as session:
15
+ service = TodoService(session)
16
+ todo = service.mark_done(todo_id)
17
+
18
+ if todo:
19
+ success(f"Todo [bold]#{todo.id}[/bold] marked as done: {todo.title}")
20
+ else:
21
+ error(f"Todo #{todo_id} not found.")
22
+ raise typer.Exit(1)
@@ -0,0 +1,56 @@
1
+ from typing import Optional
2
+ import typer
3
+ from devtodo.db import init_db, get_session
4
+ from devtodo.services.todo_service import TodoService
5
+ from devtodo.utils.output import success, error, print_todo
6
+ from devtodo.utils.dates import parse_date
7
+
8
+ app = typer.Typer(help="Edit a todo.")
9
+
10
+
11
+ @app.callback(invoke_without_command=True)
12
+ def edit(
13
+ todo_id: int = typer.Argument(..., help="ID of the todo to edit"),
14
+ title: Optional[str] = typer.Option(None, "--title", help="New title"),
15
+ description: Optional[str] = typer.Option(None, "--desc", "-d", help="New description"),
16
+ status: Optional[str] = typer.Option(None, "--status", "-s", help="New status: open, done, in_progress"),
17
+ priority: Optional[str] = typer.Option(None, "--priority", "-p", help="New priority: low, medium, high"),
18
+ project: Optional[str] = typer.Option(None, "--project", "-P", help="New project"),
19
+ tags: Optional[str] = typer.Option(None, "--tags", "-t", help="New tags (comma-separated)"),
20
+ due: Optional[str] = typer.Option(None, "--due", help="New due date (YYYY-MM-DD)"),
21
+ ) -> None:
22
+ if priority and priority not in ("low", "medium", "high"):
23
+ error(f"Invalid priority '{priority}'. Use: low, medium, high")
24
+ raise typer.Exit(1)
25
+ if status and status not in ("open", "done", "in_progress"):
26
+ error(f"Invalid status '{status}'. Use: open, done, in_progress")
27
+ raise typer.Exit(1)
28
+
29
+ due_date = None
30
+ if due:
31
+ try:
32
+ due_date = parse_date(due)
33
+ except ValueError as e:
34
+ error(str(e))
35
+ raise typer.Exit(1)
36
+
37
+ engine = init_db()
38
+ with get_session(engine) as session:
39
+ service = TodoService(session)
40
+ todo = service.edit(
41
+ todo_id=todo_id,
42
+ title=title,
43
+ description=description,
44
+ status=status,
45
+ priority=priority,
46
+ project=project,
47
+ tags=tags,
48
+ due_date=due_date,
49
+ )
50
+
51
+ if todo:
52
+ success(f"Todo [bold]#{todo.id}[/bold] updated.")
53
+ print_todo(todo)
54
+ else:
55
+ error(f"Todo #{todo_id} not found.")
56
+ raise typer.Exit(1)
@@ -0,0 +1,120 @@
1
+ """devtodo github – GitHub Issue integration commands."""
2
+
3
+ from typing import Optional
4
+ import typer
5
+ from rich.console import Console
6
+
7
+ from devtodo.db import init_db, get_session
8
+ from devtodo.services.todo_service import TodoService
9
+ from devtodo.utils.output import success, error, print_todos
10
+
11
+ app = typer.Typer(help="GitHub Issue integration.")
12
+ console = Console()
13
+
14
+
15
+ def _require_repo(repo: Optional[str]) -> str:
16
+ if not repo:
17
+ error("Provide --repo owner/reponame e.g. --repo torvalds/linux")
18
+ raise typer.Exit(1)
19
+ if "/" not in repo:
20
+ error("--repo must be in format owner/reponame")
21
+ raise typer.Exit(1)
22
+ return repo
23
+
24
+
25
+ @app.command("sync")
26
+ def sync(
27
+ repo: Optional[str] = typer.Option(None, "--repo", "-r", help="GitHub repo (owner/name)"),
28
+ state: str = typer.Option("open", "--state", "-s", help="Issue state: open / closed / all"),
29
+ limit: int = typer.Option(50, "--limit", "-n", help="Max issues to fetch"),
30
+ ) -> None:
31
+ """Import GitHub Issues as todos."""
32
+ from devtodo.github_client import fetch_issues
33
+ repo = _require_repo(repo)
34
+
35
+ console.print(f"[dim]Fetching {state} issues from [bold]{repo}[/bold]...[/dim]")
36
+ try:
37
+ issues = fetch_issues(repo, state=state, limit=limit)
38
+ except Exception as e:
39
+ error(f"GitHub API error: {e}")
40
+ raise typer.Exit(1)
41
+
42
+ if not issues:
43
+ console.print("[dim]No issues found.[/dim]")
44
+ return
45
+
46
+ engine = init_db()
47
+ added = 0
48
+ skipped = 0
49
+ with get_session(engine) as session:
50
+ service = TodoService(session)
51
+ existing_urls = {t.github_url for t in service.list_todos() if t.github_url}
52
+
53
+ for issue in issues:
54
+ url = issue["html_url"]
55
+ if url in existing_urls:
56
+ skipped += 1
57
+ continue
58
+ service.add(
59
+ title=issue["title"],
60
+ description=issue.get("body") or None,
61
+ github_url=url,
62
+ github_issue_number=issue["number"],
63
+ )
64
+ added += 1
65
+
66
+ success(f"Imported {added} issue(s) from [bold]{repo}[/bold] ({skipped} already existed).")
67
+
68
+
69
+ @app.command("push")
70
+ def push(
71
+ todo_id: int = typer.Argument(..., help="Todo ID to push as GitHub Issue"),
72
+ repo: Optional[str] = typer.Option(None, "--repo", "-r", help="GitHub repo (owner/name)"),
73
+ ) -> None:
74
+ """Push a todo as a new GitHub Issue."""
75
+ from devtodo.github_client import create_issue
76
+ repo = _require_repo(repo)
77
+
78
+ engine = init_db()
79
+ with get_session(engine) as session:
80
+ service = TodoService(session)
81
+ todo = service.get(todo_id)
82
+ if not todo:
83
+ error(f"Todo #{todo_id} not found.")
84
+ raise typer.Exit(1)
85
+
86
+ if todo.github_url:
87
+ error(f"Todo #{todo_id} is already linked to: {todo.github_url}")
88
+ raise typer.Exit(1)
89
+
90
+ console.print(f"[dim]Creating issue in [bold]{repo}[/bold]...[/dim]")
91
+ try:
92
+ issue = create_issue(repo, title=todo.title, body=todo.description)
93
+ except Exception as e:
94
+ error(f"GitHub API error: {e}")
95
+ raise typer.Exit(1)
96
+
97
+ service.edit(
98
+ todo_id=todo_id,
99
+ github_url=issue["html_url"],
100
+ github_issue_number=issue["number"],
101
+ )
102
+
103
+ success(f"Issue created: [link={issue['html_url']}]{issue['html_url']}[/link]")
104
+
105
+
106
+ @app.command("list")
107
+ def list_linked() -> None:
108
+ """List todos that are linked to GitHub Issues."""
109
+ engine = init_db()
110
+ with get_session(engine) as session:
111
+ service = TodoService(session)
112
+ todos = [t for t in service.list_todos() if t.github_url]
113
+
114
+ if not todos:
115
+ console.print("[dim]No todos linked to GitHub Issues.[/dim]")
116
+ return
117
+
118
+ print_todos(todos)
119
+ for t in todos:
120
+ console.print(f" #{t.id} → [dim]{t.github_url}[/dim]")
@@ -0,0 +1,21 @@
1
+ from typing import Optional
2
+ import typer
3
+ from devtodo.db import init_db, get_session
4
+ from devtodo.services.todo_service import TodoService
5
+ from devtodo.utils.output import print_todos
6
+
7
+ app = typer.Typer(help="List todos.")
8
+
9
+
10
+ @app.callback(invoke_without_command=True)
11
+ def list_todos(
12
+ status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter by status (open, done, in_progress)"),
13
+ project: Optional[str] = typer.Option(None, "--project", "-P", help="Filter by project"),
14
+ priority: Optional[str] = typer.Option(None, "--priority", "-p", help="Filter by priority"),
15
+ tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Filter by tag"),
16
+ ) -> None:
17
+ engine = init_db()
18
+ with get_session(engine) as session:
19
+ service = TodoService(session)
20
+ todos = service.list_todos(status=status, project=project, priority=priority, tag=tag)
21
+ print_todos(todos)
@@ -0,0 +1,27 @@
1
+ import typer
2
+ from devtodo.db import init_db, get_session
3
+ from devtodo.services.todo_service import TodoService
4
+ from devtodo.utils.output import success, error
5
+
6
+ app = typer.Typer(help="Remove a todo.")
7
+
8
+
9
+ @app.callback(invoke_without_command=True)
10
+ def remove(
11
+ todo_id: int = typer.Argument(..., help="ID of the todo to remove"),
12
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
13
+ ) -> None:
14
+ engine = init_db()
15
+ with get_session(engine) as session:
16
+ service = TodoService(session)
17
+ todo = service.get(todo_id)
18
+ if not todo:
19
+ error(f"Todo #{todo_id} not found.")
20
+ raise typer.Exit(1)
21
+
22
+ if not force:
23
+ typer.confirm(f"Remove todo #{todo_id}: '{todo.title}'?", abort=True)
24
+
25
+ service.remove(todo_id)
26
+
27
+ success(f"Todo #{todo_id} removed.")
@@ -0,0 +1,39 @@
1
+ from pathlib import Path
2
+ from sqlmodel import SQLModel, create_engine, Session
3
+
4
+ DB_PATH = Path.home() / ".devtodo" / "devtodo.db"
5
+
6
+
7
+ def get_engine():
8
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
9
+ return create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
10
+
11
+
12
+ def init_db(engine=None):
13
+ from devtodo.models import Todo # noqa: F401 – registers the table
14
+ if engine is None:
15
+ engine = get_engine()
16
+ SQLModel.metadata.create_all(engine)
17
+ _migrate(engine)
18
+ return engine
19
+
20
+
21
+ def _migrate(engine) -> None:
22
+ """Add new columns to existing databases without Alembic."""
23
+ new_columns = [
24
+ ("github_url", "TEXT"),
25
+ ("github_issue_number", "INTEGER"),
26
+ ]
27
+ with engine.connect() as conn:
28
+ for col, col_type in new_columns:
29
+ try:
30
+ conn.exec_driver_sql(f"ALTER TABLE todo ADD COLUMN {col} {col_type}")
31
+ conn.commit()
32
+ except Exception:
33
+ pass # column already exists
34
+
35
+
36
+ def get_session(engine=None):
37
+ if engine is None:
38
+ engine = get_engine()
39
+ return Session(engine)
@@ -0,0 +1,50 @@
1
+ """GitHub API client for devtodo."""
2
+
3
+ import os
4
+ from typing import Optional
5
+ import httpx
6
+
7
+ GITHUB_API = "https://api.github.com"
8
+
9
+
10
+ def _get_token() -> Optional[str]:
11
+ return os.environ.get("GITHUB_TOKEN")
12
+
13
+
14
+ def _headers() -> dict:
15
+ token = _get_token()
16
+ headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
17
+ if token:
18
+ headers["Authorization"] = f"Bearer {token}"
19
+ return headers
20
+
21
+
22
+ def fetch_issues(repo: str, state: str = "open", limit: int = 50) -> list[dict]:
23
+ """Fetch issues from a GitHub repo. repo = 'owner/name'"""
24
+ url = f"{GITHUB_API}/repos/{repo}/issues"
25
+ params = {"state": state, "per_page": min(limit, 100), "page": 1}
26
+ resp = httpx.get(url, headers=_headers(), params=params, timeout=10)
27
+ resp.raise_for_status()
28
+ # filter out pull requests (GitHub returns PRs as issues too)
29
+ return [i for i in resp.json() if "pull_request" not in i]
30
+
31
+
32
+ def create_issue(repo: str, title: str, body: Optional[str] = None, labels: list[str] | None = None) -> dict:
33
+ """Create a GitHub issue. Returns the created issue dict."""
34
+ url = f"{GITHUB_API}/repos/{repo}/issues"
35
+ payload: dict = {"title": title}
36
+ if body:
37
+ payload["body"] = body
38
+ if labels:
39
+ payload["labels"] = labels
40
+ resp = httpx.post(url, headers=_headers(), json=payload, timeout=10)
41
+ resp.raise_for_status()
42
+ return resp.json()
43
+
44
+
45
+ def close_issue(repo: str, issue_number: int) -> dict:
46
+ """Close a GitHub issue."""
47
+ url = f"{GITHUB_API}/repos/{repo}/issues/{issue_number}"
48
+ resp = httpx.patch(url, headers=_headers(), json={"state": "closed"}, timeout=10)
49
+ resp.raise_for_status()
50
+ return resp.json()