use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::{AgentState, IssueType, MolType, Status};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub design: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub acceptance_criteria: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default)]
pub status: Status,
#[serde(default)]
pub priority: i32,
#[serde(default)]
pub issue_type: IssueType,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub estimated_minutes: Option<i32>,
pub created_at: DateTime<Utc>,
pub created_by: String,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub closed_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub close_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub defer_until: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_system: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delete_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compaction_level: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compacted_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compacted_at_commit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub original_size: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_state: Option<AgentState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mol_type: Option<MolType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hook_bead: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub role_bead: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rig: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_activity: Option<DateTime<Utc>>,
#[serde(default)]
pub pinned: bool,
#[serde(default)]
pub is_template: bool,
#[serde(default)]
pub ephemeral: bool,
}
impl Issue {
pub fn new(id: impl Into<String>, title: impl Into<String>, created_by: impl Into<String>) -> Self {
let now = Utc::now();
Self {
id: id.into(),
content_hash: None,
title: title.into(),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: 2,
issue_type: IssueType::Task,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: now,
created_by: created_by.into(),
updated_at: now,
closed_at: None,
close_reason: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
labels: Vec::new(),
deleted_at: None,
deleted_by: None,
delete_reason: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
agent_state: None,
mol_type: None,
hook_bead: None,
role_bead: None,
rig: None,
last_activity: None,
pinned: false,
is_template: false,
ephemeral: false,
}
}
pub fn compute_content_hash(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.title.as_bytes());
if let Some(ref desc) = self.description {
hasher.update(desc.as_bytes());
}
if let Some(ref design) = self.design {
hasher.update(design.as_bytes());
}
if let Some(ref ac) = self.acceptance_criteria {
hasher.update(ac.as_bytes());
}
if let Some(ref notes) = self.notes {
hasher.update(notes.as_bytes());
}
hex::encode(hasher.finalize())
}
pub fn update_content_hash(&mut self) {
self.content_hash = Some(self.compute_content_hash());
}
pub fn touch(&mut self) {
self.updated_at = Utc::now();
}
pub fn close(&mut self, reason: Option<String>) {
self.status = Status::Closed;
self.closed_at = Some(Utc::now());
self.close_reason = reason;
self.touch();
}
pub fn tombstone(&mut self, actor: &str, reason: Option<String>) {
self.status = Status::Tombstone;
self.deleted_at = Some(Utc::now());
self.deleted_by = Some(actor.to_string());
self.delete_reason = reason;
self.touch();
}
pub fn is_deleted(&self) -> bool {
self.deleted_at.is_some() || self.status == Status::Tombstone
}
pub fn is_potentially_ready(&self) -> bool {
matches!(self.status, Status::Open) && !self.is_deleted()
}
pub fn parent_id(&self) -> Option<&str> {
self.id.rsplit_once('.').map(|(parent, _)| parent)
}
pub fn has_parent(&self) -> bool {
self.id.contains('.')
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn with_type(mut self, issue_type: IssueType) -> Self {
self.issue_type = issue_type;
self
}
pub fn with_priority(mut self, priority: i32) -> Self {
self.priority = priority.clamp(0, 4);
self
}
pub fn with_assignee(mut self, assignee: impl Into<String>) -> Self {
self.assignee = Some(assignee.into());
self
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.labels.push(label.into());
self
}
}
impl Default for Issue {
fn default() -> Self {
Self::new("", "", "unknown")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_issue_creation() {
let issue = Issue::new("bd-a1b2", "Test task", "alice");
assert_eq!(issue.id, "bd-a1b2");
assert_eq!(issue.title, "Test task");
assert_eq!(issue.created_by, "alice");
assert_eq!(issue.status, Status::Open);
assert_eq!(issue.priority, 2);
}
#[test]
fn test_content_hash() {
let mut issue = Issue::new("bd-a1b2", "Test task", "alice");
issue.description = Some("Description".to_string());
let hash1 = issue.compute_content_hash();
issue.title = "Changed title".to_string();
let hash2 = issue.compute_content_hash();
assert_ne!(hash1, hash2);
}
#[test]
fn test_parent_id() {
let issue = Issue::new("bd-a1b2.1", "Child task", "alice");
assert_eq!(issue.parent_id(), Some("bd-a1b2"));
assert!(issue.has_parent());
let parent = Issue::new("bd-a1b2", "Parent task", "alice");
assert_eq!(parent.parent_id(), None);
assert!(!parent.has_parent());
}
#[test]
fn test_tombstone() {
let mut issue = Issue::new("bd-a1b2", "Test task", "alice");
issue.tombstone("bob", Some("Duplicate".to_string()));
assert!(issue.is_deleted());
assert_eq!(issue.status, Status::Tombstone);
assert_eq!(issue.deleted_by.as_deref(), Some("bob"));
assert_eq!(issue.delete_reason.as_deref(), Some("Duplicate"));
}
}