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.

485 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/// A work item in Triage this many days is waiting too long: Home says so
124/// to those who plan.
125pub const TRIAGE_NUDGE_DAYS: u32 = 7;
126/// A work item in Later untouched this many days is set aside.
127pub const SET_ASIDE_DAYS: u32 = 90;
128/// A work item in Now with nothing happening on it this many days has gone
129/// quiet.
130pub const QUIET_DAYS: u32 = 5;
131/// How far back the plan's week looks, in days.
132pub 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")]
139pub 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")]
156pub 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
168impl 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)]
190pub 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.
201pub 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")]
216pub 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.
237fn present<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
238where
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)]
255pub 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)]
287pub 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")]
300pub 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")]
334pub 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)]
346pub 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)]
357pub 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.
366const DIGITS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
367
368fn 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.
376pub 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)]
399mod 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}