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 +71 -0
- devtodo-0.1.0/README.md +50 -0
- devtodo-0.1.0/pyproject.toml +40 -0
- devtodo-0.1.0/setup.cfg +4 -0
- devtodo-0.1.0/src/devtodo/__init__.py +3 -0
- devtodo-0.1.0/src/devtodo/commands/__init__.py +0 -0
- devtodo-0.1.0/src/devtodo/commands/add.py +44 -0
- devtodo-0.1.0/src/devtodo/commands/done.py +22 -0
- devtodo-0.1.0/src/devtodo/commands/edit.py +56 -0
- devtodo-0.1.0/src/devtodo/commands/github_cmd.py +120 -0
- devtodo-0.1.0/src/devtodo/commands/list.py +21 -0
- devtodo-0.1.0/src/devtodo/commands/remove.py +27 -0
- devtodo-0.1.0/src/devtodo/db.py +39 -0
- devtodo-0.1.0/src/devtodo/github_client.py +50 -0
- devtodo-0.1.0/src/devtodo/main.py +230 -0
- devtodo-0.1.0/src/devtodo/models.py +17 -0
- devtodo-0.1.0/src/devtodo/services/__init__.py +0 -0
- devtodo-0.1.0/src/devtodo/services/todo_service.py +118 -0
- devtodo-0.1.0/src/devtodo/tui.py +238 -0
- devtodo-0.1.0/src/devtodo/utils/__init__.py +0 -0
- devtodo-0.1.0/src/devtodo/utils/dates.py +13 -0
- devtodo-0.1.0/src/devtodo/utils/output.py +80 -0
- devtodo-0.1.0/src/devtodo.egg-info/PKG-INFO +71 -0
- devtodo-0.1.0/src/devtodo.egg-info/SOURCES.txt +29 -0
- devtodo-0.1.0/src/devtodo.egg-info/dependency_links.txt +1 -0
- devtodo-0.1.0/src/devtodo.egg-info/entry_points.txt +3 -0
- devtodo-0.1.0/src/devtodo.egg-info/requires.txt +5 -0
- devtodo-0.1.0/src/devtodo.egg-info/top_level.txt +1 -0
- devtodo-0.1.0/tests/test_add.py +42 -0
- devtodo-0.1.0/tests/test_done.py +62 -0
- devtodo-0.1.0/tests/test_list.py +50 -0
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).
|
devtodo-0.1.0/README.md
ADDED
|
@@ -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"]
|
devtodo-0.1.0/setup.cfg
ADDED
|
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()
|