From e85ab232dcc17902bc88b06b10d5dfb23c1334e9 Mon Sep 17 00:00:00 2001 From: Will Crichton Date: Fri, 30 Aug 2024 17:31:15 -0700 Subject: [PATCH] Various improvements (#11) * Issues can link to PRs * Add support for hard reset when merging ref solution * Add quest tests --- assets/main.css | 116 +++++++++++-- src/git.rs | 59 ++++++- src/github.rs | 123 +++++++++++-- src/main.rs | 1 + src/quest.rs | 290 +++++++++++++++++++++++++++---- src/stage.rs | 2 +- src/ui.rs | 452 ++++++++++++++++++++++++++++++++---------------- src/utils.rs | 14 ++ 8 files changed, 836 insertions(+), 221 deletions(-) create mode 100644 src/utils.rs diff --git a/assets/main.css b/assets/main.css index f6aa9f6..96adc18 100644 --- a/assets/main.css +++ b/assets/main.css @@ -20,18 +20,37 @@ code, pre { position: relative; } -#refresh { - position: absolute; - top: 1rem; - right: 1rem; +.error { + border: 2px solid rgb(226, 33, 33); + border-radius: 8px; + padding: 0.5rem 1rem; + margin: 2rem 0; + + > div { + margin-bottom: 1rem; + } + + .action { + font-weight: bold; + } + + pre { + background-color: #eee; + padding: 0.25rem 0.5rem; + } } h1 { margin-top: 0; + margin-bottom: 1rem; } -.stages li { - margin-bottom: 1rem; +.stages { + margin: 0; + + li { + margin-bottom: 1rem; + } } .stage-title { @@ -46,6 +65,29 @@ h1 { font-style: italic; } +.selected-file { + margin-left: 0.5rem; +} + +.help { + display: inline-block; + border: 1px solid #ccc; + background: #fafafa; + padding: 3px 5px; + border-radius: 4px; + + summary { + cursor: pointer; + } + + > div { + padding-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + } +} + #loading-cover { width: 100vw; height: 100vh; @@ -95,10 +137,18 @@ button, label { border: 1px solid #aaa; border-radius: 4px; padding: 3px 5px; - cursor: pointer; - - &:hover { - background-color: #fafafa; + + &:not([disabled]) { + cursor: pointer; + + &:hover { + background-color: #fafafa; + } + + &:active { + border-color: black; + background-color: white; + } } } @@ -111,7 +161,49 @@ button, label { display: flex; flex-direction: column; gap: 1rem; + + table { + width: max-content; + + td { + padding-bottom: 0.5rem; + } + + td:first-child { + padding-right: 1rem; + } + } +} + +.columns { + display: flex; + justify-content: space-between; + gap: 2rem; } -/* .working-dir { -} */ \ No newline at end of file +.meta { + width: 200px; + border: 1px solid #ccc; + background-color: #fafafa; + padding: 0.5rem; + height: max-content; + + h2 { + margin: 0 0 1rem; + } + + > div { + display: flex; + flex-direction: column; + gap: 1rem; + } + + pre { + max-width: 100%; + overflow-x: auto; + } + + select { + max-width: 100%; + } +} \ No newline at end of file diff --git a/src/git.rs b/src/git.rs index a5c8759..ebee600 100644 --- a/src/git.rs +++ b/src/git.rs @@ -38,7 +38,7 @@ fn git_output(f: impl FnOnce(&mut Command)) -> Result { pub struct GitRepo {} -const UPSTREAM: &str = "upstream"; +pub const UPSTREAM: &str = "upstream"; impl GitRepo { pub fn new() -> Self { @@ -49,7 +49,7 @@ impl GitRepo { git(|cmd| { cmd.args(["clone", url]); }) - .context("Failed to clone") + .with_context(|| format!("Failed to clone: {url}")) } pub fn setup_upstream(&self, upstream: &GithubRepo) -> Result<()> { @@ -66,26 +66,57 @@ impl GitRepo { Ok(()) } - pub fn create_branch_from(&self, target_branch: &str, base_branch: &str) -> Result<()> { + pub fn create_branch_from(&self, target_branch: &str, base_branch: &str) -> Result { git(|cmd| { cmd.args(["checkout", "-b", target_branch]); }) .with_context(|| format!("Failed to checkout branch {target_branch}"))?; - git(|cmd| { + let res = git(|cmd| { cmd.args([ "cherry-pick", &format!("{UPSTREAM}/{base_branch}..{UPSTREAM}/{target_branch}"), ]); - }) - .with_context(|| format!("Failed to cherry-pick commits onto {target_branch}"))?; + }); + + if res.is_err() { + tracing::warn!("Merge conflicts when cherry-picking, resorting to hard reset"); + + git(|cmd| { + cmd.args(["cherry-pick", "--abort"]); + }) + .context("Failed to abort cherry-pick")?; + + let upstream_target = format!("{UPSTREAM}/{target_branch}"); + git(|cmd| { + cmd.args(["reset", "--hard", &upstream_target]); + }) + .with_context(|| format!("Failed to hard reset to {upstream_target}"))?; + + git(|cmd| { + cmd.args(["reset", "--soft", "main"]); + }) + .context("Failed to soft reset to main")?; + + git(|cmd| { + cmd.args(["commit", "-m", "Override with reference solution"]); + }) + .context("Failed to commit reference solution")?; + } git(|cmd| { cmd.args(["push", "-u", "origin", target_branch]); }) .with_context(|| format!("Failed to push branch {target_branch}"))?; - Ok(()) + let head = self.head_commit()?; + + git(|cmd| { + cmd.args(["checkout", "main"]); + }) + .context("Failed to checkout main")?; + + Ok(head) } pub fn checkout_main_and_pull(&self) -> Result<()> { @@ -109,4 +140,18 @@ impl GitRepo { .context("Failed to get head commit")?; Ok(output.trim_end().to_string()) } + + pub fn reset(&self, branch: &str) -> Result<()> { + git(|cmd| { + cmd.args(["reset", "--hard", branch]); + }) + .context("Failed to reset")?; + + git(|cmd| { + cmd.args(["push", "--force"]); + }) + .context("Failed to push reset branch")?; + + Ok(()) + } } diff --git a/src/github.rs b/src/github.rs index 3ffcf03..160b947 100644 --- a/src/github.rs +++ b/src/github.rs @@ -9,15 +9,20 @@ use octocrab::{ issues::Issue, pulls::{self, PullRequest}, repos::Branch, + IssueState, }, pulls::PullRequestHandler, repos::RepoHandler, GitHubError, Octocrab, }; use parking_lot::{MappedMutexGuard, Mutex, MutexGuard}; +use regex::Regex; use serde_json::json; use std::{env, fs, process::Command, sync::Arc, time::Duration}; use tokio::{time::timeout, try_join}; +use tracing::warn; + +use crate::utils; pub struct GithubRepo { user: String, @@ -27,6 +32,11 @@ pub struct GithubRepo { issues: Mutex>>, } +pub enum PullSelector { + Branch(String), + Label(String), +} + impl GithubRepo { pub fn new(user: &str, name: &str) -> Self { GithubRepo { @@ -58,7 +68,11 @@ impl GithubRepo { }) => return Ok(()), Err(e) => return Err(e.into()), }; - let (prs, issues) = (pr_page.take_items(), issue_page.take_items()); + let (prs, mut issues) = (pr_page.take_items(), issue_page.take_items()); + + // Pull requests are considered issues, so filter them out + issues.retain(|issue| issue.pull_request.is_none()); + *self.prs.lock() = Some(prs); *self.issues.lock() = Some(issues); Ok(()) @@ -168,9 +182,16 @@ impl GithubRepo { MutexGuard::map(self.prs.lock(), |opt| opt.as_mut().unwrap()) } - pub fn pr(&self, ref_field: &str) -> Option> { + pub fn pr(&self, selector: &PullSelector) -> Option> { let prs = self.prs(); - let idx = prs.iter().position(|pr| pr.head.ref_field == ref_field)?; + let idx = prs.iter().position(|pr| match selector { + PullSelector::Branch(branch) => &pr.head.ref_field == branch, + PullSelector::Label(label) => pr + .labels + .as_ref() + .map(|labels| labels.iter().any(|l| &l.name == label)) + .unwrap_or(false), + })?; Some(MappedMutexGuard::map(prs, |prs| &mut prs[idx])) } @@ -190,7 +211,12 @@ impl GithubRepo { Some(MappedMutexGuard::map(issues, |issues| &mut issues[idx])) } - pub async fn copy_pr(&self, base: &GithubRepo, base_pr: &PullRequest, head: &str) -> Result<()> { + pub async fn copy_pr( + &self, + base: &GithubRepo, + base_pr: &PullRequest, + head: &str, + ) -> Result { let pulls = self.pr_handler(); let request = pulls .create( @@ -209,6 +235,20 @@ impl GithubRepo { ); let self_pr = request.send().await?; + // TODO: lots of parallelism below we should exploit + + let labels = match &base_pr.labels { + Some(labels) => labels + .iter() + .map(|label| label.name.clone()) + .collect::>(), + None => Vec::new(), + }; + self + .issue_handler() + .add_labels(self_pr.number, &labels) + .await?; + let comment_pages = base .pr_handler() .list_comments(Some(base_pr.number)) @@ -220,7 +260,7 @@ impl GithubRepo { self.copy_pr_comment(self_pr.number, &comment, head).await?; } - Ok(()) + Ok(self_pr) } pub async fn copy_pr_comment( @@ -244,11 +284,46 @@ impl GithubRepo { Ok(()) } - pub async fn copy_issue(&self, issue: &Issue) -> Result<()> { - self + fn process_issue_body(&self, body: &str) -> String { + let re = Regex::new(r"\{\{ (\S+) (\S+) \}\}").unwrap(); + let mut new_body = body.to_string(); + let substitutions = re.captures_iter(body).filter_map(|cap| { + let full_match = cap.get(0).unwrap(); + let label = &cap[1]; + let kind = &cap[2]; + let number = match kind { + "pr" => { + let Some(pr) = self.pr(&PullSelector::Label(label.to_string())) else { + warn!("No PR with label {label}"); + return None; + }; + pr.number + } + "issue" => { + let Some(issue) = self.issue(label) else { + warn!("No issue with label {label}"); + return None; + }; + issue.number + } + _ => unimplemented!(), + }; + + Some((full_match.range(), format!("#{number}"))) + }); + utils::replace_many_ranges(&mut new_body, substitutions); + + // todo!() + new_body + } + + pub async fn copy_issue(&self, issue: &Issue) -> Result { + let body = issue.body.as_ref().unwrap(); + let body_processed = self.process_issue_body(body); + let issue = self .issue_handler() .create(&issue.title) - .body(issue.body.as_ref().unwrap()) + .body(body_processed) .labels( issue .labels @@ -258,14 +333,35 @@ impl GithubRepo { ) .send() .await?; + Ok(issue) + } + + pub async fn close_issue(&self, issue: &Issue) -> Result<()> { + self + .issue_handler() + .update(issue.number) + .state(IssueState::Closed) + .send() + .await?; + Ok(()) + } + + pub async fn merge_pr(&self, pr: &PullRequest) -> Result<()> { + self.pr_handler().merge(pr.number).send().await?; + Ok(()) + } + + pub async fn delete(&self) -> Result<()> { + self.repo_handler().delete().await?; Ok(()) } } +#[derive(Debug)] pub enum GithubToken { Found(String), + NotFound, Error(anyhow::Error), - Missing, } macro_rules! token_try { @@ -280,14 +376,14 @@ macro_rules! token_try { fn read_github_token_from_fs() -> GithubToken { let home = match home::home_dir() { Some(dir) => dir, - None => return GithubToken::Missing, + None => return GithubToken::NotFound, }; let path = home.join(".rqst-token"); if path.exists() { let token = token_try!(fs::read_to_string(path)); GithubToken::Found(token.trim_end().to_string()) } else { - GithubToken::Missing + GithubToken::NotFound } } @@ -305,7 +401,7 @@ fn generate_github_token_from_cli() -> GithubToken { let token_clean = token.trim_end().to_string(); GithubToken::Found(token_clean) } else { - GithubToken::Missing + GithubToken::NotFound } } Err(err) => GithubToken::Error(err.into()), @@ -314,13 +410,12 @@ fn generate_github_token_from_cli() -> GithubToken { pub fn get_github_token() -> GithubToken { match read_github_token_from_fs() { - GithubToken::Missing => generate_github_token_from_cli(), + GithubToken::NotFound => generate_github_token_from_cli(), result => result, } } pub fn init_octocrab(token: &str) -> Result<()> { - println!("ok {token:?}"); let crab_inst = Octocrab::builder() .personal_token(token.to_string()) .build()?; diff --git a/src/main.rs b/src/main.rs index 89aaee6..8b66cf2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod github; mod quest; mod stage; mod ui; +mod utils; fn main() { ui::launch(); diff --git a/src/quest.rs b/src/quest.rs index da988f3..865499a 100644 --- a/src/quest.rs +++ b/src/quest.rs @@ -7,15 +7,15 @@ use std::{ }; use crate::{ - git::GitRepo, - github::GithubRepo, + git::{GitRepo, UPSTREAM}, + github::{GithubRepo, PullSelector}, stage::{Stage, StageConfig, StagePart, StagePartStatus}, }; use anyhow::{ensure, Context, Result}; use dioxus::signals::{SyncSignal, Writable}; use http::StatusCode; use octocrab::{ - models::{pulls::PullRequest, IssueState}, + models::{issues::Issue, pulls::PullRequest, IssueState}, params::{issues, pulls, Direction}, GitHubError, }; @@ -34,7 +34,8 @@ pub struct QuestConfig { impl QuestConfig { pub fn load(dir: impl AsRef) -> Result { let output = Command::new("git") - .args(["show", "upstream/meta:rqst.toml"]) + .arg("show") + .arg(format!("{UPSTREAM}/meta:rqst.toml")) .current_dir(dir) .output() .context("git failed")?; @@ -65,7 +66,7 @@ pub struct Quest { pub dir: PathBuf, pub config: QuestConfig, - pub state_signal: SyncSignal>, + pub state_signal: Option>>, pub stages: Vec, } @@ -95,7 +96,7 @@ impl Quest { pub async fn load( dir: PathBuf, config: QuestConfig, - state_signal: SyncSignal>, + state_signal: Option>>, ) -> Result { let user = load_user().await?; let upstream = GithubRepo::new(&config.author, &config.repo); @@ -189,7 +190,11 @@ impl Quest { .into_iter() .filter_map(|issue| { let label = issue.labels.first()?; - Some((label.name.clone(), issue)) + if issue.pull_request.is_none() { + Some((label.name.clone(), issue)) + } else { + None + } }) .collect::>(); @@ -212,15 +217,19 @@ impl Quest { Some((stage, part, finished)) }); - let issue_stages = issue_map.keys().filter_map(|label| { - let stage = stage_map.get(label)?; - Some(( - (*stage).clone(), - StagePart::Starter, - stage.config.no_starter(), - )) + let issue_stages = issue_map.iter().filter_map(|(label, issue)| { + let stage = (*stage_map.get(label)?).clone(); + Some(if matches!(issue.state, IssueState::Closed) { + (stage, StagePart::Solution, true) + } else { + let no_starter = stage.config.no_starter(); + (stage, StagePart::Starter, no_starter) + }) }); + tracing::debug!("PRs: {:#?}", pr_stages.clone().collect::>()); + tracing::debug!("Issues: {:#?}", issue_stages.clone().collect::>()); + let Some((stage, part, finished)) = pr_stages .chain(issue_stages) .max_by_key(|(stage, part, finished)| (stage.idx, *part, *finished)) @@ -256,8 +265,10 @@ impl Quest { pub async fn infer_state_update(&self) -> Result<()> { let (new_state, _) = try_join!(self.infer_state(), self.origin.fetch())?; - let mut state_signal = self.state_signal; - state_signal.set(Some(new_state)); + if let Some(mut state_signal) = self.state_signal { + state_signal.set(Some(new_state)); + } + Ok(()) } @@ -289,22 +300,44 @@ impl Quest { Ok(()) } - async fn file_pr(&self, target_branch: &str, base_branch: &str) -> Result<()> { + async fn file_pr(&self, target_branch: &str, base_branch: &str) -> Result { self.origin_git.checkout_main_and_pull()?; - self + let branch_head = self .origin_git .create_branch_from(target_branch, base_branch)?; - let head = self.origin_git.head_commit()?; + let pr = self + .upstream + .pr(&PullSelector::Branch(target_branch.into())) + .unwrap() + .clone(); + let new_pr = self + .origin + .copy_pr(&self.upstream, &pr, &branch_head) + .await?; - let pr = self.upstream.pr(target_branch).unwrap().clone(); - self.origin.copy_pr(&self.upstream, &pr, &head).await?; + tracing::debug!("Filed PR: {base_branch} -> {target_branch}"); - Ok(()) + Ok(new_pr) + } + + async fn file_issue(&self, stage_index: usize) -> Result { + let stage = &self.stages[stage_index]; + let issue = self + .upstream + .issue(&stage.config.label) + .unwrap_or_else(|| panic!("Missing issue for stage {}", stage.config.label)) + .clone(); + let new_issue = self.origin.copy_issue(&issue).await?; + self.infer_state_update().await?; + Ok(new_issue) } - pub async fn file_feature_and_issue(&self, stage_index: usize) -> Result<()> { + pub async fn file_feature_and_issue( + &self, + stage_index: usize, + ) -> Result<(Option, Issue)> { let stage = &self.stages[stage_index]; let base_branch = if stage_index > 0 { let prev_stage = &self.stages[stage_index - 1]; @@ -313,23 +346,25 @@ impl Quest { "main".into() }; - if !stage.config.no_starter() { - self + let pr = if !stage.config.no_starter() { + let pr = self .file_pr(&stage.branch_name(StagePart::Starter), &base_branch) .await?; - } - - let issue = self.upstream.issue(&stage.config.label).unwrap().clone(); - self.origin.copy_issue(&issue).await?; + Some(pr) + } else { + None + }; + // Need to refresh our state for issues that refer to the filed PR self.infer_state_update().await?; - Ok(()) + let issue = self.file_issue(stage_index).await?; + Ok((pr, issue)) } - pub async fn file_solution(&self, stage_index: usize) -> Result<()> { + pub async fn file_solution(&self, stage_index: usize) -> Result { let stage = &self.stages[stage_index]; - self + let pr = self .file_pr( &stage.branch_name(StagePart::Solution), &stage.branch_name(StagePart::Starter), @@ -338,7 +373,7 @@ impl Quest { self.infer_state_update().await?; - Ok(()) + Ok(pr) } pub fn issue_url(&self, stage_index: usize) -> Option { @@ -349,13 +384,198 @@ impl Quest { pub fn feature_pr_url(&self, stage_index: usize) -> Option { let stage = &self.stages[stage_index]; - let pr = self.origin.pr(&stage.branch_name(StagePart::Starter))?; + let pr = self + .origin + .pr(&PullSelector::Branch(stage.branch_name(StagePart::Starter)))?; Some(pr.html_url.as_ref().unwrap().to_string()) } pub fn solution_pr_url(&self, stage_index: usize) -> Option { let stage = &self.stages[stage_index]; - let pr = self.origin.pr(&stage.branch_name(StagePart::Solution))?; + let pr = self.origin.pr(&PullSelector::Branch( + stage.branch_name(StagePart::Solution), + ))?; Some(pr.html_url.as_ref().unwrap().to_string()) } + + pub fn reference_solution_pr_url(&self, stage_index: usize) -> Option { + let stage = &self.stages[stage_index]; + let pr = self.upstream.pr(&PullSelector::Branch( + stage.branch_name(StagePart::Solution), + ))?; + Some(pr.html_url.as_ref().unwrap().to_string()) + } + + pub async fn hard_reset(&self, stage_index: usize) -> Result<()> { + let prev_stage = &self.stages[stage_index - 1]; + let branch = format!("{UPSTREAM}/{}", prev_stage.branch_name(StagePart::Solution)); + self.origin_git.reset(&branch)?; + let issue = self.file_issue(stage_index - 1).await?; + self + .origin + .issue_handler() + .update(issue.number) + .state(IssueState::Closed) + .send() + .await?; + + self.infer_state_update().await?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::github::{self, GithubToken}; + use env::current_dir; + use std::{ + fs, + sync::{Arc, Once}, + }; + use tracing::Level; + + const TEST_ORG: &str = "cognitive-engineering-lab"; + const TEST_REPO: &str = "rqst-test"; + + struct DeleteRemoteRepo(Arc); + impl Drop for DeleteRemoteRepo { + fn drop(&mut self) { + tokio::task::block_in_place(move || { + tokio::runtime::Handle::current().block_on(async move { + self.0.origin.delete().await.unwrap(); + }) + }) + } + } + + struct DeleteLocalRepo(PathBuf); + impl Drop for DeleteLocalRepo { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + + fn setup() { + static SETUP: Once = Once::new(); + SETUP.call_once(|| { + dioxus_logger::init(Level::DEBUG).expect("failed to init logger"); + + let token = github::get_github_token(); + match token { + GithubToken::Found(token) => github::init_octocrab(&token).unwrap(), + other => panic!("Failed to get github token: {other:?}"), + } + }); + } + + async fn load_test_quest() -> Result> { + let config = load_config_from_remote(TEST_ORG, TEST_REPO).await?; + assert_eq!( + config, + QuestConfig { + title: "Test".into(), + author: TEST_ORG.into(), + repo: TEST_REPO.into(), + stages: vec![ + StageConfig { + label: "00-stage".into(), + name: "A".into(), + no_starter: Some(true) + }, + StageConfig { + label: "01-stage".into(), + name: "B".into(), + no_starter: None + }, + StageConfig { + label: "02-stage".into(), + name: "C".into(), + no_starter: None + } + ] + } + ); + + let dir = current_dir()?.join(TEST_REPO); + Ok(Arc::new(Quest::load(dir, config, None).await?)) + } + + macro_rules! test_quest { + ($id:ident) => { + setup(); + + let $id = load_test_quest().await?; + + $id.create_repo().await?; + let _remote = DeleteRemoteRepo(Arc::clone(&$id)); + + $id.clone_repo()?; + let _local = DeleteLocalRepo($id.dir.clone()); + }; + } + + // TODO: some of this machinery should be its own tester binary + #[tokio::test(flavor = "multi_thread")] + #[ignore] + async fn standard_playthrough() -> Result<()> { + test_quest!(quest); + + macro_rules! state_is { + ($a:expr, $b:expr, $c:expr) => { + let state = quest.infer_state().await?; + assert_eq!((state.stage.idx, state.part, state.status), ($a, $b, $c)); + }; + } + + state_is!(0, StagePart::Starter, StagePartStatus::Start); + + let issue = quest.file_issue(0).await?; + state_is!(0, StagePart::Solution, StagePartStatus::Start); + + quest.origin.close_issue(&issue).await?; + state_is!(1, StagePart::Starter, StagePartStatus::Start); + + let (pr, issue) = quest.file_feature_and_issue(1).await?; + let pr = pr.unwrap(); + state_is!(1, StagePart::Starter, StagePartStatus::Ongoing); + + quest.origin.merge_pr(&pr).await?; + state_is!(1, StagePart::Solution, StagePartStatus::Start); + + let pr = quest.file_solution(1).await?; + state_is!(1, StagePart::Solution, StagePartStatus::Ongoing); + + quest.origin.merge_pr(&pr).await?; + state_is!(1, StagePart::Solution, StagePartStatus::Ongoing); + + quest.origin.close_issue(&issue).await?; + state_is!(2, StagePart::Starter, StagePartStatus::Start); + + Ok(()) + } + + // TODO: can't seem to run these even sequentially? + #[tokio::test(flavor = "multi_thread")] + #[ignore] + async fn skip() -> Result<()> { + test_quest!(quest); + + macro_rules! state_is { + ($a:expr, $b:expr, $c:expr) => { + let state = quest.infer_state().await?; + assert_eq!((state.stage.idx, state.part, state.status), ($a, $b, $c)); + }; + } + + state_is!(0, StagePart::Starter, StagePartStatus::Start); + + quest.hard_reset(1).await?; + state_is!(1, StagePart::Starter, StagePartStatus::Start); + + quest.hard_reset(2).await?; + state_is!(2, StagePart::Starter, StagePartStatus::Start); + + Ok(()) + } } diff --git a/src/stage.rs b/src/stage.rs index f3ce999..4d1b3a1 100644 --- a/src/stage.rs +++ b/src/stage.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; pub struct StageConfig { pub label: String, pub name: String, - no_starter: Option, + pub no_starter: Option, } impl StageConfig { diff --git a/src/ui.rs b/src/ui.rs index e5d1eb6..d990e40 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -13,18 +13,6 @@ use futures_util::FutureExt; use std::{env, ops::Deref, path::PathBuf, rc::Rc, sync::Arc}; use tracing::Level; -macro_rules! error_view { - ($label:expr, $error:expr) => {{ - rsx! { - div { - class: "error", - div { {format!("{} failed with error:", $label)} } - pre { {format!("{:?}", $error)} } - } - } - }}; -} - #[derive(Clone)] struct QuestRef(Arc); @@ -43,128 +31,235 @@ impl Deref for QuestRef { } #[component] -fn QuestView(quest: QuestRef) -> Element { - let mut error_signal = use_signal_sync(|| None::); +fn StageView(stage: usize) -> Element { + let quest = use_context::(); let mut loading_signal = use_context::>(); - let mut title_signal = use_context::>(); + let mut app_error = use_context::>(); + + let state = quest.state_signal.unwrap().read().as_ref().unwrap().clone(); + let cur_stage = state.stage.idx; let quest_ref = quest.clone(); - let title = quest.config.title.clone(); - use_hook(move || { - title_signal.set(Title(Some(title))); - tokio::spawn(async move { quest_ref.infer_state_loop().await }); - }); + let advance_stage = move |_| { + let quest_ref = quest_ref.clone(); + tokio::spawn(async move { + loading_signal.set(ShowLoading(true)); + let res = match state.part { + StagePart::Starter => quest_ref + .file_feature_and_issue(cur_stage) + .map(|res| res.map(|_| ())) + .boxed(), + StagePart::Solution => quest_ref + .file_solution(cur_stage) + .map(|res| res.map(|_| ())) + .boxed(), + } + .await; + if let Err(err) = res { + app_error.set(AppError::from_error("Running Github action", &err)); + } + loading_signal.set(ShowLoading(false)); + }); + }; - let state = quest.state_signal.read().as_ref().unwrap().clone(); - let cur_stage = state.stage.idx; - let quest_dir = quest.dir.clone(); + rsx! { + li { + div { + span { + class: "stage-title", + {quest.stages[stage].config.name.clone()} + } + span { + class: "separator", + "·" + } + + if stage == cur_stage { + if state.status.is_start() { + {match state.part { + StagePart::Starter => rsx!{ + button { + onclick: advance_stage, + if quest.stages[stage].config.no_starter() { + "File issue" + } else { + "File issue & starter PR" + } + } + }, + StagePart::Solution => rsx! { + details { + class: "help", + + summary { "Help" } + + div { + "Try first learning from our reference solution and incorporating it into your codebase. If that doesn't work, we can replace your code with ours." + } + + div { + div { + a { + href: quest.reference_solution_pr_url(stage).unwrap(), + "View reference solution" + } + } + + div { + button { + onclick: advance_stage, + "File reference solution" + } + } + } + } + } + }} + } else { + span { + class: "status", + {match state.part { + StagePart::Starter if !quest.stages[stage].config.no_starter() => "Waiting for you to merge starter PR", + _ => "Waiting for you to merge solution PR and close issue" + }} + } + } + } else { + span { + class: "status", + "Completed" + } + } + } + + div { + class: "gh-links", + + if let Some(issue_url) = quest.issue_url(stage) { + a { + href: issue_url, + "Issue" + } + } + + if let Some(feature_pr_url) = quest.feature_pr_url(stage) { + a { + href: feature_pr_url, + "Starter PR" + } + } + if let Some(solution_pr_url) = quest.solution_pr_url(stage) { + a { + href: solution_pr_url, + "Solution PR" + } + } + } + } + } +} + +fn SetChapter() -> Element { + let mut loading_signal = use_context::>(); + let quest = use_context::(); + let mut selected = use_signal(|| None::); rsx! { - if let Some(err) = &*error_signal.read() { - pre { "{err:?}" } + select { + onchange: move |event| selected.set(Some(event.value().parse::().unwrap())), + + option { + disabled: true, + selected: selected.read().is_none(), + value: "", + "Choose a chapter..." + } + + for (i, stage) in quest.stages.iter().enumerate() { + option { + value: i.to_string(), + {format!("Chapter {i}: {}", stage.config.name)} + } + } } button { - id: "refresh", + disabled: selected.read().is_none(), onclick: move |_| { + let stage_index = *selected.read_unchecked().as_ref().unwrap(); let quest_ref = quest.clone(); tokio::spawn(async move { - quest_ref.infer_state_update().await.unwrap(); + loading_signal.set(ShowLoading(true)); + quest_ref.hard_reset(stage_index).await.unwrap(); + loading_signal.set(ShowLoading(false)); }); }, - "⟳" + "Skip to chapter" } + } +} - div { - class: "working-dir", - "Directory: " - code { {quest_dir.display().to_string()} } - } +fn QuestView() -> Element { + let quest = use_context::(); + let mut title_signal = use_context::>(); - ol { - class: "stages", - for stage in 0 ..= cur_stage { - li { - div { - span { - class: "stage-title", - {quest.stages[stage].config.name.clone()} - } - span { - class: "separator", - "·" - } + let quest_ref = quest.clone(); + let title = quest.config.title.clone(); + use_hook(move || { + title_signal.set(Title(Some(title))); + tokio::spawn(async move { quest_ref.infer_state_loop().await }); + }); - if stage == cur_stage { - if state.status.is_start() { - button { - onclick: { - let quest_ref = quest.clone(); - move |_| { - let quest_ref = quest_ref.clone(); - tokio::spawn(async move { - loading_signal.set(ShowLoading(true)); - let res = match state.part { - StagePart::Starter => quest_ref.file_feature_and_issue(cur_stage).boxed(), - StagePart::Solution => quest_ref.file_solution(cur_stage).boxed(), - }.await; - if let Err(e) = res { - error_signal.set(Some(e)); - } - loading_signal.set(ShowLoading(false)); - }); - } - }, - {match state.part { - StagePart::Starter => if quest.stages[stage].config.no_starter() { - "File issue" - } else { - "File issue & starter PR" - }, - StagePart::Solution => "Give solution" - }} - } - } else { - span { - class: "status", - {match state.part { - StagePart::Starter if !quest.stages[stage].config.no_starter() => "Waiting for you to merge starter PR", - _ => "Waiting for you to solve & close issue" - }} - } - } - } else { - span { - class: "status", - "Completed" - } - } + let state = quest.state_signal.unwrap().read().as_ref().unwrap().clone(); + let cur_stage = state.stage.idx; + let quest_dir = quest.dir.display().to_string(); + + rsx! { + div { + class: "columns", + + div { + ol { + start: "0", + class: "stages", + for stage in 0 ..= cur_stage { + StageView { stage } } + } + } - div { - class: "gh-links", + div { + class: "meta", - if let Some(issue_url) = quest.issue_url(stage) { - a { - href: issue_url, - "Issue" - } - } + h2 { + "Controls" + } - if let Some(feature_pr_url) = quest.feature_pr_url(stage) { - a { - href: feature_pr_url, - "Starter PR" - } + div { + div { + button { + onclick: move |_| { + let quest_ref = quest.clone(); + tokio::spawn(async move { + quest_ref.infer_state_update().await.unwrap(); + }); + }, + "Refresh state" } + } - if let Some(solution_pr_url) = quest.solution_pr_url(stage) { - a { - href: solution_pr_url, - "Solution PR" - } + div { + button { + onclick: move |_| { + eval(&format!(r#"navigator.clipboard.writeText("{quest_dir}")"#)); + }, + "Copy directory to 📋" } } + + div { + SetChapter {} + } } } } @@ -181,7 +276,7 @@ fn ExistingQuestLoader(dir: PathBuf, config: QuestConfig) -> Element { let dir = dir.clone(); async move { loading_signal.set(ShowLoading(true)); - let quest = Quest::load(dir, config, state_signal).await?; + let quest = Quest::load(dir, config, Some(state_signal)).await?; quest_slot.set(Some(QuestRef(Arc::new(quest)))); loading_signal.set(ShowLoading(false)); Ok::<_, anyhow::Error>(()) @@ -201,7 +296,11 @@ fn QuestLoader() -> Element { let quest_slot = use_context_provider(|| SyncSignal::>::new_maybe_sync(None)); use_context_provider(|| SyncSignal::>::new_maybe_sync(None)); match &*quest_slot.read_unchecked() { - Some(quest) => rsx! { QuestView { quest: quest.clone() }}, + Some(quest) => { + let quest_ref = quest.clone(); + use_context_provider(move || quest_ref); + rsx! { QuestView { }} + } None => { let dir = env::current_dir().unwrap(); let config = QuestConfig::load(&dir); @@ -224,6 +323,7 @@ fn InitForm() -> Element { let mut new_dir = use_signal(|| None::); let mut repo = use_signal(|| None::); let mut state = use_signal(|| InitState::AwaitingInput); + let mut app_error = use_context::>(); let cur_state = state.read(); match &*cur_state { @@ -236,45 +336,53 @@ fn InitForm() -> Element { strong { "Start a new quest" } } - div { - select { - onchange: move |event| repo.set(Some(event.value())), - option { - disabled: true, - selected: repo.read().is_none(), - value: "", - "Choose a quest..." - } - option { - value: "rqst-async", - "rqst-async" + table { + tr { + td { "Quest:" } + td { + select { + onchange: move |event| repo.set(Some(event.value())), + option { + disabled: true, + selected: repo.read().is_none(), + value: "", + "Choose a quest..." + } + option { + value: "rqst-async", + "rqst-async" + } + } } } - } - div { - label { - r#for: "new-quest-dir", - "Choose a dir" - } + tr { + td { "Directory:" } + td { + label { + r#for: "new-quest-dir", + "Choose a dir" + } - if let Some(new_dir) = &*new_dir.read() { - span { - class: "selected-file", - "{new_dir}" - } - } + if let Some(new_dir) = &*new_dir.read() { + span { + class: "selected-file", + "{new_dir}" + } + } - input { - id: "new-quest-dir", - r#type: "file", - "webkitdirectory": true, - onchange: move |event| { - let mut files = event.files().unwrap().files(); - if !files.is_empty() { - new_dir.set(Some(files.remove(0))); + input { + id: "new-quest-dir", + r#type: "file", + "webkitdirectory": true, + onchange: move |event| { + let mut files = event.files().unwrap().files(); + if !files.is_empty() { + new_dir.set(Some(files.remove(0))); + } + }, } - }, + } } } @@ -322,7 +430,13 @@ fn InitForm() -> Element { let config = QuestConfig::load(dir); match config { Ok(config) => rsx! { ExistingQuestLoader { dir: dir.clone(), config } }, - Err(e) => error_view!(format!("Loading quest from {}", dir.display()), e), + Err(e) => { + app_error.set(AppError::from_error( + format!("Loading quest from directory: {}", dir.display()), + &e, + )); + rsx! {} + } } } } @@ -333,6 +447,7 @@ fn InitView(repo: String, dir: PathBuf) -> Element { let state_signal = use_context::>>(); let mut quest_slot = use_context::>>(); let mut loading_signal = use_context::>(); + let mut app_error = use_context::>(); let quest = use_resource(move || { let repo = repo.clone(); let dir = dir.clone(); @@ -340,7 +455,7 @@ fn InitView(repo: String, dir: PathBuf) -> Element { loading_signal.set(ShowLoading(true)); let result = tokio::spawn(async move { let config = quest::load_config_from_remote("cognitive-engineering-lab", &repo).await?; - let quest = Quest::load(dir.join(repo), config, state_signal).await?; + let quest = Quest::load(dir.join(repo), config, Some(state_signal)).await?; quest.create_repo().await?; quest_slot.set(Some(QuestRef(Arc::new(quest)))); loading_signal.set(ShowLoading(false)); @@ -353,17 +468,18 @@ fn InitView(repo: String, dir: PathBuf) -> Element { }); match &*quest.read_unchecked() { - None => rsx! { "Initializing repo..." }, - Some(Err(e)) => rsx! { - div { "Failed to initialize repo with error:" } - pre { "{e:?}" } - }, + None => rsx! { "Initializing quest..." }, + Some(Err(e)) => { + app_error.set(AppError::from_error("Initializing quest", e)); + rsx! {} + } Some(Ok(())) => rsx! { "Unreachable?" }, } } fn GithubLoader() -> Element { let token = use_hook(|| Rc::new(github::get_github_token())); + let mut app_error = use_context::>(); match token.as_ref() { GithubToken::Found(token) => { let init_res = use_hook(|| Rc::new(github::init_octocrab(token))); @@ -375,8 +491,11 @@ fn GithubLoader() -> Element { }, } } - GithubToken::Error(err) => error_view!("Github token", err), - GithubToken::Missing => rsx! { + GithubToken::Error(err) => { + app_error.set(AppError::from_error("Loading GitHub API", err)); + rsx! {} + } + GithubToken::NotFound => rsx! { div { "Before running RepoQuest, you need to provide it access to Github. " "Follow the instructions at the link below and restart RepoQuest." @@ -399,10 +518,20 @@ struct ShowLoading(bool); #[derive(Clone)] struct Title(Option); +#[derive(Clone)] +struct AppError(Option<(String, String)>); + +impl AppError { + pub fn from_error(action: impl Into, error: &anyhow::Error) -> Self { + AppError(Some((action.into(), format!("{error:?}")))) + } +} + #[component] fn App() -> Element { let show_loading = use_context_provider(|| SyncSignal::new_maybe_sync(ShowLoading(false))); let title = use_context_provider(|| SyncSignal::new_maybe_sync(Title(None))); + let app_error = use_context_provider(|| SyncSignal::new_maybe_sync(AppError(None))); rsx! { link { rel: "stylesheet", href: "main.css" } @@ -419,12 +548,31 @@ fn App() -> Element { div { id: "app", + h1 { "RepoQuest" if let Some(title) = title.read().0.as_ref() { ": {title}" } } + + if let Some((action, error)) = &app_error.read().0 { + div { + class: "error", + + div { + class: "action", + "Fatal error while: {action}" + } + + div { + "RepoQuest encountered an unrecoverable error. Please fix the issue and restart RepoQuest, or contact the developers for support. The backtrace is below." + } + + pre { {error.clone()} } + } + } + GithubLoader {} } } diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..51702f2 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,14 @@ +use std::ops::Range; + +pub fn replace_many_ranges( + s: &mut String, + ranges: impl IntoIterator, impl AsRef)>, +) { + let ranges = ranges.into_iter().collect::>(); + if !ranges.is_empty() { + debug_assert!((0..ranges.len() - 1).all(|i| ranges[i].0.end <= ranges[i + 1].0.start)); + for (range, content) in ranges.into_iter().rev() { + s.replace_range(range, content.as_ref()); + } + } +}