1120 lines
33 KiB
Python
1120 lines
33 KiB
Python
import shutil
|
||
import subprocess
|
||
import textwrap
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from automation import runner, workflow, summary as summary_module
|
||
|
||
SUMMARY_TEMPLATE = """
|
||
# Summary — <Stage Title>
|
||
|
||
<!-- SUMMARY:DECISIONS START -->
|
||
## Decisions (ADR-style)
|
||
- (none yet)
|
||
<!-- SUMMARY:DECISIONS END -->
|
||
|
||
<!-- SUMMARY:OPEN_QUESTIONS START -->
|
||
## Open Questions
|
||
- (none yet)
|
||
<!-- SUMMARY:OPEN_QUESTIONS END -->
|
||
|
||
<!-- SUMMARY:AWAITING START -->
|
||
## Awaiting Replies
|
||
- (none yet)
|
||
<!-- SUMMARY:AWAITING END -->
|
||
|
||
<!-- SUMMARY:ACTION_ITEMS START -->
|
||
## Action Items
|
||
- (none yet)
|
||
<!-- SUMMARY:ACTION_ITEMS END -->
|
||
|
||
<!-- SUMMARY:VOTES START -->
|
||
## Votes (latest per participant)
|
||
READY: 0 • CHANGES: 0 • REJECT: 0
|
||
- (no votes yet)
|
||
<!-- SUMMARY:VOTES END -->
|
||
|
||
<!-- SUMMARY:PARTICIPANTS START -->
|
||
## Participants
|
||
- (none yet)
|
||
<!-- SUMMARY:PARTICIPANTS END -->
|
||
|
||
<!-- SUMMARY:TIMELINE START -->
|
||
## Timeline (most recent first)
|
||
- <YYYY-MM-DD HH:MM> <name>: <one-liner>
|
||
<!-- SUMMARY:TIMELINE END -->
|
||
|
||
<!-- SUMMARY:LINKS START -->
|
||
## Links
|
||
- Related PRs: –
|
||
- Commits: –
|
||
- Design/Plan: ../design/design.md
|
||
<!-- SUMMARY:LINKS END -->
|
||
"""
|
||
|
||
|
||
@pytest.fixture()
|
||
def temp_repo(tmp_path, monkeypatch):
|
||
repo = tmp_path / "repo"
|
||
repo.mkdir()
|
||
run_git(repo, "init")
|
||
run_git(repo, "config", "user.email", "dev@example.com")
|
||
run_git(repo, "config", "user.name", "Dev")
|
||
monkeypatch.chdir(repo)
|
||
return repo
|
||
|
||
|
||
def run_git(cwd: Path, *args: str) -> None:
|
||
subprocess.run(
|
||
["git", *args],
|
||
cwd=cwd,
|
||
check=True,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
)
|
||
|
||
|
||
def write_file(path: Path, content: str) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(textwrap.dedent(content).strip() + "\n", encoding="utf-8")
|
||
|
||
|
||
def test_parse_votes_reads_index_snapshot(temp_repo):
|
||
repo = temp_repo
|
||
discussion = repo / "Docs/features/demo/discussions/example.discussion.md"
|
||
write_file(
|
||
discussion,
|
||
"""
|
||
## Thread
|
||
""",
|
||
)
|
||
run_git(repo, "add", ".")
|
||
run_git(repo, "commit", "-m", "seed")
|
||
|
||
# Stage a vote from Alice
|
||
write_file(
|
||
discussion,
|
||
"""
|
||
## Thread
|
||
Name: Alice
|
||
Looks good.
|
||
VOTE: READY
|
||
""",
|
||
)
|
||
run_git(repo, "add", discussion.relative_to(repo).as_posix())
|
||
|
||
# Add an unstaged vote from Bob (should be ignored)
|
||
discussion.write_text(
|
||
textwrap.dedent(
|
||
"""
|
||
## Thread
|
||
Name: Alice
|
||
Looks good.
|
||
VOTE: READY
|
||
|
||
Name: Bob
|
||
Still concerned.
|
||
VOTE: REJECT
|
||
"""
|
||
).strip()
|
||
+ "\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
votes = workflow.parse_votes(Path("Docs/features/demo/discussions/example.discussion.md"))
|
||
assert votes == {"Alice": "READY"}
|
||
|
||
|
||
def test_parse_votes_supports_multiline_comments(temp_repo):
|
||
repo = temp_repo
|
||
discussion = repo / "Docs/features/demo/discussions/multiline.discussion.md"
|
||
write_file(
|
||
discussion,
|
||
"""
|
||
## Thread
|
||
"""
|
||
)
|
||
run_git(repo, "add", ".")
|
||
run_git(repo, "commit", "-m", "seed")
|
||
|
||
write_file(
|
||
discussion,
|
||
"""
|
||
## Thread
|
||
Name: Dana
|
||
This spans multiple lines to ensure parsing keeps ownership.
|
||
We should capture every sentence without needing extra prefixes.
|
||
|
||
VOTE: READY
|
||
"""
|
||
)
|
||
run_git(repo, "add", discussion.relative_to(repo).as_posix())
|
||
|
||
votes = workflow.parse_votes(Path("Docs/features/demo/discussions/multiline.discussion.md"))
|
||
assert votes == {"Dana": "READY"}
|
||
|
||
|
||
def test_summarize_participants_tracks_latest_comment():
|
||
text = """
|
||
Name: Alice
|
||
Initial thoughts on the rollout.
|
||
VOTE: READY
|
||
|
||
Name: Bob
|
||
Adding telemetry requirements.
|
||
VOTE: READY
|
||
|
||
Name: Alice
|
||
Needs revision if security scope expands?
|
||
Consider adding an auth gateway.
|
||
VOTE: CHANGES
|
||
"""
|
||
|
||
blocks = workflow.extract_comment_blocks(text)
|
||
roster = workflow.summarize_participants(blocks)
|
||
|
||
assert len(roster) == 2
|
||
alice = next(item for item in roster if item["name"] == "Alice")
|
||
assert alice["vote"] == "CHANGES"
|
||
assert "Needs revision if security scope expands?" in alice["last_comment"]
|
||
|
||
|
||
def test_summary_sanitizes_garbled_participant_name(tmp_path):
|
||
summary_path = tmp_path / "summary.md"
|
||
summary_path.write_text(textwrap.dedent(SUMMARY_TEMPLATE).strip() + "\n", encoding="utf-8")
|
||
|
||
summary_module.update_summary_file(
|
||
summary_path,
|
||
votes={"PySide6/QtWidgets/QComboBox.sip": "READY"},
|
||
action_items=[{
|
||
"participant": "PySide6/QtWidgets/QComboBox.sip",
|
||
"action": "Review the combo box layout across breakpoints.",
|
||
"status": "TODO",
|
||
}],
|
||
questions=[{
|
||
"participant": "PySide6/QtWidgets/QComboBox.sip",
|
||
"question": "Will combo boxes resize gracefully?",
|
||
"status": "OPEN",
|
||
}],
|
||
decisions=[{
|
||
"participant": "PySide6/QtWidgets/QComboBox.sip",
|
||
"decision": "Adopt the shared combo box widget.",
|
||
"rationale": "",
|
||
"supporters": [],
|
||
}],
|
||
mentions=[{
|
||
"from": "PySide6/QtWidgets/QComboBox.sip",
|
||
"to": "PySide6/QtWidgets/QComboBox.sip",
|
||
"context": "@PySide6/QtWidgets/QComboBox.sip needs review",
|
||
}],
|
||
participants=[{
|
||
"name": "PySide6/QtWidgets/QComboBox.sip",
|
||
"vote": "READY",
|
||
"last_comment": "Ensuring combo boxes adapt to expansion.",
|
||
}],
|
||
)
|
||
|
||
content = summary_path.read_text(encoding="utf-8")
|
||
assert "suggested by @QComboBox" in content
|
||
assert "- QComboBox: READY" in content
|
||
|
||
|
||
def test_check_promotion_threshold_requires_human_ready():
|
||
vote_groups = {
|
||
"READY": ["AI_Helper"],
|
||
"CHANGES": [],
|
||
"REJECT": [],
|
||
}
|
||
|
||
result = workflow.check_promotion_threshold(
|
||
vote_groups,
|
||
"1_human",
|
||
"all",
|
||
"READY_FOR_TESTING",
|
||
"REJECTED",
|
||
)
|
||
assert result is None
|
||
|
||
vote_groups["READY"].append("Dana")
|
||
result = workflow.check_promotion_threshold(
|
||
vote_groups,
|
||
"1_human",
|
||
"all",
|
||
"READY_FOR_TESTING",
|
||
"REJECTED",
|
||
)
|
||
assert result == "READY_FOR_TESTING"
|
||
|
||
|
||
def test_implementation_tasks_summary_and_promotion_guard(temp_repo):
|
||
repo = temp_repo
|
||
impl_disc = repo / "Docs/features/demo/discussions/implementation.discussion.md"
|
||
impl_sum = repo / "Docs/features/demo/discussions/implementation.discussion.sum.md"
|
||
|
||
write_file(
|
||
impl_disc,
|
||
"""
|
||
---
|
||
type: implementation-discussion
|
||
stage: implementation
|
||
status: OPEN
|
||
feature_id: FR_2025-11-02_test-feature
|
||
created: 2025-11-02
|
||
promotion_rule:
|
||
allow_agent_votes: true
|
||
ready_min_eligible_votes: 1
|
||
reject_min_eligible_votes: all
|
||
---
|
||
"""
|
||
)
|
||
|
||
write_file(
|
||
impl_sum,
|
||
"""
|
||
<!--META
|
||
{
|
||
"kind": "discussion_summary",
|
||
"tokens": ["FeatureId", "CreatedDate"]
|
||
}
|
||
-->
|
||
|
||
# Summary — Implementation {FeatureId}
|
||
|
||
<!-- SUMMARY:TASKS START -->
|
||
## Tasks
|
||
- (none yet)
|
||
<!-- SUMMARY:TASKS END -->
|
||
|
||
<!-- SUMMARY:VOTES START -->
|
||
## Votes (latest per participant)
|
||
READY: 0 • CHANGES: 0 • REJECT: 0
|
||
- (no votes yet)
|
||
<!-- SUMMARY:VOTES END -->
|
||
|
||
<!-- SUMMARY:PARTICIPANTS START -->
|
||
## Participants
|
||
- (none yet)
|
||
<!-- SUMMARY:PARTICIPANTS END -->
|
||
|
||
<!-- SUMMARY:DECISIONS START -->
|
||
## Decisions (ADR-style)
|
||
- (none yet)
|
||
<!-- SUMMARY:DECISIONS END -->
|
||
|
||
<!-- SUMMARY:OPEN_QUESTIONS START -->
|
||
## Open Questions
|
||
- (none yet)
|
||
<!-- SUMMARY:OPEN_QUESTIONS END -->
|
||
|
||
<!-- SUMMARY:AWAITING START -->
|
||
## Awaiting Replies
|
||
- (none yet)
|
||
<!-- SUMMARY:AWAITING END -->
|
||
|
||
<!-- SUMMARY:ACTION_ITEMS START -->
|
||
## Action Items
|
||
- (none yet)
|
||
<!-- SUMMARY:ACTION_ITEMS END -->
|
||
|
||
<!-- SUMMARY:TIMELINE START -->
|
||
## Timeline (most recent first)
|
||
- 2025-11-02 00:00 Maintainer: Kickoff
|
||
<!-- SUMMARY:TIMELINE END -->
|
||
|
||
<!-- SUMMARY:LINKS START -->
|
||
## Links
|
||
- Design/Plan: ../design/design.md
|
||
<!-- SUMMARY:LINKS END -->
|
||
"""
|
||
)
|
||
|
||
run_git(repo, "add", ".")
|
||
run_git(repo, "commit", "-m", "seed")
|
||
|
||
write_file(
|
||
impl_disc,
|
||
"""
|
||
---
|
||
type: implementation-discussion
|
||
stage: implementation
|
||
status: OPEN
|
||
feature_id: FR_2025-11-02_test-feature
|
||
created: 2025-11-02
|
||
promotion_rule:
|
||
allow_agent_votes: true
|
||
ready_min_eligible_votes: 1
|
||
reject_min_eligible_votes: all
|
||
---
|
||
|
||
## Tasks
|
||
- [ ] Bootstrap services container [#456] @Dana
|
||
- [x] Update deployment manifests 1a2b3c4
|
||
|
||
Name: Dana
|
||
Implementation underway.
|
||
VOTE: READY
|
||
|
||
Name: AI_Helper
|
||
Tracking automation state.
|
||
VOTE: READY
|
||
"""
|
||
)
|
||
|
||
run_git(repo, "add", impl_disc.relative_to(repo).as_posix())
|
||
workflow._run_status()
|
||
|
||
summary_after_first = impl_sum.read_text()
|
||
assert "Progress: 1/2 complete (50%)" in summary_after_first
|
||
assert "- [ ] Bootstrap services container" in summary_after_first
|
||
assert "refs: PR #456" in summary_after_first
|
||
assert "- [x] Update deployment manifests" in summary_after_first
|
||
assert "refs: commit 1a2b3c4" in summary_after_first
|
||
|
||
content_after_first = impl_disc.read_text()
|
||
assert "status: OPEN" in content_after_first
|
||
assert "Name: AI_Implementer" in content_after_first
|
||
assert "Tracked tasks: 1/2 complete (50%)" in content_after_first
|
||
assert "VOTE: CHANGES" in content_after_first
|
||
|
||
tasks_file = repo / "Docs/features/demo/implementation/tasks.md"
|
||
assert tasks_file.exists()
|
||
tasks_content = tasks_file.read_text()
|
||
assert "- [ ] Bootstrap services container [#456] @Dana" in tasks_content
|
||
assert "- [x] Update deployment manifests 1a2b3c4" in tasks_content
|
||
|
||
write_file(
|
||
impl_disc,
|
||
"""
|
||
---
|
||
type: implementation-discussion
|
||
stage: implementation
|
||
status: OPEN
|
||
feature_id: FR_2025-11-02_test-feature
|
||
created: 2025-11-02
|
||
promotion_rule:
|
||
allow_agent_votes: true
|
||
ready_min_eligible_votes: 1
|
||
reject_min_eligible_votes: all
|
||
---
|
||
|
||
## Tasks
|
||
- [x] Bootstrap services container [#456] @Dana
|
||
- [x] Update deployment manifests 1a2b3c4
|
||
|
||
Name: Dana
|
||
Implementation complete.
|
||
VOTE: READY
|
||
|
||
Name: AI_Helper
|
||
Tracking automation state.
|
||
VOTE: READY
|
||
"""
|
||
)
|
||
|
||
run_git(repo, "add", impl_disc.relative_to(repo).as_posix())
|
||
workflow._run_status()
|
||
|
||
final_content = impl_disc.read_text()
|
||
assert "status: READY_FOR_TESTING" in final_content
|
||
assert "Tracked tasks: 2/2 complete (100%)" in final_content
|
||
assert "VOTE: READY" in final_content
|
||
|
||
summary_after_completion = impl_sum.read_text()
|
||
assert "Progress: 2/2 complete (100%)" in summary_after_completion
|
||
assert "refs: PR #456" in summary_after_completion
|
||
assert "refs: commit 1a2b3c4" in summary_after_completion
|
||
tasks_section = summary_after_completion.split("<!-- SUMMARY:TASKS START -->", 1)[1].split("<!-- SUMMARY:TASKS END -->", 1)[0]
|
||
assert "- [ ]" not in tasks_section
|
||
|
||
updated_tasks = tasks_file.read_text()
|
||
assert updated_tasks.count("- [x]") == 2
|
||
|
||
|
||
def test_get_discussion_changes_returns_only_staged_lines(temp_repo):
|
||
repo = temp_repo
|
||
discussion = repo / "Docs/features/demo/discussions/sample.discussion.md"
|
||
write_file(
|
||
discussion,
|
||
"""
|
||
## Discussion
|
||
""",
|
||
)
|
||
run_git(repo, "add", ".")
|
||
run_git(repo, "commit", "-m", "base")
|
||
|
||
write_file(
|
||
discussion,
|
||
"""
|
||
## Discussion
|
||
Name: Alice
|
||
Proposal incoming.
|
||
VOTE: READY
|
||
""",
|
||
)
|
||
run_git(repo, "add", discussion.relative_to(repo).as_posix())
|
||
|
||
# Unstaged change should be ignored
|
||
discussion.write_text(
|
||
textwrap.dedent(
|
||
"""
|
||
## Discussion
|
||
Name: Alice
|
||
Proposal incoming.
|
||
VOTE: READY
|
||
|
||
Name: Bob
|
||
Needs changes.
|
||
VOTE: CHANGES
|
||
"""
|
||
).strip()
|
||
+ "\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
additions = workflow.get_discussion_changes(Path("Docs/features/demo/discussions/sample.discussion.md"))
|
||
assert "Alice" in additions
|
||
assert "Bob" not in additions
|
||
|
||
|
||
def test_get_discussion_changes_new_file_returns_full_content(temp_repo):
|
||
repo = temp_repo
|
||
discussion = repo / "Docs/features/new/discussions/brand-new.discussion.md"
|
||
write_file(
|
||
discussion,
|
||
"""
|
||
## Kickoff
|
||
Name: Maintainer
|
||
Bootstrapping.
|
||
VOTE: READY
|
||
""",
|
||
)
|
||
run_git(repo, "add", discussion.relative_to(repo).as_posix())
|
||
|
||
additions = workflow.get_discussion_changes(Path("Docs/features/new/discussions/brand-new.discussion.md"))
|
||
assert "Bootstrapping" in additions
|
||
assert "Maintainer" in additions
|
||
|
||
|
||
def test_run_status_updates_summary_sections(temp_repo):
|
||
repo = temp_repo
|
||
discussion = repo / "Docs/features/demo/discussions/example.discussion.md"
|
||
summary = repo / "Docs/features/demo/discussions/example.discussion.sum.md"
|
||
|
||
write_file(discussion, """
|
||
## Discussion
|
||
""")
|
||
write_file(summary, SUMMARY_TEMPLATE)
|
||
run_git(repo, "add", ".")
|
||
run_git(repo, "commit", "-m", "seed")
|
||
|
||
write_file(discussion, """
|
||
## Discussion
|
||
Name: Alice
|
||
Kickoff.
|
||
VOTE: READY
|
||
|
||
Name: Bob
|
||
QUESTION: What is the rollout plan?
|
||
VOTE: CHANGES
|
||
|
||
Name: Bob
|
||
TODO: Document rollout plan
|
||
VOTE: READY
|
||
|
||
Name: Carol
|
||
DONE: Documented rollout plan
|
||
VOTE: READY
|
||
|
||
Name: Alice
|
||
DECISION: Ship approach A
|
||
VOTE: READY
|
||
|
||
Name: Alice
|
||
Thanks team! @bob @carol
|
||
VOTE: READY
|
||
""")
|
||
run_git(repo, "add", discussion.relative_to(repo).as_posix())
|
||
|
||
workflow._run_status()
|
||
|
||
content = summary.read_text(encoding="utf-8")
|
||
assert "READY: 3 • CHANGES: 0 • REJECT: 0" in content
|
||
assert "- Alice: READY" in content
|
||
assert "- Bob: READY" in content
|
||
assert "- Carol: READY" in content
|
||
assert "## Open Questions" in content and "@Bob: What is the rollout plan?" in content
|
||
assert "### TODO (unassigned):" in content and "Document rollout plan" in content
|
||
assert "### Completed:" in content and "Documented rollout plan" in content
|
||
assert "### Decision 1: Ship approach A" in content
|
||
assert "### @bob" in content
|
||
assert "@Alice: Kickoff." in content
|
||
assert "## Participants" in content
|
||
assert "Total: 3 (Humans: 3 • Agents: 0)" in content
|
||
assert "- Alice — READY" in content
|
||
assert "- Bob — READY" in content
|
||
assert "- Carol — READY" in content
|
||
assert "Last: Thanks team! @bob @carol" in content
|
||
|
||
staged = subprocess.run(
|
||
["git", "diff", "--cached", "--name-only"],
|
||
cwd=repo,
|
||
check=True,
|
||
capture_output=True,
|
||
text=True,
|
||
).stdout.split()
|
||
assert "Docs/features/demo/discussions/example.discussion.sum.md" in staged
|
||
|
||
|
||
def test_run_status_promotes_feature_stage(temp_repo):
|
||
repo = temp_repo
|
||
discussion = repo / "Docs/features/demo/discussions/feature.discussion.md"
|
||
write_file(
|
||
discussion,
|
||
"""
|
||
---
|
||
type: feature-discussion
|
||
stage: feature
|
||
status: OPEN
|
||
feature_id: DEMO-123
|
||
created: 2025-01-01
|
||
promotion_rule:
|
||
allow_agent_votes: false
|
||
ready_min_eligible_votes: 2
|
||
reject_min_eligible_votes: 1
|
||
---
|
||
|
||
Name: Alice
|
||
Looks good.
|
||
VOTE: READY
|
||
|
||
---
|
||
|
||
Name: Bob
|
||
Ship it.
|
||
VOTE: READY
|
||
""",
|
||
)
|
||
run_git(repo, "add", discussion.relative_to(repo).as_posix())
|
||
|
||
workflow._run_status()
|
||
|
||
content = discussion.read_text(encoding="utf-8")
|
||
assert "status: READY_FOR_DESIGN" in content
|
||
|
||
|
||
def test_run_status_promotes_design_stage(temp_repo):
|
||
repo = temp_repo
|
||
discussion = repo / "Docs/features/demo/discussions/design.discussion.md"
|
||
write_file(
|
||
discussion,
|
||
"""
|
||
---
|
||
type: design-discussion
|
||
stage: design
|
||
status: OPEN
|
||
feature_id: DEMO-123
|
||
created: 2025-01-01
|
||
promotion_rule:
|
||
allow_agent_votes: false
|
||
ready_min_eligible_votes: 2
|
||
reject_min_eligible_votes: 1
|
||
---
|
||
|
||
Name: Alice
|
||
Architecture is solid.
|
||
VOTE: READY
|
||
|
||
---
|
||
|
||
Name: Bob
|
||
Agree with the approach.
|
||
VOTE: READY
|
||
""",
|
||
)
|
||
run_git(repo, "add", discussion.relative_to(repo).as_posix())
|
||
|
||
workflow._run_status()
|
||
|
||
content = discussion.read_text(encoding="utf-8")
|
||
assert "status: READY_FOR_IMPLEMENTATION" in content
|
||
|
||
|
||
def test_extract_structured_basic():
|
||
"""Test lightweight pattern matching for discussion markers."""
|
||
text = """
|
||
# Discussion Title
|
||
|
||
Name: Alice
|
||
QUESTION: What about security considerations?
|
||
VOTE: READY
|
||
|
||
Name: Bob
|
||
TODO: Review OAuth libraries for security vulnerabilities
|
||
@Alice I'll handle the security review
|
||
VOTE: READY
|
||
|
||
Name: Carol
|
||
DECISION: Use OAuth2 for third-party authentication
|
||
VOTE: READY
|
||
|
||
Name: Dave
|
||
DONE: Completed initial research on OAuth2 providers
|
||
VOTE: READY
|
||
|
||
Name: Eve
|
||
Question: Should we support social login providers?
|
||
VOTE: CHANGES
|
||
|
||
Name: Frank
|
||
We should definitely support Google. What about GitHub?
|
||
VOTE: READY
|
||
|
||
Name: Grace
|
||
ACTION: Create comparison matrix for OAuth providers
|
||
ASSIGNED: OAuth provider comparison (@Grace taking this)
|
||
VOTE: READY
|
||
"""
|
||
|
||
result = workflow.extract_structured_basic(text)
|
||
|
||
# Check questions
|
||
assert len(result["questions"]) == 3
|
||
question_texts = [q["question"] for q in result["questions"]]
|
||
assert "What about security considerations?" in question_texts
|
||
assert "Should we support social login providers?" in question_texts
|
||
assert "We should definitely support Google. What about GitHub?" in question_texts
|
||
|
||
# Check participants
|
||
assert result["questions"][0]["participant"] == "Alice"
|
||
assert result["questions"][1]["participant"] == "Eve"
|
||
assert result["questions"][2]["participant"] == "Frank"
|
||
|
||
# Check action items
|
||
assert len(result["action_items"]) == 4
|
||
actions = result["action_items"]
|
||
|
||
# TODO items (Bob's TODO and Grace's ACTION both become TODO)
|
||
todo_items = [a for a in actions if a["status"] == "TODO"]
|
||
assert len(todo_items) == 2
|
||
|
||
bob_todo = next(a for a in todo_items if a["participant"] == "Bob")
|
||
assert "Review OAuth libraries" in bob_todo["action"]
|
||
|
||
grace_action = next(a for a in todo_items if "comparison matrix" in a["action"])
|
||
assert grace_action["participant"] == "Grace"
|
||
|
||
# DONE item
|
||
done = next(a for a in actions if a["status"] == "DONE")
|
||
assert "Completed initial research" in done["action"]
|
||
assert done["participant"] == "Dave"
|
||
assert done["completed_by"] == "Dave"
|
||
|
||
# ASSIGNED item
|
||
assigned = next(a for a in actions if a["status"] == "ASSIGNED")
|
||
assert "OAuth provider comparison" in assigned["action"]
|
||
assert assigned["participant"] == "Grace"
|
||
assert assigned["assignee"] == "Grace"
|
||
|
||
# Check decisions
|
||
assert len(result["decisions"]) == 1
|
||
decision = result["decisions"][0]
|
||
assert "Use OAuth2" in decision["decision"]
|
||
assert decision["participant"] == "Carol"
|
||
|
||
# Check mentions
|
||
assert len(result["mentions"]) == 2
|
||
mention_targets = [m["to"] for m in result["mentions"]]
|
||
assert "Alice" in mention_targets
|
||
assert "Grace" in mention_targets
|
||
|
||
# Check timeline
|
||
assert result["timeline"] is not None
|
||
assert result["timeline"]["participant"] == "Alice"
|
||
assert len(result["timeline"]["summary"]) <= 120
|
||
|
||
|
||
def test_extract_structured_basic_handles_edge_cases():
|
||
"""Test edge cases in pattern matching."""
|
||
text = """
|
||
Name: Alice
|
||
This is just a comment without markers
|
||
VOTE: READY
|
||
|
||
Name: Bob
|
||
TODO:
|
||
VOTE: READY
|
||
|
||
Name: Carol
|
||
DECISION:
|
||
VOTE: READY
|
||
|
||
Name: Dave
|
||
https://example.com/?param=value
|
||
VOTE: CHANGES
|
||
|
||
Name: Eve
|
||
TODO: Valid action item here
|
||
VOTE: READY
|
||
"""
|
||
|
||
result = workflow.extract_structured_basic(text)
|
||
|
||
# Empty markers should be ignored
|
||
assert len(result["action_items"]) == 1
|
||
assert "Valid action item" in result["action_items"][0]["action"]
|
||
|
||
# Empty decision should be ignored
|
||
assert len(result["decisions"]) == 0
|
||
|
||
# URL with ? should not be treated as question
|
||
assert len(result["questions"]) == 0
|
||
|
||
# Timeline should capture first meaningful comment
|
||
assert result["timeline"]["participant"] == "Alice"
|
||
|
||
|
||
def test_extract_structured_basic_skips_headers():
|
||
"""Test that markdown headers are skipped."""
|
||
text = """
|
||
# Main Header
|
||
## Sub Header
|
||
|
||
- Alice: Q: Real question here?
|
||
"""
|
||
|
||
result = workflow.extract_structured_basic(text)
|
||
|
||
# Should have one question, headers ignored
|
||
assert len(result["questions"]) == 1
|
||
assert result["questions"][0]["question"] == "Real question here?"
|
||
|
||
# Timeline should use Alice, not the headers
|
||
assert result["timeline"]["participant"] == "Alice"
|
||
|
||
|
||
def test_runner_invokes_participant_agent(temp_repo):
|
||
repo = temp_repo
|
||
project_root = Path(__file__).resolve().parent.parent
|
||
|
||
# Set up a fake vendored SDK and a mock provider
|
||
(repo / ".cascadingdev" / "cascadingdev" / "agent").mkdir(parents=True, exist_ok=True)
|
||
shutil.copytree(project_root / "src" / "cascadingdev", repo / ".cascadingdev" / "cascadingdev", dirs_exist_ok=True)
|
||
shutil.rmtree(repo / ".cascadingdev" / "cascadingdev" / "agent" / "__pycache__", ignore_errors=True)
|
||
providers_stub = """
|
||
class ProviderClient:
|
||
def __init__(self, repo_root, override=None):
|
||
self.repo_root = repo_root
|
||
|
||
def structured(self, prompt: str, model_hint: str = "") -> dict:
|
||
return {"comment": "Moderator intro guidance.", "vote": "READY"}
|
||
"""
|
||
(repo / ".cascadingdev" / "cascadingdev" / "agent" / "providers.py").write_text(textwrap.dedent(providers_stub).strip() + "\n", encoding="utf-8")
|
||
|
||
# Copy the real moderator agent
|
||
agents_dir = repo / "agents"
|
||
agents_dir.mkdir(parents=True, exist_ok=True)
|
||
mod_src = project_root / "agents" / "moderator.py"
|
||
(agents_dir / "moderator.py").write_text(mod_src.read_text(), encoding="utf-8")
|
||
|
||
# Write the rules to invoke the agent
|
||
rules_content = """
|
||
version: 1
|
||
file_associations:
|
||
"feature.discussion.md": "feature_discussion_update"
|
||
rules:
|
||
feature_discussion_update:
|
||
participants:
|
||
- "agents/moderator.py"
|
||
"""
|
||
write_file(repo / ".ai-rules.yml", rules_content)
|
||
|
||
discussion = repo / "Docs/features/demo/discussions/feature.discussion.md"
|
||
write_file(discussion, "Name: Alice\nNo vote here.")
|
||
run_git(repo, "add", ".")
|
||
|
||
# Run the runner
|
||
from automation.config import RulesConfig
|
||
from automation.patcher import ModelConfig
|
||
rules = RulesConfig.load(repo)
|
||
model = ModelConfig.from_sources(repo)
|
||
runner.process(repo, rules, model)
|
||
|
||
# Check that the moderator added its comment
|
||
updated = discussion.read_text(encoding="utf-8")
|
||
assert "Moderator intro guidance." in updated
|
||
assert "VOTE: READY" in updated
|
||
|
||
|
||
def test_moderator_agent_posts_each_commit(temp_repo):
|
||
repo = temp_repo
|
||
project_root = Path(__file__).resolve().parent.parent
|
||
|
||
# Fake vendored SDK with deterministic provider
|
||
(repo / ".cascadingdev" / "cascadingdev" / "agent").mkdir(parents=True, exist_ok=True)
|
||
shutil.copytree(project_root / "src" / "cascadingdev", repo / ".cascadingdev" / "cascadingdev", dirs_exist_ok=True)
|
||
shutil.rmtree(repo / ".cascadingdev" / "cascadingdev" / "agent" / "__pycache__", ignore_errors=True)
|
||
providers_stub = """
|
||
class ProviderClient:
|
||
def __init__(self, repo_root, override=None):
|
||
self.repo_root = repo_root
|
||
|
||
def structured(self, prompt: str, model_hint: str = "") -> dict:
|
||
return {"comment": "One-time moderator reply.", "vote": "READY"}
|
||
"""
|
||
(repo / ".cascadingdev" / "cascadingdev" / "agent" / "providers.py").write_text(
|
||
textwrap.dedent(providers_stub).strip() + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
agents_dir = repo / "agents"
|
||
agents_dir.mkdir(parents=True, exist_ok=True)
|
||
mod_src = project_root / "agents" / "moderator.py"
|
||
(agents_dir / "moderator.py").write_text(mod_src.read_text(), encoding="utf-8")
|
||
|
||
rules_content = """
|
||
version: 1
|
||
file_associations:
|
||
"feature.discussion.md": "feature_discussion_update"
|
||
rules:
|
||
feature_discussion_update:
|
||
participants:
|
||
- "agents/moderator.py"
|
||
"""
|
||
write_file(repo / ".ai-rules.yml", rules_content)
|
||
|
||
discussion = repo / "Docs/features/demo/discussions/feature.discussion.md"
|
||
write_file(discussion, "Name: Alex\nSummary incoming soon.")
|
||
run_git(repo, "add", ".")
|
||
|
||
from automation.config import RulesConfig
|
||
from automation.patcher import ModelConfig
|
||
|
||
rules = RulesConfig.load(repo)
|
||
model = ModelConfig.from_sources(repo)
|
||
|
||
runner.process(repo, rules, model)
|
||
first_pass = discussion.read_text(encoding="utf-8")
|
||
assert first_pass.count("Name: AI_Moderator") == 1
|
||
|
||
# Stage and run again; moderator should contribute a fresh comment.
|
||
run_git(repo, "add", ".")
|
||
runner.process(repo, rules, model)
|
||
second_pass = discussion.read_text(encoding="utf-8")
|
||
|
||
assert second_pass.count("Name: AI_Moderator") == 2
|
||
|
||
|
||
def test_visualizer_generates_diagram(temp_repo):
|
||
repo = temp_repo
|
||
project_root = Path(__file__).resolve().parent.parent
|
||
|
||
# Minimal AI config
|
||
(repo / "config").mkdir(parents=True, exist_ok=True)
|
||
(repo / "config" / "ai.yml").write_text("version: 1\nrunner:\n command_chain:\n - echo\n")
|
||
|
||
# Copy the real visualizer agent
|
||
agents_dir = repo / "agents"
|
||
agents_dir.mkdir(parents=True, exist_ok=True)
|
||
vis_src = project_root / "agents" / "visualizer.py"
|
||
(agents_dir / "visualizer.py").write_text(vis_src.read_text(), encoding="utf-8")
|
||
|
||
# Write rules to invoke the visualizer
|
||
rules_content = """
|
||
version: 1
|
||
file_associations:
|
||
"design.discussion.md": "design_discussion_update"
|
||
rules:
|
||
design_discussion_update:
|
||
participants:
|
||
- "agents/visualizer.py"
|
||
"""
|
||
write_file(repo / ".ai-rules.yml", rules_content)
|
||
|
||
discussion = repo / "Docs/features/demo/discussions/design.discussion.md"
|
||
write_file(discussion, "Name: Dana\n@AI_visual please sketch the login flow.\nVOTE: READY")
|
||
run_git(repo, "add", ".")
|
||
|
||
# Run the runner
|
||
from automation.config import RulesConfig
|
||
from automation.patcher import ModelConfig
|
||
rules = RulesConfig.load(repo)
|
||
model = ModelConfig.from_sources(repo)
|
||
runner.process(repo, rules, model)
|
||
|
||
# Check that a diagram was created
|
||
diagram_dir = repo / "Docs/features/demo" / "diagrams"
|
||
generated = sorted(diagram_dir.glob("diagram_*.puml"))
|
||
assert generated, "diagram not generated"
|
||
assert "please sketch the login flow." in generated[0].read_text(encoding="utf-8")
|
||
|
||
# Check that a comment was posted
|
||
updated = discussion.read_text(encoding="utf-8")
|
||
assert "Name: AI_Visualizer" in updated
|
||
assert generated[0].relative_to(repo).as_posix() in updated
|
||
start = updated.index("<!-- AUTO:VISUALIZER START -->")
|
||
end = updated.index("<!-- AUTO:VISUALIZER END -->", start)
|
||
agent_block = updated[start:end]
|
||
assert "VOTE:" not in agent_block
|
||
|
||
|
||
def test_researcher_handles_request(temp_repo):
|
||
repo = temp_repo
|
||
project_root = Path(__file__).resolve().parent.parent
|
||
|
||
# Fake vendored SDK with deterministic provider
|
||
(repo / ".cascadingdev" / "cascadingdev" / "agent").mkdir(parents=True, exist_ok=True)
|
||
shutil.copytree(project_root / "src" / "cascadingdev", repo / ".cascadingdev" / "cascadingdev", dirs_exist_ok=True)
|
||
shutil.rmtree(repo / ".cascadingdev" / "cascadingdev" / "agent" / "__pycache__", ignore_errors=True)
|
||
providers_stub = """
|
||
class ProviderClient:
|
||
def __init__(self, repo_root, override=None):
|
||
self.repo_root = repo_root
|
||
|
||
def structured(self, prompt: str, model_hint: str = "") -> dict:
|
||
return {
|
||
"summary": "- Found two maintained libraries for terminal UIs.\\n- Rich has the broadest widget support.",
|
||
"sources": [
|
||
{"title": "Textual", "url": "https://github.com/Textualize/textual", "insight": "Active project for rich TUI apps."},
|
||
{"title": "Rich", "url": "https://github.com/Textualize/rich", "insight": "Provides underlying rendering primitives."}
|
||
]
|
||
}
|
||
"""
|
||
(repo / ".cascadingdev" / "cascadingdev" / "agent" / "providers.py").write_text(
|
||
textwrap.dedent(providers_stub).strip() + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
agents_dir = repo / "agents"
|
||
agents_dir.mkdir(parents=True, exist_ok=True)
|
||
researcher_src = project_root / "agents" / "researcher.py"
|
||
(agents_dir / "researcher.py").write_text(researcher_src.read_text(), encoding="utf-8")
|
||
|
||
rules_content = """
|
||
version: 1
|
||
file_associations:
|
||
"design.discussion.md": "design_discussion_update"
|
||
rules:
|
||
design_discussion_update:
|
||
participants:
|
||
- path: "agents/researcher.py"
|
||
background: true
|
||
"""
|
||
write_file(repo / ".ai-rules.yml", rules_content)
|
||
|
||
discussion = repo / "Docs/features/demo/discussions/design.discussion.md"
|
||
write_file(
|
||
discussion,
|
||
"Name: Casey\n@AI-researcher: find python libraries for interactive terminal UIs.\nVOTE: READY",
|
||
)
|
||
run_git(repo, "add", ".")
|
||
|
||
from automation.config import RulesConfig
|
||
from automation.patcher import ModelConfig
|
||
|
||
rules = RulesConfig.load(repo)
|
||
model = ModelConfig.from_sources(repo)
|
||
|
||
runner.process(repo, rules, model)
|
||
|
||
# Poll for asynchronous completion
|
||
deadline = time.time() + 2.0
|
||
while time.time() < deadline:
|
||
text = discussion.read_text(encoding="utf-8")
|
||
if "AI_Researcher" in text:
|
||
break
|
||
time.sleep(0.05)
|
||
else:
|
||
raise AssertionError("Researcher agent did not respond in time")
|
||
|
||
updated = discussion.read_text(encoding="utf-8")
|
||
assert "### Research Findings" in updated
|
||
assert "Textual" in updated
|
||
assert "Rich" in updated
|
||
start = updated.index("<!-- AUTO:RESEARCHER START -->")
|
||
end = updated.index("<!-- AUTO:RESEARCHER END -->", start)
|
||
agent_block = updated[start:end]
|
||
assert "VOTE:" not in agent_block
|
||
|
||
|
||
def test_background_participant_runs_async(temp_repo):
|
||
repo = temp_repo
|
||
|
||
agents_dir = repo / "agents"
|
||
agents_dir.mkdir(parents=True, exist_ok=True)
|
||
background_agent = agents_dir / "background.py"
|
||
background_agent.write_text(
|
||
textwrap.dedent(
|
||
"""
|
||
#!/usr/bin/env python3
|
||
import argparse
|
||
import time
|
||
from pathlib import Path
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--repo-root", required=True)
|
||
parser.add_argument("--path", required=True)
|
||
args = parser.parse_args()
|
||
|
||
repo_root = Path(args.repo_root)
|
||
discussion = (repo_root / args.path).resolve()
|
||
time.sleep(0.2)
|
||
text = discussion.read_text(encoding="utf-8")
|
||
if not text.endswith("\\n"):
|
||
text += "\\n"
|
||
text += "Name: AI_Background\\nCompleted async job.\\nVOTE: READY\\n"
|
||
discussion.write_text(text, encoding="utf-8")
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
"""
|
||
).strip()
|
||
+ "\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
rules_content = """
|
||
version: 1
|
||
file_associations:
|
||
"feature.discussion.md": "feature_discussion_update"
|
||
rules:
|
||
feature_discussion_update:
|
||
participants:
|
||
- path: "agents/background.py"
|
||
background: true
|
||
"""
|
||
write_file(repo / ".ai-rules.yml", rules_content)
|
||
|
||
discussion = repo / "Docs/features/demo/discussions/feature.discussion.md"
|
||
write_file(discussion, "Name: Casey\nRequesting async support.\nVOTE: READY")
|
||
run_git(repo, "add", ".")
|
||
|
||
from automation.config import RulesConfig
|
||
from automation.patcher import ModelConfig
|
||
|
||
rules = RulesConfig.load(repo)
|
||
model = ModelConfig.from_sources(repo)
|
||
|
||
start = time.time()
|
||
runner.process(repo, rules, model)
|
||
duration = time.time() - start
|
||
|
||
# Runner should return before the agent finishes sleeping for 0.2s
|
||
assert duration < 0.15
|
||
|
||
# Wait for background agent to finish its work
|
||
deadline = time.time() + 2.0
|
||
while time.time() < deadline:
|
||
text = discussion.read_text(encoding="utf-8")
|
||
if "AI_Background" in text:
|
||
break
|
||
time.sleep(0.05)
|
||
else: # pragma: no cover - defensive
|
||
raise AssertionError("Background participant did not complete in time")
|
||
|
||
assert "Completed async job." in discussion.read_text(encoding="utf-8")
|