Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
name: Tests

on:
pull_request:
branches: [ main, master ]
push:
branches: [ main, master ]
workflow_dispatch:

permissions:
contents: read
pull-requests: write
checks: write

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run linting
run: |
ruff check src tests
continue-on-error: true

- name: Run type checking
run: |
mypy src
continue-on-error: true

- name: Run tests with coverage
run: |
pytest tests/ \
--cov=src/devman \
--cov-report=term \
--cov-report=html \
--cov-report=json \
--cov-report=xml \
-v

- name: Generate coverage badge
if: matrix.python-version == '3.13'
run: |
COVERAGE=$(python -c "import json; print(json.load(open('coverage.json'))['totals']['percent_covered_display'])")
echo "COVERAGE=$COVERAGE" >> $GITHUB_ENV
echo "Coverage: $COVERAGE%"

- name: Upload coverage reports
if: matrix.python-version == '3.13'
uses: actions/upload-artifact@v4
with:
name: coverage-reports
path: |
htmlcov/
coverage.json
coverage.xml
.coverage

- name: Comment PR with test results
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');

// Read coverage data
const coverage = JSON.parse(fs.readFileSync('coverage.json', 'utf8'));
const totalCoverage = coverage.totals.percent_covered_display;

// Read test output
let testOutput = '';
try {
testOutput = fs.readFileSync('test-output.txt', 'utf8');
} catch (e) {
testOutput = 'Test output not available';
}

// Build coverage table
let coverageTable = '| File | Coverage |\n|------|----------|\n';
for (const [file, data] of Object.entries(coverage.files)) {
const fileName = file.replace('src/devman/', '');
const fileCoverage = data.summary.percent_covered_display;
coverageTable += `| \`${fileName}\` | ${fileCoverage}% |\n`;
}

// Create comment body
const body = `## 🧪 Test Results (Python ${{ matrix.python-version }})

### 📊 Coverage Summary
**Total Coverage: ${totalCoverage}%**

<details>
<summary>Coverage by File</summary>

${coverageTable}

</details>

### 📈 Coverage Details
- **Lines:** ${coverage.totals.covered_lines}/${coverage.totals.num_statements} covered
- **Branches:** ${coverage.totals.covered_branches}/${coverage.totals.num_branches} covered
- **Missing Lines:** ${coverage.totals.missing_lines}

---
*Coverage report generated by pytest-cov*`;

// Post or update comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});

const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('Test Results (Python ${{ matrix.python-version }})')
);

if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});
}

- name: Check coverage threshold
if: matrix.python-version == '3.13'
run: |
COVERAGE=$(python -c "import json; print(float(json.load(open('coverage.json'))['totals']['percent_covered_display']))")
THRESHOLD=70
echo "Coverage: $COVERAGE%"
echo "Threshold: $THRESHOLD%"
if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
echo "⚠️ Warning: Coverage $COVERAGE% is below threshold $THRESHOLD%"
exit 0 # Don't fail the build, just warn
else
echo "✅ Coverage $COVERAGE% meets threshold $THRESHOLD%"
fi

nix-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install Nix
uses: cachix/install-nix-action@v25
with:
nix_path: nixpkgs=channel:nixos-unstable

- name: Build with Nix
run: nix build .#devman

- name: Test devman CLI
run: |
./result/bin/devman --help || echo "Binary not in expected location"
1 change: 0 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
typer
rich
pathlib-abc
jinja2
pydantic
pyyaml
tomli-w
Expand Down
2 changes: 1 addition & 1 deletion src/devman/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# src/devman/__init__.py
"""DevEnv project templating system."""

__version__ = "0.1.0"
__version__ = "0.2.0"

3 changes: 2 additions & 1 deletion src/devman/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import typer

from devman.commands import doctor, down, init, index, switch, up
from devman.commands import bootstrap, doctor, down, init, index, switch, up

app = typer.Typer(
name="devman",
Expand All @@ -24,6 +24,7 @@ def main(ctx: typer.Context) -> None:
app.command(name="up")(up.run)
app.command(name="down")(down.run)
app.command(name="switch")(switch.run)
app.command(name="bootstrap")(bootstrap.run)
app.command(name="doctor")(doctor.run)
app.command(name="init")(init.run)

Expand Down
6 changes: 2 additions & 4 deletions src/devman/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from .bootstrap import bootstrap
from .doctor import doctor
from .down import down
from .index import index_list, index_rebuild, index_status
from . import index
from .init import init
from .switch import switch
from .up import up
Expand All @@ -12,9 +12,7 @@
"bootstrap",
"doctor",
"down",
"index_list",
"index_rebuild",
"index_status",
"index",
"init",
"switch",
"up",
Expand Down
8 changes: 3 additions & 5 deletions src/devman/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

from __future__ import annotations

from typing import Dict

from devman.claude_code import ClaudeCodeWorkspace
from devman.integrations import NvimIntegration, TmuxIntegration, TmuxpIntegration

Expand All @@ -14,7 +12,7 @@
CLAUDE_WORKSPACE = ClaudeCodeWorkspace()


def run() -> Dict[str, bool]:
def run() -> dict[str, bool]:
"""Return availability of external tools."""
return {
"tmux": TMUX.is_available(),
Expand All @@ -24,14 +22,14 @@ def run() -> Dict[str, bool]:
}


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


def doctor() -> Dict[str, bool]:
def doctor() -> dict[str, bool]:
"""Backward-compatible alias for run."""
return run()
51 changes: 51 additions & 0 deletions src/devman/commands/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@

from __future__ import annotations

import json
from typing import Iterable

import typer

from devman.discovery import IndexManager, resolve_roots
from devman.models import WorkspaceEntry, WorkspaceIndex

app = typer.Typer(help="Manage workspace index")


def _manager(manager: IndexManager | None = None) -> IndexManager:
return manager or IndexManager()
Expand Down Expand Up @@ -57,3 +62,49 @@ def find_entry(
) -> WorkspaceEntry | None:
"""Find a workspace entry matching the provided query."""
return _manager(manager).find_entry(entries, query)


@app.command(name="status")
def index_status() -> None:
"""Show index cache status."""
payload = load_index()
if not payload:
typer.echo("Index cache missing.")
raise typer.Exit(code=1)

# Convert to dict for JSON serialization
index_dict = {
"entries": [
{
"name": entry.name,
"workspace_root": str(entry.workspace_root),
"devman_dir": str(entry.devman_dir),
"group": entry.group,
"tags": entry.tags,
}
for entry in payload.entries
]
}
typer.echo(json.dumps(index_dict, indent=2))


@app.command(name="rebuild")
def index_rebuild(
roots: list[str] = typer.Argument(None, help="Roots to index")
) -> None:
"""Force rebuild the index."""
resolved_roots = roots or []
index = rebuild_index(resolved_roots)
for line in list_entries(index.entries):
typer.echo(line)


@app.command(name="list")
def index_list(
roots: list[str] = typer.Argument(None, help="Roots to index")
) -> None:
"""List indexed workspaces."""
resolved_roots = roots or []
index = refresh_index(resolved_roots)
for line in list_entries(index.entries):
typer.echo(line)
21 changes: 21 additions & 0 deletions src/devman/commands/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from typing import Iterable

import typer

from devman.discovery import IndexManager, resolve_roots
from devman.models import WorkspaceEntry

Expand All @@ -18,3 +20,22 @@ def resolve_workspace(
index_manager = manager or IndexManager()
index = index_manager.refresh(resolve_roots(roots))
return index_manager.find_entry(index.entries, query)


def run(query: str, roots: list[str] | None = None) -> WorkspaceEntry:
"""Switch to a different workspace by name or query."""
resolved_roots = roots or []
workspace = resolve_workspace(query, resolved_roots)

if not workspace:
raise typer.Exit(f"No workspace found matching '{query}'")

typer.echo(f"Switching to workspace: {workspace.name}")
typer.echo(f"Location: {workspace.workspace_root}")

return workspace


def switch(query: str, roots: list[str] | None = None) -> WorkspaceEntry:
"""Backward-compatible alias for run."""
return run(query=query, roots=roots)
6 changes: 3 additions & 3 deletions src/devman/commands/up.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def resolve_active_workspace(
index_manager = manager or IndexManager()
index = index_manager.refresh(resolve_roots(roots))
if not index.entries:
raise ValueError("No workspaces found.")
raise typer.Exit("No workspaces found.")

if selector:
return selector(index.entries)
Expand All @@ -67,7 +67,7 @@ def _resolve_workspace_config(root: Path | None = None) -> WorkspaceConfig:
workspace_root = root or Path.cwd()
devman_dir = find_devman_dir(workspace_root)
if not devman_dir:
raise ValueError("No workspace found.")
raise typer.Exit("No workspace found.")
return load_workspace_config(devman_dir)


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


def _load_nvim_session(config: WorkspaceConfig) -> None:
if not config.nvim_sessions_dir or not config.nvim_listen:
if not config.nvim_sessions_dir or config.nvim_listen is None:
return

session_name = config.nvim_default_session or "home.vim"
Expand Down
Loading
Loading