Skip to main content

symphony_core/
issue.rs

1//! Normalized issue record (Spec Section 4.1.1).
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6/// A normalized issue from the tracker, used for orchestration, prompt rendering,
7/// and observability output.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Issue {
10    /// Stable tracker-internal ID.
11    pub id: String,
12    /// Human-readable ticket key (e.g. `ABC-123`).
13    pub identifier: String,
14    pub title: String,
15    pub description: Option<String>,
16    /// Lower numbers are higher priority in dispatch sorting.
17    pub priority: Option<i32>,
18    /// Current tracker state name.
19    pub state: String,
20    /// Tracker-provided branch metadata if available.
21    pub branch_name: Option<String>,
22    pub url: Option<String>,
23    /// Normalized to lowercase.
24    pub labels: Vec<String>,
25    pub blocked_by: Vec<BlockerRef>,
26    pub created_at: Option<DateTime<Utc>>,
27    pub updated_at: Option<DateTime<Utc>>,
28}
29
30/// A reference to a blocking issue.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct BlockerRef {
33    pub id: Option<String>,
34    pub identifier: Option<String>,
35    pub state: Option<String>,
36}
37
38impl Issue {
39    /// Sanitize the identifier to a workspace-safe key.
40    /// Replace any character not in `[A-Za-z0-9._-]` with `_`.
41    pub fn workspace_key(&self) -> String {
42        self.identifier
43            .chars()
44            .map(|c| {
45                if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
46                    c
47                } else {
48                    '_'
49                }
50            })
51            .collect()
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn workspace_key_sanitizes_identifier() {
61        let issue = Issue {
62            id: "id1".into(),
63            identifier: "ABC-123".into(),
64            title: "Test".into(),
65            description: None,
66            priority: Some(1),
67            state: "Todo".into(),
68            branch_name: None,
69            url: None,
70            labels: vec![],
71            blocked_by: vec![],
72            created_at: None,
73            updated_at: None,
74        };
75        assert_eq!(issue.workspace_key(), "ABC-123");
76    }
77
78    #[test]
79    fn workspace_key_replaces_special_chars() {
80        let issue = Issue {
81            id: "id2".into(),
82            identifier: "PROJ/feat#42".into(),
83            title: "Test".into(),
84            description: None,
85            priority: None,
86            state: "Todo".into(),
87            branch_name: None,
88            url: None,
89            labels: vec![],
90            blocked_by: vec![],
91            created_at: None,
92            updated_at: None,
93        };
94        assert_eq!(issue.workspace_key(), "PROJ_feat_42");
95    }
96}