Skip to content

Commit f4da912

Browse files
committed
fix: link exhausted free credits to license purchase
1 parent c38900c commit f4da912

8 files changed

Lines changed: 215 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,12 @@ python -m pip install codeboarding==0.15.1
405405
tests/run_local.sh --repo /path/to/repo --base main --head feature
406406
```
407407

408+
When CodeBoarding's free hosted credits run out, the failure comment and job summary link to
409+
[buy a CodeBoarding license](https://codeboarding.org/pricing) and explain how to configure
410+
`llm: license` and `license_key`. The free weekly allowance is shared across repositories
411+
under the same GitHub owner. A supplied license that did not grant paid access gets a
412+
license-check hint; provider billing failures keep their own guidance.
413+
408414
## License
409415

410416
MIT. See [LICENSE](LICENSE).

‎action.yml‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -741,17 +741,21 @@ runs:
741741
# this same sticky comment with the input and secret to fix, and "see the workflow
742742
# logs" posted over the top of it would send the reader hunting for what they had
743743
# just been told.
744-
- name: Post review failure
744+
- name: Build review failure
745+
id: review_failure
745746
if: failure() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_comment.outcome != 'success' && steps.llm.outputs.error == ''
746747
continue-on-error: true
748+
shell: bash
749+
env:
750+
FAILURE_REASON: ${{ steps.review_baseline.outputs.failure_reason || steps.review_analyze.outputs.failure_reason }}
751+
run: '"$GITHUB_ACTION_PATH/scripts/action/build-review-failure.sh"'
752+
753+
- name: Post review failure
754+
if: failure() && steps.review_failure.outputs.path != ''
755+
continue-on-error: true
747756
uses: marocchino/sticky-pull-request-comment@v2
748757
with:
749758
header: ${{ steps.guard.outputs.comment_id }}
750759
number: ${{ steps.guard.outputs.pr_number }}
751760
GITHUB_TOKEN: ${{ inputs.github_token }}
752-
message: |
753-
### CodeBoarding review · failed
754-
755-
${{ steps.review_baseline.outputs.failure_reason || steps.review_analyze.outputs.failure_reason }}
756-
757-
See the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
761+
path: ${{ steps.review_failure.outputs.path }}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env bash
2+
# Builds the failure body shared by the PR comment and the job summary.
3+
set -euo pipefail
4+
BODY="${RUNNER_TEMP}/review-failure.md"
5+
{
6+
printf '### CodeBoarding review · failed\n\n'
7+
if [ -n "${FAILURE_REASON:-}" ]; then
8+
printf '%s\n\n' "$FAILURE_REASON"
9+
fi
10+
printf 'See the [workflow logs](%s/%s/actions/runs/%s).\n' \
11+
"$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID"
12+
} > "$BODY"
13+
echo "path=$BODY" >> "$GITHUB_OUTPUT"
14+
cat "$BODY" >> "$GITHUB_STEP_SUMMARY"

‎scripts/action/with-auth.sh‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ for file in "$AUTH_DIR/env"/*; do
3030
done
3131

3232
export CODEBOARDING_SOURCE=github_action
33+
CODEBOARDING_LLM_TIER="$(cat "$AUTH_DIR/tier")"
34+
export CODEBOARDING_LLM_TIER
3335
unset ACTIONS_ID_TOKEN_REQUEST_URL ACTIONS_ID_TOKEN_REQUEST_TOKEN
3436
if [ -n "${MODEL:-}" ]; then
3537
export AGENT_MODEL="$MODEL"

‎scripts/analyze_repository.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import shutil
1010
import subprocess
1111
import sys
12+
import tempfile
1213
from pathlib import Path
1314

1415
PROG = "codeboarding"
@@ -19,6 +20,28 @@
1920
3: "The LLM provider's token or credit quota is exhausted. Add credits or raise the quota, then re-run.",
2021
}
2122

23+
PRICING_LINK = "[Buy a CodeBoarding license](https://codeboarding.org/pricing)"
24+
QUOTA_REFUSALS = {
25+
"free_quota_exhausted": (
26+
"Your GitHub owner's free CodeBoarding credits for this week are exhausted. "
27+
"The allowance is shared across its repositories. "
28+
f"{PRICING_LINK} to continue now, or wait for the weekly allowance to reset. "
29+
"After purchasing, add the `CODEBOARDING_LICENSE` repository secret and set "
30+
"`llm: license` and `license_key: ${{ secrets.CODEBOARDING_LICENSE }}` "
31+
"on each CodeBoarding Action invocation, then re-run."
32+
),
33+
"license_not_accepted": (
34+
"The supplied CodeBoarding license did not grant paid access, and your GitHub owner's free credits "
35+
"are exhausted. Check that the license is active and `license_key` uses the correct "
36+
"`CODEBOARDING_LICENSE` secret. "
37+
"[Manage or buy a CodeBoarding license](https://codeboarding.org/pricing), then re-run."
38+
),
39+
"hosted_provider_quota_exhausted": (
40+
"CodeBoarding's hosted LLM provider has exhausted its credits or quota. "
41+
"Please contact CodeBoarding support; this is not your free-credit allowance."
42+
),
43+
}
44+
2245

2346
class AnalysisError(RuntimeError):
2447
pass
@@ -74,12 +97,19 @@ def _parse_cli_response(raw: str, working_dir: str) -> tuple[bool, Path | None,
7497

7598

7699
def _run_command(args: list[str], output_dir: Path) -> str:
100+
with tempfile.TemporaryDirectory(prefix="codeboarding-error-") as temp_dir:
101+
error_file = Path(temp_dir) / "error.json"
102+
return _run_with_error_report(args, output_dir, error_file)
103+
104+
105+
def _run_with_error_report(args: list[str], output_dir: Path, error_file: Path) -> str:
77106
process = subprocess.Popen(
78107
args,
79108
stdout=subprocess.PIPE,
80109
text=True,
81110
bufsize=1,
82111
cwd=str(output_dir.parent),
112+
env={**os.environ, "CODEBOARDING_ERROR_FILE": str(error_file)},
83113
)
84114
stdout_lines: list[str] = []
85115
for line in process.stdout or ():
@@ -88,10 +118,23 @@ def _run_command(args: list[str], output_dir: Path) -> str:
88118
# CLI stdout to stderr so engine progress remains visible in Actions.
89119
print(line, end="", file=sys.stderr, flush=True)
90120

121+
if process.stdout is not None:
122+
process.stdout.close()
91123
return_code = process.wait()
92124
stdout = "".join(stdout_lines)
93125
if return_code != 0:
94126
reason = ENGINE_REFUSALS.get(return_code)
127+
if return_code == 3:
128+
try:
129+
report = json.loads(error_file.read_text(encoding="utf-8"))
130+
except (OSError, ValueError):
131+
report = {}
132+
if isinstance(report, dict) and report.get("version") == 1:
133+
code = report.get("code")
134+
if isinstance(code, str):
135+
if code == "free_quota_exhausted" and os.getenv("CODEBOARDING_LLM_TIER") == "license":
136+
code = "license_not_accepted"
137+
reason = QUOTA_REFUSALS.get(code, reason)
95138
if reason:
96139
# Written straight to the step's outputs: the shell never sees this script's stdout on failure.
97140
with open(os.environ.get("GITHUB_OUTPUT", os.devnull), "a", encoding="utf-8") as outputs:

‎tests/test_action_inputs.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,12 @@ def test_the_generic_failure_comment_never_buries_the_actionable_one(self) -> No
139139
fix, then immediately replaces it with "see the workflow logs" -- sending the reader
140140
to hunt for what they had just been told.
141141
"""
142-
start = ACTION.index("- name: Post review failure")
143-
condition = ACTION[start : ACTION.index("message:", start)]
142+
start = ACTION.index("- name: Build review failure")
143+
condition = ACTION[start : ACTION.index("run:", start)]
144144
self.assertIn("steps.llm.outputs.error == ''", condition)
145+
start = ACTION.index("- name: Post review failure")
146+
condition = ACTION[start : ACTION.index("uses:", start)]
147+
self.assertIn("steps.review_failure.outputs.path != ''", condition)
145148

146149
def test_python_is_available_before_the_credential_check_runs(self) -> None:
147150
"""The check is a Python program, so a runner without a system python3 would fail a

‎tests/test_quota_comment.py‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Exercise core's subprocess refusal contract through the posted comment body."""
2+
3+
import json
4+
import os
5+
import re
6+
import subprocess
7+
import sys
8+
import tempfile
9+
import unittest
10+
from pathlib import Path
11+
from unittest.mock import patch
12+
13+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
14+
import analyze_repository as ar
15+
16+
ROOT = Path(__file__).resolve().parent.parent
17+
PRICING = "https://codeboarding.org/pricing"
18+
19+
20+
class QuotaCommentTests(unittest.TestCase):
21+
def _failure_body(self, report: object, tier: str = "hosted", mode: str = "review") -> tuple[str, str]:
22+
with tempfile.TemporaryDirectory() as tmp:
23+
root = Path(tmp)
24+
outputs = root / "analysis-outputs"
25+
program = (
26+
"import os,sys; from pathlib import Path; "
27+
f"Path(os.environ['CODEBOARDING_ERROR_FILE']).write_text({json.dumps(json.dumps(report))}); "
28+
"sys.exit(3)"
29+
)
30+
with (
31+
patch.dict(os.environ, {"GITHUB_OUTPUT": str(outputs), "CODEBOARDING_LLM_TIER": tier}),
32+
self.assertRaises(ar.AnalysisError),
33+
):
34+
ar._run_command([sys.executable, "-c", program], root / "out")
35+
36+
reason = outputs.read_text().removeprefix("failure_reason=").strip()
37+
summary = root / "summary.md"
38+
script = "build-review-failure.sh" if mode == "review" else "sync-summary.sh"
39+
result = subprocess.run(
40+
[str(ROOT / "scripts" / "action" / script)],
41+
env={
42+
"PATH": os.environ["PATH"],
43+
"RUNNER_TEMP": tmp,
44+
"GITHUB_OUTPUT": str(root / "comment-outputs"),
45+
"GITHUB_STEP_SUMMARY": str(summary),
46+
"GITHUB_SERVER_URL": "https://github.com",
47+
"GITHUB_REPOSITORY": "octocat/hello",
48+
"GITHUB_RUN_ID": "123",
49+
"FAILURE_REASON": reason,
50+
},
51+
capture_output=True,
52+
text=True,
53+
check=True,
54+
)
55+
self.assertEqual(result.stderr, "")
56+
text = summary.read_text()
57+
if mode == "review":
58+
self.assertEqual(text, (root / "review-failure.md").read_text())
59+
self.assertIn(f"path={root / 'review-failure.md'}", (root / "comment-outputs").read_text())
60+
return text, reason
61+
62+
def test_free_credits_comment_links_to_purchase_and_explains_setup(self):
63+
body, reason = self._failure_body({"version": 1, "code": "free_quota_exhausted"})
64+
self.assertIn("### CodeBoarding review · failed", body)
65+
self.assertIn("free CodeBoarding credits for this week are exhausted", body)
66+
self.assertIn(f"[Buy a CodeBoarding license]({PRICING})", body)
67+
self.assertIn("shared across its repositories", body)
68+
self.assertIn("`CODEBOARDING_LICENSE`", body)
69+
self.assertIn("`llm: license`", body)
70+
self.assertIn("`license_key: ${{ secrets.CODEBOARDING_LICENSE }}`", body)
71+
self.assertIn("https://github.com/octocat/hello/actions/runs/123", body)
72+
self.assertNotIn("Traceback", body)
73+
self.assertIn(reason, body)
74+
75+
def test_sync_reports_the_same_purchase_instructions(self):
76+
body, reason = self._failure_body({"version": 1, "code": "free_quota_exhausted"}, mode="sync")
77+
self.assertIn(f"[Buy a CodeBoarding license]({PRICING})", body)
78+
self.assertIn(reason, body)
79+
80+
def test_supplied_license_is_checked_before_suggesting_another_purchase(self):
81+
for code in ("license_not_accepted", "free_quota_exhausted"):
82+
with self.subTest(code=code):
83+
body, _ = self._failure_body({"version": 1, "code": code}, tier="license")
84+
self.assertIn("Check that the license is active", body)
85+
self.assertIn(f"]({PRICING})", body)
86+
self.assertNotIn("After purchasing", body)
87+
88+
def test_provider_billing_does_not_sell_a_codeboarding_license(self):
89+
for code in ("provider_quota_exhausted", "hosted_provider_quota_exhausted"):
90+
with self.subTest(code=code):
91+
body, _ = self._failure_body({"version": 1, "code": code})
92+
self.assertNotIn(PRICING, body)
93+
self.assertNotIn("Buy", body)
94+
95+
def test_unknown_or_invalid_report_keeps_the_legacy_message(self):
96+
for report in (
97+
[],
98+
{"version": 2, "code": "free_quota_exhausted"},
99+
{"version": 1, "code": []},
100+
{"version": 1, "code": "unknown"},
101+
):
102+
with self.subTest(report=report):
103+
body, _ = self._failure_body(report)
104+
self.assertIn("LLM provider's token or credit quota is exhausted", body)
105+
self.assertNotIn(PRICING, body)
106+
107+
def test_workflow_posts_the_tested_failure_file(self):
108+
action = (ROOT / "action.yml").read_text()
109+
steps = re.split(r"\n - name: ", action[action.index("\nruns:\n") :])[1:]
110+
build = next(step for step in steps if step.startswith("Build review failure\n"))
111+
post = next(step for step in steps if step.startswith("Post review failure\n"))
112+
self.assertIn("failure()", build)
113+
self.assertIn(
114+
"steps.review_baseline.outputs.failure_reason || steps.review_analyze.outputs.failure_reason", build
115+
)
116+
self.assertIn("build-review-failure.sh", build)
117+
self.assertIn("marocchino/sticky-pull-request-comment@v2", post)
118+
self.assertIn("path: ${{ steps.review_failure.outputs.path }}", post)
119+
self.assertIn("header: ${{ steps.guard.outputs.comment_id }}", post)
120+
121+
122+
if __name__ == "__main__":
123+
unittest.main()

‎tests/test_with_auth.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ def test_a_step_that_keeps_them_leaves_them_for_the_next(self) -> None:
4444
self.assertEqual(self._run().returncode, 0, "the second analysis step found no credentials")
4545
self.assertFalse(self.auth.exists())
4646

47+
def test_the_resolved_tier_reaches_core(self) -> None:
48+
(self.auth / "tier").write_text("license", encoding="utf-8")
49+
result = subprocess.run(
50+
[str(WITH_AUTH), "bash", "-c", 'test "$CODEBOARDING_LLM_TIER" = license'],
51+
env={"PATH": os.environ["PATH"], "RUNNER_TEMP": str(self.runner_temp)},
52+
capture_output=True,
53+
text=True,
54+
check=False,
55+
)
56+
self.assertEqual(result.returncode, 0, result.stderr)
57+
4758

4859
class ActionWiringTests(unittest.TestCase):
4960
def _steps(self) -> list[str]:

0 commit comments

Comments
 (0)