Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

350 linesCodeBlameRaw
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
13use serde::{Deserialize, Deserializer, Serialize};
14
15use super::WorkItem;
16use crate::repos::RepoPath;
17use 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")]
22pub 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
36impl 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")]
78pub 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
87impl 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.
116pub const DEFAULT_NOW_CAP: u32 = 6;
117/// The most a workspace may set Now's cap to.
118pub const MAX_NOW_CAP: u32 = 50;
119/// How many of Next the plan shows before "n more".
120pub const NEXT_SHOWN: u32 = 10;
121/// The most spend budget a work item may be given, in dollars.
122pub const MAX_BUDGET_USD: f64 = 100_000.0;
123
124/// What `Some(None)` means in a change: the field is cleared. Absent
125/// leaves it as it is; JSON `null` clears it.
126fn present<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
127where
128 D: Deserializer<'de>,
129 T: Deserialize<'de>,
130{
131 Option::<T>::deserialize(deserializer).map(Some)
132}
133
134/// `plan_work_item`: moves a work item in its workspace's plan, and sets
135/// what the plan says of it. Whichever fields are given change. Placing it
136/// `before` or `after` another open work item puts it next to that one, on
137/// that one's horizon; `top` puts it first on its horizon; a new horizon
138/// with no position puts it last there. Triaging, and anything in Next,
139/// Later or Set aside, needs the Triage role on it; moving into or out of
140/// Now, ranking Now, and urgent need the right to manage milestones.
141/// Refused for a closed work item. Publishes `work_item.planned`.
142/// Returns `Outcome<WorkItem>`.
143#[derive(Debug, Default, Serialize, Deserialize)]
144pub struct PlanWorkItemArgs {
145 pub actor: User,
146 pub workspace: String,
147 pub number: u32,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub horizon: Option<Horizon>,
150 /// The number of the open work item to put it just before.
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub before: Option<u32>,
153 /// The number of the open work item to put it just after.
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub after: Option<u32>,
156 /// First on its horizon.
157 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
158 pub top: bool,
159 /// `null` clears it.
160 #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
161 pub appetite: Option<Option<Appetite>>,
162 /// In dollars, what an agent working on it may spend; `null` clears it.
163 #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
164 pub budget_usd: Option<Option<f64>>,
165 /// First in Now, ahead of its rank, until it closes: for fires.
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub urgent: Option<bool>,
168}
169
170/// `get_plan`: a workspace's plan, as a member sees it: Now in full, Next
171/// to its top [`NEXT_SHOWN`] (or in full, with `all`), and how many are
172/// in Triage, Later and Set aside, Later grouped by the repository each
173/// work item is filed in. With `repo`, only work items linked to it.
174/// Returns `Outcome<Plan>`.
175#[derive(Debug, Default, Serialize, Deserialize)]
176pub struct GetPlanArgs {
177 pub workspace: String,
178 pub viewer: Viewer,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub repo: Option<RepoPath>,
181 /// Every one of Next, not only its top.
182 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
183 pub all: bool,
184}
185
186/// A workspace's plan.
187#[derive(Clone, Debug, Default, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct Plan {
190 pub workspace: String,
191 /// Urgent first, then by rank.
192 pub now: Vec<WorkItem>,
193 /// How many Now holds before the plan says it is over.
194 pub now_cap: u32,
195 /// By rank, the top first.
196 pub next: Vec<WorkItem>,
197 /// Every open work item in Next, shown or not.
198 pub next_total: u32,
199 pub triage_total: u32,
200 pub later_total: u32,
201 /// Later, by the repository each is filed in (none for those filed in
202 /// the workspace), the largest group first.
203 pub later: Vec<PlanGroup>,
204 pub set_aside_total: u32,
205}
206
207/// Work items of one horizon filed in one repository.
208#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct PlanGroup {
211 /// `owner/name`; none for work items filed in the workspace.
212 pub repo: Option<String>,
213 pub count: u32,
214}
215
216/// `next_work_item`: the work item to take next in a workspace: the first
217/// in Now nobody is on, else the top of Next, leaving out any that is
218/// blocked by open work, claimed by someone, or assigned to someone other
219/// than the viewer. With `repo`, only work items linked to it. Returns
220/// `Outcome<Option<WorkItem>>`: none when nothing is ready.
221#[derive(Debug, Default, Serialize, Deserialize)]
222pub struct NextWorkItemArgs {
223 pub workspace: String,
224 pub viewer: Viewer,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub repo: Option<RepoPath>,
227}
228
229/// `configure_plan`: sets how many work items Now holds before the plan
230/// says it is over (1 to [`MAX_NOW_CAP`]). Needs the right to manage
231/// milestones. Returns `Outcome<Plan>` without its work items.
232#[derive(Debug, Default, Serialize, Deserialize)]
233pub struct ConfigurePlanArgs {
234 pub actor: User,
235 pub workspace: String,
236 pub now_cap: u32,
237}
238
239// --- Rank ---------------------------------------------------------------------
240
241/// The digits a rank is written in, in the order SQLite compares them.
242const DIGITS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
243
244fn digit(c: u8) -> usize {
245 DIGITS.iter().position(|d| *d == c).unwrap_or(0)
246}
247
248/// A rank that sorts after `before` and ahead of `after` (`None`: no bound
249/// on that side). Ranks are strings of base-62 digits, compared as text,
250/// never ending in `0`, so there is always room between two of them.
251/// `before` must sort ahead of `after` when both are given.
252pub fn rank_between(before: Option<&str>, after: Option<&str>) -> String {
253 let low = before.unwrap_or("").as_bytes();
254 let high = after.map(str::as_bytes);
255 let mut out = Vec::new();
256 let mut at = 0;
257 loop {
258 let lo = low.get(at).map_or(0, |c| digit(*c));
259 // Past the end of `after`, or below it already, there is no upper
260 // bound on this digit.
261 let hi = match high {
262 Some(high) if out.as_slice() == &high[..at.min(high.len())] => high.get(at).map_or(DIGITS.len(), |c| digit(*c)),
263 _ => DIGITS.len(),
264 };
265 if hi.saturating_sub(lo) > 1 {
266 out.push(DIGITS[(lo + hi).div_ceil(2).min(hi - 1).max(lo + 1)]);
267 return String::from_utf8(out).unwrap_or_default();
268 }
269 out.push(DIGITS[lo]);
270 at += 1;
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 fn between(before: Option<&str>, after: Option<&str>) -> String {
279 let rank = rank_between(before, after);
280 assert!(!rank.ends_with('0'), "{rank} ends in 0");
281 if let Some(before) = before {
282 assert!(before < rank.as_str(), "{before} < {rank}");
283 }
284 if let Some(after) = after {
285 assert!(rank.as_str() < after, "{rank} < {after}");
286 }
287 rank
288 }
289
290 #[test]
291 fn a_rank_always_fits_between_two_others() {
292 let first = between(None, None);
293 let next = between(Some(&first), None);
294 let ahead = between(None, Some(&first));
295 between(Some(&ahead), Some(&first));
296 between(Some(&first), Some(&next));
297 // Neighbours: one digit apart, then none apart.
298 between(Some("a"), Some("b"));
299 between(Some("a"), Some("a1"));
300 between(Some("a0z"), Some("a1"));
301 between(Some("zzz"), None);
302 between(None, Some("01"));
303 between(None, Some("001"));
304 }
305
306 #[test]
307 fn ranking_one_place_over_and_over_stays_ordered() {
308 // Always before the same item: ranks grow, but keep their order.
309 let mut ranks = vec![between(None, None)];
310 let last = ranks[0].clone();
311 let mut low: Option<String> = None;
312 for _ in 0..200 {
313 let rank = between(low.as_deref(), Some(&last));
314 low = Some(rank.clone());
315 ranks.push(rank);
316 }
317 // And always at the top.
318 let mut top = ranks[0].clone();
319 for _ in 0..200 {
320 top = between(None, Some(&top));
321 }
322 assert!(top.len() < 60);
323 }
324
325 #[test]
326 fn horizons_and_appetites_read_as_people_write_them() {
327 assert_eq!(Horizon::parse("Set aside"), Some(Horizon::SetAside));
328 assert_eq!(Horizon::parse("set-aside"), Some(Horizon::SetAside));
329 assert_eq!(Horizon::parse(" NOW "), Some(Horizon::Now));
330 assert_eq!(Horizon::parse("soon"), None);
331 assert_eq!(serde_json::to_value(Horizon::SetAside).unwrap(), "set_aside");
332 assert_eq!(Appetite::parse("M"), Some(Appetite::Medium));
333 assert_eq!(Appetite::parse("large"), Some(Appetite::Large));
334 assert_eq!(Appetite::parse("xl"), None);
335 }
336
337 #[test]
338 fn a_change_tells_clearing_from_leaving_alone() {
339 let args: PlanWorkItemArgs =
340 serde_json::from_value(serde_json::json!({ "actor": { "id": "usr_1", "username": "ana" }, "workspace": "acme", "number": 3 }))
341 .unwrap();
342 assert_eq!(args.appetite, None);
343 let args: PlanWorkItemArgs = serde_json::from_value(serde_json::json!({
344 "actor": { "id": "usr_1", "username": "ana" }, "workspace": "acme", "number": 3, "appetite": null, "budget_usd": 25.0
345 }))
346 .unwrap();
347 assert_eq!(args.appetite, Some(None));
348 assert_eq!(args.budget_usd, Some(Some(25.0)));
349 }
350}