use std::{
collections::HashMap,
fmt::Write,
fs,
path::{Path, PathBuf},
};
use crate::{
chapter::{Chapter, ChapterPart},
git::{Branch, GitRepo, MergeType, Ref},
github::{self, GithubRepo, load_user},
package::QuestPackage,
source::{InstanceOutputs, QuestSource},
};
use eyre::{Context, Result, bail, ensure};
use http::StatusCode;
use octocrab::{
GitHubError,
models::{IssueState, issues::Issue, pulls::PullRequest},
params::{Direction, pulls},
};
use serde::{Deserialize, Serialize};
use tokio::{join, try_join};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub struct QuestConfig {
pub title: String,
pub author: String,
pub repo: String,
pub chapters: Vec<Chapter>,
pub read_only: Option<Vec<PathBuf>>,
pub r#final: Option<serde_json::Value>,
pub final_url: Option<String>,
pub rq_version: String,
}
#[derive(Debug, strum::Display)]
pub enum QuestStrictness {
Relaxed,
Strict,
}
impl QuestStrictness {
pub fn is_strict(&self) -> bool {
matches!(self, QuestStrictness::Strict)
}
}
#[derive(Debug)]
pub struct QuestUserPrefs {
pub name: String,
pub strictness: QuestStrictness,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ChapterState {
pub chapter: Chapter,
pub issue_url: Option<String>,
pub pr_url: Option<String>,
pub refsol_url: Option<String>,
}
impl QuestConfig {
pub fn load(repo: &GitRepo, remote: Option<&str>) -> Result<Self> {
let branch = match remote {
Some(remote) => Branch::new(format!("{remote}/meta")),
None => Branch::meta(),
};
let config_str = repo.read_file_string(&branch, Path::new("rqst.toml"))?;
let mut config = toml::de::from_str::<QuestConfig>(&config_str)
.context("Failed to parse quest configuration rqst.toml")?;
let final_path = Path::new("final.toml");
if repo.contains_file(&branch, final_path)? {
let quiz_str = repo.read_file_string(&branch, final_path)?;
let quiz =
toml::de::from_str::<serde_json::Value>(&quiz_str).context("Failed to parse final.toml")?;
config.r#final = Some(quiz);
}
Ok(config)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum QuestState {
Ongoing { chapter: u32, started: bool },
Completed,
}
pub struct Quest {
pub source: Box<dyn QuestSource>,
pub origin: GithubRepo,
pub origin_git: GitRepo,
pub chapter_index: HashMap<String, usize>,
pub dir: PathBuf,
pub config: QuestConfig,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct StateDescriptor {
pub dir: PathBuf,
pub chapters: Vec<ChapterState>,
pub state: QuestState,
pub can_skip: bool,
}
pub enum CreateSource {
Remote { user: String, repo: String },
Package(Box<QuestPackage>),
}
impl Quest {
async fn load_core(
dir: &Path,
config: QuestConfig,
template: Box<dyn QuestSource>,
origin: GithubRepo,
origin_git: GitRepo,
) -> Result<Self> {
let chapter_index = config
.chapters
.iter()
.enumerate()
.map(|(i, chapter)| (chapter.label.clone(), i))
.collect::<HashMap<_, _>>();
let q = Quest {
dir: dir.to_path_buf(),
config,
source: template,
origin,
origin_git,
chapter_index,
};
let exists = q.origin.fetch().await?;
ensure!(exists, "Repo is missing");
Ok(q)
}
#[tracing::instrument(skip(source))]
pub async fn create(dir: &Path, source: CreateSource, prefs: QuestUserPrefs) -> Result<Self> {
github::check_ssh()?;
let template: Box<dyn QuestSource> = match source {
CreateSource::Remote { user, repo } => {
let upstream = GithubRepo::load(&user, &repo).await?;
Box::new(upstream)
}
CreateSource::Package(package) => Box::new(*package),
};
let InstanceOutputs {
origin,
origin_git,
config,
} = template.instantiate(dir, &prefs.name).await?;
if prefs.strictness.is_strict() {
origin_git.install_hooks()?;
origin
.set_var("STRICTNESS", &prefs.strictness.to_string())
.await?;
}
Self::load_core(&dir.join(&prefs.name), config, template, origin, origin_git).await
}
pub async fn load(dir: &Path) -> Result<Self> {
let user = load_user().await?;
let origin_git = GitRepo::new(dir);
let remote = origin_git
.upstream()
.context("Failed to test for upstream")?;
let config = QuestConfig::load(&origin_git, remote).context(
"Failed to load quest config. Did you run repo-quest in a non-RepoQuest Git repository?",
)?;
let origin_fut = async {
GithubRepo::load(&user, &config.repo)
.await
.context("Failed to load GitHub repo")
};
let template_fut = async {
if remote.is_some() {
let upstream = GithubRepo::load(&config.author, &config.repo)
.await
.context("Failed to load upstream GitHub repo")?;
Ok(Box::new(upstream) as Box<dyn QuestSource>)
} else {
let contents = origin_git.read_file_bytes(&Branch::meta(), Path::new("package.json.gz"))?;
let package =
QuestPackage::load_from_blob(&contents).context("Failed to load quest package")?;
Ok(Box::new(package) as Box<dyn QuestSource>)
}
};
let (origin, template) = try_join!(origin_fut, template_fut)?;
Self::load_core(dir, config, template, origin, origin_git).await
}
pub fn chapters(&self) -> &[Chapter] {
&self.config.chapters
}
fn chapter(&self, idx: usize) -> &Chapter {
&self.config.chapters[idx]
}
pub async fn infer_state(&self) -> Result<QuestState> {
let prs = self.origin.pr_handler();
let pr_page_future = prs
.list()
.state(octocrab::params::State::All)
.sort(pulls::Sort::Created)
.direction(Direction::Descending)
.per_page(10)
.send();
let mut pr_page = match pr_page_future.await {
Ok(result) => result,
Err(octocrab::Error::GitHub { source, .. })
if matches!(
&*source,
GitHubError {
status_code: StatusCode::NOT_FOUND,
..
}
) =>
{
return Ok(QuestState::Ongoing {
chapter: 0,
started: false,
});
}
Err(e) => return Err(e.into()),
};
let prs = pr_page.take_items();
let chapter_map = self
.chapters()
.iter()
.map(|chapter| (chapter.label.clone(), chapter))
.collect::<HashMap<_, _>>();
let pr_chapters = prs.into_iter().filter_map(|pr| {
let label = &pr.labels.iter().flatten().nth(0)?.name;
let chapter = (*chapter_map.get(label)?).clone();
match (pr.state.as_ref(), pr.merged_at.is_some()) {
(Some(IssueState::Open), _) => Some((chapter, false)),
(Some(IssueState::Closed), true) => Some((chapter, true)),
_ => None,
}
});
tracing::trace!("PRs: {:#?}", pr_chapters.clone().collect::<Vec<_>>());
let chapter_idx = |chapter: &Chapter| self.chapter_index[&chapter.label];
let Some((chapter, finished)) =
pr_chapters.max_by_key(|(chapter, finished)| (chapter_idx(chapter), *finished))
else {
return Ok(QuestState::Ongoing {
chapter: 0,
started: false,
});
};
let chapter = chapter_idx(&chapter);
Ok(if finished {
if chapter == self.chapters().len() - 1 {
QuestState::Completed
} else {
QuestState::Ongoing {
chapter: (chapter + 1) as u32,
started: false,
}
}
} else {
QuestState::Ongoing {
chapter: chapter as u32,
started: true,
}
})
}
pub async fn state_descriptor(&self) -> Result<StateDescriptor> {
let state = self.infer_state().await?;
Ok(StateDescriptor {
dir: self.dir.clone(),
chapters: self.chapter_states(),
state,
can_skip: self.source.can_skip(),
})
}
async fn file_pr(
&self,
default_title: &str,
origin_head: &Branch,
origin_commit: &Ref,
upstream_head: &Branch,
merge_type: MergeType,
chapter_label: &str,
) -> Result<PullRequest> {
let pr = self.source.pull_request(upstream_head);
let (title, mut body, mut labels, comments) = match pr {
Some(pr) => {
let comments = self.source.pull_request_comments(&pr).await?;
let title = pr.title.expect("Missing PR title");
let body = format!(
"{}\n\nResolves {{{{ {chapter_label} issue }}}}. (Don't merge until you've added your solution!)\n",
pr.body.expect("Missing PR body")
);
let labels = match pr.labels {
Some(labels) => labels.iter().map(|label| label.name.clone()).collect(),
None => Vec::new(),
};
(title, body, labels, comments)
}
None => (
default_title.to_string(),
format!(
"This PR resolves {{{{ {chapter_label} issue }}}}. (Don't merge until you've added your solution!)"
),
vec![chapter_label.to_string()],
Vec::new(),
),
};
const RESET_LABEL: &str = "reset";
if merge_type.is_reset() {
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.");
labels.push(RESET_LABEL.into());
}
let new_pr = self
.origin
.file_pr(
&title,
&body,
&labels,
origin_head,
origin_commit,
&comments,
)
.await?;
Ok(new_pr)
}
fn commit_chapter_to_readme(&self, chapter: &Chapter) -> Result<()> {
let readme_path = self.dir.join("README.md");
let mut readme_contents =
fs::read_to_string(&readme_path).context("Failed to read README.md")?;
write!(readme_contents, "\n- [x] {}", chapter.name)?;
fs::write(&readme_path, readme_contents)?;
self.origin_git.add(&readme_path)?;
let commit_msg = format!("Start of solution for {}", chapter.name);
self.origin_git.commit(&commit_msg)?;
Ok(())
}
#[tracing::instrument(skip(self))]
pub async fn start_chapter(&self, chapter_index: usize) -> Result<(PullRequest, Issue)> {
let chapter = self.chapter(chapter_index);
ensure!(
!self.origin_git.contains_unstaged_changes()?,
"Your Git workspace contains unstaged changes. Remove or commit them before starting a new chapter."
);
let src_issue = self
.source
.issue(&chapter.label)
.with_context(|| format!("Failed to get issue for chapter: {}", chapter.name))?;
let issue = self
.origin
.copy_issue(&src_issue)
.await
.with_context(|| format!("Failed to file issue for chapter: {}", chapter.name))?;
macro_rules! unrecoverable_expect {
($e:expr, $e2:expr, $fmt:expr, $($arg:expr)*) => {{
match $e {
Ok(x) => x,
Err(e) => {
let s = format!($fmt, $($arg),*);
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);
}
}
}}
}
let cleanup_issue = async |err| {
unrecoverable_expect!(
self.origin.close_issue(&issue).await,
err,
"Failed to close issue #{}",
issue.number
)
};
let upstream_base = (!chapter.no_starter()).then(|| {
if chapter_index > 0 {
let prev_chapter = self.chapter(chapter_index - 1);
prev_chapter.branch(ChapterPart::Solution)
} else {
Branch::main()
}
});
let origin_head = Branch::new(&chapter.label);
let upstream_head = chapter.branch(ChapterPart::Starter);
let res = self.origin_git.create_branch_from_main(&origin_head);
if let Err(e) = res {
cleanup_issue(&e).await;
return Err(e);
}
let cleanup_local_branch = |err| {
unrecoverable_expect!(
self.origin_git.checkout(&Branch::main()),
err,
"Failed to checkout main",
);
unrecoverable_expect!(
self.origin_git.delete_local_branch(&origin_head),
err,
"Failed to delete branch `{}`",
origin_head.as_str()
)
};
let res = self.commit_chapter_to_readme(chapter);
if let Err(e) = res {
cleanup_issue(&e).await;
cleanup_local_branch(&e);
return Err(e);
}
let merge_type = match upstream_base.as_ref() {
Some(upstream_base) => {
match self
.source
.apply_patch(&self.origin_git, upstream_base, &upstream_head)
{
Ok(merge_type) => merge_type,
Err(e) => {
cleanup_issue(&e).await;
cleanup_local_branch(&e);
return Err(e);
}
}
}
None => MergeType::Success,
};
let res = self.origin_git.push(&origin_head);
if let Err(e) = res {
cleanup_issue(&e).await;
cleanup_local_branch(&e);
return Err(e);
}
let cleanup_remote_branch = |err| {
unrecoverable_expect!(
self.origin_git.delete_remote_branch(&origin_head),
err,
"Failed to cleanup remote branch `{}`",
origin_head.as_str()
)
};
let res = self.origin_git.head_commit();
let origin_commit = match res {
Ok(x) => x,
Err(e) => {
cleanup_issue(&e).await;
cleanup_local_branch(&e);
cleanup_remote_branch(&e);
return Err(e);
}
};
let res = self
.file_pr(
&src_issue.title,
&origin_head,
&origin_commit,
&upstream_head,
merge_type,
&chapter.label,
)
.await;
let pr = match res {
Ok(x) => x,
Err(e) => {
cleanup_issue(&e).await;
cleanup_local_branch(&e);
cleanup_remote_branch(&e);
return Err(e);
}
};
let cleanup_pr = async |err| {
unrecoverable_expect!(
self.origin.close_pr(&pr).await,
err,
"Failed to cleanup PR #{}",
pr.number
)
};
let res = try_join!(
self.origin.update_issue_links(&issue),
self.origin.update_pr_links(&pr)
);
if let Err(e) = res {
join!(cleanup_issue(&e), cleanup_pr(&e));
cleanup_local_branch(&e);
cleanup_remote_branch(&e);
return Err(e);
}
Ok((pr, issue))
}
#[tracing::instrument(skip(self))]
pub async fn add_solution(&self, chapter_index: usize) -> Result<MergeType> {
let chapter = self.chapter(chapter_index);
if self.source.refsol_url(chapter).is_none() {
panic!("Attempting to use reference solution for a quest source w/o one")
}
let base = if chapter.no_starter() {
if chapter_index > 0 {
let prev_chapter = self.chapter(chapter_index - 1);
prev_chapter.branch(ChapterPart::Solution)
} else {
Branch::main()
}
} else {
chapter.branch(ChapterPart::Starter)
};
let origin_head = Branch::new(&chapter.label);
let upstream_head = chapter.branch(ChapterPart::Solution);
let merge_type = self
.source
.apply_patch(&self.origin_git, &base, &upstream_head)?;
self.origin_git.push(&origin_head)?;
Ok(merge_type)
}
fn chapter_states(&self) -> Vec<ChapterState> {
self
.chapters()
.iter()
.map(|chapter| {
let issue_url = self
.origin
.issue(&chapter.label)
.map(|issue| issue.html_url.to_string());
let pr_url = self
.origin
.pr(&Branch::new(&chapter.label))
.map(|pr| pr.html_url.as_ref().unwrap().to_string());
let refsol_url = self.source.refsol_url(chapter);
ChapterState {
chapter: chapter.clone(),
issue_url,
pr_url,
refsol_url,
}
})
.collect()
}
#[tracing::instrument(skip(self))]
pub async fn skip_to_chapter(&self, chapter_index: usize) -> Result<()> {
let Some(upstream) = self.origin_git.upstream()? else {
bail!("Cannot skip to chapter without an upstream")
};
if chapter_index > 1 {
let prev_chapter = self.chapter(chapter_index - 2);
let branch = Branch::new(format!(
"{upstream}/{}",
prev_chapter.branch(ChapterPart::Solution)
));
self
.origin_git
.hard_reset(&branch)
.with_context(|| format!("Failed to reset to branch: {branch}"))?;
}
let (pr, issue) = self.start_chapter(chapter_index - 1).await?;
self.add_solution(chapter_index - 1).await?;
self.origin.merge_pr(&pr).await?;
self.origin.wait_for_issue_closed(&issue).await?;
self.start_chapter(chapter_index).await?;
Ok(())
}
}