#![doc = include_str!("../README.md")] use std::fmt::{Display, Formatter}; use std::os::unix::fs::MetadataExt; use std::str::FromStr; /////////////////////////////////////////////// Error ////////////////////////////////////////////// /// The different kinds of error that can be generated by onboard. #[derive(Debug)] pub enum Error { /// A std library I/O error. IO(std::io::Error), /// An error parsing a UTF-8 string. Utf8Error(std::str::Utf8Error), /// The objective is missing a body. /// /// Correct this error by adding a blank line between headers and the body. MissingBody, /// The provided header is not interpretable by the system. UnknownHeader(String), /// The provided header was specified multiple times. DuplicateHeader(String), /// The provided title is invalid. It must be non-empty after calling `trim()`. InvalidTitle(String), /// The provided owner is not a valid github username. InvalidOwner(String), /// The provided accountable party is not a valid github username. InvalidAccountable(String), /// The provided status is not one of the valid statuses. /// /// Valid statuses include: proposed, planned, in-progress, in-review, completed. InvalidStatus(String), /// The provided size is not one of the valid sizes. /// /// Valid sizes include: XXXL, XXL, XL, L, M, S, XS. /// /// See also: [TShirtSize](enum.TShirtSize.html) InvalidSize(String), /// The provided priority is not a valid number. InvalidPriority(String), /// The provided extension is not a valid extension. InvalidExtension(String), /// An objective points at a parent that does not exist. MissingParent(String), } impl From for Error { fn from(e: std::io::Error) -> Self { Error::IO(e) } } impl From for Error { fn from(e: std::str::Utf8Error) -> Self { Error::Utf8Error(e) } } ////////////////////////////////////////////// Status ////////////////////////////////////////////// /// The different statuses an objective can have. #[derive(Debug, Eq, PartialEq)] pub enum Status { /// The objective has been proposed, but no action has been taken on it. Proposed, /// The objective is planned, so it has a time frame, but no action has been taken on it. Planned, /// The objective is actively in-progress. InProgress, /// The objective is complete and pending whatever review is appropriate. InReview, /// The objective is complete. Completed, } impl FromStr for Status { type Err = Error; fn from_str(s: &str) -> Result { match s { "proposed" => Ok(Status::Proposed), "planned" => Ok(Status::Planned), "in-progress" => Ok(Status::InProgress), "in-review" => Ok(Status::InReview), "completed" => Ok(Status::Completed), _ => Err(Error::InvalidStatus(s.to_string())), } } } impl Display for Status { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { match self { Status::Proposed => write!(f, "proposed"), Status::Planned => write!(f, "planned"), Status::InProgress => write!(f, "in-progress"), Status::InReview => write!(f, "in-review"), Status::Completed => write!(f, "completed"), } } } //////////////////////////////////////////// TShirtSize //////////////////////////////////////////// /// A rough estimate of the amount of work an objective will take if performed by a single IC. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TShirtSize { /// An unbounded amount of work. XXXL, /// At most one half of work. XXL, /// At most one quarter of work. XL, /// At most one eighth of work. L, /// At most one week of work. M, /// At most one day of work. S, /// Trivial, but more than filing a task. XS, } impl TShirtSize { pub fn cost_in_days(&self) -> usize { match self { TShirtSize::XXXL => usize::MAX, TShirtSize::XXL => 6 * 28, TShirtSize::XL => 3 * 28, TShirtSize::L => 3 * 14, TShirtSize::M => 7, TShirtSize::S => 1, TShirtSize::XS => 0, } } pub fn cannot_contain(&self, sub_tasks: &[TShirtSize]) -> bool { self.cost_in_days() < sub_tasks.iter().map(|t| t.cost_in_days()).sum() } } impl FromStr for TShirtSize { type Err = Error; fn from_str(s: &str) -> Result { match s { "XXXL" => Ok(TShirtSize::XXXL), "XXL" => Ok(TShirtSize::XXL), "XL" => Ok(TShirtSize::XL), "L" => Ok(TShirtSize::L), "M" => Ok(TShirtSize::M), "S" => Ok(TShirtSize::S), "XS" => Ok(TShirtSize::XS), _ => Err(Error::InvalidSize(s.to_string())), } } } impl Display for TShirtSize { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { match self { TShirtSize::XXXL => write!(f, "XXXL"), TShirtSize::XXL => write!(f, "XXL"), TShirtSize::XL => write!(f, "XL"), TShirtSize::L => write!(f, "L"), TShirtSize::M => write!(f, "M"), TShirtSize::S => write!(f, "S"), TShirtSize::XS => write!(f, "XS"), } } } ////////////////////////////////////////////// Header ////////////////////////////////////////////// /// A valid, typed, header for an objective. #[derive(Debug, Eq, PartialEq)] pub enum Header { /// The title of the objective. Title(String), /// The owner of the objective. Owner(String), /// The accountable party for the objective. Accountable(String), /// The status of the objective. Status(Status), /// The size of the objective. Size(TShirtSize), /// The priority of the objective. Priority(u64), /// The parent of the objective relative to the current objective. Parent(utf8path::Path<'static>), /// A custom header matching with x-gh--
. This does mean that the /// language of github usernames comprising your team must be left-recursive. Custom(String, String), } impl Header { fn key(&self) -> &str { match self { Header::Title(_) => "title", Header::Owner(_) => "owner", Header::Accountable(_) => "accountable", Header::Status(_) => "status", Header::Size(_) => "size", Header::Priority(_) => "priority", Header::Parent(_) => "parent", Header::Custom(key, _) => key, } } } impl FromStr for Header { type Err = Error; fn from_str(s: &str) -> Result { let (header, value) = s.split_once(": ").unwrap(); let value = value.trim(); match header { "title" => { if value.is_empty() { Err(Error::InvalidTitle(value.to_string())) } else { Ok(Header::Title(value.to_string())) } } "owner" => { if value.is_empty() || !is_github_username(value) { Err(Error::InvalidOwner(value.to_string())) } else { Ok(Header::Owner(value.to_string())) } } "accountable" => { if !is_github_username(value) { Err(Error::InvalidAccountable(value.to_string())) } else { Ok(Header::Accountable(value.to_string())) } } "status" => { let status = value.parse::()?; Ok(Header::Status(status)) } "size" => { let size = value.parse::()?; Ok(Header::Size(size)) } "priority" => { let priority = value .parse::() .map_err(|_| Error::InvalidPriority(value.to_string()))?; Ok(Header::Priority(priority)) } "parent" => Ok(Header::Parent(utf8path::Path::from(value).into_owned())), _ => { if header.starts_with("x-gh-") && is_github_username(header.chars().skip(5).collect::()) { Ok(Header::Custom(header.to_string(), value.to_string())) } else { Err(Error::InvalidExtension(header.to_string())) } } } } } impl Display for Header { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { match self { Header::Title(title) => write!(f, "title: {}", title.trim()), Header::Owner(owner) => write!(f, "owner: {}", owner.trim()), Header::Accountable(accountable) => write!(f, "accountable: {}", accountable.trim()), Header::Status(status) => write!(f, "status: {}", status), Header::Size(size) => write!(f, "size: {}", size), Header::Priority(priority) => write!(f, "priority: {}", priority), Header::Parent(parent) => write!(f, "parent: {}", parent), Header::Custom(header, value) => write!(f, "{}: {}", header.trim(), value.trim()), } } } ///////////////////////////////////////////// Objective //////////////////////////////////////////// /// An objective is a high level, low level, or other goal. #[derive(Debug, Eq, PartialEq)] pub struct Objective { pub headers: Vec
, pub body: String, } impl Objective { /// The parent of the objective relative to the current objective. pub fn parent(&self) -> Option> { self.headers.iter().find_map(|header| match header { Header::Parent(parent) => Some(parent.clone()), _ => None, }) } /// The owner of the objective. pub fn owner(&self) -> Option { self.headers.iter().find_map(|header| match header { Header::Owner(owner) => Some(owner.clone()), _ => None, }) } /// The accountable party for the objective. pub fn accountable(&self) -> Option { self.headers.iter().find_map(|header| match header { Header::Accountable(accountable) => Some(accountable.clone()), _ => None, }) } /// The size of the objective. pub fn size(&self) -> TShirtSize { self.headers .iter() .find_map(|header| match header { Header::Size(size) => Some(*size), _ => None, }) .unwrap_or(TShirtSize::XXXL) } /// The priority of the objective. pub fn priority(&self) -> u64 { self.headers .iter() .find_map(|header| match header { Header::Priority(priority) => Some(*priority), _ => None, }) .unwrap_or(0) } } impl FromStr for Objective { type Err = Error; fn from_str(s: &str) -> Result { if let Some((headers, body)) = s.split_once("\n\n") { let headers = headers .split('\n') .map(|s| s.parse::
()) .collect::, Error>>()?; for (idx1, h1) in headers.iter().enumerate() { for (idx2, h2) in headers.iter().enumerate() { if idx1 != idx2 && h1.key() == h2.key() { return Err(Error::DuplicateHeader(h1.key().to_string())); } } } Ok(Objective { headers, body: body.to_string(), }) } else { Err(Error::MissingBody) } } } impl Display for Objective { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { for header in self.headers.iter() { writeln!(f, "{}", header)?; } write!(f, "\n{}\n", self.body.trim()) } } ////////////////////////////////////////// ObjectiveGraph ////////////////////////////////////////// /// An ObjectiveGraph is a collection of objectives and their relationships. #[derive(Debug, Default, Eq, PartialEq)] pub struct ObjectiveGraph { objectives: Vec<(utf8path::Path<'static>, Objective)>, parent_to_child: Vec<(usize, usize)>, child_to_parent: Vec<(usize, usize)>, } impl ObjectiveGraph { /// Load an ObjectiveGraph from a root directory. pub fn load<'a>(root: impl Into>) -> Result { let root = root.into().into_owned(); let objectives = Self::load_recursive(root)?; let mut parent_to_child = vec![]; let mut child_to_parent = vec![]; for (child_idx, child) in objectives.iter().enumerate() { for (parent_idx, parent) in objectives.iter().enumerate() { let Some(child_parent) = child.1.parent() else { continue; }; fn is_same_file( lhs: utf8path::Path<'_>, rhs: utf8path::Path<'_>, ) -> Result { let lhs = lhs.into_std().metadata()?; let rhs = rhs.into_std().metadata()?; Ok(lhs.dev() == rhs.dev() && lhs.ino() == rhs.ino()) } if is_same_file(child_parent, parent.0.clone())? { parent_to_child.push((parent_idx, child_idx)); child_to_parent.push((child_idx, parent_idx)); } } } Ok(Self { objectives, parent_to_child, child_to_parent, }) } /// Merge another ObjectiveGraph into this one. pub fn merge(&mut self, other: Self) { let offset = self.objectives.len(); self.objectives.extend(other.objectives); self.parent_to_child.extend( other .parent_to_child .into_iter() .map(|(a, b)| (a + offset, b + offset)), ); self.child_to_parent.extend( other .child_to_parent .into_iter() .map(|(a, b)| (a + offset, b + offset)), ); } /// Report any lints found in the ObjectiveGraph. pub fn report(&mut self) -> Result, Error> { let mut lint = vec![]; // First rule: The objective graph must be a directed acyclic graph. for (child_idx, _) in self.parent_to_child.iter() { let mut visited = vec![false; self.objectives.len()]; let mut stack = vec![child_idx]; while let Some(idx) = stack.pop().copied() { if visited[idx] { lint.push(( Lint::GraphNotADag(self.objectives[idx].0.clone()), "cycle detected", )); return Ok(lint); } visited[idx] = true; for (_, child) in self .parent_to_child .iter() .filter(|(parent, _)| *parent == idx) { stack.push(child); } } } // Second rule: Every objective must have an owner or accountable party, but not both. // If the objective has an accountable party, it must have a parent // objective. for (path, objective) in self.objectives.iter() { if objective.owner().is_none() && objective.accountable().is_none() { lint.push(( Lint::InvalidOwnership(path.clone()), "neither owner nor accountable were set", )); continue; } if objective.owner().is_some() && objective.accountable().is_some() { lint.push(( Lint::InvalidOwnership(path.clone()), "both owner and accountable were set", )); continue; } if objective.accountable().is_some() && objective.parent().is_none() { lint.push(( Lint::InvalidOwnership(path.clone()), "no parent was set for accountable", )); continue; } if objective.owner().is_some() && objective.parent().is_some() { // SAFETY(rescrv): objective.owner.is_some() implies objective.owner.unwrap() // succeeds. lint.push((Lint::InvalidOwnership(path.clone()), "owner with parent")); continue; } } // Third rule: No person has assigned to them a sum total of child work for a parent that // exceeds the size of the parent. for (parent_idx, (path, parent)) in self.objectives.iter().enumerate() { let children = self .parent_to_child .iter() .filter(|(parent, _)| *parent == parent_idx) .map(|(_, child_idx)| &self.objectives[*child_idx].1) .collect::>(); let mut grouped_by_owner = children .iter() .map(|child| (child.owner().or(child.accountable()).unwrap(), child)) .collect::>(); grouped_by_owner.sort_by_key(|(owner, _)| owner.clone()); let uniq_owners = grouped_by_owner .iter() .map(|(owner, _)| owner) .collect::>(); let mut uniq_owners = uniq_owners.iter().collect::>(); uniq_owners.sort(); for owner in uniq_owners { let shirts = grouped_by_owner .iter() .filter(|(o, _)| o == *owner) .map(|(_, child)| child) .map(|child| child.size()) .collect::>(); if parent.size().cannot_contain(&shirts) { lint.push(( Lint::TooMuchWork(path.clone(), owner.to_string()), "too much work", )); } } } // Fourth rule: No objective has a priority with higher number/lower priority than its // parent. for (parent_idx, (parent_path, parent)) in self.objectives.iter().enumerate() { let children = self .parent_to_child .iter() .filter(|(parent, _)| *parent == parent_idx) .map(|(_, child_idx)| &self.objectives[*child_idx]) .collect::>(); for (child_path, child) in children { if parent.priority() < child.priority() { lint.push(( Lint::HigherPriority(parent_path.clone(), child_path.clone()), "child has higher-number/lower-priority than its parent", )); } } } Ok(lint) } fn load_recursive(root: utf8path::Path) -> Result, Error> { let mut objectives = vec![]; for entry in std::fs::read_dir(root.clone())? { let entry = entry.unwrap(); let path = utf8path::Path::try_from(entry.path())?; if path.clone().into_std().is_file() { let contents = std::fs::read_to_string(&path)?; let mut objective = contents.parse::()?; for header in objective.headers.iter_mut() { if let Header::Parent(parent) = header { *parent = path.dirname().join(parent.clone()).into_owned(); if !path.clone().into_std().is_file() { return Err(Error::MissingParent(parent.to_string())); } } } objectives.push((path, objective)); } else if path.clone().into_std().is_dir() { objectives.extend(Self::load_recursive(path)?); } } Ok(objectives) } } /////////////////////////////////////////////// Lint /////////////////////////////////////////////// /// The different kinds of lint that can be detected by onboard. #[derive(Clone, Debug, Eq, PartialEq)] pub enum Lint { /// The objective graph is not a directed acyclic graph. GraphNotADag(utf8path::Path<'static>), /// An objective has neither an owner nor an accountable party. InvalidOwnership(utf8path::Path<'static>), /// An objective has too much work assigned to a single person. TooMuchWork(utf8path::Path<'static>, String), /// An objective has a higher-number/lower-priority than its parent. HigherPriority(utf8path::Path<'static>, utf8path::Path<'static>), } //////////////////////////////////////// is_github_username //////////////////////////////////////// /// Returns true if the given string is a valid GitHub username. pub fn is_github_username(s: impl AsRef) -> bool { let s = s.as_ref(); fn consecutive_underscore(s: &str) -> bool { s.chars().any(|c| c == '_') && s.chars() .zip(s.chars().skip(1)) .any(|(a, b)| a == '_' && b == '_') } s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') && !s.starts_with('-') && !s.ends_with('-') && !consecutive_underscore(s) } /////////////////////////////////////////////// tests ////////////////////////////////////////////// #[cfg(test)] mod tests { use super::*; #[test] fn status() { assert_eq!(Status::Proposed, "proposed".parse().unwrap()); assert_eq!(Status::Planned, "planned".parse().unwrap()); assert_eq!(Status::InProgress, "in-progress".parse().unwrap()); assert_eq!(Status::InReview, "in-review".parse().unwrap()); assert_eq!(Status::Completed, "completed".parse().unwrap()); } #[test] fn tshirt_size() { assert_eq!(TShirtSize::XXXL, "XXXL".parse().unwrap()); assert_eq!(TShirtSize::XXL, "XXL".parse().unwrap()); assert_eq!(TShirtSize::XL, "XL".parse().unwrap()); assert_eq!(TShirtSize::L, "L".parse().unwrap()); assert_eq!(TShirtSize::M, "M".parse().unwrap()); assert_eq!(TShirtSize::S, "S".parse().unwrap()); assert_eq!(TShirtSize::XS, "XS".parse().unwrap()); } #[test] fn header() { assert_eq!( Header::Title("foo".to_string()), "title: foo".parse().unwrap() ); assert_eq!( Header::Owner("foo".to_string()), "owner: foo".parse().unwrap() ); assert_eq!( Header::Accountable("foo".to_string()), "accountable: foo".parse().unwrap() ); assert_eq!( Header::Status(Status::Proposed), "status: proposed".parse().unwrap() ); assert_eq!( Header::Size(TShirtSize::XXXL), "size: XXXL".parse().unwrap() ); assert_eq!(Header::Priority(42), "priority: 42".parse().unwrap()); assert_eq!( Header::Custom("x-gh-foo".to_string(), "bar".to_string()), "x-gh-foo: bar".parse().unwrap() ); } #[test] fn objective() { assert_eq!( Objective { headers: vec![ Header::Title("foo".to_string()), Header::Owner("bar".to_string()), Header::Accountable("baz".to_string()), Header::Status(Status::Proposed), Header::Size(TShirtSize::XXXL), Header::Priority(42), Header::Parent(utf8path::Path::from("../foo.md")), Header::Custom("x-gh-foo".to_string(), "bar".to_string()) ], body: "quux\n".to_string() }, r#"title: foo owner: bar accountable: baz status: proposed size: XXXL priority: 42 parent: ../foo.md x-gh-foo: bar quux "# .parse() .unwrap() ); assert_eq!( r#"title: foo owner: bar accountable: baz status: proposed size: XXXL priority: 42 parent: ../foo.md x-gh-foo: bar quux "#, Objective { headers: vec![ Header::Title("foo".to_string()), Header::Owner("bar".to_string()), Header::Accountable("baz".to_string()), Header::Status(Status::Proposed), Header::Size(TShirtSize::XXXL), Header::Priority(42), Header::Parent(utf8path::Path::from("../foo.md")), Header::Custom("x-gh-foo".to_string(), "bar".to_string()) ], body: "quux\n".to_string() } .to_string(), ); } #[test] fn objective_graph_not_dag() { let mut objective_graph = ObjectiveGraph::default(); objective_graph.objectives.push(( utf8path::Path::from("foo.md"), Objective { headers: vec![ Header::Title("foo".to_string()), Header::Owner("bar".to_string()), Header::Accountable("baz".to_string()), Header::Status(Status::Proposed), Header::Size(TShirtSize::XXXL), Header::Priority(42), Header::Parent(utf8path::Path::from("bar.md")), Header::Custom("x-gh-foo".to_string(), "bar".to_string()), ], body: "".to_string(), }, )); objective_graph.objectives.push(( utf8path::Path::from("foo.md"), Objective { headers: vec![ Header::Title("foo".to_string()), Header::Owner("bar".to_string()), Header::Accountable("baz".to_string()), Header::Status(Status::Proposed), Header::Size(TShirtSize::XXXL), Header::Priority(42), Header::Parent(utf8path::Path::from("foo.md")), Header::Custom("x-gh-foo".to_string(), "bar".to_string()), ], body: "".to_string(), }, )); objective_graph.parent_to_child.push((0, 1)); objective_graph.parent_to_child.push((1, 0)); objective_graph.child_to_parent.push((0, 1)); objective_graph.child_to_parent.push((1, 0)); let lint = vec![( Lint::GraphNotADag(utf8path::Path::from("foo.md").into_owned()), "cycle detected", )]; assert_eq!(lint, objective_graph.report().unwrap()); } }