1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6
7use super::{AgentState, IssueType, MolType, Status};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Issue {
12 pub id: String,
14
15 #[serde(skip_serializing_if = "Option::is_none")]
17 pub content_hash: Option<String>,
18
19 pub title: String,
22
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub description: Option<String>,
26
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub design: Option<String>,
30
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub acceptance_criteria: Option<String>,
34
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub notes: Option<String>,
38
39 #[serde(default)]
42 pub status: Status,
43
44 #[serde(default)]
46 pub priority: i32,
47
48 #[serde(default)]
50 pub issue_type: IssueType,
51
52 #[serde(skip_serializing_if = "Option::is_none")]
55 pub assignee: Option<String>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub owner: Option<String>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub estimated_minutes: Option<i32>,
64
65 pub created_at: DateTime<Utc>,
68
69 pub created_by: String,
71
72 pub updated_at: DateTime<Utc>,
74
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub closed_at: Option<DateTime<Utc>>,
78
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub close_reason: Option<String>,
82
83 #[serde(skip_serializing_if = "Option::is_none")]
86 pub due_at: Option<DateTime<Utc>>,
87
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub defer_until: Option<DateTime<Utc>>,
91
92 #[serde(skip_serializing_if = "Option::is_none")]
95 pub external_ref: Option<String>,
96
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub source_system: Option<String>,
100
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub labels: Vec<String>,
105
106 #[serde(skip_serializing_if = "Option::is_none")]
109 pub deleted_at: Option<DateTime<Utc>>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub deleted_by: Option<String>,
114
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub delete_reason: Option<String>,
118
119 #[serde(skip_serializing_if = "Option::is_none")]
122 pub compaction_level: Option<i32>,
123
124 #[serde(skip_serializing_if = "Option::is_none")]
126 pub compacted_at: Option<DateTime<Utc>>,
127
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub compacted_at_commit: Option<String>,
131
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub original_size: Option<i32>,
135
136 #[serde(skip_serializing_if = "Option::is_none")]
139 pub agent_state: Option<AgentState>,
140
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub mol_type: Option<MolType>,
144
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub hook_bead: Option<String>,
148
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub role_bead: Option<String>,
152
153 #[serde(skip_serializing_if = "Option::is_none")]
155 pub rig: Option<String>,
156
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub last_activity: Option<DateTime<Utc>>,
160
161 #[serde(default)]
164 pub pinned: bool,
165
166 #[serde(default)]
168 pub is_template: bool,
169
170 #[serde(default)]
172 pub ephemeral: bool,
173}
174
175impl Issue {
176 pub fn new(id: impl Into<String>, title: impl Into<String>, created_by: impl Into<String>) -> Self {
178 let now = Utc::now();
179 Self {
180 id: id.into(),
181 content_hash: None,
182 title: title.into(),
183 description: None,
184 design: None,
185 acceptance_criteria: None,
186 notes: None,
187 status: Status::Open,
188 priority: 2,
189 issue_type: IssueType::Task,
190 assignee: None,
191 owner: None,
192 estimated_minutes: None,
193 created_at: now,
194 created_by: created_by.into(),
195 updated_at: now,
196 closed_at: None,
197 close_reason: None,
198 due_at: None,
199 defer_until: None,
200 external_ref: None,
201 source_system: None,
202 labels: Vec::new(),
203 deleted_at: None,
204 deleted_by: None,
205 delete_reason: None,
206 compaction_level: None,
207 compacted_at: None,
208 compacted_at_commit: None,
209 original_size: None,
210 agent_state: None,
211 mol_type: None,
212 hook_bead: None,
213 role_bead: None,
214 rig: None,
215 last_activity: None,
216 pinned: false,
217 is_template: false,
218 ephemeral: false,
219 }
220 }
221
222 pub fn compute_content_hash(&self) -> String {
224 let mut hasher = Sha256::new();
225
226 hasher.update(self.title.as_bytes());
228 if let Some(ref desc) = self.description {
229 hasher.update(desc.as_bytes());
230 }
231 if let Some(ref design) = self.design {
232 hasher.update(design.as_bytes());
233 }
234 if let Some(ref ac) = self.acceptance_criteria {
235 hasher.update(ac.as_bytes());
236 }
237 if let Some(ref notes) = self.notes {
238 hasher.update(notes.as_bytes());
239 }
240
241 hex::encode(hasher.finalize())
242 }
243
244 pub fn update_content_hash(&mut self) {
246 self.content_hash = Some(self.compute_content_hash());
247 }
248
249 pub fn touch(&mut self) {
251 self.updated_at = Utc::now();
252 }
253
254 pub fn close(&mut self, reason: Option<String>) {
256 self.status = Status::Closed;
257 self.closed_at = Some(Utc::now());
258 self.close_reason = reason;
259 self.touch();
260 }
261
262 pub fn tombstone(&mut self, actor: &str, reason: Option<String>) {
264 self.status = Status::Tombstone;
265 self.deleted_at = Some(Utc::now());
266 self.deleted_by = Some(actor.to_string());
267 self.delete_reason = reason;
268 self.touch();
269 }
270
271 pub fn is_deleted(&self) -> bool {
273 self.deleted_at.is_some() || self.status == Status::Tombstone
274 }
275
276 pub fn is_potentially_ready(&self) -> bool {
279 matches!(self.status, Status::Open) && !self.is_deleted()
280 }
281
282 pub fn parent_id(&self) -> Option<&str> {
284 self.id.rsplit_once('.').map(|(parent, _)| parent)
285 }
286
287 pub fn has_parent(&self) -> bool {
289 self.id.contains('.')
290 }
291
292 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
294 self.description = Some(desc.into());
295 self
296 }
297
298 pub fn with_type(mut self, issue_type: IssueType) -> Self {
300 self.issue_type = issue_type;
301 self
302 }
303
304 pub fn with_priority(mut self, priority: i32) -> Self {
306 self.priority = priority.clamp(0, 4);
307 self
308 }
309
310 pub fn with_assignee(mut self, assignee: impl Into<String>) -> Self {
312 self.assignee = Some(assignee.into());
313 self
314 }
315
316 pub fn with_label(mut self, label: impl Into<String>) -> Self {
318 self.labels.push(label.into());
319 self
320 }
321}
322
323impl Default for Issue {
324 fn default() -> Self {
325 Self::new("", "", "unknown")
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn test_issue_creation() {
335 let issue = Issue::new("bd-a1b2", "Test task", "alice");
336 assert_eq!(issue.id, "bd-a1b2");
337 assert_eq!(issue.title, "Test task");
338 assert_eq!(issue.created_by, "alice");
339 assert_eq!(issue.status, Status::Open);
340 assert_eq!(issue.priority, 2);
341 }
342
343 #[test]
344 fn test_content_hash() {
345 let mut issue = Issue::new("bd-a1b2", "Test task", "alice");
346 issue.description = Some("Description".to_string());
347
348 let hash1 = issue.compute_content_hash();
349
350 issue.title = "Changed title".to_string();
351 let hash2 = issue.compute_content_hash();
352
353 assert_ne!(hash1, hash2);
354 }
355
356 #[test]
357 fn test_parent_id() {
358 let issue = Issue::new("bd-a1b2.1", "Child task", "alice");
359 assert_eq!(issue.parent_id(), Some("bd-a1b2"));
360 assert!(issue.has_parent());
361
362 let parent = Issue::new("bd-a1b2", "Parent task", "alice");
363 assert_eq!(parent.parent_id(), None);
364 assert!(!parent.has_parent());
365 }
366
367 #[test]
368 fn test_tombstone() {
369 let mut issue = Issue::new("bd-a1b2", "Test task", "alice");
370 issue.tombstone("bob", Some("Duplicate".to_string()));
371
372 assert!(issue.is_deleted());
373 assert_eq!(issue.status, Status::Tombstone);
374 assert_eq!(issue.deleted_by.as_deref(), Some("bob"));
375 assert_eq!(issue.delete_reason.as_deref(), Some("Duplicate"));
376 }
377}