v2.8.0

Added

  • GitLab cross-run finding dedup (issue #710): reruns no longer post a brand-new inline discussion for a finding that already has one. post_findings fuzzy-matches each finding against this bot’s still-open prior discussions (same file, within 3 lines, compatible category: the same tolerance GitHub’s canonical-review reuse uses) and skips reposting a match, keeping the existing discussion open instead of duplicating it. Every newly-posted discussion carries a per-finding metadata marker (fingerprint/category/severity) so future runs can match against it. Narrower than GitHub’s canonical-review reuse: GitLab has no dismiss/false-positive/wont-fix/fixed verdict system, so there’s no suppression, no escalation reply, and no in-place update of an existing discussion’s body: an unmatched or more-severe finding simply gets its own fresh discussion. Set AI_GITLAB_CROSS_RUN_DEDUP=false to disable and restore the pre-dedup behavior of always posting a fresh discussion. See Features → Quiet reruns (GitHub).

Fixed

  • GitLabProvider.resolve_stale() had no concept of “a discussion this same run just created or matched as still active”: it resolved every owned, unresolved, resolvable, marker-bearing discussion on the MR unconditionally, including one post_findings had just posted moments earlier in the same run. In effect, every GitLab inline review comment was auto-collapsed as “resolved” before a human ever saw it as open, the same bug class already fixed once on GitHub (issue #718), reintroduced on GitLab from day one. Fixed with the same per-run keep-alive tracking GitHub uses; applies regardless of AI_GITLAB_CROSS_RUN_DEDUP.
  • GitLabProvider.post_findings’s failure detection compared inline_posted == 0 against len(findings) > 0, which would misclassify a run with zero inline candidates (everything routed to the body, or everything matched an existing discussion) as a failure. Now compares against len(inline_candidates) (what was actually attempted) and requires a real HTTP error, not just “nothing needed posting.”
  • /ai-pr-review dismiss, false-positive, and wont-fix on a body-level finding could reply with a raw ::warning:: GitHub Actions annotation glued onto the front of the posted PR comment. The verdict-recording failure path printed it to stdout, the same stream the calling workflow captures as the reply body. It now prints to stderr, matching every other warning in the CLI.
  • Canonical-review selection (select_canonical) could pick an empty-body review GitHub auto-creates every time the bot replies to an inline comment: a severity-escalation notice, a recurred-finding reply, or a feedback acknowledgement. Once that empty review became canonical, verdict recording failed with HTTP 422 (“Could not edit a review with a missing body”) on every subsequent dismiss/false-positive/wont-fix/fixed, and canonical-review reuse silently stopped updating in place on every rerun. select_canonical now skips reviews with an empty or whitespace-only body.
  • A finding’s stable F<n> ID could be misclassified as body-level, never resolving its thread or dismissing the blocking review, if an older review had once rendered it as an out-of-diff body bullet before it moved to an inline thread in a later run. Classification now prefers the newest review that renders the ID at all, so a finding that has moved buckets between cycles is resolved correctly. A truncated review body (over GitHub’s size limit) is also handled: its id-map marker can list an ID whose bullet was cut off by truncation, and that alone is no longer trusted as proof the ID moved inline.
  • Self-contradictory verdicts marker no longer trusted (issue #755). A GitHub review’s verdicts marker was observed claiming a finding “dismissed” in the very same review body that rendered it as a visible, non-suppressed finding, a state the classification logic proves impossible under any normal code path. Trusting a contradictory entry risks permanently and silently suppressing a finding no human ever acted on. merge_verdicts() now cross-checks each review’s verdicts marker against that same review’s own id-map and drops any dismissed/fixed entry contradicted by a rendered fingerprint in the identical body.

Changed

  • Anthropic premium default: claude-opus-4-8 → claude-opus-5. Verified with tests/canary/live_model_canary.py against tests/canary/stress_diff.txt (the code-reviewer/silent-failure-hunter agents, the same ones issue #592 crashed): 4/4 calls completed with stop_reason: end_turn. Discovered live in the process: unlike Opus 4.7/4.8, Opus 5 has adaptive thinking on by default, uncapped: a single call against the stress diff took 206s and spent 11,433 thinking tokens (vs. Opus 4.8’s 44s/0 thinking tokens on the identical prompt), exceeding llm/anthropic.py’s 180s client timeout under real load. resolve_effort() now caps Opus 5 at effort="low" the same way it already does for Sonnet 5 (issue #592), live-verified to bring the same call down to 67s/2,464 thinking tokens. resolve_temperature() also live-verified: Opus 5 rejects an explicit temperature identically to Opus 4.8 (HTTP 400 `temperature` is deprecated for this model), so the existing strip-to-omit handling now covers it too. Added a config/model-pricing.json entry for claude-opus-5 (identical rates to Opus 4.8 per Anthropic’s pricing page: $5/$25 per MTok, $6.25/$0.50 cache write/read). Set AI_MODEL_PREMIUM=claude-opus-4-8 to restore the previous default.