mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 19:03:08 +00:00
Add Azure DevOps work item synchronization with handoffs system
This commit is contained in:
@@ -632,6 +632,272 @@ class TestCliValidation:
|
||||
assert "agent skills" in plain.lower()
|
||||
|
||||
|
||||
class TestHandoffsFieldInSkills:
|
||||
"""Test handling of handoffs field in command templates for AI skills (ADO sync feature)."""
|
||||
|
||||
def test_skill_generation_with_handoffs_in_template(self, project_dir):
|
||||
"""Skills should generate successfully from templates containing handoffs field."""
|
||||
# Create template with handoffs
|
||||
cmds_dir = project_dir / ".claude" / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
|
||||
(cmds_dir / "specify.md").write_text(
|
||||
"---\n"
|
||||
"description: Create specification\n"
|
||||
"handoffs:\n"
|
||||
" - label: Sync to Azure DevOps\n"
|
||||
" agent: speckit.adosync\n"
|
||||
" prompt: Sync user stories to ADO\n"
|
||||
" send: true\n"
|
||||
" - label: Build Plan\n"
|
||||
" agent: speckit.plan\n"
|
||||
" send: false\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Specify Command\n"
|
||||
"\n"
|
||||
"Create specs.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
assert result is True
|
||||
skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Verify skill has valid structure
|
||||
assert "name: speckit-specify" in content
|
||||
assert "description:" in content
|
||||
# Body content should be preserved
|
||||
assert "Create specs." in content
|
||||
|
||||
def test_skill_generation_with_multiline_handoffs_prompt(self, project_dir):
|
||||
"""Skills should generate successfully from templates with multiline handoffs prompts."""
|
||||
cmds_dir = project_dir / ".claude" / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
|
||||
(cmds_dir / "plan.md").write_text(
|
||||
"---\n"
|
||||
"description: Create plan\n"
|
||||
"handoffs:\n"
|
||||
" - label: Sync Tasks\n"
|
||||
" agent: speckit.adosync\n"
|
||||
" prompt: |\n"
|
||||
" Read the tasks.md file and show me all the tasks.\n"
|
||||
" Ask me which tasks I want to sync.\n"
|
||||
" Then create Task work items in Azure DevOps.\n"
|
||||
" send: true\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Plan\n"
|
||||
"\n"
|
||||
"Plan body.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
assert result is True
|
||||
skill_file = project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Verify skill was generated successfully
|
||||
assert "name: speckit-plan" in content
|
||||
assert "Plan body." in content
|
||||
|
||||
def test_handoffs_field_parseable_in_generated_skill(self, project_dir):
|
||||
"""Generated SKILL.md should have valid parseable YAML regardless of source frontmatter."""
|
||||
cmds_dir = project_dir / ".claude" / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
|
||||
(cmds_dir / "tasks.md").write_text(
|
||||
"---\n"
|
||||
"description: Generate tasks\n"
|
||||
"handoffs:\n"
|
||||
" - label: Sync to ADO\n"
|
||||
" agent: speckit.adosync\n"
|
||||
" prompt: Sync tasks to Azure DevOps\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Tasks\n"
|
||||
"\n"
|
||||
"Task content.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
install_ai_skills(project_dir, "claude")
|
||||
|
||||
skill_file = project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Extract and parse frontmatter to verify it's valid YAML
|
||||
parts = content.split("---", 2)
|
||||
assert len(parts) >= 3
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
|
||||
# The generated SKILL.md should have agentskills.io compliant frontmatter
|
||||
assert isinstance(parsed, dict)
|
||||
assert "name" in parsed
|
||||
assert parsed["name"] == "speckit-tasks"
|
||||
assert "description" in parsed
|
||||
assert "compatibility" in parsed
|
||||
|
||||
# Body should be preserved
|
||||
assert "Task content." in content
|
||||
|
||||
def test_templates_with_handoffs_and_scripts_fields(self, project_dir):
|
||||
"""Skills should generate from templates with multiple complex fields like handoffs and scripts."""
|
||||
cmds_dir = project_dir / ".claude" / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
|
||||
(cmds_dir / "specify.md").write_text(
|
||||
"---\n"
|
||||
"description: Spec command\n"
|
||||
"handoffs:\n"
|
||||
" - label: Sync to ADO\n"
|
||||
" agent: speckit.adosync\n"
|
||||
" prompt: |\n"
|
||||
" Sync user stories from spec.md.\n"
|
||||
" The spec file path is: {spec_file_path}\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/create-new-feature.sh\n"
|
||||
" ps: scripts/powershell/create-new-feature.ps1\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Specify\n"
|
||||
"\n"
|
||||
"Command body.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
install_ai_skills(project_dir, "claude")
|
||||
|
||||
skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Skill should be generated successfully
|
||||
assert "name: speckit-specify" in content
|
||||
assert "Command body." in content
|
||||
|
||||
def test_multiple_handoffs_dont_break_skill_generation(self, project_dir):
|
||||
"""Templates with multiple handoffs should generate skills without errors."""
|
||||
cmds_dir = project_dir / ".claude" / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
|
||||
(cmds_dir / "plan.md").write_text(
|
||||
"---\n"
|
||||
"description: Plan command\n"
|
||||
"handoffs:\n"
|
||||
" - label: Sync User Stories\n"
|
||||
" agent: speckit.adosync\n"
|
||||
" prompt: Sync user stories\n"
|
||||
" send: true\n"
|
||||
" - label: Sync Tasks\n"
|
||||
" agent: speckit.adosync\n"
|
||||
" prompt: Sync tasks with -FromTasks\n"
|
||||
" send: false\n"
|
||||
" - label: Create Checklist\n"
|
||||
" agent: speckit.checklist\n"
|
||||
" send: true\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Plan\n"
|
||||
"\n"
|
||||
"Planning content.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
assert result is True
|
||||
skill_file = project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Skill should be generated with valid structure
|
||||
assert "name: speckit-plan" in content
|
||||
assert "Planning content." in content
|
||||
|
||||
def test_handoffs_field_optional_in_skills(self, project_dir):
|
||||
"""Commands without handoffs should still generate valid skills."""
|
||||
cmds_dir = project_dir / ".claude" / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
|
||||
(cmds_dir / "legacy.md").write_text(
|
||||
"---\n"
|
||||
"description: Legacy command without handoffs\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Legacy Command\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
assert result is True
|
||||
skill_file = project_dir / ".claude" / "skills" / "speckit-legacy" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Should have valid structure without handoffs
|
||||
assert "name: speckit-legacy" in content
|
||||
assert "Legacy command without handoffs" in content
|
||||
|
||||
def test_empty_handoffs_array_in_skills(self, project_dir):
|
||||
"""Commands with empty handoffs array should generate valid skills."""
|
||||
cmds_dir = project_dir / ".claude" / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
|
||||
(cmds_dir / "test.md").write_text(
|
||||
"---\n"
|
||||
"description: Test command\n"
|
||||
"handoffs: []\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Test\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
assert result is True
|
||||
skill_file = project_dir / ".claude" / "skills" / "speckit-test" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Should handle empty handoffs gracefully
|
||||
assert "name: speckit-test" in content
|
||||
|
||||
def test_adosync_command_generates_skill(self, project_dir):
|
||||
"""The adosync command itself should generate a valid skill."""
|
||||
cmds_dir = project_dir / ".claude" / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
|
||||
(cmds_dir / "adosync.md").write_text(
|
||||
"---\n"
|
||||
"description: Sync selected user stories or tasks to Azure DevOps\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/create-ado-workitems.sh\n"
|
||||
" ps: scripts/powershell/create-ado-workitems.ps1\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# ADO Sync Command\n"
|
||||
"\n"
|
||||
"Sync to Azure DevOps.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
assert result is True
|
||||
skill_file = project_dir / ".claude" / "skills" / "speckit-adosync" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
|
||||
assert "name: speckit-adosync" in content
|
||||
assert "Azure DevOps" in content
|
||||
|
||||
|
||||
class TestParameterOrderingIssue:
|
||||
"""Test fix for GitHub issue #1641: parameter ordering issues."""
|
||||
|
||||
|
||||
@@ -987,3 +987,305 @@ class TestExtensionCatalog:
|
||||
|
||||
assert not catalog.cache_file.exists()
|
||||
assert not catalog.cache_metadata_file.exists()
|
||||
|
||||
|
||||
# ===== Handoffs Field Tests (ADO Sync) =====
|
||||
|
||||
class TestHandoffsField:
|
||||
"""Test parsing and handling of handoffs field in command frontmatter (ADO sync feature)."""
|
||||
|
||||
def test_parse_frontmatter_with_handoffs(self):
|
||||
"""Test parsing frontmatter containing handoffs field."""
|
||||
content = """---
|
||||
description: "Test command with handoffs"
|
||||
handoffs:
|
||||
- label: Sync to Azure DevOps
|
||||
agent: speckit.adosync
|
||||
prompt: Sync user stories to Azure DevOps
|
||||
send: true
|
||||
- label: Create Tasks
|
||||
agent: speckit.tasks
|
||||
prompt: Break down into tasks
|
||||
send: false
|
||||
---
|
||||
|
||||
# Command content
|
||||
$ARGUMENTS
|
||||
"""
|
||||
registrar = CommandRegistrar()
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
assert "handoffs" in frontmatter
|
||||
assert len(frontmatter["handoffs"]) == 2
|
||||
|
||||
# Verify first handoff
|
||||
assert frontmatter["handoffs"][0]["label"] == "Sync to Azure DevOps"
|
||||
assert frontmatter["handoffs"][0]["agent"] == "speckit.adosync"
|
||||
assert frontmatter["handoffs"][0]["prompt"] == "Sync user stories to Azure DevOps"
|
||||
assert frontmatter["handoffs"][0]["send"] is True
|
||||
|
||||
# Verify second handoff
|
||||
assert frontmatter["handoffs"][1]["label"] == "Create Tasks"
|
||||
assert frontmatter["handoffs"][1]["agent"] == "speckit.tasks"
|
||||
assert frontmatter["handoffs"][1]["send"] is False
|
||||
|
||||
def test_parse_frontmatter_with_multiline_handoff_prompt(self):
|
||||
"""Test parsing handoffs with multiline prompts."""
|
||||
content = """---
|
||||
description: "Test command"
|
||||
handoffs:
|
||||
- label: Sync Tasks to ADO
|
||||
agent: speckit.adosync
|
||||
prompt: |
|
||||
Read the tasks.md file and show me all the tasks.
|
||||
Ask me which tasks I want to sync (I can say "all", specific numbers like "1,2,3", or ranges like "1-10").
|
||||
Then create Task work items in Azure DevOps.
|
||||
send: true
|
||||
---
|
||||
|
||||
# Command
|
||||
$ARGUMENTS
|
||||
"""
|
||||
registrar = CommandRegistrar()
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
assert "handoffs" in frontmatter
|
||||
assert len(frontmatter["handoffs"]) == 1
|
||||
assert "Read the tasks.md file" in frontmatter["handoffs"][0]["prompt"]
|
||||
assert "Ask me which tasks" in frontmatter["handoffs"][0]["prompt"]
|
||||
|
||||
def test_parse_frontmatter_with_handoffs_missing_optional_fields(self):
|
||||
"""Test parsing handoffs with only required fields."""
|
||||
content = """---
|
||||
description: "Minimal handoff"
|
||||
handoffs:
|
||||
- label: Next Step
|
||||
agent: speckit.plan
|
||||
---
|
||||
|
||||
# Command
|
||||
$ARGUMENTS
|
||||
"""
|
||||
registrar = CommandRegistrar()
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
assert "handoffs" in frontmatter
|
||||
assert len(frontmatter["handoffs"]) == 1
|
||||
assert frontmatter["handoffs"][0]["label"] == "Next Step"
|
||||
assert frontmatter["handoffs"][0]["agent"] == "speckit.plan"
|
||||
assert "prompt" not in frontmatter["handoffs"][0]
|
||||
assert "send" not in frontmatter["handoffs"][0]
|
||||
|
||||
def test_handoffs_field_preserved_in_rendered_markdown(self):
|
||||
"""Test that handoffs field is preserved when rendering commands."""
|
||||
frontmatter = {
|
||||
"description": "Create specification",
|
||||
"handoffs": [
|
||||
{
|
||||
"label": "Sync to Azure DevOps",
|
||||
"agent": "speckit.adosync",
|
||||
"prompt": "Sync user stories from the spec.md",
|
||||
"send": True
|
||||
}
|
||||
]
|
||||
}
|
||||
body = "# Specify Command\n\n$ARGUMENTS"
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
rendered = registrar._render_markdown_command(frontmatter, body, "test-ext")
|
||||
|
||||
# Verify handoffs is in the frontmatter
|
||||
assert "handoffs:" in rendered
|
||||
assert "agent: speckit.adosync" in rendered
|
||||
assert "Sync user stories from the spec.md" in rendered
|
||||
assert "send: true" in rendered
|
||||
|
||||
def test_handoffs_field_preserved_in_rendered_toml(self):
|
||||
"""Test that handoffs field is NOT included in TOML format (unsupported)."""
|
||||
frontmatter = {
|
||||
"description": "Create specification",
|
||||
"handoffs": [
|
||||
{
|
||||
"label": "Sync to ADO",
|
||||
"agent": "speckit.adosync",
|
||||
"send": True
|
||||
}
|
||||
]
|
||||
}
|
||||
body = "# Command\n\n{{args}}"
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
rendered = registrar._render_toml_command(frontmatter, body, "test-ext")
|
||||
|
||||
# TOML format only extracts description, not complex structures like handoffs
|
||||
assert 'description = "Create specification"' in rendered
|
||||
# Handoffs should not appear in TOML (it only supports simple fields)
|
||||
assert "handoffs" not in rendered
|
||||
|
||||
def test_register_command_with_handoffs_to_claude(self, temp_dir, project_dir):
|
||||
"""Test registering command with handoffs field for Claude."""
|
||||
import yaml
|
||||
|
||||
# Create extension with handoffs in command
|
||||
ext_dir = temp_dir / "ext-handoffs"
|
||||
ext_dir.mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-handoffs",
|
||||
"name": "Extension with Handoffs",
|
||||
"version": "1.0.0",
|
||||
"description": "Test handoffs",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.handoffs.specify",
|
||||
"file": "commands/specify.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
with open(ext_dir / "extension.yml", 'w') as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands").mkdir()
|
||||
cmd_content = """---
|
||||
description: Create spec with handoffs
|
||||
handoffs:
|
||||
- label: Sync to ADO
|
||||
agent: speckit.adosync
|
||||
prompt: Sync to Azure DevOps
|
||||
send: true
|
||||
---
|
||||
|
||||
# Specify
|
||||
|
||||
$ARGUMENTS
|
||||
"""
|
||||
(ext_dir / "commands" / "specify.md").write_text(cmd_content)
|
||||
|
||||
# Register command
|
||||
claude_dir = project_dir / ".claude" / "commands"
|
||||
claude_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir)
|
||||
|
||||
# Verify registration
|
||||
assert len(registered) == 1
|
||||
cmd_file = claude_dir / "speckit.handoffs.specify.md"
|
||||
assert cmd_file.exists()
|
||||
|
||||
# Verify handoffs field is preserved
|
||||
content = cmd_file.read_text()
|
||||
assert "handoffs:" in content
|
||||
assert "agent: speckit.adosync" in content
|
||||
assert "Sync to Azure DevOps" in content
|
||||
|
||||
def test_handoffs_agent_field_format_validation(self):
|
||||
"""Test that agent field in handoffs uses correct format."""
|
||||
content = """---
|
||||
description: "Test"
|
||||
handoffs:
|
||||
- label: Invalid Agent Format
|
||||
agent: invalid-agent-name
|
||||
---
|
||||
|
||||
# Command
|
||||
"""
|
||||
registrar = CommandRegistrar()
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
# Should parse successfully (validation happens elsewhere)
|
||||
assert "handoffs" in frontmatter
|
||||
assert frontmatter["handoffs"][0]["agent"] == "invalid-agent-name"
|
||||
|
||||
def test_multiple_handoffs_with_same_agent(self):
|
||||
"""Test command with multiple handoffs referencing the same agent."""
|
||||
content = """---
|
||||
description: "Multiple handoffs"
|
||||
handoffs:
|
||||
- label: Sync User Stories
|
||||
agent: speckit.adosync
|
||||
prompt: Sync user stories
|
||||
send: true
|
||||
- label: Sync Tasks
|
||||
agent: speckit.adosync
|
||||
prompt: Sync tasks with -FromTasks flag
|
||||
send: false
|
||||
---
|
||||
|
||||
# Command
|
||||
$ARGUMENTS
|
||||
"""
|
||||
registrar = CommandRegistrar()
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
assert len(frontmatter["handoffs"]) == 2
|
||||
assert frontmatter["handoffs"][0]["agent"] == "speckit.adosync"
|
||||
assert frontmatter["handoffs"][1]["agent"] == "speckit.adosync"
|
||||
assert frontmatter["handoffs"][0]["label"] != frontmatter["handoffs"][1]["label"]
|
||||
|
||||
def test_handoffs_with_interpolation_placeholders(self):
|
||||
"""Test handoffs with prompt containing variable placeholders."""
|
||||
content = """---
|
||||
description: "Command with variable interpolation"
|
||||
handoffs:
|
||||
- label: Sync to ADO
|
||||
agent: speckit.adosync
|
||||
prompt: |
|
||||
Sync user stories from the spec.md we just created.
|
||||
|
||||
The spec file path is: {spec_file_path}
|
||||
|
||||
Please:
|
||||
1. Show me the list of user stories found
|
||||
2. Ask which ones I want to sync (or suggest 'all')
|
||||
3. Create the work items in Azure DevOps
|
||||
send: true
|
||||
---
|
||||
|
||||
# Command
|
||||
$ARGUMENTS
|
||||
"""
|
||||
registrar = CommandRegistrar()
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
assert "handoffs" in frontmatter
|
||||
assert "{spec_file_path}" in frontmatter["handoffs"][0]["prompt"]
|
||||
|
||||
def test_empty_handoffs_array(self):
|
||||
"""Test command with empty handoffs array."""
|
||||
content = """---
|
||||
description: "No handoffs"
|
||||
handoffs: []
|
||||
---
|
||||
|
||||
# Command
|
||||
$ARGUMENTS
|
||||
"""
|
||||
registrar = CommandRegistrar()
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
assert "handoffs" in frontmatter
|
||||
assert len(frontmatter["handoffs"]) == 0
|
||||
|
||||
def test_handoffs_field_not_present(self):
|
||||
"""Test command without handoffs field (backwards compatibility)."""
|
||||
content = """---
|
||||
description: "Legacy command without handoffs"
|
||||
---
|
||||
|
||||
# Command
|
||||
$ARGUMENTS
|
||||
"""
|
||||
registrar = CommandRegistrar()
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
# Should not have handoffs field
|
||||
assert "handoffs" not in frontmatter
|
||||
|
||||
Reference in New Issue
Block a user