Skip to content
Merged
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/action/supported-providers.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
26 changes: 25 additions & 1 deletion scripts/analyze_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -77,21 +80,42 @@ 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 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
Comment thread
ivanmilevtues marked this conversation as resolved.
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
44 changes: 43 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,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")
Expand Down
Loading