Skip to main content

max / goingson

9.9 KB · 287 lines History Blame Raw
1 //! Problem domain types and DTOs.
2 //!
3 //! GoingsOn is the list of solutions; other systems are lists of problems. A
4 //! [`Problem`] is a candidate pulled from one of those systems that a human
5 //! promotes into a task. Nothing here creates a task on its own.
6 //!
7 //! Problems are a cached mirror keyed on `(source, source_ref)`. Adapters re-pull
8 //! and upsert; the triage decision ([`ProblemStatus`] and `promoted_task_id`)
9 //! survives that because upserts leave it alone.
10 //!
11 //! Urgency is not a stored field. It is the painhours score, computed from
12 //! `pain`, `scale`, and age by the shared `painhours` crate so the ranking
13 //! matches wam's. See [`Problem::painhours`].
14
15 use super::shared::{CssClass, DbValue, ParseableEnum};
16 use crate::id_types::{ProblemId, ProjectId, TaskId};
17 use chrono::{DateTime, Utc};
18 /// The painhours color band, re-exported under a name that does not collide
19 /// with GoingsOn's own task [`Priority`](super::Priority). Derived from the
20 /// score, never stored.
21 pub use painhours::Priority as ProblemBand;
22 use serde::{Deserialize, Serialize};
23 use strum_macros::EnumString;
24
25 /// Where a problem is in triage.
26 ///
27 /// Only `Open` problems are unaddressed. Everything else is settled and stops
28 /// climbing the list; see [`Problem::age_anchor`].
29 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, EnumString)]
30 pub enum ProblemStatus {
31 /// Untriaged. Sitting in the inbox, ageing, waiting for a decision.
32 #[strum(serialize = "Open")]
33 #[default]
34 Open,
35 /// A task was created from it. `promoted_task_id` points at the solution.
36 #[strum(serialize = "Promoted")]
37 Promoted,
38 /// Seen and deliberately not acted on.
39 #[strum(serialize = "Dismissed")]
40 Dismissed,
41 /// The source says it is fixed, without GoingsOn having promoted it.
42 #[strum(serialize = "Resolved")]
43 Resolved,
44 }
45
46 impl ProblemStatus {
47 /// Returns a human-readable display string.
48 pub fn as_str(&self) -> &'static str {
49 match self {
50 ProblemStatus::Open => "Open",
51 ProblemStatus::Promoted => "Promoted",
52 ProblemStatus::Dismissed => "Dismissed",
53 ProblemStatus::Resolved => "Resolved",
54 }
55 }
56
57 /// Whether the problem has left the open set. Settled problems freeze their
58 /// age anchor, so they stop climbing the ranking.
59 pub fn is_settled(&self) -> bool {
60 !matches!(self, ProblemStatus::Open)
61 }
62 }
63
64 impl ParseableEnum for ProblemStatus {}
65
66 impl DbValue for ProblemStatus {
67 fn db_value(&self) -> &'static str {
68 match self {
69 ProblemStatus::Open => "Open",
70 ProblemStatus::Promoted => "Promoted",
71 ProblemStatus::Dismissed => "Dismissed",
72 ProblemStatus::Resolved => "Resolved",
73 }
74 }
75 }
76
77 impl CssClass for ProblemStatus {
78 fn css_class(&self) -> &'static str {
79 match self {
80 ProblemStatus::Open => "problem-open",
81 ProblemStatus::Promoted => "problem-promoted",
82 ProblemStatus::Dismissed => "problem-dismissed",
83 ProblemStatus::Resolved => "problem-resolved",
84 }
85 }
86 }
87
88 /// A problem pulled from an external source, awaiting triage.
89 #[derive(Debug, Clone, Serialize, Deserialize)]
90 #[serde(rename_all = "camelCase")]
91 pub struct Problem {
92 /// Unique identifier (GoingsOn's, not the source's).
93 pub id: ProblemId,
94 /// Which adapter this came from: `wam`, `audit`, `fuzz`.
95 pub source: String,
96 /// The problem's id within that source. Unique per source.
97 pub source_ref: String,
98 /// One-line statement of the problem.
99 pub title: String,
100 /// Detail, if the source supplied any.
101 pub body: String,
102 /// How much it hurts a hit user, 1-5.
103 pub pain: u8,
104 /// How broadly it hits, 1-5.
105 pub scale: u8,
106 /// Triage state.
107 pub status: ProblemStatus,
108 /// The GoingsOn project this problem belongs to, once attributed.
109 pub project_id: Option<ProjectId>,
110 /// Free-form tags, typically module and lens from the reporting skill.
111 pub tags: Vec<String>,
112 /// The task promoted from this problem, if any. The problem-to-solution
113 /// backlink.
114 pub promoted_task_id: Option<TaskId>,
115 /// When the SOURCE created it, not when we pulled it. Age drives the score.
116 pub created_at: DateTime<Utc>,
117 /// When the source last changed it.
118 pub updated_at: DateTime<Utc>,
119 /// When it left the open set. `None` while `Open`.
120 pub settled_at: Option<DateTime<Utc>>,
121 /// The last pull that saw it in its source. Older than the source's latest
122 /// pull means the source no longer reports it.
123 pub last_seen_at: DateTime<Utc>,
124 }
125
126 impl Problem {
127 /// The instant age is measured to: frozen once the problem is settled, so
128 /// promoted, dismissed, and resolved problems stop climbing the list.
129 ///
130 /// Mirrors wam's `Ticket::age_anchor`. A settled problem missing its
131 /// `settled_at` falls back to `updated_at` rather than ageing forever.
132 fn age_anchor(&self) -> DateTime<Utc> {
133 if self.status.is_settled() {
134 self.settled_at.unwrap_or(self.updated_at)
135 } else {
136 Utc::now()
137 }
138 }
139
140 /// Age in whole weeks, measured to [`Self::age_anchor`].
141 pub fn age_weeks(&self) -> u32 {
142 painhours::age_weeks(self.created_at, self.age_anchor())
143 }
144
145 /// The painhours score: a 0-100 urgency number, the ranking key for the
146 /// Problems view. Computed rather than stored, because it moves with the
147 /// clock.
148 pub fn painhours(&self) -> u32 {
149 painhours::painhours(self.pain, self.scale, self.age_weeks())
150 }
151
152 /// Color band derived from the painhours score.
153 pub fn band(&self) -> ProblemBand {
154 ProblemBand::from_painhours(self.painhours())
155 }
156
157 /// Human-readable age for display, e.g. `3d`.
158 pub fn age(&self) -> String {
159 painhours::age_label(self.created_at, Utc::now())
160 }
161
162 /// Whether the source stopped reporting this problem, judged against the
163 /// most recent pull from that source. A stale problem is shown as such
164 /// rather than deleted, since a source being briefly unreachable must not
165 /// erase triage history.
166 pub fn is_stale(&self, source_last_pulled_at: DateTime<Utc>) -> bool {
167 self.last_seen_at < source_last_pulled_at
168 }
169 }
170
171 /// Data for ingesting a problem from a source.
172 ///
173 /// Carries no id and no triage state: those belong to GoingsOn, and an upsert
174 /// must not clobber them on re-pull.
175 #[derive(Debug, Clone, Serialize, Deserialize)]
176 pub struct NewProblem {
177 pub source: String,
178 pub source_ref: String,
179 pub title: String,
180 pub body: String,
181 pub pain: u8,
182 pub scale: u8,
183 pub project_id: Option<ProjectId>,
184 pub tags: Vec<String>,
185 /// The source's creation instant. Defaults to now when a source has none.
186 pub created_at: DateTime<Utc>,
187 /// The source's last-modified instant.
188 pub updated_at: DateTime<Utc>,
189 /// The source's own view of whether this is still a live problem. `true`
190 /// settles the mirrored row as `Resolved`.
191 pub resolved_upstream: bool,
192 }
193
194 #[cfg(test)]
195 mod tests {
196 use super::*;
197 use chrono::Duration;
198
199 fn problem(status: ProblemStatus, age: Duration) -> Problem {
200 let now = Utc::now();
201 Problem {
202 id: ProblemId::new(),
203 source: "wam".into(),
204 source_ref: "abc123".into(),
205 title: "it breaks".into(),
206 body: String::new(),
207 pain: 3,
208 scale: 3,
209 status,
210 project_id: None,
211 tags: vec![],
212 promoted_task_id: None,
213 created_at: now - age,
214 updated_at: now,
215 settled_at: status.is_settled().then_some(now - age + Duration::days(1)),
216 last_seen_at: now,
217 }
218 }
219
220 #[test]
221 fn problem_status_db_value_round_trips() {
222 for st in [
223 ProblemStatus::Open,
224 ProblemStatus::Promoted,
225 ProblemStatus::Dismissed,
226 ProblemStatus::Resolved,
227 ] {
228 assert_eq!(ProblemStatus::from_str_or_default(st.db_value()), st);
229 }
230 }
231
232 #[test]
233 fn unknown_status_falls_back_to_open() {
234 assert_eq!(
235 ProblemStatus::from_str_or_default("bogus"),
236 ProblemStatus::Open
237 );
238 }
239
240 #[test]
241 fn only_open_is_unsettled() {
242 assert!(!ProblemStatus::Open.is_settled());
243 assert!(ProblemStatus::Promoted.is_settled());
244 assert!(ProblemStatus::Dismissed.is_settled());
245 assert!(ProblemStatus::Resolved.is_settled());
246 }
247
248 #[test]
249 fn open_problems_climb_with_age() {
250 let fresh = problem(ProblemStatus::Open, Duration::days(1));
251 let old = problem(ProblemStatus::Open, Duration::days(60));
252 assert!(old.painhours() > fresh.painhours());
253 }
254
255 #[test]
256 fn settling_freezes_the_score() {
257 // Same creation date, same factors: the settled one is pinned to the day
258 // after it was created, the open one keeps climbing.
259 let age = Duration::days(90);
260 let open = problem(ProblemStatus::Open, age);
261 for settled in [
262 ProblemStatus::Promoted,
263 ProblemStatus::Dismissed,
264 ProblemStatus::Resolved,
265 ] {
266 let p = problem(settled, age);
267 assert_eq!(p.age_weeks(), 1, "{settled:?} must freeze at settled_at");
268 assert!(p.painhours() < open.painhours(), "{settled:?}");
269 }
270 }
271
272 #[test]
273 fn settled_without_a_timestamp_falls_back_to_updated_at() {
274 let mut p = problem(ProblemStatus::Promoted, Duration::days(90));
275 p.settled_at = None;
276 // updated_at is "now", so it reads as its full age rather than ageing on.
277 assert_eq!(p.age_weeks(), 13);
278 }
279
280 #[test]
281 fn staleness_is_relative_to_the_last_pull() {
282 let p = problem(ProblemStatus::Open, Duration::days(1));
283 assert!(!p.is_stale(p.last_seen_at));
284 assert!(p.is_stale(p.last_seen_at + Duration::seconds(1)));
285 }
286 }
287