11use anyhow:: Result ;
22use serde:: { Deserialize , Serialize } ;
3- use std:: collections:: HashMap ;
3+ use std:: collections:: { HashMap , HashSet } ;
44use std:: path:: { Path , PathBuf } ;
55use 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 ) ]
154154pub 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+
18301930fn 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+
19062010fn 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