Skip to content

Commit fd68910

Browse files
committed
Add check and normalise CLI commands for .issues files (Task-12, Task-13)
- issues-fs check: validates .issues files (duplicates, broken refs, parse errors) - issues-fs normalise: exports .issues to JSON issue.json structure (--dry-run supported) - 14 new tests, 108 total CLI tests passing https://claude.ai/code/session_01Sk3ihGTCicEo3dHVs6iXXW
1 parent 177758d commit fd68910

File tree

5 files changed

+417
-0
lines changed

5 files changed

+417
-0
lines changed

issues_fs_cli/cli/cli__check.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# ═══════════════════════════════════════════════════════════════════════════════
2+
# CLI Check Command - Validate .issues files
3+
# ═══════════════════════════════════════════════════════════════════════════════
4+
5+
import os
6+
import typer
7+
8+
from issues_fs.issues.issues_file.Issues_File__Check__Service import Issues_File__Check__Service
9+
from issues_fs_cli.cli.CLI__Output import CLI__Output
10+
11+
12+
def check(file : str = typer.Argument(None , help="Specific .issues file to check" ),
13+
for_agent : bool = typer.Option (False, "--for-agent" , help="Agent-optimized output" )
14+
) -> None: # Validate .issues files
15+
issues_dir = find_issues_dir()
16+
if issues_dir is None:
17+
CLI__Output.error("No .issues/ directory found. "
18+
"Run 'issues-fs init' to create one.", for_agent)
19+
raise typer.Exit(code=1)
20+
21+
if file:
22+
file_path = os.path.join(issues_dir, file) if not os.path.isabs(file) else file
23+
if not os.path.exists(file_path):
24+
CLI__Output.error(f"File not found: {file_path}", for_agent)
25+
raise typer.Exit(code=1)
26+
files = [file_path]
27+
else:
28+
files = discover_issues_files(issues_dir)
29+
30+
if not files:
31+
CLI__Output.success("No .issues files found.")
32+
raise typer.Exit(code=0)
33+
34+
checker = Issues_File__Check__Service()
35+
files_data = []
36+
37+
for path in files:
38+
with open(path, 'r') as f:
39+
content = f.read()
40+
filename = os.path.basename(path)
41+
files_data.append((content, filename))
42+
43+
if len(files_data) == 1:
44+
summary = checker.check_content(files_data[0][0], files_data[0][1])
45+
else:
46+
summary = checker.check_multiple(files_data)
47+
48+
report = checker.format_report(summary)
49+
print(report)
50+
51+
if summary.is_valid is False:
52+
raise typer.Exit(code=1)
53+
54+
55+
def find_issues_dir() -> str: # Walk up from cwd to find .issues/
56+
current = os.getcwd()
57+
while True:
58+
candidate = os.path.join(current, '.issues')
59+
if os.path.isdir(candidate):
60+
return candidate
61+
parent = os.path.dirname(current)
62+
if parent == current:
63+
return None
64+
current = parent
65+
66+
67+
def discover_issues_files(issues_dir: str) -> list: # Find all *.issues files in directory
68+
result = []
69+
for entry in os.listdir(issues_dir):
70+
if entry.endswith('.issues'):
71+
result.append(os.path.join(issues_dir, entry))
72+
return sorted(result)

issues_fs_cli/cli/cli__main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from issues_fs_cli.cli.cli__comment import comment, comments
1414
from issues_fs_cli.cli.cli__types import types_app, link_types_app
1515
from issues_fs_cli.cli.cli__init import init
16+
from issues_fs_cli.cli.cli__check import check
17+
from issues_fs_cli.cli.cli__normalise import normalise
1618

1719

1820
app = typer.Typer(name = "issues-fs" ,
@@ -32,6 +34,14 @@
3234
app.command("delete" )(delete )
3335

3436

37+
# ═══════════════════════════════════════════════════════════════════════════════
38+
# .issues File Commands
39+
# ═══════════════════════════════════════════════════════════════════════════════
40+
41+
app.command("check" )(check )
42+
app.command("normalise")(normalise )
43+
44+
3545
# ═══════════════════════════════════════════════════════════════════════════════
3646
# Link Commands
3747
# ═══════════════════════════════════════════════════════════════════════════════
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# ═══════════════════════════════════════════════════════════════════════════════
2+
# CLI Normalise Command - Export .issues files to JSON issue structure
3+
# ═══════════════════════════════════════════════════════════════════════════════
4+
5+
import json
6+
import os
7+
import typer
8+
9+
from issues_fs.issues.issues_file.Issues_File__Normalise__Service import Issues_File__Normalise__Service
10+
from issues_fs_cli.cli.CLI__Output import CLI__Output
11+
12+
13+
def normalise(file : str = typer.Argument(None , help="Specific .issues file to normalise" ),
14+
dry_run : bool = typer.Option (False, "--dry-run" , help="Show what would be written" ),
15+
for_agent : bool = typer.Option (False, "--for-agent" , help="Agent-optimized output" )
16+
) -> None: # Export .issues to JSON
17+
issues_dir = find_issues_dir()
18+
if issues_dir is None:
19+
CLI__Output.error("No .issues/ directory found. "
20+
"Run 'issues-fs init' to create one.", for_agent)
21+
raise typer.Exit(code=1)
22+
23+
if file:
24+
file_path = os.path.join(issues_dir, file) if not os.path.isabs(file) else file
25+
if not os.path.exists(file_path):
26+
CLI__Output.error(f"File not found: {file_path}", for_agent)
27+
raise typer.Exit(code=1)
28+
files = [file_path]
29+
else:
30+
files = discover_issues_files(issues_dir)
31+
32+
if not files:
33+
CLI__Output.success("No .issues files found.")
34+
raise typer.Exit(code=0)
35+
36+
normaliser = Issues_File__Normalise__Service()
37+
files_data = []
38+
39+
for path in files:
40+
with open(path, 'r') as f:
41+
content = f.read()
42+
filename = os.path.basename(path)
43+
files_data.append((content, filename))
44+
45+
if len(files_data) == 1:
46+
file_map, errors = normaliser.normalise_to_dict(files_data[0][0], files_data[0][1])
47+
else:
48+
file_map, errors = normaliser.normalise_multiple(files_data)
49+
50+
if dry_run:
51+
print(f"Would write {len(file_map)} files:")
52+
for path in sorted(file_map.keys()):
53+
print(f" {path}")
54+
if errors:
55+
print(f"\nErrors ({len(errors)}):")
56+
for error in errors:
57+
print(f" {error}")
58+
return
59+
60+
written = 0
61+
for rel_path, content in file_map.items():
62+
abs_path = os.path.join(issues_dir, rel_path)
63+
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
64+
with open(abs_path, 'w') as f:
65+
f.write(content)
66+
written += 1
67+
68+
print(f"Normalised: {written} issue files written to {issues_dir}/")
69+
70+
if errors:
71+
print(f"Errors ({len(errors)}):")
72+
for error in errors:
73+
print(f" {error}")
74+
raise typer.Exit(code=1)
75+
76+
77+
def find_issues_dir() -> str: # Walk up from cwd to find .issues/
78+
current = os.getcwd()
79+
while True:
80+
candidate = os.path.join(current, '.issues')
81+
if os.path.isdir(candidate):
82+
return candidate
83+
parent = os.path.dirname(current)
84+
if parent == current:
85+
return None
86+
current = parent
87+
88+
89+
def discover_issues_files(issues_dir: str) -> list: # Find all *.issues files in directory
90+
result = []
91+
for entry in os.listdir(issues_dir):
92+
if entry.endswith('.issues'):
93+
result.append(os.path.join(issues_dir, entry))
94+
return sorted(result)

tests/unit/cli/test_cli__check.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# ═══════════════════════════════════════════════════════════════════════════════
2+
# test_cli__check - Tests for the check CLI command
3+
# ═══════════════════════════════════════════════════════════════════════════════
4+
5+
import os
6+
import tempfile
7+
import shutil
8+
9+
from unittest import TestCase
10+
from typer.testing import CliRunner
11+
12+
from issues_fs_cli.cli.cli__main import app
13+
14+
15+
class test_cli__check(TestCase):
16+
17+
@classmethod
18+
def setUpClass(cls):
19+
cls.runner = CliRunner()
20+
cls.temp_dir = tempfile.mkdtemp()
21+
cls.issues_dir = os.path.join(cls.temp_dir, '.issues')
22+
os.makedirs(cls.issues_dir)
23+
cls.original_cwd = os.getcwd()
24+
os.chdir(cls.temp_dir)
25+
26+
@classmethod
27+
def tearDownClass(cls):
28+
os.chdir(cls.original_cwd)
29+
shutil.rmtree(cls.temp_dir)
30+
31+
def setUp(self):
32+
for f in os.listdir(self.issues_dir):
33+
if f.endswith('.issues'):
34+
os.remove(os.path.join(self.issues_dir, f))
35+
36+
# ═══════════════════════════════════════════════════════════════════════════
37+
# Basic Check
38+
# ═══════════════════════════════════════════════════════════════════════════
39+
40+
def test__check__no_files(self):
41+
result = self.runner.invoke(app, ["check"])
42+
assert result.exit_code == 0
43+
assert "No .issues files" in result.output
44+
45+
def test__check__valid_file(self):
46+
path = os.path.join(self.issues_dir, 'tasks.issues')
47+
with open(path, 'w') as f:
48+
f.write('Task-1 | todo | First task\nTask-2 | done | Second task')
49+
50+
result = self.runner.invoke(app, ["check"])
51+
assert result.exit_code == 0
52+
assert 'PASS' in result.output
53+
assert 'Issues: 2' in result.output
54+
55+
def test__check__invalid_file(self):
56+
path = os.path.join(self.issues_dir, 'bad.issues')
57+
with open(path, 'w') as f:
58+
f.write('Task-1 | todo | Good\nbad line\nalso bad')
59+
60+
result = self.runner.invoke(app, ["check"])
61+
assert result.exit_code == 1
62+
assert 'FAIL' in result.output
63+
64+
# ═══════════════════════════════════════════════════════════════════════════
65+
# Specific File
66+
# ═══════════════════════════════════════════════════════════════════════════
67+
68+
def test__check__specific_file(self):
69+
path = os.path.join(self.issues_dir, 'specific.issues')
70+
with open(path, 'w') as f:
71+
f.write('Bug-1 | confirmed | A bug')
72+
73+
result = self.runner.invoke(app, ["check", "specific.issues"])
74+
assert result.exit_code == 0
75+
assert 'PASS' in result.output
76+
77+
def test__check__missing_file(self):
78+
result = self.runner.invoke(app, ["check", "ghost.issues"])
79+
assert result.exit_code == 1
80+
assert 'not found' in result.output.lower()
81+
82+
# ═══════════════════════════════════════════════════════════════════════════
83+
# Validation Errors
84+
# ═══════════════════════════════════════════════════════════════════════════
85+
86+
def test__check__duplicate_labels(self):
87+
path = os.path.join(self.issues_dir, 'dups.issues')
88+
with open(path, 'w') as f:
89+
f.write('Task-1 | todo | First\nTask-1 | done | Duplicate')
90+
91+
result = self.runner.invoke(app, ["check"])
92+
assert result.exit_code == 1
93+
assert 'Duplicate' in result.output
94+
assert 'Task-1' in result.output
95+
96+
def test__check__broken_refs(self):
97+
path = os.path.join(self.issues_dir, 'refs.issues')
98+
with open(path, 'w') as f:
99+
f.write('Task-1 | todo | Needs -> Ghost-1')
100+
101+
result = self.runner.invoke(app, ["check"])
102+
assert result.exit_code == 1
103+
assert 'Broken' in result.output
104+
assert 'Ghost-1' in result.output
105+
106+
# ═══════════════════════════════════════════════════════════════════════════
107+
# Multiple Files
108+
# ═══════════════════════════════════════════════════════════════════════════
109+
110+
def test__check__multiple_files(self):
111+
with open(os.path.join(self.issues_dir, 'a.issues'), 'w') as f:
112+
f.write('Task-1 | todo | First')
113+
with open(os.path.join(self.issues_dir, 'b.issues'), 'w') as f:
114+
f.write('Bug-1 | confirmed | A bug')
115+
116+
result = self.runner.invoke(app, ["check"])
117+
assert result.exit_code == 0
118+
assert 'PASS' in result.output
119+
assert 'Issues: 2' in result.output

0 commit comments

Comments
 (0)