| 1 | //! The plan: where each open work item of a workspace stands, and in what |
| 2 | //! order the work is taken. |
| 3 | //! |
| 4 | //! Every open work item is on one [`Horizon`]. New ones start in Triage |
| 5 | //! until someone places them. Within Now, Next and Later its position is |
| 6 | //! its priority: there are no priority levels, only a rank, a fractional |
| 7 | //! key (see [`rank_between`]) so moving one item rewrites only that item. |
| 8 | //! Each horizon is shown within a bound, so no view of the plan grows |
| 9 | //! without end: Now has a soft cap the workspace sets ([`DEFAULT_NOW_CAP`]), |
| 10 | //! Next is shown to its top [`NEXT_SHOWN`], Later as groups, and Set aside |
| 11 | //! only to search. |
| 12 | |
| 13 | use serde::{Deserialize, Deserializer, Serialize}; |
| 14 | |
| 15 | use super::WorkItem; |
| 16 | use crate::repos::RepoPath; |
| 17 | use crate::{User, Viewer}; |
| 18 | |
| 19 | /// Where an open work item stands in its workspace's plan. |
| 20 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] |
| 21 | #[serde(rename_all = "snake_case")] |
| 22 | pub enum Horizon { |
| 23 | /// Not placed yet: opened by someone, filed by an agent or imported. |
| 24 | #[default] |
| 25 | Triage, |
| 26 | /// What the workspace has chosen to do and is on. |
| 27 | Now, |
| 28 | /// Ranked: the top is what anyone, or any agent, takes next. |
| 29 | Next, |
| 30 | /// Wanted, not ranked against Next yet. |
| 31 | Later, |
| 32 | /// Let go: out of every view but search, and back with one move. |
| 33 | SetAside, |
| 34 | } |
| 35 | |
| 36 | impl Horizon { |
| 37 | pub const ALL: [Horizon; 5] = [Horizon::Triage, Horizon::Now, Horizon::Next, Horizon::Later, Horizon::SetAside]; |
| 38 | |
| 39 | pub fn as_str(self) -> &'static str { |
| 40 | match self { |
| 41 | Horizon::Triage => "triage", |
| 42 | Horizon::Now => "now", |
| 43 | Horizon::Next => "next", |
| 44 | Horizon::Later => "later", |
| 45 | Horizon::SetAside => "set_aside", |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | /// Its name, as people say it. |
| 50 | pub fn label(self) -> &'static str { |
| 51 | match self { |
| 52 | Horizon::Triage => "Triage", |
| 53 | Horizon::Now => "Now", |
| 54 | Horizon::Next => "Next", |
| 55 | Horizon::Later => "Later", |
| 56 | Horizon::SetAside => "Set aside", |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /// `now`, `Set aside`, `set-aside`: any case, `-`, `_` or a space. |
| 61 | pub fn parse(name: &str) -> Option<Horizon> { |
| 62 | let name: String = name.trim().to_ascii_lowercase().chars().map(|c| if c == '-' || c == ' ' { '_' } else { c }).collect(); |
| 63 | Horizon::ALL.into_iter().find(|horizon| horizon.as_str() == name) |
| 64 | } |
| 65 | |
| 66 | /// Whether moving an item into it, or within it, needs the right to |
| 67 | /// manage milestones rather than to triage: what the workspace commits |
| 68 | /// to is decided by those who set its goals. |
| 69 | pub fn committed(self) -> bool { |
| 70 | self == Horizon::Now |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | /// How much a piece of work is worth: the time it should take, not a |
| 75 | /// guess at how long it will. |
| 76 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] |
| 77 | #[serde(rename_all = "snake_case")] |
| 78 | pub enum Appetite { |
| 79 | /// One session. |
| 80 | Small, |
| 81 | /// A few sessions, about a week. |
| 82 | Medium, |
| 83 | /// A phase: broken into smaller work items before it is taken on. |
| 84 | Large, |
| 85 | } |
| 86 | |
| 87 | impl Appetite { |
| 88 | pub const ALL: [Appetite; 3] = [Appetite::Small, Appetite::Medium, Appetite::Large]; |
| 89 | |
| 90 | pub fn as_str(self) -> &'static str { |
| 91 | match self { |
| 92 | Appetite::Small => "small", |
| 93 | Appetite::Medium => "medium", |
| 94 | Appetite::Large => "large", |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | /// `S`, `M` or `L`. |
| 99 | pub fn letter(self) -> &'static str { |
| 100 | match self { |
| 101 | Appetite::Small => "S", |
| 102 | Appetite::Medium => "M", |
| 103 | Appetite::Large => "L", |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// `small` or `s`, any case. |
| 108 | pub fn parse(name: &str) -> Option<Appetite> { |
| 109 | let name = name.trim().to_ascii_lowercase(); |
| 110 | Appetite::ALL.into_iter().find(|appetite| appetite.as_str() == name || appetite.letter().eq_ignore_ascii_case(&name)) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | /// How many work items Now holds before the plan says it is over, when the |
| 115 | /// workspace has not said otherwise. Past it, g1t warns; it never refuses. |
| 116 | pub const DEFAULT_NOW_CAP: u32 = 6; |
| 117 | /// The most a workspace may set Now's cap to. |
| 118 | pub const MAX_NOW_CAP: u32 = 50; |
| 119 | /// How many of Next the plan shows before "n more". |
| 120 | pub const NEXT_SHOWN: u32 = 10; |
| 121 | /// The most spend budget a work item may be given, in dollars. |
| 122 | pub const MAX_BUDGET_USD: f64 = 100_000.0; |
| 123 | /// A work item in Triage this many days is waiting too long: Home says so |
| 124 | /// to those who plan. |
| 125 | pub const TRIAGE_NUDGE_DAYS: u32 = 7; |
| 126 | /// A work item in Later untouched this many days is set aside. |
| 127 | pub const SET_ASIDE_DAYS: u32 = 90; |
| 128 | /// A work item in Now with nothing happening on it this many days has gone |
| 129 | /// quiet. |
| 130 | pub const QUIET_DAYS: u32 = 5; |
| 131 | /// How far back the plan's week looks, in days. |
| 132 | pub const WEEK_DAYS: u32 = 7; |
| 133 | |
| 134 | /// What g1t suggests for a work item in Triage: drafted by the |
| 135 | /// workspace's own AI provider from the work item and the plan, for a |
| 136 | /// person to accept or not. |
| 137 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 138 | #[serde(rename_all = "camelCase")] |
| 139 | pub struct PlanSuggestion { |
| 140 | pub horizon: Horizon, |
| 141 | #[serde(default)] |
| 142 | pub appetite: Option<Appetite>, |
| 143 | /// An open work item it seems to repeat, by number. |
| 144 | #[serde(default)] |
| 145 | pub duplicate_of: Option<u32>, |
| 146 | /// Why, in a sentence. |
| 147 | pub why: String, |
| 148 | /// RFC 3339. |
| 149 | pub at: String, |
| 150 | } |
| 151 | |
| 152 | /// How far along a work item in Now is, as its record says: what its |
| 153 | /// sessions, decisions and pull requests show, never set by hand. |
| 154 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] |
| 155 | #[serde(rename_all = "snake_case")] |
| 156 | pub enum Hill { |
| 157 | /// Nothing decided yet: sessions on it, or nothing at all. |
| 158 | FiguringOut, |
| 159 | /// A decision is recorded on it: the approach is settled. |
| 160 | Decided, |
| 161 | /// A draft pull request, or commits made for it. |
| 162 | Building, |
| 163 | /// A pull request in review or merged, or landed in some of its |
| 164 | /// targets. |
| 165 | Landing, |
| 166 | } |
| 167 | |
| 168 | impl Hill { |
| 169 | pub fn as_str(self) -> &'static str { |
| 170 | match self { |
| 171 | Hill::FiguringOut => "figuring_out", |
| 172 | Hill::Decided => "decided", |
| 173 | Hill::Building => "building", |
| 174 | Hill::Landing => "landing", |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | pub fn label(self) -> &'static str { |
| 179 | match self { |
| 180 | Hill::FiguringOut => "Figuring it out", |
| 181 | Hill::Decided => "Decided", |
| 182 | Hill::Building => "Building", |
| 183 | Hill::Landing => "Landing", |
| 184 | } |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | /// What a work item's record says of it, for its hill. |
| 189 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 190 | pub struct HillFacts { |
| 191 | pub decisions: u32, |
| 192 | pub draft_pulls: u32, |
| 193 | pub commits: u32, |
| 194 | pub open_pulls: u32, |
| 195 | pub merged_pulls: u32, |
| 196 | pub landed_targets: u32, |
| 197 | } |
| 198 | |
| 199 | /// Where a work item with `facts` is on its hill: the furthest stage any |
| 200 | /// fact reaches. |
| 201 | pub fn hill_of(facts: &HillFacts) -> Hill { |
| 202 | if facts.open_pulls > 0 || facts.merged_pulls > 0 || facts.landed_targets > 0 { |
| 203 | Hill::Landing |
| 204 | } else if facts.draft_pulls > 0 || facts.commits > 0 { |
| 205 | Hill::Building |
| 206 | } else if facts.decisions > 0 { |
| 207 | Hill::Decided |
| 208 | } else { |
| 209 | Hill::FiguringOut |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | /// How a work item in Now stands. |
| 214 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 215 | #[serde(rename_all = "camelCase")] |
| 216 | pub struct Progress { |
| 217 | pub number: u32, |
| 218 | pub hill: Hill, |
| 219 | /// RFC 3339: the last time anything happened on it (a session, a |
| 220 | /// decision, a pull request, a change to it). |
| 221 | #[serde(default)] |
| 222 | pub active_at: Option<String>, |
| 223 | /// Nothing has happened on it for [`QUIET_DAYS`]. |
| 224 | #[serde(default)] |
| 225 | pub quiet: bool, |
| 226 | /// Where it stands, in a sentence or two, drafted by g1t from its |
| 227 | /// record with the workspace's AI provider; none until it has one. |
| 228 | #[serde(default)] |
| 229 | pub standing: Option<String>, |
| 230 | /// RFC 3339: when `standing` was drafted. |
| 231 | #[serde(default)] |
| 232 | pub standing_at: Option<String>, |
| 233 | } |
| 234 | |
| 235 | /// What `Some(None)` means in a change: the field is cleared. Absent |
| 236 | /// leaves it as it is; JSON `null` clears it. |
| 237 | fn present<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error> |
| 238 | where |
| 239 | D: Deserializer<'de>, |
| 240 | T: Deserialize<'de>, |
| 241 | { |
| 242 | Option::<T>::deserialize(deserializer).map(Some) |
| 243 | } |
| 244 | |
| 245 | /// `plan_work_item`: moves a work item in its workspace's plan, and sets |
| 246 | /// what the plan says of it. Whichever fields are given change. Placing it |
| 247 | /// `before` or `after` another open work item puts it next to that one, on |
| 248 | /// that one's horizon; `top` puts it first on its horizon; a new horizon |
| 249 | /// with no position puts it last there. Triaging, and anything in Next, |
| 250 | /// Later or Set aside, needs the Triage role on it; moving into or out of |
| 251 | /// Now, ranking Now, and urgent need the right to manage milestones. |
| 252 | /// Refused for a closed work item. Publishes `work_item.planned`. |
| 253 | /// Returns `Outcome<WorkItem>`. |
| 254 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 255 | pub struct PlanWorkItemArgs { |
| 256 | pub actor: User, |
| 257 | pub workspace: String, |
| 258 | pub number: u32, |
| 259 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 260 | pub horizon: Option<Horizon>, |
| 261 | /// The number of the open work item to put it just before. |
| 262 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 263 | pub before: Option<u32>, |
| 264 | /// The number of the open work item to put it just after. |
| 265 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 266 | pub after: Option<u32>, |
| 267 | /// First on its horizon. |
| 268 | #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
| 269 | pub top: bool, |
| 270 | /// `null` clears it. |
| 271 | #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] |
| 272 | pub appetite: Option<Option<Appetite>>, |
| 273 | /// In dollars, what an agent working on it may spend; `null` clears it. |
| 274 | #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] |
| 275 | pub budget_usd: Option<Option<f64>>, |
| 276 | /// First in Now, ahead of its rank, until it closes: for fires. |
| 277 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 278 | pub urgent: Option<bool>, |
| 279 | } |
| 280 | |
| 281 | /// `get_plan`: a workspace's plan, as a member sees it: Now in full, Next |
| 282 | /// to its top [`NEXT_SHOWN`] (or in full, with `all`), and how many are |
| 283 | /// in Triage, Later and Set aside, Later grouped by the repository each |
| 284 | /// work item is filed in. With `repo`, only work items linked to it. |
| 285 | /// Returns `Outcome<Plan>`. |
| 286 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 287 | pub struct GetPlanArgs { |
| 288 | pub workspace: String, |
| 289 | pub viewer: Viewer, |
| 290 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 291 | pub repo: Option<RepoPath>, |
| 292 | /// Every one of Next, not only its top. |
| 293 | #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
| 294 | pub all: bool, |
| 295 | } |
| 296 | |
| 297 | /// A workspace's plan. |
| 298 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] |
| 299 | #[serde(rename_all = "camelCase")] |
| 300 | pub struct Plan { |
| 301 | pub workspace: String, |
| 302 | /// Urgent first, then by rank. |
| 303 | pub now: Vec<WorkItem>, |
| 304 | /// How many Now holds before the plan says it is over. |
| 305 | pub now_cap: u32, |
| 306 | /// By rank, the top first. |
| 307 | pub next: Vec<WorkItem>, |
| 308 | /// Every open work item in Next, shown or not. |
| 309 | pub next_total: u32, |
| 310 | pub triage_total: u32, |
| 311 | pub later_total: u32, |
| 312 | /// Later, by the repository each is filed in (none for those filed in |
| 313 | /// the workspace), the largest group first. |
| 314 | pub later: Vec<PlanGroup>, |
| 315 | pub set_aside_total: u32, |
| 316 | /// How each work item in Now stands, in Now's order. |
| 317 | #[serde(default)] |
| 318 | pub progress: Vec<Progress>, |
| 319 | /// What closed from Now in the last [`WEEK_DAYS`], the most recent |
| 320 | /// first; at most 10. |
| 321 | #[serde(default)] |
| 322 | pub landed: Vec<WorkItem>, |
| 323 | /// How many have waited in Triage more than [`TRIAGE_NUDGE_DAYS`]. |
| 324 | #[serde(default)] |
| 325 | pub triage_waiting: u32, |
| 326 | /// How many were set aside in the last 30 days. |
| 327 | #[serde(default)] |
| 328 | pub set_aside_recent: u32, |
| 329 | } |
| 330 | |
| 331 | /// Work items of one horizon filed in one repository. |
| 332 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 333 | #[serde(rename_all = "camelCase")] |
| 334 | pub struct PlanGroup { |
| 335 | /// `owner/name`; none for work items filed in the workspace. |
| 336 | pub repo: Option<String>, |
| 337 | pub count: u32, |
| 338 | } |
| 339 | |
| 340 | /// `next_work_item`: the work item to take next in a workspace: the first |
| 341 | /// in Now nobody is on, else the top of Next, leaving out any that is |
| 342 | /// blocked by open work, claimed by someone, or assigned to someone other |
| 343 | /// than the viewer. With `repo`, only work items linked to it. Returns |
| 344 | /// `Outcome<Option<WorkItem>>`: none when nothing is ready. |
| 345 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 346 | pub struct NextWorkItemArgs { |
| 347 | pub workspace: String, |
| 348 | pub viewer: Viewer, |
| 349 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 350 | pub repo: Option<RepoPath>, |
| 351 | } |
| 352 | |
| 353 | /// `configure_plan`: sets how many work items Now holds before the plan |
| 354 | /// says it is over (1 to [`MAX_NOW_CAP`]). Needs the right to manage |
| 355 | /// milestones. Returns `Outcome<Plan>` without its work items. |
| 356 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 357 | pub struct ConfigurePlanArgs { |
| 358 | pub actor: User, |
| 359 | pub workspace: String, |
| 360 | pub now_cap: u32, |
| 361 | } |
| 362 | |
| 363 | // --- Rank --------------------------------------------------------------------- |
| 364 | |
| 365 | /// The digits a rank is written in, in the order SQLite compares them. |
| 366 | const DIGITS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; |
| 367 | |
| 368 | fn digit(c: u8) -> usize { |
| 369 | DIGITS.iter().position(|d| *d == c).unwrap_or(0) |
| 370 | } |
| 371 | |
| 372 | /// A rank that sorts after `before` and ahead of `after` (`None`: no bound |
| 373 | /// on that side). Ranks are strings of base-62 digits, compared as text, |
| 374 | /// never ending in `0`, so there is always room between two of them. |
| 375 | /// `before` must sort ahead of `after` when both are given. |
| 376 | pub fn rank_between(before: Option<&str>, after: Option<&str>) -> String { |
| 377 | let low = before.unwrap_or("").as_bytes(); |
| 378 | let high = after.map(str::as_bytes); |
| 379 | let mut out = Vec::new(); |
| 380 | let mut at = 0; |
| 381 | loop { |
| 382 | let lo = low.get(at).map_or(0, |c| digit(*c)); |
| 383 | // Past the end of `after`, or below it already, there is no upper |
| 384 | // bound on this digit. |
| 385 | let hi = match high { |
| 386 | Some(high) if out.as_slice() == &high[..at.min(high.len())] => high.get(at).map_or(DIGITS.len(), |c| digit(*c)), |
| 387 | _ => DIGITS.len(), |
| 388 | }; |
| 389 | if hi.saturating_sub(lo) > 1 { |
| 390 | out.push(DIGITS[(lo + hi).div_ceil(2).min(hi - 1).max(lo + 1)]); |
| 391 | return String::from_utf8(out).unwrap_or_default(); |
| 392 | } |
| 393 | out.push(DIGITS[lo]); |
| 394 | at += 1; |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | #[cfg(test)] |
| 399 | mod tests { |
| 400 | use super::*; |
| 401 | |
| 402 | fn between(before: Option<&str>, after: Option<&str>) -> String { |
| 403 | let rank = rank_between(before, after); |
| 404 | assert!(!rank.ends_with('0'), "{rank} ends in 0"); |
| 405 | if let Some(before) = before { |
| 406 | assert!(before < rank.as_str(), "{before} < {rank}"); |
| 407 | } |
| 408 | if let Some(after) = after { |
| 409 | assert!(rank.as_str() < after, "{rank} < {after}"); |
| 410 | } |
| 411 | rank |
| 412 | } |
| 413 | |
| 414 | #[test] |
| 415 | fn a_rank_always_fits_between_two_others() { |
| 416 | let first = between(None, None); |
| 417 | let next = between(Some(&first), None); |
| 418 | let ahead = between(None, Some(&first)); |
| 419 | between(Some(&ahead), Some(&first)); |
| 420 | between(Some(&first), Some(&next)); |
| 421 | // Neighbours: one digit apart, then none apart. |
| 422 | between(Some("a"), Some("b")); |
| 423 | between(Some("a"), Some("a1")); |
| 424 | between(Some("a0z"), Some("a1")); |
| 425 | between(Some("zzz"), None); |
| 426 | between(None, Some("01")); |
| 427 | between(None, Some("001")); |
| 428 | } |
| 429 | |
| 430 | #[test] |
| 431 | fn ranking_one_place_over_and_over_stays_ordered() { |
| 432 | // Always before the same item: ranks grow, but keep their order. |
| 433 | let mut ranks = vec![between(None, None)]; |
| 434 | let last = ranks[0].clone(); |
| 435 | let mut low: Option<String> = None; |
| 436 | for _ in 0..200 { |
| 437 | let rank = between(low.as_deref(), Some(&last)); |
| 438 | low = Some(rank.clone()); |
| 439 | ranks.push(rank); |
| 440 | } |
| 441 | // And always at the top. |
| 442 | let mut top = ranks[0].clone(); |
| 443 | for _ in 0..200 { |
| 444 | top = between(None, Some(&top)); |
| 445 | } |
| 446 | assert!(top.len() < 60); |
| 447 | } |
| 448 | |
| 449 | #[test] |
| 450 | fn the_hill_is_the_furthest_stage_the_record_reaches() { |
| 451 | assert_eq!(hill_of(&HillFacts::default()), Hill::FiguringOut); |
| 452 | assert_eq!(hill_of(&HillFacts { decisions: 2, ..HillFacts::default() }), Hill::Decided); |
| 453 | assert_eq!(hill_of(&HillFacts { decisions: 1, commits: 3, ..HillFacts::default() }), Hill::Building); |
| 454 | assert_eq!(hill_of(&HillFacts { draft_pulls: 1, ..HillFacts::default() }), Hill::Building); |
| 455 | assert_eq!(hill_of(&HillFacts { open_pulls: 1, ..HillFacts::default() }), Hill::Landing); |
| 456 | assert_eq!(hill_of(&HillFacts { landed_targets: 1, draft_pulls: 1, ..HillFacts::default() }), Hill::Landing); |
| 457 | assert_eq!(serde_json::to_value(Hill::FiguringOut).unwrap(), "figuring_out"); |
| 458 | } |
| 459 | |
| 460 | #[test] |
| 461 | fn horizons_and_appetites_read_as_people_write_them() { |
| 462 | assert_eq!(Horizon::parse("Set aside"), Some(Horizon::SetAside)); |
| 463 | assert_eq!(Horizon::parse("set-aside"), Some(Horizon::SetAside)); |
| 464 | assert_eq!(Horizon::parse(" NOW "), Some(Horizon::Now)); |
| 465 | assert_eq!(Horizon::parse("soon"), None); |
| 466 | assert_eq!(serde_json::to_value(Horizon::SetAside).unwrap(), "set_aside"); |
| 467 | assert_eq!(Appetite::parse("M"), Some(Appetite::Medium)); |
| 468 | assert_eq!(Appetite::parse("large"), Some(Appetite::Large)); |
| 469 | assert_eq!(Appetite::parse("xl"), None); |
| 470 | } |
| 471 | |
| 472 | #[test] |
| 473 | fn a_change_tells_clearing_from_leaving_alone() { |
| 474 | let args: PlanWorkItemArgs = |
| 475 | serde_json::from_value(serde_json::json!({ "actor": { "id": "usr_1", "username": "ana" }, "workspace": "acme", "number": 3 })) |
| 476 | .unwrap(); |
| 477 | assert_eq!(args.appetite, None); |
| 478 | let args: PlanWorkItemArgs = serde_json::from_value(serde_json::json!({ |
| 479 | "actor": { "id": "usr_1", "username": "ana" }, "workspace": "acme", "number": 3, "appetite": null, "budget_usd": 25.0 |
| 480 | })) |
| 481 | .unwrap(); |
| 482 | assert_eq!(args.appetite, Some(None)); |
| 483 | assert_eq!(args.budget_usd, Some(Some(25.0))); |
| 484 | } |
| 485 | } |