monoco-toolkit 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.
- monoco/core/__init__.py +0 -0
- monoco/core/config.py +113 -0
- monoco/core/git.py +184 -0
- monoco/core/output.py +97 -0
- monoco/core/setup.py +285 -0
- monoco/core/telemetry.py +86 -0
- monoco/core/workspace.py +40 -0
- monoco/daemon/__init__.py +0 -0
- monoco/daemon/app.py +378 -0
- monoco/daemon/commands.py +36 -0
- monoco/daemon/models.py +24 -0
- monoco/daemon/reproduce_stats.py +41 -0
- monoco/daemon/services.py +265 -0
- monoco/daemon/stats.py +124 -0
- monoco/features/__init__.py +0 -0
- monoco/features/config/commands.py +70 -0
- monoco/features/i18n/__init__.py +0 -0
- monoco/features/i18n/commands.py +121 -0
- monoco/features/i18n/core.py +178 -0
- monoco/features/issue/commands.py +710 -0
- monoco/features/issue/core.py +1174 -0
- monoco/features/issue/linter.py +172 -0
- monoco/features/issue/models.py +154 -0
- monoco/features/skills/__init__.py +1 -0
- monoco/features/skills/core.py +96 -0
- monoco/features/spike/commands.py +110 -0
- monoco/features/spike/core.py +154 -0
- monoco/main.py +73 -0
- monoco_toolkit-0.1.0.dist-info/METADATA +86 -0
- monoco_toolkit-0.1.0.dist-info/RECORD +33 -0
- monoco_toolkit-0.1.0.dist-info/WHEEL +4 -0
- monoco_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- monoco_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.tree import Tree
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from monoco.core.config import get_config
|
|
11
|
+
from monoco.core.output import print_output
|
|
12
|
+
from .models import IssueType, IssueStatus, IssueSolution, IssueStage, IsolationType, IssueMetadata
|
|
13
|
+
from . import core
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Agent-Native Issue Management.")
|
|
16
|
+
backlog_app = typer.Typer(help="Manage backlog operations.")
|
|
17
|
+
app.add_typer(backlog_app, name="backlog")
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
@app.command("create")
|
|
21
|
+
def create(
|
|
22
|
+
type: IssueType = typer.Argument(..., help="Issue type (epic, feature, chore, fix)"),
|
|
23
|
+
title: str = typer.Option(..., "--title", "-t", help="Issue title"),
|
|
24
|
+
parent: Optional[str] = typer.Option(None, "--parent", "-p", help="Parent Issue ID"),
|
|
25
|
+
is_backlog: bool = typer.Option(False, "--backlog", help="Create as backlog item"),
|
|
26
|
+
stage: Optional[IssueStage] = typer.Option(None, "--stage", help="Issue stage (todo, doing, review)"),
|
|
27
|
+
dependencies: List[str] = typer.Option([], "--dependency", "-d", help="Issue dependency ID(s)"),
|
|
28
|
+
related: List[str] = typer.Option([], "--related", "-r", help="Related Issue ID(s)"),
|
|
29
|
+
subdir: Optional[str] = typer.Option(None, "--subdir", "-s", help="Subdirectory for organization (e.g. 'Backend/Auth')"),
|
|
30
|
+
sprint: Optional[str] = typer.Option(None, "--sprint", help="Sprint ID"),
|
|
31
|
+
tags: List[str] = typer.Option([], "--tag", help="Tags"),
|
|
32
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
33
|
+
):
|
|
34
|
+
"""Create a new issue."""
|
|
35
|
+
config = get_config()
|
|
36
|
+
issues_root = _resolve_issues_root(config, root)
|
|
37
|
+
status = IssueStatus.BACKLOG if is_backlog else IssueStatus.OPEN
|
|
38
|
+
|
|
39
|
+
if parent:
|
|
40
|
+
parent_path = core.find_issue_path(issues_root, parent)
|
|
41
|
+
if not parent_path:
|
|
42
|
+
console.print(f"[red]✘ Error:[/red] Parent issue {parent} not found.")
|
|
43
|
+
raise typer.Exit(code=1)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
issue, path = core.create_issue_file(
|
|
47
|
+
issues_root,
|
|
48
|
+
type,
|
|
49
|
+
title,
|
|
50
|
+
parent,
|
|
51
|
+
status=status,
|
|
52
|
+
stage=stage,
|
|
53
|
+
dependencies=dependencies,
|
|
54
|
+
related=related,
|
|
55
|
+
subdir=subdir,
|
|
56
|
+
sprint=sprint,
|
|
57
|
+
tags=tags
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
rel_path = path.relative_to(Path.cwd())
|
|
62
|
+
except ValueError:
|
|
63
|
+
rel_path = path
|
|
64
|
+
|
|
65
|
+
console.print(f"[green]✔[/green] Created [bold]{issue.id}[/bold] in status [cyan]{issue.status.value}[/cyan].")
|
|
66
|
+
console.print(f"[dim]Path: {rel_path}[/dim]")
|
|
67
|
+
except ValueError as e:
|
|
68
|
+
console.print(f"[red]✘ Error:[/red] {str(e)}")
|
|
69
|
+
raise typer.Exit(code=1)
|
|
70
|
+
|
|
71
|
+
@app.command("open")
|
|
72
|
+
def move_open(
|
|
73
|
+
issue_id: str = typer.Argument(..., help="Issue ID to open"),
|
|
74
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
75
|
+
):
|
|
76
|
+
"""Move issue to open status and set stage to Todo."""
|
|
77
|
+
config = get_config()
|
|
78
|
+
issues_root = _resolve_issues_root(config, root)
|
|
79
|
+
try:
|
|
80
|
+
# Pull operation: Force stage to TODO
|
|
81
|
+
core.update_issue(issues_root, issue_id, status=IssueStatus.OPEN, stage=IssueStage.TODO)
|
|
82
|
+
console.print(f"[green]▶[/green] Issue [bold]{issue_id}[/bold] moved to open/todo.")
|
|
83
|
+
except Exception as e:
|
|
84
|
+
console.print(f"[red]✘ Error:[/red] {str(e)}")
|
|
85
|
+
raise typer.Exit(code=1)
|
|
86
|
+
|
|
87
|
+
@app.command("start")
|
|
88
|
+
def start(
|
|
89
|
+
issue_id: str = typer.Argument(..., help="Issue ID to start"),
|
|
90
|
+
branch: bool = typer.Option(False, "--branch", "-b", help="Start in a new git branch"),
|
|
91
|
+
worktree: bool = typer.Option(False, "--worktree", "-w", help="Start in a new git worktree"),
|
|
92
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
93
|
+
):
|
|
94
|
+
"""Start working on an issue (Stage -> Doing)."""
|
|
95
|
+
config = get_config()
|
|
96
|
+
issues_root = _resolve_issues_root(config, root)
|
|
97
|
+
project_root = _resolve_project_root(config)
|
|
98
|
+
|
|
99
|
+
if branch and worktree:
|
|
100
|
+
console.print("[red]Error:[/red] Cannot specify both --branch and --worktree.")
|
|
101
|
+
raise typer.Exit(code=1)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
# Implicitly ensure status is Open
|
|
105
|
+
core.update_issue(issues_root, issue_id, status=IssueStatus.OPEN, stage=IssueStage.DOING)
|
|
106
|
+
|
|
107
|
+
if branch:
|
|
108
|
+
try:
|
|
109
|
+
meta = core.start_issue_isolation(issues_root, issue_id, IsolationType.BRANCH, project_root)
|
|
110
|
+
console.print(f"[green]✔[/green] Switched to branch [bold]{meta.isolation.ref}[/bold]")
|
|
111
|
+
except Exception as e:
|
|
112
|
+
console.print(f"[red]Error:[/red] Failed to create branch: {e}")
|
|
113
|
+
raise typer.Exit(code=1)
|
|
114
|
+
|
|
115
|
+
if worktree:
|
|
116
|
+
try:
|
|
117
|
+
meta = core.start_issue_isolation(issues_root, issue_id, IsolationType.WORKTREE, project_root)
|
|
118
|
+
console.print(f"[green]✔[/green] Created worktree at [bold]{meta.isolation.path}[/bold]")
|
|
119
|
+
except Exception as e:
|
|
120
|
+
console.print(f"[red]Error:[/red] Failed to create worktree: {e}")
|
|
121
|
+
raise typer.Exit(code=1)
|
|
122
|
+
|
|
123
|
+
console.print(f"[green]🚀[/green] Issue [bold]{issue_id}[/bold] started.")
|
|
124
|
+
except Exception as e:
|
|
125
|
+
console.print(f"[red]✘ Error:[/red] {str(e)}")
|
|
126
|
+
raise typer.Exit(code=1)
|
|
127
|
+
|
|
128
|
+
@app.command("submit")
|
|
129
|
+
def submit(
|
|
130
|
+
issue_id: str = typer.Argument(..., help="Issue ID to submit"),
|
|
131
|
+
prune: bool = typer.Option(False, "--prune", help="Delete branch/worktree after submit"),
|
|
132
|
+
force: bool = typer.Option(False, "--force", help="Force delete branch/worktree"),
|
|
133
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
134
|
+
):
|
|
135
|
+
"""Submit issue for review (Stage -> Review) and generate delivery report."""
|
|
136
|
+
config = get_config()
|
|
137
|
+
issues_root = _resolve_issues_root(config, root)
|
|
138
|
+
project_root = _resolve_project_root(config)
|
|
139
|
+
try:
|
|
140
|
+
# Implicitly ensure status is Open
|
|
141
|
+
core.update_issue(issues_root, issue_id, status=IssueStatus.OPEN, stage=IssueStage.REVIEW)
|
|
142
|
+
console.print(f"[green]🚀[/green] Issue [bold]{issue_id}[/bold] submitted for review.")
|
|
143
|
+
|
|
144
|
+
# Delivery Report Generation
|
|
145
|
+
try:
|
|
146
|
+
core.generate_delivery_report(issues_root, issue_id, project_root)
|
|
147
|
+
console.print(f"[dim]✔ Delivery report appended to issue file.[/dim]")
|
|
148
|
+
except Exception as e:
|
|
149
|
+
console.print(f"[yellow]⚠ Failed to generate delivery report: {e}[/yellow]")
|
|
150
|
+
|
|
151
|
+
if prune:
|
|
152
|
+
try:
|
|
153
|
+
deleted = core.prune_issue_resources(issues_root, issue_id, force, project_root)
|
|
154
|
+
if deleted:
|
|
155
|
+
console.print(f"[dim]✔ Pruned resources: {', '.join(deleted)}[/dim]")
|
|
156
|
+
except Exception as e:
|
|
157
|
+
console.print(f"[red]Prune Error:[/red] {e}")
|
|
158
|
+
raise typer.Exit(code=1)
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
console.print(f"[red]✘ Error:[/red] {str(e)}")
|
|
162
|
+
raise typer.Exit(code=1)
|
|
163
|
+
|
|
164
|
+
@app.command("close")
|
|
165
|
+
def move_close(
|
|
166
|
+
issue_id: str = typer.Argument(..., help="Issue ID to close"),
|
|
167
|
+
solution: Optional[IssueSolution] = typer.Option(None, "--solution", "-s", help="Solution type"),
|
|
168
|
+
prune: bool = typer.Option(False, "--prune", help="Delete branch/worktree after close"),
|
|
169
|
+
force: bool = typer.Option(False, "--force", help="Force delete branch/worktree"),
|
|
170
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
171
|
+
):
|
|
172
|
+
"""Close issue."""
|
|
173
|
+
config = get_config()
|
|
174
|
+
issues_root = _resolve_issues_root(config, root)
|
|
175
|
+
project_root = _resolve_project_root(config)
|
|
176
|
+
try:
|
|
177
|
+
core.update_issue(issues_root, issue_id, status=IssueStatus.CLOSED, solution=solution)
|
|
178
|
+
console.print(f"[dim]✔[/dim] Issue [bold]{issue_id}[/bold] closed.")
|
|
179
|
+
|
|
180
|
+
if prune:
|
|
181
|
+
try:
|
|
182
|
+
deleted = core.prune_issue_resources(issues_root, issue_id, force, project_root)
|
|
183
|
+
if deleted:
|
|
184
|
+
console.print(f"[dim]✔ Pruned resources: {', '.join(deleted)}[/dim]")
|
|
185
|
+
except Exception as e:
|
|
186
|
+
console.print(f"[red]Prune Error:[/red] {e}")
|
|
187
|
+
raise typer.Exit(code=1)
|
|
188
|
+
|
|
189
|
+
except Exception as e:
|
|
190
|
+
console.print(f"[red]✘ Error:[/red] {str(e)}")
|
|
191
|
+
raise typer.Exit(code=1)
|
|
192
|
+
|
|
193
|
+
@backlog_app.command("push")
|
|
194
|
+
def push(
|
|
195
|
+
issue_id: str = typer.Argument(..., help="Issue ID to push to backlog"),
|
|
196
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
197
|
+
):
|
|
198
|
+
"""Push issue to backlog."""
|
|
199
|
+
config = get_config()
|
|
200
|
+
issues_root = _resolve_issues_root(config, root)
|
|
201
|
+
try:
|
|
202
|
+
core.update_issue(issues_root, issue_id, status=IssueStatus.BACKLOG)
|
|
203
|
+
console.print(f"[blue]💤[/blue] Issue [bold]{issue_id}[/bold] pushed to backlog.")
|
|
204
|
+
except Exception as e:
|
|
205
|
+
console.print(f"[red]✘ Error:[/red] {str(e)}")
|
|
206
|
+
raise typer.Exit(code=1)
|
|
207
|
+
|
|
208
|
+
@backlog_app.command("pull")
|
|
209
|
+
def pull(
|
|
210
|
+
issue_id: str = typer.Argument(..., help="Issue ID to pull from backlog"),
|
|
211
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
212
|
+
):
|
|
213
|
+
"""Pull issue from backlog (Open & Todo)."""
|
|
214
|
+
config = get_config()
|
|
215
|
+
issues_root = _resolve_issues_root(config, root)
|
|
216
|
+
try:
|
|
217
|
+
core.update_issue(issues_root, issue_id, status=IssueStatus.OPEN, stage=IssueStage.TODO)
|
|
218
|
+
console.print(f"[green]🔥[/green] Issue [bold]{issue_id}[/bold] pulled from backlog.")
|
|
219
|
+
except Exception as e:
|
|
220
|
+
console.print(f"[red]✘ Error:[/red] {str(e)}")
|
|
221
|
+
raise typer.Exit(code=1)
|
|
222
|
+
|
|
223
|
+
@app.command("cancel")
|
|
224
|
+
def cancel(
|
|
225
|
+
issue_id: str = typer.Argument(..., help="Issue ID to cancel"),
|
|
226
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
227
|
+
):
|
|
228
|
+
"""Cancel issue."""
|
|
229
|
+
config = get_config()
|
|
230
|
+
issues_root = _resolve_issues_root(config, root)
|
|
231
|
+
try:
|
|
232
|
+
core.update_issue(issues_root, issue_id, status=IssueStatus.CLOSED, solution=IssueSolution.CANCELLED)
|
|
233
|
+
console.print(f"[red]✘[/red] Issue [bold]{issue_id}[/bold] cancelled.")
|
|
234
|
+
except Exception as e:
|
|
235
|
+
console.print(f"[red]✘ Error:[/red] {str(e)}")
|
|
236
|
+
raise typer.Exit(code=1)
|
|
237
|
+
|
|
238
|
+
@app.command("delete")
|
|
239
|
+
def delete(
|
|
240
|
+
issue_id: str = typer.Argument(..., help="Issue ID to delete"),
|
|
241
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
242
|
+
):
|
|
243
|
+
"""Physically remove an issue file."""
|
|
244
|
+
config = get_config()
|
|
245
|
+
issues_root = _resolve_issues_root(config, root)
|
|
246
|
+
try:
|
|
247
|
+
core.delete_issue_file(issues_root, issue_id)
|
|
248
|
+
console.print(f"[red]✔[/red] Issue [bold]{issue_id}[/bold] physically deleted.")
|
|
249
|
+
except Exception as e:
|
|
250
|
+
console.print(f"[red]✘ Error:[/red] {str(e)}")
|
|
251
|
+
raise typer.Exit(code=1)
|
|
252
|
+
|
|
253
|
+
@app.command("move")
|
|
254
|
+
def move(
|
|
255
|
+
issue_id: str = typer.Argument(..., help="Issue ID to move"),
|
|
256
|
+
target: str = typer.Option(..., "--to", help="Target project directory (e.g., ../OtherProject)"),
|
|
257
|
+
renumber: bool = typer.Option(False, "--renumber", help="Automatically renumber on ID conflict"),
|
|
258
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override source issues root directory"),
|
|
259
|
+
):
|
|
260
|
+
"""Move an issue to another project."""
|
|
261
|
+
config = get_config()
|
|
262
|
+
source_issues_root = _resolve_issues_root(config, root)
|
|
263
|
+
|
|
264
|
+
# Resolve target project
|
|
265
|
+
target_path = Path(target).resolve()
|
|
266
|
+
|
|
267
|
+
# Check if target is a project root or Issues directory
|
|
268
|
+
if (target_path / "Issues").exists():
|
|
269
|
+
target_issues_root = target_path / "Issues"
|
|
270
|
+
elif target_path.name == "Issues" and target_path.exists():
|
|
271
|
+
target_issues_root = target_path
|
|
272
|
+
else:
|
|
273
|
+
console.print(f"[red]✘ Error:[/red] Target path must be a project root with 'Issues' directory or an 'Issues' directory itself.")
|
|
274
|
+
raise typer.Exit(code=1)
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
updated_meta, new_path = core.move_issue(
|
|
278
|
+
source_issues_root,
|
|
279
|
+
issue_id,
|
|
280
|
+
target_issues_root,
|
|
281
|
+
renumber=renumber
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
rel_path = new_path.relative_to(Path.cwd())
|
|
286
|
+
except ValueError:
|
|
287
|
+
rel_path = new_path
|
|
288
|
+
|
|
289
|
+
if updated_meta.id != issue_id:
|
|
290
|
+
console.print(f"[green]✔[/green] Moved and renumbered: [bold]{issue_id}[/bold] → [bold]{updated_meta.id}[/bold]")
|
|
291
|
+
else:
|
|
292
|
+
console.print(f"[green]✔[/green] Moved [bold]{issue_id}[/bold] to target project.")
|
|
293
|
+
|
|
294
|
+
console.print(f"[dim]New path: {rel_path}[/dim]")
|
|
295
|
+
|
|
296
|
+
except FileNotFoundError as e:
|
|
297
|
+
console.print(f"[red]✘ Error:[/red] {str(e)}")
|
|
298
|
+
raise typer.Exit(code=1)
|
|
299
|
+
except ValueError as e:
|
|
300
|
+
console.print(f"[red]✘ Conflict:[/red] {str(e)}")
|
|
301
|
+
raise typer.Exit(code=1)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
console.print(f"[red]✘ Error:[/red] {str(e)}")
|
|
304
|
+
raise typer.Exit(code=1)
|
|
305
|
+
|
|
306
|
+
@app.command("board")
|
|
307
|
+
def board(
|
|
308
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
309
|
+
):
|
|
310
|
+
"""Visualize issues in a Kanban board."""
|
|
311
|
+
config = get_config()
|
|
312
|
+
issues_root = _resolve_issues_root(config, root)
|
|
313
|
+
|
|
314
|
+
board_data = core.get_board_data(issues_root)
|
|
315
|
+
|
|
316
|
+
from rich.columns import Columns
|
|
317
|
+
from rich.console import RenderableType
|
|
318
|
+
|
|
319
|
+
columns: List[RenderableType] = []
|
|
320
|
+
|
|
321
|
+
stage_titles = {
|
|
322
|
+
"todo": "[bold white]TODO[/bold white]",
|
|
323
|
+
"doing": "[bold yellow]DOING[/bold yellow]",
|
|
324
|
+
"review": "[bold cyan]REVIEW[/bold cyan]",
|
|
325
|
+
"done": "[bold green]DONE[/bold green]"
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
for stage, issues in board_data.items():
|
|
329
|
+
issue_list = []
|
|
330
|
+
for issue in sorted(issues, key=lambda x: x.updated_at, reverse=True):
|
|
331
|
+
type_color = {
|
|
332
|
+
IssueType.FEATURE: "green",
|
|
333
|
+
IssueType.CHORE: "blue",
|
|
334
|
+
IssueType.FIX: "red",
|
|
335
|
+
IssueType.EPIC: "magenta"
|
|
336
|
+
}.get(issue.type, "white")
|
|
337
|
+
|
|
338
|
+
issue_list.append(
|
|
339
|
+
Panel(
|
|
340
|
+
f"[{type_color}]{issue.id}[/{type_color}]\n{issue.title}",
|
|
341
|
+
expand=True,
|
|
342
|
+
padding=(0, 1)
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
from rich.console import Group
|
|
347
|
+
content = Group(*issue_list) if issue_list else "[dim]Empty[/dim]"
|
|
348
|
+
|
|
349
|
+
columns.append(
|
|
350
|
+
Panel(
|
|
351
|
+
content,
|
|
352
|
+
title=stage_titles.get(stage, stage.upper()),
|
|
353
|
+
width=35,
|
|
354
|
+
padding=(1, 1)
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
console.print(Columns(columns, equal=True, expand=True))
|
|
359
|
+
|
|
360
|
+
@app.command("list")
|
|
361
|
+
def list_cmd(
|
|
362
|
+
status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter by status (open, closed, backlog, all)"),
|
|
363
|
+
type: Optional[IssueType] = typer.Option(None, "--type", "-t", help="Filter by type"),
|
|
364
|
+
stage: Optional[IssueStage] = typer.Option(None, "--stage", help="Filter by stage"),
|
|
365
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
366
|
+
workspace: bool = typer.Option(False, "--workspace", "-w", help="Include issues from workspace members"),
|
|
367
|
+
):
|
|
368
|
+
"""List issues in a table format with filtering."""
|
|
369
|
+
config = get_config()
|
|
370
|
+
issues_root = _resolve_issues_root(config, root)
|
|
371
|
+
|
|
372
|
+
# Validation
|
|
373
|
+
if status and status.lower() not in ["open", "closed", "backlog", "all"]:
|
|
374
|
+
console.print(f"[red]Invalid status:[/red] {status}. Use open, closed, backlog or all.")
|
|
375
|
+
raise typer.Exit(code=1)
|
|
376
|
+
|
|
377
|
+
target_status = status.lower() if status else "open"
|
|
378
|
+
|
|
379
|
+
issues = core.list_issues(issues_root, recursive_workspace=workspace)
|
|
380
|
+
filtered = []
|
|
381
|
+
|
|
382
|
+
for i in issues:
|
|
383
|
+
# Status Filter
|
|
384
|
+
if target_status != "all":
|
|
385
|
+
if i.status.value != target_status:
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
# Type Filter
|
|
389
|
+
if type and i.type != type:
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
# Stage Filter
|
|
393
|
+
if stage and i.stage != stage:
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
filtered.append(i)
|
|
397
|
+
|
|
398
|
+
# Sort: Updated Descending
|
|
399
|
+
filtered.append(i)
|
|
400
|
+
|
|
401
|
+
# Sort: Updated Descending
|
|
402
|
+
filtered.sort(key=lambda x: x.updated_at, reverse=True)
|
|
403
|
+
|
|
404
|
+
# Render
|
|
405
|
+
_render_issues_table(filtered, title=f"Issues ({len(filtered)})")
|
|
406
|
+
|
|
407
|
+
def _render_issues_table(issues: List[IssueMetadata], title: str = "Issues"):
|
|
408
|
+
table = Table(title=title, show_header=True, header_style="bold magenta")
|
|
409
|
+
table.add_column("ID", style="cyan", width=12)
|
|
410
|
+
table.add_column("Type", width=10)
|
|
411
|
+
table.add_column("Status", width=10)
|
|
412
|
+
table.add_column("Stage", width=10)
|
|
413
|
+
table.add_column("Title", style="white")
|
|
414
|
+
table.add_column("Updated", style="dim", width=20)
|
|
415
|
+
|
|
416
|
+
type_colors = {
|
|
417
|
+
IssueType.EPIC: "magenta",
|
|
418
|
+
IssueType.FEATURE: "green",
|
|
419
|
+
IssueType.CHORE: "blue",
|
|
420
|
+
IssueType.FIX: "red"
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
status_colors = {
|
|
424
|
+
IssueStatus.OPEN: "green",
|
|
425
|
+
IssueStatus.BACKLOG: "blue",
|
|
426
|
+
IssueStatus.CLOSED: "dim"
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for i in issues:
|
|
430
|
+
t_color = type_colors.get(i.type, "white")
|
|
431
|
+
s_color = status_colors.get(i.status, "white")
|
|
432
|
+
|
|
433
|
+
stage_str = i.stage.value if i.stage else "-"
|
|
434
|
+
updated_str = i.updated_at.strftime("%Y-%m-%d %H:%M")
|
|
435
|
+
|
|
436
|
+
table.add_row(
|
|
437
|
+
i.id,
|
|
438
|
+
f"[{t_color}]{i.type.value}[/{t_color}]",
|
|
439
|
+
f"[{s_color}]{i.status.value}[/{s_color}]",
|
|
440
|
+
stage_str,
|
|
441
|
+
i.title,
|
|
442
|
+
updated_str
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
console.print(table)
|
|
446
|
+
|
|
447
|
+
@app.command("query")
|
|
448
|
+
def query_cmd(
|
|
449
|
+
query: str = typer.Argument(..., help="Search query (e.g. '+bug -ui' or 'login')"),
|
|
450
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
451
|
+
):
|
|
452
|
+
"""
|
|
453
|
+
Search issues using advanced syntax.
|
|
454
|
+
|
|
455
|
+
Syntax:
|
|
456
|
+
term : Must include 'term' (implicit AND)
|
|
457
|
+
+term : Must include 'term'
|
|
458
|
+
-term : Must NOT include 'term'
|
|
459
|
+
|
|
460
|
+
Scope: ID, Title, Body, Tags, Status, Stage, Dependencies, Related.
|
|
461
|
+
"""
|
|
462
|
+
config = get_config()
|
|
463
|
+
issues_root = _resolve_issues_root(config, root)
|
|
464
|
+
|
|
465
|
+
results = core.search_issues(issues_root, query)
|
|
466
|
+
|
|
467
|
+
# Sort by relevance? Or just updated?
|
|
468
|
+
# For now, updated at descending is useful.
|
|
469
|
+
results.sort(key=lambda x: x.updated_at, reverse=True)
|
|
470
|
+
|
|
471
|
+
_render_issues_table(results, title=f"Search Results for '{query}' ({len(results)})")
|
|
472
|
+
|
|
473
|
+
@app.command("scope")
|
|
474
|
+
def scope(
|
|
475
|
+
sprint: Optional[str] = typer.Option(None, "--sprint", help="Filter by Sprint ID"),
|
|
476
|
+
all: bool = typer.Option(False, "--all", "-a", help="Show all, otherwise show only open items"),
|
|
477
|
+
recursive: bool = typer.Option(False, "--recursive", "-r", help="Recursively scan subdirectories"),
|
|
478
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
479
|
+
workspace: bool = typer.Option(False, "--workspace", "-w", help="Include issues from workspace members"),
|
|
480
|
+
):
|
|
481
|
+
"""Show progress tree."""
|
|
482
|
+
config = get_config()
|
|
483
|
+
issues_root = _resolve_issues_root(config, root)
|
|
484
|
+
|
|
485
|
+
issues = core.list_issues(issues_root, recursive_workspace=workspace)
|
|
486
|
+
filtered_issues = []
|
|
487
|
+
|
|
488
|
+
for meta in issues:
|
|
489
|
+
if sprint and meta.sprint != sprint:
|
|
490
|
+
continue
|
|
491
|
+
if not all and meta.status != IssueStatus.OPEN:
|
|
492
|
+
continue
|
|
493
|
+
filtered_issues.append(meta)
|
|
494
|
+
|
|
495
|
+
issues = filtered_issues
|
|
496
|
+
|
|
497
|
+
tree = Tree(f"[bold blue]Monoco Issue Scope[/bold blue]")
|
|
498
|
+
epics = sorted([i for i in issues if i.type == IssueType.EPIC], key=lambda x: x.id)
|
|
499
|
+
stories = [i for i in issues if i.type == IssueType.FEATURE]
|
|
500
|
+
tasks = [i for i in issues if i.type in [IssueType.CHORE, IssueType.FIX]]
|
|
501
|
+
|
|
502
|
+
status_map = {IssueStatus.OPEN: "[blue]●[/blue]", IssueStatus.CLOSED: "[green]✔[/green]", IssueStatus.BACKLOG: "[dim]💤[/dim]"}
|
|
503
|
+
|
|
504
|
+
for epic in epics:
|
|
505
|
+
epic_node = tree.add(f"{status_map[epic.status]} [bold]{epic.id}[/bold]: {epic.title}")
|
|
506
|
+
child_stories = sorted([s for s in stories if s.parent == epic.id], key=lambda x: x.id)
|
|
507
|
+
for story in child_stories:
|
|
508
|
+
story_node = epic_node.add(f"{status_map[story.status]} [bold]{story.id}[/bold]: {story.title}")
|
|
509
|
+
child_tasks = sorted([t for t in tasks if t.parent == story.id], key=lambda x: x.id)
|
|
510
|
+
for task in child_tasks:
|
|
511
|
+
story_node.add(f"{status_map[task.status]} [bold]{task.id}[/bold]: {task.title}")
|
|
512
|
+
|
|
513
|
+
console.print(Panel(tree, expand=False))
|
|
514
|
+
|
|
515
|
+
@app.command("lint")
|
|
516
|
+
def lint(
|
|
517
|
+
recursive: bool = typer.Option(False, "--recursive", "-r", help="Recursively scan subdirectories"),
|
|
518
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
519
|
+
):
|
|
520
|
+
"""Verify the integrity of the Issues directory (declarative check)."""
|
|
521
|
+
from . import linter
|
|
522
|
+
config = get_config()
|
|
523
|
+
issues_root = _resolve_issues_root(config, root)
|
|
524
|
+
linter.run_lint(issues_root, recursive=recursive)
|
|
525
|
+
|
|
526
|
+
def _resolve_issues_root(config, cli_root: Optional[str]) -> Path:
|
|
527
|
+
"""
|
|
528
|
+
Resolve the absolute path to the issues directory.
|
|
529
|
+
Implements Smart Path Resolution & Workspace Awareness.
|
|
530
|
+
"""
|
|
531
|
+
from monoco.core.workspace import is_project_root, find_projects
|
|
532
|
+
|
|
533
|
+
# 1. Handle Explicit CLI Root
|
|
534
|
+
if cli_root:
|
|
535
|
+
path = Path(cli_root).resolve()
|
|
536
|
+
|
|
537
|
+
# Scenario A: User pointed to a Project Root (e.g. ./Toolkit)
|
|
538
|
+
# We auto-resolve to ./Toolkit/Issues if it exists
|
|
539
|
+
if is_project_root(path) and (path / "Issues").exists():
|
|
540
|
+
return path / "Issues"
|
|
541
|
+
|
|
542
|
+
# Scenario B: User pointed to Issues dir directly (e.g. ./Toolkit/Issues)
|
|
543
|
+
# Or user pointed to a path that will be created
|
|
544
|
+
return path
|
|
545
|
+
|
|
546
|
+
# 2. Handle Default / Contextual Execution (No --root)
|
|
547
|
+
# We need to detect if we are in a Workspace Root with multiple projects
|
|
548
|
+
cwd = Path.cwd()
|
|
549
|
+
|
|
550
|
+
# If CWD is NOT a project root (no monoco.yaml/Issues), scan for subprojects
|
|
551
|
+
if not is_project_root(cwd):
|
|
552
|
+
subprojects = find_projects(cwd)
|
|
553
|
+
if len(subprojects) > 1:
|
|
554
|
+
console.print(f"[yellow]Workspace detected with {len(subprojects)} projects:[/yellow]")
|
|
555
|
+
for p in subprojects:
|
|
556
|
+
console.print(f" - [bold]{p.name}[/bold]")
|
|
557
|
+
console.print("\n[yellow]Please specify a project using --root <PATH>.[/yellow]")
|
|
558
|
+
# We don't exit here strictly, but usually this means we can't find 'Issues' in CWD anyway
|
|
559
|
+
# so the config fallbacks below will likely fail or point to non-existent CWD/Issues.
|
|
560
|
+
# But let's fail fast to be helpful.
|
|
561
|
+
raise typer.Exit(code=1)
|
|
562
|
+
elif len(subprojects) == 1:
|
|
563
|
+
# Auto-select the only child project?
|
|
564
|
+
# It's safer to require explicit intent, but let's try to be helpful if it's obvious.
|
|
565
|
+
# However, standard behavior is usually "operate on current dir".
|
|
566
|
+
# Let's stick to standard config resolution, but maybe warn.
|
|
567
|
+
pass
|
|
568
|
+
|
|
569
|
+
# 3. Config Fallback
|
|
570
|
+
config_issues_path = Path(config.paths.issues)
|
|
571
|
+
if config_issues_path.is_absolute():
|
|
572
|
+
return config_issues_path
|
|
573
|
+
else:
|
|
574
|
+
return (Path(config.paths.root) / config_issues_path).resolve()
|
|
575
|
+
|
|
576
|
+
def _resolve_project_root(config) -> Path:
|
|
577
|
+
"""Resolve project root from config or defaults."""
|
|
578
|
+
return Path(config.paths.root).resolve()
|
|
579
|
+
|
|
580
|
+
@app.command("commit")
|
|
581
|
+
def commit(
|
|
582
|
+
message: Optional[str] = typer.Option(None, "--message", "-m", help="Commit message"),
|
|
583
|
+
issue_id: Optional[str] = typer.Option(None, "--issue", "-i", help="Link commit to Issue ID"),
|
|
584
|
+
detached: bool = typer.Option(False, "--detached", help="Flag commit as intentionally detached (no issue link)"),
|
|
585
|
+
type: Optional[str] = typer.Option(None, "--type", "-t", help="Commit type (feat, fix, etc.)"),
|
|
586
|
+
scope: Optional[str] = typer.Option(None, "--scope", "-s", help="Commit scope"),
|
|
587
|
+
subject: Optional[str] = typer.Option(None, "--subject", help="Commit subject"),
|
|
588
|
+
root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
|
|
589
|
+
):
|
|
590
|
+
"""
|
|
591
|
+
Atomic Commit: Validate (Lint) and Commit.
|
|
592
|
+
|
|
593
|
+
Modes:
|
|
594
|
+
1. Linked Commit (--issue): Commits staged changes with 'Ref: <ID>' footer.
|
|
595
|
+
2. Detached Commit (--detached): Commits staged changes without link.
|
|
596
|
+
3. Auto-Issue (No args): Only allowed if ONLY issue files are modified.
|
|
597
|
+
"""
|
|
598
|
+
config = get_config()
|
|
599
|
+
issues_root = _resolve_issues_root(config, root)
|
|
600
|
+
project_root = _resolve_project_root(config)
|
|
601
|
+
|
|
602
|
+
# 1. Lint Check (Gatekeeper)
|
|
603
|
+
console.print("[dim]Running pre-commit lint check...[/dim]")
|
|
604
|
+
try:
|
|
605
|
+
from . import linter
|
|
606
|
+
linter.check_integrity(issues_root, recursive=True)
|
|
607
|
+
except Exception:
|
|
608
|
+
pass
|
|
609
|
+
|
|
610
|
+
# 2. Stage & Commit
|
|
611
|
+
from monoco.core import git
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
# Check Staging Status
|
|
615
|
+
code, stdout, _ = git._run_git(["diff", "--cached", "--name-only"], project_root)
|
|
616
|
+
staged_files = [l for l in stdout.splitlines() if l.strip()]
|
|
617
|
+
|
|
618
|
+
# Determine Mode
|
|
619
|
+
if issue_id:
|
|
620
|
+
# MODE: Linked Commit
|
|
621
|
+
console.print(f"[bold cyan]Linked Commit Mode[/bold cyan] (Ref: {issue_id})")
|
|
622
|
+
|
|
623
|
+
if not core.find_issue_path(issues_root, issue_id):
|
|
624
|
+
console.print(f"[red]Error:[/red] Issue {issue_id} not found.")
|
|
625
|
+
raise typer.Exit(code=1)
|
|
626
|
+
|
|
627
|
+
if not staged_files:
|
|
628
|
+
console.print("[yellow]No staged files.[/yellow] Please `git add` files.")
|
|
629
|
+
raise typer.Exit(code=1)
|
|
630
|
+
|
|
631
|
+
if not message:
|
|
632
|
+
if not type or not subject:
|
|
633
|
+
console.print("[red]Error:[/red] Provide --message OR (--type and --subject).")
|
|
634
|
+
raise typer.Exit(code=1)
|
|
635
|
+
scope_part = f"({scope})" if scope else ""
|
|
636
|
+
message = f"{type}{scope_part}: {subject}"
|
|
637
|
+
|
|
638
|
+
if f"Ref: {issue_id}" not in message:
|
|
639
|
+
message += f"\n\nRef: {issue_id}"
|
|
640
|
+
|
|
641
|
+
commit_hash = git.git_commit(project_root, message)
|
|
642
|
+
console.print(f"[green]✔ Committed:[/green] {commit_hash[:7]}")
|
|
643
|
+
|
|
644
|
+
elif detached:
|
|
645
|
+
# MODE: Detached
|
|
646
|
+
console.print(f"[bold yellow]Detached Commit Mode[/bold yellow]")
|
|
647
|
+
|
|
648
|
+
if not staged_files:
|
|
649
|
+
console.print("[yellow]No staged files.[/yellow] Please `git add` files.")
|
|
650
|
+
raise typer.Exit(code=1)
|
|
651
|
+
|
|
652
|
+
if not message:
|
|
653
|
+
console.print("[red]Error:[/red] Detached commits require --message.")
|
|
654
|
+
raise typer.Exit(code=1)
|
|
655
|
+
|
|
656
|
+
commit_hash = git.git_commit(project_root, message)
|
|
657
|
+
console.print(f"[green]✔ Committed:[/green] {commit_hash[:7]}")
|
|
658
|
+
|
|
659
|
+
else:
|
|
660
|
+
# MODE: Implicit / Auto-DB
|
|
661
|
+
# Strict Policy: Only allow if changes are constrained to Issues/ directory
|
|
662
|
+
|
|
663
|
+
# Check if any non-issue files are staged
|
|
664
|
+
# (We assume issues dir is 'Issues/')
|
|
665
|
+
try:
|
|
666
|
+
rel_issues = issues_root.relative_to(project_root)
|
|
667
|
+
issues_prefix = str(rel_issues)
|
|
668
|
+
except ValueError:
|
|
669
|
+
issues_prefix = "Issues" # Fallback
|
|
670
|
+
|
|
671
|
+
non_issue_staged = [f for f in staged_files if not f.startswith(issues_prefix)]
|
|
672
|
+
|
|
673
|
+
if non_issue_staged:
|
|
674
|
+
console.print(f"[red]⛔ Strict Policy:[/red] Code changes detected in staging ({len(non_issue_staged)} files).")
|
|
675
|
+
console.print("You must specify [bold]--issue <ID>[/bold] or [bold]--detached[/bold].")
|
|
676
|
+
raise typer.Exit(code=1)
|
|
677
|
+
|
|
678
|
+
# If nothing staged, check unstaged Issue files (Legacy Auto-Add)
|
|
679
|
+
if not staged_files:
|
|
680
|
+
status_files = git.get_git_status(project_root, str(rel_issues))
|
|
681
|
+
if not status_files:
|
|
682
|
+
console.print("[yellow]Nothing to commit.[/yellow]")
|
|
683
|
+
return
|
|
684
|
+
|
|
685
|
+
# Auto-stage Issue files
|
|
686
|
+
git.git_add(project_root, status_files)
|
|
687
|
+
staged_files = status_files # Now they are staged
|
|
688
|
+
else:
|
|
689
|
+
pass
|
|
690
|
+
|
|
691
|
+
# Auto-generate message from Issue File
|
|
692
|
+
if not message:
|
|
693
|
+
cnt = len(staged_files)
|
|
694
|
+
if cnt == 1:
|
|
695
|
+
fpath = project_root / staged_files[0]
|
|
696
|
+
match = core.parse_issue(fpath)
|
|
697
|
+
if match:
|
|
698
|
+
action = "update"
|
|
699
|
+
message = f"docs(issues): {action} {match.id} {match.title}"
|
|
700
|
+
else:
|
|
701
|
+
message = f"docs(issues): update {staged_files[0]}"
|
|
702
|
+
else:
|
|
703
|
+
message = f"docs(issues): batch update {cnt} files"
|
|
704
|
+
|
|
705
|
+
commit_hash = git.git_commit(project_root, message)
|
|
706
|
+
console.print(f"[green]✔ Committed (DB):[/green] {commit_hash[:7]} - {message}")
|
|
707
|
+
|
|
708
|
+
except Exception as e:
|
|
709
|
+
console.print(f"[red]Git Error:[/red] {e}")
|
|
710
|
+
raise typer.Exit(code=1)
|