4 Commits

Author SHA1 Message Date
gitea_admin cdf116ece9 Merge PR #10: read the AI-USAGE opt-in at render time
Late AI-USAGE labels now reach the review body. Verified live on this PR: AI-REVIEW then AI-USAGE six seconds later, usage block present in review #131.
2026-08-20 23:42:36 +00:00
Marcos c58c00181e fix(review): read the AI-USAGE opt-in at render time, not at review start
The previous two commits put the label re-read in the webhook, at review
start. That is too early to help: the review claims on the AI-REVIEW event
and the re-read runs milliseconds later, while the reviewer's second click
(AI-USAGE) is still a second or two away. It would have kept 404ing quietly
if the path fix hadn't landed, and even fixed it caught nothing.

Move the check to where the decision is actually used — just before the
usage block is rendered, after the model has run. That is a minute or more
after the trigger, by which time the label is there. Attribution is computed
in the same branch, so a late opt-in still gets its per-comment token lines.

`pr_has_label` goes through the existing gitea_get helper, which owns the
/api/v1 prefix, so the path can't drift again. Any failure returns False and
the payload's verdict stands: a review is never lost over a usage section.

Reverts the webhook-side re-read from 4ef62f2 and aedea97.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
2026-08-20 23:35:04 +00:00
Marcos aedea973ab fix(webhook): label re-read must hit /api/v1, not the bare host
GITEA_API is the host with no version prefix — ai_review._get / _post append
/api/v1 per call. The re-read helper didn't, so it 404'd on every review and
silently fell back to the payload's verdict: "could not re-read labels for
gitea_admin/pragent#10: HTTP Error 404" in the pod log, usage block still
missing. Caught by labelling PR #10 AI-REVIEW then AI-USAGE, which is the
exact sequence the fix exists to handle.

Test asserts the composed URL, since a wrong path here fails silently by
design (the helper swallows errors so a review is never lost over a usage
section).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
2026-08-20 23:29:03 +00:00
Marcos 4ef62f28bb fix(webhook): re-read PR labels at review start so a late AI-USAGE counts
Labelling a PR is two webhook events. AI-REVIEW arrives first, the review
claims (repo, index, sha) and starts; the AI-USAGE event that follows a
moment later hits the in-flight dedupe and is dropped. `report_usage` was
snapshotted from the first payload, which had not seen AI-USAGE yet, so the
review posted without its usage block even though the label was on the PR by
the time it finished — observed on gitea_admin/pragent#9 (`usage=False` in
the pod log, no <details> section in the posted review).

Re-read the labels from the API at review start and upgrade the flag. The
read is best-effort: any failure logs and returns [], leaving the payload's
verdict intact, because a usage section is not worth failing a review over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
2026-08-20 23:24:54 +00:00
2 changed files with 72 additions and 0 deletions
+43
View File
@@ -66,6 +66,8 @@ SHA_MARKER = "<!-- pragent:sha={sha} -->"
_SHA_MARKER_RE = re.compile(r"<!-- pragent:sha=([0-9a-f]{7,40}) -->") _SHA_MARKER_RE = re.compile(r"<!-- pragent:sha=([0-9a-f]{7,40}) -->")
AI_REVIEW_LABEL = "AI-REVIEW" AI_REVIEW_LABEL = "AI-REVIEW"
# Opt-in label for the token-usage block. Read at render time — see pr_has_label.
AI_USAGE_LABEL = "AI-USAGE"
SEVERITIES = ("critical", "high", "medium", "low") SEVERITIES = ("critical", "high", "medium", "low")
# Severity rank — higher = more severe. Used by `apply_repo_config` to drop # Severity rank — higher = more severe. Used by `apply_repo_config` to drop
# findings below `severity_threshold`. Critical=3, high=2, medium=1, low=0. # findings below `severity_threshold`. Critical=3, high=2, medium=1, low=0.
@@ -156,6 +158,33 @@ def parse_text_blocks(content: list) -> str:
return "\n".join(out).strip() return "\n".join(out).strip()
def pr_has_label(api: str, repo: str, index: str, token: str, label: str) -> bool:
"""True if the PR currently carries `label`. False on any failure.
Read at RENDER time, not at review start. A reviewer labels AI-REVIEW and
AI-USAGE seconds apart; the review claims on the first event and the
second is dropped by the in-flight dedupe, so the trigger payload never
sees the opt-in. Re-reading when the review begins is no better — that is
still milliseconds after the first click. Only a read taken once the
review has finished (a minute or more later) reliably sees the label.
"""
try:
code, raw = gitea_get(api, repo, f"issues/{index}/labels", token)
if code >= 300:
return False
data = json.loads(raw.decode() or "[]")
except Exception as e:
print(f"pragent: could not re-read labels for {repo}#{index}: {e}",
file=sys.stderr, flush=True)
return False
if not isinstance(data, list):
return False
return any(
(isinstance(x, dict) and x.get("name") == label) or x == label
for x in data
)
def _int_env(name: str, default: int) -> int: def _int_env(name: str, default: int) -> int:
"""Read an int from the environment, falling back on anything unparseable. """Read an int from the environment, falling back on anything unparseable.
@@ -1896,6 +1925,13 @@ def review_pr(
file=sys.stderr, flush=True, file=sys.stderr, flush=True,
) )
salvaged = salvage_summary(stdout) salvaged = salvage_summary(stdout)
# The AI-USAGE opt-in is re-checked HERE, at render time: the label
# is usually applied moments after AI-REVIEW, long after this
# review was claimed and its trigger payload frozen.
if not report_usage:
report_usage = pr_has_label(api, repo, index, token, AI_USAGE_LABEL)
if report_usage and usage and usage.get('output'):
compute_attribution(findings, usage['output'])
usage_section = _render_collapsible_usage(usage, model, config=config) if report_usage else "" usage_section = _render_collapsible_usage(usage, model, config=config) if report_usage else ""
post_review(api, repo, index, token, format_review_body( post_review(api, repo, index, token, format_review_body(
salvaged or "AI review produced no parseable output.", salvaged or "AI review produced no parseable output.",
@@ -1936,6 +1972,13 @@ def review_pr(
# for it. # for it.
if report_usage and usage and usage.get("output"): if report_usage and usage and usage.get("output"):
compute_attribution(findings, usage["output"]) compute_attribution(findings, usage["output"])
# The AI-USAGE opt-in is re-checked HERE, at render time: the label
# is usually applied moments after AI-REVIEW, long after this
# review was claimed and its trigger payload frozen.
if not report_usage:
report_usage = pr_has_label(api, repo, index, token, AI_USAGE_LABEL)
if report_usage and usage and usage.get('output'):
compute_attribution(findings, usage['output'])
usage_section = _render_collapsible_usage(usage, model, config=config) if report_usage else "" usage_section = _render_collapsible_usage(usage, model, config=config) if report_usage else ""
# Anchor against the RAW diff, never the compressed one. Compression # Anchor against the RAW diff, never the compressed one. Compression
+29
View File
@@ -616,6 +616,35 @@ def test_reference_non_url_renders_as_plain_text():
assert "](CVE-" not in body assert "](CVE-" not in body
def test_pr_has_label_reads_the_live_labels(monkeypatch):
# The AI-USAGE opt-in is read at render time, not from the trigger
# payload: labelling AI-REVIEW then AI-USAGE is two events, the review
# claims on the first, and the second is dropped by the in-flight dedupe.
seen = {}
def _get(api, repo, path, token, accept="application/json"):
seen["path"] = path
return 200, b'[{"name": "AI-REVIEW"}, {"name": "AI-USAGE"}]'
monkeypatch.setattr(ai_review, "gitea_get", _get)
assert ai_review.pr_has_label("http://api", "o/r", "9", "t", "AI-USAGE") is True
assert seen["path"] == "issues/9/labels"
assert ai_review.pr_has_label("http://api", "o/r", "9", "t", "NOPE") is False
def test_pr_has_label_survives_a_broken_api(monkeypatch):
def _boom(*a, **k):
raise RuntimeError("gitea down")
monkeypatch.setattr(ai_review, "gitea_get", _boom)
assert ai_review.pr_has_label("http://api", "o/r", "9", "t", "AI-USAGE") is False
monkeypatch.setattr(ai_review, "gitea_get", lambda *a, **k: (404, b"nope"))
assert ai_review.pr_has_label("http://api", "o/r", "9", "t", "AI-USAGE") is False
monkeypatch.setattr(ai_review, "gitea_get", lambda *a, **k: (200, b'{"not": "a list"}'))
assert ai_review.pr_has_label("http://api", "o/r", "9", "t", "AI-USAGE") is False
def test_int_env_falls_back_on_garbage(monkeypatch, capsys): def test_int_env_falls_back_on_garbage(monkeypatch, capsys):
monkeypatch.setenv("PRAGENT_DIFF_CONTEXT", "two") monkeypatch.setenv("PRAGENT_DIFF_CONTEXT", "two")
assert ai_review._int_env("PRAGENT_DIFF_CONTEXT", 1) == 1 assert ai_review._int_env("PRAGENT_DIFF_CONTEXT", 1) == 1