Skip to content

Commit bcca68f

Browse files
Merge pull request #38 from Bullish-Design/claude/devman-review-testing-OP4uY
Review devman repo and add test automation
2 parents ae5996c + ad1ab24 commit bcca68f

18 files changed

Lines changed: 1433 additions & 25 deletions

File tree

.github/workflows/test.yml

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
name: Tests
2+
3+
on:
4+
pull_request:
5+
branches: [ main, master ]
6+
push:
7+
branches: [ main, master ]
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
pull-requests: write
13+
checks: write
14+
15+
jobs:
16+
test:
17+
runs-on: ubuntu-latest
18+
strategy:
19+
matrix:
20+
python-version: ["3.11", "3.12", "3.13"]
21+
22+
steps:
23+
- name: Checkout code
24+
uses: actions/checkout@v4
25+
26+
- name: Set up Python ${{ matrix.python-version }}
27+
uses: actions/setup-python@v5
28+
with:
29+
python-version: ${{ matrix.python-version }}
30+
cache: 'pip'
31+
32+
- name: Install dependencies
33+
run: |
34+
python -m pip install --upgrade pip
35+
pip install -e ".[dev]"
36+
37+
- name: Run linting
38+
run: |
39+
ruff check src tests
40+
continue-on-error: true
41+
42+
- name: Run type checking
43+
run: |
44+
mypy src
45+
continue-on-error: true
46+
47+
- name: Run tests with coverage
48+
run: |
49+
pytest tests/ \
50+
--cov=src/devman \
51+
--cov-report=term \
52+
--cov-report=html \
53+
--cov-report=json \
54+
--cov-report=xml \
55+
-v
56+
57+
- name: Generate coverage badge
58+
if: matrix.python-version == '3.13'
59+
run: |
60+
COVERAGE=$(python -c "import json; print(json.load(open('coverage.json'))['totals']['percent_covered_display'])")
61+
echo "COVERAGE=$COVERAGE" >> $GITHUB_ENV
62+
echo "Coverage: $COVERAGE%"
63+
64+
- name: Upload coverage reports
65+
if: matrix.python-version == '3.13'
66+
uses: actions/upload-artifact@v4
67+
with:
68+
name: coverage-reports
69+
path: |
70+
htmlcov/
71+
coverage.json
72+
coverage.xml
73+
.coverage
74+
75+
- name: Comment PR with test results
76+
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
77+
uses: actions/github-script@v7
78+
with:
79+
script: |
80+
const fs = require('fs');
81+
82+
// Read coverage data
83+
const coverage = JSON.parse(fs.readFileSync('coverage.json', 'utf8'));
84+
const totalCoverage = coverage.totals.percent_covered_display;
85+
86+
// Read test output
87+
let testOutput = '';
88+
try {
89+
testOutput = fs.readFileSync('test-output.txt', 'utf8');
90+
} catch (e) {
91+
testOutput = 'Test output not available';
92+
}
93+
94+
// Build coverage table
95+
let coverageTable = '| File | Coverage |\n|------|----------|\n';
96+
for (const [file, data] of Object.entries(coverage.files)) {
97+
const fileName = file.replace('src/devman/', '');
98+
const fileCoverage = data.summary.percent_covered_display;
99+
coverageTable += `| \`${fileName}\` | ${fileCoverage}% |\n`;
100+
}
101+
102+
// Create comment body
103+
const body = `## 🧪 Test Results (Python ${{ matrix.python-version }})
104+
105+
### 📊 Coverage Summary
106+
**Total Coverage: ${totalCoverage}%**
107+
108+
<details>
109+
<summary>Coverage by File</summary>
110+
111+
${coverageTable}
112+
113+
</details>
114+
115+
### 📈 Coverage Details
116+
- **Lines:** ${coverage.totals.covered_lines}/${coverage.totals.num_statements} covered
117+
- **Branches:** ${coverage.totals.covered_branches}/${coverage.totals.num_branches} covered
118+
- **Missing Lines:** ${coverage.totals.missing_lines}
119+
120+
---
121+
*Coverage report generated by pytest-cov*`;
122+
123+
// Post or update comment
124+
const { data: comments } = await github.rest.issues.listComments({
125+
owner: context.repo.owner,
126+
repo: context.repo.repo,
127+
issue_number: context.issue.number,
128+
});
129+
130+
const botComment = comments.find(comment =>
131+
comment.user.type === 'Bot' &&
132+
comment.body.includes('Test Results (Python ${{ matrix.python-version }})')
133+
);
134+
135+
if (botComment) {
136+
await github.rest.issues.updateComment({
137+
owner: context.repo.owner,
138+
repo: context.repo.repo,
139+
comment_id: botComment.id,
140+
body: body
141+
});
142+
} else {
143+
await github.rest.issues.createComment({
144+
owner: context.repo.owner,
145+
repo: context.repo.repo,
146+
issue_number: context.issue.number,
147+
body: body
148+
});
149+
}
150+
151+
- name: Check coverage threshold
152+
if: matrix.python-version == '3.13'
153+
run: |
154+
COVERAGE=$(python -c "import json; print(float(json.load(open('coverage.json'))['totals']['percent_covered_display']))")
155+
THRESHOLD=70
156+
echo "Coverage: $COVERAGE%"
157+
echo "Threshold: $THRESHOLD%"
158+
if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
159+
echo "⚠️ Warning: Coverage $COVERAGE% is below threshold $THRESHOLD%"
160+
exit 0 # Don't fail the build, just warn
161+
else
162+
echo "✅ Coverage $COVERAGE% meets threshold $THRESHOLD%"
163+
fi
164+
165+
nix-build:
166+
runs-on: ubuntu-latest
167+
steps:
168+
- name: Checkout code
169+
uses: actions/checkout@v4
170+
171+
- name: Install Nix
172+
uses: cachix/install-nix-action@v25
173+
with:
174+
nix_path: nixpkgs=channel:nixos-unstable
175+
176+
- name: Build with Nix
177+
run: nix build .#devman
178+
179+
- name: Test devman CLI
180+
run: |
181+
./result/bin/devman --help || echo "Binary not in expected location"

flake.nix

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@
3939
typer
4040
rich
4141
pathlib-abc
42-
jinja2
4342
pydantic
4443
pyyaml
4544
tomli-w

src/devman/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# src/devman/__init__.py
22
"""DevEnv project templating system."""
33

4-
__version__ = "0.1.0"
4+
__version__ = "0.2.0"
55

src/devman/cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import typer
77

8-
from devman.commands import doctor, down, init, index, switch, up
8+
from devman.commands import bootstrap, doctor, down, init, index, switch, up
99

1010
app = typer.Typer(
1111
name="devman",
@@ -24,6 +24,7 @@ def main(ctx: typer.Context) -> None:
2424
app.command(name="up")(up.run)
2525
app.command(name="down")(down.run)
2626
app.command(name="switch")(switch.run)
27+
app.command(name="bootstrap")(bootstrap.run)
2728
app.command(name="doctor")(doctor.run)
2829
app.command(name="init")(init.run)
2930

src/devman/commands/__init__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from .bootstrap import bootstrap
44
from .doctor import doctor
55
from .down import down
6-
from .index import index_list, index_rebuild, index_status
6+
from . import index
77
from .init import init
88
from .switch import switch
99
from .up import up
@@ -12,9 +12,7 @@
1212
"bootstrap",
1313
"doctor",
1414
"down",
15-
"index_list",
16-
"index_rebuild",
17-
"index_status",
15+
"index",
1816
"init",
1917
"switch",
2018
"up",

src/devman/commands/doctor.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
from __future__ import annotations
44

5-
from typing import Dict
6-
75
from devman.claude_code import ClaudeCodeWorkspace
86
from devman.integrations import NvimIntegration, TmuxIntegration, TmuxpIntegration
97

@@ -14,7 +12,7 @@
1412
CLAUDE_WORKSPACE = ClaudeCodeWorkspace()
1513

1614

17-
def run() -> Dict[str, bool]:
15+
def run() -> dict[str, bool]:
1816
"""Return availability of external tools."""
1917
return {
2018
"tmux": TMUX.is_available(),
@@ -24,14 +22,14 @@ def run() -> Dict[str, bool]:
2422
}
2523

2624

27-
def render_report(status: Dict[str, bool]) -> list[str]:
25+
def render_report(status: dict[str, bool]) -> list[str]:
2826
"""Render a human-readable report."""
2927
lines = []
3028
for tool, available in status.items():
3129
lines.append(f"{tool}: {'ok' if available else 'missing'}")
3230
return lines
3331

3432

35-
def doctor() -> Dict[str, bool]:
33+
def doctor() -> dict[str, bool]:
3634
"""Backward-compatible alias for run."""
3735
return run()

src/devman/commands/index.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33

44
from __future__ import annotations
55

6+
import json
67
from typing import Iterable
78

9+
import typer
10+
811
from devman.discovery import IndexManager, resolve_roots
912
from devman.models import WorkspaceEntry, WorkspaceIndex
1013

14+
app = typer.Typer(help="Manage workspace index")
15+
1116

1217
def _manager(manager: IndexManager | None = None) -> IndexManager:
1318
return manager or IndexManager()
@@ -57,3 +62,49 @@ def find_entry(
5762
) -> WorkspaceEntry | None:
5863
"""Find a workspace entry matching the provided query."""
5964
return _manager(manager).find_entry(entries, query)
65+
66+
67+
@app.command(name="status")
68+
def index_status() -> None:
69+
"""Show index cache status."""
70+
payload = load_index()
71+
if not payload:
72+
typer.echo("Index cache missing.")
73+
raise typer.Exit(code=1)
74+
75+
# Convert to dict for JSON serialization
76+
index_dict = {
77+
"entries": [
78+
{
79+
"name": entry.name,
80+
"workspace_root": str(entry.workspace_root),
81+
"devman_dir": str(entry.devman_dir),
82+
"group": entry.group,
83+
"tags": entry.tags,
84+
}
85+
for entry in payload.entries
86+
]
87+
}
88+
typer.echo(json.dumps(index_dict, indent=2))
89+
90+
91+
@app.command(name="rebuild")
92+
def index_rebuild(
93+
roots: list[str] = typer.Argument(None, help="Roots to index")
94+
) -> None:
95+
"""Force rebuild the index."""
96+
resolved_roots = roots or []
97+
index = rebuild_index(resolved_roots)
98+
for line in list_entries(index.entries):
99+
typer.echo(line)
100+
101+
102+
@app.command(name="list")
103+
def index_list(
104+
roots: list[str] = typer.Argument(None, help="Roots to index")
105+
) -> None:
106+
"""List indexed workspaces."""
107+
resolved_roots = roots or []
108+
index = refresh_index(resolved_roots)
109+
for line in list_entries(index.entries):
110+
typer.echo(line)

src/devman/commands/switch.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from typing import Iterable
77

8+
import typer
9+
810
from devman.discovery import IndexManager, resolve_roots
911
from devman.models import WorkspaceEntry
1012

@@ -18,3 +20,22 @@ def resolve_workspace(
1820
index_manager = manager or IndexManager()
1921
index = index_manager.refresh(resolve_roots(roots))
2022
return index_manager.find_entry(index.entries, query)
23+
24+
25+
def run(query: str, roots: list[str] | None = None) -> WorkspaceEntry:
26+
"""Switch to a different workspace by name or query."""
27+
resolved_roots = roots or []
28+
workspace = resolve_workspace(query, resolved_roots)
29+
30+
if not workspace:
31+
raise typer.Exit(f"No workspace found matching '{query}'")
32+
33+
typer.echo(f"Switching to workspace: {workspace.name}")
34+
typer.echo(f"Location: {workspace.workspace_root}")
35+
36+
return workspace
37+
38+
39+
def switch(query: str, roots: list[str] | None = None) -> WorkspaceEntry:
40+
"""Backward-compatible alias for run."""
41+
return run(query=query, roots=roots)

src/devman/commands/up.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def resolve_active_workspace(
4545
index_manager = manager or IndexManager()
4646
index = index_manager.refresh(resolve_roots(roots))
4747
if not index.entries:
48-
raise ValueError("No workspaces found.")
48+
raise typer.Exit("No workspaces found.")
4949

5050
if selector:
5151
return selector(index.entries)
@@ -67,7 +67,7 @@ def _resolve_workspace_config(root: Path | None = None) -> WorkspaceConfig:
6767
workspace_root = root or Path.cwd()
6868
devman_dir = find_devman_dir(workspace_root)
6969
if not devman_dir:
70-
raise ValueError("No workspace found.")
70+
raise typer.Exit("No workspace found.")
7171
return load_workspace_config(devman_dir)
7272

7373

@@ -106,7 +106,7 @@ def _record_state(config: WorkspaceConfig, session_name: str | None) -> None:
106106

107107

108108
def _load_nvim_session(config: WorkspaceConfig) -> None:
109-
if not config.nvim_sessions_dir or not config.nvim_listen:
109+
if not config.nvim_sessions_dir or config.nvim_listen is None:
110110
return
111111

112112
session_name = config.nvim_default_session or "home.vim"

0 commit comments

Comments
 (0)