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            )
77            .await;
78            (query_name, query, query_index, result)
79        });
80    }
81
82    while let Some((name, query, query_index, result)) = futures.next().await {
83        match result {
84            Ok(prs) => {
85                if verbose {
86                    eprintln!(
87                        "  Found {} PRs for {}",
88                        prs.len(),
89                        name.as_deref().unwrap_or(&query)
90                    );
91                }
92                // Extend with (pr, query_index) pairs to track which query each PR came from
93                all_prs.extend(prs.into_iter().map(|pr| (pr, query_index)));
94                any_succeeded = true;
95            }
96            Err(e) => {
97                // If it's an auth error, bail immediately (all queries will fail)
98                if e.downcast_ref::<AuthError>().is_some() {
99                    return Err(e);
100                }
101                eprintln!(
102                    "Query failed: {} - {}",
103                    name.as_deref().unwrap_or(&query),
104                    e
105                );
106            }
107        }
108    }
109
110    // If all queries failed, return error
111    if !any_succeeded && !config.queries.is_empty() {
112        anyhow::bail!("All queries failed. Check your network connection and GitHub token.");
113    }
114
115    // Deduplicate PRs by URL (same PR may appear in multiple queries)
116    // First match wins: track both unique PRs and their query index
117    let mut seen_urls = HashSet::new();
118    let mut pr_to_query_index = HashMap::new();
119    let unique_prs: Vec<_> = all_prs
120        .into_iter()
121        .filter_map(|(pr, query_idx)| {
122            if seen_urls.insert(pr.url.clone()) {
123                pr_to_query_index.insert(pr.url.clone(), query_idx);
124                Some(pr)
125            } else {
126                None
127            }
128        })
129        .collect();
130
131    if verbose {
132        eprintln!("After deduplication: {} unique PRs", unique_prs.len());
133    }
134
135    // Split into active and snoozed
136    let active_prs = filter_active_prs(unique_prs.clone(), snooze_state);
137    let snoozed_prs = filter_snoozed_prs(unique_prs, snooze_state);
138
139    if verbose {
140        eprintln!(
141            "After filter: {} active, {} snoozed",
142            active_prs.len(),
143            snoozed_prs.len()
144        );
145    }
146
147    // Score active PRs (merge per-query scoring config with global for each PR)
148    let mut active_scored: Vec<_> = active_prs
149        .into_iter()
150        .map(|pr| {
151            // Look up which query this PR came from and merge its scoring config
152            let query_idx = pr_to_query_index.get(&pr.url).copied().unwrap_or(0);
153            let scoring =
154                merge_scoring_configs(&global_scoring, config.queries[query_idx].scoring.as_ref());
155            let result = calculate_score(&pr, &scoring);
156            (pr, result)
157        })
158        .collect();
159
160    // Score snoozed PRs (merge per-query scoring config with global for each PR)
161    let mut snoozed_scored: Vec<_> = snoozed_prs
162        .into_iter()
163        .map(|pr| {
164            // Look up which query this PR came from and merge its scoring config
165            let query_idx = pr_to_query_index.get(&pr.url).copied().unwrap_or(0);
166            let scoring =
167                merge_scoring_configs(&global_scoring, config.queries[query_idx].scoring.as_ref());
168            let result = calculate_score(&pr, &scoring);
169            (pr, result)
170        })
171        .collect();
172
173    // Sort both lists by score descending, then by age ascending (older first for ties)
174    let sort_fn = |a: &(PullRequest, ScoreResult), b: &(PullRequest, ScoreResult)| {
175        // Primary: score descending
176        let score_cmp =
177            b.1.score
178                .partial_cmp(&a.1.score)
179                .unwrap_or(std::cmp::Ordering::Equal);
180        if score_cmp != std::cmp::Ordering::Equal {
181            return score_cmp;
182        }
183        // Tie-breaker: age ascending (older first = smaller created_at)
184        a.0.created_at.cmp(&b.0.created_at)
185    };
186
187    active_scored.sort_by(sort_fn);
188    snoozed_scored.sort_by(sort_fn);
189
190    // Fetch rate limit info (best-effort, don't fail the whole fetch if unavailable)
191    let rate_limit_remaining = match client.ratelimit().get().await {
192        Ok(rate_limit) => Some(rate_limit.resources.core.remaining as u64),
193        Err(_) => None,
194    };
195
196    Ok((active_scored, snoozed_scored, rate_limit_remaining))
197}