1use crate::model::{Comment, Event, Issue, IssueType, Priority, Status};
2use chrono::{DateTime, Utc};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
9pub struct StaleIssue {
10 pub created_at: DateTime<Utc>,
11 pub id: String,
12 pub issue_type: IssueType,
13 pub priority: Priority,
14 pub status: Status,
15 pub title: String,
16 pub updated_at: DateTime<Utc>,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub assignee: Option<String>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26pub struct ReadyIssue {
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub acceptance_criteria: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub assignee: Option<String>,
31 pub created_at: DateTime<Utc>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub created_by: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub description: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub estimated_minutes: Option<i32>,
38 pub id: String,
39 pub issue_type: IssueType,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub notes: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub owner: Option<String>,
44 pub priority: Priority,
45 pub status: Status,
46 pub title: String,
47 pub updated_at: DateTime<Utc>,
48}
49
50impl From<Issue> for ReadyIssue {
51 fn from(issue: Issue) -> Self {
52 Self {
53 acceptance_criteria: issue.acceptance_criteria,
54 assignee: issue.assignee,
55 created_at: issue.created_at,
56 created_by: issue.created_by,
57 description: issue.description,
58 estimated_minutes: issue.estimated_minutes,
59 id: issue.id,
60 issue_type: issue.issue_type,
61 notes: issue.notes,
62 owner: issue.owner,
63 priority: issue.priority,
64 status: issue.status,
65 title: issue.title,
66 updated_at: issue.updated_at,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
76pub struct BlockedIssueOutput {
77 pub blocked_by: Vec<String>,
78 pub blocked_by_count: usize,
79 pub created_at: DateTime<Utc>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub created_by: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub description: Option<String>,
84 pub id: String,
85 pub issue_type: IssueType,
86 pub priority: Priority,
87 pub status: Status,
88 pub title: String,
89 pub updated_at: DateTime<Utc>,
90}
91
92impl From<Issue> for StaleIssue {
93 fn from(issue: Issue) -> Self {
94 Self {
95 created_at: issue.created_at,
96 id: issue.id,
97 issue_type: issue.issue_type,
98 priority: issue.priority,
99 status: issue.status,
100 title: issue.title,
101 updated_at: issue.updated_at,
102 assignee: issue.assignee,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
109pub struct IssueWithCounts {
110 #[serde(flatten)]
111 pub issue: Issue,
112 pub dependency_count: usize,
113 pub dependent_count: usize,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
121pub struct ListPage {
122 pub issues: Vec<IssueWithCounts>,
124 pub total: usize,
126 pub limit: usize,
128 pub offset: usize,
130 pub has_more: bool,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
136pub struct IssueDetails {
137 #[serde(flatten)]
138 pub issue: Issue,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub labels: Vec<String>,
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub dependencies: Vec<IssueWithDependencyMetadata>,
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub dependents: Vec<IssueWithDependencyMetadata>,
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub comments: Vec<Comment>,
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub events: Vec<Event>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub parent: Option<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
154pub struct IssueWithDependencyMetadata {
155 pub id: String,
156 pub title: String,
157 pub status: Status,
158 pub priority: Priority,
159 #[serde(rename = "dependency_type")]
160 pub dep_type: String,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
165pub struct BlockedIssue {
166 #[serde(flatten)]
167 pub issue: Issue,
168 pub blocked_by_count: usize,
169 pub blocked_by: Vec<String>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
174pub struct TreeNode {
175 #[serde(flatten)]
176 pub issue: Issue,
177 pub depth: usize,
178 pub parent_id: Option<String>,
179 pub truncated: bool,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
184pub struct StatsSummary {
185 pub total_issues: usize,
186 pub open_issues: usize,
187 pub in_progress_issues: usize,
188 pub closed_issues: usize,
189 pub blocked_issues: usize,
190 pub deferred_issues: usize,
191 pub draft_issues: usize,
192 pub ready_issues: usize,
193 pub tombstone_issues: usize,
194 pub pinned_issues: usize,
195 pub epics_eligible_for_closure: usize,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub average_lead_time_hours: Option<f64>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
202pub struct Breakdown {
203 pub dimension: String,
204 pub counts: Vec<BreakdownEntry>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
209pub struct BreakdownEntry {
210 pub key: String,
211 pub count: usize,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
216pub struct RecentActivity {
217 pub hours_tracked: u32,
218 pub commit_count: usize,
219 pub issues_created: usize,
220 pub issues_closed: usize,
221 pub issues_updated: usize,
222 pub issues_reopened: usize,
223 pub total_changes: usize,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
228pub struct Statistics {
229 pub summary: StatsSummary,
230 #[serde(skip_serializing_if = "Vec::is_empty")]
231 pub breakdowns: Vec<Breakdown>,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 pub recent_activity: Option<RecentActivity>,
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use chrono::{TimeZone, Utc};
240
241 fn base_issue(id: &str, title: &str) -> Issue {
242 Issue {
243 id: id.to_string(),
244 content_hash: None,
245 title: title.to_string(),
246 description: None,
247 design: None,
248 acceptance_criteria: None,
249 notes: None,
250 status: Status::Open,
251 priority: Priority::MEDIUM,
252 issue_type: crate::model::IssueType::Task,
253 assignee: None,
254 owner: None,
255 estimated_minutes: None,
256 created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
257 created_by: None,
258 updated_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
259 closed_at: None,
260 close_reason: None,
261 closed_by_session: None,
262 due_at: None,
263 defer_until: None,
264 external_ref: None,
265 source_system: None,
266 source_repo: None,
267 deleted_at: None,
268 deleted_by: None,
269 delete_reason: None,
270 original_type: None,
271 compaction_level: None,
272 compacted_at: None,
273 compacted_at_commit: None,
274 original_size: None,
275 sender: None,
276 ephemeral: false,
277 pinned: false,
278 is_template: false,
279 labels: vec![],
280 dependencies: vec![],
281 comments: vec![],
282 }
283 }
284
285 #[test]
286 fn issue_with_counts_serializes_counts() {
287 let issue = base_issue("bd-1", "Test");
288 let iwc = IssueWithCounts {
289 issue,
290 dependency_count: 2,
291 dependent_count: 1,
292 };
293
294 let json = serde_json::to_string(&iwc).unwrap();
295 assert!(json.contains("\"dependency_count\":2"));
296 assert!(json.contains("\"dependent_count\":1"));
297 assert!(json.contains("\"id\":\"bd-1\""));
298 }
299
300 #[test]
301 fn issue_details_serializes_parent_and_relations() {
302 let issue = base_issue("bd-2", "Details");
303 let details = IssueDetails {
304 issue,
305 labels: vec!["backend".to_string()],
306 dependencies: vec![],
307 dependents: vec![],
308 comments: vec![],
309 events: vec![],
310 parent: Some("bd-parent".to_string()),
311 };
312
313 let json = serde_json::to_string(&details).unwrap();
314 assert!(json.contains("\"parent\":\"bd-parent\""));
315 assert!(json.contains("\"labels\":[\"backend\"]"));
316 }
317
318 #[test]
319 fn blocked_issue_serializes_blockers() {
320 let issue = base_issue("bd-3", "Blocked");
321 let blocked = BlockedIssue {
322 issue,
323 blocked_by_count: 2,
324 blocked_by: vec!["bd-a".to_string(), "bd-b".to_string()],
325 };
326
327 let json = serde_json::to_string(&blocked).unwrap();
328 assert!(json.contains("\"blocked_by_count\":2"));
329 assert!(json.contains("\"blocked_by\":[\"bd-a\",\"bd-b\"]"));
330 }
331}