diff --git a/README.md b/README.md index 927c34b..42e9a75 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,11 @@ Every run reports what it resolved, so the answer never has to be inferred from with an error annotation saying which, and in review mode the failure comment says it too. No partial analysis is published. +When your CodeBoarding quota is exhausted, the failure reason links to +[buy a CodeBoarding license](https://codeboarding.org/pricing) or suggests waiting for your +weekly reset. If CodeBoarding's hosted provider runs out of quota, the failure reason identifies +a CodeBoarding service issue. Own-provider quota failures retain their provider billing guidance. + ## Model selection All model inputs are optional and are passed directly to Core without action-side validation: diff --git a/action.yml b/action.yml index 3de0b22..9b2bf3e 100644 --- a/action.yml +++ b/action.yml @@ -404,7 +404,7 @@ runs: if: steps.guard.outputs.skip != 'true' shell: bash run: | - python -m pip install --disable-pip-version-check 'codeboarding==0.15.1' + python -m pip install --disable-pip-version-check 'codeboarding==0.15.2' # Fail here, with the reason, rather than mid-analysis with a traceback: # pinning a release does not pin what its dependencies resolve to. Run # the console script, not `python -c`, which would put the analyzed diff --git a/scripts/action/supported-providers.json b/scripts/action/supported-providers.json index 9a83440..59bf34f 100644 --- a/scripts/action/supported-providers.json +++ b/scripts/action/supported-providers.json @@ -18,7 +18,7 @@ "a selection env (AWS_DEFAULT_REGION, OLLAMA_API_KEY) therefore cannot select a provider", "on its own, which is why ollama and litellm need their base URL and not just a key." ], - "engine": "0.15.1", + "engine": "0.15.2", "hosted_provider": "openrouter", "providers": { "openrouter": { diff --git a/scripts/analyze_repository.py b/scripts/analyze_repository.py index 446bdce..517d38b 100755 --- a/scripts/analyze_repository.py +++ b/scripts/analyze_repository.py @@ -10,10 +10,13 @@ import subprocess import sys from pathlib import Path +from threading import Thread PROG = "codeboarding" +# Shared with CodeBoarding/codeboarding_cli/main.py; keep the prefix in sync. +ENGINE_ERROR_PREFIX = "CodeBoarding: " -# The engine's exit codes for an LLM refusal the user has to fix, and what to tell them. +# Fallback guidance for engine refusals without a recovery message. ENGINE_REFUSALS = { 2: "The LLM provider rejected the API key. Check the key's secret and re-run.", 3: "The LLM provider's token or credit quota is exhausted. Add credits or raise the quota, then re-run.", @@ -77,10 +80,21 @@ def _run_command(args: list[str], output_dir: Path) -> str: process = subprocess.Popen( args, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, bufsize=1, cwd=str(output_dir.parent), ) + stderr_lines: list[str] = [] + + def forward_stderr() -> None: + for line in process.stderr or (): + stderr_lines.append(line) + print(line, end="", file=sys.stderr, flush=True) + + # Drain both pipes while the engine runs, preserving stdout's JSON contract. + stderr_reader = Thread(target=forward_stderr) + stderr_reader.start() stdout_lines: list[str] = [] for line in process.stdout or (): stdout_lines.append(line) @@ -88,10 +102,20 @@ def _run_command(args: list[str], output_dir: Path) -> str: # CLI stdout to stderr so engine progress remains visible in Actions. print(line, end="", file=sys.stderr, flush=True) + if process.stdout is not None: + process.stdout.close() return_code = process.wait() + stderr_reader.join() + if process.stderr is not None: + process.stderr.close() stdout = "".join(stdout_lines) if return_code != 0: reason = ENGINE_REFUSALS.get(return_code) + if return_code in ENGINE_REFUSALS: + for line in reversed(stderr_lines): + if ENGINE_ERROR_PREFIX in line: + reason = line.partition(ENGINE_ERROR_PREFIX)[2].strip() or reason + break if reason: # Written straight to the step's outputs: the shell never sees this script's stdout on failure. with open(os.environ.get("GITHUB_OUTPUT", os.devnull), "a", encoding="utf-8") as outputs: diff --git a/tests/test_analyze_repository.py b/tests/test_analyze_repository.py index 7c923fa..b9969aa 100644 --- a/tests/test_analyze_repository.py +++ b/tests/test_analyze_repository.py @@ -60,7 +60,9 @@ def test_run_command_streams_stdout_to_action_logs(self) -> None: command = [ sys.executable, "-c", - "print('Analyzing repository...'); print('{\"requiresFullAnalysis\": true}')", + "import sys; print('Analyzing repository...'); " + "print('{\"requiresFullAnalysis\": true}', flush=True); " + "print('Final diagnostic', file=sys.stderr)", ] with patch("sys.stderr", stderr): @@ -68,6 +70,7 @@ def test_run_command_streams_stdout_to_action_logs(self) -> None: self.assertIn("Analyzing repository...", stderr.getvalue()) self.assertIn('{"requiresFullAnalysis": true}', stderr.getvalue()) + self.assertIn("Final diagnostic", stderr.getvalue()) self.assertEqual(stdout, 'Analyzing repository...\n{"requiresFullAnalysis": true}\n') def _fail_with(self, exit_code: int, outputs: Path) -> ar.AnalysisError: @@ -86,6 +89,45 @@ def test_run_command_names_an_exhausted_quota(self) -> None: self.assertIn("quota is exhausted", str(error)) self.assertEqual(outputs.read_text(encoding="utf-8"), f"failure_reason={error}\n") + def test_engine_refusals_preserve_cores_recovery_message(self) -> None: + messages = ( + ( + 3, + "Your CodeBoarding quota is exhausted. Buy a CodeBoarding license at " + "https://codeboarding.org/pricing, or wait for your weekly reset.", + ), + ( + 3, + "The LLM provider's token or credit quota is exhausted. " + "Add credits or raise the quota for this provider, then re-run.", + ), + ( + 3, + "The hosted LLM provider's quota is exhausted. " + "This is a CodeBoarding service issue. Please try again later or contact CodeBoarding support.", + ), + (2, "Your openai API key was rejected (HTTP 401). Verify the key ending in '…a8dd' and try again."), + ( + 2, + "CodeBoarding's hosted openrouter credentials were rejected (HTTP 401). This run used no key of " + "yours: check that the workflow grants `id-token: write`, then re-run.", + ), + ) + for exit_code, message in messages: + with self.subTest(exit_code=exit_code, message=message), tempfile.TemporaryDirectory() as tmp: + outputs = Path(tmp) / "github_output" + program = ( + "import sys; " + f"print('ERROR [codeboarding_cli.main:140] CodeBoarding: ' + {message!r}, file=sys.stderr); " + "print('Additional unprefixed guidance', file=sys.stderr); " + f"sys.exit({exit_code})" + ) + with patch.dict("os.environ", {"GITHUB_OUTPUT": str(outputs)}), patch("sys.stderr", io.StringIO()): + with self.assertRaises(ar.AnalysisError) as caught: + ar._run_command([sys.executable, "-c", program], Path(tmp) / "out") + self.assertEqual(str(caught.exception), message) + self.assertEqual(outputs.read_text(), f"failure_reason={message}\n") + def test_run_command_names_a_rejected_key(self) -> None: with tempfile.TemporaryDirectory() as tmp: error = self._fail_with(2, Path(tmp) / "github_output")