github_fetch/
filters.rs

1use chrono::{DateTime, Utc};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4
5use crate::types::GitHubIssue;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub enum IssueState {
9    Open,
10    Closed,
11    All,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct IssueFilters {
16    pub state: IssueState,
17    pub include_labels: Vec<String>,
18    pub exclude_labels: Vec<String>,
19    pub rust_errors_only: bool,
20    pub code_blocks_only: bool,
21    pub min_body_length: Option<usize>,
22    pub date_range: Option<DateRange>,
23    pub include_pull_requests: bool,
24    pub min_comments: Option<u32>,
25    pub required_keywords: Vec<String>,
26    pub excluded_keywords: Vec<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DateRange {
31    pub start: Option<DateTime<Utc>>,
32    pub end: Option<DateTime<Utc>>,
33}
34
35impl Default for IssueFilters {
36    fn default() -> Self {
37        Self {
38            state: IssueState::All,
39            include_labels: vec![],
40            exclude_labels: vec![
41                "duplicate".to_string(),
42                "invalid".to_string(),
43                "wontfix".to_string(),
44                "question".to_string(),
45            ],
46            rust_errors_only: false,
47            code_blocks_only: false,
48            min_body_length: Some(50),
49            date_range: None,
50            include_pull_requests: false,
51            min_comments: None,
52            required_keywords: vec![],
53            excluded_keywords: vec![
54                "discussion".to_string(),
55                "RFC".to_string(),
56                "tracking".to_string(),
57            ],
58        }
59    }
60}
61
62impl IssueFilters {
63    pub fn rust_error_focused() -> Self {
64        Self {
65            state: IssueState::All,
66            include_labels: vec![
67                "E-help-wanted".to_string(),
68                "A-diagnostics".to_string(),
69                "A-borrowck".to_string(),
70                "E-easy".to_string(),
71                "E-medium".to_string(),
72            ],
73            rust_errors_only: true,
74            code_blocks_only: true,
75            min_body_length: Some(100),
76            ..Default::default()
77        }
78    }
79
80    pub fn matches(&self, issue: &GitHubIssue) -> bool {
81        match self.state {
82            IssueState::Open => {
83                if issue.state != "Open" {
84                    return false;
85                }
86            }
87            IssueState::Closed => {
88                if issue.state != "Closed" {
89                    return false;
90                }
91            }
92            IssueState::All => {}
93        }
94
95        if !self.include_pull_requests && issue.is_pull_request {
96            return false;
97        }
98
99        if !self.include_labels.is_empty() {
100            let has_included_label = issue.labels.iter().any(|label| {
101                self.include_labels
102                    .iter()
103                    .any(|include_label| include_label.to_lowercase() == label.name.to_lowercase())
104            });
105            if !has_included_label {
106                return false;
107            }
108        }
109
110        if issue.labels.iter().any(|label| {
111            self.exclude_labels
112                .iter()
113                .any(|exclude_label| exclude_label.to_lowercase() == label.name.to_lowercase())
114        }) {
115            return false;
116        }
117
118        if let Some(min_length) = self.min_body_length {
119            if issue.body.as_ref().map_or(0, |b| b.len()) < min_length {
120                return false;
121            }
122        }
123
124        if let Some(min_comments) = self.min_comments {
125            if issue.comments < min_comments {
126                return false;
127            }
128        }
129
130        if let Some(date_range) = &self.date_range {
131            if let Some(start) = date_range.start {
132                if issue.created_at < start {
133                    return false;
134                }
135            }
136            if let Some(end) = date_range.end {
137                if issue.created_at > end {
138                    return false;
139                }
140            }
141        }
142
143        let content =
144            format!("{} {}", issue.title, issue.body.as_deref().unwrap_or("")).to_lowercase();
145
146        if !self.required_keywords.is_empty() {
147            if !self
148                .required_keywords
149                .iter()
150                .any(|keyword| content.contains(&keyword.to_lowercase()))
151            {
152                return false;
153            }
154        }
155
156        if self
157            .excluded_keywords
158            .iter()
159            .any(|keyword| content.contains(&keyword.to_lowercase()))
160        {
161            return false;
162        }
163
164        if self.rust_errors_only {
165            if !has_rust_error_codes(&content) {
166                return false;
167            }
168        }
169
170        if self.code_blocks_only {
171            if !has_code_blocks(issue.body.as_deref().unwrap_or("")) {
172                return false;
173            }
174        }
175
176        true
177    }
178}
179
180pub fn has_rust_error_codes(text: &str) -> bool {
181    let error_regex = Regex::new(r"E0\d{3,4}").unwrap();
182    error_regex.is_match(text)
183}
184
185pub fn has_code_blocks(text: &str) -> bool {
186    if text.contains("```") {
187        return true;
188    }
189
190    text.lines()
191        .any(|line| line.starts_with("    ") && !line.trim().is_empty())
192}
193
194pub fn extract_error_codes(text: &str) -> Vec<String> {
195    let error_regex = Regex::new(r"E0\d{3,4}").unwrap();
196    error_regex
197        .find_iter(text)
198        .map(|m| m.as_str().to_string())
199        .collect::<std::collections::HashSet<_>>()
200        .into_iter()
201        .collect()
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_rust_error_detection() {
210        assert!(has_rust_error_codes("Error E0382: use of moved value"));
211        assert!(has_rust_error_codes("Getting E0502 and E0499 errors"));
212        assert!(!has_rust_error_codes("No errors here"));
213
214        let codes = extract_error_codes("E0382 and E0502 errors occurred");
215        assert_eq!(codes.len(), 2);
216        assert!(codes.contains(&"E0382".to_string()));
217        assert!(codes.contains(&"E0502".to_string()));
218    }
219
220    #[test]
221    fn test_code_block_detection() {
222        assert!(has_code_blocks("```rust\nfn main() {}\n```"));
223        assert!(has_code_blocks("    let x = 5;\n    println!(\"{}\", x);"));
224        assert!(!has_code_blocks("Just regular text without code"));
225    }
226}