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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,26 @@ on:
branches:
- main

permissions:
contents: read
checks: write
pull-requests: write

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --check
- run: cargo test --quiet
- run: cargo build --quiet --bin postil
- name: Dogfood Postil review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARN · The dogfood Postil review step runs on every PR and will fail the CI job if the review bot finds errors or itself fails. This could block merges for unrelated changes when the bot has issues. Consider adding continue-on-error: true to prevent the dogfood check from blocking the pipeline, or use a separate check suite that is not required.

if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: target/debug/postil review --base "origin/${{ github.base_ref }}"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/target
.cache/
3 changes: 3 additions & 0 deletions .postil.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
severityThreshold: error
review:
onClean: approve
34 changes: 28 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
# Postil Reviewer

`postil` is the review-bot binary for Postil. Hosted Postil workers and the
GitHub Action both run this CLI; the website does not contain review logic.
`postil` is the low-noise review gate binary for Postil. Hosted Postil workers,
the GitHub Action, and local review commands all run this CLI; the website does
not contain review logic.

## Usage

```bash
postil review --repo owner/repo --pr 123 --sha HEAD_SHA
```

Local diff review does not require GitHub configuration:

```bash
postil review --diff-file .cache/change.diff
postil review --staged
postil review --base origin/main
```

By default, the command also reads GitHub Actions context:

- `GITHUB_REPOSITORY`
Expand All @@ -17,7 +26,7 @@ By default, the command also reads GitHub Actions context:
- `OPENROUTER_API_KEY`

Review models are configured with `REVIEW_MODEL` or `REVIEW_MODEL_CASCADE`.
The default model is `moonshotai/kimi-k2.6`.
The default model is `deepseek/deepseek-v4-pro`.

## Configuration

Expand Down Expand Up @@ -45,9 +54,17 @@ review:
maxFindings: 25
review:
enabled: true
onClean: approve
onClean: skip
```

Clean reviews are silent by default: Postil completes the check-run or local
command without posting a PR approval comment. Set `review.review.onClean:
approve` only when a repository explicitly wants clean PR approval reviews.

`reviewer.focus` is passed into the review prompt as repository-specific
evidence. Use it for enforced areas such as security-sensitive code, migrations,
public API contracts, or user-visible behavior.

The CLI also loads per-repository review config from the pull request head SHA,
using this order:

Expand All @@ -67,11 +84,16 @@ Hosted callers can request the structured review envelope:
postil review --output-json .cache/postil-review.json
```

The JSON includes `summary`, `findings`, `usage`, and `modelUsed`.
The JSON includes `summary`, `findings`, `usage`, and `modelUsed`. Findings
include `path`, `line`, `severity`, `body`, and optional `kind`
(`risk`, `humanEscalation`, `guardrail`, or `uncertainty`).

Hosted workers that already created an in-progress check-run should pass
`--check-run-id`; CI/self-hosted runs omit it and the CLI creates the check-run.

If a model response is not valid Postil JSON, the CLI fails closed by converting
that response into an `error` finding at `.postil/model-output:1`.

## Testing

```bash
Expand All @@ -81,5 +103,5 @@ cargo test --quiet
Live OpenRouter smoke test:

```bash
infisical run --env=prod -- env REVIEW_MODEL=xiaomi/mimo-v2.5-pro cargo test --quiet live_openrouter_smoke -- --ignored
infisical run --env=prod -- env REVIEW_MODEL=deepseek/deepseek-v4-pro cargo test --quiet live_openrouter_smoke -- --ignored
```
42 changes: 36 additions & 6 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ impl Default for ReviewConfig {
fn default() -> Self {
Self {
enabled: true,
on_clean: OnClean::Approve,
on_clean: OnClean::Skip,
auto_merge: false,
required_checks: Vec::new(),
auto_merge_timeout_ms: 15_000,
Expand Down Expand Up @@ -159,7 +159,7 @@ impl RuntimeFileConfig {

#[derive(Debug, Clone)]
pub struct RuntimeConfig {
pub github_token: String,
pub github_token: Option<String>,
pub github_repository: Option<String>,
pub github_event_path: Option<String>,
pub github_api_url: String,
Expand Down Expand Up @@ -201,12 +201,11 @@ pub struct RuntimeOverrides {
impl RuntimeConfig {
pub fn load(file: Option<RuntimeFileConfig>, flags: RuntimeOverrides) -> Result<Self> {
let file = file.unwrap_or_default();
let github_token = pick_required(
let github_token = pick_optional(
flags.github_token,
env::var("GITHUB_TOKEN").ok(),
file.github_token,
"GITHUB_TOKEN",
)?;
);
let openrouter_api_key = pick_required(
flags.openrouter_api_key,
env::var("OPENROUTER_API_KEY").ok(),
Expand Down Expand Up @@ -239,7 +238,7 @@ impl RuntimeConfig {
.review_model
.or_else(|| env::var("REVIEW_MODEL").ok())
.or(file.review_model)
.unwrap_or_else(|| "moonshotai/kimi-k2.6".to_string()),
.unwrap_or_else(|| "deepseek/deepseek-v4-pro".to_string()),
review_model_cascade: flags
.review_model_cascade
.or_else(|| env::var("REVIEW_MODEL_CASCADE").ok())
Expand Down Expand Up @@ -296,6 +295,14 @@ fn pick_required(
.ok_or_else(|| anyhow!("{name} is not set"))
}

fn pick_optional(
flag: Option<String>,
env: Option<String>,
file: Option<String>,
) -> Option<String> {
flag.or(env).or(file).filter(|v| !v.trim().is_empty())
}

fn severity_env(name: &str) -> Option<Severity> {
env::var(name).ok().and_then(|v| match v.trim() {
"info" => Some(Severity::Info),
Expand Down Expand Up @@ -409,3 +416,26 @@ pub fn translate_kodo(text: &str) -> Result<RepoReviewConfig> {
..RepoReviewConfig::default()
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn clean_reviews_skip_by_default() {
assert_eq!(ReviewConfig::default().on_clean, OnClean::Skip);
}

#[test]
fn local_runtime_does_not_require_github_token() {
let cfg = RuntimeConfig::load(
Some(RuntimeFileConfig {
openrouter_api_key: Some("test-openrouter".to_string()),
..RuntimeFileConfig::default()
}),
RuntimeOverrides::default(),
)
.unwrap();
assert!(cfg.diff_limit > 0);
}
}
13 changes: 5 additions & 8 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use serde_json::json;
use crate::{
config::{RepoReviewConfig, ReviewTarget, translate_coderabbit, translate_kodo},
review::{Finding, ReviewEnvelope},
text::limit_text,
};

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -46,11 +47,7 @@ impl GithubClient {
));
}
let diff = res.text().await.context("read diff body")?;
Ok(if diff.len() > limit {
format!("{}\n\n[diff truncated]", &diff[..limit])
} else {
diff
})
Ok(limit_text(diff, limit))
}

pub async fn load_repo_config(&self, target: &ReviewTarget) -> Result<RepoReviewConfig> {
Expand Down Expand Up @@ -326,18 +323,18 @@ impl CheckOutput {
} else if warnings > 0 {
format!("{warnings} warning{}", if warnings == 1 { "" } else { "s" })
} else {
"No issues".to_string()
"No merge-relevant findings".to_string()
};
let text = if envelope.findings.is_empty() {
Some("No issues found.".to_string())
None
} else {
Some(render_findings(&envelope.findings))
};
Self {
title,
summary: if envelope.summary.trim().is_empty() {
if envelope.findings.is_empty() {
"No issues found.".to_string()
"No merge-relevant findings.".to_string()
} else {
"See inline review comments.".to_string()
}
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ pub mod config;
pub mod github;
pub mod openrouter;
pub mod review;
pub mod text;
Loading