Skip to content

Commit fe613d1

Browse files
committed
feat: add approval gates feature
- Configuration loader for .speckit/approval-gates.yaml - CLI command: specify approval - Unit tests and YAML template - Enables approval enforcement between workflow phases
1 parent f8da535 commit fe613d1

File tree

7 files changed

+415
-0
lines changed

7 files changed

+415
-0
lines changed

docs/approval-gates-guide.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Approval Gates
2+
3+
Enforce approval requirements between workflow phases to prevent "spec-less coding".
4+
5+
## Quick Start
6+
7+
### 1. Create Configuration
8+
9+
```bash
10+
mkdir -p .speckit
11+
cat > .speckit/approval-gates.yaml << 'EOF'
12+
approval_gates:
13+
specify:
14+
enabled: true
15+
requires: [product_lead, architect]
16+
min_approvals: 1
17+
description: "Functional spec approval"
18+
19+
plan:
20+
enabled: true
21+
requires: [architect, tech_lead]
22+
min_approvals: 2
23+
description: "Technical spec approval"
24+
25+
tasks:
26+
enabled: true
27+
requires: [tech_lead]
28+
min_approvals: 1
29+
description: "Task breakdown approval"
30+
31+
implement:
32+
enabled: false
33+
EOF
34+
```
35+
36+
### 2. Check Status
37+
38+
```bash
39+
specify approval
40+
```
41+
42+
Expected output:
43+
```
44+
✅ Approval gates enabled
45+
46+
specify
47+
• Enabled: ✅
48+
• Min approvals: 1
49+
plan
50+
• Enabled: ✅
51+
• Min approvals: 2
52+
tasks
53+
• Enabled: ✅
54+
• Min approvals: 1
55+
implement: disabled
56+
```
57+
58+
## Configuration
59+
60+
Edit `.speckit/approval-gates.yaml` to:
61+
- **enabled**: true/false - Enable/disable this gate
62+
- **requires**: [role1, role2] - Who can approve
63+
- **min_approvals**: number - How many approvals needed
64+
- **description**: string - What this gate is for
65+
66+
### Available Phases
67+
68+
- `constitution` — Project fundamentals
69+
- `specify` — Functional specifications
70+
- `plan` — Technical specifications
71+
- `tasks` — Task breakdown
72+
- `implement` — Implementation (optional)
73+
74+
## Why Use Approval Gates?
75+
76+
**Prevents spec-less coding** — Requires approval before moving phases
77+
**Ensures alignment** — Teams must agree before proceeding
78+
**Creates clarity** — Clear approval requirements for each phase
79+
80+
## Commands
81+
82+
```bash
83+
# Check gate status
84+
specify approval
85+
86+
# Explicitly request status
87+
specify approval --action status
88+
specify approval -a status
89+
90+
# Show help
91+
specify approval --help
92+
```
93+
94+
## Examples
95+
96+
### Basic Setup (All Phases)
97+
```yaml
98+
approval_gates:
99+
specify:
100+
enabled: true
101+
min_approvals: 1
102+
plan:
103+
enabled: true
104+
min_approvals: 2
105+
tasks:
106+
enabled: true
107+
min_approvals: 1
108+
```
109+
110+
### Minimal Setup (Only Specify)
111+
```yaml
112+
approval_gates:
113+
specify:
114+
enabled: true
115+
min_approvals: 1
116+
```
117+
118+
### Strict Setup (High Approval Requirements)
119+
```yaml
120+
approval_gates:
121+
constitution:
122+
enabled: true
123+
requires: [owner]
124+
min_approvals: 1
125+
specify:
126+
enabled: true
127+
requires: [product_lead, architect]
128+
min_approvals: 2
129+
plan:
130+
enabled: true
131+
requires: [architect, tech_lead, security_lead]
132+
min_approvals: 3
133+
```
134+
135+
## Troubleshooting
136+
137+
### Command not found
138+
```bash
139+
# Make sure you're in the spec-kit project
140+
cd ~/Documents/Projects/spec-kit
141+
uv run specify approval
142+
```
143+
144+
### No approval gates configured
145+
Create `.speckit/approval-gates.yaml` in your project root.
146+
147+
### YAML errors
148+
Check YAML indentation — spaces matter! Use a YAML validator if unsure.
149+
150+
---
151+
152+
**Template:** See `docs/examples/approval-gates.yaml` for a full example.

docs/examples/approval-gates.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# .speckit/approval-gates.yaml
2+
# Copy this file to your project root: .speckit/approval-gates.yaml
3+
# Then customize for your team's needs
4+
5+
approval_gates:
6+
constitution:
7+
enabled: false
8+
requires: [owner]
9+
min_approvals: 1
10+
11+
specify:
12+
enabled: true
13+
requires: [product_lead, architect]
14+
min_approvals: 1
15+
description: "Functional spec approval"
16+
17+
plan:
18+
enabled: true
19+
requires: [architect, tech_lead]
20+
min_approvals: 2
21+
description: "Technical spec approval"
22+
23+
tasks:
24+
enabled: true
25+
requires: [tech_lead]
26+
min_approvals: 1
27+
description: "Task breakdown approval"
28+
29+
implement:
30+
enabled: false
31+
description: "Implementation gate (optional)"
32+
33+
github_actions:
34+
# Future: GitHub Actions integration
35+
enabled: false

src/specify_cli/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4571,6 +4571,18 @@ def extension_set_priority(
45714571
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
45724572

45734573

4574+
@app.command()
4575+
def approval(
4576+
action: str = typer.Option("status", "--action", "-a", help="Approval action"),
4577+
):
4578+
"""Check approval gates status (if configured).
4579+
4580+
If no .speckit/approval-gates.yaml exists, shows setup instructions.
4581+
"""
4582+
from .approval_command import approval_command
4583+
approval_command(action=action)
4584+
4585+
45744586
def main():
45754587
app()
45764588

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Approval gate command
2+
3+
Provides 'specify approval' command using Typer framework.
4+
"""
5+
6+
import typer
7+
from rich.console import Console
8+
from specify_cli.approval_gates import ApprovalGatesConfig
9+
10+
console = Console()
11+
12+
13+
def approval_command(
14+
action: str = typer.Option("status", "--action", "-a", help="Approval action"),
15+
):
16+
"""Check approval gates status (if configured).
17+
18+
If no .speckit/approval-gates.yaml exists, returns helpful message.
19+
20+
Example:
21+
specify approval
22+
"""
23+
24+
config = ApprovalGatesConfig.load()
25+
26+
if config is None:
27+
console.print("ℹ️ No approval gates configured")
28+
console.print(" Create .speckit/approval-gates.yaml to enable")
29+
console.print("")
30+
console.print(" See: docs/approval-gates-guide.md for setup")
31+
return
32+
33+
if action == "status":
34+
console.print("✅ Approval gates enabled")
35+
console.print("")
36+
for phase, gate in config.gates.items():
37+
if gate.get("enabled"):
38+
min_approvals = gate.get("min_approvals", 1)
39+
console.print(f" {phase}")
40+
console.print(f" • Enabled: ✅")
41+
console.print(f" • Min approvals: {min_approvals}")
42+
else:
43+
console.print(f" {phase}: disabled")

src/specify_cli/approval_gates.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Approval Gates Configuration Handler
2+
3+
Loads and validates .speckit/approval-gates.yaml from user projects.
4+
"""
5+
6+
from pathlib import Path
7+
from typing import Optional, Dict
8+
import yaml
9+
10+
11+
class ApprovalGatesConfig:
12+
"""Load and validate approval gates from .speckit/approval-gates.yaml"""
13+
14+
CONFIG_FILE = Path(".speckit/approval-gates.yaml")
15+
16+
@classmethod
17+
def load(cls) -> Optional['ApprovalGatesConfig']:
18+
"""Load approval gates config if it exists in user's project
19+
20+
Returns None if no approval gates configured.
21+
"""
22+
if not cls.CONFIG_FILE.exists():
23+
return None # No approval gates configured - this is OK
24+
25+
with open(cls.CONFIG_FILE) as f:
26+
data = yaml.safe_load(f)
27+
28+
if data is None:
29+
return None
30+
31+
return cls(data)
32+
33+
def __init__(self, config: Dict):
34+
self.gates = config.get("approval_gates", {})
35+
36+
def is_phase_gated(self, phase: str) -> bool:
37+
"""Check if a phase requires approval"""
38+
gate = self.gates.get(phase, {})
39+
return gate.get("enabled", False)
40+
41+
def get_phase_gate(self, phase: str) -> Optional[Dict]:
42+
"""Get gate configuration for a specific phase"""
43+
if self.is_phase_gated(phase):
44+
return self.gates.get(phase)
45+
return None

tests/test_approval_command.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Tests for approval CLI command"""
2+
3+
import pytest
4+
from typer.testing import CliRunner
5+
from unittest.mock import patch, MagicMock
6+
from specify_cli.approval_command import approval_command
7+
import typer
8+
9+
10+
def test_approval_status_no_config():
11+
"""Test approval status when no config exists"""
12+
with patch("specify_cli.approval_command.ApprovalGatesConfig.load", return_value=None):
13+
# Create a simple typer app to test the command
14+
app = typer.Typer()
15+
app.command()(approval_command)
16+
17+
runner = CliRunner()
18+
result = runner.invoke(app, ["--action", "status"])
19+
assert result.exit_code == 0
20+
assert "No approval gates configured" in result.stdout
21+
22+
23+
def test_approval_status_with_config():
24+
"""Test approval status with gates configured"""
25+
# Mock configuration
26+
mock_config = MagicMock()
27+
mock_config.gates = {
28+
"specify": {"enabled": True, "min_approvals": 1},
29+
"plan": {"enabled": True, "min_approvals": 2},
30+
}
31+
32+
with patch("specify_cli.approval_command.ApprovalGatesConfig.load", return_value=mock_config):
33+
app = typer.Typer()
34+
app.command()(approval_command)
35+
36+
runner = CliRunner()
37+
result = runner.invoke(app, ["--action", "status"])
38+
assert result.exit_code == 0
39+
assert "Approval gates enabled" in result.stdout
40+
assert "specify" in result.stdout
41+
assert "plan" in result.stdout
42+
43+
44+
def test_approval_default_action():
45+
"""Test approval command with default action (status)"""
46+
mock_config = MagicMock()
47+
mock_config.gates = {
48+
"specify": {"enabled": True, "min_approvals": 1},
49+
}
50+
51+
with patch("specify_cli.approval_command.ApprovalGatesConfig.load", return_value=mock_config):
52+
app = typer.Typer()
53+
app.command()(approval_command)
54+
55+
runner = CliRunner()
56+
# Invoke without --action (should default to status)
57+
result = runner.invoke(app, [])
58+
assert result.exit_code == 0
59+
assert "Approval gates enabled" in result.stdout

0 commit comments

Comments
 (0)