Skip to content
Open
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
152 changes: 152 additions & 0 deletions docs/approval-gates-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Approval Gates

Enforce approval requirements between workflow phases to prevent "spec-less coding".

## Quick Start

### 1. Create Configuration

```bash
mkdir -p .speckit
cat > .speckit/approval-gates.yaml << 'EOF'
approval_gates:
specify:
enabled: true
requires: [product_lead, architect]
min_approvals: 1
description: "Functional spec approval"

plan:
enabled: true
requires: [architect, tech_lead]
min_approvals: 2
description: "Technical spec approval"

tasks:
enabled: true
requires: [tech_lead]
min_approvals: 1
description: "Task breakdown approval"

implement:
enabled: false
EOF
```

### 2. Check Status

```bash
specify approval
```

Expected output:
```
βœ… Approval gates enabled

specify
β€’ Enabled: βœ…
β€’ Min approvals: 1
plan
β€’ Enabled: βœ…
β€’ Min approvals: 2
tasks
β€’ Enabled: βœ…
β€’ Min approvals: 1
implement: disabled
```

## Configuration

Edit `.speckit/approval-gates.yaml` to:
- **enabled**: true/false - Enable/disable this gate
- **requires**: [role1, role2] - Who can approve
- **min_approvals**: number - How many approvals needed
- **description**: string - What this gate is for

### Available Phases

- `constitution` β€” Project fundamentals
- `specify` β€” Functional specifications
- `plan` β€” Technical specifications
- `tasks` β€” Task breakdown
- `implement` β€” Implementation (optional)

## Why Use Approval Gates?

βœ… **Prevents spec-less coding** β€” Requires approval before moving phases
βœ… **Ensures alignment** β€” Teams must agree before proceeding
βœ… **Creates clarity** β€” Clear approval requirements for each phase

## Commands

```bash
# Check gate status
specify approval

# Explicitly request status
specify approval --action status
specify approval -a status

# Show help
specify approval --help
```

## Examples

### Basic Setup (All Phases)
```yaml
approval_gates:
specify:
enabled: true
min_approvals: 1
plan:
enabled: true
min_approvals: 2
tasks:
enabled: true
min_approvals: 1
```

### Minimal Setup (Only Specify)
```yaml
approval_gates:
specify:
enabled: true
min_approvals: 1
```

### Strict Setup (High Approval Requirements)
```yaml
approval_gates:
constitution:
enabled: true
requires: [owner]
min_approvals: 1
specify:
enabled: true
requires: [product_lead, architect]
min_approvals: 2
plan:
enabled: true
requires: [architect, tech_lead, security_lead]
min_approvals: 3
```

## Troubleshooting

### Command not found
```bash
# Make sure you're in the spec-kit project
cd ~/spec-kit
uv run specify approval
```

### No approval gates configured
Create `.speckit/approval-gates.yaml` in your project root.

### YAML errors
Check YAML indentation β€” spaces matter! Use a YAML validator if unsure.

---

**Template:** See `docs/examples/approval-gates.yaml` for a full example.
35 changes: 35 additions & 0 deletions docs/examples/approval-gates.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# .speckit/approval-gates.yaml
# Copy this file to your project root: .speckit/approval-gates.yaml
# Then customize for your team's needs

approval_gates:
constitution:
enabled: false
requires: [owner]
min_approvals: 1

specify:
enabled: true
requires: [product_lead, architect]
min_approvals: 1
description: "Functional spec approval"

plan:
enabled: true
requires: [architect, tech_lead]
min_approvals: 2
description: "Technical spec approval"

tasks:
enabled: true
requires: [tech_lead]
min_approvals: 1
description: "Task breakdown approval"

implement:
enabled: false
description: "Implementation gate (optional)"

github_actions:
# Future: GitHub Actions integration
enabled: false
12 changes: 12 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4571,6 +4571,18 @@ def extension_set_priority(
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")


@app.command()
def approval(
action: str = typer.Option("status", "--action", "-a", help="Approval action"),
):
"""Check approval gates status (if configured).

If no .speckit/approval-gates.yaml exists, shows setup instructions.
"""
from .approval_command import approval_command
approval_command(action=action)


def main():
app()

Expand Down
43 changes: 43 additions & 0 deletions src/specify_cli/approval_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Approval gate command

Provides 'specify approval' command using Typer framework.
"""

import typer
from rich.console import Console
from specify_cli.approval_gates import ApprovalGatesConfig

console = Console()


def approval_command(
action: str = typer.Option("status", "--action", "-a", help="Approval action"),
):
"""Check approval gates status (if configured).

If no .speckit/approval-gates.yaml exists, returns helpful message.

Example:
specify approval
"""

config = ApprovalGatesConfig.load()

if config is None:
console.print("ℹ️ No approval gates configured")
console.print(" Create .speckit/approval-gates.yaml to enable")
console.print("")
console.print(" See: docs/approval-gates-guide.md for setup")
return

if action == "status":
console.print("βœ… Approval gates enabled")
console.print("")
for phase, gate in config.gates.items():
if gate.get("enabled"):
min_approvals = gate.get("min_approvals", 1)
console.print(f" {phase}")
console.print(f" β€’ Enabled: βœ…")
console.print(f" β€’ Min approvals: {min_approvals}")
else:
console.print(f" {phase}: disabled")
45 changes: 45 additions & 0 deletions src/specify_cli/approval_gates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Approval Gates Configuration Handler

Loads and validates .speckit/approval-gates.yaml from user projects.
"""

from pathlib import Path
from typing import Optional, Dict
import yaml


class ApprovalGatesConfig:
"""Load and validate approval gates from .speckit/approval-gates.yaml"""

CONFIG_FILE = Path(".speckit/approval-gates.yaml")

@classmethod
def load(cls) -> Optional['ApprovalGatesConfig']:
"""Load approval gates config if it exists in user's project

Returns None if no approval gates configured.
"""
if not cls.CONFIG_FILE.exists():
return None # No approval gates configured - this is OK

with open(cls.CONFIG_FILE) as f:
data = yaml.safe_load(f)

if data is None:
return None

return cls(data)

def __init__(self, config: Dict):
self.gates = config.get("approval_gates", {})

def is_phase_gated(self, phase: str) -> bool:
"""Check if a phase requires approval"""
gate = self.gates.get(phase, {})
return gate.get("enabled", False)

def get_phase_gate(self, phase: str) -> Optional[Dict]:
"""Get gate configuration for a specific phase"""
if self.is_phase_gated(phase):
return self.gates.get(phase)
return None
59 changes: 59 additions & 0 deletions tests/test_approval_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Tests for approval CLI command"""

import pytest
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
from specify_cli.approval_command import approval_command
import typer


def test_approval_status_no_config():
"""Test approval status when no config exists"""
with patch("specify_cli.approval_command.ApprovalGatesConfig.load", return_value=None):
# Create a simple typer app to test the command
app = typer.Typer()
app.command()(approval_command)

runner = CliRunner()
result = runner.invoke(app, ["--action", "status"])
assert result.exit_code == 0
assert "No approval gates configured" in result.stdout


def test_approval_status_with_config():
"""Test approval status with gates configured"""
# Mock configuration
mock_config = MagicMock()
mock_config.gates = {
"specify": {"enabled": True, "min_approvals": 1},
"plan": {"enabled": True, "min_approvals": 2},
}

with patch("specify_cli.approval_command.ApprovalGatesConfig.load", return_value=mock_config):
app = typer.Typer()
app.command()(approval_command)

runner = CliRunner()
result = runner.invoke(app, ["--action", "status"])
assert result.exit_code == 0
assert "Approval gates enabled" in result.stdout
assert "specify" in result.stdout
assert "plan" in result.stdout


def test_approval_default_action():
"""Test approval command with default action (status)"""
mock_config = MagicMock()
mock_config.gates = {
"specify": {"enabled": True, "min_approvals": 1},
}

with patch("specify_cli.approval_command.ApprovalGatesConfig.load", return_value=mock_config):
app = typer.Typer()
app.command()(approval_command)

runner = CliRunner()
# Invoke without --action (should default to status)
result = runner.invoke(app, [])
assert result.exit_code == 0
assert "Approval gates enabled" in result.stdout
Loading