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 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 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 #[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 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 #[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}