1#![allow(clippy::doc_markdown)]
2#![allow(clippy::missing_errors_doc)]
3#![allow(clippy::single_match_else)]
4use crate::{Comment, DEFAULT_GITHUB_API_URL, GitHubApi, GithubError};
5use jsonwebtoken::{Algorithm, EncodingKey, Header};
6
7use log::info;
8use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::time::Duration;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14pub(crate) const SQUAWK_USER_AGENT: &str = "squawk/2.27.0";
15
16#[derive(Debug, Serialize)]
17struct CommentBody {
18 pub body: String,
19}
20
21#[derive(Debug)]
22pub struct CommentArgs {
23 pub owner: String,
24 pub repo: String,
25 pub issue: i64,
26 pub body: String,
27}
28
29#[derive(Debug, Deserialize)]
30pub struct GithubAccessToken {
31 pub expires_at: String,
32 pub permissions: Value,
33 pub repository_selection: String,
34 pub token: String,
35}
36fn create_access_token(
38 github_api_url: &str,
39 jwt: &str,
40 install_id: i64,
41) -> Result<GithubAccessToken, GithubError> {
42 Ok(reqwest::blocking::Client::new()
43 .post(format!(
44 "{github_api_url}/app/installations/{install_id}/access_tokens",
45 ))
46 .header(AUTHORIZATION, format!("Bearer {jwt}"))
47 .header(ACCEPT, "application/vnd.github.machine-man-preview+json")
48 .header(USER_AGENT, SQUAWK_USER_AGENT)
49 .send()?
50 .error_for_status()?
51 .json::<GithubAccessToken>()?)
52}
53
54pub(crate) fn create_comment(
56 github_api_url: &str,
57 comment: CommentArgs,
58 secret: &str,
59) -> Result<(), GithubError> {
60 if comment.body.len() > 65_536 {
62 return Err(GithubError::CommentTooLarge(format!(
63 "Comment body is too large ({} characters). GitHub API limit is 65,536 characters.",
64 comment.body.len()
65 )));
66 }
67 let comment_body = CommentBody { body: comment.body };
68 reqwest::blocking::Client::new()
69 .post(format!(
70 "{github_api_url}/repos/{owner}/{repo}/issues/{issue_number}/comments",
71 owner = comment.owner,
72 repo = comment.repo,
73 issue_number = comment.issue
74 ))
75 .header(AUTHORIZATION, format!("Bearer {secret}"))
76 .header(USER_AGENT, SQUAWK_USER_AGENT)
77 .json(&comment_body)
78 .send()?
79 .error_for_status()?;
80 Ok(())
81}
82
83#[derive(Debug, Deserialize)]
84pub struct GitHubAppInfo {
85 pub id: i64,
86 pub slug: String,
87}
88
89pub fn get_app_info(github_api_url: &str, jwt: &str) -> Result<GitHubAppInfo, GithubError> {
91 Ok(reqwest::blocking::Client::new()
92 .get(format!("{github_api_url}/app"))
93 .header(AUTHORIZATION, format!("Bearer {jwt}"))
94 .header(USER_AGENT, SQUAWK_USER_AGENT)
95 .send()?
96 .error_for_status()?
97 .json::<GitHubAppInfo>()?)
98}
99
100impl std::fmt::Display for GithubError {
101 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
102 match *self {
103 Self::JsonWebTokenCreation(ref err) => {
104 write!(f, "Could not create JWT: {err}")
105 }
106 Self::HttpError(ref err) => {
107 write!(f, "Problem calling GitHub API: {err}")
108 }
109 Self::CommentTooLarge(ref msg) => {
110 write!(f, "Comment size error: {msg}")
111 }
112 }
113 }
114}
115
116impl std::convert::From<reqwest::Error> for GithubError {
117 fn from(e: reqwest::Error) -> Self {
118 Self::HttpError(e)
119 }
120}
121
122impl std::convert::From<jsonwebtoken::errors::Error> for GithubError {
123 fn from(e: jsonwebtoken::errors::Error) -> Self {
124 Self::JsonWebTokenCreation(e)
125 }
126}
127
128#[derive(Debug, Serialize, Deserialize)]
129struct Claim {
130 iat: u64,
132 exp: u64,
134 iss: String,
136}
137
138fn generate_jwt(
142 private_key: &str,
143 app_identifier: i64,
144) -> Result<String, jsonwebtoken::errors::Error> {
145 let now_unix_time = SystemTime::now()
146 .duration_since(UNIX_EPOCH)
147 .expect("problem getting current time");
148 let claim = Claim {
149 iat: now_unix_time.as_secs(),
150 exp: (now_unix_time + Duration::from_secs(10 * 60)).as_secs(),
151 iss: app_identifier.to_string(),
152 };
153
154 jsonwebtoken::encode(
155 &Header::new(Algorithm::RS256),
156 &claim,
157 &EncodingKey::from_rsa_pem(private_key.as_ref())?,
158 )
159}
160
161pub struct PullRequest {
162 pub owner: String,
163 pub repo: String,
164 pub issue: i64,
165}
166
167pub(crate) fn list_comments(
169 github_api_url: &str,
170 pr: &PullRequest,
171 secret: &str,
172) -> Result<Vec<Comment>, GithubError> {
173 Ok(reqwest::blocking::Client::new()
176 .get(format!(
177 "{github_api_url}/repos/{owner}/{repo}/issues/{issue_number}/comments",
178 owner = pr.owner,
179 repo = pr.repo,
180 issue_number = pr.issue
181 ))
182 .query(&[("per_page", 100)])
183 .header(AUTHORIZATION, format!("Bearer {secret}",))
184 .header(USER_AGENT, SQUAWK_USER_AGENT)
185 .send()?
186 .error_for_status()?
187 .json::<Vec<Comment>>()?)
188}
189
190pub(crate) fn update_comment(
192 github_api_url: &str,
193 owner: &str,
194 repo: &str,
195 comment_id: i64,
196 body: String,
197 secret: &str,
198) -> Result<(), GithubError> {
199 if body.len() > 65_536 {
201 return Err(GithubError::CommentTooLarge(format!(
202 "Comment body is too large ({} characters). GitHub API limit is 65,536 characters.",
203 body.len()
204 )));
205 }
206
207 reqwest::blocking::Client::new()
208 .patch(format!(
209 "{github_api_url}/repos/{owner}/{repo}/issues/comments/{comment_id}",
210 ))
211 .header(AUTHORIZATION, format!("Bearer {secret}"))
212 .header(USER_AGENT, SQUAWK_USER_AGENT)
213 .json(&CommentBody { body })
214 .send()?
215 .error_for_status()?;
216 Ok(())
217}
218
219pub struct GitHub {
220 github_api_url: String,
221 slug_name: String,
222 installation_access_token: String,
223}
224
225impl GitHub {
226 pub fn new(private_key: &str, app_id: i64, installation_id: i64) -> Result<Self, GithubError> {
227 Self::new_with_url(DEFAULT_GITHUB_API_URL, private_key, app_id, installation_id)
228 }
229
230 pub fn new_with_url(
231 github_api_url: &str,
232 private_key: &str,
233 app_id: i64,
234 installation_id: i64,
235 ) -> Result<Self, GithubError> {
236 info!("generating jwt");
237 let jwt = generate_jwt(private_key, app_id)?;
238 info!("getting app info");
239 let app_info = get_app_info(github_api_url, &jwt)?;
240 let access_token = create_access_token(github_api_url, &jwt, installation_id)?;
241
242 Ok(GitHub {
243 github_api_url: github_api_url.to_string(),
244 slug_name: format!("{}[bot]", app_info.slug),
245 installation_access_token: access_token.token,
246 })
247 }
248}
249
250impl GitHubApi for GitHub {
251 fn app_slug(&self) -> String {
252 self.slug_name.clone()
253 }
254 fn create_issue_comment(
255 &self,
256 owner: &str,
257 repo: &str,
258 issue_id: i64,
259 body: &str,
260 ) -> Result<(), GithubError> {
261 create_comment(
262 &self.github_api_url,
263 CommentArgs {
264 owner: owner.to_string(),
265 repo: repo.to_string(),
266 issue: issue_id,
267 body: body.to_string(),
268 },
269 &self.installation_access_token,
270 )
271 }
272 fn list_issue_comments(
273 &self,
274 owner: &str,
275 repo: &str,
276 issue_id: i64,
277 ) -> Result<Vec<Comment>, GithubError> {
278 list_comments(
279 &self.github_api_url,
280 &PullRequest {
281 owner: owner.to_string(),
282 repo: repo.to_string(),
283 issue: issue_id,
284 },
285 &self.installation_access_token,
286 )
287 }
288 fn update_issue_comment(
289 &self,
290 owner: &str,
291 repo: &str,
292 comment_id: i64,
293 body: &str,
294 ) -> Result<(), GithubError> {
295 update_comment(
296 &self.github_api_url,
297 owner,
298 repo,
299 comment_id,
300 body.to_string(),
301 &self.installation_access_token,
302 )
303 }
304}