1580 lines
58 KiB
Python
1580 lines
58 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["pytest>=8.0", "ruamel.yaml>=0.18"]
|
|
# ///
|
|
"""Corruption-critical tests for sprint-status.py.
|
|
|
|
Each test runs the script as a subprocess via ``uv run`` against a temp copy of
|
|
an inline fixture, then re-reads the file to assert comments and formatting
|
|
survive and punctuation-heavy action values round-trip intact.
|
|
|
|
Run: uv run scripts/tests/test_sprint_status.py
|
|
or: uv run --with pytest --with ruamel.yaml -m pytest scripts/tests/test_sprint_status.py
|
|
"""
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import re
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from ruamel.yaml import YAML
|
|
|
|
SCRIPT = Path(__file__).resolve().parents[1] / "sprint_status.py"
|
|
# Vendored copy of bmad-sprint-planning's sprint-status-template.yaml — skills
|
|
# must not path into each other's directories (PATH-05). The repo-level
|
|
# test/test-template-sync.js keeps this fixture identical to the source.
|
|
TEMPLATE = Path(__file__).resolve().parent / "fixtures" / "sprint-status-template.yaml"
|
|
|
|
FIXTURE = """\
|
|
# Sprint Status Tracking
|
|
# STATUS DEFINITIONS:
|
|
# backlog - not yet started
|
|
# ready-for-dev - ready to be implemented
|
|
# done - completed
|
|
generated: "01-01-2026 09:00"
|
|
last_updated: "01-01-2026 09:00"
|
|
project: "Demo Project"
|
|
project_key: "DEMO"
|
|
tracking_system: "file"
|
|
story_location: "docs/stories"
|
|
development_status:
|
|
epic-1: backlog
|
|
1-1-user-authentication: done
|
|
1-2-account-management: done
|
|
epic-1-retrospective: optional
|
|
epic-2: backlog
|
|
2-1-dashboard: backlog
|
|
"""
|
|
|
|
|
|
# Two epics' worth of items, because the flag's headline use is epic N's retro
|
|
# closing epic N-1's items: nothing may scope a selector to --epic. The two
|
|
# "Scripted item" entries share their action text and differ only by epic, so the
|
|
# legacy epic+action selector has to discriminate on the epic to resolve at all.
|
|
ACTION_FIXTURE = """\
|
|
# Sprint Status Tracking
|
|
# STATUS DEFINITIONS:
|
|
# open - committed during a retrospective, not yet addressed
|
|
generated: "01-01-2026 09:00"
|
|
last_updated: "01-01-2026 09:00"
|
|
development_status:
|
|
1-1-a: done
|
|
epic-1-retrospective: optional
|
|
2-1-b: done
|
|
epic-2-retrospective: optional
|
|
|
|
# Action items committed during retrospectives
|
|
action_items:
|
|
- id: "epic-1-retro-item-1-x"
|
|
epic: 1
|
|
action: "Scripted item"
|
|
owner: "Amelia"
|
|
status: "open"
|
|
ref: "docs/epic-1-retro.md"
|
|
- epic: 1
|
|
action: "Pre-existing item"
|
|
owner: "Charlie"
|
|
status: open
|
|
- id: "epic-2-retro-item-1-y"
|
|
epic: 2
|
|
action: "Scripted item"
|
|
owner: "Dana"
|
|
status: "in-progress"
|
|
ref: "docs/epic-2-retro.md"
|
|
"""
|
|
|
|
# Three spellings of the same scalar, to pin that a status write never re-styles
|
|
# the line it lands on.
|
|
STYLE_FIXTURE = """\
|
|
last_updated: "01-01-2026 09:00"
|
|
development_status:
|
|
1-1-a: done
|
|
epic-1-retrospective: optional
|
|
action_items:
|
|
- id: "double"
|
|
epic: 1
|
|
action: "Double"
|
|
status: "open"
|
|
- id: "single"
|
|
epic: 1
|
|
action: "Single"
|
|
status: 'open'
|
|
- id: "plain"
|
|
epic: 1
|
|
action: "Plain"
|
|
status: open
|
|
"""
|
|
|
|
|
|
def _run(args):
|
|
cmd = ["uv", "run", str(SCRIPT), *args]
|
|
# LC_ALL=C keeps os.strerror text stable so error-string assertions do not
|
|
# depend on the developer's locale.
|
|
return subprocess.run(
|
|
cmd, capture_output=True, text=True, env={**os.environ, "LC_ALL": "C"}
|
|
)
|
|
|
|
|
|
def _module():
|
|
"""Import the script as a module, for the few properties that cannot be
|
|
triggered through the CLI (a failure after the temp file already exists)."""
|
|
spec = importlib.util.spec_from_file_location("sprint_status", SCRIPT)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def _write_fixture(tmp_path):
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(FIXTURE, encoding="utf-8")
|
|
return target
|
|
|
|
|
|
def _write_action_fixture(tmp_path):
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(ACTION_FIXTURE, encoding="utf-8")
|
|
return target
|
|
|
|
|
|
def _load(path):
|
|
yaml = YAML(typ="rt")
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
return yaml.load(fh)
|
|
|
|
|
|
def _json(proc):
|
|
"""Parse the JSON-only stdout contract, surfacing a crash instead of hiding
|
|
it behind a JSONDecodeError."""
|
|
assert proc.stdout, f"empty stdout; stderr was: {proc.stderr}"
|
|
assert "Traceback" not in proc.stderr, proc.stderr
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
def test_detect_epic(tmp_path):
|
|
target = _write_fixture(tmp_path)
|
|
proc = _run(["detect-epic", "--file", str(target)])
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = json.loads(proc.stdout)
|
|
assert out["epic"] == 1
|
|
assert out["story_count"] == 2
|
|
assert out["retro_key"] == "epic-1-retrospective"
|
|
assert out["retro_status"] == "optional"
|
|
assert set(out["done_stories"]) == {
|
|
"1-1-user-authentication",
|
|
"1-2-account-management",
|
|
}
|
|
|
|
|
|
def test_detect_epic_rejects_typed_retrospective_status_as_json(tmp_path):
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
" epic-1-retrospective: 2026-01-01\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
|
|
proc = _run(["detect-epic", "--file", str(target)])
|
|
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["error"] == "epic-1-retrospective status must be a string or null"
|
|
assert "restored" not in out
|
|
assert target.read_text(encoding="utf-8") == fixture
|
|
|
|
|
|
# --- pending_stories: the unfinished-epic gate --------------------------------
|
|
|
|
|
|
def test_pending_stories_lists_the_selected_epics_unfinished_keys(tmp_path):
|
|
# The gate the skill branches on before Phase 1: an epic whose highest done
|
|
# story selected it, but which is not actually finished. Document order, so
|
|
# the listing the user confirms matches the file they can open.
|
|
fixture = (
|
|
"development_status:\n"
|
|
" epic-2: backlog\n"
|
|
" 2-1-a: done\n"
|
|
" 2-2-b: backlog\n"
|
|
" 2-3-c: ready-for-dev\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(["detect-epic", "--file", str(target)])
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = _json(proc)
|
|
assert out["epic"] == 2
|
|
assert out["pending_stories"] == ["2-2-b", "2-3-c"]
|
|
# A non-story key sitting beside them never leaks in: STORY_RE gates entry.
|
|
assert "epic-2" not in out["pending_stories"]
|
|
|
|
|
|
def test_pending_stories_is_empty_for_a_complete_epic(tmp_path):
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 2-1-a: done\n"
|
|
" 2-2-b: done\n"
|
|
" epic-2-retrospective: optional\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(["detect-epic", "--file", str(target)])
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = _json(proc)
|
|
assert out["epic"] == 2
|
|
assert out["pending_stories"] == []
|
|
|
|
|
|
def test_pending_stories_ignores_other_epics(tmp_path):
|
|
# FIXTURE detects epic 1 while 2-1-dashboard sits at backlog. The key is
|
|
# scoped to the *selected* epic -- unlike done_stories, which spans the whole
|
|
# file -- so another epic's unfinished work must never block this retro.
|
|
target = _write_fixture(tmp_path)
|
|
proc = _run(["detect-epic", "--file", str(target)])
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = _json(proc)
|
|
assert out["epic"] == 1
|
|
assert out["pending_stories"] == []
|
|
# done_stories keeps its whole-file scope, unchanged.
|
|
assert set(out["done_stories"]) == {
|
|
"1-1-user-authentication",
|
|
"1-2-account-management",
|
|
}
|
|
|
|
|
|
def test_pending_stories_present_when_no_epic_is_detected(tmp_path):
|
|
# No done story anywhere: the shape stays uniform so a caller can read
|
|
# pending_stories without first branching on epic.
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 1-1-a: backlog\n"
|
|
" 2-1-b: ready-for-dev\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(["detect-epic", "--file", str(target)])
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = _json(proc)
|
|
assert out["epic"] is None
|
|
assert out["story_count"] == 0
|
|
assert out["pending_stories"] == []
|
|
|
|
|
|
def test_detect_epic_flag_aims_pending_stories_at_a_supplied_epic(tmp_path):
|
|
# Auto-detect would pick epic 2 (highest with a done story). --epic 1 aims
|
|
# the unfinished-epic gate at the orchestrator's explicit choice instead —
|
|
# the -H <epic> path that previously had no pending_stories at all.
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
" 1-2-b: backlog\n"
|
|
" 1-3-c: review\n"
|
|
" 2-1-a: done\n"
|
|
" 2-2-b: done\n"
|
|
" epic-1-retrospective: optional\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(["detect-epic", "--file", str(target), "--epic", "1"])
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = _json(proc)
|
|
assert out["epic"] == 1
|
|
assert out["retro_key"] == "epic-1-retrospective"
|
|
assert out["retro_status"] == "optional"
|
|
assert out["pending_stories"] == ["1-2-b", "1-3-c"]
|
|
# done_stories keeps its whole-file scope.
|
|
assert set(out["done_stories"]) == {"1-1-a", "2-1-a", "2-2-b"}
|
|
|
|
|
|
def test_detect_epic_flag_lists_pending_when_no_story_is_done(tmp_path):
|
|
# Without --epic, no done story means epic is null. With --epic, an
|
|
# unfinished epic that never landed a done story is still addressable —
|
|
# every story key of that epic is pending.
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 3-1-a: backlog\n"
|
|
" 3-2-b: ready-for-dev\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(["detect-epic", "--file", str(target), "--epic", "3"])
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = _json(proc)
|
|
assert out["epic"] == 3
|
|
assert out["pending_stories"] == ["3-1-a", "3-2-b"]
|
|
assert out["retro_key"] == "epic-3-retrospective"
|
|
assert out["retro_status"] is None
|
|
|
|
|
|
def test_detect_epic_flag_empty_pending_for_a_complete_supplied_epic(tmp_path):
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
" 1-2-b: done\n"
|
|
" 2-1-a: backlog\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(["detect-epic", "--file", str(target), "--epic", "1"])
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = _json(proc)
|
|
assert out["epic"] == 1
|
|
assert out["pending_stories"] == []
|
|
|
|
|
|
def test_detect_epic_flag_zero_story_count_marks_a_nonexistent_epic(tmp_path):
|
|
# --epic 9 against a file that has no epic-9 stories: pending_stories is
|
|
# empty exactly as it is for a finished epic, so story_count is the only
|
|
# signal separating "complete" from "typo'd". The gate reads 0 as suspect,
|
|
# never as done.
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
" 1-2-b: done\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(["detect-epic", "--file", str(target), "--epic", "9"])
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = _json(proc)
|
|
assert out["epic"] == 9
|
|
assert out["story_count"] == 0
|
|
assert out["pending_stories"] == []
|
|
assert out["retro_status"] is None
|
|
# The finished epic it could be confused with reports its real count.
|
|
proc = _run(["detect-epic", "--file", str(target), "--epic", "1"])
|
|
out = _json(proc)
|
|
assert out["story_count"] == 2
|
|
assert out["pending_stories"] == []
|
|
|
|
|
|
def test_detect_epic_flag_rejects_non_positive_epic(tmp_path):
|
|
target = _write_fixture(tmp_path)
|
|
for bad in ("0", "-3"):
|
|
proc = _run(["detect-epic", "--file", str(target), "--epic", bad])
|
|
assert proc.returncode == 1, proc.stderr
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert "epic" in out["error"]
|
|
assert "restored" not in out
|
|
|
|
|
|
def test_update_rejects_non_positive_epic(tmp_path):
|
|
target = _write_fixture(tmp_path)
|
|
for bad in ("0", "-3"):
|
|
proc = _run(["update", "--file", str(target), "--epic", bad])
|
|
assert proc.returncode == 1, proc.stderr
|
|
assert "positive integer" in _json(proc)["error"]
|
|
assert target.read_text(encoding="utf-8") == FIXTURE
|
|
|
|
|
|
# --- The JSON-only contract covers the help paths ----------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[["-h"], ["--help"], ["detect-epic", "-h"], ["detect-epic", "--help"]],
|
|
ids=["top-short", "top-long", "sub-short", "sub-long"],
|
|
)
|
|
def test_help_flags_emit_json_not_usage(args):
|
|
# argparse's built-in help action bypasses error() -- it prints usage text
|
|
# to stdout and exits 0, which is exactly the contract this script sells.
|
|
# add_help=False turns -h into an ordinary unrecognized argument instead.
|
|
proc = _run(args)
|
|
# Exit 2 specifically: the module docstring reserves 2 for argument errors
|
|
# and 1 for I/O failures, and retro-document.md teaches callers to tell the
|
|
# two apart, so collapsing them must fail here.
|
|
assert proc.returncode == 2
|
|
assert "usage:" not in proc.stdout
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["error"]
|
|
# An argparse rejection speaks for no file, so it carries no "restored" --
|
|
# the same rule test_only_the_write_path_reports_restored pins for exit 1.
|
|
assert "restored" not in out
|
|
|
|
|
|
@pytest.mark.parametrize("flag", ["-h", "--help"])
|
|
def test_update_help_flag_emits_json_not_usage(tmp_path, flag):
|
|
# The update subparser too, driven with its required arguments present so
|
|
# nothing but the help flag itself can be what argparse objects to.
|
|
target = _write_fixture(tmp_path)
|
|
proc = _run(["update", "--file", str(target), "--epic", "1", flag])
|
|
assert proc.returncode == 2
|
|
assert "usage:" not in proc.stdout
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert flag in out["error"]
|
|
assert "restored" not in out
|
|
# A rejected invocation must not have written anything.
|
|
assert target.read_text(encoding="utf-8") == FIXTURE
|
|
|
|
|
|
def test_update_sets_retro_and_appends_action(tmp_path):
|
|
target = _write_fixture(tmp_path)
|
|
payload = '[{"action":"Fix #42: colons: and # hashes","owner":"Amelia"}]'
|
|
proc = _run(
|
|
[
|
|
"update",
|
|
"--file",
|
|
str(target),
|
|
"--epic",
|
|
"1",
|
|
"--set-retro-done",
|
|
"--add-action",
|
|
payload,
|
|
]
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = json.loads(proc.stdout)
|
|
assert out["ok"] is True
|
|
assert out["retro_key_found"] is True
|
|
assert out["retro_status_after"] == "done"
|
|
assert out["action_items_added"] == 1
|
|
|
|
# File must still parse cleanly (punctuation did not corrupt it).
|
|
data = _load(target)
|
|
assert data is not None
|
|
|
|
# STATUS DEFINITIONS comment survived.
|
|
raw = target.read_text(encoding="utf-8")
|
|
assert "STATUS DEFINITIONS" in raw
|
|
|
|
# Retro status flipped to done.
|
|
assert data["development_status"]["epic-1-retrospective"] == "done"
|
|
|
|
# The action value round-trips with literal '#' and ':' intact.
|
|
action = data["action_items"][0]
|
|
assert action["action"] == "Fix #42: colons: and # hashes"
|
|
assert action["owner"] == "Amelia"
|
|
assert action["epic"] == 1
|
|
assert action["status"] == "open"
|
|
|
|
|
|
def test_update_rejects_typed_retrospective_status_before_writing(tmp_path):
|
|
fixture = (
|
|
"last_updated: 01-01-2026 09:00\n"
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
" epic-1-retrospective: 2026-01-01\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
|
|
proc = _run(
|
|
[
|
|
"update",
|
|
"--file",
|
|
str(target),
|
|
"--epic",
|
|
"1",
|
|
"--set-retro-done",
|
|
"--add-action",
|
|
'[{"action":"Must not be appended","owner":"Amelia"}]',
|
|
]
|
|
)
|
|
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert out["error"] == "epic-1-retrospective status must be a string or null"
|
|
assert target.read_text(encoding="utf-8") == fixture
|
|
|
|
|
|
def test_detect_epic_matches_split_story_keys(tmp_path):
|
|
# A split-story key like 2-6a-... is first-class in BMAD (an oversized story
|
|
# split into 2-6a / 2-6b) and must not be invisible to detection — otherwise
|
|
# an epic whose only done stories are splits is silently skipped.
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 1-1-first: done\n"
|
|
" 2-6a-split-auth: done\n"
|
|
" epic-2-retrospective: optional\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(["detect-epic", "--file", str(target)])
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = json.loads(proc.stdout)
|
|
assert out["epic"] == 2
|
|
assert "2-6a-split-auth" in out["done_stories"]
|
|
assert out["retro_key"] == "epic-2-retrospective"
|
|
|
|
|
|
def test_update_rejects_non_list_action_items(tmp_path):
|
|
# A hand-corrupted action_items must fail on the JSON contract, not crash.
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
" epic-1-retrospective: optional\n"
|
|
'action_items: "oops-not-a-list"\n'
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--add-action", '[{"action":"x","owner":"y"}]']
|
|
)
|
|
assert proc.returncode == 1
|
|
out = json.loads(proc.stdout) # must be JSON, not a traceback
|
|
assert out["ok"] is False
|
|
assert "action_items" in out["error"]
|
|
|
|
|
|
def test_appended_items_carry_id_and_ref(tmp_path):
|
|
target = _write_fixture(tmp_path)
|
|
ref = "docs/stories/epic-1-retro-2026-07-21.md"
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--set-retro-done",
|
|
"--add-action", '[{"action":"Fix the seam","owner":"Amelia"}]',
|
|
"--ref", ref, "--verdict", "accepted-with-open-items"]
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = json.loads(proc.stdout)
|
|
assert out["verdict"] == "accepted-with-open-items" # echoed, not written to a key
|
|
|
|
item = _load(target)["action_items"][0]
|
|
assert item["id"].startswith("epic-1-retro-item-1-")
|
|
assert item["ref"] == ref
|
|
# The retro key value stays "done" — verdict is not encoded into it.
|
|
assert _load(target)["development_status"]["epic-1-retrospective"] == "done"
|
|
|
|
|
|
def test_free_spelled_verdict_is_rejected_before_the_file_is_touched(tmp_path):
|
|
# The SKILL's prose verdict ("accepted with open items") and the frontmatter
|
|
# token (accepted-with-open-items) used to be two spellings of one value; an
|
|
# orchestrator branching on the echo would fall through both. Only the
|
|
# frontmatter vocabulary passes; anything else fails with the file intact.
|
|
target = _write_fixture(tmp_path)
|
|
for bad in ("accepted with open items", "ship it", "ACCEPTED"):
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--set-retro-done",
|
|
"--verdict", bad]
|
|
)
|
|
assert proc.returncode == 1, f"accepted {bad!r}"
|
|
out = _json(proc)
|
|
assert out["ok"] is False and "--verdict" in out["error"]
|
|
assert out["restored"] is True
|
|
assert target.read_text(encoding="utf-8") == FIXTURE
|
|
|
|
|
|
def test_explicit_item_id_is_preserved(tmp_path):
|
|
target = _write_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--add-action", '[{"action":"a","owner":"o","id":"custom-id-7"}]']
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert _load(target)["action_items"][0]["id"] == "custom-id-7"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
hasattr(os, "geteuid") and os.geteuid() == 0,
|
|
reason="root bypasses file permission bits",
|
|
)
|
|
def test_write_failure_reports_restore_status(tmp_path):
|
|
# If the write cannot happen, the caller must be told whether the original
|
|
# was restored — a silent failure defeats the script's core guarantee.
|
|
#
|
|
# The write is atomic (temp file + os.replace), and os.replace needs write
|
|
# permission on the *directory*, not on the target — a read-only target is
|
|
# now replaceable. Making the containing directory read-only is what blocks
|
|
# the write: mkstemp fails, while the restore write to the still-writable
|
|
# target succeeds.
|
|
holder = tmp_path / "holder"
|
|
holder.mkdir()
|
|
target = _write_fixture(holder)
|
|
os.chmod(holder, 0o555)
|
|
try:
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--set-retro-done"]
|
|
)
|
|
finally:
|
|
os.chmod(holder, 0o755)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
# The file was never touched: the temp file could not even be created.
|
|
assert target.read_text(encoding="utf-8") == FIXTURE
|
|
assert [p.name for p in holder.iterdir()] == ["sprint-status.yaml"]
|
|
|
|
|
|
def test_punctuation_does_not_corrupt_file(tmp_path):
|
|
# Explicit re-parse guarantee for YAML-breaking punctuation.
|
|
target = _write_fixture(tmp_path)
|
|
payload = '[{"action":"weird: value # with: hashes","owner":"Bob # Smith"}]'
|
|
proc = _run(
|
|
[
|
|
"update",
|
|
"--file",
|
|
str(target),
|
|
"--epic",
|
|
"1",
|
|
"--add-action",
|
|
payload,
|
|
]
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
# Re-parse must succeed and preserve the literal punctuation.
|
|
data = _load(target)
|
|
assert data["action_items"][0]["action"] == "weird: value # with: hashes"
|
|
assert data["action_items"][0]["owner"] == "Bob # Smith"
|
|
|
|
|
|
# --- Formatting fidelity -----------------------------------------------------
|
|
|
|
|
|
def test_template_round_trip_changes_only_last_updated(tmp_path):
|
|
# The repo's own sprint-status template is the shape every generated file
|
|
# inherits: 2-space sequence indent and a mid-file comment above
|
|
# action_items. An update must touch nothing but last_updated — a re-indent
|
|
# of a pre-existing, untouched entry defeats the preservation guarantee that
|
|
# motivates "do not hand-edit this file".
|
|
source = TEMPLATE.read_text(encoding="utf-8")
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(source, encoding="utf-8")
|
|
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--date", "01-01-2026 09:00"]
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert _json(proc)["ok"] is True
|
|
|
|
before = source.splitlines()
|
|
after = target.read_text(encoding="utf-8").splitlines()
|
|
assert len(before) == len(after)
|
|
changed = [(b, a) for b, a in zip(before, after) if b != a]
|
|
assert len(changed) == 1, changed
|
|
assert changed[0][1] == "last_updated: 01-01-2026 09:00"
|
|
# The pre-existing action item keeps its 2-space sequence indent.
|
|
assert " - epic: 1" in after
|
|
|
|
|
|
def test_mid_file_comment_survives_update(tmp_path):
|
|
fixture = (
|
|
"# header\n"
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
" epic-1-retrospective: optional\n"
|
|
"\n"
|
|
"# Action items committed during retrospectives\n"
|
|
"action_items:\n"
|
|
" - epic: 1\n"
|
|
' action: "Pre-existing item"\n'
|
|
' owner: "Charlie"\n'
|
|
" status: open\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--set-retro-done",
|
|
"--add-action", '[{"action":"New item","owner":"Amelia"}]']
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
raw = target.read_text(encoding="utf-8")
|
|
assert "# Action items committed during retrospectives" in raw
|
|
assert "# header" in raw
|
|
assert ' action: "Pre-existing item"' in raw
|
|
|
|
|
|
def test_legacy_offset_zero_file_is_canonicalized(tmp_path):
|
|
# Files the previous version of this script wrote carry action_items at
|
|
# column 0. The indent pin re-indents them to the template's shape on the
|
|
# next write. That is a deliberate one-time canonicalization, not a silent
|
|
# failure: the update still succeeds and no comment is lost.
|
|
fixture = (
|
|
"# header\n"
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
" epic-1-retrospective: optional\n"
|
|
"action_items:\n"
|
|
'- id: "legacy"\n'
|
|
" epic: 1\n"
|
|
' action: "written by the old code"\n'
|
|
" status: open\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(["update", "--file", str(target), "--epic", "1", "--set-retro-done"])
|
|
assert proc.returncode == 0, proc.stderr
|
|
raw = target.read_text(encoding="utf-8")
|
|
assert ' - id: "legacy"' in raw
|
|
assert ' action: "written by the old code"' in raw
|
|
assert "# header" in raw
|
|
|
|
|
|
def test_lost_comment_fails_with_restore(tmp_path):
|
|
# A standalone comment inside a flow collection is genuinely dropped by the
|
|
# round-trip. The leading-block check never saw it; the full multiset does,
|
|
# and the original bytes must come back.
|
|
fixture = (
|
|
"# header\n"
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
" epic-1-retrospective: optional\n"
|
|
"tags: [\n"
|
|
" # a standalone comment the round-trip drops\n"
|
|
' "alpha",\n'
|
|
' "beta",\n'
|
|
"]\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--set-retro-done"]
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "comment line lost" in out["error"]
|
|
assert "a standalone comment the round-trip drops" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == fixture
|
|
|
|
|
|
# --- Malformed input stays on the JSON contract ------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("command", ["detect-epic", "update"])
|
|
def test_non_mapping_root_is_json_error(tmp_path, command):
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text("- a\n- b\n", encoding="utf-8")
|
|
args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else [])
|
|
proc = _run([command, *args])
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert "root document is not a mapping" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == "- a\n- b\n"
|
|
|
|
|
|
@pytest.mark.parametrize("command", ["detect-epic", "update"])
|
|
@pytest.mark.parametrize(
|
|
"body",
|
|
['development_status: "not-a-mapping"\n', "development_status:\n - a\n - b\n"],
|
|
ids=["scalar", "list"],
|
|
)
|
|
def test_non_mapping_development_status_is_json_error(tmp_path, command, body):
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(body, encoding="utf-8")
|
|
args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else [])
|
|
proc = _run([command, *args])
|
|
# update used to report ok:true here while silently doing nothing.
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert "development_status is not a mapping" in out["error"]
|
|
|
|
|
|
@pytest.mark.parametrize("command", ["detect-epic", "update"])
|
|
def test_directory_target_is_json_error(tmp_path, command):
|
|
target = tmp_path / "a-directory"
|
|
target.mkdir()
|
|
args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else [])
|
|
proc = _run([command, *args])
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["error"]
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
hasattr(os, "geteuid") and os.geteuid() == 0,
|
|
reason="root bypasses file permission bits",
|
|
)
|
|
@pytest.mark.parametrize("command", ["detect-epic", "update"])
|
|
def test_unreadable_target_is_json_error(tmp_path, command):
|
|
# The other half of the OSError widening: PermissionError, not just
|
|
# IsADirectoryError, has to stay on the JSON contract.
|
|
target = _write_fixture(tmp_path)
|
|
os.chmod(target, 0o000)
|
|
args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else [])
|
|
try:
|
|
proc = _run([command, *args])
|
|
finally:
|
|
os.chmod(target, 0o644)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert "denied" in out["error"].lower()
|
|
|
|
|
|
@pytest.mark.parametrize("command", ["detect-epic", "update"])
|
|
def test_invalid_utf8_is_json_error(tmp_path, command):
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_bytes(b"development_status:\n 1-1-a: d\xffone\n")
|
|
args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else [])
|
|
proc = _run([command, *args])
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert "utf-8" in out["error"].lower()
|
|
|
|
|
|
# --- Atomic write ------------------------------------------------------------
|
|
|
|
|
|
def test_atomic_write_failure_leaves_target_byte_identical(tmp_path, monkeypatch):
|
|
# The failure the atomic write exists for: something goes wrong after the
|
|
# temp file has been written. Nothing may reach the target and no temp file
|
|
# may survive. No CLI path reaches here -- a read-only directory fails at
|
|
# mkstemp instead -- so this drives the helper directly.
|
|
mod = _module()
|
|
target = _write_fixture(tmp_path)
|
|
|
|
def boom(*args, **kwargs):
|
|
raise OSError(28, "No space left on device")
|
|
|
|
monkeypatch.setattr(mod.os, "replace", boom)
|
|
with pytest.raises(OSError):
|
|
mod._atomic_write(str(target), b"replacement bytes\n", 0o644)
|
|
assert target.read_text(encoding="utf-8") == FIXTURE
|
|
assert [p.name for p in tmp_path.iterdir()] == ["sprint-status.yaml"]
|
|
|
|
|
|
def test_dir_fsync_failure_after_rename_is_not_a_write_failure(tmp_path, monkeypatch):
|
|
# Once os.replace has returned, the new bytes ARE the file. The directory
|
|
# fsync that follows is durability polish; if it raised, cmd_update would
|
|
# emit "restored": true about a write that in fact landed — the one lie the
|
|
# restored contract exists to prevent. Deny opening the directory (the only
|
|
# thing _atomic_write opens by path after the rename) and require success.
|
|
mod = _module()
|
|
target = _write_fixture(tmp_path)
|
|
directory = os.path.dirname(os.path.realpath(str(target)))
|
|
real_open = os.open
|
|
|
|
def deny_directory_open(p, *args, **kwargs):
|
|
if p == directory:
|
|
raise OSError(5, "Input/output error")
|
|
return real_open(p, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(mod.os, "open", deny_directory_open)
|
|
mod._atomic_write(str(target), b"replacement bytes\n", 0o644)
|
|
assert target.read_bytes() == b"replacement bytes\n"
|
|
assert [p.name for p in tmp_path.iterdir()] == ["sprint-status.yaml"]
|
|
|
|
|
|
def test_restore_is_atomic(tmp_path, monkeypatch):
|
|
# _restore is the rollback the reference sells as the safety net. A
|
|
# truncating rewrite that dies halfway would destroy the very bytes it is
|
|
# putting back, which is how a full disk used to corrupt the file.
|
|
mod = _module()
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text("damaged\n", encoding="utf-8")
|
|
|
|
def boom(*args, **kwargs):
|
|
raise OSError(28, "No space left on device")
|
|
|
|
monkeypatch.setattr(mod.os, "replace", boom)
|
|
assert mod._restore(str(target), FIXTURE.encode("utf-8"), 0o644) is False
|
|
# It reported failure honestly and left the file no worse than it found it.
|
|
assert target.read_text(encoding="utf-8") == "damaged\n"
|
|
assert [p.name for p in tmp_path.iterdir()] == ["sprint-status.yaml"]
|
|
|
|
|
|
def test_symlinked_target_is_written_through(tmp_path):
|
|
# os.replace onto a symlink would detach the link and leave the real file
|
|
# stale while reporting ok:true.
|
|
real = tmp_path / "real-sprint-status.yaml"
|
|
real.write_text(FIXTURE, encoding="utf-8")
|
|
link = tmp_path / "sprint-status.yaml"
|
|
link.symlink_to(real)
|
|
proc = _run(["update", "--file", str(link), "--epic", "1", "--set-retro-done"])
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert link.is_symlink(), "the symlink was replaced by a regular file"
|
|
assert _load(real)["development_status"]["epic-1-retrospective"] == "done"
|
|
|
|
|
|
def test_atomic_write_preserves_mode_and_leaves_no_temp_file(tmp_path):
|
|
# mkstemp creates 0600; without carrying the target's mode over, every
|
|
# update would silently narrow the file.
|
|
holder = tmp_path / "holder"
|
|
holder.mkdir()
|
|
target = _write_fixture(holder)
|
|
os.chmod(target, 0o640)
|
|
proc = _run(["update", "--file", str(target), "--epic", "1", "--set-retro-done"])
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert stat.S_IMODE(target.stat().st_mode) == 0o640
|
|
assert [p.name for p in holder.iterdir()] == ["sprint-status.yaml"]
|
|
|
|
|
|
# --- Result-JSON precision ---------------------------------------------------
|
|
|
|
|
|
def test_retro_key_found_is_null_without_the_flag(tmp_path):
|
|
# No development_status key at all: the update must not conjure one, and
|
|
# retro_key_found must say "not asked" rather than "absent".
|
|
fixture = 'project: "Demo"\nlast_updated: "01-01-2026 09:00"\n'
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(["update", "--file", str(target), "--epic", "1"])
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = _json(proc)
|
|
assert out["ok"] is True
|
|
assert out["retro_key_found"] is None
|
|
assert "development_status" not in target.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_retro_key_found_is_false_when_the_key_is_absent(tmp_path):
|
|
target = _write_fixture(tmp_path)
|
|
proc = _run(["update", "--file", str(target), "--epic", "2", "--set-retro-done"])
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = _json(proc)
|
|
assert out["ok"] is True
|
|
assert out["retro_key_found"] is False
|
|
# Nothing was written into the mapping.
|
|
assert "epic-2-retrospective" not in _load(target)["development_status"]
|
|
|
|
|
|
@pytest.mark.parametrize("command", ["detect-epic", "update"])
|
|
def test_only_the_write_path_reports_restored(tmp_path, command):
|
|
# "restored" speaks to the state of a file the command may have written.
|
|
# detect-epic never writes, so inventing the key there would mislead callers
|
|
# that branch on it.
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text("- a\n- b\n", encoding="utf-8")
|
|
args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else [])
|
|
out = _json(_run([command, *args]))
|
|
assert out["ok"] is False
|
|
assert ("restored" in out) is (command == "update")
|
|
|
|
|
|
def test_pre_write_failure_reports_restored(tmp_path):
|
|
# retro-document.md teaches callers that ok:false carries restored:true;
|
|
# a failure before the write must not read as "the file may be incomplete".
|
|
target = _write_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--add-action", "{not json"]
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert target.read_text(encoding="utf-8") == FIXTURE
|
|
|
|
|
|
# --- Action-item validation and identity -------------------------------------
|
|
|
|
|
|
def test_non_latin_action_keeps_its_text_in_the_id(tmp_path):
|
|
target = _write_fixture(tmp_path)
|
|
payload = json.dumps(
|
|
[{"action": "Улучшить обработку ошибок", "owner": "Amelia"}],
|
|
ensure_ascii=False,
|
|
)
|
|
proc = _run(["update", "--file", str(target), "--epic", "1", "--add-action", payload])
|
|
assert proc.returncode == 0, proc.stderr
|
|
item_id = _load(target)["action_items"][0]["id"]
|
|
assert item_id == "epic-1-retro-item-1-улучшить-обработку-ошибок"
|
|
|
|
|
|
def test_unsluggable_action_falls_back_to_a_hash(tmp_path):
|
|
target = _write_fixture(tmp_path)
|
|
payload = json.dumps([{"action": "!!! 🎉", "owner": "Amelia"}], ensure_ascii=False)
|
|
proc = _run(["update", "--file", str(target), "--epic", "1", "--add-action", payload])
|
|
assert proc.returncode == 0, proc.stderr
|
|
item_id = _load(target)["action_items"][0]["id"]
|
|
assert not item_id.endswith("-item")
|
|
assert re.fullmatch(r"epic-1-retro-item-1-[0-9a-f]{8}", item_id), item_id
|
|
|
|
|
|
def test_empty_action_is_rejected(tmp_path):
|
|
target = _write_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--add-action", '[{"action":" ","owner":"x"}]']
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "action" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == FIXTURE
|
|
|
|
|
|
def test_non_string_action_is_rejected(tmp_path):
|
|
# A JSON null would otherwise be str()'d into a literal "None" and written
|
|
# as a real action item, which the new emptiness check alone lets through.
|
|
target = _write_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--add-action", '[{"action":null,"owner":"x"}]']
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert target.read_text(encoding="utf-8") == FIXTURE
|
|
|
|
|
|
def test_date_is_normalized_to_the_canonical_format(tmp_path):
|
|
# strptime accepts unpadded spellings; writing those through would defeat
|
|
# the point of validating the format.
|
|
target = _write_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--date", "1-2-2026 9:05"]
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert _json(proc)["last_updated"] == "01-02-2026 09:05"
|
|
assert _load(target)["last_updated"] == "01-02-2026 09:05"
|
|
|
|
|
|
def test_malformed_date_is_rejected(tmp_path):
|
|
target = _write_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--date", "not-a-date"]
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "--date" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == FIXTURE
|
|
|
|
|
|
# --- Action-item status transitions ------------------------------------------
|
|
|
|
|
|
def test_set_action_status_applies_both_selector_forms(tmp_path):
|
|
# The whole point of the flag: an item written by this script (selected by
|
|
# id) and a legacy item that predates ids (selected by epic + exact action
|
|
# text) both move off "open" in a single call.
|
|
target = _write_action_fixture(tmp_path)
|
|
payload = json.dumps(
|
|
[
|
|
{"id": "epic-1-retro-item-1-x", "status": "done"},
|
|
{"epic": 1, "action": "Pre-existing item", "status": "in-progress"},
|
|
]
|
|
)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--set-action-status", payload]
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = _json(proc)
|
|
assert out["ok"] is True
|
|
assert out["action_items_updated"] == 2
|
|
assert out["action_items_added"] == 0
|
|
|
|
items = _load(target)["action_items"]
|
|
assert items[0]["status"] == "done"
|
|
assert items[1]["status"] == "in-progress"
|
|
# Every other key of both items survived untouched, in place.
|
|
assert items[0]["id"] == "epic-1-retro-item-1-x"
|
|
assert items[0]["epic"] == 1
|
|
assert items[0]["action"] == "Scripted item"
|
|
assert items[0]["owner"] == "Amelia"
|
|
assert items[0]["ref"] == "docs/epic-1-retro.md"
|
|
assert "id" not in items[1]
|
|
assert items[1]["epic"] == 1
|
|
assert items[1]["action"] == "Pre-existing item"
|
|
assert items[1]["owner"] == "Charlie"
|
|
# The epic-2 item shares its action text with items[0]; the epic-1 selector
|
|
# must not have touched it.
|
|
assert items[2]["status"] == "in-progress"
|
|
assert items[2]["id"] == "epic-2-retro-item-1-y"
|
|
assert len(items) == 3
|
|
|
|
|
|
def test_set_action_status_changes_exactly_one_line(tmp_path):
|
|
# The status write is surgical: it must not re-style neighbouring lines, and
|
|
# an item whose status was quoted keeps its quoting. last_updated is rewritten
|
|
# with the same text it already held, so the whole file differs by one line.
|
|
target = _write_action_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--date", "01-01-2026 09:00",
|
|
"--set-action-status", '[{"id":"epic-1-retro-item-1-x","status":"done"}]']
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
|
|
before = ACTION_FIXTURE.splitlines()
|
|
after = target.read_text(encoding="utf-8").splitlines()
|
|
assert len(before) == len(after)
|
|
changed = [(b, a) for b, a in zip(before, after) if b != a]
|
|
assert changed == [(' status: "open"', ' status: "done"')], changed
|
|
|
|
|
|
def test_set_action_status_composes_with_retro_done_and_add_action(tmp_path):
|
|
target = _write_action_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--set-retro-done",
|
|
"--add-action", '[{"action":"Brand new item","owner":"Amelia"}]',
|
|
"--set-action-status",
|
|
'[{"epic":1,"action":"Pre-existing item","status":"done"}]']
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = _json(proc)
|
|
assert out["ok"] is True
|
|
assert out["retro_status_after"] == "done"
|
|
assert out["action_items_added"] == 1
|
|
assert out["action_items_updated"] == 1
|
|
|
|
data = _load(target)
|
|
assert data["development_status"]["epic-1-retrospective"] == "done"
|
|
items = data["action_items"]
|
|
assert len(items) == 4
|
|
assert items[1]["status"] == "done" # the targeted pre-existing item
|
|
assert items[3]["action"] == "Brand new item"
|
|
assert items[3]["status"] == "open" # the appended item is always open
|
|
|
|
|
|
def test_set_action_status_cannot_target_an_item_added_in_the_same_run(tmp_path):
|
|
# Selectors resolve against action_items as loaded, so the append cannot be
|
|
# observed by the same invocation. Silently succeeding here would make the
|
|
# flag a back door for writing a non-open status onto a brand-new item.
|
|
target = _write_action_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--add-action", '[{"action":"Brand new","owner":"A","id":"brand-new"}]',
|
|
"--set-action-status", '[{"id":"brand-new","status":"done"}]']
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "no action item matches" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
|
|
|
|
|
|
def test_set_action_status_rejects_unknown_id(tmp_path):
|
|
target = _write_action_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--set-action-status", '[{"id":"not-in-the-file","status":"done"}]']
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "not-in-the-file" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
|
|
|
|
|
|
def test_set_action_status_rejects_selector_when_action_items_is_absent(tmp_path):
|
|
# No action_items key at all must read as "no match", not as a crash.
|
|
target = _write_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--set-action-status", '[{"id":"anything","status":"done"}]']
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "no action item matches" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == FIXTURE
|
|
|
|
|
|
def test_set_action_status_rejects_ambiguous_selector(tmp_path):
|
|
# Two legacy items with identical epic + action text: guessing between them
|
|
# would write the wrong row half the time.
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
" epic-1-retrospective: optional\n"
|
|
"action_items:\n"
|
|
" - epic: 1\n"
|
|
' action: "Same text"\n'
|
|
' owner: "Charlie"\n'
|
|
" status: open\n"
|
|
" - epic: 1\n"
|
|
' action: "Same text"\n'
|
|
' owner: "Dana"\n'
|
|
" status: open\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--set-action-status", '[{"epic":1,"action":"Same text","status":"done"}]']
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "ambiguous" in out["error"]
|
|
assert "Same text" in out["error"]
|
|
assert "2 matches" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == fixture
|
|
|
|
|
|
def test_set_action_status_rejects_two_entries_hitting_the_same_item(tmp_path):
|
|
# The id form and the epic/action form can name the same row; applying both
|
|
# would overcount action_items_updated and hide a conflicting pair.
|
|
target = _write_action_fixture(tmp_path)
|
|
payload = json.dumps(
|
|
[
|
|
{"id": "epic-1-retro-item-1-x", "status": "done"},
|
|
{"epic": 1, "action": "Scripted item", "status": "in-progress"},
|
|
]
|
|
)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--set-action-status", payload]
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "same action item" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
|
|
|
|
|
|
def test_entry_with_both_selector_forms_uses_the_id(tmp_path):
|
|
# A caller that copied a whole item through supplies both. The id is the
|
|
# precise form and wins; the extra keys are ignored, not rejected. The two
|
|
# forms are pointed at *different* rows so the precedence is observable:
|
|
# the id names items[0], the epic/action pair names items[1].
|
|
target = _write_action_fixture(tmp_path)
|
|
payload = json.dumps(
|
|
[
|
|
{
|
|
"id": "epic-1-retro-item-1-x",
|
|
"epic": 1,
|
|
"action": "Pre-existing item",
|
|
"owner": "Charlie",
|
|
"status": "done",
|
|
}
|
|
]
|
|
)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--set-action-status", payload]
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert _json(proc)["action_items_updated"] == 1
|
|
items = _load(target)["action_items"]
|
|
assert items[0]["status"] == "done" # the id's item
|
|
assert items[1]["status"] == "open" # the epic/action item, untouched
|
|
|
|
|
|
def test_set_action_status_rejects_invalid_status(tmp_path):
|
|
target = _write_action_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--set-action-status", '[{"id":"epic-1-retro-item-1-x","status":"closed"}]']
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "closed" in out["error"]
|
|
# The allowed vocabulary is named so the caller can correct the call.
|
|
assert "open, in-progress, done" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
|
|
|
|
|
|
def test_set_action_status_rejects_malformed_json(tmp_path):
|
|
target = _write_action_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--set-action-status", "{not json"]
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "invalid --set-action-status JSON" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
|
|
|
|
|
|
_SELECTOR_SHAPE_ERROR = (
|
|
"each --set-action-status entry must have a non-empty string id, "
|
|
"or an integer epic and a non-empty string action"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("payload", "expected_error"),
|
|
[
|
|
(
|
|
'{"id":"epic-1-retro-item-1-x","status":"done"}',
|
|
"--set-action-status must be a JSON array",
|
|
),
|
|
(
|
|
'["epic-1-retro-item-1-x"]',
|
|
"each --set-action-status entry must be an object",
|
|
),
|
|
('[{"status":"done"}]', _SELECTOR_SHAPE_ERROR),
|
|
(
|
|
'[{"id":"","status":"done"}]',
|
|
"each --set-action-status id must be a non-empty string",
|
|
),
|
|
(
|
|
'[{"id":42,"status":"done"}]',
|
|
"each --set-action-status id must be a non-empty string",
|
|
),
|
|
(
|
|
'[{"epic":"1","action":"Pre-existing item","status":"done"}]',
|
|
_SELECTOR_SHAPE_ERROR,
|
|
),
|
|
(
|
|
'[{"epic":true,"action":"Pre-existing item","status":"done"}]',
|
|
_SELECTOR_SHAPE_ERROR,
|
|
),
|
|
('[{"epic":1,"action":" ","status":"done"}]', _SELECTOR_SHAPE_ERROR),
|
|
(
|
|
# No status key at all: the status validator runs before the selector
|
|
# validator, so this is the status branch, not the selector branch.
|
|
'[{"epic":1,"action":"Pre-existing item"}]',
|
|
"invalid --set-action-status status None",
|
|
),
|
|
(
|
|
'[{"id":"epic-1-retro-item-1-x","status":3}]',
|
|
"invalid --set-action-status status 3",
|
|
),
|
|
],
|
|
ids=[
|
|
"not-a-list",
|
|
"entry-not-an-object",
|
|
"no-selector",
|
|
"empty-id",
|
|
"non-string-id",
|
|
"string-epic",
|
|
"bool-epic",
|
|
"blank-action",
|
|
"no-status-key",
|
|
"non-string-status",
|
|
],
|
|
)
|
|
def test_set_action_status_rejects_bad_shapes(tmp_path, payload, expected_error):
|
|
# Each case pins its own message: collapsing the branches into one generic
|
|
# error would leave a caller unable to tell which part of the array is wrong.
|
|
target = _write_action_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--set-action-status", payload]
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert expected_error in out["error"], out["error"]
|
|
assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
|
|
|
|
|
|
def test_selectors_are_not_scoped_to_the_epic_flag(tmp_path):
|
|
# The flag's headline use: epic 2's retro closing epic 1's items. --epic only
|
|
# names the retro key and stamps appended items; scoping selectors to it would
|
|
# silently break the documented cross-epic workflow while every same-epic test
|
|
# kept passing.
|
|
target = _write_action_fixture(tmp_path)
|
|
payload = json.dumps(
|
|
[
|
|
{"id": "epic-1-retro-item-1-x", "status": "done"},
|
|
{"epic": 1, "action": "Pre-existing item", "status": "done"},
|
|
]
|
|
)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "2", "--set-retro-done",
|
|
"--set-action-status", payload]
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert _json(proc)["action_items_updated"] == 2
|
|
|
|
data = _load(target)
|
|
assert data["development_status"]["epic-2-retrospective"] == "done"
|
|
items = data["action_items"]
|
|
assert items[0]["status"] == "done"
|
|
assert items[1]["status"] == "done"
|
|
# Epic 2's own item is not swept along.
|
|
assert items[2]["status"] == "in-progress"
|
|
|
|
|
|
def test_legacy_selector_discriminates_on_the_epic(tmp_path):
|
|
# Two items share the action text "Scripted item" and differ only by epic, so
|
|
# an epic-blind text match would be ambiguous -- or worse, silently pick one.
|
|
target = _write_action_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--set-action-status",
|
|
'[{"epic":2,"action":"Scripted item","status":"done"}]']
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert _json(proc)["action_items_updated"] == 1
|
|
items = _load(target)["action_items"]
|
|
assert items[2]["status"] == "done"
|
|
assert items[0]["status"] == "open" # the epic-1 namesake, untouched
|
|
|
|
|
|
def test_non_mapping_action_item_does_not_crash_the_selector(tmp_path):
|
|
# A hand-edited scalar in the action_items list must be skipped, not
|
|
# AttributeError'd into an empty stdout with a traceback.
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
" epic-1-retrospective: optional\n"
|
|
"action_items:\n"
|
|
' - "a bare string someone hand-edited in"\n'
|
|
' - id: "real"\n'
|
|
" epic: 1\n"
|
|
' action: "Real item"\n'
|
|
" status: open\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--set-action-status", '[{"id":"real","status":"done"}]']
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert _json(proc)["action_items_updated"] == 1
|
|
items = _load(target)["action_items"]
|
|
assert items[0] == "a bare string someone hand-edited in"
|
|
assert items[1]["status"] == "done"
|
|
|
|
|
|
def test_non_mapping_action_item_stays_on_the_json_contract_when_unmatched(tmp_path):
|
|
# Same guard, reject path: the scalar must not be dereferenced while looking
|
|
# for a selector that is not there.
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
"action_items:\n"
|
|
" - 42\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1",
|
|
"--set-action-status", '[{"epic":1,"action":"Nothing","status":"done"}]']
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc) # asserts stdout is JSON and stderr carries no traceback
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "no action item matches" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == fixture
|
|
|
|
|
|
def test_boolean_epic_in_the_file_does_not_match_epic_one(tmp_path):
|
|
# True == 1 in Python, so without the file-side bool guard a hand-edited
|
|
# "epic: true" row would be silently rewritten by a selector aimed at epic 1.
|
|
fixture = (
|
|
"development_status:\n"
|
|
" 1-1-a: done\n"
|
|
"action_items:\n"
|
|
" - epic: true\n"
|
|
' action: "Boolean epic"\n'
|
|
" status: open\n"
|
|
)
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(fixture, encoding="utf-8")
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--set-action-status",
|
|
'[{"epic":1,"action":"Boolean epic","status":"done"}]']
|
|
)
|
|
assert proc.returncode == 1
|
|
out = _json(proc)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "no action item matches" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == fixture
|
|
|
|
|
|
def test_status_write_preserves_every_scalar_style(tmp_path):
|
|
# The status write must land on the line without re-styling it, whichever way
|
|
# the file spells the scalar.
|
|
target = tmp_path / "sprint-status.yaml"
|
|
target.write_text(STYLE_FIXTURE, encoding="utf-8")
|
|
payload = json.dumps(
|
|
[
|
|
{"id": "double", "status": "done"},
|
|
{"id": "single", "status": "done"},
|
|
{"id": "plain", "status": "done"},
|
|
]
|
|
)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--date",
|
|
"01-01-2026 09:00", "--set-action-status", payload]
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert _json(proc)["action_items_updated"] == 3
|
|
|
|
before = STYLE_FIXTURE.splitlines()
|
|
after = target.read_text(encoding="utf-8").splitlines()
|
|
assert len(before) == len(after)
|
|
changed = [(b, a) for b, a in zip(before, after) if b != a]
|
|
assert changed == [
|
|
(' status: "open"', ' status: "done"'),
|
|
(" status: 'open'", " status: 'done'"),
|
|
(" status: open", " status: done"),
|
|
], changed
|
|
|
|
|
|
def test_action_status_vocabulary_is_exactly_the_three():
|
|
# bmad-sprint-planning is the authority. Widening this tuple would let the
|
|
# script write a value sprint-planning's status view reports as illegal.
|
|
assert _module().ACTION_STATUSES == ("open", "in-progress", "done")
|
|
|
|
|
|
def test_empty_status_array_is_a_no_op(tmp_path):
|
|
target = _write_action_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "1", "--date",
|
|
"01-01-2026 09:00", "--set-action-status", "[]"]
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert _json(proc)["action_items_updated"] == 0
|
|
assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
|
|
|
|
|
|
def test_in_progress_item_transitions_to_done(tmp_path):
|
|
# Every other success case starts from "open"; the middle of the lifecycle
|
|
# has to work too.
|
|
target = _write_action_fixture(tmp_path)
|
|
proc = _run(
|
|
["update", "--file", str(target), "--epic", "2", "--set-action-status",
|
|
'[{"id":"epic-2-retro-item-1-y","status":"done"}]']
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert _json(proc)["action_items_updated"] == 1
|
|
assert _load(target)["action_items"][2]["status"] == "done"
|
|
|
|
|
|
def test_action_items_updated_is_always_reported(tmp_path):
|
|
# Consumers read the counter unconditionally, so it must be present even when
|
|
# the flag was not passed.
|
|
target = _write_fixture(tmp_path)
|
|
proc = _run(["update", "--file", str(target), "--epic", "1", "--set-retro-done"])
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert _json(proc)["action_items_updated"] == 0
|
|
|
|
|
|
def test_post_write_status_mismatch_restores(tmp_path, monkeypatch, capsys):
|
|
# The last line of defence: the written file is re-parsed and every targeted
|
|
# item is checked. No CLI path can fake a mismatch, so the re-parse is
|
|
# doctored directly.
|
|
mod = _module()
|
|
target = _write_action_fixture(tmp_path)
|
|
real_load_yaml = mod._load_yaml
|
|
calls = {"n": 0}
|
|
|
|
def flaky(path):
|
|
yaml, data = real_load_yaml(path)
|
|
calls["n"] += 1
|
|
if calls["n"] == 2: # the post-write re-parse
|
|
data["action_items"][0]["status"] = "open"
|
|
return yaml, data
|
|
|
|
monkeypatch.setattr(mod, "_load_yaml", flaky)
|
|
args = mod.build_parser().parse_args(
|
|
["update", "--file", str(target), "--epic", "1", "--date", "01-01-2026 09:00",
|
|
"--set-action-status", '[{"id":"epic-1-retro-item-1-x","status":"done"}]']
|
|
)
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
mod.cmd_update(args)
|
|
assert excinfo.value.code == 1
|
|
out = json.loads(capsys.readouterr().out)
|
|
assert out["ok"] is False
|
|
assert out["restored"] is True
|
|
assert "after write" in out["error"]
|
|
assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main([__file__, "-q"]))
|