Skip to main content

pr_bro/
fetch.rs

1use crate::config::Config;
2use crate::github::cache::CacheConfig;
3use crate::github::types::PullRequest;
4use crate::scoring::{calculate_score, merge_scoring_configs, ScoreResult};
5use crate::snooze::{filter_active_prs, filter_snoozed_prs, SnoozeState};
6use anyhow::Result;
7use futures::stream::{FuturesUnordered, StreamExt};
8use std::collections::{HashMap, HashSet};
9use std::fmt;
10
11/// Typed error for GitHub authentication failures (401 / Bad credentials).
12/// Callers can downcast `anyhow::Error` to this type to distinguish auth
13/// errors from transient network errors and trigger a token re-prompt.
14#[derive(Debug)]
15pub struct AuthError {
16    pub message: String,
17}
18
19impl fmt::Display for AuthError {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        write!(f, "{}", self.message)
22    }
23}
24
25impl std::error::Error for AuthError {}
26
27/// Fetch PRs from all configured queries, deduplicate, score, and split into
28/// active and snoozed lists. Both lists are sorted by score descending.
29///
30/// This function is called from main.rs for initial load and from the TUI
31/// event loop for manual/auto refresh.
32pub async fn fetch_and_score_prs(
33    client: &octocrab::Octocrab,
34    config: &Config,
35    snooze_state: &SnoozeState,
36    cache_config: &CacheConfig,
37    verbose: bool,
38    auth_username: Option<&str>,
39) -> Result<(
40    Vec<(PullRequest, ScoreResult)>,
41    Vec<(PullRequest, ScoreResult)>,
42    Option<u64>,
43)> {
44    if verbose {
45        let cache_status = if cache_config.enabled {
46            "enabled"
47        } else {
48            "disabled (--no-cache)"
49        };
50        eprintln!("Cache: {}", cache_status);
51    }
52
53    // Resolve global scoring config once (fallback for queries without per-query scoring)
54    let global_scoring = config.scoring.clone().unwrap_or_default();
55
56    // Search PRs for each query in parallel
57    let mut all_prs = Vec::new();
58    let mut any_succeeded = false;
59
60    let mut futures = FuturesUnordered::new();
61    let auth_username_owned = auth_username.map(|s| s.to_string());
62    for (query_index, query_config) in config.queries.iter().enumerate() {
63        let client = client.clone();
64        let query = query_config.query.clone();
65        let query_name = query_config.name.clone();
66        let auth_username_clone = auth_username_owned.clone();
67        // Merge scoring config for this query to get the effective exclude patterns
68        let merged_scoring = merge_scoring_configs(&global_scoring, query_config.scoring.as_ref());
69        let exclude_patterns = merged_scoring.size.and_then(|s| s.exclude);
70        futures.push(async move {
71            let result = crate::github::search_and_enrich_prs(
72                &client,
73                &query,
74                auth_username_clone.as_deref(),
75                exclude_patterns,
76                verbose,
77            )
78            .await;
79            (query_name, query, query_index, result)
80        });
81    }
82
83    while let Some((name, query, query_index, result)) = futures.next().await {
84        match result {
85            Ok(prs) => {
86                if verbose {
87                    eprintln!(
88                        "  Found {} PRs for {}",
89                        prs.len(),
90                        name.as_deref().unwrap_or(&query)
91                    );
92                }
93                // Extend with (pr, query_index) pairs to track which query each PR came from
94                all_prs.extend(prs.into_iter().map(|pr| (pr, query_index)));
95                any_succeeded = true;
96            }
97            Err(e) => {
98                // If it's an auth error, bail immediately (all queries will fail)
99                if e.downcast_ref::<AuthError>().is_some() {
100                    return Err(e);
101                }
102                if verbose {
103                    eprintln!(
104                        "Query failed: {} - {}",
105                        name.as_deref().unwrap_or(&query),
106                        e
107                    );
108                }
109            }
110        }
111    }
112
113    // If all queries failed, return error
114    if !any_succeeded && !config.queries.is_empty() {
115        anyhow::bail!("All queries failed. Check your network connection and GitHub token.");
116    }
117
118    // Deduplicate PRs by URL (same PR may appear in multiple queries)
119    // First match wins: track both unique PRs and their query index
120    let mut seen_urls = HashSet::new();
121    let mut pr_to_query_index = HashMap::new();
122    let unique_prs: Vec<_> = all_prs
123        .into_iter()
124        .filter_map(|(pr, query_idx)| {
125            if seen_urls.insert(pr.url.clone()) {
126                pr_to_query_index.insert(pr.url.clone(), query_idx);
127                Some(pr)
128            } else {
129                None
130            }
131        })
132        .collect();
133
134    if verbose {
135        eprintln!("After deduplication: {} unique PRs", unique_prs.len());
136    }
137
138    // Split into active and snoozed
139    let active_prs = filter_active_prs(unique_prs.clone(), snooze_state);
140    let snoozed_prs = filter_snoozed_prs(unique_prs, snooze_state);
141
142    if verbose {
143        eprintln!(
144            "After filter: {} active, {} snoozed",
145            active_prs.len(),
146            snoozed_prs.len()
147        );
148    }
149
150    // Score active PRs (merge per-query scoring config with global for each PR)
151    let mut active_scored: Vec<_> = active_prs
152        .into_iter()
153        .map(|pr| {
154            // Look up which query this PR came from and merge its scoring config
155            let query_idx = pr_to_query_index.get(&pr.url).copied().unwrap_or(0);
156            let scoring =
157                merge_scoring_configs(&global_scoring, config.queries[query_idx].scoring.as_ref());
158            let result = calculate_score(&pr, &scoring);
159            (pr, result)
160        })
161        .collect();
162
163    // Score snoozed PRs (merge per-query scoring config with global for each PR)
164    let mut snoozed_scored: Vec<_> = snoozed_prs
165        .into_iter()
166        .map(|pr| {
167            // Look up which query this PR came from and merge its scoring config
168            let query_idx = pr_to_query_index.get(&pr.url).copied().unwrap_or(0);
169            let scoring =
170                merge_scoring_configs(&global_scoring, config.queries[query_idx].scoring.as_ref());
171            let result = calculate_score(&pr, &scoring);
172            (pr, result)
173        })
174        .collect();
175
176    // Sort both lists by score descending, then by age ascending (older first for ties)
177    let sort_fn = |a: &(PullRequest, ScoreResult), b: &(PullRequest, ScoreResult)| {
178        // Primary: score descending
179        let score_cmp =
180            b.1.score
181                .partial_cmp(&a.1.score)
182                .unwrap_or(std::cmp::Ordering::Equal);
183        if score_cmp != std::cmp::Ordering::Equal {
184            return score_cmp;
185        }
186        // Tie-breaker: age ascending (older first = smaller created_at)
187        a.0.created_at.cmp(&b.0.created_at)
188    };
189
190    active_scored.sort_by(sort_fn);
191    snoozed_scored.sort_by(sort_fn);
192
193    // Fetch rate limit info (best-effort, don't fail the whole fetch if unavailable)
194    let rate_limit_remaining = match client.ratelimit().get().await {
195        Ok(rate_limit) => Some(rate_limit.resources.core.remaining as u64),
196        Err(_) => None,
197    };
198
199    Ok((active_scored, snoozed_scored, rate_limit_remaining))
200}