Skip to main content

repo_quest/
quest.rs

1use std::{
2  collections::HashMap,
3  fmt::Write,
4  fs,
5  path::{Path, PathBuf},
6};
7
8use crate::{
9  chapter::{Chapter, ChapterPart},
10  git::{Branch, GitRepo, MergeType, Ref},
11  github::{self, GithubRepo, load_user},
12  package::QuestPackage,
13  source::{InstanceOutputs, QuestSource},
14};
15use eyre::{Context, Result, bail, ensure};
16use http::StatusCode;
17use octocrab::{
18  GitHubError,
19  models::{IssueState, issues::Issue, pulls::PullRequest},
20  params::{Direction, pulls},
21};
22use serde::{Deserialize, Serialize};
23use tokio::{join, try_join};
24
25#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "kebab-case")]
27pub struct QuestConfig {
28  pub title: String,
29  pub author: String,
30  pub repo: String,
31  pub chapters: Vec<Chapter>,
32  pub read_only: Option<Vec<PathBuf>>,
33  pub r#final: Option<serde_json::Value>,
34  pub final_url: Option<String>,
35  pub rq_version: String,
36}
37
38#[derive(Debug, strum::Display)]
39pub enum QuestStrictness {
40  Relaxed,
41  Strict,
42}
43
44impl QuestStrictness {
45  pub fn is_strict(&self) -> bool {
46    matches!(self, QuestStrictness::Strict)
47  }
48}
49
50#[derive(Debug)]
51pub struct QuestUserPrefs {
52  pub name: String,
53  pub strictness: QuestStrictness,
54}
55
56#[derive(Serialize, Deserialize, Clone)]
57pub struct ChapterState {
58  pub chapter: Chapter,
59  pub issue_url: Option<String>,
60  pub pr_url: Option<String>,
61  pub refsol_url: Option<String>,
62}
63
64impl QuestConfig {
65  pub fn load(repo: &GitRepo, remote: Option<&str>) -> Result<Self> {
66    let branch = match remote {
67      Some(remote) => Branch::new(format!("{remote}/meta")),
68      None => Branch::meta(),
69    };
70    let config_str = repo.read_file_string(&branch, Path::new("rqst.toml"))?;
71    let mut config = toml::de::from_str::<QuestConfig>(&config_str)
72      .context("Failed to parse quest configuration rqst.toml")?;
73
74    let final_path = Path::new("final.toml");
75    if repo.contains_file(&branch, final_path)? {
76      let quiz_str = repo.read_file_string(&branch, final_path)?;
77      let quiz =
78        toml::de::from_str::<serde_json::Value>(&quiz_str).context("Failed to parse final.toml")?;
79      config.r#final = Some(quiz);
80    }
81
82    Ok(config)
83  }
84}
85
86#[derive(Clone, Debug, Serialize, Deserialize)]
87#[serde(tag = "type")]
88pub enum QuestState {
89  Ongoing { chapter: u32, started: bool },
90  Completed,
91}
92
93pub struct Quest {
94  pub source: Box<dyn QuestSource>,
95  pub origin: GithubRepo,
96  pub origin_git: GitRepo,
97  pub chapter_index: HashMap<String, usize>,
98  pub dir: PathBuf,
99  pub config: QuestConfig,
100}
101
102#[derive(Serialize, Deserialize, Clone)]
103pub struct StateDescriptor {
104  pub dir: PathBuf,
105  pub chapters: Vec<ChapterState>,
106  pub state: QuestState,
107  pub can_skip: bool,
108}
109
110pub enum CreateSource {
111  Remote { user: String, repo: String },
112  Package(Box<QuestPackage>),
113}
114
115impl Quest {
116  async fn load_core(
117    dir: &Path,
118    config: QuestConfig,
119    template: Box<dyn QuestSource>,
120    origin: GithubRepo,
121    origin_git: GitRepo,
122  ) -> Result<Self> {
123    let chapter_index = config
124      .chapters
125      .iter()
126      .enumerate()
127      .map(|(i, chapter)| (chapter.label.clone(), i))
128      .collect::<HashMap<_, _>>();
129
130    let q = Quest {
131      dir: dir.to_path_buf(),
132      config,
133      source: template,
134      origin,
135      origin_git,
136      chapter_index,
137    };
138
139    let exists = q.origin.fetch().await?;
140    ensure!(exists, "Repo is missing");
141
142    Ok(q)
143  }
144
145  #[tracing::instrument(skip(source))]
146  pub async fn create(dir: &Path, source: CreateSource, prefs: QuestUserPrefs) -> Result<Self> {
147    github::check_ssh()?;
148
149    let template: Box<dyn QuestSource> = match source {
150      CreateSource::Remote { user, repo } => {
151        let upstream = GithubRepo::load(&user, &repo).await?;
152        Box::new(upstream)
153      }
154      CreateSource::Package(package) => Box::new(*package),
155    };
156
157    let InstanceOutputs {
158      origin,
159      origin_git,
160      config,
161    } = template.instantiate(dir, &prefs.name).await?;
162
163    if prefs.strictness.is_strict() {
164      origin_git.install_hooks()?;
165      origin
166        .set_var("STRICTNESS", &prefs.strictness.to_string())
167        .await?;
168    }
169
170    Self::load_core(&dir.join(&prefs.name), config, template, origin, origin_git).await
171  }
172
173  pub async fn load(dir: &Path) -> Result<Self> {
174    let user = load_user().await?;
175    let origin_git = GitRepo::new(dir);
176    let remote = origin_git
177      .upstream()
178      .context("Failed to test for upstream")?;
179    let config = QuestConfig::load(&origin_git, remote).context(
180      "Failed to load quest config. Did you run repo-quest in a non-RepoQuest Git repository?",
181    )?;
182    let origin_fut = async {
183      GithubRepo::load(&user, &config.repo)
184        .await
185        .context("Failed to load GitHub repo")
186    };
187    let template_fut = async {
188      if remote.is_some() {
189        let upstream = GithubRepo::load(&config.author, &config.repo)
190          .await
191          .context("Failed to load upstream GitHub repo")?;
192        Ok(Box::new(upstream) as Box<dyn QuestSource>)
193      } else {
194        let contents = origin_git.read_file_bytes(&Branch::meta(), Path::new("package.json.gz"))?;
195        let package =
196          QuestPackage::load_from_blob(&contents).context("Failed to load quest package")?;
197        Ok(Box::new(package) as Box<dyn QuestSource>)
198      }
199    };
200    let (origin, template) = try_join!(origin_fut, template_fut)?;
201
202    Self::load_core(dir, config, template, origin, origin_git).await
203  }
204
205  pub fn chapters(&self) -> &[Chapter] {
206    &self.config.chapters
207  }
208
209  fn chapter(&self, idx: usize) -> &Chapter {
210    &self.config.chapters[idx]
211  }
212
213  pub async fn infer_state(&self) -> Result<QuestState> {
214    let prs = self.origin.pr_handler();
215    let pr_page_future = prs
216      .list()
217      .state(octocrab::params::State::All)
218      .sort(pulls::Sort::Created)
219      .direction(Direction::Descending)
220      .per_page(10)
221      .send();
222    let mut pr_page = match pr_page_future.await {
223      Ok(result) => result,
224      Err(octocrab::Error::GitHub { source, .. })
225        if matches!(
226          &*source,
227          GitHubError {
228            status_code: StatusCode::NOT_FOUND,
229            ..
230          }
231        ) =>
232      {
233        return Ok(QuestState::Ongoing {
234          chapter: 0,
235          started: false,
236        });
237      }
238      Err(e) => return Err(e.into()),
239    };
240
241    let prs = pr_page.take_items();
242
243    let chapter_map = self
244      .chapters()
245      .iter()
246      .map(|chapter| (chapter.label.clone(), chapter))
247      .collect::<HashMap<_, _>>();
248
249    let pr_chapters = prs.into_iter().filter_map(|pr| {
250      let label = &pr.labels.iter().flatten().nth(0)?.name;
251      let chapter = (*chapter_map.get(label)?).clone();
252      match (pr.state.as_ref(), pr.merged_at.is_some()) {
253        (Some(IssueState::Open), _) => Some((chapter, false)),
254        (Some(IssueState::Closed), true) => Some((chapter, true)),
255        _ => None,
256      }
257    });
258
259    tracing::trace!("PRs: {:#?}", pr_chapters.clone().collect::<Vec<_>>());
260
261    let chapter_idx = |chapter: &Chapter| self.chapter_index[&chapter.label];
262    let Some((chapter, finished)) =
263      pr_chapters.max_by_key(|(chapter, finished)| (chapter_idx(chapter), *finished))
264    else {
265      return Ok(QuestState::Ongoing {
266        chapter: 0,
267        started: false,
268      });
269    };
270
271    let chapter = chapter_idx(&chapter);
272
273    Ok(if finished {
274      if chapter == self.chapters().len() - 1 {
275        QuestState::Completed
276      } else {
277        QuestState::Ongoing {
278          chapter: (chapter + 1) as u32,
279          started: false,
280        }
281      }
282    } else {
283      QuestState::Ongoing {
284        chapter: chapter as u32,
285        started: true,
286      }
287    })
288  }
289
290  pub async fn state_descriptor(&self) -> Result<StateDescriptor> {
291    let state = self.infer_state().await?;
292    Ok(StateDescriptor {
293      dir: self.dir.clone(),
294      chapters: self.chapter_states(),
295      state,
296      can_skip: self.source.can_skip(),
297    })
298  }
299
300  async fn file_pr(
301    &self,
302    default_title: &str,
303    origin_head: &Branch,
304    origin_commit: &Ref,
305    upstream_head: &Branch,
306    merge_type: MergeType,
307    chapter_label: &str,
308  ) -> Result<PullRequest> {
309    let pr = self.source.pull_request(upstream_head);
310
311    let (title, mut body, mut labels, comments) = match pr {
312      Some(pr) => {
313        let comments = self.source.pull_request_comments(&pr).await?;
314        let title = pr.title.expect("Missing PR title");
315        let body = format!(
316          "{}\n\nResolves {{{{ {chapter_label} issue }}}}. (Don't merge until you've added your solution!)\n",
317          pr.body.expect("Missing PR body")
318        );
319        let labels = match pr.labels {
320          Some(labels) => labels.iter().map(|label| label.name.clone()).collect(),
321          None => Vec::new(),
322        };
323        (title, body, labels, comments)
324      }
325      None => (
326        default_title.to_string(),
327        format!(
328          "This PR resolves {{{{ {chapter_label} issue }}}}. (Don't merge until you've added your solution!)"
329        ),
330        vec![chapter_label.to_string()],
331        Vec::new(),
332      ),
333    };
334
335    const RESET_LABEL: &str = "reset";
336    if merge_type.is_reset() {
337      body.push_str("\n\nNote: due to a merge conflict, this PR is a hard reset to the reference code, and may have overwritten your previous changes.");
338      labels.push(RESET_LABEL.into());
339    }
340
341    let new_pr = self
342      .origin
343      .file_pr(
344        &title,
345        &body,
346        &labels,
347        origin_head,
348        origin_commit,
349        &comments,
350      )
351      .await?;
352
353    Ok(new_pr)
354  }
355
356  fn commit_chapter_to_readme(&self, chapter: &Chapter) -> Result<()> {
357    let readme_path = self.dir.join("README.md");
358    let mut readme_contents =
359      fs::read_to_string(&readme_path).context("Failed to read README.md")?;
360    write!(readme_contents, "\n- [x] {}", chapter.name)?;
361    fs::write(&readme_path, readme_contents)?;
362
363    self.origin_git.add(&readme_path)?;
364    let commit_msg = format!("Start of solution for {}", chapter.name);
365    self.origin_git.commit(&commit_msg)?;
366
367    Ok(())
368  }
369
370  /// Starts a new chapter by filing an issue and pull request.
371  #[tracing::instrument(skip(self))]
372  pub async fn start_chapter(&self, chapter_index: usize) -> Result<(PullRequest, Issue)> {
373    // This panics if `chapter_index` is out of bounds, which should be checked by the caller.
374    let chapter = self.chapter(chapter_index);
375
376    ensure!(
377      !self.origin_git.contains_unstaged_changes()?,
378      "Your Git workspace contains unstaged changes. Remove or commit them before starting a new chapter."
379    );
380
381    // Find the issue for the chapter in the quest source.
382    //
383    // This fails if the chapter is mis-labeled, which should be checked by the quest designer.
384    // TODO: incorporate this into a quest linter
385    let src_issue = self
386      .source
387      .issue(&chapter.label)
388      .with_context(|| format!("Failed to get issue for chapter: {}", chapter.name))?;
389
390    // Copy the issue into the user's quest instance. Do this early on because we need
391    // the issue ID later.
392    //
393    // This should only fail due to connection issues w/ Github.
394    let issue = self
395      .origin
396      .copy_issue(&src_issue)
397      .await
398      .with_context(|| format!("Failed to file issue for chapter: {}", chapter.name))?;
399
400    macro_rules! unrecoverable_expect {
401      ($e:expr, $e2:expr, $fmt:expr, $($arg:expr)*) => {{
402        match $e {
403          Ok(x) => x,
404          Err(e) => {
405            let s = format!($fmt, $($arg),*);
406            panic!("Repo is now in an inconsistent state because a failure occurred, and a second failure occurred while rolling back from the first failure.\n\nThe second failure was: {s}:\n{e:?}\n\nThe first failure was: {:?}", $e2);
407          }
408        }
409      }}
410    }
411
412    let cleanup_issue = async |err| {
413      unrecoverable_expect!(
414        self.origin.close_issue(&issue).await,
415        err,
416        "Failed to close issue #{}",
417        issue.number
418      )
419    };
420
421    // By default, the upstream quest repo should have a branch structure:
422    //   ch00-foo-a -> ch00-foo-b -> ch01-bar-a -> ch01-bar-b
423    // If we are starting chapter 1, then we want in the origin:
424    //    origin_head = ch01-bar
425    // And from upstream:
426    //    upstream_base = ch00-foo-b
427    //    upstream_head = ch01-bar-a
428    // The goal is to port the commits in ch00-foo-b..ch01-bar-a onto ch01-bar.
429    //
430    // If no_starter = true, then the upstream repo should look like:
431    //   ch00-foo-a -> ch00-foo-b               -> ch01-bar-b
432    // And we instead want:
433    //    upstream_head = ch01-bar-b
434    // And we will not port any commits.
435    let upstream_base = (!chapter.no_starter()).then(|| {
436      if chapter_index > 0 {
437        let prev_chapter = self.chapter(chapter_index - 1);
438        prev_chapter.branch(ChapterPart::Solution)
439      } else {
440        Branch::main()
441      }
442    });
443    let origin_head = Branch::new(&chapter.label);
444    let upstream_head = chapter.branch(ChapterPart::Starter);
445
446    // Start by creating the new branch from a fresh pull of main.
447    //
448    // This could fail if the workspace is unclean.
449    let res = self.origin_git.create_branch_from_main(&origin_head);
450    if let Err(e) = res {
451      cleanup_issue(&e).await;
452      return Err(e);
453    }
454    let cleanup_local_branch = |err| {
455      unrecoverable_expect!(
456        self.origin_git.checkout(&Branch::main()),
457        err,
458        "Failed to checkout main",
459      );
460      unrecoverable_expect!(
461        self.origin_git.delete_local_branch(&origin_head),
462        err,
463        "Failed to delete branch `{}`",
464        origin_head.as_str()
465      )
466    };
467
468    // We can only file a pull request with at least one commit, so we create a sort-of dummy
469    // commit by adding a checked box for the chapter to the README.
470    //
471    // This could fail if the workspace is unclean.
472    let res = self.commit_chapter_to_readme(chapter);
473    if let Err(e) = res {
474      cleanup_issue(&e).await;
475      cleanup_local_branch(&e);
476      return Err(e);
477    }
478
479    let merge_type = match upstream_base.as_ref() {
480      Some(upstream_base) => {
481        // If the chapter has starter code, then we commit the starter code to the current branch.
482        match self
483          .source
484          .apply_patch(&self.origin_git, upstream_base, &upstream_head)
485        {
486          Ok(merge_type) => merge_type,
487          Err(e) => {
488            cleanup_issue(&e).await;
489            cleanup_local_branch(&e);
490            return Err(e);
491          }
492        }
493      }
494      None => MergeType::Success,
495    };
496
497    // We then push the local branch to Github in preparation for making a pull request.
498    //
499    // This should only fail for network/auth reasons.
500    let res = self.origin_git.push(&origin_head);
501    if let Err(e) = res {
502      cleanup_issue(&e).await;
503      cleanup_local_branch(&e);
504      return Err(e);
505    }
506    let cleanup_remote_branch = |err| {
507      unrecoverable_expect!(
508        self.origin_git.delete_remote_branch(&origin_head),
509        err,
510        "Failed to cleanup remote branch `{}`",
511        origin_head.as_str()
512      )
513    };
514
515    // Retrieve the SHA of the current HEAD to file for the PR.
516    let res = self.origin_git.head_commit();
517    let origin_commit = match res {
518      Ok(x) => x,
519      Err(e) => {
520        cleanup_issue(&e).await;
521        cleanup_local_branch(&e);
522        cleanup_remote_branch(&e);
523        return Err(e);
524      }
525    };
526
527    let res = self
528      .file_pr(
529        &src_issue.title,
530        &origin_head,
531        &origin_commit,
532        &upstream_head,
533        merge_type,
534        &chapter.label,
535      )
536      .await;
537    let pr = match res {
538      Ok(x) => x,
539      Err(e) => {
540        cleanup_issue(&e).await;
541        cleanup_local_branch(&e);
542        cleanup_remote_branch(&e);
543        return Err(e);
544      }
545    };
546    let cleanup_pr = async |err| {
547      unrecoverable_expect!(
548        self.origin.close_pr(&pr).await,
549        err,
550        "Failed to cleanup PR #{}",
551        pr.number
552      )
553    };
554
555    // Now that we have the PR and issue numbers, go back and update the bodies of each
556    // with the right internal links.
557    let res = try_join!(
558      self.origin.update_issue_links(&issue),
559      self.origin.update_pr_links(&pr)
560    );
561    if let Err(e) = res {
562      // let e = eyre!("foo");
563      join!(cleanup_issue(&e), cleanup_pr(&e));
564      cleanup_local_branch(&e);
565      cleanup_remote_branch(&e);
566      return Err(e);
567    }
568
569    Ok((pr, issue))
570  }
571
572  #[tracing::instrument(skip(self))]
573  pub async fn add_solution(&self, chapter_index: usize) -> Result<MergeType> {
574    let chapter = self.chapter(chapter_index);
575    if self.source.refsol_url(chapter).is_none() {
576      panic!("Attempting to use reference solution for a quest source w/o one")
577    }
578
579    let base = if chapter.no_starter() {
580      // TODO: repeats w/ file_feature
581      if chapter_index > 0 {
582        let prev_chapter = self.chapter(chapter_index - 1);
583        prev_chapter.branch(ChapterPart::Solution)
584      } else {
585        Branch::main()
586      }
587    } else {
588      chapter.branch(ChapterPart::Starter)
589    };
590
591    let origin_head = Branch::new(&chapter.label);
592    let upstream_head = chapter.branch(ChapterPart::Solution);
593
594    let merge_type = self
595      .source
596      .apply_patch(&self.origin_git, &base, &upstream_head)?;
597
598    self.origin_git.push(&origin_head)?;
599
600    Ok(merge_type)
601  }
602
603  fn chapter_states(&self) -> Vec<ChapterState> {
604    self
605      .chapters()
606      .iter()
607      .map(|chapter| {
608        let issue_url = self
609          .origin
610          .issue(&chapter.label)
611          .map(|issue| issue.html_url.to_string());
612
613        let pr_url = self
614          .origin
615          .pr(&Branch::new(&chapter.label))
616          .map(|pr| pr.html_url.as_ref().unwrap().to_string());
617
618        let refsol_url = self.source.refsol_url(chapter);
619
620        ChapterState {
621          chapter: chapter.clone(),
622          issue_url,
623          pr_url,
624          refsol_url,
625        }
626      })
627      .collect()
628  }
629
630  #[tracing::instrument(skip(self))]
631  pub async fn skip_to_chapter(&self, chapter_index: usize) -> Result<()> {
632    let Some(upstream) = self.origin_git.upstream()? else {
633      bail!("Cannot skip to chapter without an upstream")
634    };
635
636    if chapter_index > 1 {
637      let prev_chapter = self.chapter(chapter_index - 2);
638      let branch = Branch::new(format!(
639        "{upstream}/{}",
640        prev_chapter.branch(ChapterPart::Solution)
641      ));
642      self
643        .origin_git
644        .hard_reset(&branch)
645        .with_context(|| format!("Failed to reset to branch: {branch}"))?;
646    }
647
648    let (pr, issue) = self.start_chapter(chapter_index - 1).await?;
649    self.add_solution(chapter_index - 1).await?;
650    self.origin.merge_pr(&pr).await?;
651    self.origin.wait_for_issue_closed(&issue).await?;
652    self.start_chapter(chapter_index).await?;
653
654    Ok(())
655  }
656}