From 81fd7d56e5548999131605bb74ca7fd32508a4d1 Mon Sep 17 00:00:00 2001 From: shane Date: Thu, 9 Apr 2026 16:33:24 +0900 Subject: [PATCH 1/7] feat: add post-develop verification before label transition Gate the transition to action:review on passing project verification (tests, lint, type check). Detection-based approach tries common project commands from pyproject.toml, package.json, or Makefile. If no commands are detected, verification is skipped (don't block). Closes #69 --- askcc/cli.py | 13 +++-- askcc/functions.py | 112 ++++++++++++++++++++++++++++++++++++++++ tests/test_askcc.py | 123 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 3 deletions(-) diff --git a/askcc/cli.py b/askcc/cli.py index f6a2d94..47239a4 100644 --- a/askcc/cli.py +++ b/askcc/cli.py @@ -11,6 +11,7 @@ from .functions import ( CheckResult, _parse_issue_url, + _run_project_verification, append_usage_to_last_comment, bootstrap_templates, fetch_github_issue, @@ -201,8 +202,7 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901 sys.exit(1) issue_url = pr_result.stdout.strip() ci_context = ( - f"Auto-detected PR: {issue_url}\n\n" - "No linked issue provided. Use the PR URL above to find CI failures." + f"Auto-detected PR: {issue_url}\n\nNo linked issue provided. Use the PR URL above to find CI failures." ) with tempfile.NamedTemporaryFile(mode="w", prefix="askcc_fix-ci_", suffix=".md", delete=False) as f: f.write(ci_context) @@ -264,7 +264,14 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901 transition_issue_to_planning(issue_url) if action == AgentAction.DEVELOP and return_code == 0: - transition_issue_to_review(issue_url) + verify_result = _run_project_verification(cwd) + if verify_result.passed: + transition_issue_to_review(issue_url) + else: + logger.warning("Post-develop verification failed: %s", verify_result.message) + for check in verify_result.checks: + status = "PASS" if check.passed else "FAIL" + logger.warning(" [%s] %s: %s", status, check.name, check.message) sys.exit(return_code) diff --git a/askcc/functions.py b/askcc/functions.py index dc6d17c..82a5740 100644 --- a/askcc/functions.py +++ b/askcc/functions.py @@ -208,6 +208,118 @@ class CheckResult: message: str +@dataclass(frozen=True) +class VerificationResult: + passed: bool + message: str + checks: list[CheckResult] + + +VERIFICATION_TIMEOUT = 300 # 5 minutes per command + + +_POE_TASK_CHECKS: tuple[tuple[str, str, list[str]], ...] = ( + ("test", "tests", ["uv", "run", "poe", "test"]), + ("check", "lint", ["uv", "run", "poe", "check"]), + ("typecheck", "typecheck", ["uv", "run", "poe", "typecheck"]), +) + +_DIRECT_TOOL_CHECKS: tuple[tuple[str, str, list[str]], ...] = ( + ("pytest", "tests", ["uv", "run", "pytest"]), + ("ruff", "lint", ["uv", "run", "ruff", "check"]), + ("pyright", "typecheck", ["uv", "run", "pyright"]), +) + + +def _has_poe_task(content: str, task_name: str) -> bool: + """Check if a poe task is defined in pyproject.toml content.""" + if f"[tool.poe.tasks.{task_name}]" in content: + return True + return re.search(rf"^{task_name}\s*=", content, re.MULTILINE) is not None + + +def _detect_pyproject_commands(content: str) -> list[tuple[str, list[str]]]: + """Detect verification commands from pyproject.toml content.""" + commands: list[tuple[str, list[str]]] = [] + if "poe.tasks" in content: + for task_name, check_name, cmd in _POE_TASK_CHECKS: + if _has_poe_task(content, task_name): + commands.append((check_name, cmd)) + else: + for tool_name, check_name, cmd in _DIRECT_TOOL_CHECKS: + if tool_name in content: + commands.append((check_name, cmd)) + return commands + + +def _detect_verification_commands(cwd: Path) -> list[tuple[str, list[str]]]: + """Detect project verification commands based on config files present in cwd. + + Returns a list of (check_name, command_args) tuples. + """ + pyproject = cwd / "pyproject.toml" + if pyproject.exists(): + return _detect_pyproject_commands(pyproject.read_text()) + + package_json = cwd / "package.json" + if package_json.exists(): + content = package_json.read_text() + commands: list[tuple[str, list[str]]] = [] + if '"test"' in content: + commands.append(("tests", ["npm", "test"])) + if '"lint"' in content: + commands.append(("lint", ["npm", "run", "lint"])) + return commands + + makefile = cwd / "Makefile" + if makefile.exists(): + content = makefile.read_text() + commands = [] + if "test:" in content or "test :" in content: + commands.append(("tests", ["make", "test"])) + if "lint:" in content or "lint :" in content: + commands.append(("lint", ["make", "lint"])) + return commands + + return [] + + +def _run_project_verification(cwd: Path) -> VerificationResult: + """Run detected project verification commands and return aggregate result.""" + commands = _detect_verification_commands(cwd) + if not commands: + logger.info("No verification commands detected in %s, skipping verification", cwd) + return VerificationResult(passed=True, message="No verification commands detected, skipping", checks=[]) + + checks: list[CheckResult] = [] + for name, cmd in commands: + logger.info("Running verification: %s (%s)", name, " ".join(cmd)) + try: + result = subprocess.run( # noqa: S603 + cmd, + capture_output=True, + text=True, + check=False, + cwd=cwd, + timeout=VERIFICATION_TIMEOUT, + ) + if result.returncode == 0: + checks.append(CheckResult(name=name, passed=True, message="passed")) + else: + stderr_snippet = result.stderr.strip()[:200] if result.stderr else result.stdout.strip()[:200] + checks.append(CheckResult(name=name, passed=False, message=f"failed: {stderr_snippet}")) + except FileNotFoundError: + checks.append(CheckResult(name=name, passed=False, message=f"command not found: {cmd[0]}")) + except subprocess.TimeoutExpired: + checks.append(CheckResult(name=name, passed=False, message=f"timed out after {VERIFICATION_TIMEOUT}s")) + + all_passed = all(c.passed for c in checks) + passed_count = sum(1 for c in checks if c.passed) + total = len(checks) + message = f"{passed_count}/{total} checks passed" + return VerificationResult(passed=all_passed, message=message, checks=checks) + + def _has_acceptance_criteria(body: str) -> bool: """Check for an acceptance criteria section with checklist items.""" match = re.search(r"#{2,}\s+acceptance\s+criteria", body, re.IGNORECASE) diff --git a/tests/test_askcc.py b/tests/test_askcc.py index 4ac83e1..e9f9b6e 100644 --- a/tests/test_askcc.py +++ b/tests/test_askcc.py @@ -11,12 +11,16 @@ from askcc.cli import main from askcc.definitions import AGENT_CONFIGS, AgentAction, AgentConfig, SupportedLanguage from askcc.functions import ( + CheckResult, + VerificationResult, _add_issue_label, + _detect_verification_commands, _find_linked_pr_number, _find_option_id, _has_acceptance_criteria, _has_dependencies_section, _parse_issue_url, + _run_project_verification, _swap_issue_labels, _transition_project_fields, append_usage_to_last_comment, @@ -728,12 +732,14 @@ def test_develop_fails_on_validation_failure(self, capfd: pytest.CaptureFixture[ def test_develop_skip_validation_bypasses_check(self): """Develop --skip-validation skips readiness validation and runs Claude.""" + verification_passed = VerificationResult(passed=True, message="skipped", checks=[]) with ( patch("askcc.cli.bootstrap_templates"), patch("askcc.cli.validate_issue_labels", return_value=[]), patch("askcc.cli.validate_issue_readiness") as mock_validate, patch("askcc.cli.fetch_github_issue", return_value="issue body"), patch("askcc.cli.get_runner", return_value=_mock_runner()), + patch("askcc.cli._run_project_verification", return_value=verification_passed), patch("askcc.cli.transition_issue_to_review"), patch("sys.argv", ["askcc", "develop", "--skip-validation", "-g", self.ISSUE_URL]), pytest.raises(SystemExit), @@ -932,6 +938,17 @@ def test_calls_label_swap_and_project_transition(self): mock_project.assert_called_once_with("/usr/bin/gh", "monkut", "askcc-cli", 42) +_VERIFICATION_PASSED = VerificationResult(passed=True, message="3/3 checks passed", checks=[]) +_VERIFICATION_FAILED = VerificationResult( + passed=False, + message="1/2 checks passed", + checks=[ + CheckResult(name="tests", passed=True, message="passed"), + CheckResult(name="lint", passed=False, message="failed: ruff errors"), + ], +) + + class TestDevelopTransitionIntegration: ISSUE_URL = "https://github.com/monkut/askcc-cli/issues/1" @@ -942,6 +959,7 @@ def test_develop_success_triggers_transition(self): patch("askcc.cli.validate_issue_readiness", return_value=[]), patch("askcc.cli.fetch_github_issue", return_value="issue body"), patch("askcc.cli.get_runner", return_value=_mock_runner()), + patch("askcc.cli._run_project_verification", return_value=_VERIFICATION_PASSED), patch("askcc.cli.transition_issue_to_review") as mock_transition, patch("sys.argv", ["askcc", "develop", "--skip-validation", "-g", self.ISSUE_URL]), pytest.raises(SystemExit), @@ -950,6 +968,22 @@ def test_develop_success_triggers_transition(self): mock_transition.assert_called_once_with(self.ISSUE_URL) + def test_develop_verification_failure_blocks_transition(self): + with ( + patch("askcc.cli.bootstrap_templates"), + patch("askcc.cli.validate_issue_labels", return_value=[]), + patch("askcc.cli.validate_issue_readiness", return_value=[]), + patch("askcc.cli.fetch_github_issue", return_value="issue body"), + patch("askcc.cli.get_runner", return_value=_mock_runner()), + patch("askcc.cli._run_project_verification", return_value=_VERIFICATION_FAILED), + patch("askcc.cli.transition_issue_to_review") as mock_transition, + patch("sys.argv", ["askcc", "develop", "--skip-validation", "-g", self.ISSUE_URL]), + pytest.raises(SystemExit), + ): + main() + + mock_transition.assert_not_called() + def test_develop_failure_skips_transition(self): with ( patch("askcc.cli.bootstrap_templates"), @@ -980,6 +1014,95 @@ def test_plan_success_skips_transition(self): mock_transition.assert_not_called() +class TestDetectVerificationCommands: + def test_detects_poe_tasks(self, tmp_path: Path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[tool.poe.tasks]\ncheck = "uv run ruff check"\ntypecheck = "uv run pyright"\n\n' + '[tool.poe.tasks.test]\nshell = "uv run pytest"\n' + ) + commands = _detect_verification_commands(tmp_path) + names = [name for name, _ in commands] + assert "tests" in names + assert "lint" in names + assert "typecheck" in names + + def test_detects_direct_tools(self, tmp_path: Path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test"\n\n[dependency-groups]\ndev = ["pytest", "ruff", "pyright"]\n') + commands = _detect_verification_commands(tmp_path) + names = [name for name, _ in commands] + assert "tests" in names + assert "lint" in names + assert "typecheck" in names + + def test_detects_npm_scripts(self, tmp_path: Path): + package_json = tmp_path / "package.json" + package_json.write_text('{"scripts": {"test": "jest", "lint": "eslint ."}}') + commands = _detect_verification_commands(tmp_path) + names = [name for name, _ in commands] + assert "tests" in names + assert "lint" in names + + def test_detects_makefile_targets(self, tmp_path: Path): + makefile = tmp_path / "Makefile" + makefile.write_text("test:\n\tpytest\n\nlint:\n\truff check\n") + commands = _detect_verification_commands(tmp_path) + names = [name for name, _ in commands] + assert "tests" in names + assert "lint" in names + + def test_returns_empty_when_no_config(self, tmp_path: Path): + commands = _detect_verification_commands(tmp_path) + assert commands == [] + + def test_pyproject_takes_precedence_over_package_json(self, tmp_path: Path): + (tmp_path / "pyproject.toml").write_text('[dependency-groups]\ndev = ["pytest"]\n') + (tmp_path / "package.json").write_text('{"scripts": {"test": "jest"}}') + commands = _detect_verification_commands(tmp_path) + # Should use uv run pytest, not npm test + assert any("uv" in cmd[0] for _, cmd in commands) + + +class TestRunProjectVerification: + def test_skips_when_no_commands_detected(self, tmp_path: Path): + result = _run_project_verification(tmp_path) + assert result.passed is True + assert result.checks == [] + assert "skipping" in result.message.lower() + + def test_all_checks_pass(self, tmp_path: Path): + (tmp_path / "pyproject.toml").write_text('[dependency-groups]\ndev = ["pytest", "ruff"]\n') + ok = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + with patch("askcc.functions.subprocess.run", return_value=ok): + result = _run_project_verification(tmp_path) + assert result.passed is True + assert all(c.passed for c in result.checks) + + def test_partial_failure(self, tmp_path: Path): + (tmp_path / "pyproject.toml").write_text('[dependency-groups]\ndev = ["pytest", "ruff"]\n') + ok = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + fail = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="lint error found") + with patch("askcc.functions.subprocess.run", side_effect=[ok, fail]): + result = _run_project_verification(tmp_path) + assert result.passed is False + assert result.message == "1/2 checks passed" + + def test_command_not_found(self, tmp_path: Path): + (tmp_path / "pyproject.toml").write_text('[dependency-groups]\ndev = ["pytest"]\n') + with patch("askcc.functions.subprocess.run", side_effect=FileNotFoundError): + result = _run_project_verification(tmp_path) + assert result.passed is False + assert "command not found" in result.checks[0].message + + def test_timeout(self, tmp_path: Path): + (tmp_path / "pyproject.toml").write_text('[dependency-groups]\ndev = ["pytest"]\n') + with patch("askcc.functions.subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 300)): + result = _run_project_verification(tmp_path) + assert result.passed is False + assert "timed out" in result.checks[0].message + + class TestLoadPrepareConfig: def test_load_prepare_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): templates_dir = tmp_path / "templates" From 0c853c730905ee1040eb2a91ed7ed4ef8f54cded Mon Sep 17 00:00:00 2001 From: shane Date: Thu, 9 Apr 2026 16:36:25 +0900 Subject: [PATCH 2/7] :bug: Fix --cwd argument order in skill examples The --cwd flag is defined on the main parser, so it must appear before the subcommand. All examples had it after, causing argparse to reject the command. --- askcc/skills/request-askcc/SKILL.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/askcc/skills/request-askcc/SKILL.md b/askcc/skills/request-askcc/SKILL.md index c3a594d..7cdc303 100644 --- a/askcc/skills/request-askcc/SKILL.md +++ b/askcc/skills/request-askcc/SKILL.md @@ -25,7 +25,7 @@ Use the `askcc` tool to request processing of GitHub issues defined by a URL. ```bash # This fleshes out a backlog issue by suggesting acceptance criteria, identifying dependencies, and proposing an estimate to get it ready for planning. - askcc prepare --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 + askcc --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} prepare --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 ``` - "Validate https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1" @@ -39,14 +39,14 @@ Use the `askcc` tool to request processing of GitHub issues defined by a URL. ```bash # This analyzes a prepared issue against the codebase and produces a step-by-step implementation plan. - askcc plan --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 + askcc --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} plan --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 ``` - "Proceed with development of https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1" ```bash # This proceeds to implement/develop a github issue that has a clear development/implementation plan. - askcc develop --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 + askcc --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} develop --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 ``` @@ -61,35 +61,35 @@ Use the `askcc` tool to request processing of GitHub issues defined by a URL. ```bash # This investigates the github issue, researches the codebase, and proposes best-practice solutions with trade-offs. - askcc explore --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 + askcc --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} explore --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 ``` - "Diagnose https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1" ```bash # This investigates the reported issue, identifies potential root causes, and requests additional information. - askcc diagnose --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 + askcc --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} diagnose --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 ``` - "Review the PR for https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1" ```bash # This fetches the issue and its linked PR, reviews the code against Definition of Done criteria, and posts a structured review on the PR. - askcc pr-review --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 + askcc --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} pr-review --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 ``` - "Fix CI for https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1" ```bash # This fetches the linked PR, identifies failing CI checks, and implements fixes to make them pass. - askcc fix-ci --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 + askcc --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} fix-ci --github-issue-url https://github.com/{GITHUB ORG}/{GITHUB REPO}/issues/1 ``` - "Fix CI on the current branch" (no issue URL) ```bash # This auto-detects the open PR for the current branch, identifies failing CI checks, and implements fixes. - askcc fix-ci --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} + askcc --cwd {PROJECTS DIRECTORY}/{TARGET DEVELOPMENT REPOSITORY} fix-ci ``` WARNING: If the `{TARGET DEVELOPMENT REPOSITORY}` cannot be determined, ASK user in Slack. From 5bc05edb070367be132bb1b4388a606b6eef6561 Mon Sep 17 00:00:00 2001 From: shane Date: Thu, 9 Apr 2026 16:44:26 +0900 Subject: [PATCH 3/7] :recycle: Replace hardcoded tool detection with user-configured verify commands Verification commands are now read from [tool.askcc.verify] in pyproject.toml or [[verify]] in .askcc.toml, making the feature language/framework agnostic. No auto-detection of specific tools. --- askcc/functions.py | 81 ++++++++++++++------------------------------- tests/test_askcc.py | 75 ++++++++++++++++++++--------------------- 2 files changed, 63 insertions(+), 93 deletions(-) diff --git a/askcc/functions.py b/askcc/functions.py index 82a5740..980ac42 100644 --- a/askcc/functions.py +++ b/askcc/functions.py @@ -1,9 +1,11 @@ import json import logging import re +import shlex import shutil import subprocess import tempfile +import tomllib from dataclasses import dataclass, replace from importlib.resources import files as package_files from pathlib import Path @@ -218,68 +220,35 @@ class VerificationResult: VERIFICATION_TIMEOUT = 300 # 5 minutes per command -_POE_TASK_CHECKS: tuple[tuple[str, str, list[str]], ...] = ( - ("test", "tests", ["uv", "run", "poe", "test"]), - ("check", "lint", ["uv", "run", "poe", "check"]), - ("typecheck", "typecheck", ["uv", "run", "poe", "typecheck"]), -) - -_DIRECT_TOOL_CHECKS: tuple[tuple[str, str, list[str]], ...] = ( - ("pytest", "tests", ["uv", "run", "pytest"]), - ("ruff", "lint", ["uv", "run", "ruff", "check"]), - ("pyright", "typecheck", ["uv", "run", "pyright"]), -) - - -def _has_poe_task(content: str, task_name: str) -> bool: - """Check if a poe task is defined in pyproject.toml content.""" - if f"[tool.poe.tasks.{task_name}]" in content: - return True - return re.search(rf"^{task_name}\s*=", content, re.MULTILINE) is not None - - -def _detect_pyproject_commands(content: str) -> list[tuple[str, list[str]]]: - """Detect verification commands from pyproject.toml content.""" - commands: list[tuple[str, list[str]]] = [] - if "poe.tasks" in content: - for task_name, check_name, cmd in _POE_TASK_CHECKS: - if _has_poe_task(content, task_name): - commands.append((check_name, cmd)) - else: - for tool_name, check_name, cmd in _DIRECT_TOOL_CHECKS: - if tool_name in content: - commands.append((check_name, cmd)) - return commands - - def _detect_verification_commands(cwd: Path) -> list[tuple[str, list[str]]]: - """Detect project verification commands based on config files present in cwd. + """Load user-configured verification commands from project config. + + Checks (in order): + 1. ``[tool.askcc.verify]`` in ``pyproject.toml`` + 2. ``[[verify]]`` in ``.askcc.toml`` Returns a list of (check_name, command_args) tuples. + If no config is found, returns an empty list (verification is skipped). """ pyproject = cwd / "pyproject.toml" if pyproject.exists(): - return _detect_pyproject_commands(pyproject.read_text()) - - package_json = cwd / "package.json" - if package_json.exists(): - content = package_json.read_text() - commands: list[tuple[str, list[str]]] = [] - if '"test"' in content: - commands.append(("tests", ["npm", "test"])) - if '"lint"' in content: - commands.append(("lint", ["npm", "run", "lint"])) - return commands - - makefile = cwd / "Makefile" - if makefile.exists(): - content = makefile.read_text() - commands = [] - if "test:" in content or "test :" in content: - commands.append(("tests", ["make", "test"])) - if "lint:" in content or "lint :" in content: - commands.append(("lint", ["make", "lint"])) - return commands + try: + data = tomllib.loads(pyproject.read_text()) + entries = data.get("tool", {}).get("askcc", {}).get("verify", []) + if entries: + return [(e["name"], shlex.split(e["cmd"])) for e in entries if "name" in e and "cmd" in e] + except (tomllib.TOMLDecodeError, KeyError): + logger.warning("Failed to parse [tool.askcc.verify] from %s", pyproject) + + askcc_toml = cwd / ".askcc.toml" + if askcc_toml.exists(): + try: + data = tomllib.loads(askcc_toml.read_text()) + entries = data.get("verify", []) + if entries: + return [(e["name"], shlex.split(e["cmd"])) for e in entries if "name" in e and "cmd" in e] + except (tomllib.TOMLDecodeError, KeyError): + logger.warning("Failed to parse [[verify]] from %s", askcc_toml) return [] diff --git a/tests/test_askcc.py b/tests/test_askcc.py index e9f9b6e..8862cca 100644 --- a/tests/test_askcc.py +++ b/tests/test_askcc.py @@ -1015,53 +1015,50 @@ def test_plan_success_skips_transition(self): class TestDetectVerificationCommands: - def test_detects_poe_tasks(self, tmp_path: Path): + def test_reads_from_pyproject_toml(self, tmp_path: Path): pyproject = tmp_path / "pyproject.toml" pyproject.write_text( - '[tool.poe.tasks]\ncheck = "uv run ruff check"\ntypecheck = "uv run pyright"\n\n' - '[tool.poe.tasks.test]\nshell = "uv run pytest"\n' + '[[tool.askcc.verify]]\nname = "tests"\ncmd = "uv run poe test"\n\n' + '[[tool.askcc.verify]]\nname = "lint"\ncmd = "uv run poe check"\n' ) commands = _detect_verification_commands(tmp_path) - names = [name for name, _ in commands] - assert "tests" in names - assert "lint" in names - assert "typecheck" in names + assert commands == [ + ("tests", ["uv", "run", "poe", "test"]), + ("lint", ["uv", "run", "poe", "check"]), + ] - def test_detects_direct_tools(self, tmp_path: Path): - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nname = "test"\n\n[dependency-groups]\ndev = ["pytest", "ruff", "pyright"]\n') - commands = _detect_verification_commands(tmp_path) - names = [name for name, _ in commands] - assert "tests" in names - assert "lint" in names - assert "typecheck" in names - - def test_detects_npm_scripts(self, tmp_path: Path): - package_json = tmp_path / "package.json" - package_json.write_text('{"scripts": {"test": "jest", "lint": "eslint ."}}') + def test_reads_from_askcc_toml(self, tmp_path: Path): + askcc_toml = tmp_path / ".askcc.toml" + askcc_toml.write_text( + '[[verify]]\nname = "tests"\ncmd = "npm test"\n\n[[verify]]\nname = "lint"\ncmd = "npm run lint"\n' + ) commands = _detect_verification_commands(tmp_path) - names = [name for name, _ in commands] - assert "tests" in names - assert "lint" in names + assert commands == [ + ("tests", ["npm", "test"]), + ("lint", ["npm", "run", "lint"]), + ] - def test_detects_makefile_targets(self, tmp_path: Path): - makefile = tmp_path / "Makefile" - makefile.write_text("test:\n\tpytest\n\nlint:\n\truff check\n") + def test_pyproject_takes_precedence_over_askcc_toml(self, tmp_path: Path): + (tmp_path / "pyproject.toml").write_text('[[tool.askcc.verify]]\nname = "tests"\ncmd = "make test"\n') + (tmp_path / ".askcc.toml").write_text('[[verify]]\nname = "tests"\ncmd = "npm test"\n') commands = _detect_verification_commands(tmp_path) - names = [name for name, _ in commands] - assert "tests" in names - assert "lint" in names + assert commands == [("tests", ["make", "test"])] def test_returns_empty_when_no_config(self, tmp_path: Path): commands = _detect_verification_commands(tmp_path) assert commands == [] - def test_pyproject_takes_precedence_over_package_json(self, tmp_path: Path): - (tmp_path / "pyproject.toml").write_text('[dependency-groups]\ndev = ["pytest"]\n') - (tmp_path / "package.json").write_text('{"scripts": {"test": "jest"}}') + def test_returns_empty_when_pyproject_has_no_verify_section(self, tmp_path: Path): + (tmp_path / "pyproject.toml").write_text('[project]\nname = "myapp"\n') commands = _detect_verification_commands(tmp_path) - # Should use uv run pytest, not npm test - assert any("uv" in cmd[0] for _, cmd in commands) + assert commands == [] + + def test_skips_entries_missing_required_fields(self, tmp_path: Path): + (tmp_path / ".askcc.toml").write_text( + '[[verify]]\nname = "tests"\ncmd = "pytest"\n\n[[verify]]\nname = "incomplete"\n' # missing cmd + ) + commands = _detect_verification_commands(tmp_path) + assert commands == [("tests", ["pytest"])] class TestRunProjectVerification: @@ -1072,7 +1069,9 @@ def test_skips_when_no_commands_detected(self, tmp_path: Path): assert "skipping" in result.message.lower() def test_all_checks_pass(self, tmp_path: Path): - (tmp_path / "pyproject.toml").write_text('[dependency-groups]\ndev = ["pytest", "ruff"]\n') + (tmp_path / ".askcc.toml").write_text( + '[[verify]]\nname = "tests"\ncmd = "pytest"\n\n[[verify]]\nname = "lint"\ncmd = "ruff check"\n' + ) ok = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") with patch("askcc.functions.subprocess.run", return_value=ok): result = _run_project_verification(tmp_path) @@ -1080,7 +1079,9 @@ def test_all_checks_pass(self, tmp_path: Path): assert all(c.passed for c in result.checks) def test_partial_failure(self, tmp_path: Path): - (tmp_path / "pyproject.toml").write_text('[dependency-groups]\ndev = ["pytest", "ruff"]\n') + (tmp_path / ".askcc.toml").write_text( + '[[verify]]\nname = "tests"\ncmd = "pytest"\n\n[[verify]]\nname = "lint"\ncmd = "ruff check"\n' + ) ok = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") fail = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="lint error found") with patch("askcc.functions.subprocess.run", side_effect=[ok, fail]): @@ -1089,14 +1090,14 @@ def test_partial_failure(self, tmp_path: Path): assert result.message == "1/2 checks passed" def test_command_not_found(self, tmp_path: Path): - (tmp_path / "pyproject.toml").write_text('[dependency-groups]\ndev = ["pytest"]\n') + (tmp_path / ".askcc.toml").write_text('[[verify]]\nname = "tests"\ncmd = "pytest"\n') with patch("askcc.functions.subprocess.run", side_effect=FileNotFoundError): result = _run_project_verification(tmp_path) assert result.passed is False assert "command not found" in result.checks[0].message def test_timeout(self, tmp_path: Path): - (tmp_path / "pyproject.toml").write_text('[dependency-groups]\ndev = ["pytest"]\n') + (tmp_path / ".askcc.toml").write_text('[[verify]]\nname = "tests"\ncmd = "pytest"\n') with patch("askcc.functions.subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 300)): result = _run_project_verification(tmp_path) assert result.passed is False From 2b65aa56109d1124ee81deee68ec5d8400aa31da Mon Sep 17 00:00:00 2001 From: shane Date: Thu, 9 Apr 2026 16:47:08 +0900 Subject: [PATCH 4/7] :memo: Document optional post-develop verification config in README --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index 5b581ed..34822bf 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,44 @@ Edit any file to customize the agent's behavior. User prompt templates **must** Override the config directory by setting the `ASKCC_HOME` environment variable (e.g. for testing). +### Post-Develop Verification + +After the `develop` command completes successfully, askcc can run verification commands (tests, linting, type checks) before transitioning the issue to `action:review`. This is **optional** — if no verification config is found, the transition proceeds without checks. + +Configure verification commands in either of these files (checked in order): + +#### `pyproject.toml` + +```toml +[[tool.askcc.verify]] +name = "tests" +cmd = "uv run poe test" + +[[tool.askcc.verify]] +name = "lint" +cmd = "uv run poe check" + +[[tool.askcc.verify]] +name = "typecheck" +cmd = "uv run poe typecheck" +``` + +#### `.askcc.toml` + +For non-Python projects or when `pyproject.toml` is not used: + +```toml +[[verify]] +name = "tests" +cmd = "npm test" + +[[verify]] +name = "lint" +cmd = "npm run lint" +``` + +Each entry requires a `name` (used in log output) and `cmd` (shell command to run). If any command fails, the issue label stays at `action:develop` and the failure details are logged. Each command has a 5-minute timeout. + ### Examples Prepare a backlog issue for development: From da4bfb793e3383ed07c1093b7ff92cf746f3c635 Mon Sep 17 00:00:00 2001 From: shane Date: Thu, 9 Apr 2026 16:50:25 +0900 Subject: [PATCH 5/7] :sparkles: Add mermaid flow diagram requirement to DEVELOP prompt PR description --- askcc/definitions.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/askcc/definitions.py b/askcc/definitions.py index 992fea9..8f0977c 100644 --- a/askcc/definitions.py +++ b/askcc/definitions.py @@ -221,6 +221,25 @@ - No hardcoded credentials or connection strings — use environment variables or secrets management. - No overly permissive file or network access introduced by the change. +PR description: +- Include a `## Key Flows` section with mermaid diagrams illustrating the main flows \ +introduced or changed by this PR. Focus on control flow, data flow, or state transitions \ +that help the reviewer understand the change at a glance. Example: + ``` + ## Key Flows + + ```mermaid + flowchart TD + A[develop completes] --> B{{verification configured?}} + B -- yes --> C[run checks] + B -- no --> D[transition to review] + C -- all pass --> D + C -- any fail --> E[stay in develop] + `` ` + ``` +- Keep diagrams concise — one or two diagrams covering the most important flows. \ +Skip this section if the change is trivial (e.g. config-only, docs-only, single-line fix). + On completion: - Run /simplify or /refactor to simplify and improve the code. - Commit, push the feature branch, and open a PR linked to the issue. From 309dc646ce1264641aad30abe68d746480e87537 Mon Sep 17 00:00:00 2001 From: shane Date: Thu, 9 Apr 2026 16:51:24 +0900 Subject: [PATCH 6/7] :bookmark: Bump version to 0.3.0 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ae7736f..2f2f40f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "askcc" -version = "0.2.1" +version = "0.3.0" description = "A one-shot cc cli executor" authors = [{ name = "mknt", email = "shane.cousins@gmail.com" }] readme = "README.md" diff --git a/uv.lock b/uv.lock index 55567df..f6b0e8e 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = "==3.14.*" [[package]] name = "askcc" -version = "0.2.1" +version = "0.3.0" source = { editable = "." } [package.dev-dependencies] From 7aa90c3f6481ed6ef0ed041b8f434c52f400aa46 Mon Sep 17 00:00:00 2001 From: shane Date: Thu, 9 Apr 2026 16:51:37 +0900 Subject: [PATCH 7/7] :bookmark: Bump version to 0.2.2 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2f2f40f..55d48a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "askcc" -version = "0.3.0" +version = "0.2.2" description = "A one-shot cc cli executor" authors = [{ name = "mknt", email = "shane.cousins@gmail.com" }] readme = "README.md" diff --git a/uv.lock b/uv.lock index f6b0e8e..8790968 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = "==3.14.*" [[package]] name = "askcc" -version = "0.3.0" +version = "0.2.2" source = { editable = "." } [package.dev-dependencies]