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 #[tracing::instrument(skip(self))]
372 pub async fn start_chapter(&self, chapter_index: usize) -> Result<(PullRequest, Issue)> {
373 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 let src_issue = self
386 .source
387 .issue(&chapter.label)
388 .with_context(|| format!("Failed to get issue for chapter: {}", chapter.name))?;
389
390 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 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 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 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 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 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 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 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 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 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}