mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Implement PR 1 of the workflow-overlays plan: a concrete, standalone WorkflowResolver for downstream workflow extensibility without touching the Preset subsystem. - Add overlay manifest schema (Overlay, OverlayEdit, validate_overlay_yaml) - Add pure-function merge engine (find_step, apply_edit, merge_steps, validate_edits) with recursive anchor search and higher-wins semantics - Add StepListComposer and tiered layer sources (project, installed, base) - Add WorkflowResolver facade with inline HIGHER_WINS priority sorting - Add CLI verbs: workflow overlay add/set-priority/enable/disable/remove/list and workflow resolve <id> - Wire WorkflowEngine.load_workflow through WorkflowResolver - Extend workflow add to copy optional overlays/ subdirectory from local workflow directories - Add comprehensive unit, integration, and security tests Refs: discussion #3473 (https://github.com/github/spec-kit/discussions/3473) Assisted-by: Kimi (model: opencode-go/kimi-k2.7-code, autonomous)
315 lines
11 KiB
Python
315 lines
11 KiB
Python
"""Tests for workflow overlay CLI commands."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
from typer.testing import CliRunner
|
|
|
|
from specify_cli import app
|
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
@pytest.fixture
|
|
def project_dir(tmp_path):
|
|
"""Create a mock spec-kit project with ``.specify/workflows/`` directory."""
|
|
workflows_dir = tmp_path / ".specify" / "workflows"
|
|
workflows_dir.mkdir(parents=True, exist_ok=True)
|
|
return tmp_path
|
|
|
|
|
|
def _write_workflow(project_root: Path, workflow_id: str, data: dict) -> Path:
|
|
wf_dir = project_root / ".specify" / "workflows" / workflow_id
|
|
wf_dir.mkdir(parents=True, exist_ok=True)
|
|
wf_path = wf_dir / "workflow.yml"
|
|
wf_path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
|
return wf_path
|
|
|
|
|
|
def _write_overlay(project_root: Path, workflow_id: str, overlay_id: str, data: dict) -> Path:
|
|
ov_dir = project_root / ".specify" / "workflows" / "overlays" / workflow_id
|
|
ov_dir.mkdir(parents=True, exist_ok=True)
|
|
ov_path = ov_dir / f"{overlay_id}.yml"
|
|
ov_path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
|
return ov_path
|
|
|
|
|
|
class TestOverlayCli:
|
|
"""CLI-level tests for ``specify workflow overlay *``."""
|
|
|
|
def test_overlay_add(self, project_dir, monkeypatch):
|
|
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
|
_write_workflow(
|
|
project_dir,
|
|
"wf",
|
|
{
|
|
"schema_version": "1.0",
|
|
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
|
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
|
},
|
|
)
|
|
overlay_file = project_dir / "overlay.yml"
|
|
overlay_file.write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"id": "ov1",
|
|
"extends": "wf",
|
|
"priority": 10,
|
|
"edits": [
|
|
{
|
|
"operation": "insert_after",
|
|
"anchor": "a",
|
|
"step": {"id": "new", "type": "command", "command": "echo"},
|
|
}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = runner.invoke(
|
|
app, ["workflow", "overlay", "add", str(overlay_file), "--priority", "5"]
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Overlay 'ov1' added" in result.output
|
|
|
|
installed = project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
|
|
assert installed.is_file()
|
|
data = yaml.safe_load(installed.read_text(encoding="utf-8"))
|
|
assert data["priority"] == 5
|
|
|
|
def test_overlay_set_priority(self, project_dir, monkeypatch):
|
|
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
|
_write_workflow(
|
|
project_dir,
|
|
"wf",
|
|
{
|
|
"schema_version": "1.0",
|
|
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
|
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
|
},
|
|
)
|
|
_write_overlay(
|
|
project_dir,
|
|
"wf",
|
|
"ov1",
|
|
{
|
|
"id": "ov1",
|
|
"extends": "wf",
|
|
"priority": 10,
|
|
"edits": [
|
|
{
|
|
"operation": "insert_after",
|
|
"anchor": "a",
|
|
"step": {"id": "new", "type": "command", "command": "echo"},
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
result = runner.invoke(
|
|
app, ["workflow", "overlay", "set-priority", "wf", "ov1", "20"]
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
data = yaml.safe_load(
|
|
(
|
|
project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
|
|
).read_text(encoding="utf-8")
|
|
)
|
|
assert data["priority"] == 20
|
|
|
|
def test_overlay_disable_and_enable(self, project_dir, monkeypatch):
|
|
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
|
_write_workflow(
|
|
project_dir,
|
|
"wf",
|
|
{
|
|
"schema_version": "1.0",
|
|
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
|
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
|
},
|
|
)
|
|
_write_overlay(
|
|
project_dir,
|
|
"wf",
|
|
"ov1",
|
|
{
|
|
"id": "ov1",
|
|
"extends": "wf",
|
|
"priority": 10,
|
|
"edits": [
|
|
{
|
|
"operation": "insert_after",
|
|
"anchor": "a",
|
|
"step": {"id": "new", "type": "command", "command": "echo"},
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
result = runner.invoke(app, ["workflow", "overlay", "disable", "wf", "ov1"])
|
|
assert result.exit_code == 0, result.output
|
|
data = yaml.safe_load(
|
|
(
|
|
project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
|
|
).read_text(encoding="utf-8")
|
|
)
|
|
assert data["enabled"] is False
|
|
|
|
result = runner.invoke(app, ["workflow", "overlay", "enable", "wf", "ov1"])
|
|
assert result.exit_code == 0, result.output
|
|
data = yaml.safe_load(
|
|
(
|
|
project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
|
|
).read_text(encoding="utf-8")
|
|
)
|
|
assert data["enabled"] is True
|
|
|
|
def test_overlay_remove(self, project_dir, monkeypatch):
|
|
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
|
_write_workflow(
|
|
project_dir,
|
|
"wf",
|
|
{
|
|
"schema_version": "1.0",
|
|
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
|
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
|
},
|
|
)
|
|
_write_overlay(
|
|
project_dir,
|
|
"wf",
|
|
"ov1",
|
|
{
|
|
"id": "ov1",
|
|
"extends": "wf",
|
|
"priority": 10,
|
|
"edits": [
|
|
{
|
|
"operation": "insert_after",
|
|
"anchor": "a",
|
|
"step": {"id": "new", "type": "command", "command": "echo"},
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
result = runner.invoke(app, ["workflow", "overlay", "remove", "wf", "ov1"])
|
|
assert result.exit_code == 0, result.output
|
|
assert not (
|
|
project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
|
|
).exists()
|
|
|
|
def test_overlay_list(self, project_dir, monkeypatch):
|
|
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
|
_write_workflow(
|
|
project_dir,
|
|
"wf",
|
|
{
|
|
"schema_version": "1.0",
|
|
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
|
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
|
},
|
|
)
|
|
_write_overlay(
|
|
project_dir,
|
|
"wf",
|
|
"ov1",
|
|
{
|
|
"id": "ov1",
|
|
"extends": "wf",
|
|
"priority": 10,
|
|
"edits": [
|
|
{
|
|
"operation": "insert_after",
|
|
"anchor": "a",
|
|
"step": {"id": "new", "type": "command", "command": "echo"},
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
result = runner.invoke(app, ["workflow", "overlay", "list", "wf"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "ov1" in result.output
|
|
|
|
def test_workflow_resolve(self, project_dir, monkeypatch):
|
|
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
|
_write_workflow(
|
|
project_dir,
|
|
"wf",
|
|
{
|
|
"schema_version": "1.0",
|
|
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
|
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
|
},
|
|
)
|
|
_write_overlay(
|
|
project_dir,
|
|
"wf",
|
|
"ov1",
|
|
{
|
|
"id": "ov1",
|
|
"extends": "wf",
|
|
"priority": 10,
|
|
"edits": [
|
|
{
|
|
"operation": "insert_after",
|
|
"anchor": "a",
|
|
"step": {"id": "new", "type": "command", "command": "echo"},
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
result = runner.invoke(app, ["workflow", "resolve", "wf"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "base" in result.output
|
|
assert "project:ov1" in result.output
|
|
assert "new" in result.output
|
|
|
|
def test_workflow_add_copies_overlays(self, project_dir, monkeypatch, tmp_path):
|
|
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
|
source_dir = tmp_path / "source-wf"
|
|
source_dir.mkdir()
|
|
(source_dir / "workflow.yml").write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"schema_version": "1.0",
|
|
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
|
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
overlays_dir = source_dir / "overlays"
|
|
overlays_dir.mkdir()
|
|
(overlays_dir / "ov1.yml").write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"id": "ov1",
|
|
"extends": "wf",
|
|
"priority": 10,
|
|
"edits": [
|
|
{
|
|
"operation": "insert_after",
|
|
"anchor": "a",
|
|
"step": {"id": "new", "type": "command", "command": "echo"},
|
|
}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = runner.invoke(app, ["workflow", "add", str(source_dir)])
|
|
assert result.exit_code == 0, result.output
|
|
installed_overlay = (
|
|
project_dir / ".specify" / "workflows" / "wf" / "overlays" / "ov1.yml"
|
|
)
|
|
assert installed_overlay.is_file()
|