squawk_github/
app.rs

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.28.1";
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}
36/// https://developer.github.com/v3/apps/#create-an-installation-access-token-for-an-app
37fn 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
54/// https://developer.github.com/v3/issues/comments/#create-an-issue-comment
55pub(crate) fn create_comment(
56    github_api_url: &str,
57    comment: CommentArgs,
58    secret: &str,
59) -> Result<(), GithubError> {
60    // Check comment size before attempting to send
61    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
89/// Get the bot name for finding existing comments on a PR
90pub 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    /// Issued at
131    iat: u64,
132    /// Expiration time
133    exp: u64,
134    /// Issuer
135    iss: String,
136}
137
138/// Create an authentication token to make application requests.
139/// https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app
140/// This is different from authenticating as an installation
141fn 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
167/// https://developer.github.com/v3/issues/comments/#list-issue-comments
168pub(crate) fn list_comments(
169    github_api_url: &str,
170    pr: &PullRequest,
171    secret: &str,
172) -> Result<Vec<Comment>, GithubError> {
173    // TODO(sbdchd): use the next links to get _all_ the comments
174    // see: https://developer.github.com/v3/guides/traversing-with-pagination/
175    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
190/// https://developer.github.com/v3/issues/comments/#update-an-issue-comment
191pub(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    // Check comment size before attempting to send
200    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}