Skip to main content

rq_core/
github.rs

1use eyre::{Context, Result, bail, ensure};
2use futures_util::future::try_join_all;
3use http::StatusCode;
4use octocrab::{
5  GitHubError, Octocrab,
6  issues::IssueHandler,
7  models::{
8    IssueState, Label,
9    issues::Issue,
10    pulls::{self, Comment, PullRequest},
11    repos::Branch,
12  },
13  pulls::PullRequestHandler,
14  repos::RepoHandler,
15};
16use parking_lot::{MappedMutexGuard, Mutex, MutexGuard};
17use regex::Regex;
18use serde::{Deserialize, Serialize};
19use serde_json::json;
20use specta::Type;
21use std::{env, fs, path::Path, sync::Arc, time::Duration};
22use tokio::{time::timeout, try_join};
23use tracing::warn;
24
25use crate::{
26  command::command,
27  git::{GitRepo, MergeType},
28  package::QuestPackage,
29  utils,
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  selector: &PullSelector,
48  prs: impl IntoIterator<Item = &'a PullRequest> + 'a,
49) -> Option<usize> {
50  prs.into_iter().position(|pr| match selector {
51    PullSelector::Branch(branch) => &pr.head.ref_field == branch,
52    PullSelector::Label(label) => pr
53      .labels
54      .as_ref()
55      .map(|labels| labels.iter().any(|l| &l.name == label))
56      .unwrap_or(false),
57  })
58}
59
60pub fn find_issue<'a>(
61  label_name: &str,
62  issues: impl IntoIterator<Item = &'a Issue> + 'a,
63) -> Option<usize> {
64  issues
65    .into_iter()
66    .position(|issue| issue.labels.iter().any(|label| label.name == label_name))
67}
68
69const RESET_LABEL: &str = "reset";
70
71pub async fn load_user() -> Result<String> {
72  let user = octocrab::instance()
73    .current()
74    .user()
75    .await
76    .context("Failed to query Github connector for current user")?;
77  Ok(user.login)
78}
79
80/// Checks that the user's SSH keys are configured such that `git clone git@github.com:...` can be run.
81pub fn check_ssh() -> Result<()> {
82  let output = command("ssh -T git@github.com", Path::new("/")).output()?;
83  match output.status.code() {
84    // `ssh` exits with status 1 for "success" here, and status 255 for failure
85    Some(1) => Ok(()),
86    _ => {
87      let stderr = String::from_utf8(output.stderr)?;
88      if stderr.contains("git@github.com: Permission denied (publickey).") {
89        bail!(
90          "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"
91        );
92      } else {
93        bail!("Failed to establish a secure connection to Github with error:\n{stderr}")
94      }
95    }
96  }
97}
98
99pub enum GitProtocol {
100  Ssh,
101  Https,
102}
103
104#[derive(PartialEq, Eq, Debug)]
105pub enum TestRepoResult {
106  HasContent,
107  NoContent,
108  NotFound,
109}
110
111impl GithubRepo {
112  pub fn new(user: &str, name: &str) -> Self {
113    GithubRepo {
114      user: user.to_string(),
115      name: name.to_string(),
116      gh: octocrab::instance(),
117      prs: Mutex::new(None),
118      issues: Mutex::new(None),
119    }
120  }
121
122  pub async fn load(user: &str, name: &str) -> Result<Self> {
123    let repo = GithubRepo::new(user, name);
124    ensure!(repo.fetch().await?, "Not found");
125    Ok(repo)
126  }
127
128  /// Returns true if repo
129  pub async fn fetch(&self) -> Result<bool> {
130    let (pr_handler, issue_handler) = (self.pr_handler(), self.issue_handler());
131    let res = try_join!(
132      pr_handler.list().state(octocrab::params::State::All).send(),
133      issue_handler
134        .list()
135        .state(octocrab::params::State::All)
136        .send()
137    );
138    let (mut pr_page, mut issue_page) = match res {
139      Ok(pages) => pages,
140      Err(octocrab::Error::GitHub { source, .. })
141        if matches!(
142          &*source,
143          GitHubError {
144            status_code: StatusCode::NOT_FOUND,
145            ..
146          },
147        ) =>
148      {
149        return Ok(false);
150      }
151      Err(e) => return Err(e.into()),
152    };
153    let (prs, mut issues) = (pr_page.take_items(), issue_page.take_items());
154
155    // Pull requests are considered issues, so filter them out
156    issues.retain(|issue| issue.pull_request.is_none());
157
158    *self.prs.lock() = Some(prs);
159    *self.issues.lock() = Some(issues);
160
161    Ok(true)
162  }
163
164  pub fn remote(&self, protocol: GitProtocol) -> String {
165    match protocol {
166      GitProtocol::Https => format!("https://github.com/{}/{}", self.user, self.name),
167      GitProtocol::Ssh => format!("git@github.com:{}/{}.git", self.user, self.name),
168    }
169  }
170
171  pub async fn test_repo(&self) -> Result<TestRepoResult> {
172    let result = self.repo_handler().list_commits().send().await;
173    match result {
174      Err(octocrab::Error::GitHub { source, .. })
175        if matches!(
176          &*source,
177          GitHubError {
178            status_code: StatusCode::NO_CONTENT | StatusCode::CONFLICT,
179            ..
180          }
181        ) =>
182      {
183        Ok(TestRepoResult::NoContent)
184      }
185      Err(octocrab::Error::GitHub { source, .. })
186        if matches!(
187          &*source,
188          GitHubError {
189            status_code: StatusCode::NOT_FOUND,
190            ..
191          }
192        ) =>
193      {
194        Ok(TestRepoResult::NotFound)
195      }
196      Ok(_) => Ok(TestRepoResult::HasContent),
197      Err(e) => {
198        if let octocrab::Error::GitHub { source, .. } = &e {
199          tracing::debug!("Error: {:?}", source.status_code);
200        }
201
202        Err(e.into())
203      }
204    }
205  }
206
207  pub fn clone(&self, path: &Path) -> Result<GitRepo> {
208    let remote = self.remote(GitProtocol::Ssh);
209    GitRepo::clone(&path.join(&self.name), &remote)
210  }
211
212  // There is some unknown delay between creating a repo from a template and its contents being added.
213  // We have to wait until that happens
214  async fn wait_for_content(&self, expected: TestRepoResult) -> Result<()> {
215    const RETRY_INTERVAL: u64 = 500;
216    const RETRY_TIMEOUT: u64 = 5000;
217
218    let strategy = tokio_retry::strategy::FixedInterval::from_millis(RETRY_INTERVAL);
219    let has_content = tokio_retry::Retry::spawn(strategy, || async {
220      match self.test_repo().await {
221        Ok(actual) if expected == actual => Ok(()),
222        result => {
223          tracing::debug!("wait status: {result:?}");
224          Err(result)
225        }
226      }
227    });
228    let _ = timeout(Duration::from_millis(RETRY_TIMEOUT), has_content)
229      .await
230      .context("Repo is still empty after timeout")?;
231
232    Ok(())
233  }
234
235  async fn create_labels(&self, labels: &[Label]) -> Result<()> {
236    let issues = self.issue_handler();
237    try_join_all(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    .await
245    .context("Failed to create labels")?;
246    Ok(())
247  }
248
249  async fn unsubscribe(&self) -> Result<()> {
250    let route = format!("/repos/{}/{}/subscription", self.user, self.name);
251    self
252      .gh
253      .put::<serde_json::Value, _, _>(
254        route,
255        Some(&json!({
256            "subscribed": false,
257            "ignored": true
258        })),
259      )
260      .await
261      .context("Failed to unsubscribe from repo")?;
262    Ok(())
263  }
264
265  pub async fn instantiate_from_package(package: &QuestPackage) -> Result<GithubRepo> {
266    let user = load_user().await.context("Failed to load user")?;
267    let params = json!({
268        "name": &package.config.repo,
269        "private": true,
270    });
271    octocrab::instance()
272      .post::<_, serde_json::Value>("/user/repos", Some(&params))
273      .await
274      .context("Failed to create repo")?;
275    let repo = GithubRepo::new(&user, &package.config.repo);
276    repo
277      .wait_for_content(TestRepoResult::NoContent)
278      .await
279      .context("Github repo was not properly initialized")?;
280    repo
281      .unsubscribe()
282      .await
283      .context("Failed to unsubscribe from repo")?;
284    repo
285      .create_labels(&package.labels)
286      .await
287      .context("Failed to transfer package labels to repo")?;
288    Ok(repo)
289  }
290
291  pub async fn instantiate_from_repo(base: &GithubRepo) -> Result<GithubRepo> {
292    let user = load_user().await?;
293    let name = &base.name;
294    base
295      .repo_handler()
296      .generate(name)
297      .owner(&user)
298      // TODO: make this configurable? Right now we don't want privacy so we can see learner progress
299      // .private(true)
300      .send()
301      .await
302      .with_context(|| format!("Failed to clone template repo {}/{}", base.user, base.name))?;
303
304    let repo = GithubRepo::new(&user, name);
305    repo
306      .wait_for_content(TestRepoResult::HasContent)
307      .await
308      .context("Github repo was not properly initialized")?;
309
310    // Unsubscribe from repo notifications to avoid annoying emails.
311    repo
312      .unsubscribe()
313      .await
314      .context("Failed to unsubscribe from repo")?;
315
316    // Copy all issue labels.
317    let mut page = base
318      .issue_handler()
319      .list_labels_for_repo()
320      .send()
321      .await
322      .context("Failed to fetch labels from upstream repo")?;
323    let labels = page.take_items();
324    repo
325      .create_labels(&labels)
326      .await
327      .context("Failed to transfer upstream labels to repo")?;
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 async fn branches(&self) -> Result<Vec<Branch>> {
337    let pages = self
338      .repo_handler()
339      .list_branches()
340      .send()
341      .await
342      .context("Failed to fetch branches")?;
343    let branches = pages.into_iter().collect::<Vec<_>>();
344    Ok(branches)
345  }
346
347  pub fn pr_handler(&self) -> PullRequestHandler {
348    self.gh.pulls(&self.user, &self.name)
349  }
350
351  pub fn prs(&self) -> MappedMutexGuard<'_, Vec<PullRequest>> {
352    MutexGuard::map(self.prs.lock(), |opt| {
353      opt.as_mut().expect("PRs not populated")
354    })
355  }
356
357  pub fn pr(&self, selector: &PullSelector) -> Option<MappedMutexGuard<'_, PullRequest>> {
358    let prs = self.prs();
359    let idx = find_pr(selector, prs.iter())?;
360    Some(MappedMutexGuard::map(prs, |prs| &mut prs[idx]))
361  }
362
363  pub async fn pr_comments(&self, pr: &PullRequest) -> Result<Vec<pulls::Comment>> {
364    let comment_pages = self
365      .pr_handler()
366      .list_comments(Some(pr.number))
367      .send()
368      .await
369      .with_context(|| format!("Failed to fetch comments for PR {}", pr.number))?;
370    let comments = comment_pages.into_iter().collect::<Vec<_>>();
371    Ok(comments)
372  }
373
374  pub fn issue_handler(&self) -> IssueHandler {
375    self.gh.issues(&self.user, &self.name)
376  }
377
378  pub fn issues(&self) -> MappedMutexGuard<'_, Vec<Issue>> {
379    MutexGuard::map(self.issues.lock(), |opt| {
380      opt.as_mut().expect("Issues not populated")
381    })
382  }
383
384  pub fn issue(&self, label_name: &str) -> Option<MappedMutexGuard<'_, Issue>> {
385    let issues = self.issues();
386    let idx = find_issue(label_name, issues.iter())?;
387    Some(MappedMutexGuard::map(issues, |issues| &mut issues[idx]))
388  }
389
390  pub async fn copy_pr(
391    &self,
392    pr: &PullRequest,
393    comments: &[Comment],
394    head: &str,
395    merge_type: MergeType,
396  ) -> Result<PullRequest> {
397    let pulls = self.pr_handler();
398    let mut body = pr
399      .body
400      .as_ref()
401      .expect("Author error: PR missing body")
402      .clone();
403
404    let is_reset = match merge_type {
405      MergeType::SolutionReset => {
406        body.push_str(r#"
407
408Note: due to a merge conflict, this PR is a hard reset to the reference solution, and may have overwritten your previous changes."#);
409        true
410      }
411
412      MergeType::StarterReset => {
413        body.push_str(r#"
414
415Note: due to a merge conflict, this PR is a hard reset to the starter code, and may have overwritten your previous changes."#);
416        true
417      }
418
419      MergeType::Success => false,
420    };
421
422    let request = pulls
423      .create(
424        pr.title.as_ref().expect("Author error: PR missing title"),
425        &pr.head.ref_field,
426        "main", // don't copy base
427      )
428      .body(body);
429    let self_pr = request.send().await.context("Failed to create new PR")?;
430
431    // TODO: lots of parallelism below we should exploit
432
433    let mut labels = match &pr.labels {
434      Some(labels) => labels
435        .iter()
436        .map(|label| label.name.clone())
437        .collect::<Vec<_>>(),
438      None => Vec::new(),
439    };
440    if is_reset {
441      labels.push(RESET_LABEL.into());
442    }
443    self
444      .issue_handler()
445      .add_labels(self_pr.number, &labels)
446      .await
447      .context("Failed to add labels to PR")?;
448
449    for comment in comments {
450      self
451        .copy_pr_comment(self_pr.number, comment, head)
452        .await
453        .context("Failed to add comment to PR")?;
454    }
455
456    Ok(self_pr)
457  }
458
459  pub async fn copy_pr_comment(
460    &self,
461    pr: u64,
462    comment: &pulls::Comment,
463    commit: &str,
464  ) -> Result<()> {
465    let route = format!("/repos/{}/{}/pulls/{pr}/comments", self.user, self.name);
466    let comment_json = json!({
467      "path": comment.path,
468      "commit_id": commit,
469      "body": comment.body,
470      "line": comment.line
471    });
472    let _response = self
473      .gh
474      .post::<_, serde_json::Value>(route, Some(&comment_json))
475      .await
476      .with_context(|| format!("Failed to copy PR comment: {comment_json:#?}"))?;
477    Ok(())
478  }
479
480  fn process_issue_body(&self, body: &str) -> String {
481    let re = Regex::new(r"\{\{ (\S+) (\S+) \}\}").unwrap();
482    let mut new_body = body.to_string();
483    let substitutions = re.captures_iter(body).filter_map(|cap| {
484      let full_match = cap.get(0).unwrap();
485      let label = &cap[1];
486      let kind = &cap[2];
487      let number = match kind {
488        "pr" => {
489          let Some(pr) = self.pr(&PullSelector::Label(label.to_string())) else {
490            warn!("No PR with label {label}");
491            return None;
492          };
493          pr.number
494        }
495        "issue" => {
496          let Some(issue) = self.issue(label) else {
497            warn!("No issue with label {label}");
498            return None;
499          };
500          issue.number
501        }
502        _ => unimplemented!(),
503      };
504
505      Some((full_match.range(), format!("#{number}")))
506    });
507    utils::replace_many_ranges(&mut new_body, substitutions);
508
509    new_body
510  }
511
512  pub async fn copy_issue(&self, issue: &Issue) -> Result<Issue> {
513    let body = issue.body.as_ref().unwrap();
514    let body_processed = self.process_issue_body(body);
515    let issue = self
516      .issue_handler()
517      .create(&issue.title)
518      .body(body_processed)
519      .labels(
520        issue
521          .labels
522          .iter()
523          .map(|label| label.name.clone())
524          .collect::<Vec<_>>(),
525      )
526      .send()
527      .await
528      .with_context(|| format!("Failed to create issue: {}", issue.title))?;
529    Ok(issue)
530  }
531
532  pub async fn close_issue(&self, issue: &Issue) -> Result<()> {
533    self
534      .issue_handler()
535      .update(issue.number)
536      .state(IssueState::Closed)
537      .send()
538      .await
539      .with_context(|| format!("Failed to close issue: {}", issue.number))?;
540    Ok(())
541  }
542
543  pub async fn merge_pr(&self, pr: &PullRequest) -> Result<()> {
544    self
545      .pr_handler()
546      .merge(pr.number)
547      .send()
548      .await
549      .with_context(|| format!("Failed to merge PR: {}", pr.number))?;
550    Ok(())
551  }
552
553  pub async fn delete(&self) -> Result<()> {
554    self
555      .repo_handler()
556      .delete()
557      .await
558      .context("Failed to delete repo")?;
559    Ok(())
560  }
561}
562
563#[derive(Serialize, Deserialize, Type, Debug, Clone)]
564#[serde(tag = "type", content = "value")]
565pub enum GithubToken {
566  Found(String),
567  NotFound,
568  Error(String),
569}
570
571macro_rules! token_try {
572  ($e:expr) => {{
573    match $e {
574      Ok(x) => x,
575      Err(e) => return GithubToken::Error(format!("{e:?}")),
576    }
577  }};
578}
579
580fn read_github_token_from_fs() -> GithubToken {
581  let home = match home::home_dir() {
582    Some(dir) => dir,
583    None => return GithubToken::NotFound,
584  };
585  let path = home.join(".rqst-token");
586  if path.exists() {
587    let token = token_try!(fs::read_to_string(path));
588    GithubToken::Found(token.trim_end().to_string())
589  } else {
590    GithubToken::NotFound
591  }
592}
593
594fn generate_github_token_from_cli() -> GithubToken {
595  let res = command("gh auth token", &env::current_dir().unwrap()).output();
596  match res {
597    Ok(token_output) if token_output.status.success() => {
598      let token = token_try!(String::from_utf8(token_output.stdout));
599      let token_clean = token.trim_end().to_string();
600      GithubToken::Found(token_clean)
601    }
602    _ => GithubToken::NotFound,
603  }
604}
605
606pub fn get_github_token() -> GithubToken {
607  match read_github_token_from_fs() {
608    GithubToken::NotFound => generate_github_token_from_cli(),
609    result => result,
610  }
611}
612
613pub fn init_octocrab(token: &str) -> Result<()> {
614  let crab_inst = Octocrab::builder()
615    .personal_token(token.to_string())
616    .build()
617    .context("Failed to build Github connector")?;
618  octocrab::initialise(crab_inst);
619  Ok(())
620}