Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Add testing infrastructure for patch reviews
Merged did:key:z6MkgFq6...nBGz opened 1 year ago
6 files changed +255 -53 de643c5a 76f55f0d
modified Cargo.lock
@@ -1627,6 +1627,15 @@ dependencies = [
]

[[package]]
+
name = "qcheck"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b439bd4242da51d62d18c95e6a6add749346756b0d1a587dfd0cc22fa6b5f3f0"
+
dependencies = [
+
 "rand",
+
]
+

+
[[package]]
name = "quote"
version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1655,6 +1664,7 @@ dependencies = [
 "multibase",
 "nonempty",
 "once_cell",
+
 "qcheck",
 "radicle-cob",
 "radicle-crypto",
 "radicle-git-ext",
@@ -1752,7 +1762,9 @@ dependencies = [
 "amplify",
 "cyphernet",
 "ec25519",
+
 "fastrand",
 "multibase",
+
 "qcheck",
 "radicle-git-ext",
 "radicle-ssh",
 "serde",
modified Cargo.toml
@@ -53,4 +53,5 @@ tui-textarea = { version = "0.5.1", default-features = false, features = ["termi
tui-tree-widget = { version = "0.21.0" }

[dev-dependencies]
-
pretty_assertions = "^1.4.1"

\ No newline at end of file
+
pretty_assertions = "^1.4.1"
+
radicle = { version = "0.14.0", features = ["test"]}

\ No newline at end of file
modified bin/commands/patch.rs
@@ -343,17 +343,19 @@ mod interface {

        loop {
            // Reload review
+
            let signer = profile.signer()?;
            let (review_id, review) = patch::find_review(&patch, revision, &signer)
                .ok_or_else(|| anyhow!("Could not find review."))?;

            let selection = review::Tui::new(
+
                profile.storage.clone(),
+
                rid,
+
                signer,
                patch_id,
                patch.title().to_string(),
                revision.clone(),
                review.clone(),
                hunks.clone(),
-
                profile.clone(),
-
                rid,
            )
            .run()
            .await?;
@@ -377,6 +379,7 @@ mod interface {
                            let builder = CommentBuilder::new(revision.head(), path.to_path_buf());
                            let comments = builder.edit(hunk)?;

+
                            let signer = profile.signer()?;
                            patch.transaction("Review comments", &signer, |tx| {
                                for comment in comments {
                                    tx.review_comment(
modified bin/commands/patch/review.rs
@@ -16,15 +16,14 @@ use ratatui::style::Stylize;
use ratatui::text::Text;
use ratatui::{Frame, Viewport};

+
use radicle::crypto::Signer;
use radicle::identity::RepoId;
use radicle::patch::PatchId;
use radicle::patch::Review;
use radicle::patch::Revision;
use radicle::storage::ReadStorage;
use radicle::storage::WriteRepository;
-
use radicle::Profile;
-

-
use radicle_cli::terminal;
+
use radicle::Storage;

use radicle_tui as tui;

@@ -63,49 +62,53 @@ pub struct Selection {
}

pub struct Tui {
+
    pub storage: Storage,
+
    pub rid: RepoId,
+
    pub signer: Box<dyn Signer>,
    pub patch: PatchId,
    pub title: String,
    pub revision: Revision,
    pub review: Review,
    pub queue: ReviewQueue,
-
    pub profile: Profile,
-
    pub rid: RepoId,
}

impl Tui {
+
    #[allow(clippy::too_many_arguments)]
    pub fn new(
+
        storage: Storage,
+
        rid: RepoId,
+
        signer: Box<dyn Signer>,
        patch: PatchId,
        title: String,
        revision: Revision,
        review: Review,
        queue: ReviewQueue,
-
        profile: Profile,
-
        rid: RepoId,
    ) -> Self {
        Self {
+
            storage,
+
            rid,
+
            signer,
            patch,
            title,
            revision,
            review,
            queue,
-
            rid,
-
            profile,
        }
    }

-
    pub async fn run(&self) -> Result<Option<Selection>> {
+
    pub async fn run(self) -> Result<Option<Selection>> {
        let viewport = Viewport::Fullscreen;
-
        let _ = self.profile.signer()?;

        let channel = Channel::default();
        let state = App::new(
-
            self.patch,
-
            self.title.clone(),
-
            self.revision.clone(),
-
            self.review.clone(),
-
            self.queue.clone(),
-
            self.profile.clone(),
+
            self.storage,
            self.rid,
+
            self.signer,
+
            self.patch,
+
            self.title,
+
            self.revision,
+
            self.review,
+
            self.queue,
        )?;

        tui::im(state, viewport, channel).await
@@ -139,45 +142,60 @@ pub struct ReviewItemState {

#[derive(Clone)]
pub struct App<'a> {
+
    /// The nodes' storage.
+
    storage: Storage,
+
    /// The repository to operate on.
+
    rid: RepoId,
+
    /// Signer of all writes to the storage or repo.
+
    signer: Arc<Mutex<Box<dyn Signer>>>,
+
    /// Patch this review belongs to.
    patch: PatchId,
+
    /// Title of the patch this patch this review belongs to.
    title: String,
+
    /// Revision this review belongs to.
    revision: Revision,
+
    /// List of all hunks and its table widget state.
    queue: Arc<Mutex<(Vec<HunkItem<'a>>, TableState)>>,
+
    /// States of diff views for all hunks.
    items: HashMap<usize, ReviewItemState>,
-
    profile: Profile,
-
    rid: RepoId,
+
    /// Current app page.
    page: AppPage,
+
    /// State of panes widget on the main page.
    windows: GroupState,
+
    /// State of text view widget on the help page.
    help: TextViewState<'a>,
}

-
impl<'a> TryFrom<&Tui> for App<'a> {
+
impl<'a> TryFrom<Tui> for App<'a> {
    type Error = anyhow::Error;

-
    fn try_from(tui: &Tui) -> Result<Self, Self::Error> {
+
    fn try_from(tui: Tui) -> Result<Self, Self::Error> {
        App::new(
-
            tui.patch,
-
            tui.title.clone(),
-
            tui.revision.clone(),
-
            tui.review.clone(),
-
            tui.queue.clone(),
-
            tui.profile.clone(),
+
            tui.storage,
            tui.rid,
+
            tui.signer,
+
            tui.patch,
+
            tui.title,
+
            tui.revision,
+
            tui.review,
+
            tui.queue,
        )
    }
}

impl<'a> App<'a> {
+
    #[allow(clippy::too_many_arguments)]
    pub fn new(
+
        storage: Storage,
+
        rid: RepoId,
+
        signer: Box<dyn Signer>,
        patch: PatchId,
        title: String,
        revision: Revision,
        review: Review,
        queue: ReviewQueue,
-
        profile: Profile,
-
        rid: RepoId,
    ) -> Result<Self, anyhow::Error> {
-
        let repo = profile.storage.repository(rid)?;
+
        let repo = storage.repository(rid)?;
        let queue = queue
            .iter()
            .map(|item| HunkItem::from((&repo, &review, item)))
@@ -194,7 +212,8 @@ impl<'a> App<'a> {
        }

        let mut app = App {
-
            profile,
+
            storage,
+
            signer: Arc::new(Mutex::new(signer)),
            rid,
            patch,
            title,
@@ -211,14 +230,13 @@ impl<'a> App<'a> {
        Ok(app)
    }

-
    pub fn accept_current_hunk(&mut self) -> Result<()> {
-
        let repo = self.profile.storage.repository(self.rid).unwrap();
-
        let signer = terminal::signer(&self.profile)?;
-

-
        let mut brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), &signer)?;
-
        let selected = self.queue.lock().unwrap().1.selected();
+
    #[allow(clippy::borrowed_box)]
+
    pub fn accept_current_hunk(&self) -> Result<()> {
+
        let repo = self.storage.repository(self.rid).unwrap();
+
        let signer: &Box<dyn Signer> = &self.signer.lock().unwrap();

-
        if let Some(selected) = selected {
+
        if let Some(selected) = self.selected_hunk_idx() {
+
            let mut brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), signer)?;
            let hunks = self.queue.lock().unwrap().0.clone();

            if let Some(hunk) = hunks.get(selected) {
@@ -230,32 +248,29 @@ impl<'a> App<'a> {

                let diff = file.item_diff(&hunk.inner.1)?;
                brain.accept(diff, repo.raw())?;
-

-
                self.reload_states()?;
            }
        }

        Ok(())
    }

-
    pub fn discard_accepted_hunks(&mut self) -> Result<()> {
-
        let repo = self.profile.storage.repository(self.rid).unwrap();
-
        let signer = terminal::signer(&self.profile)?;
+
    #[allow(clippy::borrowed_box)]
+
    pub fn discard_accepted_hunks(&self) -> Result<()> {
+
        let repo = self.storage.repository(self.rid).unwrap();
+
        let signer: &Box<dyn Signer> = &self.signer.lock().unwrap();

-
        let mut brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), &signer)?;
+
        let mut brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), signer)?;
        brain.discard_accepted(repo.raw())?;

-
        self.reload_states()?;
-

        Ok(())
    }

+
    #[allow(clippy::borrowed_box)]
    pub fn reload_states(&mut self) -> anyhow::Result<()> {
-
        let repo = self.profile.storage.repository(self.rid).unwrap();
-
        let signer = terminal::signer(&self.profile)?;
-

-
        let brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), &signer)?;
+
        let repo = self.storage.repository(self.rid).unwrap();
+
        let signer: &Box<dyn Signer> = &self.signer.lock().unwrap();

+
        let brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), signer)?;
        let (base_diff, queue_diff) =
            DiffUtil::new(&repo).base_queue(brain.clone(), &self.revision)?;

@@ -284,6 +299,10 @@ impl<'a> App<'a> {

        Ok(())
    }
+

+
    pub fn selected_hunk_idx(&self) -> Option<usize> {
+
        self.queue.lock().unwrap().1.selected()
+
    }
}

impl<'a> App<'a> {
@@ -557,6 +576,7 @@ impl<'a> store::Update<Message<'a>> for App<'a> {
                    Ok(()) => log::info!("Accepted hunk."),
                    Err(err) => log::info!("An error occured while accepting hunk: {}", err),
                }
+
                let _ = self.reload_states();
                None
            }
            Message::Discard => {
@@ -564,6 +584,7 @@ impl<'a> store::Update<Message<'a>> for App<'a> {
                    Ok(()) => log::info!("Discarded all hunks."),
                    Err(err) => log::info!("An error occured while discarding hunks: {}", err),
                }
+
                let _ = self.reload_states();
                None
            }
            Message::ShowMain => {
@@ -611,3 +632,131 @@ review is done, it needs to be finalized via `rad patch review --accept | --reje
`d`         discard accepted hunks (reject all)"#
        .into()
}
+

+
#[cfg(test)]
+
mod test {
+
    use anyhow::*;
+

+
    use radicle::patch::Cache;
+

+
    use store::Update;
+

+
    use super::*;
+
    use crate::test;
+

+
    impl<'a> App<'a> {
+
        pub fn hunks(&self) -> Vec<HunkItem> {
+
            self.queue.lock().unwrap().0.clone()
+
        }
+
    }
+

+
    mod fixtures {
+
        use anyhow::*;
+

+
        use radicle::cob::cache::NoCache;
+
        use radicle::patch::{Cache, PatchMut, Review, ReviewId, Revision, Verdict};
+
        use radicle::prelude::Signer;
+
        use radicle::storage::git::cob::DraftStore;
+
        use radicle::storage::git::Repository;
+
        use radicle::storage::WriteRepository;
+
        use radicle::test::setup::NodeWithRepo;
+

+
        use crate::cob::patch;
+

+
        use super::builder::{Brain, ReviewBuilder};
+
        use super::App;
+

+
        pub fn app<'a>(
+
            node: &NodeWithRepo,
+
            patch: PatchMut<Repository, NoCache>,
+
        ) -> Result<App<'a>> {
+
            let draft_store = DraftStore::new(&node.repo.repo, *node.signer.public_key());
+
            let mut drafts = Cache::no_cache(&draft_store)?;
+
            let mut draft = drafts.get_mut(&patch.id())?;
+

+
            let (_, revision) = patch.latest();
+
            let (_, review) = draft_review(&node, &mut draft, revision)?;
+

+
            let brain = Brain::load_or_new(*patch.id(), revision, node.repo.raw(), &node.signer)?;
+
            let hunks = ReviewBuilder::new(&node.repo).hunks(&brain, revision)?;
+

+
            App::new(
+
                node.storage.clone(),
+
                node.repo.id,
+
                Box::new(node.signer.clone()),
+
                *patch.id(),
+
                patch.title().to_string(),
+
                revision.clone(),
+
                review.clone(),
+
                hunks,
+
            )
+
        }
+

+
        pub fn draft_review<'a>(
+
            node: &NodeWithRepo,
+
            draft: &'a mut PatchMut<DraftStore<Repository>, NoCache>,
+
            revision: &Revision,
+
        ) -> Result<(ReviewId, &'a Review)> {
+
            let id = draft.review(
+
                revision.id(),
+
                Some(Verdict::Reject),
+
                None,
+
                vec![],
+
                &node.node.signer,
+
            )?;
+

+
            let (_, review) = patch::find_review(draft, revision, &node.node.signer)
+
                .ok_or_else(|| anyhow!("Could not find review."))?;
+

+
            Ok((id, review))
+
        }
+
    }
+

+
    #[test]
+
    fn app_can_be_constructed() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let app = fixtures::app(&alice, patch)?;
+

+
        assert_eq!(app.hunks().len(), 2);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn first_hunk_is_selected_by_default() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let app = fixtures::app(&alice, patch)?;
+

+
        assert_eq!(app.selected_hunk_idx(), Some(0));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn hunk_can_be_selected() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let mut app = fixtures::app(&alice, patch)?;
+
        app.update(Message::ItemChanged {
+
            state: TableState::new(Some(1)),
+
        });
+

+
        assert_eq!(app.selected_hunk_idx(), Some(1));
+

+
        Ok(())
+
    }
+
}
modified bin/main.rs
@@ -3,6 +3,8 @@ mod commands;
mod git;
mod log;
mod settings;
+
#[cfg(test)]
+
mod test;
mod ui;

use std::ffi::OsString;
added bin/test.rs
@@ -0,0 +1,35 @@
+
pub mod fixtures {
+
    use anyhow::Result;
+

+
    use radicle::cob::cache::NoCache;
+
    use radicle::patch::{Cache, MergeTarget, PatchMut, Patches};
+
    use radicle::storage::git::Repository;
+
    use radicle::test::setup::{BranchWith, NodeWithRepo};
+

+
    pub fn node_with_repo() -> NodeWithRepo {
+
        NodeWithRepo::default()
+
    }
+

+
    pub fn branch(node: &NodeWithRepo) -> BranchWith {
+
        let checkout = node.repo.checkout();
+
        checkout.branch_with([("README", b"Hello World!")])
+
    }
+

+
    pub fn patch<'a, 'g>(
+
        node: &'a NodeWithRepo,
+
        branch: &BranchWith,
+
        patches: &'a mut Cache<Patches<'a, Repository>, NoCache>,
+
    ) -> Result<PatchMut<'a, 'g, Repository, NoCache>> {
+
        let patch = patches.create(
+
            "My first patch",
+
            "Blah blah blah.",
+
            MergeTarget::Delegates,
+
            branch.base,
+
            branch.oid,
+
            &[],
+
            &node.signer,
+
        )?;
+

+
        Ok(patch)
+
    }
+
}