diff --git a/README.md b/README.md index 927c34b..2f86b5c 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,10 @@ 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 free CodeBoarding quota is exhausted, the failure reason links to +[buy a CodeBoarding license](https://codeboarding.org/pricing) or suggests waiting for your +weekly reset. 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/scripts/analyze_repository.py b/scripts/analyze_repository.py index 446bdce..41ba8ff 100755 --- a/scripts/analyze_repository.py +++ b/scripts/analyze_repository.py @@ -10,6 +10,7 @@ import subprocess import sys from pathlib import Path +from threading import Thread PROG = "codeboarding" @@ -77,10 +78,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 +100,21 @@ 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 == 3: + # Keep Core's quota-specific recovery guidance instead of replacing it with provider billing advice. + for line in reversed(stderr_lines): + if "CodeBoarding: " in line: + reason = line.partition("CodeBoarding: ")[2].strip() + 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..9445d8e 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,27 @@ 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_quota_failure_preserves_cores_recovery_message(self) -> None: + messages = ( + "Your free CodeBoarding quota is exhausted. Buy a CodeBoarding license at " + "https://codeboarding.org/pricing, or wait for your weekly reset.", + "The LLM provider's token or credit quota is exhausted. " + "Add credits or raise the quota for this provider, then re-run.", + ) + for message in messages: + with self.subTest(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); " + "sys.exit(3)" + ) + 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")