Skip to main content

repo_quest/
github.rs

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  // It's possible there may be identical PRs (or issues) because one was partially created and
51  // later closed in a rollback. We therefore look for the most recently created one.
52  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
83/// Checks that the user's SSH keys are configured such that `git clone git@github.com:...` can be run.
84pub fn check_ssh() -> Result<()> {
85  let output = command("ssh -T git@github.com", Path::new("/")).output()?;
86  match output.status.code() {
87    // `ssh` exits with status 1 for "success" here, and status 255 for failure
88    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  /// Returns true if the repo is registered w/ github
133  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    // Pull requests are considered issues, so filter them out
160    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  // There is some unknown delay between creating a repo from a template and its contents being added.
220  // We have to wait until that happens
221  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(&params))
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      // TODO: make this configurable? Right now we don't want privacy so we can see learner progress
314      // .private(true)
315      .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  /// Copies a reference issue onto the current repo, including comments and labels.
472  ///
473  /// Fails for any of the reasons that `CreateIssueBuilder::send` can fail.
474  #[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    // letself.issues.lock().as_mut().unwrap().iter().find(|i| i.number == issue.number).unwrap()
509
510    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    // Both 405 and 409 seem to happen after a PR is filed and before Github
567    // decides a PR is mergeable, so we try on a loop until those errors go away.
568    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}