├── Contributors.md ├── Cargo.toml ├── .gitignore ├── Readme.md └── src ├── models.rs ├── networking.rs └── main.rs /Contributors.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | * [Kenny Ackerson](https://twitter.com/pearapps) 4 | * [Matthew Bischoff](http://matthewbischoff.com) 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "remindbot" 3 | version = "0.1.0" 4 | authors = ["kenny "] 5 | 6 | [dependencies] 7 | hyper = "*" 8 | rustc-serialize = "*" 9 | chrono = "*" 10 | argparse = "*" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here http://doc.crates.io/guide.html#cargotoml-vs-cargolock 7 | Cargo.lock 8 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # What is Remindbot? 2 | 3 | Remindbot is a GitHub bot that reminds github assignees of stale pull requests. 4 | 5 | # How it works 6 | 7 | Remindbot works by looking at all the pull requests in a repository and checking to see if any of the comments are: 8 | 9 | * By the current assignee 10 | * Within a certain time (by default, the last day) 11 | 12 | If these two are not true and the PR is older than a day (also configurable), the bot will leave a comment on the Pull Request to remind the assignee that they need to review it. 13 | 14 | # How to use 15 | 16 | 1. Compile the bot using [Cargo](https://crates.io) 17 | - Use `cargo build --release` 18 | 2. `cd` to the directory where the binary is built (by default its at `./target/release`) 19 | 3. The command to run RemindBot is `remindbot --owner pearapps --repo initializeme --auth_token SOME_TOKEN` 20 | - `--owner` is the GitHub user whose repo you want to remind assignees on 21 | - `--repo` is the repo name you want to remind assignees on 22 | - This will run RemindBot once 23 | - If you want RemindBot to run continuously - you have to handle that yourself for now. 24 | 25 | # What else? 26 | 27 | This bot also will tell you the average amount of time all open Pull Requests with an assignee have been open. 28 | 29 | ## Why Rust? 30 | 31 | Rust is expressive, safe, and fast. 32 | -------------------------------------------------------------------------------- /src/models.rs: -------------------------------------------------------------------------------- 1 | use chrono::*; 2 | 3 | #[derive(Debug)] 4 | #[derive(RustcDecodable)] 5 | pub struct PullRequest { 6 | pub number: i32, 7 | pub created_at: String, 8 | pub assignee: Option, 9 | pub assignees: Option>, 10 | pub _links: Links, 11 | } 12 | 13 | impl PullRequest { 14 | pub fn assignees(&self) -> Option>{ 15 | if let Some(ref assignees) = self.assignees { 16 | return Some(assignees.clone()); 17 | } 18 | else if let Some(ref assignee) = self.assignee { 19 | return Some([assignee.clone()].to_vec()); 20 | } 21 | else { 22 | None 23 | } 24 | } 25 | } 26 | 27 | #[derive(Clone)] 28 | #[derive(Debug)] 29 | #[derive(RustcDecodable)] 30 | pub struct Comment { 31 | pub user: User, 32 | pub created_at: String, 33 | } 34 | 35 | impl Comment { 36 | pub fn created_at_date_time(&self) -> Result, ParseError> { 37 | return DateTime::parse_from_rfc3339(&self.created_at); 38 | } 39 | } 40 | 41 | impl PullRequest { 42 | pub fn created_at_date_time(&self) -> Result, ParseError> { 43 | return DateTime::parse_from_rfc3339(&self.created_at); 44 | } 45 | } 46 | 47 | #[derive(Clone)] 48 | #[derive(Debug)] 49 | #[derive(RustcDecodable)] 50 | pub struct User { 51 | pub login: String, 52 | } 53 | 54 | #[derive(Debug)] 55 | #[derive(RustcDecodable)] 56 | pub struct Links { 57 | pub comments: Link, 58 | pub review_comments: Link, 59 | } 60 | 61 | #[derive(Debug)] 62 | #[derive(RustcDecodable)] 63 | pub struct Link { 64 | pub href: String, 65 | } 66 | -------------------------------------------------------------------------------- /src/networking.rs: -------------------------------------------------------------------------------- 1 | extern crate rustc_serialize; 2 | 3 | use rustc_serialize::json; 4 | use hyper::header::Headers; 5 | use hyper::client::Client; 6 | use std::io::Read; 7 | 8 | pub fn post_request(url: &str, client: &Client, headers: Headers, body_string: &str) { 9 | 10 | #[derive(RustcEncodable)] 11 | struct CommentPost<'a> { 12 | pub body: &'a str, 13 | } 14 | 15 | let post = CommentPost { body: body_string }; 16 | 17 | if let Ok(json) = json::encode(&post) { 18 | let response = client.post(url) 19 | .headers(headers.clone()) 20 | .body(&json) 21 | .send(); 22 | println!("{:?}", response); 23 | } else { 24 | println!("Failed to encode comment post."); 25 | } 26 | } 27 | 28 | pub fn network_request(url: &str, client: &Client, headers: Headers) -> Option<(String, Headers)> { 29 | if let Ok(mut response) = client.get(url) 30 | .headers(headers.clone()) 31 | .send() { 32 | let mut response_buffer = String::new(); 33 | 34 | response.read_to_string(&mut response_buffer); 35 | let response_headers = response.headers.clone(); 36 | return Some((response_buffer, response_headers)); 37 | } 38 | return None; 39 | } 40 | 41 | pub fn get_model_from_network(url: &str, 42 | client: &Client, 43 | headers: Headers) -> Option { 44 | if let Some(response_buffer) = network_request(url, &client, headers) { 45 | let request: Result = 46 | json::decode(&response_buffer.0); 47 | 48 | return request.ok(); 49 | } 50 | return None; 51 | } 52 | 53 | pub fn get_models_from_network(url: &str, 54 | client: &Client, 55 | headers: Headers) 56 | -> Vec { 57 | 58 | 59 | // println!("{:?}", response_buffer); 60 | if let Some(response_buffer) = network_request(url, &client, headers.clone()) { 61 | let requests: Result, rustc_serialize::json::DecoderError> = 62 | json::decode(&response_buffer.0); 63 | 64 | if let Ok(requests) = requests { 65 | if let Some(next_url) = next_url(&response_buffer.1) { 66 | let mut mutable_requests = requests; 67 | mutable_requests.extend(get_models_from_network(&next_url, client, headers)); 68 | return mutable_requests; 69 | }; 70 | return requests; 71 | }; 72 | } 73 | return Vec::new(); 74 | } 75 | 76 | pub fn next_url(headers: &Headers) -> Option { 77 | if let Some(link_header) = headers.get_raw("Link") { 78 | let ref first = link_header[0]; 79 | 80 | if let Ok(whole_link_header) = String::from_utf8(first.clone()) { 81 | 82 | // println!("{:?}", whole_link_header); 83 | 84 | let parts: Vec<&str> = whole_link_header.split(",") 85 | .filter(|x| x.contains("rel=\"next\"")) 86 | .collect(); 87 | 88 | if !parts.is_empty() { 89 | let next = parts[0].to_string(); 90 | 91 | if let Some(indices) = next.find("<") 92 | .and_then(|x| next.find(">").and_then(|y| Some((x, y)))) { 93 | let url = &next[indices.0 + 1..indices.1]; 94 | println!("{:?}", url); 95 | return Some(url.to_string()); 96 | } 97 | 98 | return None; 99 | }; 100 | }; 101 | 102 | }; 103 | 104 | return None; 105 | } 106 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod models; 2 | mod networking; 3 | 4 | #[macro_use] 5 | extern crate hyper; 6 | extern crate rustc_serialize; 7 | extern crate chrono; 8 | extern crate argparse; 9 | 10 | header! { (UserAgent, "User-Agent") => [String] } 11 | header! { (Authorization, "Authorization") => [String] } 12 | 13 | use hyper::client::Client; 14 | use hyper::header::Headers; 15 | use chrono::*; 16 | use argparse::{ArgumentParser, Store}; 17 | 18 | use models::*; 19 | use networking::*; 20 | 21 | fn main() { 22 | 23 | let mut repo_owner = String::from(""); 24 | let mut repo = String::from(""); 25 | let mut time_since_comment_to_consider_disqualification = 24 * 60 * 60; 26 | let mut message = "Bump".to_string(); 27 | let mut authorization_token = String::from(""); 28 | 29 | { 30 | let mut parser = ArgumentParser::new(); 31 | parser.refer(&mut repo_owner) 32 | .add_option(&["--owner"], Store, "Owner of the github repo."); 33 | parser.refer(&mut repo) 34 | .add_option(&["--repo"], Store, "The name of the github repo."); 35 | 36 | parser.refer(&mut time_since_comment_to_consider_disqualification) 37 | .add_option(&["--seconds"], 38 | Store, 39 | "The lower limit of seconds since the last time the assignee has 40 | \ 41 | commented on a PR to remind the assignee via a comment."); 42 | 43 | parser.refer(&mut message) 44 | .add_option(&["--message"], 45 | Store, 46 | "The message in the comment on the open pull request. 47 | The \ 48 | default is 24 * 60 * 60; which is a day in seconds."); 49 | 50 | parser.refer(&mut authorization_token) 51 | .add_option(&["--auth_token"], 52 | Store, 53 | "The personal access token of the user that will be requesting \ 54 | information on the repo's pull requests 55 | and commenting on \ 56 | the pull requests if needed."); 57 | 58 | parser.parse_args_or_exit(); 59 | } 60 | 61 | if repo_owner.len() == 0 || repo.len() == 0 || authorization_token.len() == 0 { 62 | panic!("Invalid input, you must give a owner, repo and auth_token"); 63 | } 64 | 65 | let url = &format!("https://api.github.com/repos/{}/{}/pulls", repo_owner, repo); 66 | 67 | let dt = Local::now(); 68 | 69 | let http_client = Client::new(); 70 | 71 | let mut headers = Headers::new(); 72 | headers.set(UserAgent("remind-bot".to_string())); 73 | headers.set(Authorization(format!("token {}", authorization_token).to_string())); 74 | 75 | let headers_clone = headers.clone(); 76 | 77 | let user: Option = get_model_from_network("https://api.github.com/user", &http_client, headers.clone()); 78 | 79 | if user.is_none() { 80 | println!("Could not get user information"); 81 | }; 82 | 83 | let requests: Vec = get_models_from_network(url, &http_client, headers); 84 | 85 | let pull_requests: Vec<&PullRequest> = 86 | requests 87 | .iter() 88 | .map(|request| { 89 | 90 | let new_headers = headers_clone.clone(); 91 | 92 | let mut comments = get_models_from_network(&request._links.comments.href, &http_client, new_headers.clone()); 93 | 94 | comments.extend(get_models_from_network(&request._links.review_comments.href, &http_client, new_headers)); 95 | 96 | return (request, comments); 97 | }) 98 | .map(|x| { 99 | 100 | // This code block is responsbible for filtering out comments that are not by the assignee. 101 | 102 | if let Some(ref assignee) = x.0.assignee { 103 | let comments: Vec = 104 | x.1 105 | .iter() 106 | .filter(|x: &&Comment| { 107 | if let &Some(ref current_user) = &user { 108 | println!("{:?}", current_user); 109 | 110 | return x.user.login == current_user.login || x.user.login == assignee.login; 111 | } 112 | else { 113 | x.user.login == assignee.login 114 | } 115 | }) 116 | .cloned() 117 | .collect(); 118 | 119 | return (x.0, comments); 120 | } 121 | 122 | return x; 123 | }) 124 | .filter(|x| { 125 | 126 | let pull_request_time: Result, ParseError> = x.0.created_at_date_time(); 127 | 128 | if let Some(pull_request_date_time) = pull_request_time.ok() { 129 | let comments: Vec<&Comment> = x.1.iter().filter(|x: &&Comment| { 130 | 131 | let time: Result, ParseError> = x.created_at_date_time(); 132 | 133 | if let Some(date_time) = time.ok() { 134 | return !is_after_desired_time(&date_time, &dt, time_since_comment_to_consider_disqualification); 135 | } 136 | 137 | return false 138 | }).collect(); 139 | 140 | return 141 | comments.len() == 0 142 | || 143 | // We want to check the length of the unfiltered array here because this is checking if 144 | // the pull requet has no comments by the assignee at all and is older than the specified time. 145 | // This works because in the previous filter filters the comments to ones created by the assignee. 146 | (x.1.len() == 0 && is_after_desired_time(&pull_request_date_time, &dt, time_since_comment_to_consider_disqualification)); 147 | } 148 | 149 | return false 150 | }) 151 | .map(|x| 152 | // We just want the pull requests at this point 153 | x.0 154 | ) 155 | .collect(); 156 | 157 | for pull_request in &pull_requests { 158 | if let Some(ref assignee) = pull_request.assignee { 159 | let mut final_message_string = format!("@{} ", assignee.login); 160 | final_message_string.push_str(&message); 161 | post_request(&pull_request._links.comments.href, 162 | &http_client, 163 | headers_clone.clone(), 164 | &final_message_string); 165 | } 166 | 167 | } 168 | 169 | let to_measure = 170 | requests 171 | .iter() 172 | .filter(|x| { 173 | x.assignee.is_some() 174 | }) 175 | .map(|pr| { 176 | if let Some(time) = pr.created_at_date_time().ok() { 177 | return Some(dt.timestamp() - time.timestamp()); 178 | } 179 | 180 | None 181 | }) 182 | .filter(|pr| { 183 | pr.is_some() 184 | }) 185 | .fold(0, |x, y| { 186 | 187 | if let Some(second) = y { 188 | return x + second; 189 | } 190 | 191 | x 192 | 193 | }) / (requests.len() as i64); 194 | 195 | println!("The average time the currently open pull requests with assignees have been open in {:?} is {:?} seconds", repo, to_measure); 196 | } 197 | 198 | fn is_after_desired_time(date_time: &DateTime, 199 | desired_time: &DateTime, 200 | seconds: i64) -> bool { 201 | desired_time.timestamp() - date_time.timestamp() > seconds 202 | } 203 | --------------------------------------------------------------------------------