squawk_github/
lib.rs

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
30/// https://developer.github.com/v3/apps/#create-an-installation-access-token-for-an-app
31fn 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
44/// https://developer.github.com/v3/issues/comments/#create-an-issue-comment
45fn 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
67/// Get the bot name for finding existing comments on a PR
68pub 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    /// Issued at
114    iat: u64,
115    /// Expiration time
116    exp: u64,
117    /// Issuer
118    iss: String,
119}
120
121/// Create an authentication token to make application requests.
122/// https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app
123/// This is different from authenticating as an installation
124fn 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
144/// https://developer.github.com/v3/issues/comments/#list-issue-comments
145fn list_comments(pr: &PullRequest, secret: &str) -> Result<Vec<Comment>, GithubError> {
146    // TODO(sbdchd): use the next links to get _all_ the comments
147    // see: https://developer.github.com/v3/guides/traversing-with-pagination/
148    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
162/// https://developer.github.com/v3/issues/comments/#update-an-issue-comment
163fn 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}