Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions scripts/analyze_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import subprocess
import sys
from pathlib import Path
from threading import Thread

PROG = "codeboarding"

Expand Down Expand Up @@ -77,21 +78,43 @@ 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)
# The shell captures this helper's stdout as its result contract. Mirror
# 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:
Expand Down
26 changes: 25 additions & 1 deletion tests/test_analyze_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,17 @@ 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):
stdout = ar._run_command(command, Path(tmp) / "out")

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:
Expand All @@ -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")
Expand Down
Loading