diff --git a/radicle-cli/examples/rad-issue.md b/radicle-cli/examples/rad-issue.md index 918c8f19..39c14975 100644 --- a/radicle-cli/examples/rad-issue.md +++ b/radicle-cli/examples/rad-issue.md @@ -11,7 +11,7 @@ The issue is now listed under our project. ``` $ rad issue list -17e52fd6b2cd2e0f9317e18371dc978a691ed1e3 "flux capacitor underpowered" +2b4650e3c66d568132034de0d02871a2fbf9c5b5 "flux capacitor underpowered" ``` Great! Now we've documented the issue for ourselves and others. @@ -22,20 +22,20 @@ others to work on. This is to ensure work is not duplicated. Let's assign ourselves to this one. ``` -$ rad assign 17e52fd6b2cd2e0f9317e18371dc978a691ed1e3 z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi +$ rad assign 2b4650e3c66d568132034de0d02871a2fbf9c5b5 z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi ``` It will now show in the list of issues assigned to us. ``` $ rad issue list --assigned -17e52fd6b2cd2e0f9317e18371dc978a691ed1e3 "flux capacitor underpowered" z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi +2b4650e3c66d568132034de0d02871a2fbf9c5b5 "flux capacitor underpowered" z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi ``` Note: this can always be undone with the `unassign` subcommand. ``` -$ rad unassign 17e52fd6b2cd2e0f9317e18371dc978a691ed1e3 z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi +$ rad unassign 2b4650e3c66d568132034de0d02871a2fbf9c5b5 z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi ``` Great, now we have communicated to the world about our car's defect. @@ -44,8 +44,8 @@ But wait! We've found an important detail about the car's power requirements. It will help whoever works on a fix. ``` -$ rad comment 17e52fd6b2cd2e0f9317e18371dc978a691ed1e3 --message 'The flux capacitor needs 1.21 Gigawatts' -z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 -$ rad comment 17e52fd6b2cd2e0f9317e18371dc978a691ed1e3 --reply-to z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --message 'More power!' +$ rad comment 2b4650e3c66d568132034de0d02871a2fbf9c5b5 --message 'The flux capacitor needs 1.21 Gigawatts' z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/7 +$ rad comment 2b4650e3c66d568132034de0d02871a2fbf9c5b5 --reply-to z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/7 --message 'More power!' +z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/8 ``` diff --git a/radicle-cli/src/commands/issue.rs b/radicle-cli/src/commands/issue.rs index 57e89e1b..d8ea4875 100644 --- a/radicle-cli/src/commands/issue.rs +++ b/radicle-cli/src/commands/issue.rs @@ -38,6 +38,7 @@ Options pub struct Metadata { title: String, labels: Vec, + assignees: Vec, } #[derive(Default, Debug, PartialEq, Eq)] @@ -205,7 +206,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { title: Some(title), description: Some(description), } => { - issues.create(title, description, &[], &signer)?; + issues.create(title, description, &[], &[], &signer)?; } Operation::Show { id } => { let issue = issues @@ -227,6 +228,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { let meta = Metadata { title: title.unwrap_or("Enter a title".to_owned()), labels: vec![], + assignees: vec![], }; let yaml = serde_yaml::to_string(&meta)?; let doc = format!( @@ -263,6 +265,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { &meta.title, description.trim(), meta.labels.as_slice(), + meta.assignees.as_slice(), &signer, )?; } diff --git a/radicle-httpd/src/api/test.rs b/radicle-httpd/src/api/test.rs index 39eb7d61..06e29df1 100644 --- a/radicle-httpd/src/api/test.rs +++ b/radicle-httpd/src/api/test.rs @@ -23,7 +23,7 @@ use crate::api::{auth, Context}; pub const HEAD: &str = "1e978d19f251cd9821d9d9a76d1bd436bf0690d5"; pub const HEAD_1: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7"; pub const PATCH_ID: &str = "4250f0117659ee4de9af99e699a63395cd6ffa1c"; -pub const ISSUE_ID: &str = "d8131af9738258fac139c4c96b71c02411f94892"; +pub const ISSUE_ID: &str = "8adca8aad2a2cb99b9847d20193930cde2042a57"; const PASSWORD: &str = "radicle"; @@ -113,6 +113,7 @@ pub fn seed(dir: &Path) -> Context { "Issue #1".to_string(), "Change 'hello world' to 'hello everyone'".to_string(), &[], + &[], &signer, ) .unwrap(); diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index 06522960..3e7cbd6b 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/radicle-httpd/src/api/v1/projects.rs @@ -14,7 +14,7 @@ use tower_http::set_header::SetResponseHeaderLayer; use radicle::cob::issue::{Action, Issues}; use radicle::cob::patch::Patches; -use radicle::cob::{thread, Tag}; +use radicle::cob::{thread, ActorId, Tag}; use radicle::identity::Id; use radicle::node::NodeId; use radicle::storage::{git::paths, ReadRepository, WriteStorage}; @@ -404,6 +404,7 @@ pub struct IssueCreate { pub title: String, pub description: String, pub tags: Vec, + pub assignees: Vec, } /// Create a new issue. @@ -424,7 +425,13 @@ async fn issue_create_handler( let repo = storage.repository(project)?; let mut issues = Issues::open(ctx.profile.public_key, &repo)?; let issue = issues - .create(issue.title, issue.description, &issue.tags, &signer) + .create( + issue.title, + issue.description, + &issue.tags, + &issue.assignees, + &signer, + ) .map_err(Error::from)?; Ok::<_, Error>(( @@ -551,7 +558,7 @@ mod routes { use crate::api::test::{self, get, patch, post, HEAD, HEAD_1, ISSUE_ID, PATCH_ID}; - const CREATED_ISSUE_ID: &str = "768c6735912a34856552ae6a9ca77d728962bf31"; + const CREATED_ISSUE_ID: &str = "b56febfba1e7dd20f4aea43ca2fe9dcf1fd448ba"; #[tokio::test] async fn test_projects_root() { @@ -1041,6 +1048,7 @@ mod routes { "title": "Issue #2", "description": "Change 'hello world' to 'hello everyone'", "tags": ["bug"], + "assignees": [], })) .unwrap(); let response = post( @@ -1147,7 +1155,7 @@ mod routes { "replyTo": null, }, { - "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4", + "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/5", "author": { "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi", }, @@ -1217,7 +1225,7 @@ mod routes { "replyTo": null, }, { - "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4", + "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/5", "author": { "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi", }, diff --git a/radicle/src/cob/issue.rs b/radicle/src/cob/issue.rs index 57f137ca..cd0741ae 100644 --- a/radicle/src/cob/issue.rs +++ b/radicle/src/cob/issue.rs @@ -193,7 +193,11 @@ impl Deref for Issue { } impl store::Transaction { - pub fn assign(&mut self, add: Vec, remove: Vec) -> OpId { + pub fn assign( + &mut self, + add: impl IntoIterator, + remove: impl IntoIterator, + ) -> OpId { let add = add.into_iter().collect::>(); let remove = remove.into_iter().collect::>(); @@ -273,10 +277,10 @@ impl<'a, 'g> IssueMut<'a, 'g> { /// Assign one or more actors to an issue. pub fn assign( &mut self, - assignees: Vec, + assignees: impl IntoIterator, signer: &G, ) -> Result { - self.transaction("Assign", signer, |tx| tx.assign(assignees, vec![])) + self.transaction("Assign", signer, |tx| tx.assign(assignees, [])) } /// Set the issue title. @@ -332,10 +336,10 @@ impl<'a, 'g> IssueMut<'a, 'g> { /// Unassign one or more actors from an issue. pub fn unassign( &mut self, - assignees: Vec, + assignees: impl IntoIterator, signer: &G, ) -> Result { - self.transaction("Unassign", signer, |tx| tx.assign(vec![], assignees)) + self.transaction("Unassign", signer, |tx| tx.assign([], assignees)) } pub fn transaction( @@ -416,16 +420,18 @@ impl<'a> Issues<'a> { title: impl ToString, description: impl ToString, tags: &[Tag], + assignees: &[ActorId], signer: &G, ) -> Result, Error> { let (id, issue, clock) = Transaction::initial("Create issue", &mut self.raw, signer, |tx| { tx.thread(description); + tx.assign(assignees.to_owned(), []); tx.edit(title); tx.tag(tags.to_owned(), []); })?; // Just a sanity check that our clock is advancing as expected. - debug_assert_eq!(clock.get(), 3); + debug_assert_eq!(clock.get(), 4); Ok(IssueMut { id, @@ -498,15 +504,30 @@ mod test { let assignee: ActorId = arbitrary::gen(1); let assignee_two: ActorId = arbitrary::gen(1); - let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &signer) + let issue = issues + .create( + "My first issue", + "Blah blah blah.", + &[], + &[assignee], + &signer, + ) .unwrap(); - issue.assign(vec![assignee, assignee_two], &signer).unwrap(); - let id = issue.id; let issue = issues.get(&id).unwrap().unwrap(); let assignees: Vec<_> = issue.assigned().cloned().collect::>(); + + assert_eq!(1, assignees.len()); + assert!(assignees.contains(&assignee)); + + let mut issue = issues.get_mut(&id).unwrap(); + issue.assign([assignee_two], &signer).unwrap(); + + let id = issue.id; + let issue = issues.get(&id).unwrap().unwrap(); + let assignees: Vec<_> = issue.assigned().cloned().collect::>(); + assert_eq!(2, assignees.len()); assert!(assignees.contains(&assignee)); assert!(assignees.contains(&assignee_two)); @@ -519,18 +540,27 @@ mod test { let mut issues = Issues::open(*signer.public_key(), &project).unwrap(); let assignee: ActorId = arbitrary::gen(1); + let assignee_two: ActorId = arbitrary::gen(1); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[assignee], + &signer, + ) .unwrap(); - issue.assign(vec![assignee], &signer).unwrap(); - issue.assign(vec![assignee], &signer).unwrap(); + issue.assign([assignee_two], &signer).unwrap(); + issue.assign([assignee_two], &signer).unwrap(); let id = issue.id; let issue = issues.get(&id).unwrap().unwrap(); let assignees: Vec<_> = issue.assigned().cloned().collect::>(); - assert_eq!(1, assignees.len()); + + assert_eq!(2, assignees.len()); assert!(assignees.contains(&assignee)); + assert!(assignees.contains(&assignee_two)); } #[test] @@ -539,9 +569,10 @@ mod test { let (_, signer, project) = test::setup::context(&tmp); let mut issues = Issues::open(*signer.public_key(), &project).unwrap(); let created = issues - .create("My first issue", "Blah blah blah.", &[], &signer) + .create("My first issue", "Blah blah blah.", &[], &[], &signer) .unwrap(); - assert_eq!(created.clock().get(), 3); + + assert_eq!(created.clock().get(), 4); let (id, created) = (created.id, created.issue); let issue = issues.get(&id).unwrap().unwrap(); @@ -560,7 +591,7 @@ mod test { let (_, signer, project) = test::setup::context(&tmp); let mut issues = Issues::open(*signer.public_key(), &project).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &signer) + .create("My first issue", "Blah blah blah.", &[], &[], &signer) .unwrap(); issue @@ -574,6 +605,7 @@ mod test { let id = issue.id; let mut issue = issues.get_mut(&id).unwrap(); + assert_eq!( *issue.state(), State::Closed { @@ -583,6 +615,7 @@ mod test { issue.lifecycle(State::Open, &signer).unwrap(); let issue = issues.get(&id).unwrap().unwrap(); + assert_eq!(*issue.state(), State::Open); } @@ -595,15 +628,21 @@ mod test { let assignee: ActorId = arbitrary::gen(1); let assignee_two: ActorId = arbitrary::gen(1); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[assignee, assignee_two], + &signer, + ) .unwrap(); - issue.assign(vec![assignee, assignee_two], &signer).unwrap(); - issue.unassign(vec![assignee], &signer).unwrap(); + issue.unassign([assignee], &signer).unwrap(); let id = issue.id; let issue = issues.get(&id).unwrap().unwrap(); let assignees: Vec<_> = issue.assigned().cloned().collect::>(); + assert_eq!(1, assignees.len()); assert!(assignees.contains(&assignee_two)); } @@ -614,7 +653,7 @@ mod test { let (_, signer, project) = test::setup::context(&tmp); let mut issues = Issues::open(*signer.public_key(), &project).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &signer) + .create("My first issue", "Blah blah blah.", &[], &[], &signer) .unwrap(); issue.edit("Sorry typo", &signer).unwrap(); @@ -632,7 +671,7 @@ mod test { let (_, signer, project) = test::setup::context(&tmp); let mut issues = Issues::open(*signer.public_key(), &project).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &signer) + .create("My first issue", "Blah blah blah.", &[], &[], &signer) .unwrap(); let comment = OpId::initial(*signer.public_key()); @@ -655,7 +694,7 @@ mod test { let author = *signer.public_key(); let mut issues = Issues::open(*signer.public_key(), &project).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &signer) + .create("My first issue", "Blah blah blah.", &[], &[], &signer) .unwrap(); let root = OpId::root(author); @@ -676,6 +715,7 @@ mod test { issue.comment("Re: Ha. Ha. Ha.", c2, &signer).unwrap(); let issue = issues.get(&id).unwrap().unwrap(); + assert_eq!(issue.replies(&c1).nth(0).unwrap().1.body(), "Re: Hi."); assert_eq!(issue.replies(&c2).nth(0).unwrap().1.body(), "Re: Ha."); assert_eq!(issue.replies(&c2).nth(1).unwrap().1.body(), "Re: Ha. Ha."); @@ -690,12 +730,18 @@ mod test { let tmp = tempfile::tempdir().unwrap(); let (_, signer, project) = test::setup::context(&tmp); let mut issues = Issues::open(*signer.public_key(), &project).unwrap(); - let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &signer) - .unwrap(); - let bug_tag = Tag::new("bug").unwrap(); + let ux_tag = Tag::new("ux").unwrap(); let wontfix_tag = Tag::new("wontfix").unwrap(); + let mut issue = issues + .create( + "My first issue", + "Blah blah blah.", + &[ux_tag.clone()], + &[], + &signer, + ) + .unwrap(); issue.tag([bug_tag.clone()], [], &signer).unwrap(); issue.tag([wontfix_tag.clone()], [], &signer).unwrap(); @@ -704,6 +750,7 @@ mod test { let issue = issues.get(&id).unwrap().unwrap(); let tags = issue.tags().cloned().collect::>(); + assert!(tags.contains(&ux_tag)); assert!(tags.contains(&bug_tag)); assert!(tags.contains(&wontfix_tag)); } @@ -715,7 +762,7 @@ mod test { let author = *signer.public_key(); let mut issues = Issues::open(*signer.public_key(), &project).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &signer) + .create("My first issue", "Blah blah blah.", &[], &[], &signer) .unwrap(); // The root thread op id is always the same. @@ -760,9 +807,9 @@ mod test { let (_, signer, project) = test::setup::context(&tmp); let mut issues = Issues::open(*signer.public_key(), &project).unwrap(); - issues.create("First", "Blah", &[], &signer).unwrap(); - issues.create("Second", "Blah", &[], &signer).unwrap(); - issues.create("Third", "Blah", &[], &signer).unwrap(); + issues.create("First", "Blah", &[], &[], &signer).unwrap(); + issues.create("Second", "Blah", &[], &[], &signer).unwrap(); + issues.create("Third", "Blah", &[], &[], &signer).unwrap(); let issues = issues .all()