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
9 changes: 7 additions & 2 deletions .genai-review.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@
# Action inputs in the workflow take precedence over anything here, so a
# workflow can always override the repository's defaults.
#
# The file is read from the pull request's base branch, not from the pull
# request itself, so a change to this file takes effect once it is merged.
# That is deliberate: otherwise a pull request could configure its own review.
#
# There is deliberately no `api_key` key. Credentials belong in a repository
# secret; a key in a file here is a key that gets committed by accident.
# secret; a key in a file here is a key that gets committed by accident. For
# the same reason there is no `base_url`: it decides which server receives the
# key, so it can only be set as an action input.

# --- which model reviews the code ---------------------------------------
provider: gemini # gemini | openai | anthropic | openai-compatible
# model: gemini-3.8-flash # defaults to a cost-conscious model per provider
# base_url: http://localhost:11434/v1 # for openai-compatible only

# --- how the review is posted -------------------------------------------
mode: review # review (inline comments) | files | patch (v2 behaviour)
Expand Down
45 changes: 44 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,51 @@
reported separately rather than averaged — a resolved thread means the
conversation ended, not that the finding was right.

### Security

- **`.genai-review.yml` is now read from the pull request's base branch.** It
was read from the working directory, which meant two things. With an
`actions/checkout` step, the file came from the pull request itself, so
whoever opened it could set `ignore_paths: ["**"]` or `max_comments: 0` and
review their own change out of existence, or point `base_url` at their own
server and receive the API key. Without a checkout — which is what the README
recommends — the file was silently never read at all. It is now fetched over
the API from the base commit, so it works without a checkout and a pull
request cannot configure its own review.
- **`base_url` can no longer be set in `.genai-review.yml`.** It decides which
server receives the API key, so it is an action input only.

### Fixed

- **Files that were not reviewed dropped out of the summary on the next push.**
The last reviewed SHA advanced even when a run skipped files as too large,
truncated the diff, or got unusable output from the model. The next push
diffed from there, so those files were neither reviewed nor listed under
"Not reviewed" — the one promise the summary exists to keep. The SHA now
advances only when a run covered everything, and the summary says the next
push will review the earlier range again.
- **Findings counted as posted when the review failed to post.** If GitHub
rejected the review, its findings were still recorded as posted and never
tried again. They are now listed in the summary instead and retried on the
next push.
- **Incremental review broke after merging the base branch or rebasing.** The
comparison with the last reviewed commit then contains changes that are not
part of the pull request, and GitHub rejects the whole review with a 422 over
a single comment on one of them. The comparison is now checked first: a
rewritten history or a merge commit in the range falls back to reviewing the
whole pull request, and the summary says why. Findings are also placed
against the pull request's own diff, so a file outside it is never reviewed
or commented on.
- **One-click suggestions could land on the wrong lines.** A finding up to
three lines off the diff is moved to the nearest line, or its range is
shortened to fit a hunk, but its `suggestion` was still offered as a one-click
change — so "Commit suggestion" replaced code the suggestion was not written
for. A moved finding now shows its suggestion as a plain code block.
- **The action was listed as "ChatGPT GitHub Actions".** `action.yml` now names
it GenAI Code Review and describes what v3 does.
- **The migration guide said `@v2` was untouched.** It was moved once, to the
fix-only v2.1; the guide and the v3 release notes now say so.

- **The comment budget was unmeasurable.** Precision and recall were computed
over every finding the model produced, and the budget changes only what is
posted — so a budgeted and an unbudgeted run were identical by construction,
Expand Down Expand Up @@ -61,7 +104,7 @@
- **`mode` now defaults to `review`** instead of `files`. A workflow that never
set `mode` gets inline comments on the diff instead of one long summary
comment. Workflows that set `mode` explicitly are unaffected, and the `v2`
tag is untouched — this applies only when the pin moves to `v3`. Pin
tag keeps the `files` default — this applies only when the pin moves to `v3`. Pin
`mode: files` to keep the previous output.

### Added
Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,15 @@ Every setting can be given as an action input or in a `.genai-review.yml` file
at the repository root. **Action inputs take precedence** over the file, so a
workflow can always override the repository's defaults.

The file is read over the API from the pull request's **base branch**, not from
the pull request, and needs no checkout. A change to it takes effect once it is
merged. Otherwise whoever opens a pull request could set `ignore_paths: ["**"]`
and review their own change out of existence.

See [`.genai-review.yml.example`](.genai-review.yml.example) for a commented
file. There is deliberately no `api_key` key in it: credentials belong in a
secret.
secret. `base_url` is also input-only, because it decides which server receives
the key.

### Reference

Expand All @@ -194,7 +200,7 @@ secret.
| `custom_prompt` | — | Extra instructions for the reviewer |
| `temperature` | `0.5` | Sampling temperature |
| `max_tokens` | `8192` | Response length cap. Below ~4000 a large review truncates and is lost |
| `config_path` | `.genai-review.yml` | Repository config file |
| `config_path` | `.genai-review.yml` | Repository config file, read from the base branch |
| `runlog_path` | `genai-review-runlog.json` | Where the per-run JSON log is written |

`ignore_paths` defaults to skipping lockfiles, vendored and generated code,
Expand Down Expand Up @@ -233,7 +239,11 @@ diff; its only output is findings validated against a schema.

**API keys.** Pass them from a secret. The key is never written to the run log,
never included in an error message posted to a pull request, and never read from
`.genai-review.yml`.
`.genai-review.yml`. Neither is `base_url`, the server the key is sent to.

**The config file cannot be changed by the pull request it configures.**
`.genai-review.yml` is read from the base branch, so a pull request that edits it
is reviewed under the settings already merged.

**What leaves your repository.** The diff of the files being reviewed is sent to
whichever provider you configure. If that is not acceptable for a given
Expand Down
6 changes: 3 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: "ChatGPT GitHub Actions"
description: "This GitHub Action automates code review for pull requests using OpenAI's GPT models. By integrating this action into your CI/CD pipeline, it provides detailed, AI-generated reviews of your code directly within the PR comments. This tool helps maintain code quality, ensures adherence to best practices, and highlights potential improvements and bugs."
name: "GenAI Code Review"
description: "Reviews pull request diffs with an LLM (Gemini, OpenAI, Anthropic or any OpenAI-compatible server) and posts only the few inline comments most worth a human's attention, saying what it held back and what it did not review."
inputs:
provider:
description: "LLM provider: gemini (default), openai, anthropic, or openai-compatible. A workflow that sets the deprecated openai_api_key and no provider stays on openai."
Expand Down Expand Up @@ -81,7 +81,7 @@ inputs:
required: false
default: ""
config_path:
description: "Repository config file. Action inputs take precedence over it. Default .genai-review.yml."
description: "Repository config file, read from the pull request's base branch (never from the pull request itself). Action inputs take precedence over it. Default .genai-review.yml."
required: false
default: ""
runlog_path:
Expand Down
8 changes: 5 additions & 3 deletions docs/migrating-v2-to-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ To keep the v2 behaviour, pin it:
mode: files # <- keeps the single-comment output
```

Note that `@v2` itself is untouched. A workflow pinned to `cirolini/genai-code-review@v2`
keeps the `files` default and keeps behaving exactly as it does today; only
moving the pin to `@v3` changes anything.
`@v2` keeps the v2 behaviour. It was moved once, on 2026-09-19, to
[v2.1](../CHANGELOG.md#v21--2026-09-19): a maintenance release that made v2
start again after it had stopped working everywhere, with no new inputs and the
same `files` default. A workflow pinned to `cirolini/genai-code-review@v2` keeps
the single-comment output; only moving the pin to `@v3` changes it.

### Also check your model

Expand Down
11 changes: 7 additions & 4 deletions docs/release-notes-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ https://github.com/cirolini/genai-code-review/releases/tag/v3.0.0
tags are independent and not aligned.
- **`mode` defaults to `review`.** A major version is the right moment to make
v3 do what v3 is for. Workflows that set `mode` explicitly are unaffected;
`@v2` is untouched and keeps the `files` default.
`@v2` keeps the `files` default. (It points at v2.1, a fix-only release
from the same day; see the changelog.)
- **`provider` defaults to `gemini`.** It is the only configuration with
measured numbers behind it — 100% precision, 93% recall, 0% noise, $0.0009
per pull request. A workflow passing the deprecated `openai_api_key` without
Expand All @@ -22,8 +23,9 @@ https://github.com/cirolini/genai-code-review/releases/tag/v3.0.0
### Marketplace

The existing listing is named "ChatGPT GitHub Actions", which no longer
describes the action. Updating it is a separate, explicit step and has not been
done.
describes the action. `action.yml` was renamed to "GenAI Code Review" after
v3.0.0; the listing itself changes only when a release is published to the
Marketplace, which is a separate, explicit step and has not been done.

### Two adapters remain unverified against a live API

Expand Down Expand Up @@ -51,7 +53,8 @@ never set `mode` gets inline comments instead of one summary comment — pin
though a workflow passing the deprecated `openai_api_key` without a `provider`
stays on OpenAI.

Every v2 input still works, and `@v2` itself is untouched. See
Every v2 input still works, and `@v2` keeps the v2 behaviour (it points at the
fix-only v2.1). See
[the migration guide](migrating-v2-to-v3.md).

#### Multiple providers
Expand Down
31 changes: 30 additions & 1 deletion src/clients/github_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import os

import requests
from github import Github
from github import Github, UnknownObjectException

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -118,6 +118,35 @@ def get_compare_patch(self, base_sha, head_sha):
logger.info("Retrieved diff %s..%s", base_sha[:7], head_sha[:7])
return response.text

def get_compare(self, base_sha, head_sha):
"""
The comparison between two commits, as JSON: its `status` and commits.

Read before trusting an incremental diff. A force-push leaves the two
commits `diverged`, and a merge from the base branch puts commits in the
range that are not part of the pull request.
"""
url = f"{self.api_root}/repos/{self.repo_name}/compare/{base_sha}...{head_sha}"
response = requests.get(url, headers=self._api_headers(), timeout=60)
response.raise_for_status()
return response.json()

def get_base_file(self, pr_id, path):
"""
A file's text on the pull request's base commit, or None if absent.

Configuration is read from here rather than from the pull request's own
branch, because whoever opens a pull request controls its branch.
"""
base_sha = self.get_pr(pr_id).base.sha
try:
content = self.repo.get_contents(path, ref=base_sha)
except UnknownObjectException:
return None
if isinstance(content, list): # a directory, not a file
return None
return content.decoded_content.decode("utf-8")

def create_review(self, pr_id, comments, body):
"""
Post one review containing every inline comment.
Expand Down
4 changes: 4 additions & 0 deletions src/findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ class Finding:
title: str
rationale: str
suggestion: str | None = None
# Set when placement moved or shortened the model's line range to fit the
# diff. The suggestion was written for the original lines, so it must not
# be offered as a one-click change on the new ones.
relocated: bool = False

def to_dict(self) -> dict:
return asdict(self)
Expand Down
19 changes: 11 additions & 8 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from panel import parse_panel
from paths import parse_ignore_paths
from providers import ProviderError, build_provider
from repo_config import load as load_repo_config
from repo_config import load_from_base as load_repo_config
from repo_config import resolve as resolve_setting
from review_run import ReviewSettings, execute
from runlog import write as write_runlog
Expand All @@ -32,14 +32,13 @@ def main():
review that never happened.
"""
try:
env_vars = get_env_vars()
github_client = GithubClient(get_env_variable("GITHUB_TOKEN", required=True))
pr_id = _as_int("GITHUB_PR_ID", get_env_variable("GITHUB_PR_ID", required=True))
env_vars = get_env_vars(load_repo_config(github_client, pr_id))
except ValueError as e:
logger.error("Environment variable error: %s", e)
raise

github_client = GithubClient(env_vars["GITHUB_TOKEN"])
pr_id = env_vars["GITHUB_PR_ID"]

try:
provider = build_provider(
env_vars["PROVIDER"],
Expand Down Expand Up @@ -108,10 +107,13 @@ def report_provider_failure(github_client, pr_id, error):
logger.exception("Could not post the failure comment; original error follows")


def get_env_vars():
def get_env_vars(config=None):
"""
Read configuration from the environment and `.genai-review.yml`.

`config` is the repository config file, already read from the pull
request's base commit by the caller. None means there is none.

Precedence is action input, then repository config file, then default. The
workflow is the more specific statement of intent, and a file in the
repository should not silently override what a workflow asked for.
Expand All @@ -123,7 +125,7 @@ def get_env_vars():
Raises:
ValueError: if a required variable is missing or cannot be converted.
"""
config = load_repo_config()
config = config or {}

def setting(name, key, default=None):
return resolve_setting(get_env_variable(name, required=False), config, key, default)
Expand All @@ -135,7 +137,8 @@ def setting(name, key, default=None):
"LANGUAGE": setting("LANGUAGE", "language", "en"),
"CUSTOM_PROMPT": setting("CUSTOM_PROMPT", "custom_prompt"),
"PROVIDER": _resolve_provider(setting("PROVIDER", "provider", None)),
"BASE_URL": setting("BASE_URL", "base_url") or None,
# Never from the config file: it decides where the API key is sent.
"BASE_URL": get_env_variable("BASE_URL", required=False) or None,
"MIN_SEVERITY": str(setting("MIN_SEVERITY", "min_severity", "nit")).strip().lower(),
"INCREMENTAL": _as_bool(setting("INCREMENTAL", "incremental", True), True),
"PANEL": _as_panel(setting("PANEL", "panel")),
Expand Down
Loading
Loading