@@ -22,6 +22,10 @@ use crate::git2_ops::GitRepo;
2222pub struct GitHubConfig {
2323 pub token : String ,
2424 pub api_base : String ,
25+ /// GitHub usernames whose PRs should be synced.
26+ /// When non-empty, only PRs from these authors will be synced.
27+ /// When empty, PRs from forks will be excluded.
28+ pub sync_authors : Vec < String > ,
2529}
2630
2731/// Repository identification (owner/repo extracted from remote URL)
@@ -50,6 +54,8 @@ pub struct PullRequest {
5054 pub html_url : String ,
5155 pub base : PrBranchRef ,
5256 pub head : PrBranchRef ,
57+ /// The user who created this PR
58+ pub user : PrUser ,
5359 #[ serde( default ) ]
5460 pub draft : bool ,
5561 #[ serde( default ) ]
@@ -59,11 +65,26 @@ pub struct PullRequest {
5965 pub merged_at : Option < String > ,
6066}
6167
68+ /// Minimal user info for PR author
69+ #[ derive( Debug , Clone , Deserialize ) ]
70+ pub struct PrUser {
71+ pub login : String ,
72+ }
73+
6274#[ derive( Debug , Clone , Deserialize ) ]
6375pub struct PrBranchRef {
6476 #[ serde( rename = "ref" ) ]
6577 pub ref_name : String ,
6678 pub sha : String ,
79+ /// Repository info (may be null if the fork was deleted)
80+ pub repo : Option < PrRepoRef > ,
81+ }
82+
83+ /// Minimal repo info for PR head/base references
84+ #[ derive( Debug , Clone , Deserialize ) ]
85+ pub struct PrRepoRef {
86+ /// Full name of the repo (e.g., "owner/repo")
87+ pub full_name : String ,
6788}
6889
6990#[ derive( Debug , Clone , Copy , PartialEq , Eq , Deserialize , Serialize ) ]
@@ -99,6 +120,22 @@ impl PullRequest {
99120 self . merged || self . merged_at . is_some ( )
100121 }
101122
123+ /// Check if this PR is from a fork (head repo differs from base repo)
124+ ///
125+ /// Returns true if:
126+ /// - The head repo is missing (fork was deleted)
127+ /// - The head repo full_name differs from the base repo full_name
128+ pub fn is_from_fork ( & self ) -> bool {
129+ match ( & self . head . repo , & self . base . repo ) {
130+ // If head repo is missing, the fork was probably deleted - treat as fork PR
131+ ( None , _) => true ,
132+ // If base repo is missing, something is weird but assume not a fork
133+ ( _, None ) => false ,
134+ // Compare the full names
135+ ( Some ( head_repo) , Some ( base_repo) ) => head_repo. full_name != base_repo. full_name ,
136+ }
137+ }
138+
102139 /// Get the display state for this PR
103140 pub fn display_state ( & self ) -> PrDisplayState {
104141 if self . is_merged ( ) {
@@ -191,13 +228,17 @@ impl GitHubClient {
191228
192229 /// Load config from environment/git config/config file
193230 pub fn from_env ( repo_id : & RepoIdentifier ) -> Result < Self , GitHubError > {
194- let token = find_github_token ( & repo_id. host ) ?;
231+ let ( token, sync_authors ) = find_github_config ( & repo_id. host ) ?;
195232 let api_base = if repo_id. host == "github.com" {
196233 "https://api.github.com" . to_string ( )
197234 } else {
198235 format ! ( "https://{}/api/v3" , repo_id. host)
199236 } ;
200- Ok ( Self :: new ( GitHubConfig { token, api_base } ) )
237+ Ok ( Self :: new ( GitHubConfig {
238+ token,
239+ api_base,
240+ sync_authors,
241+ } ) )
201242 }
202243
203244 /// Get PR by number
@@ -313,9 +354,37 @@ impl GitHubClient {
313354 page += 1 ;
314355 }
315356
316- // Build map of head branch name -> PR
357+ // Build map of head branch name -> PR, filtering out irrelevant PRs
317358 let pr_map: std:: collections:: HashMap < String , PullRequest > = all_prs
318359 . into_iter ( )
360+ . filter ( |pr| {
361+ // If sync_authors is configured, only include PRs from those authors
362+ if !self . config . sync_authors . is_empty ( ) {
363+ let included = self . config . sync_authors . contains ( & pr. user . login ) ;
364+ if !included {
365+ tracing:: debug!(
366+ "Skipping PR #{} '{}' - author '{}' not in sync_authors" ,
367+ pr. number,
368+ pr. title,
369+ pr. user. login
370+ ) ;
371+ }
372+ return included;
373+ }
374+
375+ // Otherwise, filter out PRs from forks
376+ if pr. is_from_fork ( ) {
377+ tracing:: debug!(
378+ "Skipping PR #{} '{}' - from fork (head: {:?})" ,
379+ pr. number,
380+ pr. title,
381+ pr. head. repo. as_ref( ) . map( |r| & r. full_name)
382+ ) ;
383+ return false ;
384+ }
385+
386+ true
387+ } )
319388 . map ( |pr| ( pr. head . ref_name . clone ( ) , pr) )
320389 . collect ( ) ;
321390
@@ -450,22 +519,35 @@ pub fn get_repo_identifier(git_repo: &GitRepo) -> Result<RepoIdentifier> {
450519 parse_remote_url ( & remote_url)
451520}
452521
453- /// Find GitHub token from various sources
454- fn find_github_token ( host : & str ) -> Result < String , GitHubError > {
522+ /// Load GitHub configuration from XDG config file
523+ fn load_github_config_file ( ) -> Option < GitHubConfigFile > {
524+ let config_path = get_github_config_path ( ) . ok ( ) ?;
525+ let contents = fs:: read_to_string ( & config_path) . ok ( ) ?;
526+ serde_yaml:: from_str ( & contents) . ok ( )
527+ }
528+
529+ /// Find GitHub token and config from various sources
530+ fn find_github_config ( host : & str ) -> Result < ( String , Vec < String > ) , GitHubError > {
531+ let config_file = load_github_config_file ( ) ;
532+ let sync_authors = config_file
533+ . as_ref ( )
534+ . map ( |c| c. sync_authors . clone ( ) )
535+ . unwrap_or_default ( ) ;
536+
455537 // 1. Check GITHUB_TOKEN env var
456538 if let Ok ( token) = std:: env:: var ( "GITHUB_TOKEN" )
457539 && !token. is_empty ( )
458540 {
459541 tracing:: debug!( "Using GitHub token from GITHUB_TOKEN env var" ) ;
460- return Ok ( token) ;
542+ return Ok ( ( token, sync_authors ) ) ;
461543 }
462544
463545 // 2. Check GH_TOKEN env var (used by gh CLI)
464546 if let Ok ( token) = std:: env:: var ( "GH_TOKEN" )
465547 && !token. is_empty ( )
466548 {
467549 tracing:: debug!( "Using GitHub token from GH_TOKEN env var" ) ;
468- return Ok ( token) ;
550+ return Ok ( ( token, sync_authors ) ) ;
469551 }
470552
471553 // 3. Check git config github.token
@@ -477,37 +559,39 @@ fn find_github_token(host: &str) -> Result<String, GitHubError> {
477559 let token = String :: from_utf8_lossy ( & output. stdout ) . trim ( ) . to_string ( ) ;
478560 if !token. is_empty ( ) {
479561 tracing:: debug!( "Using GitHub token from git config" ) ;
480- return Ok ( token) ;
562+ return Ok ( ( token, sync_authors ) ) ;
481563 }
482564 }
483565
484- // 4. Check XDG config file
485- if let Ok ( config_path) = get_github_config_path ( )
486- && let Ok ( contents) = fs:: read_to_string ( & config_path)
487- && let Ok ( config) = serde_yaml:: from_str :: < GitHubConfigFile > ( & contents)
488- {
566+ // 4. Check XDG config file for token
567+ if let Some ( config) = config_file {
489568 // Check for host-specific token first
490569 if let Some ( hosts) = & config. hosts
491570 && let Some ( token) = hosts. get ( host)
492571 {
493572 tracing:: debug!( "Using GitHub token from config file (host-specific)" ) ;
494- return Ok ( token. clone ( ) ) ;
573+ return Ok ( ( token. clone ( ) , sync_authors ) ) ;
495574 }
496575 // Fall back to default token
497576 if let Some ( token) = config. default_token {
498577 tracing:: debug!( "Using GitHub token from config file (default)" ) ;
499- return Ok ( token) ;
578+ return Ok ( ( token, sync_authors ) ) ;
500579 }
501580 }
502581
503582 Err ( GitHubError :: NoToken )
504583}
505584
506585/// GitHub config file structure
507- #[ derive( Debug , Deserialize , Serialize ) ]
586+ #[ derive( Debug , Default , Deserialize , Serialize ) ]
508587struct GitHubConfigFile {
509588 default_token : Option < String > ,
510589 hosts : Option < std:: collections:: HashMap < String , String > > ,
590+ /// GitHub usernames whose PRs should be synced.
591+ /// When set, only PRs from these authors will be synced.
592+ /// When empty/unset, PRs from forks will be excluded.
593+ #[ serde( default ) ]
594+ sync_authors : Vec < String > ,
511595}
512596
513597/// Get path to GitHub config file
@@ -525,10 +609,9 @@ pub fn save_github_token(token: &str) -> Result<()> {
525609 . place_config_file ( "github.yaml" )
526610 . context ( "Failed to create config directory" ) ?;
527611
528- let config = GitHubConfigFile {
529- default_token : Some ( token. to_string ( ) ) ,
530- hosts : None ,
531- } ;
612+ // Load existing config to preserve other settings (like sync_authors)
613+ let mut config = load_github_config_file ( ) . unwrap_or_default ( ) ;
614+ config. default_token = Some ( token. to_string ( ) ) ;
532615
533616 let contents = serde_yaml:: to_string ( & config) ?;
534617 fs:: write ( & config_path, contents) ?;
@@ -548,7 +631,7 @@ pub fn save_github_token(token: &str) -> Result<()> {
548631
549632/// Check if GitHub token is configured
550633pub fn has_github_token ( host : & str ) -> bool {
551- find_github_token ( host) . is_ok ( )
634+ find_github_config ( host) . is_ok ( )
552635}
553636
554637/// Interactive token setup
0 commit comments