Merge pull request #41494 from BerriAI/litellm_auto_merge_price_sync

ci: auto-merge provider-info-sync PRs when CI, Greptile and Bugbot are clean
This commit is contained in:
kerry-berri 2026-09-16 17:36:02 -07:00 committed by GitHub
commit db408f68ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 884 additions and 1 deletions

View file

@ -1,12 +1,14 @@
#!/usr/bin/env bash
set -uo pipefail
category="${1:?usage: classify_changes.sh <backend|client|ui|provider-harness>}"
category="${1:?usage: classify_changes.sh <backend|client|ui|provider-harness|cost-map-only>}"
has_client=false
has_backend=false
has_ci=false
has_provider_harness=false
has_cost_map=false
outside_cost_map_set=false
while IFS= read -r file || [ -n "$file" ]; do
[ -n "$file" ] || continue
case "$file" in
@ -20,9 +22,18 @@ while IFS= read -r file || [ -n "$file" ]; do
.github/* | .circleci/*) has_ci=true; has_backend=true ;;
*) has_backend=true ;;
esac
case "$file" in
model_prices_and_context_window.json | litellm/model_prices_and_context_window_backup.json | model_prices_and_context_window.schema.json)
has_cost_map=true ;;
tests/test_litellm/* | tests/proxy_unit_tests/*) : ;;
*) outside_cost_map_set=true ;;
esac
done
case "$category" in
cost-map-only)
{ [ "$has_cost_map" = true ] && [ "$outside_cost_map_set" = false ]; } && echo run || echo skip
;;
provider-harness)
[ "$has_provider_harness" = true ] && echo run || echo skip
;;

465
.github/scripts/auto_merge_price_sync.py vendored Normal file
View file

@ -0,0 +1,465 @@
"""Auto-merge the provider-info-sync bot's cost-map pull requests.
Evaluates every gate (author allowlist, cost-map-only diff, required and
non-required checks, Greptile confidence, Bugbot review, human reviews) and
merges with a merge commit when all of them hold. Every hold reason is
logged; the process exits 0 on hold and 1 only on API or programming errors.
``DRY_RUN=1`` prints the verdict without calling the merge endpoint.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Final
REPO_ROOT: Final = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CLASSIFY_SCRIPT: Final = os.path.join(REPO_ROOT, ".circleci", "scripts", "classify_changes.sh")
API_ROOT: Final = "https://api.github.com"
CHANGED_FILE_CEILING: Final = 3000
OK_CHECK_CONCLUSIONS: Final = frozenset({"success", "skipped", "neutral"})
GREPTILE_LOGIN: Final = "greptile-apps[bot]"
BUGBOT_LOGIN: Final = "cursor[bot]"
GREPTILE_SCORE_RE: Final = re.compile(r"Confidence Score:\s*(\d)/5")
BUGBOT_REVIEW_MARKER: Final = "<!-- BUGBOT_REVIEW -->"
BUGBOT_STALE_MARKER: Final = "<!-- BUGBOT_REVIEW_STALE -->"
BUGBOT_CLEAN: Final = "found no new issues"
@dataclass(frozen=True, slots=True)
class PullRequest:
number: int
title: str
author_login: str
state: str
draft: bool
mergeable: bool | None
mergeable_state: str
head_sha: str
@dataclass(frozen=True, slots=True)
class CheckRun:
name: str
status: str
conclusion: str | None
@dataclass(frozen=True, slots=True)
class CommitStatus:
context: str
state: str
@dataclass(frozen=True, slots=True)
class IssueComment:
author_login: str
body: str
updated_at: datetime
@dataclass(frozen=True, slots=True)
class Review:
author_login: str
state: str
body: str
commit_id: str
submitted_at: datetime
@dataclass(frozen=True, slots=True)
class Verdict:
merge: bool
reasons: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class EvaluationInputs:
pr: PullRequest
changed_files: tuple[str, ...]
required_contexts: frozenset[str]
check_runs: tuple[CheckRun, ...]
statuses: tuple[CommitStatus, ...]
comments: tuple[IssueComment, ...]
reviews: tuple[Review, ...]
head_commit_date: datetime
self_check_name: str
author_allowlist: frozenset[str]
def _is_bot_login(login: str) -> bool:
return login.lower().endswith("[bot]")
def _classify(changed_files: Sequence[str]) -> str:
result: Final = subprocess.run(
["bash", CLASSIFY_SCRIPT, "cost-map-only"],
input="\n".join(changed_files),
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return "error"
return result.stdout.strip()
def evaluate(
inputs: EvaluationInputs,
*,
classify: Callable[[Sequence[str]], str] = _classify,
) -> Verdict:
pr: Final = inputs.pr
reasons: list[str] = []
if pr.author_login.lower() not in {login.lower() for login in inputs.author_allowlist}:
reasons.append(f"author {pr.author_login!r} not in allowlist")
if pr.state != "open":
reasons.append("pr not open")
if pr.draft:
reasons.append("pr is a draft")
if pr.mergeable is None:
reasons.append("mergeability unknown")
elif not pr.mergeable:
reasons.append("pr not mergeable")
if pr.mergeable_state == "dirty":
reasons.append("pr has merge conflicts")
if len(inputs.changed_files) > CHANGED_FILE_CEILING:
reasons.append(f"changed file count {len(inputs.changed_files)} over {CHANGED_FILE_CEILING} ceiling")
else:
decision: Final = classify(inputs.changed_files)
if decision != "run":
reasons.append("changed files outside the cost-map-only set")
green_runs: Final = frozenset(run.name for run in inputs.check_runs if run.conclusion in OK_CHECK_CONCLUSIONS)
green_statuses: Final = frozenset(status.context for status in inputs.statuses if status.state == "success")
for context in sorted(inputs.required_contexts):
if context not in green_runs and context not in green_statuses:
reasons.append(f"required check {context!r} not green")
for run in inputs.check_runs:
if run.name == inputs.self_check_name:
continue
if run.status != "completed" or run.conclusion not in OK_CHECK_CONCLUSIONS:
reasons.append(f"check run {run.name!r} is {run.status}/{run.conclusion}")
for status in inputs.statuses:
if status.state != "success":
reasons.append(f"commit status {status.context!r} is {status.state}")
greptile: Final = tuple(
comment
for comment in inputs.comments
if comment.author_login == GREPTILE_LOGIN and GREPTILE_SCORE_RE.search(comment.body)
)
if not greptile:
reasons.append("greptile score not available")
else:
latest: Final = max(greptile, key=lambda comment: comment.updated_at)
match: Final = GREPTILE_SCORE_RE.search(latest.body)
score: Final = int(match.group(1)) if match else 0
if latest.updated_at < inputs.head_commit_date:
reasons.append("greptile score older than head commit")
elif score != 5:
reasons.append(f"greptile score {score}/5 below 5")
bugbot: Final = tuple(
review
for review in inputs.reviews
if review.author_login == BUGBOT_LOGIN
and BUGBOT_REVIEW_MARKER in review.body
and BUGBOT_STALE_MARKER not in review.body
and review.commit_id == pr.head_sha
)
if not bugbot:
reasons.append("bugbot review not available")
else:
latest_review: Final = max(bugbot, key=lambda review: review.submitted_at)
if BUGBOT_CLEAN not in latest_review.body:
reasons.append("bugbot reported issues")
latest_state_by_reviewer: Final[dict[str, str]] = {}
for review in sorted(inputs.reviews, key=lambda review: review.submitted_at):
if _is_bot_login(review.author_login):
continue
latest_state_by_reviewer[review.author_login] = review.state
for reviewer, state in latest_state_by_reviewer.items():
if state == "CHANGES_REQUESTED":
reasons.append(f"changes requested by {reviewer}")
return Verdict(merge=not reasons, reasons=tuple(reasons))
def _request(token: str, method: str, path: str, body: Mapping[str, object] | None = None) -> object:
url: Final = path if path.startswith("http") else f"{API_ROOT}{path}"
data: Final = None if body is None else json.dumps(body).encode("utf-8")
request: Final = urllib.request.Request(
url,
data=data,
method=method,
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with urllib.request.urlopen(request) as response:
return json.loads(response.read().decode("utf-8"))
def _request_allow_fail(
token: str, method: str, path: str, body: Mapping[str, object] | None = None
) -> tuple[int, object | None]:
url: Final = path if path.startswith("http") else f"{API_ROOT}{path}"
data: Final = None if body is None else json.dumps(body).encode("utf-8")
request: Final = urllib.request.Request(
url,
data=data,
method=method,
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
},
)
try:
with urllib.request.urlopen(request) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
return exc.code, None
def _items(payload: object, key: str | None = None) -> tuple[object, ...]:
source: Final = payload.get(key) if key and isinstance(payload, Mapping) else payload
if not isinstance(source, list):
return ()
return tuple(source)
def _paginate(token: str, path: str, key: str | None = None) -> list[object]:
separator: Final = "&" if "?" in path else "?"
results: list[object] = []
for page in range(1, 10_000):
batch: Final = _items(_request(token, "GET", f"{path}{separator}per_page=100&page={page}"), key)
results.extend(batch)
if len(batch) < 100:
return results
return results
def _text(value: object) -> str:
return value if isinstance(value, str) else ""
def _int(value: object) -> int:
return value if isinstance(value, int) else 0
def _bool(value: object) -> bool:
return value is True
def _nested(value: object, *keys: str) -> object:
current: object = value
for key in keys:
if not isinstance(current, Mapping):
return None
current = current.get(key)
return current
def _parse_time(value: object) -> datetime:
text: Final = _text(value)
if not text:
return datetime.min.replace(tzinfo=timezone.utc)
return datetime.fromisoformat(text.replace("Z", "+00:00"))
def _load_pr(token: str, repo: str, number: int) -> PullRequest:
data: Final = _request(token, "GET", f"/repos/{repo}/pulls/{number}")
if not isinstance(data, Mapping):
raise RuntimeError(f"unexpected pull payload for #{number}")
return PullRequest(
number=number,
title=_text(data.get("title")),
author_login=_text(_nested(data, "user", "login")),
state=_text(data.get("state")),
draft=_bool(data.get("draft")),
mergeable=data.get("mergeable") if isinstance(data.get("mergeable"), bool) else None,
mergeable_state=_text(data.get("mergeable_state")),
head_sha=_text(_nested(data, "head", "sha")),
)
def _list_candidate_prs(token: str, repo: str, base: str, allowlist: frozenset[str]) -> list[int]:
candidates: Final = _paginate(token, f"/repos/{repo}/pulls?state=open&base={base}")
return [
_int(item.get("number"))
for item in candidates
if isinstance(item, Mapping) and _text(_nested(item, "user", "login")).lower() in allowlist
]
def _changed_files(token: str, repo: str, number: int) -> tuple[str, ...]:
files: Final = _paginate(token, f"/repos/{repo}/pulls/{number}/files")
return tuple(_text(item.get("filename")) for item in files if isinstance(item, Mapping))
def _required_contexts(token: str, repo: str, base: str) -> frozenset[str]:
payload: Final = _request(token, "GET", f"/repos/{repo}/rules/branches/{base}")
contexts: set[str] = set()
for rule in _items(payload):
if not isinstance(rule, Mapping) or rule.get("type") != "required_status_checks":
continue
checks: Final = _nested(rule, "parameters", "required_status_checks")
for check in _items(checks):
if isinstance(check, Mapping):
context: Final = _text(check.get("context"))
if context:
contexts.add(context)
return frozenset(contexts)
def _check_runs(token: str, repo: str, sha: str) -> tuple[CheckRun, ...]:
runs: Final = _paginate(token, f"/repos/{repo}/commits/{sha}/check-runs", key="check_runs")
return tuple(
CheckRun(
name=_text(item.get("name")),
status=_text(item.get("status")),
conclusion=item.get("conclusion") if isinstance(item.get("conclusion"), str) else None,
)
for item in runs
if isinstance(item, Mapping)
)
def _statuses(token: str, repo: str, sha: str) -> tuple[CommitStatus, ...]:
payload: Final = _request(token, "GET", f"/repos/{repo}/commits/{sha}/status")
return tuple(
CommitStatus(context=_text(item.get("context")), state=_text(item.get("state")))
for item in _items(payload, "statuses")
if isinstance(item, Mapping)
)
def _comments(token: str, repo: str, number: int) -> tuple[IssueComment, ...]:
comments: Final = _paginate(token, f"/repos/{repo}/issues/{number}/comments")
return tuple(
IssueComment(
author_login=_text(_nested(item, "user", "login")),
body=_text(item.get("body")),
updated_at=_parse_time(item.get("updated_at")),
)
for item in comments
if isinstance(item, Mapping)
)
def _reviews(token: str, repo: str, number: int) -> tuple[Review, ...]:
reviews: Final = _paginate(token, f"/repos/{repo}/pulls/{number}/reviews")
return tuple(
Review(
author_login=_text(_nested(item, "user", "login")),
state=_text(item.get("state")),
body=_text(item.get("body")),
commit_id=_text(item.get("commit_id")),
submitted_at=_parse_time(item.get("submitted_at")),
)
for item in reviews
if isinstance(item, Mapping)
)
def _head_commit_date(token: str, repo: str, number: int) -> datetime:
commits: Final = _paginate(token, f"/repos/{repo}/pulls/{number}/commits")
if not commits:
return datetime.min.replace(tzinfo=timezone.utc)
last: Final = commits[-1]
if not isinstance(last, Mapping):
return datetime.min.replace(tzinfo=timezone.utc)
return _parse_time(_nested(last, "commit", "committer", "date"))
def _mergeable_or_refetch(token: str, repo: str, pr: PullRequest) -> PullRequest:
if pr.mergeable is not None:
return pr
time.sleep(5)
return _load_pr(token, repo, pr.number)
def _gather_inputs(
token: str,
repo: str,
number: int,
base: str,
self_check_name: str,
allowlist: frozenset[str],
) -> EvaluationInputs:
pr: Final = _mergeable_or_refetch(token, repo, _load_pr(token, repo, number))
return EvaluationInputs(
pr=pr,
changed_files=_changed_files(token, repo, number),
required_contexts=_required_contexts(token, repo, base),
check_runs=_check_runs(token, repo, pr.head_sha),
statuses=_statuses(token, repo, pr.head_sha),
comments=_comments(token, repo, number),
reviews=_reviews(token, repo, number),
head_commit_date=_head_commit_date(token, repo, number),
self_check_name=self_check_name,
author_allowlist=allowlist,
)
def merge_request_body(pr: PullRequest) -> dict[str, str]:
return {"merge_method": "merge", "commit_title": f"{pr.title} (#{pr.number})", "sha": pr.head_sha}
def _merge(token: str, repo: str, pr: PullRequest) -> None:
status, _ = _request_allow_fail(token, "PUT", f"/repos/{repo}/pulls/{pr.number}/merge", merge_request_body(pr))
if status in (200, 405, 409):
print(f"auto-merge-price-sync: PR #{pr.number} merge call returned {status}")
return
raise RuntimeError(f"merge call for PR #{pr.number} returned {status}")
def main() -> int:
token: Final = os.environ.get("GH_TOKEN", "")
repo: Final = os.environ.get("REPO", "")
base: Final = os.environ.get("BASE_BRANCH", "main")
dry_run: Final = os.environ.get("DRY_RUN", "") != ""
self_check_name: Final = os.environ.get("SELF_CHECK_NAME", "auto-merge-price-sync")
allowlist: Final = frozenset(login.lower() for login in os.environ.get("PR_AUTHOR_ALLOWLIST", "").split() if login)
if not token:
print("auto-merge-price-sync: app credentials not configured")
return 0
if not repo:
print("auto-merge-price-sync: REPO not set", file=sys.stderr)
return 1
pr_number_env: Final = os.environ.get("PR_NUMBER", "")
candidates: Final = [int(pr_number_env)] if pr_number_env else _list_candidate_prs(token, repo, base, allowlist)
for number in candidates:
inputs: Final = _gather_inputs(token, repo, number, base, self_check_name, allowlist)
verdict: Final = evaluate(inputs)
for reason in verdict.reasons:
print(f"auto-merge-price-sync: PR #{number} hold: {reason}")
if not verdict.merge:
continue
print(f"auto-merge-price-sync: PR #{number} all gates green")
if dry_run:
print(f"auto-merge-price-sync: DRY_RUN merge suppressed for PR #{number}")
continue
_merge(token, repo, inputs.pr)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,61 @@
name: auto-merge-price-sync
on:
issue_comment:
types: [created, edited]
check_suite:
types: [completed]
status: {}
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
inputs:
pr-number:
description: "Evaluate only this PR number (empty = scan all open sync-bot PRs)"
required: false
default: ""
permissions:
contents: read
pull-requests: read
checks: read
statuses: read
concurrency:
group: auto-merge-price-sync
cancel-in-progress: false
jobs:
auto-merge-price-sync:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
PROVIDER_INFO_SYNC_APP_ID: ${{ secrets.PROVIDER_INFO_SYNC_APP_ID }}
PROVIDER_INFO_SYNC_APP_PRIVATE_KEY: ${{ secrets.PROVIDER_INFO_SYNC_APP_PRIVATE_KEY }}
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Mint app token
id: app-token
if: ${{ env.PROVIDER_INFO_SYNC_APP_ID != '' && env.PROVIDER_INFO_SYNC_APP_PRIVATE_KEY != '' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.PROVIDER_INFO_SYNC_APP_ID }}
private-key: ${{ secrets.PROVIDER_INFO_SYNC_APP_PRIVATE_KEY }}
- name: Auto-merge eligible sync PRs
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ (github.event.issue.pull_request && github.event.issue.number) || github.event.inputs.pr-number || '' }}
BASE_BRANCH: main
PR_AUTHOR_ALLOWLIST: "berriai-litellm-provider-info-sync[bot]"
SELF_CHECK_NAME: auto-merge-price-sync
run: python3 .github/scripts/auto_merge_price_sync.py

View file

@ -0,0 +1,323 @@
"""Tests for .github/scripts/auto_merge_price_sync.py.
`evaluate` is pure: it takes the pull request plus the fetched facts and
returns a Verdict, so each gate is exercised by building inputs where exactly
one condition fails and asserting the matching hold reason. A merge verdict
is the thing that spends an unreviewed merge, so the defaults below are the
happy path that every case perturbs one part of.
"""
import importlib.util
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Final
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[2]
_MODULE_PATH = _REPO_ROOT / ".github" / "scripts" / "auto_merge_price_sync.py"
_spec = importlib.util.spec_from_file_location("auto_merge_price_sync", _MODULE_PATH)
merger = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = merger
_spec.loader.exec_module(merger)
HEAD_SHA: Final = "deadbeef" * 5
HEAD_DATE: Final = datetime(2026, 1, 10, tzinfo=timezone.utc)
ALLOWLIST: Final = frozenset({"berriai-litellm-provider-info-sync[bot]"})
COST_MAP_FILES: Final = ("model_prices_and_context_window.json",)
def _pr(**overrides: object) -> merger.PullRequest:
base: Final = {
"number": 1,
"title": "sync prices",
"author_login": "berriai-litellm-provider-info-sync[bot]",
"state": "open",
"draft": False,
"mergeable": True,
"mergeable_state": "clean",
"head_sha": HEAD_SHA,
}
return merger.PullRequest(**{**base, **overrides})
def _greptile(score: int, updated_at: datetime) -> merger.IssueComment:
return merger.IssueComment(
author_login="greptile-apps[bot]",
body=f"Confidence Score: {score}/5",
updated_at=updated_at,
)
def _bugbot(commit_id: str, body: str, submitted_at: datetime) -> merger.Review:
return merger.Review(
author_login="cursor[bot]",
state="COMMENTED",
body=body,
commit_id=commit_id,
submitted_at=submitted_at,
)
def _inputs(**overrides: object) -> merger.EvaluationInputs:
base: Final = {
"pr": _pr(),
"changed_files": COST_MAP_FILES,
"required_contexts": frozenset({"build"}),
"check_runs": (merger.CheckRun(name="build", status="completed", conclusion="success"),),
"statuses": (),
"comments": (_greptile(5, datetime(2026, 1, 11, tzinfo=timezone.utc)),),
"reviews": (
_bugbot(
HEAD_SHA,
"<!-- BUGBOT_REVIEW --> cursor bugbot found no new issues",
datetime(2026, 1, 11, tzinfo=timezone.utc),
),
),
"head_commit_date": HEAD_DATE,
"self_check_name": "auto-merge-price-sync",
"author_allowlist": ALLOWLIST,
}
return merger.EvaluationInputs(**{**base, **overrides})
def _evaluate(inputs: merger.EvaluationInputs) -> merger.Verdict:
return merger.evaluate(inputs, classify=lambda files: "run")
def _holds(inputs: merger.EvaluationInputs, fragment: str) -> merger.Verdict:
verdict: Final = _evaluate(inputs)
assert not verdict.merge
assert any(fragment in reason for reason in verdict.reasons), verdict.reasons
return verdict
def test_happy_path_merges() -> None:
verdict: Final = _evaluate(_inputs())
assert verdict.merge
assert verdict.reasons == ()
def test_non_allowlisted_author_holds() -> None:
_holds(_inputs(pr=_pr(author_login="octocat")), "not in allowlist")
def test_closed_pr_holds() -> None:
_holds(_inputs(pr=_pr(state="closed")), "pr not open")
def test_draft_pr_holds() -> None:
_holds(_inputs(pr=_pr(draft=True)), "draft")
def test_unmergeable_pr_holds() -> None:
_holds(_inputs(pr=_pr(mergeable=False)), "not mergeable")
def test_dirty_pr_holds() -> None:
_holds(_inputs(pr=_pr(mergeable_state="dirty")), "merge conflicts")
def test_non_cost_map_files_hold() -> None:
verdict: Final = merger.evaluate(_inputs(changed_files=("litellm/utils.py",)), classify=lambda files: "skip")
assert not verdict.merge
assert any("cost-map-only" in reason for reason in verdict.reasons)
def test_required_context_missing_holds() -> None:
_holds(_inputs(check_runs=()), "required check 'build' not green")
def test_required_context_via_commit_status_passes() -> None:
verdict: Final = _evaluate(
_inputs(
check_runs=(),
statuses=(merger.CommitStatus(context="build", state="success"),),
)
)
assert verdict.merge
def test_failing_check_run_holds() -> None:
_holds(
_inputs(
check_runs=(
merger.CheckRun(name="build", status="completed", conclusion="success"),
merger.CheckRun(name="lint", status="completed", conclusion="failure"),
)
),
"check run 'lint' is completed/failure",
)
def test_in_progress_check_run_holds() -> None:
_holds(
_inputs(
check_runs=(
merger.CheckRun(name="build", status="completed", conclusion="success"),
merger.CheckRun(name="ui", status="in_progress", conclusion=None),
)
),
"check run 'ui'",
)
def test_own_check_run_is_ignored() -> None:
verdict: Final = _evaluate(
_inputs(
check_runs=(
merger.CheckRun(name="build", status="completed", conclusion="success"),
merger.CheckRun(name="auto-merge-price-sync", status="in_progress", conclusion=None),
)
)
)
assert verdict.merge
def test_pending_commit_status_holds() -> None:
_holds(
_inputs(statuses=(merger.CommitStatus(context="codecov", state="pending"),)),
"commit status 'codecov' is pending",
)
def test_greptile_missing_holds() -> None:
_holds(_inputs(comments=()), "greptile score not available")
def test_greptile_four_of_five_holds() -> None:
_holds(
_inputs(comments=(_greptile(4, datetime(2026, 1, 11, tzinfo=timezone.utc)),)),
"greptile score 4/5",
)
def test_greptile_older_than_head_holds() -> None:
_holds(
_inputs(comments=(_greptile(5, datetime(2026, 1, 9, tzinfo=timezone.utc)),)),
"older than head commit",
)
def test_bugbot_missing_holds() -> None:
_holds(_inputs(reviews=()), "bugbot review not available")
def test_bugbot_stale_marker_ignored() -> None:
_holds(
_inputs(
reviews=(
_bugbot(
HEAD_SHA,
"<!-- BUGBOT_REVIEW --><!-- BUGBOT_REVIEW_STALE --> cursor bugbot found no new issues",
datetime(2026, 1, 11, tzinfo=timezone.utc),
),
)
),
"bugbot review not available",
)
def test_bugbot_old_commit_ignored() -> None:
_holds(
_inputs(
reviews=(
_bugbot(
"0" * 40,
"<!-- BUGBOT_REVIEW --> cursor bugbot found no new issues",
datetime(2026, 1, 11, tzinfo=timezone.utc),
),
)
),
"bugbot review not available",
)
def test_bugbot_issues_found_holds() -> None:
_holds(
_inputs(
reviews=(
_bugbot(
HEAD_SHA,
"<!-- BUGBOT_REVIEW --> cursor bugbot found 2 new issues",
datetime(2026, 1, 11, tzinfo=timezone.utc),
),
)
),
"bugbot reported issues",
)
def test_changes_requested_holds() -> None:
_holds(
_inputs(
reviews=(
_bugbot(
HEAD_SHA,
"<!-- BUGBOT_REVIEW --> cursor bugbot found no new issues",
datetime(2026, 1, 11, tzinfo=timezone.utc),
),
merger.Review(
author_login="human-reviewer",
state="CHANGES_REQUESTED",
body="",
commit_id=HEAD_SHA,
submitted_at=datetime(2026, 1, 12, tzinfo=timezone.utc),
),
)
),
"changes requested by human-reviewer",
)
def test_superseded_changes_requested_merges() -> None:
verdict: Final = _evaluate(
_inputs(
reviews=(
_bugbot(
HEAD_SHA,
"<!-- BUGBOT_REVIEW --> cursor bugbot found no new issues",
datetime(2026, 1, 12, tzinfo=timezone.utc),
),
merger.Review(
author_login="human-reviewer",
state="CHANGES_REQUESTED",
body="",
commit_id=HEAD_SHA,
submitted_at=datetime(2026, 1, 11, tzinfo=timezone.utc),
),
merger.Review(
author_login="human-reviewer",
state="APPROVED",
body="",
commit_id=HEAD_SHA,
submitted_at=datetime(2026, 1, 13, tzinfo=timezone.utc),
),
)
)
)
assert verdict.merge
def test_merge_request_pins_evaluated_head_sha() -> None:
body: Final = merger.merge_request_body(_pr(number=7, title="sync prices"))
assert body["sha"] == HEAD_SHA
assert body["merge_method"] == "merge"
assert body["commit_title"] == "sync prices (#7)"
def test_classifier_cost_map_set_runs() -> None:
assert merger._classify(["model_prices_and_context_window.json", "tests/test_litellm/test_x.py"]) == "run"
def test_classifier_backend_file_skips() -> None:
assert merger._classify(["model_prices_and_context_window.json", "litellm/main.py"]) == "skip"
def test_main_without_token_logs_and_exits_zero(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.delenv("GH_TOKEN", raising=False)
assert merger.main() == 0
assert "app credentials not configured" in capsys.readouterr().out

View file

@ -87,6 +87,29 @@ CI = [".github/workflows/test-litellm-ui-unit.yml"]
("backend", BACKEND + CLIENT, "run"),
("client", BACKEND + CLIENT, "run"),
("ui", BACKEND + CLIENT, "run"),
("cost-map-only", ["model_prices_and_context_window.json"], "run"),
("cost-map-only", ["litellm/model_prices_and_context_window_backup.json"], "run"),
("cost-map-only", ["model_prices_and_context_window.schema.json"], "run"),
(
"cost-map-only",
["model_prices_and_context_window.json", "tests/test_litellm/test_x.py"],
"run",
),
(
"cost-map-only",
["model_prices_and_context_window.json", "tests/proxy_unit_tests/test_y.py"],
"run",
),
(
"cost-map-only",
["model_prices_and_context_window.json", "litellm/utils.py"],
"skip",
),
("cost-map-only", ["tests/test_litellm/test_x.py"], "skip"),
("cost-map-only", ["model_prices_and_context_window.json", "docs/pricing.md"], "skip"),
("cost-map-only", ["model_prices_and_context_window.json", "docs/foo.mdx"], "skip"),
("cost-map-only", [], "skip"),
("cost-map-only", DOCS, "skip"),
],
)
def test_classify_decisions(category: str, changed: list[str], expected: str) -> None: