Adds basic task management tools.
This commit is contained in:
Binary file not shown.
@@ -21,7 +21,7 @@ def tracker(prefix=None, epics=True, number=1):
|
||||
label: "Team's Release"
|
||||
description: 'Ship the product.'
|
||||
""" if epics else ""
|
||||
anchor_prefix = prefix.lower() if prefix else "ot"
|
||||
anchor_prefix = prefix.lower() if prefix else "ex"
|
||||
return f"""---
|
||||
{prefix_line}projects:
|
||||
- value: 'shared'
|
||||
@@ -101,7 +101,7 @@ class TaskScriptsTest(unittest.TestCase):
|
||||
|
||||
def test_epic_add_update_clear_and_preserve(self):
|
||||
self.run_cli("add-task", "New task", "--epic", "Proof of Concept", "--subproject", "engine")
|
||||
task = self.get_task("OT-002")
|
||||
task = self.get_task("EX-002")
|
||||
self.assertEqual((task["epic"], task["epic_label"]), ("poc", "Proof of Concept"))
|
||||
self.assertEqual(task["subproject"], "engine")
|
||||
self.assertIn('data-epic="poc"', task["raw_markdown"])
|
||||
@@ -181,7 +181,7 @@ class TaskScriptsTest(unittest.TestCase):
|
||||
def test_default_prefix_without_epics_and_environment_file(self):
|
||||
self.path.write_text(tracker(epics=False))
|
||||
self.run_cli("update-tasks")
|
||||
self.assertEqual(self.get_task()["id"], "OT-001")
|
||||
self.assertEqual(self.get_task()["id"], "EX-001")
|
||||
self.assertEqual(self.get_task()["epic"], "")
|
||||
self.assertNotIn("| Epic", self.path.read_text())
|
||||
env = {**self.env, "TASKS_FILE": str(self.path)}
|
||||
@@ -190,7 +190,7 @@ class TaskScriptsTest(unittest.TestCase):
|
||||
env=env, text=True, capture_output=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), "OT-001")
|
||||
self.assertEqual(result.stdout.strip(), "EX-001")
|
||||
|
||||
def test_invalid_prefix_and_duplicate_numbers(self):
|
||||
for prefix in ("", "1BAD", "BAD SPACE", "BAD/"):
|
||||
@@ -216,7 +216,7 @@ class TaskScriptsTest(unittest.TestCase):
|
||||
self.assertNotIn("<<<<<<<", updated)
|
||||
self.assertNotIn("=======", updated)
|
||||
self.assertNotIn(">>>>>>>", updated)
|
||||
self.assertIn("OT-001", updated)
|
||||
self.assertIn("EX-001", updated)
|
||||
self.assertIn("Original", updated)
|
||||
|
||||
|
||||
@@ -226,30 +226,30 @@ class TaskScriptsTest(unittest.TestCase):
|
||||
env = {**self.env, "PATH": "/usr/bin:/bin"}
|
||||
output = self.run_cli("get-tasks", "--all", "--sort", "asc", env=env).stdout
|
||||
self.assertIn("Epic", output)
|
||||
self.assertLess(output.index("OT-001"), output.index("OT-002"))
|
||||
self.assertLess(output.index("EX-001"), output.index("EX-002"))
|
||||
output = self.run_cli(
|
||||
"get-tasks", "--all", "--accept", "Epic:Team's Release", "--select", "ID,Epic", env=env,
|
||||
).stdout
|
||||
self.assertIn("OT-001", output)
|
||||
self.assertNotIn("OT-002", output)
|
||||
self.assertIn("EX-001", output)
|
||||
self.assertNotIn("EX-002", output)
|
||||
self.assertNotIn("Title", output)
|
||||
output = self.run_cli("get-tasks", "--all", "--reject", "Epic:Proof of Concept", env=env).stdout
|
||||
self.assertNotIn("OT-002", output)
|
||||
self.assertIn("OT-001", output)
|
||||
self.assertNotIn("EX-002", output)
|
||||
self.assertIn("EX-001", output)
|
||||
for value in ("poc", "POC", "Proof of Concept"):
|
||||
with self.subTest(value=value):
|
||||
output = self.run_cli("get-tasks", "--accept", f"Epic:{value}", env=env).stdout
|
||||
self.assertIn("OT-002", output)
|
||||
self.assertNotIn("OT-001", output)
|
||||
self.assertIn("EX-002", output)
|
||||
self.assertNotIn("EX-001", output)
|
||||
output = self.run_cli("get-tasks", "--all", "--reject", "Epic:poc", env=env).stdout
|
||||
self.assertNotIn("OT-002", output)
|
||||
self.assertIn("OT-001", output)
|
||||
self.assertNotIn("EX-002", output)
|
||||
self.assertIn("EX-001", output)
|
||||
output = self.run_cli(
|
||||
"get-tasks", "--accept", "Epic:poc,release", "--accept", "Status:Triage", env=env,
|
||||
).stdout
|
||||
self.assertIn("OT-002", output)
|
||||
self.assertNotIn("OT-001", output)
|
||||
self.assertNotIn("OT-001", self.run_cli("get-tasks", "--accept", "Epic:missing", env=env).stdout)
|
||||
self.assertIn("EX-002", output)
|
||||
self.assertNotIn("EX-001", output)
|
||||
self.assertNotIn("EX-001", self.run_cli("get-tasks", "--accept", "Epic:missing", env=env).stdout)
|
||||
|
||||
def test_listing_external_filter_table_selects_epic(self):
|
||||
self.run_cli("update-tasks")
|
||||
@@ -291,6 +291,117 @@ class TaskScriptsTest(unittest.TestCase):
|
||||
self.assertIn("- Epic: Proof of Concept (poc)", prompt)
|
||||
self.assertIn("--epic=poc", prompt)
|
||||
|
||||
def test_ready_task_can_be_started(self):
|
||||
self.run_cli("update-task", "1", "--status", "ready")
|
||||
self.run_cli("work-on-task", "1", "--start")
|
||||
self.assertEqual(self.get_task()["status"], "in_progress")
|
||||
self.run_cli("work-on-task", "1")
|
||||
|
||||
def test_hyphenated_in_progress_is_already_started(self):
|
||||
self.run_cli("update-task", "1", "--status", "in-progress")
|
||||
before = self.path.read_text()
|
||||
self.run_cli("work-on-task", "1", "--start")
|
||||
self.assertEqual(before, self.path.read_text())
|
||||
|
||||
def test_repository_sample_and_config_are_consistent(self):
|
||||
config = json.loads((SCRIPTS.parent / "project-config.json").read_text())
|
||||
sample = SCRIPTS.parent / "docs" / "tasks.md"
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPTS / "get-task"), "1", "-f", str(sample), "--json"],
|
||||
env=self.env, text=True, capture_output=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
task = json.loads(result.stdout)
|
||||
self.assertIsInstance(config["name"], str)
|
||||
sys.path.insert(0, str(SCRIPTS / "lib"))
|
||||
try:
|
||||
import task_context
|
||||
update = task_context.load_get_task_module().load_update_tasks_module()
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
frontmatter = update.read_frontmatter(sample.read_text())
|
||||
project = next(item for item in frontmatter["projects"] if item["value"] == task["project"])
|
||||
self.assertIn(f"**Project:** {project['label']}", task["raw_markdown"])
|
||||
self.assertEqual(task["id"].rsplit("-", 1)[0], update.get_task_id_prefix(frontmatter))
|
||||
copy = Path(self.temp.name) / "sample.md"
|
||||
copy.write_text(sample.read_text())
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPTS / "update-tasks"), "-f", str(copy)],
|
||||
env=self.env, text=True, capture_output=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(sample.read_text(), copy.read_text())
|
||||
|
||||
def test_environment_preserves_external_tracker(self):
|
||||
env = {**self.env, "TASKS_FILE": str(self.path)}
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'source "$1" >/dev/null; printf "%s\\n" "$TASKS_FILE"; command -v get-task',
|
||||
"bash", str(SCRIPTS / "env.sh")],
|
||||
env=env, text=True, capture_output=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.splitlines(), [str(self.path), str(SCRIPTS / "get-task")])
|
||||
|
||||
|
||||
class ProjectConfigTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
sys.path.insert(0, str(SCRIPTS / "lib"))
|
||||
try:
|
||||
import task_context
|
||||
self.context = task_context
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.path = Path(self.temp.name) / "project-config.json"
|
||||
self.config = {
|
||||
"name": "The Lighthouse Novel",
|
||||
"description": "A novel about a coastal community.",
|
||||
"commands": {},
|
||||
}
|
||||
self.path.write_text(json.dumps(self.config))
|
||||
|
||||
def test_nonsoftware_configuration_in_prompt(self):
|
||||
task = {
|
||||
"id": "BOOK-001", "title": "Outline chapter one", "project": "manuscript",
|
||||
"status": "triage", "type": "task", "description": "Review the narrative arc.",
|
||||
}
|
||||
with patch.object(self.context, "load_project_config", return_value=self.context.load_project_config(self.path)):
|
||||
prompt = self.context.build_workflow_prompt(task, "triaging", "Outline the chapter")
|
||||
self.assertIn("for The Lighthouse Novel.", prompt)
|
||||
self.assertIn(self.config["description"], prompt)
|
||||
self.assertIn("No commands configured", prompt)
|
||||
self.assertIn("Do not assume this is software", prompt)
|
||||
self.assertIn("AGENTS.md", prompt)
|
||||
|
||||
def test_arbitrary_named_commands_are_guidance(self):
|
||||
self.config["commands"] = {"word-count": "wc -w manuscript.txt"}
|
||||
self.path.write_text(json.dumps(self.config))
|
||||
config = self.context.load_project_config(self.path)
|
||||
task = {"id": "1", "title": "Review", "project": "manuscript", "status": "triage", "type": "task"}
|
||||
with patch.object(self.context, "load_project_config", return_value=config):
|
||||
prompt = self.context.build_workflow_prompt(task, "reviewing", "Review")
|
||||
self.assertIn("- word-count: `wc -w manuscript.txt`", prompt)
|
||||
self.assertIn("not automatically executed", prompt)
|
||||
|
||||
def test_invalid_configuration_is_reported(self):
|
||||
invalid_configs = (
|
||||
[], {}, {**self.config, "name": ""}, {**self.config, "description": None},
|
||||
{**self.config, "commands": []}, {**self.config, "commands": {"test": None}},
|
||||
{**self.config, "commands": {"": "echo ok"}},
|
||||
{**self.config, "commands": {"review": " "}},
|
||||
)
|
||||
for config in invalid_configs:
|
||||
with self.subTest(config=config):
|
||||
self.path.write_text(json.dumps(config))
|
||||
with self.assertRaisesRegex(ValueError, "Project configuration"):
|
||||
self.context.load_project_config(self.path)
|
||||
self.path.write_text("{")
|
||||
with self.assertRaisesRegex(ValueError, "Cannot read project configuration"):
|
||||
self.context.load_project_config(self.path)
|
||||
with self.assertRaisesRegex(ValueError, "Cannot read project configuration"):
|
||||
self.context.load_project_config(self.path.parent / "missing.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user