Skip to content

Commit cda6b65

Browse files
authored
feat: trigger reviews from requested reviewers (#100)
1 parent 58d608c commit cda6b65

6 files changed

Lines changed: 336 additions & 4 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,8 @@ diffscope doctor --base-url http://localhost:11434
317317
|----------|-------------|
318318
| `DIFFSCOPE_BASE_URL` | LLM API base URL (also accepts `OPENAI_BASE_URL`) |
319319
| `DIFFSCOPE_API_KEY` | API key for the LLM endpoint |
320+
| `DIFFSCOPE_GITHUB_AUTO_REVIEW_EVENTS` | Comma-separated `pull_request` actions that start webhook reviews, such as `opened,synchronize` or `review_requested` |
321+
| `DIFFSCOPE_GITHUB_REVIEW_REQUEST_REVIEWERS` | Comma-separated GitHub logins whose requested reviews trigger `review_requested` automation, for example `EvalOpsBot` |
320322

321323
#### CLI Flags
322324
| Flag | Description |
@@ -955,6 +957,17 @@ Planned support for responding to pull request comments with interactive command
955957
@diffscope help # Show all commands
956958
```
957959

960+
### Requested Reviewer Automation
961+
962+
Server webhook deployments can run reviews only when a specific reviewer is requested. For an org-level EvalOpsBot setup, subscribe the GitHub App or webhook to `pull_request` events and run the server with:
963+
964+
```bash
965+
DIFFSCOPE_GITHUB_AUTO_REVIEW_EVENTS=review_requested
966+
DIFFSCOPE_GITHUB_REVIEW_REQUEST_REVIEWERS=EvalOpsBot
967+
```
968+
969+
With that configuration, `pull_request.review_requested` starts a full DiffScope review only when GitHub reports `requested_reviewer.login` as `EvalOpsBot`.
970+
958971
### ✅ Feedback Loop (Reduce Repeated False Positives)
959972

960973
Use the feedback store to suppress comments you’ve already reviewed:

charts/diffscope/templates/configmap.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ metadata:
66
{{- include "diffscope.labels" . | nindent 4 }}
77
data:
88
DIFFSCOPE_MODEL: {{ .Values.diffscope.model | quote }}
9+
{{- if .Values.diffscope.github.autoReviewEvents }}
10+
DIFFSCOPE_GITHUB_AUTO_REVIEW_EVENTS: {{ join "," .Values.diffscope.github.autoReviewEvents | quote }}
11+
{{- end }}
12+
{{- if .Values.diffscope.github.reviewRequestReviewers }}
13+
DIFFSCOPE_GITHUB_REVIEW_REQUEST_REVIEWERS: {{ join "," .Values.diffscope.github.reviewRequestReviewers | quote }}
14+
{{- end }}
915
{{- if .Values.diffscope.baseUrl }}
1016
DIFFSCOPE_BASE_URL: {{ .Values.diffscope.baseUrl | quote }}
1117
{{- else if .Values.ollama.enabled }}

charts/diffscope/values.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ diffscope:
2323
adapter: ""
2424
# Base URL for LLM API (auto-set to Ollama service URL when ollama.enabled)
2525
baseUrl: ""
26+
github:
27+
# pull_request actions that start server-side reviews.
28+
# Set to ["review_requested"] with reviewRequestReviewers for on-demand org review bots.
29+
autoReviewEvents:
30+
- opened
31+
- synchronize
32+
# GitHub user logins whose requested reviews trigger review_requested automation.
33+
reviewRequestReviewers: []
2634
extraArgs: []
2735

2836
# -- API keys (stored in a Secret)

docs/self-hosting.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ config:
6969
7070
extraEnv:
7171
DIFFSCOPE_SERVER_API_KEY: ${DIFFSCOPE_SERVER_API_KEY}
72+
DIFFSCOPE_GITHUB_AUTO_REVIEW_EVENTS: review_requested
73+
DIFFSCOPE_GITHUB_REVIEW_REQUEST_REVIEWERS: EvalOpsBot
7274
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
7375
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
7476
```
@@ -89,14 +91,22 @@ For enterprise installs, prefer external secret injection over storing credentia
8991
- `OPENAI_API_KEY`
9092
- `OPENROUTER_API_KEY`
9193
- `DIFFSCOPE_SERVER_API_KEY`
94+
- `DIFFSCOPE_WEBHOOK_SECRET`
9295
- `GITHUB_TOKEN`
9396
- `DIFFSCOPE_GITHUB_APP_ID`
9497
- `DIFFSCOPE_GITHUB_PRIVATE_KEY`
98+
- `DIFFSCOPE_GITHUB_AUTO_REVIEW_EVENTS`
99+
- `DIFFSCOPE_GITHUB_REVIEW_REQUEST_REVIEWERS`
95100
- `DIFFSCOPE_JIRA_BASE_URL`
96101
- `DIFFSCOPE_JIRA_EMAIL`
97102
- `DIFFSCOPE_JIRA_API_TOKEN`
98103
- `DIFFSCOPE_LINEAR_API_KEY`
99104

105+
For an on-demand review bot, set `DIFFSCOPE_GITHUB_AUTO_REVIEW_EVENTS=review_requested`
106+
and `DIFFSCOPE_GITHUB_REVIEW_REQUEST_REVIEWERS=EvalOpsBot`. The GitHub webhook must
107+
subscribe to `pull_request` events; DiffScope will ignore review requests for other
108+
reviewers.
109+
100110
### Validation behavior
101111

102112
`diffscope doctor`, the server doctor endpoint, and startup warnings now surface configuration issues for:

src/config.rs

Lines changed: 188 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use anyhow::Result;
22
use serde::{Deserialize, Serialize};
3-
use std::collections::HashMap;
3+
use std::collections::{HashMap, HashSet};
44
use std::path::{Path, PathBuf};
55
use tracing::warn;
66

@@ -150,7 +150,7 @@ pub struct VaultConfig {
150150
pub namespace: Option<String>,
151151
}
152152

153-
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
153+
#[derive(Debug, Clone, Serialize, Deserialize)]
154154
pub struct GitHubConfig {
155155
#[serde(default, rename = "github_token")]
156156
pub token: Option<String>,
@@ -174,6 +174,37 @@ pub struct GitHubConfig {
174174
/// Webhook secret for verifying GitHub webhook signatures.
175175
#[serde(default, rename = "github_webhook_secret")]
176176
pub webhook_secret: Option<String>,
177+
178+
/// pull_request actions that should start an automated review.
179+
///
180+
/// Supported values: opened, synchronize, reopened, review_requested.
181+
#[serde(
182+
default = "default_github_auto_review_events",
183+
rename = "github_auto_review_events"
184+
)]
185+
pub auto_review_events: Vec<String>,
186+
187+
/// GitHub user logins that can trigger review_requested automation.
188+
///
189+
/// This is intentionally separate from auto_review_events so an org-level
190+
/// webhook can listen to pull_request events without reviewing every PR.
191+
#[serde(default, rename = "github_review_request_reviewers")]
192+
pub review_request_reviewers: Vec<String>,
193+
}
194+
195+
impl Default for GitHubConfig {
196+
fn default() -> Self {
197+
Self {
198+
token: None,
199+
app_id: None,
200+
client_id: None,
201+
client_secret: None,
202+
private_key: None,
203+
webhook_secret: None,
204+
auto_review_events: default_github_auto_review_events(),
205+
review_request_reviewers: Vec::new(),
206+
}
207+
}
177208
}
178209

179210
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -1034,6 +1065,16 @@ impl Config {
10341065
.ok()
10351066
.filter(|s| !s.trim().is_empty());
10361067
}
1068+
if let Ok(events) = std::env::var("DIFFSCOPE_GITHUB_AUTO_REVIEW_EVENTS") {
1069+
self.github.auto_review_events = split_csv_list(&events);
1070+
}
1071+
if self.github.review_request_reviewers.is_empty() {
1072+
self.github.review_request_reviewers =
1073+
std::env::var("DIFFSCOPE_GITHUB_REVIEW_REQUEST_REVIEWERS")
1074+
.ok()
1075+
.map(|reviewers| split_csv_list(&reviewers))
1076+
.unwrap_or_default();
1077+
}
10371078
if self.jira.base_url.is_none() {
10381079
self.jira.base_url = std::env::var("DIFFSCOPE_JIRA_BASE_URL")
10391080
.ok()
@@ -1090,6 +1131,8 @@ impl Config {
10901131
normalize_optional_trimmed(&mut self.github.client_secret);
10911132
normalize_optional_trimmed(&mut self.github.private_key);
10921133
normalize_optional_trimmed(&mut self.github.webhook_secret);
1134+
normalize_lowercase_list(&mut self.github.auto_review_events);
1135+
normalize_string_list(&mut self.github.review_request_reviewers);
10931136
normalize_optional_trimmed(&mut self.jira.base_url);
10941137
normalize_optional_trimmed(&mut self.jira.email);
10951138
normalize_optional_trimmed(&mut self.jira.api_token);
@@ -1701,6 +1744,32 @@ impl Config {
17011744
"github_client_secret is set without github_client_id. The device flow only uses github_client_id today.",
17021745
));
17031746
}
1747+
for event in &self.github.auto_review_events {
1748+
if !matches!(
1749+
event.as_str(),
1750+
"opened" | "synchronize" | "reopened" | "review_requested"
1751+
) {
1752+
issues.push(ConfigValidationIssue::warning(
1753+
"github_auto_review_events",
1754+
format!(
1755+
"Unsupported GitHub pull_request action '{}'; supported values are opened, synchronize, reopened, and review_requested.",
1756+
event
1757+
),
1758+
));
1759+
}
1760+
}
1761+
if self
1762+
.github
1763+
.auto_review_events
1764+
.iter()
1765+
.any(|event| event == "review_requested")
1766+
&& self.github.review_request_reviewers.is_empty()
1767+
{
1768+
issues.push(ConfigValidationIssue::warning(
1769+
"github_review_request_reviewers",
1770+
"review_requested automation is enabled but no requested reviewer logins are configured.",
1771+
));
1772+
}
17041773

17051774
let jira_fields = [
17061775
self.jira.base_url.as_ref(),
@@ -1827,6 +1896,37 @@ fn normalize_optional_trimmed(value: &mut Option<String>) {
18271896
.map(ToOwned::to_owned);
18281897
}
18291898

1899+
fn split_csv_list(value: &str) -> Vec<String> {
1900+
value.split(',').map(ToOwned::to_owned).collect()
1901+
}
1902+
1903+
fn normalize_string_list(values: &mut Vec<String>) {
1904+
let mut seen = HashSet::new();
1905+
values.retain_mut(|value| {
1906+
let trimmed = value.trim();
1907+
if trimmed.is_empty() {
1908+
return false;
1909+
}
1910+
1911+
let normalized_key = trimmed.to_ascii_lowercase();
1912+
if !seen.insert(normalized_key) {
1913+
return false;
1914+
}
1915+
1916+
if trimmed.len() != value.len() {
1917+
*value = trimmed.to_string();
1918+
}
1919+
true
1920+
});
1921+
}
1922+
1923+
fn normalize_lowercase_list(values: &mut Vec<String>) {
1924+
normalize_string_list(values);
1925+
for value in values {
1926+
*value = value.to_ascii_lowercase();
1927+
}
1928+
}
1929+
18301930
fn apply_provider_env_api_key_fallback(
18311931
providers: &mut HashMap<String, ProviderConfig>,
18321932
provider_name: &str,
@@ -1903,6 +2003,10 @@ fn default_temperature() -> f32 {
19032003
0.2
19042004
}
19052005

2006+
fn default_github_auto_review_events() -> Vec<String> {
2007+
vec!["opened".to_string(), "synchronize".to_string()]
2008+
}
2009+
19062010
fn default_max_tokens() -> usize {
19072011
4000
19082012
}
@@ -2927,6 +3031,88 @@ auditing_model_role: primary
29273031
assert!(issues.iter().any(|issue| issue.field == "vault"));
29283032
}
29293033

3034+
#[test]
3035+
fn test_github_auto_review_events_default_to_opened_and_synchronize() {
3036+
let config = Config::default();
3037+
3038+
assert_eq!(
3039+
config.github.auto_review_events,
3040+
vec!["opened".to_string(), "synchronize".to_string()]
3041+
);
3042+
}
3043+
3044+
#[test]
3045+
fn test_config_deserialize_github_review_request_controls() {
3046+
let config: Config = serde_yaml::from_str(
3047+
r#"
3048+
github_auto_review_events:
3049+
- review_requested
3050+
github_review_request_reviewers:
3051+
- EvalOpsBot
3052+
"#,
3053+
)
3054+
.unwrap();
3055+
3056+
assert_eq!(
3057+
config.github.auto_review_events,
3058+
vec!["review_requested".to_string()]
3059+
);
3060+
assert_eq!(
3061+
config.github.review_request_reviewers,
3062+
vec!["EvalOpsBot".to_string()]
3063+
);
3064+
}
3065+
3066+
#[test]
3067+
fn test_normalize_github_review_request_controls() {
3068+
let mut config = Config {
3069+
github: GitHubConfig {
3070+
auto_review_events: vec![
3071+
" Review_Requested ".to_string(),
3072+
"review_requested".to_string(),
3073+
"opened".to_string(),
3074+
"".to_string(),
3075+
],
3076+
review_request_reviewers: vec![
3077+
" EvalOpsBot ".to_string(),
3078+
"evalopsbot".to_string(),
3079+
"AnotherBot".to_string(),
3080+
"".to_string(),
3081+
],
3082+
..GitHubConfig::default()
3083+
},
3084+
..Config::default()
3085+
};
3086+
3087+
config.normalize();
3088+
3089+
assert_eq!(
3090+
config.github.auto_review_events,
3091+
vec!["review_requested".to_string(), "opened".to_string()]
3092+
);
3093+
assert_eq!(
3094+
config.github.review_request_reviewers,
3095+
vec!["EvalOpsBot".to_string(), "AnotherBot".to_string()]
3096+
);
3097+
}
3098+
3099+
#[test]
3100+
fn test_validation_warns_when_review_request_enabled_without_reviewers() {
3101+
let config = Config {
3102+
github: GitHubConfig {
3103+
auto_review_events: vec!["review_requested".to_string()],
3104+
review_request_reviewers: Vec::new(),
3105+
..GitHubConfig::default()
3106+
},
3107+
..Config::default()
3108+
};
3109+
3110+
let issues = config.validation_issues();
3111+
assert!(issues
3112+
.iter()
3113+
.any(|issue| issue.field == "github_review_request_reviewers"));
3114+
}
3115+
29303116
#[test]
29313117
fn test_normalize_rejects_embedding_for_generation_verification_and_auditing() {
29323118
let mut config = Config {

0 commit comments

Comments
 (0)