diff --git a/.github/scripts/parse_chatgpt_topics.py b/.github/scripts/parse_chatgpt_topics.py index 6fae0dd7b..42056ffd5 100755 --- a/.github/scripts/parse_chatgpt_topics.py +++ b/.github/scripts/parse_chatgpt_topics.py @@ -119,6 +119,11 @@ def _parse_sections( "tasks": {"tasks"}, "acceptance_criteria": {"acceptance criteria", "acceptance criteria."}, "implementation_notes": { + "admin access", + "admin requirement", + "admin requirements", + "dependencies", + "dependency", "implementation notes", "implementation note", "notes", diff --git a/Issues.txt b/Issues.txt index 8bd90b06d..72e5e99b9 100644 --- a/Issues.txt +++ b/Issues.txt @@ -114,3 +114,70 @@ Manual testing is slowing down releases. An automated testing pipeline will incr - All PRs require passing tests - Code coverage is above 80% - E2E tests cover login, dashboard, and checkout flows + +--- + +Issue 6 — Fix README typo in onboarding section + +## Why +The onboarding instructions include a misspelled command that confuses new contributors. + +## Scope +- Correct the typo in the onboarding README section +- Verify the command matches the repo's actual script name + +## Tasks +- [ ] Locate the onboarding command in README +- [ ] Fix the typo and ensure formatting stays intact + +## Acceptance Criteria +- Onboarding command is spelled correctly +- README formatting remains unchanged elsewhere + +--- + +Issue 7 — Integrate Stripe payments for subscriptions + +## Why +Recurring subscriptions are blocked until Stripe billing is integrated. + +## Scope +- Add Stripe checkout flow for subscription tiers +- Store Stripe customer IDs for existing users + +## Dependencies +- Stripe API access and test keys +- Webhook endpoint configuration in Stripe + +## Tasks +- [ ] Create Stripe customer records for new signups +- [ ] Implement subscription checkout session +- [ ] Handle Stripe webhook events for subscription status + +## Acceptance Criteria +- Users can start a subscription via Stripe checkout +- Subscription status syncs via webhooks +- Billing events are recorded in the database + +--- + +Issue 8 — Rotate GitHub secrets for CI + +## Why +Security policy requires rotating CI secrets every 90 days. + +## Scope +- Rotate CI service account token +- Update secrets referenced by workflows + +## Admin Access +- Requires org admin to update repository secrets + +## Tasks +- [ ] Rotate service account token in secret manager +- [ ] Update GitHub repository secrets with new token +- [ ] Confirm CI runs use updated secrets + +## Acceptance Criteria +- New token is active in GitHub secrets +- CI pipelines succeed with rotated credentials diff --git a/agents/codex-690.md b/agents/codex-690.md new file mode 100644 index 000000000..db791e0dd --- /dev/null +++ b/agents/codex-690.md @@ -0,0 +1 @@ + diff --git a/scripts/langchain/capability_check.py b/scripts/langchain/capability_check.py index 2e58ea07f..5417f431a 100755 --- a/scripts/langchain/capability_check.py +++ b/scripts/langchain/capability_check.py @@ -166,30 +166,128 @@ def _normalize_result(payload: dict[str, Any], provider_used: str | None) -> Cap ) +def _matches_any(patterns: list[str], text: str) -> bool: + return any(re.search(pattern, text, flags=re.IGNORECASE) for pattern in patterns) + + +def _is_multi_action_task(task: str) -> bool: + lowered = task.lower() + if len(task.split()) >= 14: + return True + if any(sep in lowered for sep in (" and ", " + ", " & ", " then ", "; ")): + return True + return bool("," in task or "/" in task or re.search(r"\s\+\s", lowered)) + + +def _requires_admin_access(task: str) -> bool: + patterns = [ + r"\bgithub\s+secrets?\b", + r"\bsecrets?\b", + r"\brepository\s+settings\b", + r"\brepo\s+settings\b", + r"\bbranch\s+protection\b", + r"\badmin\s+access\b", + r"\badmin\b.*\bpermission\b", + r"\borganization\s+settings\b", + r"\borg\s+settings\b", + r"\bbilling\b", + r"\baccess\s+control\b", + ] + return _matches_any(patterns, task) + + +def _requires_external_dependency(task: str) -> bool: + patterns = [ + r"\bstripe\b", + r"\bpaypal\b", + r"\bbraintree\b", + r"\btwilio\b", + r"\bslack\b", + r"\bsentry\b", + r"\bwebhook\b", + r"\boauth\b", + r"\bapi\s+key\b", + r"\bclient\s+secret\b", + r"\bclient\s+id\b", + r"\bexternal\s+api\b", + r"\bthird-?party\b", + r"\bintegrat(e|ion)\b.*\bapi\b", + ] + return _matches_any(patterns, task) + + +def _fallback_classify( + tasks: list[str], acceptance: str, reason: str | None +) -> CapabilityCheckResult: + actionable: list[str] = [] + partial: list[dict[str, str]] = [] + blocked: list[dict[str, str]] = [] + human_actions: list[str] = [] + + for task in tasks: + if _requires_admin_access(task): + blocked.append( + { + "task": task, + "reason": "Requires admin or repository settings access", + "suggested_action": "Have a repo admin apply the change or grant access.", + } + ) + human_actions.append(f"Admin access needed: {task}") + continue + if _requires_external_dependency(task): + blocked.append( + { + "task": task, + "reason": "Requires external service credentials or configuration", + "suggested_action": "Provide credentials or have a human set up the external service.", + } + ) + human_actions.append(f"External dependency setup required: {task}") + continue + if _is_multi_action_task(task): + partial.append( + { + "task": task, + "limitation": "Task bundles multiple actions; split into smaller tasks.", + } + ) + human_actions.append(f"Split task into smaller steps: {task}") + continue + actionable.append(task) + + if reason: + human_actions.append(reason) + + if blocked: + recommendation = "BLOCKED" + elif partial or not tasks: + recommendation = "REVIEW_NEEDED" + else: + recommendation = "PROCEED" + + return CapabilityCheckResult( + actionable_tasks=actionable, + partial_tasks=partial, + blocked_tasks=blocked, + recommendation=recommendation, + human_actions_needed=human_actions, + provider_used=None, + ) + + def classify_capabilities(tasks: list[str], acceptance: str) -> CapabilityCheckResult: client_info = _get_llm_client() if not client_info: - return CapabilityCheckResult( - actionable_tasks=[], - partial_tasks=[], - blocked_tasks=[], - recommendation="REVIEW_NEEDED", - human_actions_needed=["LLM provider unavailable"], - provider_used=None, - ) + return _fallback_classify(tasks, acceptance, "LLM provider unavailable") client, provider_name = client_info try: from langchain_core.prompts import ChatPromptTemplate except ImportError: - return CapabilityCheckResult( - actionable_tasks=[], - partial_tasks=[], - blocked_tasks=[], - recommendation="REVIEW_NEEDED", - human_actions_needed=["langchain-core not installed"], - provider_used=provider_name, - ) + result = _fallback_classify(tasks, acceptance, "langchain-core not installed") + result.provider_used = provider_name + return result template = ChatPromptTemplate.from_template(AGENT_CAPABILITY_CHECK_PROMPT) chain = template | client @@ -197,25 +295,15 @@ def classify_capabilities(tasks: list[str], acceptance: str) -> CapabilityCheckR content = getattr(response, "content", None) or str(response) payload = _extract_json_payload(content) if not payload: - return CapabilityCheckResult( - actionable_tasks=[], - partial_tasks=[], - blocked_tasks=[], - recommendation="REVIEW_NEEDED", - human_actions_needed=["LLM response missing JSON payload"], - provider_used=provider_name, - ) + result = _fallback_classify(tasks, acceptance, "LLM response missing JSON payload") + result.provider_used = provider_name + return result try: data = json.loads(payload) except json.JSONDecodeError: - return CapabilityCheckResult( - actionable_tasks=[], - partial_tasks=[], - blocked_tasks=[], - recommendation="REVIEW_NEEDED", - human_actions_needed=["LLM response JSON parse failed"], - provider_used=provider_name, - ) + result = _fallback_classify(tasks, acceptance, "LLM response JSON parse failed") + result.provider_used = provider_name + return result return _normalize_result(data, provider_name) diff --git a/scripts/langchain/issue_optimizer.py b/scripts/langchain/issue_optimizer.py old mode 100644 new mode 100755 index 773e4b589..416a01d57 --- a/scripts/langchain/issue_optimizer.py +++ b/scripts/langchain/issue_optimizer.py @@ -455,7 +455,11 @@ def _coerce_split_suggestions(entry: dict[str, Any]) -> list[str]: def _is_large_task(task: str) -> bool: lowered = task.lower() - return len(task.split()) >= 14 or " and " in lowered or ", " in task or "/" in task + if len(task.split()) >= 14: + return True + if any(sep in lowered for sep in (" and ", " + ", " & ", " then ", "; ")): + return True + return bool(re.search(r"\s\+\s", lowered) or ", " in task or "/" in task) def _detect_task_splitting(tasks: list[str], *, use_llm: bool = False) -> list[dict[str, Any]]: diff --git a/scripts/run_consumer_repo_tests.py b/scripts/run_consumer_repo_tests.py new file mode 100644 index 000000000..999ce4ba4 --- /dev/null +++ b/scripts/run_consumer_repo_tests.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from collections.abc import Sequence +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from tools.integration_repo import DEFAULT_WORKFLOW_REF, render_integration_repo + +DEFAULT_DESTINATION = Path(".consumer-tests") / "integration-repo" + + +def ensure_destination(destination: Path, *, force: bool) -> None: + if destination.exists(): + if force: + shutil.rmtree(destination) + elif any(destination.iterdir()): + raise FileExistsError( + f"Destination {destination} is not empty. Use --force to overwrite." + ) + destination.mkdir(parents=True, exist_ok=True) + + +def build_pytest_command(pytest_args: Sequence[str]) -> list[str]: + return [sys.executable, "-m", "pytest", *pytest_args] + + +def build_pytest_env(destination: Path) -> dict[str, str]: + env = os.environ.copy() + src_path = str(destination.resolve() / "src") + existing = env.get("PYTHONPATH") + env["PYTHONPATH"] = f"{src_path}{os.pathsep}{existing}" if existing else src_path + return env + + +def run_pytest(destination: Path, pytest_args: Sequence[str]) -> int: + command = build_pytest_command(pytest_args) + env = build_pytest_env(destination) + result = subprocess.run(command, cwd=destination, env=env) + return result.returncode + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run tests in a consumer repo (integration template by default)." + ) + parser.add_argument( + "--destination", + type=Path, + default=DEFAULT_DESTINATION, + help="Directory for the rendered integration repo or existing repo path.", + ) + parser.add_argument( + "--workflow-ref", + default=DEFAULT_WORKFLOW_REF, + help="Reusable workflow ref to embed when rendering the integration repo.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Remove existing destination contents before rendering.", + ) + parser.add_argument( + "--skip-render", + action="store_true", + help="Run tests in an existing consumer repo instead of rendering.", + ) + parser.add_argument( + "--pytest-args", + nargs=argparse.REMAINDER, + default=[], + help="Additional pytest args (pass after --pytest-args).", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + destination = args.destination + + try: + if args.skip_render: + if not destination.exists(): + print(f"Destination not found: {destination}", file=sys.stderr) + return 1 + else: + ensure_destination(destination, force=args.force) + render_integration_repo(destination, workflow_ref=args.workflow_ref) + except (FileExistsError, FileNotFoundError) as exc: + print(str(exc), file=sys.stderr) + return 1 + + return run_pytest(destination, args.pytest_args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/scripts/test_capability_check.py b/tests/scripts/test_capability_check.py index 5454eadcb..5ccf81135 100644 --- a/tests/scripts/test_capability_check.py +++ b/tests/scripts/test_capability_check.py @@ -275,7 +275,8 @@ def from_template(_: str) -> Any: def test_returns_fallback_when_no_llm_client(self) -> None: with mock.patch("scripts.langchain.capability_check._get_llm_client", return_value=None): result = classify_capabilities(["task1"], "criteria") - assert result.recommendation == "REVIEW_NEEDED" + assert result.recommendation == "PROCEED" + assert result.actionable_tasks == ["task1"] assert "LLM provider unavailable" in result.human_actions_needed assert result.provider_used is None @@ -297,7 +298,8 @@ def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: with mock.patch.object(builtins, "__import__", mock_import): result = classify_capabilities(["task1"], "criteria") - assert result.recommendation == "REVIEW_NEEDED" + assert result.recommendation == "PROCEED" + assert result.actionable_tasks == ["task1"] assert result.provider_used == "github-models" assert "langchain-core not installed" in result.human_actions_needed @@ -345,7 +347,8 @@ def test_returns_fallback_when_response_missing_json( ): result = classify_capabilities(["task1"], "criteria") - assert result.recommendation == "REVIEW_NEEDED" + assert result.recommendation == "PROCEED" + assert result.actionable_tasks == ["task1"] assert "LLM response missing JSON payload" in result.human_actions_needed assert result.provider_used == "github-models" @@ -365,10 +368,32 @@ def test_returns_fallback_when_response_json_invalid( ): result = classify_capabilities(["task1"], "criteria") - assert result.recommendation == "REVIEW_NEEDED" + assert result.recommendation == "PROCEED" + assert result.actionable_tasks == ["task1"] assert "LLM response JSON parse failed" in result.human_actions_needed assert result.provider_used == "github-models" + def test_fallback_flags_external_dependency(self) -> None: + with mock.patch("scripts.langchain.capability_check._get_llm_client", return_value=None): + result = classify_capabilities(["Integrate Stripe payments"], "") + assert result.recommendation == "BLOCKED" + assert result.blocked_tasks[0]["task"] == "Integrate Stripe payments" + assert "external service" in result.blocked_tasks[0]["reason"].lower() + + def test_fallback_flags_admin_requirement(self) -> None: + with mock.patch("scripts.langchain.capability_check._get_llm_client", return_value=None): + result = classify_capabilities(["Update GitHub secrets"], "") + assert result.recommendation == "BLOCKED" + assert result.blocked_tasks[0]["task"] == "Update GitHub secrets" + assert "admin" in result.blocked_tasks[0]["reason"].lower() + + def test_fallback_suggests_decomposition(self) -> None: + with mock.patch("scripts.langchain.capability_check._get_llm_client", return_value=None): + result = classify_capabilities(["Refactor auth + add tests + update docs"], "") + assert result.recommendation == "REVIEW_NEEDED" + assert result.partial_tasks[0]["task"] == "Refactor auth + add tests + update docs" + assert "split" in result.partial_tasks[0]["limitation"].lower() + # The following tests require langchain_core to be installed # They test the LLM response handling paths @@ -407,7 +432,8 @@ def test_returns_fallback_when_no_json_in_response(self) -> None: mock_cpt.from_template.return_value = mock_template result = classify_capabilities(["task1"], "criteria") - assert result.recommendation == "REVIEW_NEEDED" + assert result.recommendation == "PROCEED" + assert result.actionable_tasks == ["task1"] assert "LLM response missing JSON payload" in result.human_actions_needed def test_returns_fallback_when_json_parse_fails(self) -> None: @@ -431,7 +457,8 @@ def test_returns_fallback_when_json_parse_fails(self) -> None: mock_cpt.from_template.return_value = mock_template result = classify_capabilities(["task1"], "criteria") - assert result.recommendation == "REVIEW_NEEDED" + assert result.recommendation == "PROCEED" + assert result.actionable_tasks == ["task1"] assert "LLM response JSON parse failed" in result.human_actions_needed def test_normalizes_valid_llm_response(self) -> None: diff --git a/tests/scripts/test_issue_optimizer.py b/tests/scripts/test_issue_optimizer.py index e0df5485a..747fb724f 100644 --- a/tests/scripts/test_issue_optimizer.py +++ b/tests/scripts/test_issue_optimizer.py @@ -255,6 +255,20 @@ def fake_decompose(task: str, *, use_llm: bool) -> dict[str, list[str]]: assert result[0]["split_suggestions"] == ["Split A", "Split B"] +def test_detect_task_splitting_flags_plus_separated(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_decompose(task: str, *, use_llm: bool) -> dict[str, list[str]]: + assert task == "Refactor auth + add tests + update docs" + return {"sub_tasks": ["Refactor auth", "Add tests", "Update docs"]} + + monkeypatch.setattr( + "scripts.langchain.task_decomposer.decompose_task", + fake_decompose, + ) + tasks = ["Refactor auth + add tests + update docs"] + result = issue_optimizer._detect_task_splitting(tasks, use_llm=False) + assert result[0]["split_suggestions"] == ["Refactor auth", "Add tests", "Update docs"] + + def test_ensure_task_decomposition_fills_missing_suggestions( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/scripts/test_run_consumer_repo_tests.py b/tests/scripts/test_run_consumer_repo_tests.py new file mode 100644 index 000000000..4b8d94235 --- /dev/null +++ b/tests/scripts/test_run_consumer_repo_tests.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from scripts import run_consumer_repo_tests + + +def test_ensure_destination_existing_non_empty_raises(tmp_path: Path) -> None: + destination = tmp_path / "repo" + destination.mkdir() + (destination / "existing.txt").write_text("data", encoding="utf-8") + + with pytest.raises(FileExistsError): + run_consumer_repo_tests.ensure_destination(destination, force=False) + + +def test_ensure_destination_force_clears(tmp_path: Path) -> None: + destination = tmp_path / "repo" + destination.mkdir() + (destination / "existing.txt").write_text("data", encoding="utf-8") + + run_consumer_repo_tests.ensure_destination(destination, force=True) + + assert destination.exists() + assert list(destination.iterdir()) == [] + + +def test_build_pytest_command_uses_sys_executable() -> None: + command = run_consumer_repo_tests.build_pytest_command(["-q"]) + + assert command[0] == sys.executable + assert command[1:] == ["-m", "pytest", "-q"] + + +def test_build_pytest_env_sets_pythonpath(tmp_path: Path) -> None: + destination = tmp_path / "repo" + env = run_consumer_repo_tests.build_pytest_env(destination) + + assert env["PYTHONPATH"].startswith(str(destination.resolve() / "src")) + + +def test_main_skip_render_missing_destination( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + destination = tmp_path / "missing" + + result = run_consumer_repo_tests.main(["--skip-render", "--destination", str(destination)]) + + captured = capsys.readouterr() + assert result == 1 + assert "Destination not found" in captured.err + + +def test_main_renders_and_runs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + destination = tmp_path / "rendered" + calls: dict[str, object] = {} + + def fake_render(path: Path, workflow_ref: str) -> None: + calls["render"] = (path, workflow_ref) + + def fake_run(path: Path, pytest_args: list[str]) -> int: + calls["run"] = (path, pytest_args) + return 0 + + monkeypatch.setattr(run_consumer_repo_tests, "render_integration_repo", fake_render) + monkeypatch.setattr(run_consumer_repo_tests, "run_pytest", fake_run) + + result = run_consumer_repo_tests.main( + [ + "--destination", + str(destination), + "--workflow-ref", + "owner/repo/.github/workflows/ci.yml@main", + "--pytest-args", + "-q", + ] + ) + + assert result == 0 + assert calls["render"] == ( + destination, + "owner/repo/.github/workflows/ci.yml@main", + ) + assert calls["run"] == (destination, ["-q"]) diff --git a/tests/workflows/test_chatgpt_topics_parser.py b/tests/workflows/test_chatgpt_topics_parser.py index 2d343c03a..9c6b390a5 100644 --- a/tests/workflows/test_chatgpt_topics_parser.py +++ b/tests/workflows/test_chatgpt_topics_parser.py @@ -320,6 +320,24 @@ def test_parse_sections_supports_label_separators() -> None: assert extras == [] +def test_parse_sections_supports_dependencies_and_admin_access() -> None: + labels, sections, extras = parse_module._parse_sections( + [ + "Dependencies", + "- Stripe API access required", + "Admin Access", + "- Rotate GitHub secrets", + "Why", + "Because billing is blocked", + ] + ) + assert labels == [] + assert any("Stripe API access required" in line for line in sections["implementation_notes"]) + assert any("Rotate GitHub secrets" in line for line in sections["implementation_notes"]) + assert sections["why"] == ["Because billing is blocked"] + assert extras == [] + + def test_parse_text_single_topic_empty_raises_system_exit() -> None: with pytest.raises(SystemExit) as exc: parse_module.parse_text(" \n", allow_single_fallback=True)