336 lines
9.8 KiB
Python
336 lines
9.8 KiB
Python
import subprocess
|
||
import textwrap
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from automation import workflow
|
||
|
||
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: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
|
||
- 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
|
||
- Alice: Looks good. VOTE: READY
|
||
- 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_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
|
||
- 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
|
||
- Alice: Proposal incoming. VOTE: READY
|
||
- 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
|
||
- 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
|
||
- Alice: Kickoff. VOTE: READY
|
||
- Bob: Q: What is the rollout plan?
|
||
- Bob: TODO: Document rollout plan
|
||
- Carol: DONE: Documented rollout plan
|
||
- Alice: DECISION: Ship approach A
|
||
- Alice: Thanks team! @bob @carol
|
||
""")
|
||
run_git(repo, "add", discussion.relative_to(repo).as_posix())
|
||
|
||
workflow._run_status()
|
||
|
||
content = summary.read_text(encoding="utf-8")
|
||
assert "READY: 1 • CHANGES: 0 • REJECT: 0" in content
|
||
assert "- Alice: 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. VOTE: READY" 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_extract_structured_basic():
|
||
"""Test lightweight pattern matching for discussion markers."""
|
||
text = """
|
||
# Discussion Title
|
||
|
||
- Alice: Q: What about security considerations?
|
||
- Bob: TODO: Review OAuth libraries for security vulnerabilities
|
||
- Bob: @Alice I'll handle the security review
|
||
- Carol: DECISION: Use OAuth2 for third-party authentication
|
||
- Dave: DONE: Completed initial research on OAuth2 providers
|
||
- Eve: Question: Should we support social login providers?
|
||
- Frank: We should definitely support Google. What about GitHub?
|
||
- Grace: ACTION: Create comparison matrix for OAuth providers
|
||
- Grace: ASSIGNED: OAuth provider comparison (@Grace taking this)
|
||
"""
|
||
|
||
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 = """
|
||
- Alice: This is just a comment without markers
|
||
- Bob: TODO:
|
||
- Carol: DECISION:
|
||
- Dave: https://example.com/?param=value
|
||
- Eve: TODO: Valid action item here
|
||
"""
|
||
|
||
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"
|