devtodo 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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.")
devtodo/db.py ADDED
@@ -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()
devtodo/main.py ADDED
@@ -0,0 +1,230 @@
1
+ from typing import Optional
2
+ import typer
3
+ from rich.console import Console
4
+ from datetime import date
5
+
6
+ from devtodo.db import init_db, get_session
7
+ from devtodo.services.todo_service import TodoService
8
+ from devtodo.utils.output import success, error, print_todos, print_todo
9
+ from devtodo.utils.dates import parse_date
10
+
11
+ app = typer.Typer(
12
+ name="devtodo",
13
+ help="[bold]devtodo[/bold] – a developer-focused CLI todo tool.",
14
+ rich_markup_mode="rich",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+ console = Console()
19
+
20
+
21
+ @app.command("add")
22
+ def cmd_add(
23
+ title: str = typer.Argument(..., help="Title of the todo"),
24
+ description: Optional[str] = typer.Option(None, "--desc", "-d", help="Optional description"),
25
+ priority: str = typer.Option("medium", "--priority", "-p", help="Priority: low, medium, high"),
26
+ project: Optional[str] = typer.Option(None, "--project", "-P", help="Project name"),
27
+ tags: Optional[str] = typer.Option(None, "--tags", "-t", help="Comma-separated tags"),
28
+ due: Optional[str] = typer.Option(None, "--due", help="Due date (YYYY-MM-DD)"),
29
+ ) -> None:
30
+ """Add a new todo."""
31
+ if priority not in ("low", "medium", "high"):
32
+ error(f"Invalid priority '{priority}'. Use: low, medium, high")
33
+ raise typer.Exit(1)
34
+
35
+ due_date = None
36
+ if due:
37
+ try:
38
+ due_date = parse_date(due)
39
+ except ValueError as e:
40
+ error(str(e))
41
+ raise typer.Exit(1)
42
+
43
+ engine = init_db()
44
+ with get_session(engine) as session:
45
+ service = TodoService(session)
46
+ todo = service.add(
47
+ title=title,
48
+ description=description,
49
+ priority=priority,
50
+ project=project,
51
+ tags=tags,
52
+ due_date=due_date,
53
+ )
54
+ success(f"Added todo [bold]#{todo.id}[/bold]: {todo.title}")
55
+ print_todo(todo)
56
+
57
+
58
+ @app.command("list")
59
+ def cmd_list(
60
+ status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter: open, done, in_progress"),
61
+ project: Optional[str] = typer.Option(None, "--project", "-P", help="Filter by project"),
62
+ priority: Optional[str] = typer.Option(None, "--priority", "-p", help="Filter by priority"),
63
+ tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Filter by tag"),
64
+ ) -> None:
65
+ """List todos."""
66
+ engine = init_db()
67
+ with get_session(engine) as session:
68
+ service = TodoService(session)
69
+ todos = service.list_todos(status=status, project=project, priority=priority, tag=tag)
70
+ print_todos(todos)
71
+
72
+
73
+ @app.command("done")
74
+ def cmd_done(
75
+ todo_id: int = typer.Argument(..., help="ID of the todo to mark as done"),
76
+ ) -> None:
77
+ """Mark a todo as done."""
78
+ engine = init_db()
79
+ with get_session(engine) as session:
80
+ service = TodoService(session)
81
+ todo = service.mark_done(todo_id)
82
+
83
+ if todo:
84
+ success(f"Todo [bold]#{todo.id}[/bold] marked as done: {todo.title}")
85
+ else:
86
+ error(f"Todo #{todo_id} not found.")
87
+ raise typer.Exit(1)
88
+
89
+
90
+ @app.command("remove")
91
+ def cmd_remove(
92
+ todo_id: int = typer.Argument(..., help="ID of the todo to remove"),
93
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
94
+ ) -> None:
95
+ """Remove a todo."""
96
+ engine = init_db()
97
+ with get_session(engine) as session:
98
+ service = TodoService(session)
99
+ todo = service.get(todo_id)
100
+ if not todo:
101
+ error(f"Todo #{todo_id} not found.")
102
+ raise typer.Exit(1)
103
+
104
+ if not force:
105
+ typer.confirm(f"Remove todo #{todo_id}: '{todo.title}'?", abort=True)
106
+
107
+ service.remove(todo_id)
108
+
109
+ success(f"Todo #{todo_id} removed.")
110
+
111
+
112
+ @app.command("edit")
113
+ def cmd_edit(
114
+ todo_id: int = typer.Argument(..., help="ID of the todo to edit"),
115
+ title: Optional[str] = typer.Option(None, "--title", help="New title"),
116
+ description: Optional[str] = typer.Option(None, "--desc", "-d", help="New description"),
117
+ status: Optional[str] = typer.Option(None, "--status", "-s", help="New status: open, done, in_progress"),
118
+ priority: Optional[str] = typer.Option(None, "--priority", "-p", help="New priority: low, medium, high"),
119
+ project: Optional[str] = typer.Option(None, "--project", "-P", help="New project"),
120
+ tags: Optional[str] = typer.Option(None, "--tags", "-t", help="New tags (comma-separated)"),
121
+ due: Optional[str] = typer.Option(None, "--due", help="New due date (YYYY-MM-DD)"),
122
+ ) -> None:
123
+ """Edit a todo."""
124
+ if priority and priority not in ("low", "medium", "high"):
125
+ error(f"Invalid priority '{priority}'. Use: low, medium, high")
126
+ raise typer.Exit(1)
127
+ if status and status not in ("open", "done", "in_progress"):
128
+ error(f"Invalid status '{status}'. Use: open, done, in_progress")
129
+ raise typer.Exit(1)
130
+
131
+ due_date = None
132
+ if due:
133
+ try:
134
+ due_date = parse_date(due)
135
+ except ValueError as e:
136
+ error(str(e))
137
+ raise typer.Exit(1)
138
+
139
+ engine = init_db()
140
+ with get_session(engine) as session:
141
+ service = TodoService(session)
142
+ todo = service.edit(
143
+ todo_id=todo_id,
144
+ title=title,
145
+ description=description,
146
+ status=status,
147
+ priority=priority,
148
+ project=project,
149
+ tags=tags,
150
+ due_date=due_date,
151
+ )
152
+
153
+ if todo:
154
+ success(f"Todo [bold]#{todo.id}[/bold] updated.")
155
+ print_todo(todo)
156
+ else:
157
+ error(f"Todo #{todo_id} not found.")
158
+ raise typer.Exit(1)
159
+
160
+
161
+ @app.command("clear")
162
+ def cmd_clear(
163
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
164
+ ) -> None:
165
+ """Delete ALL todos. Cannot be undone!"""
166
+ engine = init_db()
167
+ with get_session(engine) as session:
168
+ service = TodoService(session)
169
+ count = service.list_todos().__len__()
170
+
171
+ if count == 0:
172
+ console.print("[dim]No todos to delete.[/dim]")
173
+ raise typer.Exit()
174
+
175
+ if not force:
176
+ console.print(f"[bold red]⚠ This will delete ALL {count} todo(s). This cannot be undone![/bold red]")
177
+ typer.confirm("Are you sure?", abort=True)
178
+
179
+ engine = init_db()
180
+ with get_session(engine) as session:
181
+ service = TodoService(session)
182
+ service.clear_all()
183
+
184
+ success(f"Deleted all {count} todo(s). Database is now empty.")
185
+
186
+
187
+ def cmd_clear_completed(
188
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
189
+ ) -> None:
190
+ """Remove all completed todos."""
191
+ if not force:
192
+ typer.confirm("Remove all completed todos?", abort=True)
193
+
194
+ engine = init_db()
195
+ with get_session(engine) as session:
196
+ service = TodoService(session)
197
+ count = service.clear_completed()
198
+
199
+ success(f"Removed {count} completed todo(s).")
200
+
201
+
202
+ @app.command("start")
203
+ def cmd_start() -> None:
204
+ """Launch the interactive TUI."""
205
+ from devtodo.tui import run_tui
206
+ run_tui()
207
+
208
+
209
+ def cmd_today() -> None:
210
+ """Show high-priority todos and todos due today."""
211
+ today_date = date.today()
212
+
213
+ engine = init_db()
214
+ with get_session(engine) as session:
215
+ service = TodoService(session)
216
+ all_todos = service.list_todos(status="open")
217
+
218
+ focused = [
219
+ t for t in all_todos
220
+ if t.priority == "high"
221
+ or (t.due_date and t.due_date.date() <= today_date)
222
+ ]
223
+
224
+ console.print("[bold]📅 Today's focus[/bold]")
225
+ print_todos(focused)
226
+
227
+
228
+ if __name__ == "__main__":
229
+ app()
230
+
devtodo/models.py ADDED
@@ -0,0 +1,17 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+ from sqlmodel import SQLModel, Field
4
+
5
+
6
+ class Todo(SQLModel, table=True):
7
+ id: Optional[int] = Field(default=None, primary_key=True)
8
+ title: str
9
+ description: Optional[str] = None
10
+ status: str = "open"
11
+ priority: str = "medium"
12
+ project: Optional[str] = None
13
+ tags: Optional[str] = None
14
+ created_at: datetime = Field(default_factory=datetime.utcnow)
15
+ due_date: Optional[datetime] = None
16
+ github_url: Optional[str] = None
17
+ github_issue_number: Optional[int] = None
File without changes
@@ -0,0 +1,118 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+ from sqlmodel import select, Session
4
+
5
+ from devtodo.models import Todo
6
+
7
+
8
+ class TodoService:
9
+ def __init__(self, session: Session):
10
+ self.session = session
11
+
12
+ def add(
13
+ self,
14
+ title: str,
15
+ description: Optional[str] = None,
16
+ priority: str = "medium",
17
+ project: Optional[str] = None,
18
+ tags: Optional[str] = None,
19
+ due_date: Optional[datetime] = None,
20
+ ) -> Todo:
21
+ todo = Todo(
22
+ title=title,
23
+ description=description,
24
+ priority=priority,
25
+ project=project,
26
+ tags=tags,
27
+ due_date=due_date,
28
+ )
29
+ self.session.add(todo)
30
+ self.session.commit()
31
+ self.session.refresh(todo)
32
+ return todo
33
+
34
+ def list_todos(
35
+ self,
36
+ status: Optional[str] = None,
37
+ project: Optional[str] = None,
38
+ priority: Optional[str] = None,
39
+ tag: Optional[str] = None,
40
+ ) -> list[Todo]:
41
+ statement = select(Todo)
42
+ if status:
43
+ statement = statement.where(Todo.status == status)
44
+ if project:
45
+ statement = statement.where(Todo.project == project)
46
+ if priority:
47
+ statement = statement.where(Todo.priority == priority)
48
+ if tag:
49
+ statement = statement.where(Todo.tags.contains(tag))
50
+ statement = statement.order_by(Todo.id)
51
+ return list(self.session.exec(statement).all())
52
+
53
+ def get(self, todo_id: int) -> Optional[Todo]:
54
+ return self.session.get(Todo, todo_id)
55
+
56
+ def mark_done(self, todo_id: int) -> Optional[Todo]:
57
+ todo = self.get(todo_id)
58
+ if todo:
59
+ todo.status = "done"
60
+ self.session.add(todo)
61
+ self.session.commit()
62
+ self.session.refresh(todo)
63
+ return todo
64
+
65
+ def remove(self, todo_id: int) -> bool:
66
+ todo = self.get(todo_id)
67
+ if todo:
68
+ self.session.delete(todo)
69
+ self.session.commit()
70
+ return True
71
+ return False
72
+
73
+ def edit(
74
+ self,
75
+ todo_id: int,
76
+ title: Optional[str] = None,
77
+ description: Optional[str] = None,
78
+ status: Optional[str] = None,
79
+ priority: Optional[str] = None,
80
+ project: Optional[str] = None,
81
+ tags: Optional[str] = None,
82
+ due_date: Optional[datetime] = None,
83
+ ) -> Optional[Todo]:
84
+ todo = self.get(todo_id)
85
+ if not todo:
86
+ return None
87
+ if title is not None:
88
+ todo.title = title
89
+ if description is not None:
90
+ todo.description = description
91
+ if status is not None:
92
+ todo.status = status
93
+ if priority is not None:
94
+ todo.priority = priority
95
+ if project is not None:
96
+ todo.project = project
97
+ if tags is not None:
98
+ todo.tags = tags
99
+ if due_date is not None:
100
+ todo.due_date = due_date
101
+ self.session.add(todo)
102
+ self.session.commit()
103
+ self.session.refresh(todo)
104
+ return todo
105
+
106
+ def clear_completed(self) -> int:
107
+ todos = self.list_todos(status="done")
108
+ for todo in todos:
109
+ self.session.delete(todo)
110
+ self.session.commit()
111
+ return len(todos)
112
+
113
+ def clear_all(self) -> int:
114
+ todos = self.list_todos()
115
+ for todo in todos:
116
+ self.session.delete(todo)
117
+ self.session.commit()
118
+ return len(todos)
devtodo/tui.py ADDED
@@ -0,0 +1,238 @@
1
+ from textual.app import App, ComposeResult
2
+ from textual.widgets import (
3
+ Header, Footer, DataTable, Input, Label, Button, Static
4
+ )
5
+ from textual.containers import Vertical, Horizontal, Container
6
+ from textual.screen import ModalScreen
7
+ from textual.binding import Binding
8
+ from textual import events
9
+ from rich.text import Text
10
+
11
+ from devtodo.db import init_db, get_session
12
+ from devtodo.services.todo_service import TodoService
13
+ from devtodo.models import Todo
14
+
15
+
16
+ PRIORITY_STYLE = {
17
+ "high": ("red", "●"),
18
+ "medium": ("yellow", "●"),
19
+ "low": ("green", "●"),
20
+ }
21
+
22
+ STATUS_STYLE = {
23
+ "open": ("cyan", "○"),
24
+ "in_progress": ("blue", "◑"),
25
+ "done": ("dim green", "✔"),
26
+ }
27
+
28
+
29
+ class AddTodoModal(ModalScreen):
30
+ """Modal dialog to add a new todo."""
31
+
32
+ BINDINGS = [("escape", "dismiss", "Cancel")]
33
+
34
+ def compose(self) -> ComposeResult:
35
+ with Vertical(id="modal-box"):
36
+ yield Label("➕ Add new todo", id="modal-title")
37
+ yield Input(placeholder="Title *", id="input-title")
38
+ yield Input(placeholder="Description (optional)", id="input-desc")
39
+ yield Input(placeholder="Priority: low / medium / high (default: medium)", id="input-priority")
40
+ yield Input(placeholder="Project (optional)", id="input-project")
41
+ yield Input(placeholder="Tags, comma-separated (optional)", id="input-tags")
42
+ with Horizontal(id="modal-buttons"):
43
+ yield Button("Add", variant="success", id="btn-add")
44
+ yield Button("Cancel", variant="default", id="btn-cancel")
45
+
46
+ def on_button_pressed(self, event: Button.Pressed) -> None:
47
+ if event.button.id == "btn-cancel":
48
+ self.dismiss(None)
49
+ return
50
+
51
+ title = self.query_one("#input-title", Input).value.strip()
52
+ if not title:
53
+ self.query_one("#input-title", Input).focus()
54
+ return
55
+
56
+ priority = self.query_one("#input-priority", Input).value.strip() or "medium"
57
+ if priority not in ("low", "medium", "high"):
58
+ priority = "medium"
59
+
60
+ self.dismiss({
61
+ "title": title,
62
+ "description": self.query_one("#input-desc", Input).value.strip() or None,
63
+ "priority": priority,
64
+ "project": self.query_one("#input-project", Input).value.strip() or None,
65
+ "tags": self.query_one("#input-tags", Input).value.strip() or None,
66
+ })
67
+
68
+ def on_input_submitted(self, event: Input.Submitted) -> None:
69
+ # pressing Enter in any field triggers add
70
+ self.query_one("#btn-add", Button).press()
71
+
72
+
73
+ class DevtodoTUI(App):
74
+ """devtodo – Terminal UI"""
75
+
76
+ CSS = """
77
+ Screen {
78
+ background: $surface;
79
+ }
80
+
81
+ #main-table {
82
+ height: 1fr;
83
+ }
84
+
85
+ #status-bar {
86
+ height: 1;
87
+ padding: 0 1;
88
+ background: $panel;
89
+ color: $text-muted;
90
+ }
91
+
92
+ /* Modal */
93
+ AddTodoModal {
94
+ align: center middle;
95
+ }
96
+
97
+ #modal-box {
98
+ width: 60;
99
+ height: auto;
100
+ border: round $primary;
101
+ background: $surface;
102
+ padding: 1 2;
103
+ }
104
+
105
+ #modal-box Input {
106
+ margin-bottom: 1;
107
+ }
108
+
109
+ #modal-title {
110
+ text-style: bold;
111
+ color: $primary;
112
+ margin-bottom: 1;
113
+ }
114
+
115
+ #modal-buttons {
116
+ height: auto;
117
+ margin-top: 1;
118
+ }
119
+
120
+ #btn-add {
121
+ margin-right: 2;
122
+ }
123
+ """
124
+
125
+ BINDINGS = [
126
+ Binding("a", "add_todo", "Add", show=True),
127
+ Binding("d", "mark_done", "Done", show=True),
128
+ Binding("delete", "remove_todo", "Remove", show=True),
129
+ Binding("r", "refresh", "Refresh", show=True),
130
+ Binding("q", "quit", "Quit", show=True),
131
+ Binding("f", "filter_open", "Open only",show=True),
132
+ Binding("F", "filter_all", "All", show=True),
133
+ ]
134
+
135
+ def __init__(self):
136
+ super().__init__()
137
+ self.engine = init_db()
138
+ self._filter_status = None # None = all
139
+ self._todos: list[Todo] = []
140
+
141
+ def compose(self) -> ComposeResult:
142
+ yield Header(show_clock=True)
143
+ yield DataTable(id="main-table", cursor_type="row", zebra_stripes=True)
144
+ yield Static("", id="status-bar")
145
+ yield Footer()
146
+
147
+ def on_mount(self) -> None:
148
+ self.title = "devtodo"
149
+ self.sub_title = "press [a] add · [d] done · [del] remove · [q] quit"
150
+ table = self.query_one(DataTable)
151
+ table.add_columns("ID", "●", "Title", "Status", "Priority", "Project", "Tags")
152
+ self._load_todos()
153
+
154
+ def _load_todos(self) -> None:
155
+ with get_session(self.engine) as session:
156
+ service = TodoService(session)
157
+ self._todos = service.list_todos(status=self._filter_status)
158
+
159
+ table = self.query_one(DataTable)
160
+ table.clear()
161
+
162
+ for todo in self._todos:
163
+ p_color, p_dot = PRIORITY_STYLE.get(todo.priority, ("white", "●"))
164
+ s_color, s_icon = STATUS_STYLE.get(todo.status, ("white", "?"))
165
+
166
+ table.add_row(
167
+ Text(str(todo.id), style="dim"),
168
+ Text(p_dot, style=p_color),
169
+ Text(todo.title),
170
+ Text(f"{s_icon} {todo.status}", style=s_color),
171
+ Text(todo.priority, style=p_color),
172
+ Text(todo.project or "", style="blue"),
173
+ Text(todo.tags or "", style="magenta"),
174
+ key=str(todo.id),
175
+ )
176
+
177
+ self._update_status_bar()
178
+
179
+ def _update_status_bar(self) -> None:
180
+ total = len(self._todos)
181
+ done = sum(1 for t in self._todos if t.status == "done")
182
+ open_ = sum(1 for t in self._todos if t.status == "open")
183
+ filt = f" [filter: open only]" if self._filter_status else ""
184
+ self.query_one("#status-bar", Static).update(
185
+ f" {total} todos · {open_} open · {done} done{filt}"
186
+ )
187
+
188
+ def _current_todo(self) -> Todo | None:
189
+ table = self.query_one(DataTable)
190
+ if table.cursor_row < 0 or not self._todos:
191
+ return None
192
+ try:
193
+ return self._todos[table.cursor_row]
194
+ except IndexError:
195
+ return None
196
+
197
+ # ── Actions ─────────────────────────────────────────────────────────────
198
+
199
+ def action_add_todo(self) -> None:
200
+ def handle_result(data: dict | None) -> None:
201
+ if not data:
202
+ return
203
+ with get_session(self.engine) as session:
204
+ TodoService(session).add(**data)
205
+ self._load_todos()
206
+
207
+ self.push_screen(AddTodoModal(), handle_result)
208
+
209
+ def action_mark_done(self) -> None:
210
+ todo = self._current_todo()
211
+ if not todo:
212
+ return
213
+ with get_session(self.engine) as session:
214
+ TodoService(session).mark_done(todo.id)
215
+ self._load_todos()
216
+
217
+ def action_remove_todo(self) -> None:
218
+ todo = self._current_todo()
219
+ if not todo:
220
+ return
221
+ with get_session(self.engine) as session:
222
+ TodoService(session).remove(todo.id)
223
+ self._load_todos()
224
+
225
+ def action_refresh(self) -> None:
226
+ self._load_todos()
227
+
228
+ def action_filter_open(self) -> None:
229
+ self._filter_status = "open"
230
+ self._load_todos()
231
+
232
+ def action_filter_all(self) -> None:
233
+ self._filter_status = None
234
+ self._load_todos()
235
+
236
+
237
+ def run_tui() -> None:
238
+ DevtodoTUI().run()
File without changes
devtodo/utils/dates.py ADDED
@@ -0,0 +1,13 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+
5
+ def parse_date(value: Optional[str]) -> Optional[datetime]:
6
+ if not value:
7
+ return None
8
+ for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"):
9
+ try:
10
+ return datetime.strptime(value, fmt)
11
+ except ValueError:
12
+ continue
13
+ raise ValueError(f"Cannot parse date: '{value}'. Use YYYY-MM-DD format.")
@@ -0,0 +1,80 @@
1
+ from datetime import datetime
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+ from rich import box
5
+ from devtodo.models import Todo
6
+
7
+ console = Console()
8
+
9
+ PRIORITY_COLORS = {
10
+ "high": "red",
11
+ "medium": "yellow",
12
+ "low": "green",
13
+ }
14
+
15
+ STATUS_COLORS = {
16
+ "open": "cyan",
17
+ "done": "dim green",
18
+ "in_progress": "blue",
19
+ }
20
+
21
+
22
+ def _priority_badge(priority: str) -> str:
23
+ color = PRIORITY_COLORS.get(priority, "white")
24
+ return f"[{color}]{priority}[/{color}]"
25
+
26
+
27
+ def _status_badge(status: str) -> str:
28
+ color = STATUS_COLORS.get(status, "white")
29
+ return f"[{color}]{status}[/{color}]"
30
+
31
+
32
+ def print_todos(todos: list[Todo]) -> None:
33
+ if not todos:
34
+ console.print("[dim]No todos found.[/dim]")
35
+ return
36
+
37
+ table = Table(box=box.ROUNDED, show_header=True, header_style="bold magenta")
38
+ table.add_column("ID", style="bold", justify="right", width=4)
39
+ table.add_column("Title", min_width=20)
40
+ table.add_column("Status", width=12)
41
+ table.add_column("Priority", width=10)
42
+ table.add_column("Project", width=12)
43
+ table.add_column("Tags", width=14)
44
+ table.add_column("Due", width=11)
45
+
46
+ for todo in todos:
47
+ due = todo.due_date.strftime("%Y-%m-%d") if todo.due_date else ""
48
+ table.add_row(
49
+ str(todo.id),
50
+ todo.title,
51
+ _status_badge(todo.status),
52
+ _priority_badge(todo.priority),
53
+ todo.project or "",
54
+ todo.tags or "",
55
+ due,
56
+ )
57
+
58
+ console.print(table)
59
+
60
+
61
+ def print_todo(todo: Todo) -> None:
62
+ console.print(f"[bold]#{todo.id}[/bold] {todo.title}")
63
+ if todo.description:
64
+ console.print(f" [dim]{todo.description}[/dim]")
65
+ console.print(f" Status: {_status_badge(todo.status)}")
66
+ console.print(f" Priority: {_priority_badge(todo.priority)}")
67
+ if todo.project:
68
+ console.print(f" Project: [blue]{todo.project}[/blue]")
69
+ if todo.tags:
70
+ console.print(f" Tags: [magenta]{todo.tags}[/magenta]")
71
+ if todo.due_date:
72
+ console.print(f" Due: {todo.due_date.strftime('%Y-%m-%d')}")
73
+
74
+
75
+ def success(msg: str) -> None:
76
+ console.print(f"[bold green]✔[/bold green] {msg}")
77
+
78
+
79
+ def error(msg: str) -> None:
80
+ console.print(f"[bold red]✘[/bold red] {msg}")
@@ -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,23 @@
1
+ devtodo/__init__.py,sha256=1Oeb1VkZTRqRrNw2Xp78L41PI3boM_O-oxv3uNUzFhk,76
2
+ devtodo/db.py,sha256=i0bWvXfDx09vYhIMHiZd6e2IhBBAW5e_WWwXvprY0a0,1128
3
+ devtodo/github_client.py,sha256=TBs4v_GhDwoYuSbzeu0Lxut_oc8BMzWc5uLF5uPkPJ8,1695
4
+ devtodo/main.py,sha256=tbIsoJ3xgXSEM2aKIh-p0QHmUMJxiRJ3SWjJZHfzpD4,7421
5
+ devtodo/models.py,sha256=CTqVhFNncpfdj5auG4WmEjdqjE3JkjXIAe-mY9V0-ws,554
6
+ devtodo/tui.py,sha256=cHII3LQKOg8TDNYL7oE4corlpt1hOQk9k4WOCJHrfOE,7599
7
+ devtodo/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ devtodo/commands/add.py,sha256=UVTS7t2yvivr5dAZ5-67Zj7_P4EyavTq6dz2x8Wromo,1605
9
+ devtodo/commands/done.py,sha256=qpv-LlDw2uHW2safeFPXKZ9L5fzahmQ653nk-Dl1L4c,672
10
+ devtodo/commands/edit.py,sha256=10ehEKJkTk1eibZItLYYzonTlU4x6EKrfza1ICPI2Bs,2141
11
+ devtodo/commands/github_cmd.py,sha256=zUbd6Ff3zP_0z_7uN5omoXuFZBvn-XEiXyf8hYPUkQg,3888
12
+ devtodo/commands/list.py,sha256=pFVjZwJ7z6vDSVL59CYf6Cpgrxl0Cuaxd7CXwTyl2fY,906
13
+ devtodo/commands/remove.py,sha256=t47HFEQyTcERS5ks1TiXCMsYwnQL1DQNAqiYP5Eq4oo,845
14
+ devtodo/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ devtodo/services/todo_service.py,sha256=my17-ax2IfEEd0QBOVpH7Q_x2cHJerHzIGMxfN6stR4,3478
16
+ devtodo/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ devtodo/utils/dates.py,sha256=CYfhGHASwgXm2Qxp8o-VOwAEAeUcERS0z9wBgMvY-Bk,399
18
+ devtodo/utils/output.py,sha256=zfjN4zZ05iqSpv-4ZgWH6Mp8dmhOS7Cj72Sx-FyxdcA,2267
19
+ devtodo-0.1.0.dist-info/METADATA,sha256=v8hdfsaVfpOmyrpnBsjIufm1yT9WyzddPpXqEZcsEzw,1670
20
+ devtodo-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
21
+ devtodo-0.1.0.dist-info/entry_points.txt,sha256=Zph7i34gUVm4WIvG3IybJupznzQXLYQjj9QltEXDmRw,67
22
+ devtodo-0.1.0.dist-info/top_level.txt,sha256=iedrhl7lx7pyAMlH0aM03NALgkjAAGHPZRAyXwpcdzc,8
23
+ devtodo-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ devtodo = devtodo.main:app
3
+ dt = devtodo.main:app
@@ -0,0 +1 @@
1
+ devtodo