1use super::common::{
3 CreatePullRequest, CreateRepository, ForkRepository, ForkedFromRepository,
4 ListPullRequestFilters, PullRequest, PullRequestState, PullRequestStateFilter, Repository,
5 RepositoryVisibility, User, VersionControl, VersionControlSettings,
6};
7use eyre::{eyre, ContextCompat, Result};
8use native_tls::TlsConnector;
9use serde::{de::DeserializeOwned, Deserialize, Serialize};
10use std::{fmt::Debug, sync::Arc};
11use time::OffsetDateTime;
12use tracing::{info, instrument, trace};
13use ureq::{Agent, AgentBuilder, Error};
14
15#[derive(Debug, Deserialize, Serialize)]
16pub struct GitHubUser {
17 pub id: u32,
18 pub login: String,
19}
20
21impl From<GitHubUser> for User {
22 fn from(user: GitHubUser) -> User {
23 let GitHubUser { id, login, .. } = user;
24 User {
25 id: id.to_string(),
26 username: login,
27 }
28 }
29}
30
31#[derive(Debug, Deserialize, Serialize)]
32struct GitHubRepository {
33 name: String,
34 full_name: String,
35 private: bool,
36 owner: GitHubUser,
37 html_url: String,
38 ssh_url: String,
39 clone_url: String,
40 description: Option<String>,
41 #[serde(with = "time::serde::iso8601")]
42 created_at: OffsetDateTime,
43 #[serde(with = "time::serde::iso8601")]
44 updated_at: OffsetDateTime,
45 stargazers_count: u32,
46 forks_count: u32,
47 archived: bool,
48 default_branch: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 parent: Option<Box<GitHubRepository>>,
51}
52
53#[derive(Debug, Deserialize, Serialize)]
54struct GitHubCreateRepository {
55 name: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 description: Option<String>,
58 private: bool,
59 auto_init: bool,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 gitignore_template: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 license_template: Option<String>,
64}
65
66#[derive(Debug, Deserialize, Serialize)]
67struct GitHubForkRepository {
68 #[serde(skip_serializing_if = "Option::is_none")]
69 name: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 organization: Option<String>,
72}
73
74impl From<GitHubRepository> for Repository {
75 fn from(repo: GitHubRepository) -> Repository {
76 let GitHubRepository {
77 name,
78 full_name,
79 private,
80 owner,
81 html_url,
82 ssh_url,
83 clone_url,
84 description,
85 created_at,
86 updated_at,
87 stargazers_count,
88 forks_count,
89 archived,
90 default_branch,
91 parent,
92 } = repo;
93 Repository {
94 name,
95 full_name,
96 owner: Some(owner.into()),
97 visibility: if private {
98 RepositoryVisibility::Private
99 } else {
100 RepositoryVisibility::Public
101 },
102 html_url,
103 ssh_url,
104 https_url: clone_url,
105 description: description.unwrap_or_default(),
106 created_at,
107 updated_at,
108 archived,
109 default_branch,
110 stars_count: stargazers_count,
111 forks_count,
112 forked_from: parent.map(|r| ForkedFromRepository::from(*r)),
113 }
114 }
115}
116
117impl From<GitHubRepository> for ForkedFromRepository {
118 fn from(repo: GitHubRepository) -> ForkedFromRepository {
119 let GitHubRepository {
120 name,
121 full_name,
122 html_url,
123 ..
124 } = repo;
125 ForkedFromRepository {
126 name,
127 full_name,
128 html_url,
129 }
130 }
131}
132
133#[derive(Debug, Deserialize, Serialize)]
134pub enum GitHubPullRequestState {
135 #[serde(rename = "open")]
136 Open,
137 #[serde(rename = "closed")]
138 Closed,
139}
140
141#[derive(Debug, Deserialize, Serialize)]
142pub struct GitHubPullRequestBranch {
143 #[serde(rename = "ref")]
144 pub branch: String,
145 pub sha: String,
146}
147
148#[derive(Debug, Deserialize, Serialize)]
149pub struct GitHubPullRequest {
150 pub id: u32,
151 pub number: u32,
152 pub state: GitHubPullRequestState,
153 pub locked: bool,
154 pub title: String,
155 pub body: Option<String>,
156 pub head: GitHubPullRequestBranch,
157 pub base: GitHubPullRequestBranch,
158 pub html_url: String,
159 #[serde(with = "time::serde::iso8601")]
160 pub created_at: OffsetDateTime,
161 #[serde(with = "time::serde::iso8601")]
162 pub updated_at: OffsetDateTime,
163 #[serde(with = "time::serde::iso8601::option")]
164 pub merged_at: Option<OffsetDateTime>,
165 pub user: GitHubUser,
166 pub merged_by: Option<GitHubUser>,
167 pub requested_reviewers: Option<Vec<GitHubUser>>,
168}
169
170impl From<GitHubPullRequest> for PullRequest {
171 fn from(pr: GitHubPullRequest) -> PullRequest {
172 let GitHubPullRequest {
173 number,
174 state,
175 title,
176 locked,
177 body,
178 head,
179 base,
180 html_url,
181 created_at,
182 updated_at,
183 merged_at,
184 user,
185 merged_by,
186 requested_reviewers,
187 ..
188 } = pr;
189 PullRequest {
190 id: number,
191 state: match (state, merged_at, locked) {
192 (_, _, true) => PullRequestState::Locked,
193 (GitHubPullRequestState::Open, _, _) => PullRequestState::Open,
194 (GitHubPullRequestState::Closed, Some(_), _) => PullRequestState::Merged,
195 (GitHubPullRequestState::Closed, None, _) => PullRequestState::Closed,
196 },
197 title,
198 description: body.unwrap_or_default(),
199 source: head.branch,
200 target: base.branch,
201 source_sha: head.sha,
202 target_sha: base.sha,
203 url: html_url,
204 created_at,
205 updated_at,
206 author: user.into(),
207 closed_by: merged_by.map(|c| c.into()),
208 reviewers: requested_reviewers.map(|rs| rs.into_iter().map(|r| r.into()).collect()),
209 delete_source_branch: false,
210 }
211 }
212}
213
214#[derive(Debug, Deserialize, Serialize)]
215pub struct GitHubCreatePullRequest {
216 pub title: String,
217 pub body: String,
218 pub head: String,
219 pub base: String,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub head_repo: Option<String>,
222}
223
224impl From<CreatePullRequest> for GitHubCreatePullRequest {
225 fn from(pr: CreatePullRequest) -> Self {
226 let CreatePullRequest {
227 title,
228 description,
229 source,
230 target: destination,
231 ..
232 } = pr;
233 Self {
234 title,
235 body: description,
236 head: source,
237 base: destination.unwrap_or("master".to_string()),
239 head_repo: None,
240 }
241 }
242}
243
244#[derive(Debug, Deserialize, Serialize)]
245pub enum GitHubCreatePullRequestReviewEvent {
246 #[serde(rename = "APPROVE")]
247 Approve,
248 #[serde(rename = "REQUEST_CHANGES")]
249 RequestChanges,
250 #[serde(rename = "COMMENT")]
251 Comment,
252}
253
254#[derive(Debug, Deserialize, Serialize)]
255pub struct GitHubCreatePullRequestReview {
256 event: GitHubCreatePullRequestReviewEvent,
257 #[serde(skip_serializing_if = "Option::is_none")]
258 body: Option<String>,
259}
260
261#[derive(Debug, Deserialize, Serialize)]
262pub struct GitHubCreatePullRequestReviewers {
263 reviewers: Vec<String>,
264}
265
266#[derive(Debug, Default, Deserialize, Serialize)]
267pub struct GitHubUpdatePullRequest {
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub title: Option<String>,
270 #[serde(skip_serializing_if = "Option::is_none")]
271 pub body: Option<String>,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 pub base: Option<String>,
274 #[serde(skip_serializing_if = "Option::is_none")]
275 pub state: Option<GitHubPullRequestState>,
276}
277
278#[derive(Debug, Deserialize, Serialize)]
279pub struct GitHubPullRequestMerged {
280 merged: bool,
281 message: String,
282}
283
284#[derive(Debug)]
285pub struct GitHub {
286 settings: VersionControlSettings,
287 client: Agent,
288 repo: String,
289 hostname: String,
290}
291
292impl GitHub {
293 #[instrument(skip_all)]
294 fn get_repository_url(&self, url: &str) -> String {
295 format!("/repos/{}{}", self.repo, url)
296 }
297 #[instrument(skip_all)]
298 fn call<T: DeserializeOwned, U: Serialize + Debug>(
299 &self,
300 method: &str,
301 url: &str,
302 body: Option<U>,
303 ) -> Result<T> {
304 let hostname = match self.hostname.as_str() {
306 "github.com" => "api.github.com".to_string(),
307 hostname => format!("{hostname}/api/v3"),
308 };
309 let url = format!("https://{}{}", hostname, url);
310
311 info!("Calling with {method} on {url}.");
312
313 let token = &self.settings.auth;
314
315 trace!("Authenticating with token '{token}'.");
316
317 let request = self
318 .client
319 .request(method, &url)
320 .set("User-Agent", "gr")
321 .set("Authorization", &format!("Bearer {}", token))
322 .set("Content-Type", "application/json");
323 let result = if let Some(body) = &body {
324 trace!("Sending body: {}.", serde_json::to_string(&body)?);
325 request.send_json(body)
326 } else {
327 request.call()
328 };
329
330 match result {
331 Ok(result) => {
332 let status = result.status();
333 let mut t = result.into_string()?;
334
335 info!(
336 "Received response with response code {} with body size {}.",
337 status,
338 t.len()
339 );
340 trace!("Response body: {t}.");
341
342 if t.is_empty() {
344 t = "null".to_string();
345 }
346
347 let t: T = serde_json::from_str(&t)?;
348 Ok(t)
349 }
350 Err(Error::Status(status, result)) => {
351 let t = result.into_string()?;
352
353 info!(
354 "Received response with response code {} with body size {}.",
355 status,
356 t.len()
357 );
358 Err(eyre!("Request failed (response: {}).", t))
359 }
360 Err(Error::Transport(_)) => Err(eyre!("Sending data failed.")),
361 }
362 }
363
364 #[instrument(skip_all)]
365 fn get_repository_data(&self) -> Result<GitHubRepository> {
366 self.call::<GitHubRepository, i32>("GET", &self.get_repository_url(""), None)
367 }
368}
369
370impl VersionControl for GitHub {
371 #[instrument(skip_all)]
372 fn init(hostname: String, repo: String, settings: VersionControlSettings) -> Self {
373 let client = AgentBuilder::new()
374 .tls_connector(Arc::new(TlsConnector::new().unwrap()))
375 .build();
376
377 GitHub {
378 settings,
379 client,
380 repo,
381 hostname,
382 }
383 }
384 #[instrument(skip_all)]
385 fn login_url(&self) -> String {
386 format!(
387 "https://{}/settings/tokens/new?description=gr&scopes=repo,project",
388 self.hostname
389 )
390 }
391
392 #[instrument(skip_all)]
393 fn validate_token(&self, token: &str) -> Result<()> {
394 if !token.starts_with("ghp_") {
395 Err(eyre!("Your GitHub token has to start with `ghp`."))
396 } else if token.len() != 40 {
397 Err(eyre!("Your GitHub token has to be 40 characters long."))
398 } else {
399 Ok(())
400 }
401 }
402
403 #[instrument(skip(self))]
404 fn create_pr(&self, mut pr: CreatePullRequest) -> Result<PullRequest> {
405 let reviewers = pr.reviewers.clone();
406 pr.target = pr.target.or(self.settings.default_branch.clone());
407 if pr.target.is_none() {
408 let GitHubRepository { default_branch, .. } = self.get_repository_data()?;
409 pr.target = Some(default_branch);
410 }
411
412 let mut url = self.get_repository_url("/pulls");
413 let mut github_pr = GitHubCreatePullRequest::from(pr);
414 if self.settings.fork {
415 let repo = self.get_repository()?;
416 if let Some(forked) = repo.forked_from {
417 url = format!("/repos/{}/pulls", forked.full_name);
418 github_pr.head_repo = Some(repo.full_name);
419 }
420 }
421
422 let new_pr: GitHubPullRequest = self.call("POST", &url, Some(github_pr))?;
423
424 let _: GitHubPullRequest = self.call(
425 "POST",
426 &format!("{}/{}/requested_reviewers", url, new_pr.number),
427 Some(GitHubCreatePullRequestReviewers { reviewers }),
428 )?;
429
430 Ok(new_pr.into())
431 }
432
433 #[instrument(skip(self))]
434 fn get_pr_by_id(&self, id: u32) -> Result<PullRequest> {
435 let pr: GitHubPullRequest = self.call(
436 "GET",
437 &self.get_repository_url(&format!("/pulls/{id}")),
438 None as Option<i32>,
439 )?;
440
441 Ok(pr.into())
442 }
443
444 #[instrument(skip(self))]
445 fn get_pr_by_branch(&self, branch: &str) -> Result<PullRequest> {
446 let (head, _) = self
448 .repo
449 .split_once('/')
450 .wrap_err(eyre!("Invalid repo format: {}.", self.repo))?;
451
452 let prs: Vec<GitHubPullRequest> = self.call(
453 "GET",
454 &self.get_repository_url(&format!("/pulls?state=all&head={head}:{branch}")),
455 None as Option<i32>,
456 )?;
457
458 match prs.into_iter().next() {
459 Some(pr) => Ok(pr.into()),
460 None => Err(eyre!("Pull request on branch {branch} not found.")),
461 }
462 }
463
464 #[instrument(skip(self))]
465 fn list_prs(&self, filters: ListPullRequestFilters) -> Result<Vec<PullRequest>> {
466 let state = match filters.state {
467 PullRequestStateFilter::Open => "open",
468 PullRequestStateFilter::Closed
469 | PullRequestStateFilter::Merged
470 | PullRequestStateFilter::Locked => "closed",
471 PullRequestStateFilter::All => "all",
472 };
473 let prs: Vec<GitHubPullRequest> = self.call(
474 "GET",
475 &self.get_repository_url(&format!("/pulls?state={state}")),
476 None as Option<i32>,
477 )?;
478
479 Ok(prs.into_iter().map(|pr| pr.into()).collect())
480 }
481
482 #[instrument(skip(self))]
483 fn approve_pr(&self, id: u32) -> Result<()> {
484 self.call(
485 "POST",
486 &self.get_repository_url(&format!("/pulls/{id}/reviews")),
487 Some(GitHubCreatePullRequestReview {
488 event: GitHubCreatePullRequestReviewEvent::Approve,
489 body: None,
490 }),
491 )?;
492
493 Ok(())
494 }
495
496 #[instrument(skip(self))]
497 fn close_pr(&self, id: u32) -> Result<PullRequest> {
498 let closing = GitHubUpdatePullRequest {
499 state: Some(GitHubPullRequestState::Closed),
500 ..GitHubUpdatePullRequest::default()
501 };
502 let pr: GitHubPullRequest = self.call(
503 "PATCH",
504 &self.get_repository_url(&format!("/pulls/{id}")),
505 Some(closing),
506 )?;
507
508 Ok(pr.into())
509 }
510
511 #[instrument(skip(self))]
512 fn merge_pr(&self, id: u32, _: bool) -> Result<PullRequest> {
513 let _: GitHubPullRequestMerged = self.call(
514 "PUT",
515 &self.get_repository_url(&format!("/pulls/{id}/merge")),
516 None as Option<i32>,
517 )?;
518
519 self.get_pr_by_id(id)
520 }
521
522 #[instrument(skip_all)]
523 fn get_repository(&self) -> Result<Repository> {
524 let repo = self.get_repository_data()?;
525
526 Ok(repo.into())
527 }
528
529 #[instrument(skip_all)]
530 fn create_repository(&self, repo: CreateRepository) -> Result<Repository> {
531 let CreateRepository {
532 name,
533 description,
534 visibility,
535 organization,
536 init,
537 default_branch: _,
538 gitignore,
539 license,
540 } = repo;
541 let create_repo: GitHubCreateRepository = GitHubCreateRepository {
542 name,
543 description,
544 private: visibility != RepositoryVisibility::Public,
545 auto_init: init,
546 gitignore_template: gitignore,
547 license_template: license,
548 };
549 let new_repo: GitHubRepository = if let Some(org) = organization {
550 self.call("POST", &format!("/orgs/{org}/repos"), Some(create_repo))
551 } else {
552 self.call("POST", "/user/repos", Some(create_repo))
553 }?;
554
555 Ok(new_repo.into())
556 }
557
558 #[instrument(skip_all)]
559 fn fork_repository(&self, repo: ForkRepository) -> Result<Repository> {
560 let ForkRepository { name, organization } = repo;
561
562 let new_repo: GitHubRepository = self.call(
563 "POST",
564 &self.get_repository_url("/forks"),
565 Some(GitHubForkRepository { name, organization }),
566 )?;
567
568 Ok(new_repo.into())
569 }
570
571 #[instrument(skip_all)]
572 fn delete_repository(&self) -> Result<()> {
573 self.call("DELETE", &self.get_repository_url(""), None as Option<i32>)?;
574
575 Ok(())
576 }
577}