search_issue_refs/
main.rs

1//! Recursively searches local directory for references to issues that are closed.
2//!
3//! Requires a Github access token as this program will make queries to the GitHub API where authentication is needed.
4//!
5//! See <https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token>
6//!
7//! Example usage:
8//!
9//! (from the root directory of the Zebra repo)
10//! ```console
11//! GITHUB_TOKEN={valid_github_access_token} search-issue-refs
12//! ```
13//!
14//! Example output:
15//!
16//! > Found 3 possible issue refs, checking Github issue statuses..
17//! >
18//! > --------------------------------------
19//! > Found reference to closed issue #4794: ./zebra-rpc/src/methods/get_block_template_rpcs.rs:114:19
20//! > <https://github.com/ZcashFoundation/zebra/blob/main/zebra-rpc/src/methods/get_block_template_rpcs.rs#L114>
21//! > <https://github.com/ZcashFoundation/zebra/issues/4794>
22//! >
23//! > --------------------------------------
24//! > Found reference to closed issue #2379: ./zebra-consensus/src/transaction.rs:717:49
25//! > <https://github.com/ZcashFoundation/zebra/blob/main/zebra-consensus/src/transaction.rs#L717>
26//! > <https://github.com/ZcashFoundation/zebra/issues/2379>
27//! >
28//! > --------------------------------------
29//! > Found reference to closed issue #3027: ./zebra-consensus/src/transaction/check.rs:319:6
30//! > <https://github.com/ZcashFoundation/zebra/blob/main/zebra-consensus/src/transaction/check.rs#L319>
31//! > <https://github.com/ZcashFoundation/zebra/issues/3027>
32//! >
33//! > Found 3 references to closed issues.
34
35use std::{
36    collections::HashMap,
37    env,
38    ffi::OsStr,
39    fs::{self, File},
40    io::{self, BufRead},
41    path::PathBuf,
42};
43
44use color_eyre::eyre::Result;
45use regex::Regex;
46use reqwest::{
47    header::{self, HeaderMap, HeaderValue},
48    ClientBuilder,
49};
50
51use tokio::task::JoinSet;
52use zebra_utils::init_tracing;
53
54const GITHUB_TOKEN_ENV_KEY: &str = "GITHUB_TOKEN";
55
56const VALID_EXTENSIONS: [&str; 4] = ["rs", "yml", "yaml", "toml"];
57
58fn check_file_ext(ext: &OsStr) -> bool {
59    VALID_EXTENSIONS
60        .into_iter()
61        .any(|valid_extension| valid_extension == ext)
62}
63
64fn search_directory(path: &PathBuf) -> Result<Vec<PathBuf>> {
65    if path.starts_with("/target/") {
66        return Ok(vec![]);
67    }
68
69    Ok(fs::read_dir(path)?
70        .filter_map(|entry| {
71            let path = entry.ok()?.path();
72
73            if path.is_dir() {
74                search_directory(&path).ok()
75            } else if path.is_file() {
76                match path.extension() {
77                    Some(ext) if check_file_ext(ext) => Some(vec![path]),
78                    _ => None,
79                }
80            } else {
81                None
82            }
83        })
84        .flatten()
85        .collect())
86}
87
88fn github_issue_url(issue_id: &str) -> String {
89    format!("https://github.com/ZcashFoundation/zebra/issues/{issue_id}")
90}
91
92fn github_remote_file_ref(file_path: &str, line: usize) -> String {
93    let file_path = &crate_mod_path(file_path, line);
94    format!("https://github.com/ZcashFoundation/zebra/blob/main/{file_path}")
95}
96
97fn github_permalink(sha: &str, file_path: &str, line: usize) -> String {
98    let file_path = &crate_mod_path(file_path, line);
99    format!("https://github.com/ZcashFoundation/zebra/blob/{sha}/{file_path}")
100}
101
102fn crate_mod_path(file_path: &str, line: usize) -> String {
103    let file_path = &file_path[2..];
104    format!("{file_path}#L{line}")
105}
106
107fn github_issue_api_url(issue_id: &str) -> String {
108    format!("https://api.github.com/repos/ZcashFoundation/zebra/issues/{issue_id}")
109}
110
111fn github_ref_api_url(reference: &str) -> String {
112    format!("https://api.github.com/repos/ZcashFoundation/zebra/git/ref/{reference}")
113}
114
115#[derive(Debug)]
116struct PossibleIssueRef {
117    file_path: String,
118    line_number: usize,
119    column: usize,
120}
121
122impl PossibleIssueRef {
123    #[allow(clippy::print_stdout, clippy::print_stderr)]
124    fn print_paths(issue_refs: &[PossibleIssueRef]) {
125        for PossibleIssueRef {
126            file_path,
127            line_number,
128            column,
129        } in issue_refs
130        {
131            let file_ref = format!("{file_path}:{line_number}:{column}");
132            let github_file_ref = github_remote_file_ref(file_path, *line_number);
133
134            println!("{file_ref}\n{github_file_ref}\n");
135        }
136    }
137}
138
139type IssueId = String;
140
141/// Process entry point for `search-issue-refs`
142#[allow(clippy::print_stdout, clippy::print_stderr)]
143#[tokio::main]
144async fn main() -> Result<()> {
145    init_tracing();
146    color_eyre::install()?;
147
148    let possible_issue_refs = {
149        let file_paths = search_directory(&".".into())?;
150
151        // Zebra's github issue numbers could be up to 4 digits
152        let issue_regex =
153            Regex::new(r"(https://github.com/ZcashFoundation/zebra/issues/|#)(\d{1,4})").unwrap();
154
155        let mut possible_issue_refs: HashMap<IssueId, Vec<PossibleIssueRef>> = HashMap::new();
156        let mut num_possible_issue_refs = 0;
157
158        for file_path in file_paths {
159            let file = File::open(&file_path)?;
160            let lines = io::BufReader::new(file).lines();
161
162            for (line_idx, line) in lines.into_iter().enumerate() {
163                let line = line?;
164                let line_number = line_idx + 1;
165
166                for captures in issue_regex.captures_iter(&line) {
167                    let file_path = file_path
168                        .to_str()
169                        .expect("paths from read_dir should be valid unicode")
170                        .to_string();
171
172                    let column = captures
173                        .get(1)
174                        .expect("matches should have 2 captures")
175                        .start()
176                        + 1;
177
178                    let potential_issue_ref =
179                        captures.get(2).expect("matches should have 2 captures");
180                    let matching_text = potential_issue_ref.as_str();
181
182                    let id = matching_text[matching_text.len().checked_sub(4).unwrap_or(1)..]
183                        .to_string();
184
185                    let issue_entry = possible_issue_refs.entry(id).or_default();
186
187                    if issue_entry.iter().all(|issue_ref| {
188                        issue_ref.line_number != line_number || issue_ref.file_path != file_path
189                    }) {
190                        num_possible_issue_refs += 1;
191
192                        issue_entry.push(PossibleIssueRef {
193                            file_path,
194                            line_number,
195                            column,
196                        });
197                    }
198                }
199            }
200        }
201
202        let num_possible_issues = possible_issue_refs.len();
203
204        println!(
205            "\nFound {num_possible_issue_refs} possible references to {num_possible_issues} issues, checking statuses on Github..\n"
206        );
207
208        possible_issue_refs
209    };
210
211    // check if issues are closed on Github
212
213    let Some((_, github_token)) = env::vars().find(|(key, _)| key == GITHUB_TOKEN_ENV_KEY) else {
214        println!(
215            "Can't find {GITHUB_TOKEN_ENV_KEY} in env vars, printing all found possible issue refs, \
216see https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token \
217to create a github token."
218        );
219
220        for (
221            id,
222            PossibleIssueRef {
223                file_path,
224                line_number,
225                column,
226            },
227        ) in possible_issue_refs
228            .into_iter()
229            .flat_map(|(issue_id, issue_refs)| {
230                issue_refs
231                    .into_iter()
232                    .map(move |issue_ref| (issue_id.clone(), issue_ref))
233            })
234        {
235            let github_url = github_issue_url(&id);
236            let github_file_ref = github_remote_file_ref(&file_path, line_number);
237
238            println!("\n--------------------------------------");
239            println!("Found possible reference to closed issue #{id}: {file_path}:{line_number}:{column}");
240            println!("{github_file_ref}");
241            println!("{github_url}");
242        }
243
244        return Ok(());
245    };
246
247    let mut headers = HeaderMap::new();
248    let mut auth_value = HeaderValue::from_str(&format!("Bearer {github_token}"))?;
249    let accept_value = HeaderValue::from_static("application/vnd.github+json");
250    let github_api_version_value = HeaderValue::from_static("2022-11-28");
251    let user_agent_value = HeaderValue::from_static("search-issue-refs");
252
253    auth_value.set_sensitive(true);
254
255    headers.insert(header::AUTHORIZATION, auth_value);
256    headers.insert(header::ACCEPT, accept_value);
257    headers.insert("X-GitHub-Api-Version", github_api_version_value);
258    headers.insert(header::USER_AGENT, user_agent_value);
259
260    let client = ClientBuilder::new().default_headers(headers).build()?;
261
262    // get latest commit sha on main
263
264    let latest_commit_json: serde_json::Value = serde_json::from_str::<serde_json::Value>(
265        &client
266            .get(github_ref_api_url("heads/main"))
267            .send()
268            .await?
269            .text()
270            .await?,
271    )
272    .expect("response text should be json");
273
274    let latest_commit_sha = latest_commit_json["object"]["sha"]
275        .as_str()
276        .expect("response.object.sha should be a string");
277
278    let mut github_api_requests = JoinSet::new();
279
280    for (id, issue_refs) in possible_issue_refs {
281        let request = client.get(github_issue_api_url(&id)).send();
282        github_api_requests.spawn(async move { (request.await, id, issue_refs) });
283    }
284
285    // print out closed issues
286
287    let mut num_closed_issue_refs = 0;
288    let mut num_closed_issues = 0;
289
290    while let Some(res) = github_api_requests.join_next().await {
291        let Ok((res, id, issue_refs)) = res else {
292            println!("warning: failed to join api request thread/task");
293            continue;
294        };
295
296        let Ok(res) = res else {
297            println!("warning: no response from github api about issue #{id}");
298            PossibleIssueRef::print_paths(&issue_refs);
299            continue;
300        };
301
302        let Ok(text) = res.text().await else {
303            println!("warning: no response from github api about issue #{id}");
304            PossibleIssueRef::print_paths(&issue_refs);
305            continue;
306        };
307
308        let Ok(json): Result<serde_json::Value, _> = serde_json::from_str(&text) else {
309            println!("warning: no response from github api about issue #{id}");
310            PossibleIssueRef::print_paths(&issue_refs);
311            continue;
312        };
313
314        if json["closed_at"] == serde_json::Value::Null {
315            continue;
316        };
317
318        println!("\n--------------------------------------\n- #{id}\n");
319
320        num_closed_issues += 1;
321
322        for PossibleIssueRef {
323            file_path,
324            line_number,
325            column: _,
326        } in issue_refs
327        {
328            num_closed_issue_refs += 1;
329
330            let github_permalink = github_permalink(latest_commit_sha, &file_path, line_number);
331
332            println!("{github_permalink}");
333        }
334    }
335
336    println!(
337        "\nConfirmed {num_closed_issue_refs} references to {num_closed_issues} closed issues.\n"
338    );
339
340    Ok(())
341}