Skip to main content

rq_core/
quest.rs

1use std::{
2  borrow::Cow,
3  collections::HashMap,
4  path::{Path, PathBuf},
5  time::Duration,
6};
7
8use crate::{
9  git::{GitRepo, UPSTREAM},
10  github::{self, GithubRepo, PullSelector, load_user},
11  package::QuestPackage,
12  stage::{Stage, StagePart, StagePartStatus},
13  template::{InstanceOutputs, PackageTemplate, QuestTemplate, RepoTemplate},
14};
15use eyre::{Context, Result};
16use http::StatusCode;
17use octocrab::{
18  GitHubError,
19  models::{IssueState, issues::Issue, pulls::PullRequest},
20  params::{Direction, issues, pulls},
21};
22use regex::Regex;
23use serde::{Deserialize, Serialize};
24use specta::Type;
25use tokio::{time::sleep, try_join};
26
27pub trait StateEmitter: Send + Sync + 'static {
28  fn emit(&self, state: StateDescriptor) -> Result<()>;
29}
30
31pub struct NoopEmitter;
32
33impl StateEmitter for NoopEmitter {
34  fn emit(&self, _state: StateDescriptor) -> Result<()> {
35    Ok(())
36  }
37}
38
39#[derive(Clone, Debug, Serialize, Deserialize, Type, PartialEq, Eq)]
40#[serde(rename_all = "kebab-case")]
41pub struct QuestConfig {
42  pub title: String,
43  pub author: String,
44  pub repo: String,
45  pub stages: Vec<Stage>,
46  pub read_only: Option<Vec<PathBuf>>,
47  pub r#final: Option<serde_json::Value>,
48  pub final_url: Option<String>,
49}
50
51#[derive(Serialize, Deserialize, Type, Clone)]
52pub struct StageState {
53  pub stage: Stage,
54  pub issue_url: Option<String>,
55  pub feature_pr_url: Option<String>,
56  pub solution_pr_url: Option<String>,
57  pub reference_solution_pr_url: Option<String>,
58}
59
60impl QuestConfig {
61  pub fn load(repo: &GitRepo, remote: Option<&str>) -> Result<Self> {
62    let branch = match remote {
63      Some(remote) => Cow::Owned(format!("{remote}/meta")),
64      None => Cow::Borrowed("meta"),
65    };
66    let config_str = repo.read_file(&branch, "rqst.toml")?;
67    let mut config = toml::de::from_str::<QuestConfig>(&config_str)
68      .context("Failed to parse quest configuration rqst.toml")?;
69
70    if repo.contains_file(&branch, "final.toml")? {
71      let quiz_str = repo.read_file(&branch, "final.toml")?;
72      let quiz =
73        toml::de::from_str::<serde_json::Value>(&quiz_str).context("Failed to parse final.toml")?;
74      config.r#final = Some(quiz);
75    }
76
77    Ok(config)
78  }
79}
80
81#[derive(Clone, Debug, Serialize, Deserialize, Type)]
82#[serde(tag = "type")]
83pub enum QuestState {
84  Ongoing {
85    stage: u32,
86    part: StagePart,
87    status: StagePartStatus,
88  },
89  Completed,
90}
91
92pub struct Quest {
93  template: Box<dyn QuestTemplate>,
94  origin: GithubRepo,
95  origin_git: GitRepo,
96  stage_index: HashMap<String, usize>,
97  state_event: Box<dyn StateEmitter>,
98
99  pub dir: PathBuf,
100  pub config: QuestConfig,
101}
102
103#[derive(Serialize, Deserialize, Clone, Type)]
104pub struct StateDescriptor {
105  pub dir: PathBuf,
106  pub stages: Vec<StageState>,
107  pub state: QuestState,
108  pub can_skip: bool,
109  pub behind_origin: bool,
110}
111
112pub enum CreateSource {
113  Remote { user: String, repo: String },
114  Package(QuestPackage),
115}
116
117impl Quest {
118  async fn load_core(
119    dir: &Path,
120    config: QuestConfig,
121    state_event: Box<dyn StateEmitter>,
122    template: Box<dyn QuestTemplate>,
123    origin: GithubRepo,
124    origin_git: GitRepo,
125  ) -> Result<Self> {
126    let stage_index = config
127      .stages
128      .iter()
129      .enumerate()
130      .map(|(i, stage)| (stage.label.clone(), i))
131      .collect::<HashMap<_, _>>();
132
133    let q = Quest {
134      dir: dir.to_path_buf(),
135      config,
136      template,
137      origin,
138      origin_git,
139      stage_index,
140      state_event,
141    };
142
143    q.infer_state_update().await?;
144
145    Ok(q)
146  }
147
148  pub async fn create(
149    dir: &Path,
150    source: CreateSource,
151    state_event: Box<dyn StateEmitter>,
152  ) -> Result<Self> {
153    github::check_ssh()?;
154
155    let template: Box<dyn QuestTemplate> = match source {
156      CreateSource::Remote { user, repo } => {
157        let upstream = GithubRepo::load(&user, &repo).await?;
158        Box::new(RepoTemplate(upstream))
159      }
160      CreateSource::Package(package) => Box::new(PackageTemplate(package)),
161    };
162
163    let InstanceOutputs {
164      origin,
165      origin_git,
166      config,
167    } = template.instantiate(dir).await?;
168
169    origin_git.install_hooks()?;
170
171    Self::load_core(
172      &dir.join(&config.repo),
173      config,
174      state_event,
175      template,
176      origin,
177      origin_git,
178    )
179    .await
180  }
181
182  pub async fn load(dir: &Path, state_event: Box<dyn StateEmitter>) -> Result<Self> {
183    let user = load_user().await?;
184    let origin_git = GitRepo::new(dir);
185    let upstream = origin_git
186      .upstream()
187      .context("Failed to test for upstream")?;
188    let config = QuestConfig::load(&origin_git, upstream).context("Failed to load quest config")?;
189    let origin_fut = async {
190      GithubRepo::load(&user, &config.repo)
191        .await
192        .context("Failed to load GitHub repo")
193    };
194    let template_fut = async {
195      if upstream.is_some() {
196        let upstream = GithubRepo::load(&config.author, &config.repo)
197          .await
198          .context("Failed to load upstream GitHub repo")?;
199        Ok(Box::new(RepoTemplate(upstream)) as Box<dyn QuestTemplate>)
200      } else {
201        let contents = origin_git.show_bin("meta", "package.json.gz")?;
202        let package =
203          QuestPackage::load_from_blob(&contents).context("Failed to load quest package")?;
204        Ok(Box::new(PackageTemplate(package)) as Box<dyn QuestTemplate>)
205      }
206    };
207    let (origin, template) = try_join!(origin_fut, template_fut)?;
208
209    Self::load_core(dir, config, state_event, template, origin, origin_git).await
210  }
211
212  pub fn stages(&self) -> &[Stage] {
213    &self.config.stages
214  }
215
216  fn stage(&self, idx: usize) -> &Stage {
217    &self.config.stages[idx]
218  }
219
220  fn parse_stage(&self, pr: &PullRequest) -> Option<(Stage, StagePart)> {
221    let branch = &pr.head.ref_field;
222    let re = Regex::new("^(.*)-([abc])$").unwrap();
223    let (_, [name, part_str]) = re.captures(branch)?.extract();
224    let stage = self.stage_index.get(name)?;
225    let part = StagePart::parse(part_str)?;
226    Some((self.stage(*stage).clone(), part))
227  }
228
229  async fn infer_state(&self) -> Result<QuestState> {
230    let pr_handler = self.origin.pr_handler();
231    let pr_page_future = pr_handler
232      .list()
233      .state(octocrab::params::State::All)
234      .sort(pulls::Sort::Created)
235      .direction(Direction::Descending)
236      .per_page(10)
237      .send();
238
239    let issue_handler = self.origin.issue_handler();
240    let issue_page_future = issue_handler
241      .list()
242      .state(octocrab::params::State::All)
243      .sort(issues::Sort::Created)
244      .direction(Direction::Descending)
245      .per_page(10)
246      .send();
247
248    let (mut pr_page, mut issue_page) = match try_join!(pr_page_future, issue_page_future) {
249      Ok(result) => result,
250      Err(octocrab::Error::GitHub { source, .. })
251        if matches!(
252          &*source,
253          GitHubError {
254            status_code: StatusCode::NOT_FOUND,
255            ..
256          }
257        ) =>
258      {
259        return Ok(QuestState::Ongoing {
260          stage: 0,
261          part: StagePart::Starter,
262          status: StagePartStatus::Start,
263        });
264      }
265      Err(e) => return Err(e.into()),
266    };
267
268    let prs = pr_page.take_items();
269    let issues = issue_page.take_items();
270
271    let issue_map = issues
272      .into_iter()
273      .filter_map(|issue| {
274        let label = issue.labels.first()?;
275        if issue.pull_request.is_none() {
276          Some((label.name.clone(), issue))
277        } else {
278          None
279        }
280      })
281      .collect::<HashMap<_, _>>();
282
283    let stage_map = self
284      .stages()
285      .iter()
286      .map(|stage| (stage.label.clone(), stage))
287      .collect::<HashMap<_, _>>();
288
289    let pr_stages = prs.iter().filter_map(|pr| {
290      let (stage, part) = self.parse_stage(pr)?;
291      let finished = pr.merged_at.is_some()
292        && match part {
293          StagePart::Solution => {
294            let issue = issue_map.get(&stage.label)?;
295            matches!(issue.state, IssueState::Closed)
296          }
297          StagePart::Starter => true,
298        };
299      Some((stage, part, finished))
300    });
301
302    let issue_stages = issue_map.iter().filter_map(|(label, issue)| {
303      let stage = (*stage_map.get(label)?).clone();
304      Some(if matches!(issue.state, IssueState::Closed) {
305        (stage, StagePart::Solution, true)
306      } else {
307        let no_starter = stage.no_starter();
308        (stage, StagePart::Starter, no_starter)
309      })
310    });
311
312    tracing::trace!("PRs: {:#?}", pr_stages.clone().collect::<Vec<_>>());
313    tracing::trace!("Issues: {:#?}", issue_stages.clone().collect::<Vec<_>>());
314
315    let stage_idx = |stage: &Stage| self.stage_index[&stage.label];
316    let Some((stage, part, finished)) = pr_stages
317      .chain(issue_stages)
318      .max_by_key(|(stage, part, finished)| (stage_idx(stage), *part, *finished))
319    else {
320      return Ok(QuestState::Ongoing {
321        stage: 0,
322        part: StagePart::Starter,
323        status: StagePartStatus::Start,
324      });
325    };
326
327    let stage = stage_idx(&stage);
328
329    Ok(if finished {
330      match part.next_part() {
331        Some(next_part) => QuestState::Ongoing {
332          stage: stage as u32,
333          part: next_part,
334          status: StagePartStatus::Start,
335        },
336        None => {
337          if stage == self.stages().len() - 1 {
338            QuestState::Completed
339          } else {
340            QuestState::Ongoing {
341              stage: (stage + 1) as u32,
342              part: StagePart::Starter,
343              status: StagePartStatus::Start,
344            }
345          }
346        }
347      }
348    } else {
349      QuestState::Ongoing {
350        stage: stage as u32,
351        part,
352        status: StagePartStatus::Ongoing,
353      }
354    })
355  }
356
357  pub async fn state_descriptor(&self) -> Result<StateDescriptor> {
358    let state = self.infer_state().await?;
359    let behind_origin = self.origin_git.is_behind_origin()?;
360    Ok(StateDescriptor {
361      dir: self.dir.clone(),
362      stages: self.stage_states(),
363      state,
364      can_skip: self.template.can_skip(),
365      behind_origin,
366    })
367  }
368
369  pub async fn infer_state_update(&self) -> Result<()> {
370    self.origin.fetch().await?;
371    let state = self.state_descriptor().await?;
372    self.state_event.emit(state)?;
373
374    Ok(())
375  }
376
377  pub async fn infer_state_loop(&self) {
378    loop {
379      self.infer_state_update().await.unwrap();
380      sleep(Duration::from_secs(10)).await;
381    }
382  }
383
384  async fn file_pr(&self, base_branch: &str, target_branch: &str) -> Result<PullRequest> {
385    self
386      .origin_git
387      .checkout_main()
388      .context("Failed to checkout main")?;
389    self.origin_git.pull().context("Failed to pull")?;
390
391    let (branch_head, merge_type) = self
392      .origin_git
393      .create_branch_from(&*self.template, base_branch, target_branch)
394      .with_context(|| format!("Failed to create new branch: {base_branch} -> {target_branch}"))?;
395
396    let pr = self
397      .template
398      .pull_request(&PullSelector::Branch(target_branch.into()))
399      .with_context(|| format!("Failed to fetch pull request for {target_branch}"))?;
400    let comments = self.template.pull_request_comments(&pr).await?;
401    let new_pr = self
402      .origin
403      .copy_pr(&pr, &comments, &branch_head, merge_type)
404      .await
405      .context("Failed to copy PR to repo")?;
406
407    tracing::debug!("Filed PR: {base_branch} -> {target_branch}");
408
409    Ok(new_pr)
410  }
411
412  async fn file_issue(&self, stage_index: usize) -> Result<Issue> {
413    let stage = self.stage(stage_index);
414    let issue = self
415      .template
416      .issue(&stage.label)
417      .with_context(|| format!("Failed to get issue for stage: {}", stage.label))?;
418    let new_issue = self
419      .origin
420      .copy_issue(&issue)
421      .await
422      .context("Failed to copy issue to repo")?;
423    self.infer_state_update().await?;
424    Ok(new_issue)
425  }
426
427  pub async fn file_feature_and_issue(
428    &self,
429    stage_index: usize,
430  ) -> Result<(Option<PullRequest>, Issue)> {
431    let stage = self.stage(stage_index);
432    let base_branch = if stage_index > 0 {
433      let prev_stage = self.stage(stage_index - 1);
434      prev_stage.branch_name(StagePart::Solution)
435    } else {
436      "main".into()
437    };
438
439    let pr = if !stage.no_starter() {
440      let pr = self
441        .file_pr(&base_branch, &stage.branch_name(StagePart::Starter))
442        .await
443        .context("Failed to file starter PR")?;
444      Some(pr)
445    } else {
446      None
447    };
448
449    // Need to refresh our state for issues that refer to the filed PR
450    self.infer_state_update().await?;
451
452    let issue = self
453      .file_issue(stage_index)
454      .await
455      .context("Failed to file issue")?;
456    Ok((pr, issue))
457  }
458
459  pub async fn file_solution(&self, stage_index: usize) -> Result<PullRequest> {
460    let stage = self.stage(stage_index);
461    let base = if stage.no_starter() {
462      // TODO: repeats w/ file_feature
463      if stage_index > 0 {
464        let prev_stage = self.stage(stage_index - 1);
465        prev_stage.branch_name(StagePart::Solution)
466      } else {
467        "main".into()
468      }
469    } else {
470      stage.branch_name(StagePart::Starter)
471    };
472    let pr = self
473      .file_pr(&base, &stage.branch_name(StagePart::Solution))
474      .await
475      .context("Failed to file solution PR")?;
476
477    self.infer_state_update().await?;
478
479    Ok(pr)
480  }
481
482  pub fn stage_states(&self) -> Vec<StageState> {
483    self
484      .stages()
485      .iter()
486      .map(|stage| {
487        let issue_url = self
488          .origin
489          .issue(&stage.label)
490          .map(|issue| issue.html_url.to_string());
491
492        let feature_pr_url = self
493          .origin
494          .pr(&PullSelector::Branch(stage.branch_name(StagePart::Starter)))
495          .map(|pr| pr.html_url.as_ref().unwrap().to_string());
496
497        let solution_pr_url = self
498          .origin
499          .pr(&PullSelector::Branch(
500            stage.branch_name(StagePart::Solution),
501          ))
502          .map(|pr| pr.html_url.as_ref().unwrap().to_string());
503
504        let reference_solution_pr_url = self.template.reference_solution_pr_url(stage);
505
506        StageState {
507          stage: stage.clone(),
508          issue_url,
509          feature_pr_url,
510          solution_pr_url,
511          reference_solution_pr_url,
512        }
513      })
514      .collect()
515  }
516
517  pub async fn skip_to_stage(&self, stage_index: usize) -> Result<()> {
518    let prev_stage = self.stage(stage_index - 1);
519    let branch = format!("{UPSTREAM}/{}", prev_stage.branch_name(StagePart::Solution));
520    self
521      .origin_git
522      .reset(&branch)
523      .with_context(|| format!("Failed to reset to branch: {branch}"))?;
524    let issue = self
525      .file_issue(stage_index - 1)
526      .await
527      .context("Failed to file issue for preceding stage")?;
528    self
529      .origin
530      .issue_handler()
531      .update(issue.number)
532      .state(IssueState::Closed)
533      .send()
534      .await
535      .with_context(|| format!("Failed to close issue: {}", issue.number))?;
536
537    self.infer_state_update().await?;
538    Ok(())
539  }
540}
541
542#[cfg(test)]
543mod test {
544  use super::*;
545  use crate::github::{self, GithubToken};
546  use env::current_dir;
547  use eyre::ensure;
548  use std::{
549    env, fs,
550    process::Command,
551    sync::{Arc, Once},
552  };
553  use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, prelude::*};
554
555  const TEST_ORG: &str = "cognitive-engineering-lab";
556  const TEST_REPO: &str = "rqst-test";
557
558  struct DeleteRemoteRepo(Arc<Quest>);
559  impl Drop for DeleteRemoteRepo {
560    fn drop(&mut self) {
561      tokio::task::block_in_place(move || {
562        tokio::runtime::Handle::current().block_on(async move {
563          self.0.origin.delete().await.unwrap();
564        })
565      })
566    }
567  }
568
569  struct DeleteLocalRepo(PathBuf);
570  impl Drop for DeleteLocalRepo {
571    fn drop(&mut self) {
572      fs::remove_dir_all(&self.0).unwrap();
573    }
574  }
575
576  fn setup() {
577    static SETUP: Once = Once::new();
578    SETUP.call_once(|| {
579      tracing_subscriber::registry()
580        .with(fmt::layer())
581        .with(EnvFilter::from_default_env())
582        .init();
583
584      let token = github::get_github_token();
585      match token {
586        GithubToken::Found(token) => github::init_octocrab(&token).unwrap(),
587        other => panic!("Failed to get github token: {other:?}"),
588      }
589    });
590  }
591
592  async fn create_test_quest(source: CreateSource) -> Result<Arc<Quest>> {
593    let dir = current_dir()?;
594    let quest = Quest::create(&dir, source, Box::new(NoopEmitter)).await?;
595    Ok(Arc::new(quest))
596  }
597
598  macro_rules! test_quest {
599    ($id:ident, $source:expr) => {
600      setup();
601
602      let $id = create_test_quest($source).await?;
603      let _remote = DeleteRemoteRepo(Arc::clone(&$id));
604      let _local = DeleteLocalRepo($id.dir.clone());
605    };
606    ($id:ident) => {
607      test_quest!(
608        $id,
609        CreateSource::Remote {
610          user: TEST_ORG.into(),
611          repo: TEST_REPO.into(),
612        }
613      )
614    };
615  }
616
617  macro_rules! state_is {
618    ($quest:expr, $a:expr, $b:expr, $c:expr) => {{
619      let state = $quest.infer_state().await?;
620      match state {
621        QuestState::Ongoing {
622          stage,
623          part,
624          status,
625        } => assert_eq!((stage, part, status), ($a, $b, $c)),
626        QuestState::Completed => panic!("finished"),
627      };
628    }};
629  }
630
631  // TODO: some of this machinery should be its own tester binary
632  #[tokio::test(flavor = "multi_thread")]
633  #[ignore]
634  async fn remote_playthrough() -> Result<()> {
635    test_quest!(quest);
636
637    state_is!(quest, 0, StagePart::Starter, StagePartStatus::Start);
638
639    let issue = quest.file_issue(0).await?;
640    state_is!(quest, 0, StagePart::Solution, StagePartStatus::Start);
641
642    quest.origin.close_issue(&issue).await?;
643    state_is!(quest, 1, StagePart::Starter, StagePartStatus::Start);
644
645    let (pr, issue) = quest.file_feature_and_issue(1).await?;
646    let pr = pr.unwrap();
647    state_is!(quest, 1, StagePart::Starter, StagePartStatus::Ongoing);
648
649    quest.origin.merge_pr(&pr).await?;
650    state_is!(quest, 1, StagePart::Solution, StagePartStatus::Start);
651
652    let pr = quest.file_solution(1).await?;
653    state_is!(quest, 1, StagePart::Solution, StagePartStatus::Ongoing);
654
655    quest.origin.merge_pr(&pr).await?;
656    state_is!(quest, 1, StagePart::Solution, StagePartStatus::Ongoing);
657
658    quest.origin.close_issue(&issue).await?;
659    state_is!(quest, 2, StagePart::Starter, StagePartStatus::Start);
660
661    Ok(())
662  }
663
664  #[tokio::test(flavor = "multi_thread")]
665  #[ignore]
666  async fn local_playthrough() -> Result<()> {
667    let status = Command::new("git")
668      .args([
669        "clone",
670        "--mirror",
671        &format!("https://github.com/{TEST_ORG}/{TEST_REPO}"),
672        TEST_REPO,
673      ])
674      .status()?;
675    ensure!(status.success(), "clone failed");
676
677    let repo_path = env::current_dir().unwrap().join(TEST_REPO);
678    let status = Command::new("cargo")
679      .args([
680        "run",
681        "-p",
682        "rq-cli",
683        "--",
684        "pack",
685        &repo_path.display().to_string(),
686      ])
687      .status()?;
688    ensure!(status.success(), "pack failed");
689
690    fs::remove_dir_all(repo_path)?;
691
692    let package_path = PathBuf::from(format!("{TEST_REPO}.json.gz"));
693    let package = QuestPackage::load_from_file(&package_path)?;
694    test_quest!(quest, CreateSource::Package(package));
695
696    state_is!(quest, 0, StagePart::Starter, StagePartStatus::Start);
697
698    let issue = quest.file_issue(0).await?;
699    state_is!(quest, 0, StagePart::Solution, StagePartStatus::Start);
700
701    quest.origin.close_issue(&issue).await?;
702    state_is!(quest, 1, StagePart::Starter, StagePartStatus::Start);
703
704    let (pr, issue) = quest.file_feature_and_issue(1).await?;
705    let pr = pr.unwrap();
706    state_is!(quest, 1, StagePart::Starter, StagePartStatus::Ongoing);
707
708    quest.origin.merge_pr(&pr).await?;
709    state_is!(quest, 1, StagePart::Solution, StagePartStatus::Start);
710
711    // don't merge the solution PR, that doesn't exist
712
713    quest.origin.close_issue(&issue).await?;
714    state_is!(quest, 2, StagePart::Starter, StagePartStatus::Start);
715
716    fs::remove_file(package_path)?;
717
718    Ok(())
719  }
720
721  // TODO: can't seem to run these even sequentially?
722  #[tokio::test(flavor = "multi_thread")]
723  #[ignore]
724  async fn skip() -> Result<()> {
725    test_quest!(quest);
726
727    macro_rules! state_is {
728      ($a:expr, $b:expr, $c:expr) => {
729        let state = quest.infer_state().await?;
730        match state {
731          QuestState::Ongoing {
732            stage,
733            part,
734            status,
735          } => assert_eq!((stage, part, status), ($a, $b, $c)),
736          QuestState::Completed => panic!("finished"),
737        };
738      };
739    }
740
741    state_is!(0, StagePart::Starter, StagePartStatus::Start);
742
743    quest.skip_to_stage(1).await?;
744    state_is!(1, StagePart::Starter, StagePartStatus::Start);
745
746    quest.skip_to_stage(2).await?;
747    state_is!(2, StagePart::Starter, StagePartStatus::Start);
748
749    Ok(())
750  }
751}