mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 02:43:08 +00:00
Add pytest and Python linting (ruff) to CI (#1637)
* feat: add GitHub Actions workflow for testing and linting Python code * fix: resolve ruff lint errors in specify_cli - Remove extraneous f-string prefixes (F541) - Split multi-statement lines (E701, E702) - Remove unused variable assignments (F841) - Remove ruff format check from CI workflow (format-only PR to follow) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: strip ANSI codes in ai-skills help text test The Rich/Typer CLI injects ANSI escape codes into option names in --help output, causing plain string matching to fail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
50
.github/workflows/test.yml
vendored
Normal file
50
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: Test & Lint Python
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ruff:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
|
||||||
|
- name: Run ruff check
|
||||||
|
run: uvx ruff check src/
|
||||||
|
|
||||||
|
pytest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.11", "3.12", "3.13"]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
|
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --extra test
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: uv run pytest
|
||||||
@@ -670,7 +670,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
|
|||||||
except ValueError as je:
|
except ValueError as je:
|
||||||
raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}")
|
raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[red]Error fetching release information[/red]")
|
console.print("[red]Error fetching release information[/red]")
|
||||||
console.print(Panel(str(e), title="Fetch Error", border_style="red"))
|
console.print(Panel(str(e), title="Fetch Error", border_style="red"))
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
@@ -700,7 +700,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
|
|||||||
|
|
||||||
zip_path = download_dir / filename
|
zip_path = download_dir / filename
|
||||||
if verbose:
|
if verbose:
|
||||||
console.print(f"[cyan]Downloading template...[/cyan]")
|
console.print("[cyan]Downloading template...[/cyan]")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with client.stream(
|
with client.stream(
|
||||||
@@ -739,7 +739,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
|
|||||||
for chunk in response.iter_bytes(chunk_size=8192):
|
for chunk in response.iter_bytes(chunk_size=8192):
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[red]Error downloading template[/red]")
|
console.print("[red]Error downloading template[/red]")
|
||||||
detail = str(e)
|
detail = str(e)
|
||||||
if zip_path.exists():
|
if zip_path.exists():
|
||||||
zip_path.unlink()
|
zip_path.unlink()
|
||||||
@@ -823,7 +823,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
|
|||||||
tracker.add("flatten", "Flatten nested directory")
|
tracker.add("flatten", "Flatten nested directory")
|
||||||
tracker.complete("flatten")
|
tracker.complete("flatten")
|
||||||
elif verbose:
|
elif verbose:
|
||||||
console.print(f"[cyan]Found nested directory structure[/cyan]")
|
console.print("[cyan]Found nested directory structure[/cyan]")
|
||||||
|
|
||||||
for item in source_dir.iterdir():
|
for item in source_dir.iterdir():
|
||||||
dest_path = project_path / item.name
|
dest_path = project_path / item.name
|
||||||
@@ -848,7 +848,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
|
|||||||
console.print(f"[yellow]Overwriting file:[/yellow] {item.name}")
|
console.print(f"[yellow]Overwriting file:[/yellow] {item.name}")
|
||||||
shutil.copy2(item, dest_path)
|
shutil.copy2(item, dest_path)
|
||||||
if verbose and not tracker:
|
if verbose and not tracker:
|
||||||
console.print(f"[cyan]Template files merged into current directory[/cyan]")
|
console.print("[cyan]Template files merged into current directory[/cyan]")
|
||||||
else:
|
else:
|
||||||
zip_ref.extractall(project_path)
|
zip_ref.extractall(project_path)
|
||||||
|
|
||||||
@@ -874,7 +874,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
|
|||||||
tracker.add("flatten", "Flatten nested directory")
|
tracker.add("flatten", "Flatten nested directory")
|
||||||
tracker.complete("flatten")
|
tracker.complete("flatten")
|
||||||
elif verbose:
|
elif verbose:
|
||||||
console.print(f"[cyan]Flattened nested directory structure[/cyan]")
|
console.print("[cyan]Flattened nested directory structure[/cyan]")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if tracker:
|
if tracker:
|
||||||
@@ -924,13 +924,17 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
|
|||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
st = script.stat(); mode = st.st_mode
|
st = script.stat()
|
||||||
|
mode = st.st_mode
|
||||||
if mode & 0o111:
|
if mode & 0o111:
|
||||||
continue
|
continue
|
||||||
new_mode = mode
|
new_mode = mode
|
||||||
if mode & 0o400: new_mode |= 0o100
|
if mode & 0o400:
|
||||||
if mode & 0o040: new_mode |= 0o010
|
new_mode |= 0o100
|
||||||
if mode & 0o004: new_mode |= 0o001
|
if mode & 0o040:
|
||||||
|
new_mode |= 0o010
|
||||||
|
if mode & 0o004:
|
||||||
|
new_mode |= 0o001
|
||||||
if not (new_mode & 0o100):
|
if not (new_mode & 0o100):
|
||||||
new_mode |= 0o100
|
new_mode |= 0o100
|
||||||
os.chmod(script, new_mode)
|
os.chmod(script, new_mode)
|
||||||
@@ -976,7 +980,7 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker |
|
|||||||
tracker.add("constitution", "Constitution setup")
|
tracker.add("constitution", "Constitution setup")
|
||||||
tracker.complete("constitution", "copied from template")
|
tracker.complete("constitution", "copied from template")
|
||||||
else:
|
else:
|
||||||
console.print(f"[cyan]Initialized constitution from template[/cyan]")
|
console.print("[cyan]Initialized constitution from template[/cyan]")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if tracker:
|
if tracker:
|
||||||
tracker.add("constitution", "Constitution setup")
|
tracker.add("constitution", "Constitution setup")
|
||||||
@@ -1510,9 +1514,9 @@ def init(
|
|||||||
enhancement_lines = [
|
enhancement_lines = [
|
||||||
"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]",
|
"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]",
|
||||||
"",
|
"",
|
||||||
f"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)",
|
"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)",
|
||||||
f"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])",
|
"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])",
|
||||||
f"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])"
|
"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])"
|
||||||
]
|
]
|
||||||
enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2))
|
enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2))
|
||||||
console.print()
|
console.print()
|
||||||
@@ -1545,10 +1549,10 @@ def check():
|
|||||||
|
|
||||||
# Check VS Code variants (not in agent config)
|
# Check VS Code variants (not in agent config)
|
||||||
tracker.add("code", "Visual Studio Code")
|
tracker.add("code", "Visual Studio Code")
|
||||||
code_ok = check_tool("code", tracker=tracker)
|
check_tool("code", tracker=tracker)
|
||||||
|
|
||||||
tracker.add("code-insiders", "Visual Studio Code Insiders")
|
tracker.add("code-insiders", "Visual Studio Code Insiders")
|
||||||
code_insiders_ok = check_tool("code-insiders", tracker=tracker)
|
check_tool("code-insiders", tracker=tracker)
|
||||||
|
|
||||||
console.print(tracker.render())
|
console.print(tracker.render())
|
||||||
|
|
||||||
@@ -1814,14 +1818,14 @@ def extension_add(
|
|||||||
if zip_path.exists():
|
if zip_path.exists():
|
||||||
zip_path.unlink()
|
zip_path.unlink()
|
||||||
|
|
||||||
console.print(f"\n[green]✓[/green] Extension installed successfully!")
|
console.print("\n[green]✓[/green] Extension installed successfully!")
|
||||||
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
|
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
|
||||||
console.print(f" {manifest.description}")
|
console.print(f" {manifest.description}")
|
||||||
console.print(f"\n[bold cyan]Provided commands:[/bold cyan]")
|
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
|
||||||
for cmd in manifest.commands:
|
for cmd in manifest.commands:
|
||||||
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
|
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
|
||||||
|
|
||||||
console.print(f"\n[yellow]⚠[/yellow] Configuration may be required")
|
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
|
||||||
console.print(f" Check: .specify/extensions/{manifest.id}/")
|
console.print(f" Check: .specify/extensions/{manifest.id}/")
|
||||||
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
@@ -1871,11 +1875,11 @@ def extension_remove(
|
|||||||
|
|
||||||
# Confirm removal
|
# Confirm removal
|
||||||
if not force:
|
if not force:
|
||||||
console.print(f"\n[yellow]⚠ This will remove:[/yellow]")
|
console.print("\n[yellow]⚠ This will remove:[/yellow]")
|
||||||
console.print(f" • {cmd_count} commands from AI agent")
|
console.print(f" • {cmd_count} commands from AI agent")
|
||||||
console.print(f" • Extension directory: .specify/extensions/{extension}/")
|
console.print(f" • Extension directory: .specify/extensions/{extension}/")
|
||||||
if not keep_config:
|
if not keep_config:
|
||||||
console.print(f" • Config files (will be backed up)")
|
console.print(" • Config files (will be backed up)")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
confirm = typer.confirm("Continue?")
|
confirm = typer.confirm("Continue?")
|
||||||
@@ -1894,7 +1898,7 @@ def extension_remove(
|
|||||||
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/")
|
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/")
|
||||||
console.print(f"\nTo reinstall: specify extension add {extension}")
|
console.print(f"\nTo reinstall: specify extension add {extension}")
|
||||||
else:
|
else:
|
||||||
console.print(f"[red]Error:[/red] Failed to remove extension")
|
console.print("[red]Error:[/red] Failed to remove extension")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -2169,8 +2173,8 @@ def extension_update(
|
|||||||
# TODO: Implement download and reinstall from URL
|
# TODO: Implement download and reinstall from URL
|
||||||
# For now, just show message
|
# For now, just show message
|
||||||
console.print(
|
console.print(
|
||||||
f"[yellow]Note:[/yellow] Automatic update not yet implemented. "
|
"[yellow]Note:[/yellow] Automatic update not yet implemented. "
|
||||||
f"Please update manually:"
|
"Please update manually:"
|
||||||
)
|
)
|
||||||
console.print(f" specify extension remove {ext_id} --keep-config")
|
console.print(f" specify extension remove {ext_id} --keep-config")
|
||||||
console.print(f" specify extension add {ext_id}")
|
console.print(f" specify extension add {ext_id}")
|
||||||
@@ -2270,7 +2274,7 @@ def extension_disable(
|
|||||||
hook_executor.save_project_config(config)
|
hook_executor.save_project_config(config)
|
||||||
|
|
||||||
console.print(f"[green]✓[/green] Extension '{extension}' disabled")
|
console.print(f"[green]✓[/green] Extension '{extension}' disabled")
|
||||||
console.print(f"\nCommands will no longer be available. Hooks will not execute.")
|
console.print("\nCommands will no longer be available. Hooks will not execute.")
|
||||||
console.print(f"To re-enable: specify extension enable {extension}")
|
console.print(f"To re-enable: specify extension enable {extension}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Tests cover:
|
|||||||
- CLI validation: --ai-skills requires --ai
|
- CLI validation: --ai-skills requires --ai
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
import pytest
|
import pytest
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
@@ -626,5 +627,6 @@ class TestCliValidation:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, ["init", "--help"])
|
result = runner.invoke(app, ["init", "--help"])
|
||||||
|
|
||||||
assert "--ai-skills" in result.output
|
plain = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
|
||||||
assert "agent skills" in result.output.lower()
|
assert "--ai-skills" in plain
|
||||||
|
assert "agent skills" in plain.lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user