1use eyre::{Context, Result, bail, ensure, eyre};
2use futures_util::future::try_join_all;
3use http::StatusCode;
4use itertools::Itertools;
5use octocrab::{
6 GitHubError, Octocrab,
7 issues::IssueHandler,
8 models::{
9 IssueState, Label,
10 issues::Issue,
11 pulls::{self, PullRequest},
12 },
13 params::pulls::State,
14 pulls::PullRequestHandler,
15 repos::RepoHandler,
16};
17use parking_lot::{MappedMutexGuard, Mutex, MutexGuard};
18use regex::Regex;
19use serde::{Deserialize, Serialize};
20use serde_json::json;
21use std::{env, fs, path::Path, sync::Arc};
22use tokio::try_join;
23use tracing::warn;
24
25use crate::{
26 command::command,
27 git::{Branch, GitRepo, Ref},
28 package::QuestPackage,
29 utils::{self, RetryError},
30};
31
32pub struct GithubRepo {
33 user: String,
34 name: String,
35 gh: Arc<Octocrab>,
36 prs: Mutex<Option<Vec<PullRequest>>>,
37 issues: Mutex<Option<Vec<Issue>>>,
38}
39
40#[derive(Debug)]
41pub enum PullSelector {
42 Branch(String),
43 Label(String),
44}
45
46pub fn find_pr<'a>(
47 branch: &Branch,
48 prs: impl IntoIterator<Item = &'a PullRequest> + 'a,
49) -> Option<usize> {
50 prs
53 .into_iter()
54 .enumerate()
55 .sorted_by_key(|(_, pr)| pr.created_at)
56 .rev()
57 .find(|(_, pr)| (pr.head.ref_field == branch.as_str()))
58 .map(|(idx, _)| idx)
59}
60
61pub fn find_issue<'a>(
62 label_name: &str,
63 issues: impl IntoIterator<Item = &'a Issue> + 'a,
64) -> Option<usize> {
65 issues
66 .into_iter()
67 .enumerate()
68 .sorted_by_key(|(_, issue)| issue.created_at)
69 .rev()
70 .find(|(_, issue)| issue.labels.iter().any(|label| label.name == label_name))
71 .map(|(idx, _)| idx)
72}
73
74pub async fn load_user() -> Result<String> {
75 let user = octocrab::instance()
76 .current()
77 .user()
78 .await
79 .context("Failed to query Github connector for current user")?;
80 Ok(user.login)
81}
82
83pub fn check_ssh() -> Result<()> {
85 let output = command("ssh -T git@github.com", Path::new("/")).output()?;
86 match output.status.code() {
87 Some(1) => Ok(()),
89 _ => {
90 let stderr = String::from_utf8(output.stderr)?;
91 let error = if stderr.contains("git@github.com: Permission denied (publickey).") {
92 eyre!(
93 "Your machine is not setup for a secure connection to Github. Please follow the instructions here: https://docs.github.com/en/authentication/troubleshooting-ssh/error-permission-denied-publickey"
94 )
95 } else {
96 eyre!("Failed to establish a secure connection to Github with error:\n{stderr}")
97 };
98 Err(error)
99 }
100 }
101}
102
103pub enum GitProtocol {
104 Ssh,
105 Https,
106}
107
108#[derive(PartialEq, Eq, Debug)]
109pub enum TestResult {
110 HasContent,
111 NoContent,
112 NotFound,
113}
114
115impl GithubRepo {
116 pub fn new(user: &str, name: &str) -> Self {
117 GithubRepo {
118 user: user.to_string(),
119 name: name.to_string(),
120 gh: octocrab::instance(),
121 prs: Mutex::new(None),
122 issues: Mutex::new(None),
123 }
124 }
125
126 pub async fn load(user: &str, name: &str) -> Result<Self> {
127 let repo = GithubRepo::new(user, name);
128 ensure!(repo.fetch().await?, "Not found");
129 Ok(repo)
130 }
131
132 pub async fn fetch(&self) -> Result<bool> {
134 let (pr_handler, issue_handler) = (self.pr_handler(), self.issue_handler());
135 let res = try_join!(
136 pr_handler.list().state(octocrab::params::State::All).send(),
137 issue_handler
138 .list()
139 .state(octocrab::params::State::All)
140 .send()
141 );
142 let (mut pr_page, mut issue_page) = match res {
143 Ok(pages) => pages,
144 Err(octocrab::Error::GitHub { source, .. })
145 if matches!(
146 &*source,
147 GitHubError {
148 status_code: StatusCode::NOT_FOUND,
149 ..
150 },
151 ) =>
152 {
153 return Ok(false);
154 }
155 Err(e) => return Err(e.into()),
156 };
157 let (prs, mut issues) = (pr_page.take_items(), issue_page.take_items());
158
159 issues.retain(|issue| issue.pull_request.is_none());
161
162 *self.prs.lock() = Some(prs);
163 *self.issues.lock() = Some(issues);
164
165 Ok(true)
166 }
167
168 pub fn remote(&self, protocol: GitProtocol) -> String {
169 match protocol {
170 GitProtocol::Https => format!("https://github.com/{}/{}", self.user, self.name),
171 GitProtocol::Ssh => format!("git@github.com:{}/{}.git", self.user, self.name),
172 }
173 }
174
175 async fn test_repo(&self) -> Result<TestResult> {
176 let result = self.repo_handler().list_commits().send().await;
177 match result {
178 Err(octocrab::Error::GitHub { source, .. })
179 if matches!(
180 *source,
181 GitHubError {
182 status_code: StatusCode::NO_CONTENT | StatusCode::CONFLICT,
183 ..
184 }
185 ) =>
186 {
187 Ok(TestResult::NoContent)
188 }
189
190 Err(octocrab::Error::GitHub { source, .. })
191 if matches!(
192 *source,
193 GitHubError {
194 status_code: StatusCode::NOT_FOUND,
195 ..
196 }
197 ) =>
198 {
199 Ok(TestResult::NotFound)
200 }
201
202 Ok(_) => Ok(TestResult::HasContent),
203
204 Err(e) => {
205 if let octocrab::Error::GitHub { source, .. } = &e {
206 tracing::debug!("Error: {:?}", source.status_code);
207 }
208
209 Err(e.into())
210 }
211 }
212 }
213
214 pub fn clone(&self, path: &Path) -> Result<GitRepo> {
215 let remote = self.remote(GitProtocol::Ssh);
216 GitRepo::clone(&path.join(&self.name), &remote)
217 }
218
219 async fn wait_for_content(&self, expected: TestResult) -> Result<()> {
222 utils::retry_with_timeout(async || match self.test_repo().await {
223 Ok(actual) => {
224 if actual == expected {
225 Ok(())
226 } else {
227 Err(RetryError::Wait)
228 }
229 }
230 Err(e) => Err(RetryError::Err(e)),
231 })
232 .await
233 }
234
235 async fn create_labels(&self, labels: &[Label]) -> Result<()> {
236 let issues = self.issue_handler();
237 let futs = labels.iter().filter(|label| !label.default).map(|label| {
238 issues.create_label(
239 &label.name,
240 &label.color,
241 label.description.as_deref().unwrap_or(""),
242 )
243 });
244 try_join_all(futs)
245 .await
246 .context("Failed to create labels")?;
247 Ok(())
248 }
249
250 async fn unsubscribe(&self) -> Result<()> {
251 let route = format!("/repos/{}/{}/subscription", self.user, self.name);
252 self
253 .gh
254 .put::<serde_json::Value, _, _>(
255 route,
256 Some(&json!({
257 "subscribed": false,
258 "ignored": true
259 })),
260 )
261 .await
262 .context("Failed to unsubscribe from repo")?;
263 Ok(())
264 }
265
266 async fn configure(&self, labels: &[Label]) -> Result<()> {
267 try_join!(self.unsubscribe(), self.create_labels(labels))?;
268 Ok(())
269 }
270
271 pub async fn instantiate_from_package(package: &QuestPackage, name: &str) -> Result<GithubRepo> {
272 let user = load_user().await.context("Failed to load user")?;
273 let params = json!({
274 "name": name,
275 "private": true,
276 });
277 octocrab::instance()
278 .post::<_, serde_json::Value>("/user/repos", Some(¶ms))
279 .await
280 .context("Failed to create repo")?;
281
282 let repo = GithubRepo::new(&user, name);
283
284 repo
285 .wait_for_content(TestResult::NoContent)
286 .await
287 .context("Github repo was not properly initialized")?;
288
289 repo
290 .configure(&package.labels)
291 .await
292 .context("Failed to configure repo")?;
293
294 Ok(repo)
295 }
296
297 async fn get_repo_labels(&self) -> Result<Vec<Label>> {
298 let mut page = self
299 .issue_handler()
300 .list_labels_for_repo()
301 .send()
302 .await
303 .context("Failed to fetch labels from repo")?;
304 Ok(page.take_items())
305 }
306
307 pub async fn instantiate_from_repo(base: &GithubRepo, name: &str) -> Result<GithubRepo> {
308 let user = load_user().await?;
309 base
310 .repo_handler()
311 .generate(name)
312 .owner(&user)
313 .send()
316 .await
317 .with_context(|| format!("Failed to clone template repo {}/{}", base.user, base.name))?;
318
319 let repo = GithubRepo::new(&user, name);
320
321 repo
322 .wait_for_content(TestResult::HasContent)
323 .await
324 .context("Github repo was not properly initialized")?;
325
326 let labels = base.get_repo_labels().await?;
327 repo.configure(&labels).await?;
328
329 Ok(repo)
330 }
331
332 pub fn repo_handler(&self) -> RepoHandler {
333 self.gh.repos(&self.user, &self.name)
334 }
335
336 pub fn pr_handler(&self) -> PullRequestHandler {
337 self.gh.pulls(&self.user, &self.name)
338 }
339
340 pub fn prs(&self) -> MappedMutexGuard<'_, Vec<PullRequest>> {
341 MutexGuard::map(self.prs.lock(), |opt| {
342 opt.as_mut().expect("PRs not populated")
343 })
344 }
345
346 pub fn pr(&self, branch: &Branch) -> Option<MappedMutexGuard<'_, PullRequest>> {
347 let prs = self.prs();
348 let idx = find_pr(branch, prs.iter())?;
349 Some(MappedMutexGuard::map(prs, |prs| &mut prs[idx]))
350 }
351
352 pub async fn pr_comments(&self, pr: &PullRequest) -> Result<Vec<pulls::Comment>> {
353 let comment_pages = self
354 .pr_handler()
355 .list_comments(Some(pr.number))
356 .send()
357 .await
358 .with_context(|| format!("Failed to fetch comments for PR {}", pr.number))?;
359 let comments = comment_pages.into_iter().collect::<Vec<_>>();
360 Ok(comments)
361 }
362
363 pub fn issue_handler(&self) -> IssueHandler {
364 self.gh.issues(&self.user, &self.name)
365 }
366
367 pub fn issues(&self) -> MappedMutexGuard<'_, Vec<Issue>> {
368 MutexGuard::map(self.issues.lock(), |opt| {
369 opt.as_mut().expect("Issues not populated")
370 })
371 }
372
373 pub fn issue(&self, label_name: &str) -> Option<MappedMutexGuard<'_, Issue>> {
374 let issues = self.issues();
375 let idx = find_issue(label_name, issues.iter())?;
376 Some(MappedMutexGuard::map(issues, |issues| &mut issues[idx]))
377 }
378
379 #[tracing::instrument(skip(self, body, comments))]
380 pub async fn file_pr(
381 &self,
382 title: &str,
383 body: &str,
384 labels: &[String],
385 head_ref: &Branch,
386 head_commit: &Ref,
387 comments: &[pulls::Comment],
388 ) -> Result<PullRequest> {
389 let pulls = self.pr_handler();
390 let request = pulls
391 .create(title, head_ref.as_str(), Branch::main().as_str())
392 .body(body);
393 let mut pr = request.send().await.context("Failed to create PR")?;
394
395 let add_labels = async {
396 if !labels.is_empty() {
397 let labels = self
398 .issue_handler()
399 .add_labels(pr.number, labels)
400 .await
401 .context("Failed to add labels to PR")?;
402 pr.labels = Some(labels);
403 }
404 Ok::<_, eyre::Error>(())
405 };
406
407 let add_comments = try_join_all(
408 comments
409 .iter()
410 .map(|comment| self.copy_pr_comment(pr.number, comment, head_commit)),
411 );
412
413 try_join!(add_labels, add_comments)?;
414
415 self.prs.lock().as_mut().unwrap().push(pr.clone());
416
417 Ok(pr)
418 }
419
420 async fn copy_pr_comment(&self, pr: u64, comment: &pulls::Comment, commit: &Ref) -> Result<()> {
421 let route = format!("/repos/{}/{}/pulls/{pr}/comments", self.user, self.name);
422 let comment_json = json!({
423 "path": comment.path,
424 "commit_id": commit.as_str(),
425 "body": comment.body,
426 "line": comment.line
427 });
428 let _response = self
429 .gh
430 .post::<_, serde_json::Value>(route, Some(&comment_json))
431 .await
432 .with_context(|| format!("Failed to copy PR comment: {comment_json:#?}"))?;
433 Ok(())
434 }
435
436 fn update_md_links(&self, body: &str) -> String {
437 let re = Regex::new(r"\{\{ (\S+) (\S+) \}\}").unwrap();
438 let mut new_body = body.to_string();
439 let substitutions = re.captures_iter(body).filter_map(|cap| {
440 let full_match = cap.get(0).unwrap();
441 let label = &cap[1];
442 let kind = &cap[2];
443 let number = match kind {
444 "pr" => {
445 let Some(pr) = self.pr(&Branch::new(label)) else {
446 warn!("No PR for branch {label}");
447 return None;
448 };
449 pr.number
450 }
451 "issue" => {
452 let Some(issue) = self.issue(label) else {
453 warn!("No issue with label {label}");
454 return None;
455 };
456 issue.number
457 }
458 _ => panic!(
459 "Unexpected RepoQuest Markdown link with kind `{kind}`: {}",
460 full_match.as_str()
461 ),
462 };
463
464 Some((full_match.range(), format!("#{number}")))
465 });
466 utils::replace_many_ranges(&mut new_body, substitutions);
467
468 new_body
469 }
470
471 #[tracing::instrument(skip_all, fields(src_issue = src_issue.title))]
475 pub async fn copy_issue(&self, src_issue: &Issue) -> Result<Issue> {
476 let issue_handler = self.issue_handler();
477 let body = src_issue.body.as_ref().unwrap();
478
479 let label_names = src_issue
480 .labels
481 .iter()
482 .map(|label| label.name.clone())
483 .collect::<Vec<_>>();
484 let req = issue_handler
485 .create(&src_issue.title)
486 .body(body)
487 .labels(label_names);
488
489 let new_issue = req
490 .send()
491 .await
492 .with_context(|| format!("Failed to create issue: {}", src_issue.title))?;
493
494 self.issues.lock().as_mut().unwrap().push(new_issue.clone());
495
496 Ok(new_issue)
497 }
498
499 pub async fn update_issue_links(&self, issue: &Issue) -> Result<()> {
500 let issue_handler = self.issue_handler();
501 let new_body = self.update_md_links(issue.body.as_ref().unwrap());
502 let update_req = issue_handler.update(issue.number).body(&new_body);
503 update_req
504 .send()
505 .await
506 .with_context(|| format!("Failed to update issue: {}", issue.number))?;
507
508 Ok(())
511 }
512
513 pub async fn update_pr_links(&self, pr: &PullRequest) -> Result<()> {
514 let pr_handler = self.pr_handler();
515 let new_body = self.update_md_links(pr.body.as_ref().unwrap());
516 let update_req = pr_handler.update(pr.number).body(&new_body);
517 update_req
518 .send()
519 .await
520 .with_context(|| format!("Failed to update PR: {}", pr.number))?;
521 Ok(())
522 }
523
524 #[tracing::instrument(skip_all, fields(number = issue.number, title = issue.title))]
525 pub async fn close_issue(&self, issue: &Issue) -> Result<()> {
526 self
527 .issue_handler()
528 .update(issue.number)
529 .state(IssueState::Closed)
530 .send()
531 .await
532 .with_context(|| format!("Failed to close issue: {}", issue.number))?;
533 Ok(())
534 }
535
536 pub async fn close_pr(&self, pr: &PullRequest) -> Result<()> {
537 self
538 .pr_handler()
539 .update(pr.number)
540 .state(State::Closed)
541 .send()
542 .await
543 .with_context(|| format!("Failed to close PR: {}", pr.number))?;
544 Ok(())
545 }
546
547 pub async fn wait_for_issue_closed(&self, issue: &Issue) -> Result<()> {
548 utils::retry_with_timeout(async || {
549 let issue = self
550 .issue_handler()
551 .get(issue.number)
552 .await
553 .map_err(|e| RetryError::Err(e.into()))?;
554 match issue.state {
555 IssueState::Closed => Ok(()),
556 _ => Err(RetryError::Wait),
557 }
558 })
559 .await
560 }
561
562 #[tracing::instrument(skip_all, fields(number = pr.number, title = pr.title))]
563 pub async fn merge_pr(&self, pr: &PullRequest) -> Result<()> {
564 let pr_handler = self.pr_handler();
565
566 utils::retry_with_timeout(async || {
569 let req = pr_handler.merge(pr.number);
570 let resp = req.send().await;
571 resp.map_err(|e| match e {
572 octocrab::Error::GitHub { source, .. }
573 if matches!(
574 &*source,
575 GitHubError {
576 status_code: StatusCode::CONFLICT | StatusCode::METHOD_NOT_ALLOWED,
577 ..
578 }
579 ) =>
580 {
581 RetryError::Wait
582 }
583 e => {
584 eprintln!("This error seems to be flaky, so printing full error in debug mode for diagnostics:\n{e:#?}");
585 RetryError::Err(e.into())
586 }
587 })
588 })
589 .await
590 .with_context(|| format!("Failed to merge PR: {}", pr.number))?;
591
592 Ok(())
593 }
594
595 #[tracing::instrument(skip(self))]
596 pub async fn delete(&self) -> Result<()> {
597 self
598 .repo_handler()
599 .delete()
600 .await
601 .context("Failed to delete repo")?;
602 Ok(())
603 }
604
605 pub async fn set_var(&self, key: &str, val: &str) -> Result<()> {
606 let route = format!("/repos/{}/{}/actions/variables", self.user, self.name);
607 let var_json = json!({
608 "name": key,
609 "value": val
610 });
611 let _response = self
612 .gh
613 .post::<_, serde_json::Value>(route, Some(&var_json))
614 .await
615 .with_context(|| format!("Failed to set variable: {key}={val}"))?;
616 Ok(())
617 }
618}
619
620#[derive(Serialize, Deserialize, Debug, Clone)]
621#[serde(tag = "type", content = "value")]
622pub enum GithubToken {
623 Found(String),
624 NotFound,
625 Error(String),
626}
627
628macro_rules! token_try {
629 ($e:expr) => {{
630 match $e {
631 Ok(x) => x,
632 Err(e) => return GithubToken::Error(format!("{e:?}")),
633 }
634 }};
635}
636
637fn read_github_token_from_fs() -> GithubToken {
638 let home = match env::home_dir() {
639 Some(dir) => dir,
640 None => return GithubToken::NotFound,
641 };
642 let path = home.join(".rqst-token");
643 if path.exists() {
644 let token = token_try!(fs::read_to_string(path));
645 GithubToken::Found(token.trim_end().to_string())
646 } else {
647 GithubToken::NotFound
648 }
649}
650
651fn generate_github_token_from_cli() -> GithubToken {
652 let res = command("gh auth token", &env::current_dir().unwrap()).output();
653 match res {
654 Ok(token_output) if token_output.status.success() => {
655 let token = token_try!(String::from_utf8(token_output.stdout));
656 let token_clean = token.trim_end().to_string();
657 GithubToken::Found(token_clean)
658 }
659 _ => GithubToken::NotFound,
660 }
661}
662
663fn get_github_token() -> Result<String> {
664 let mut result = read_github_token_from_fs();
665 if matches!(result, GithubToken::NotFound) {
666 result = generate_github_token_from_cli();
667 }
668 match result {
669 GithubToken::Found(token) => Ok(token),
670 GithubToken::Error(err) => bail!("Failed with get Github token with error: {err}"),
671 GithubToken::NotFound => bail!("Could not find Github token"),
672 }
673}
674
675pub fn init_octocrab() -> Result<()> {
676 let token = get_github_token()?;
677 let crab_inst = Octocrab::builder()
678 .personal_token(token.to_string())
679 .build()
680 .context("Failed to build Github connector")?;
681 octocrab::initialise(crab_inst);
682 Ok(())
683}