1use jsonwebtoken::{Algorithm, EncodingKey, Header};
2use log::info;
3use reqwest::header::{ACCEPT, AUTHORIZATION};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::time::Duration;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9#[derive(Debug, Serialize)]
10struct CommentBody {
11 pub body: String,
12}
13
14#[derive(Debug)]
15pub struct CommentArgs {
16 pub owner: String,
17 pub repo: String,
18 pub issue: i64,
19 pub body: String,
20}
21
22#[derive(Debug, Deserialize)]
23pub struct GithubAccessToken {
24 pub expires_at: String,
25 pub permissions: Value,
26 pub repository_selection: String,
27 pub token: String,
28}
29
30fn create_access_token(jwt: &str, install_id: i64) -> Result<GithubAccessToken, GithubError> {
32 Ok(reqwest::Client::new()
33 .post(&format!(
34 "https://api.github.com/app/installations/{install_id}/access_tokens",
35 install_id = install_id
36 ))
37 .header(AUTHORIZATION, format!("Bearer {}", jwt))
38 .header(ACCEPT, "application/vnd.github.machine-man-preview+json")
39 .send()?
40 .error_for_status()?
41 .json::<GithubAccessToken>()?)
42}
43
44fn create_comment(comment: CommentArgs, secret: &str) -> Result<Value, GithubError> {
46 let comment_body = CommentBody { body: comment.body };
47 Ok(reqwest::Client::new()
48 .post(&format!(
49 "https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/comments",
50 owner = comment.owner,
51 repo = comment.repo,
52 issue_number = comment.issue
53 ))
54 .header(AUTHORIZATION, format!("Bearer {}", secret))
55 .json(&comment_body)
56 .send()?
57 .error_for_status()?
58 .json::<Value>()?)
59}
60
61#[derive(Debug, Deserialize)]
62pub struct AppInfo {
63 pub id: i64,
64 pub slug: String,
65}
66
67pub fn get_app_info(jwt: &str) -> Result<AppInfo, GithubError> {
69 Ok(reqwest::Client::new()
70 .get("https://api.github.com/app")
71 .header(AUTHORIZATION, format!("Bearer {}", jwt))
72 .send()?
73 .error_for_status()?
74 .json::<AppInfo>()?)
75}
76
77#[derive(Debug, Deserialize, Serialize)]
78pub struct User {
79 pub id: i64,
80 pub login: String,
81 pub r#type: String,
82}
83
84#[derive(Debug, Deserialize, Serialize)]
85pub struct Comment {
86 pub id: i64,
87 pub url: String,
88 pub html_url: String,
89 pub body: String,
90 pub user: User,
91}
92
93#[derive(Debug)]
94pub enum GithubError {
95 JsonWebTokenCreation(jsonwebtoken::errors::Error),
96 HttpError(reqwest::Error),
97}
98
99impl std::convert::From<reqwest::Error> for GithubError {
100 fn from(e: reqwest::Error) -> Self {
101 Self::HttpError(e)
102 }
103}
104
105impl std::convert::From<jsonwebtoken::errors::Error> for GithubError {
106 fn from(e: jsonwebtoken::errors::Error) -> Self {
107 Self::JsonWebTokenCreation(e)
108 }
109}
110
111#[derive(Debug, Serialize, Deserialize)]
112struct Claim {
113 iat: u64,
115 exp: u64,
117 iss: String,
119}
120
121fn generate_jwt(
125 private_key: &str,
126 app_identifier: i64,
127) -> Result<String, jsonwebtoken::errors::Error> {
128 let now_unix_time = SystemTime::now()
129 .duration_since(UNIX_EPOCH)
130 .expect("problem getting current time");
131 let claim = Claim {
132 iat: now_unix_time.as_secs(),
133 exp: (now_unix_time + Duration::from_secs(10 * 60)).as_secs(),
134 iss: app_identifier.to_string(),
135 };
136
137 jsonwebtoken::encode(
138 &Header::new(Algorithm::RS256),
139 &claim,
140 &EncodingKey::from_rsa_pem(private_key.as_ref())?,
141 )
142}
143
144fn list_comments(pr: &PullRequest, secret: &str) -> Result<Vec<Comment>, GithubError> {
146 Ok(reqwest::Client::new()
149 .get(&format!(
150 "https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/comments",
151 owner = pr.owner,
152 repo = pr.repo,
153 issue_number = pr.issue
154 ))
155 .query(&[("per_page", 100)])
156 .header(AUTHORIZATION, format!("Bearer {}", secret))
157 .send()?
158 .error_for_status()?
159 .json::<Vec<Comment>>()?)
160}
161
162fn update_comment(
164 owner: &str,
165 repo: &str,
166 comment_id: i64,
167 body: String,
168 secret: &str,
169) -> Result<Value, GithubError> {
170 Ok(reqwest::Client::new()
171 .patch(&format!(
172 "https://api.github.com/repos/{owner}/{repo}/issues/comments/{comment_id}",
173 owner = owner,
174 repo = repo,
175 comment_id = comment_id
176 ))
177 .header(AUTHORIZATION, format!("Bearer {}", secret))
178 .json(&CommentBody { body })
179 .send()?
180 .error_for_status()?
181 .json::<Value>()?)
182}
183
184pub struct PullRequest {
185 pub owner: String,
186 pub repo: String,
187 pub issue: i64,
188}
189
190pub fn comment_on_pr(
191 private_key: &str,
192 app_id: i64,
193 install_id: i64,
194 pr: PullRequest,
195 comment_body: String,
196) -> Result<Value, GithubError> {
197 info!("generating jwt");
198 let jwt = generate_jwt(private_key, app_id)?;
199 info!("getting app info");
200 let app_info = get_app_info(&jwt)?;
201 info!("creating access token");
202 let access_token = create_access_token(&jwt, install_id)?;
203 info!("fetching comments for PR");
204 let comments = list_comments(&pr, &access_token.token)?;
205
206 let bot_name = format!("{}[bot]", app_info.slug);
207
208 info!("checking for existing comment");
209 match comments
210 .iter()
211 .find(|x| x.user.r#type == "Bot" && x.user.login == bot_name)
212 {
213 Some(prev_comment) => {
214 info!("updating comment");
215 update_comment(
216 &pr.owner,
217 &pr.repo,
218 prev_comment.id,
219 comment_body,
220 &access_token.token,
221 )
222 }
223 None => {
224 info!("creating comment");
225 create_comment(
226 CommentArgs {
227 owner: pr.owner,
228 repo: pr.repo,
229 issue: pr.issue,
230 body: comment_body,
231 },
232 &access_token.token,
233 )
234 }
235 }
236}