taskrepo 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.
taskrepo/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """TaskRepo - TaskWarrior-inspired CLI for managing tasks as markdown files in git repositories."""
2
+
3
+ from taskrepo.__version__ import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """CLI module for TaskRepo."""
@@ -0,0 +1 @@
1
+ """CLI commands for TaskRepo."""
@@ -0,0 +1,166 @@
1
+ """Add command for creating new tasks."""
2
+
3
+ import click
4
+
5
+ from taskrepo.core.repository import RepositoryManager
6
+ from taskrepo.core.task import Task
7
+ from taskrepo.tui import prompts
8
+
9
+
10
+ @click.command()
11
+ @click.option("--repo", "-r", help="Repository name (will prompt if not specified)")
12
+ @click.option("--title", "-t", help="Task title (will prompt if not specified)")
13
+ @click.option("--project", "-p", help="Project name")
14
+ @click.option("--priority", type=click.Choice(["H", "M", "L"], case_sensitive=False), help="Task priority")
15
+ @click.option("--assignees", "-a", help="Comma-separated list of assignees (e.g., @user1,@user2)")
16
+ @click.option("--tags", help="Comma-separated list of tags")
17
+ @click.option("--due", help="Due date (e.g., 2025-12-31)")
18
+ @click.option("--description", "-d", help="Task description")
19
+ @click.option("--interactive/--no-interactive", "-i/-I", default=True, help="Use interactive mode")
20
+ @click.pass_context
21
+ def add(ctx, repo, title, project, priority, assignees, tags, due, description, interactive):
22
+ """Add a new task."""
23
+ config = ctx.obj["config"]
24
+ manager = RepositoryManager(config.parent_dir)
25
+
26
+ repositories = manager.discover_repositories()
27
+
28
+ # Interactive mode
29
+ if interactive:
30
+ click.echo("Creating a new task...\n")
31
+
32
+ # Select repository
33
+ if not repo:
34
+ selected_repo = prompts.prompt_repository(repositories)
35
+ if not selected_repo:
36
+ click.echo("Cancelled.")
37
+ ctx.exit(0)
38
+ else:
39
+ selected_repo = manager.get_repository(repo)
40
+ if not selected_repo:
41
+ click.secho(f"Error: Repository '{repo}' not found", fg="red", err=True)
42
+ ctx.exit(1)
43
+
44
+ # Get task title
45
+ if not title:
46
+ title = prompts.prompt_title()
47
+ if not title:
48
+ click.echo("Cancelled.")
49
+ ctx.exit(0)
50
+
51
+ # Get project
52
+ if project is None:
53
+ existing_projects = selected_repo.get_projects()
54
+ project = prompts.prompt_project(existing_projects)
55
+
56
+ # Get priority
57
+ if priority is None:
58
+ priority = prompts.prompt_priority(config.default_priority)
59
+
60
+ # Get assignees
61
+ if assignees is None:
62
+ existing_assignees = selected_repo.get_assignees()
63
+ # Add default assignee to existing list if configured
64
+ if config.default_assignee and config.default_assignee not in existing_assignees:
65
+ existing_assignees = [config.default_assignee] + existing_assignees
66
+ assignees_list = prompts.prompt_assignees(existing_assignees)
67
+ # If no assignees entered and default_assignee is set, use it
68
+ if not assignees_list and config.default_assignee:
69
+ assignees_list = [config.default_assignee]
70
+ else:
71
+ assignees_list = [a.strip() for a in assignees.split(",")]
72
+ # Ensure @ prefix
73
+ assignees_list = [a if a.startswith("@") else f"@{a}" for a in assignees_list]
74
+
75
+ # Get tags
76
+ if tags is None:
77
+ existing_tags = selected_repo.get_tags()
78
+ tags_list = prompts.prompt_tags(existing_tags)
79
+ else:
80
+ tags_list = [t.strip() for t in tags.split(",")]
81
+
82
+ # Get due date
83
+ if due is None:
84
+ due_date = prompts.prompt_due_date()
85
+ else:
86
+ import dateparser
87
+
88
+ try:
89
+ due_date = dateparser.parse(due, settings={'PREFER_DATES_FROM': 'future'})
90
+ if due_date is None:
91
+ raise ValueError("Could not parse date")
92
+ except Exception as e:
93
+ click.secho(f"Error: Invalid due date: {e}", fg="red", err=True)
94
+ ctx.exit(1)
95
+
96
+ # Get description
97
+ if description is None:
98
+ description = prompts.prompt_description()
99
+
100
+ else:
101
+ # Non-interactive mode - validate required fields
102
+ if not repo or not title:
103
+ click.secho("Error: --repo and --title are required in non-interactive mode", fg="red", err=True)
104
+ ctx.exit(1)
105
+
106
+ selected_repo = manager.get_repository(repo)
107
+ if not selected_repo:
108
+ click.secho(f"Error: Repository '{repo}' not found", fg="red", err=True)
109
+ ctx.exit(1)
110
+
111
+ # Parse assignees
112
+ assignees_list = []
113
+ if assignees:
114
+ assignees_list = [a.strip() for a in assignees.split(",")]
115
+ assignees_list = [a if a.startswith("@") else f"@{a}" for a in assignees_list]
116
+ elif config.default_assignee:
117
+ # Use default assignee if none specified
118
+ assignees_list = [config.default_assignee]
119
+
120
+ # Parse tags
121
+ tags_list = []
122
+ if tags:
123
+ tags_list = [t.strip() for t in tags.split(",")]
124
+
125
+ # Parse due date
126
+ due_date = None
127
+ if due:
128
+ import dateparser
129
+
130
+ try:
131
+ due_date = dateparser.parse(due, settings={'PREFER_DATES_FROM': 'future'})
132
+ if due_date is None:
133
+ raise ValueError("Could not parse date")
134
+ except Exception as e:
135
+ click.secho(f"Error: Invalid due date: {e}", fg="red", err=True)
136
+ ctx.exit(1)
137
+
138
+ if not priority:
139
+ priority = config.default_priority
140
+
141
+ if not description:
142
+ description = ""
143
+
144
+ # Generate task ID
145
+ task_id = selected_repo.next_task_id()
146
+
147
+ # Create task
148
+ task = Task(
149
+ id=task_id,
150
+ title=title,
151
+ status=config.default_status,
152
+ priority=priority.upper(),
153
+ project=project,
154
+ assignees=assignees_list,
155
+ tags=tags_list,
156
+ due=due_date,
157
+ description=description,
158
+ repo=selected_repo.name,
159
+ )
160
+
161
+ # Save task
162
+ task_file = selected_repo.save_task(task)
163
+
164
+ click.echo()
165
+ click.secho(f"✓ Task created: {task}", fg="green")
166
+ click.echo(f" File: {task_file}")
@@ -0,0 +1,161 @@
1
+ """Config command for interactive configuration management."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from prompt_toolkit import prompt
7
+ from prompt_toolkit.completion import WordCompleter
8
+
9
+ from taskrepo.core.config import Config
10
+
11
+
12
+ @click.command(name="config")
13
+ @click.pass_context
14
+ def config_cmd(ctx):
15
+ """Interactive configuration management."""
16
+ config = ctx.obj["config"]
17
+
18
+ while True:
19
+ click.echo("\n" + "=" * 50)
20
+ click.echo("TaskRepo Configuration")
21
+ click.echo("=" * 50)
22
+ click.echo("\nWhat would you like to configure?\n")
23
+ click.echo(" 1. View current settings")
24
+ click.echo(" 2. Change parent directory")
25
+ click.echo(" 3. Set default priority")
26
+ click.echo(" 4. Set default status")
27
+ click.echo(" 5. Set default assignee")
28
+ click.echo(" 6. Configure task sorting")
29
+ click.echo(" 7. Reset to defaults")
30
+ click.echo(" 8. Exit")
31
+
32
+ try:
33
+ choice = prompt("\nEnter choice (1-8): ", completer=WordCompleter(["1", "2", "3", "4", "5", "6", "7", "8"]))
34
+ except (KeyboardInterrupt, EOFError):
35
+ click.echo("\nExiting configuration.")
36
+ break
37
+
38
+ choice = choice.strip()
39
+
40
+ if choice == "1":
41
+ # View current settings
42
+ click.echo("\n" + "-" * 50)
43
+ click.echo("Current Configuration:")
44
+ click.echo("-" * 50)
45
+ click.echo(f" Config file: {config.config_path}")
46
+ click.echo(f" Parent directory: {config.parent_dir}")
47
+ click.echo(f" Default priority: {config.default_priority}")
48
+ click.echo(f" Default status: {config.default_status}")
49
+ default_assignee = config.default_assignee if config.default_assignee else "(none)"
50
+ click.echo(f" Default assignee: {default_assignee}")
51
+ sort_by = ", ".join(config.sort_by)
52
+ click.echo(f" Sort by: {sort_by}")
53
+ click.echo("-" * 50)
54
+
55
+ elif choice == "2":
56
+ # Change parent directory
57
+ click.echo(f"\nCurrent parent directory: {config.parent_dir}")
58
+ try:
59
+ new_dir = prompt("Enter new parent directory (or press Enter to cancel): ")
60
+ if new_dir.strip():
61
+ config.parent_dir = Path(new_dir.strip()).expanduser()
62
+ click.secho(f"✓ Parent directory updated to: {config.parent_dir}", fg="green")
63
+ else:
64
+ click.echo("Cancelled.")
65
+ except (KeyboardInterrupt, EOFError):
66
+ click.echo("\nCancelled.")
67
+
68
+ elif choice == "3":
69
+ # Set default priority
70
+ click.echo(f"\nCurrent default priority: {config.default_priority}")
71
+ try:
72
+ new_priority = prompt(
73
+ "Enter default priority (H/M/L): ",
74
+ completer=WordCompleter(["H", "M", "L"], ignore_case=True),
75
+ )
76
+ new_priority = new_priority.strip().upper()
77
+ if new_priority in {"H", "M", "L"}:
78
+ config.default_priority = new_priority
79
+ click.secho(f"✓ Default priority updated to: {new_priority}", fg="green")
80
+ elif new_priority:
81
+ click.secho("✗ Invalid priority. Must be H, M, or L.", fg="red")
82
+ except (KeyboardInterrupt, EOFError):
83
+ click.echo("\nCancelled.")
84
+
85
+ elif choice == "4":
86
+ # Set default status
87
+ click.echo(f"\nCurrent default status: {config.default_status}")
88
+ statuses = ["pending", "in_progress", "completed", "cancelled"]
89
+ try:
90
+ new_status = prompt(
91
+ "Enter default status: ",
92
+ completer=WordCompleter(statuses, ignore_case=True),
93
+ )
94
+ new_status = new_status.strip().lower()
95
+ if new_status in statuses:
96
+ config.default_status = new_status
97
+ click.secho(f"✓ Default status updated to: {new_status}", fg="green")
98
+ elif new_status:
99
+ click.secho(f"✗ Invalid status. Must be one of: {', '.join(statuses)}", fg="red")
100
+ except (KeyboardInterrupt, EOFError):
101
+ click.echo("\nCancelled.")
102
+
103
+ elif choice == "5":
104
+ # Set default assignee
105
+ current_assignee = config.default_assignee if config.default_assignee else "(none)"
106
+ click.echo(f"\nCurrent default assignee: {current_assignee}")
107
+ try:
108
+ new_assignee = prompt("Enter default assignee (GitHub handle, or leave empty for none): ")
109
+ new_assignee = new_assignee.strip()
110
+ if new_assignee:
111
+ if not new_assignee.startswith("@"):
112
+ new_assignee = f"@{new_assignee}"
113
+ config.default_assignee = new_assignee
114
+ click.secho(f"✓ Default assignee updated to: {new_assignee}", fg="green")
115
+ else:
116
+ config.default_assignee = None
117
+ click.secho("✓ Default assignee cleared", fg="green")
118
+ except (KeyboardInterrupt, EOFError):
119
+ click.echo("\nCancelled.")
120
+
121
+ elif choice == "6":
122
+ # Configure task sorting
123
+ click.echo(f"\nCurrent sort order: {', '.join(config.sort_by)}")
124
+ click.echo("\nAvailable sort fields:")
125
+ click.echo(" priority, due, created, modified, status, title, project")
126
+ click.echo(" (prefix with '-' for descending order, e.g., '-created')")
127
+ try:
128
+ new_sort = prompt("Enter sort fields (comma-separated): ")
129
+ if new_sort.strip():
130
+ sort_fields = [f.strip() for f in new_sort.split(",") if f.strip()]
131
+ try:
132
+ config.sort_by = sort_fields
133
+ click.secho(f"✓ Sort order updated to: {', '.join(sort_fields)}", fg="green")
134
+ except ValueError as e:
135
+ click.secho(f"✗ Error: {e}", fg="red")
136
+ else:
137
+ click.echo("Cancelled.")
138
+ except (KeyboardInterrupt, EOFError):
139
+ click.echo("\nCancelled.")
140
+
141
+ elif choice == "7":
142
+ # Reset to defaults
143
+ click.echo("\n⚠️ This will reset ALL configuration to defaults.")
144
+ try:
145
+ confirm = prompt("Are you sure? (yes/no): ")
146
+ if confirm.strip().lower() in {"yes", "y"}:
147
+ config._data = Config.DEFAULT_CONFIG.copy()
148
+ config.save()
149
+ click.secho("✓ Configuration reset to defaults", fg="green")
150
+ else:
151
+ click.echo("Cancelled.")
152
+ except (KeyboardInterrupt, EOFError):
153
+ click.echo("\nCancelled.")
154
+
155
+ elif choice == "8":
156
+ # Exit
157
+ click.echo("\nExiting configuration.")
158
+ break
159
+
160
+ else:
161
+ click.secho("✗ Invalid choice. Please enter a number from 1-8.", fg="red")
@@ -0,0 +1,62 @@
1
+ """Delete command for removing tasks."""
2
+
3
+ import click
4
+
5
+ from taskrepo.core.repository import RepositoryManager
6
+ from taskrepo.utils.helpers import normalize_task_id
7
+
8
+
9
+ @click.command(name="delete")
10
+ @click.argument("task_id")
11
+ @click.option("--repo", "-r", help="Repository name (will search all repos if not specified)")
12
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
13
+ @click.pass_context
14
+ def delete(ctx, task_id, repo, force):
15
+ """Delete a task permanently.
16
+
17
+ TASK_ID: Task ID to delete
18
+ """
19
+ config = ctx.obj["config"]
20
+ manager = RepositoryManager(config.parent_dir)
21
+
22
+ # Normalize task ID (convert "1" to "001", etc.)
23
+ task_id = normalize_task_id(task_id)
24
+
25
+ # Find the task
26
+ if repo:
27
+ repository = manager.get_repository(repo)
28
+ if not repository:
29
+ click.secho(f"Error: Repository '{repo}' not found", fg="red", err=True)
30
+ ctx.exit(1)
31
+ task = repository.get_task(task_id)
32
+ if not task:
33
+ click.secho(f"Error: Task '{task_id}' not found in repository '{repo}'", fg="red", err=True)
34
+ ctx.exit(1)
35
+ else:
36
+ # Search all repositories
37
+ task = None
38
+ repository = None
39
+ for r in manager.discover_repositories():
40
+ t = r.get_task(task_id)
41
+ if t:
42
+ task = t
43
+ repository = r
44
+ break
45
+
46
+ if not task:
47
+ click.secho(f"Error: Task '{task_id}' not found", fg="red", err=True)
48
+ ctx.exit(1)
49
+
50
+ # Confirmation prompt (unless --force flag is used)
51
+ if not force:
52
+ click.echo(f"\nTask to delete: {task}")
53
+ if not click.confirm("Are you sure you want to delete this task? This cannot be undone.", default=False):
54
+ click.echo("Deletion cancelled.")
55
+ ctx.exit(0)
56
+
57
+ # Delete the task
58
+ if repository.delete_task(task_id):
59
+ click.secho(f"✓ Task deleted: {task}", fg="green")
60
+ else:
61
+ click.secho(f"Error: Failed to delete task '{task_id}'", fg="red", err=True)
62
+ ctx.exit(1)
@@ -0,0 +1,53 @@
1
+ """Done command for marking tasks as completed."""
2
+
3
+ import click
4
+
5
+ from taskrepo.core.repository import RepositoryManager
6
+ from taskrepo.utils.helpers import normalize_task_id
7
+
8
+
9
+ @click.command()
10
+ @click.argument("task_id")
11
+ @click.option("--repo", "-r", help="Repository name (will search all repos if not specified)")
12
+ @click.pass_context
13
+ def done(ctx, task_id, repo):
14
+ """Mark a task as completed.
15
+
16
+ TASK_ID: Task ID to mark as done
17
+ """
18
+ config = ctx.obj["config"]
19
+ manager = RepositoryManager(config.parent_dir)
20
+
21
+ # Normalize task ID (convert "1" to "001", etc.)
22
+ task_id = normalize_task_id(task_id)
23
+
24
+ # Find the task
25
+ if repo:
26
+ repository = manager.get_repository(repo)
27
+ if not repository:
28
+ click.secho(f"Error: Repository '{repo}' not found", fg="red", err=True)
29
+ ctx.exit(1)
30
+ task = repository.get_task(task_id)
31
+ if not task:
32
+ click.secho(f"Error: Task '{task_id}' not found in repository '{repo}'", fg="red", err=True)
33
+ ctx.exit(1)
34
+ else:
35
+ # Search all repositories
36
+ task = None
37
+ repository = None
38
+ for r in manager.discover_repositories():
39
+ t = r.get_task(task_id)
40
+ if t:
41
+ task = t
42
+ repository = r
43
+ break
44
+
45
+ if not task:
46
+ click.secho(f"Error: Task '{task_id}' not found", fg="red", err=True)
47
+ ctx.exit(1)
48
+
49
+ # Mark as completed
50
+ task.status = "completed"
51
+ repository.save_task(task)
52
+
53
+ click.secho(f"✓ Task marked as completed: {task}", fg="green")
@@ -0,0 +1,85 @@
1
+ """Edit command for modifying existing tasks."""
2
+
3
+ import subprocess
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from taskrepo.core.repository import RepositoryManager
10
+ from taskrepo.core.task import Task
11
+ from taskrepo.utils.helpers import normalize_task_id
12
+
13
+
14
+ @click.command()
15
+ @click.argument("task_id")
16
+ @click.option("--repo", "-r", help="Repository name (will search all repos if not specified)")
17
+ @click.option("--editor", "-e", envvar="EDITOR", default="vim", help="Editor to use (defaults to $EDITOR or vim)")
18
+ @click.pass_context
19
+ def edit(ctx, task_id, repo, editor):
20
+ """Edit an existing task.
21
+
22
+ TASK_ID: Task ID to edit
23
+ """
24
+ config = ctx.obj["config"]
25
+ manager = RepositoryManager(config.parent_dir)
26
+
27
+ # Normalize task ID (convert "1" to "001", etc.)
28
+ task_id = normalize_task_id(task_id)
29
+
30
+ # Find the task
31
+ if repo:
32
+ repository = manager.get_repository(repo)
33
+ if not repository:
34
+ click.secho(f"Error: Repository '{repo}' not found", fg="red", err=True)
35
+ ctx.exit(1)
36
+ task = repository.get_task(task_id)
37
+ if not task:
38
+ click.secho(f"Error: Task '{task_id}' not found in repository '{repo}'", fg="red", err=True)
39
+ ctx.exit(1)
40
+ else:
41
+ # Search all repositories
42
+ task = None
43
+ repository = None
44
+ for r in manager.discover_repositories():
45
+ t = r.get_task(task_id)
46
+ if t:
47
+ task = t
48
+ repository = r
49
+ break
50
+
51
+ if not task:
52
+ click.secho(f"Error: Task '{task_id}' not found", fg="red", err=True)
53
+ ctx.exit(1)
54
+
55
+ # Create temporary file with task content
56
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
57
+ temp_file = Path(f.name)
58
+ f.write(task.to_markdown())
59
+
60
+ # Open editor
61
+ try:
62
+ subprocess.run([editor, str(temp_file)], check=True)
63
+ except subprocess.CalledProcessError:
64
+ click.secho(f"Error: Editor '{editor}' failed", fg="red", err=True)
65
+ temp_file.unlink()
66
+ ctx.exit(1)
67
+ except FileNotFoundError:
68
+ click.secho(f"Error: Editor '{editor}' not found", fg="red", err=True)
69
+ temp_file.unlink()
70
+ ctx.exit(1)
71
+
72
+ # Read modified content
73
+ try:
74
+ content = temp_file.read_text()
75
+ modified_task = Task.from_markdown(content, task_id, repository.name)
76
+ except Exception as e:
77
+ click.secho(f"Error: Failed to parse edited task: {e}", fg="red", err=True)
78
+ temp_file.unlink()
79
+ ctx.exit(1)
80
+ finally:
81
+ temp_file.unlink()
82
+
83
+ # Save modified task
84
+ repository.save_task(modified_task)
85
+ click.secho(f"✓ Task updated: {modified_task}", fg="green")