1use miyabi_types::error::{MiyabiError, Result};
14use serde::{Deserialize, Serialize};
15
16use crate::GitHubClient;
17
18impl GitHubClient {
20 pub async fn get_project_items(&self, project_number: u32) -> Result<Vec<ProjectItem>> {
37 let query = r#"
38 query($owner: String!, $number: Int!) {
39 user(login: $owner) {
40 projectV2(number: $number) {
41 id
42 items(first: 100) {
43 nodes {
44 id
45 content {
46 ... on Issue {
47 number
48 title
49 state
50 labels(first: 10) {
51 nodes {
52 name
53 }
54 }
55 }
56 ... on PullRequest {
57 number
58 title
59 state
60 }
61 }
62 fieldValues(first: 20) {
63 nodes {
64 ... on ProjectV2ItemFieldSingleSelectValue {
65 name
66 field {
67 ... on ProjectV2SingleSelectField {
68 name
69 }
70 }
71 }
72 ... on ProjectV2ItemFieldNumberValue {
73 number
74 field {
75 ... on ProjectV2Field {
76 name
77 }
78 }
79 }
80 }
81 }
82 }
83 }
84 }
85 }
86 }
87 "#;
88
89 let variables = serde_json::json!({
90 "owner": self.owner(),
91 "number": project_number as i64,
92 });
93
94 let response: ProjectResponse = self
95 .client
96 .graphql(&serde_json::json!({
97 "query": query,
98 "variables": variables
99 }))
100 .await
101 .map_err(|e| MiyabiError::GitHub(format!("Failed to query project items: {}", e)))?;
102
103 Ok(response
104 .data
105 .user
106 .project_v2
107 .items
108 .nodes
109 .into_iter()
110 .map(ProjectItem::from_node)
111 .collect())
112 }
113
114 pub async fn update_project_field(
138 &self,
139 project_id: &str,
140 item_id: &str,
141 field_name: &str,
142 value: &str,
143 ) -> Result<()> {
144 let field_query = r#"
146 query($projectId: ID!, $fieldName: String!) {
147 node(id: $projectId) {
148 ... on ProjectV2 {
149 field(name: $fieldName) {
150 ... on ProjectV2SingleSelectField {
151 id
152 options {
153 id
154 name
155 }
156 }
157 }
158 }
159 }
160 }
161 "#;
162
163 let field_vars = serde_json::json!({
164 "projectId": project_id,
165 "fieldName": field_name,
166 });
167
168 let field_response: FieldQueryResponse = self
169 .client
170 .graphql(&serde_json::json!({
171 "query": field_query,
172 "variables": field_vars
173 }))
174 .await
175 .map_err(|e| {
176 MiyabiError::GitHub(format!("Failed to query field {}: {}", field_name, e))
177 })?;
178
179 let field = field_response
180 .data
181 .node
182 .field
183 .ok_or_else(|| MiyabiError::GitHub(format!("Field '{}' not found", field_name)))?;
184
185 let option = field.options.iter().find(|opt| opt.name == value).ok_or_else(|| {
186 MiyabiError::GitHub(format!("Option '{}' not found in field '{}'", value, field_name))
187 })?;
188
189 let update_mutation = r#"
191 mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
192 updateProjectV2ItemFieldValue(input: {
193 projectId: $projectId
194 itemId: $itemId
195 fieldId: $fieldId
196 value: { singleSelectOptionId: $optionId }
197 }) {
198 projectV2Item {
199 id
200 }
201 }
202 }
203 "#;
204
205 let update_vars = serde_json::json!({
206 "projectId": project_id,
207 "itemId": item_id,
208 "fieldId": field.id,
209 "optionId": option.id,
210 });
211
212 self.client
213 .graphql::<serde_json::Value>(&serde_json::json!({
214 "query": update_mutation,
215 "variables": update_vars
216 }))
217 .await
218 .map_err(|e| {
219 MiyabiError::GitHub(format!("Failed to update field {}: {}", field_name, e))
220 })?;
221
222 Ok(())
223 }
224
225 pub async fn calculate_project_kpis(&self, project_number: u32) -> Result<KPIReport> {
233 let items = self.get_project_items(project_number).await?;
234
235 let total_tasks = items.len();
236 let completed_tasks = items.iter().filter(|i| i.status == "Done").count();
237 let completion_rate = if total_tasks > 0 {
238 (completed_tasks as f64 / total_tasks as f64) * 100.0
239 } else {
240 0.0
241 };
242
243 let total_hours: f64 = items.iter().filter_map(|i| i.actual_hours).sum();
244 let total_cost: f64 = items.iter().filter_map(|i| i.cost_usd).sum();
245
246 let quality_scores: Vec<f64> = items.iter().filter_map(|i| i.quality_score).collect();
247 let avg_quality_score = if !quality_scores.is_empty() {
248 quality_scores.iter().sum::<f64>() / quality_scores.len() as f64
249 } else {
250 0.0
251 };
252
253 let mut by_agent = std::collections::HashMap::new();
255 for item in &items {
256 if let Some(ref agent) = item.agent {
257 *by_agent.entry(agent.clone()).or_insert(0) += 1;
258 }
259 }
260
261 let mut by_phase = std::collections::HashMap::new();
263 for item in &items {
264 if let Some(ref phase) = item.phase {
265 *by_phase.entry(phase.clone()).or_insert(0) += 1;
266 }
267 }
268
269 Ok(KPIReport {
270 total_tasks,
271 completed_tasks,
272 completion_rate,
273 total_hours,
274 total_cost,
275 avg_quality_score,
276 by_agent,
277 by_phase,
278 })
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct ProjectItem {
285 pub id: String,
286 pub content_type: ContentType,
287 pub number: u64,
288 pub title: String,
289 pub state: String,
290 pub agent: Option<String>,
292 pub status: String,
293 pub priority: Option<String>,
294 pub phase: Option<String>,
295 pub estimated_hours: Option<f64>,
296 pub actual_hours: Option<f64>,
297 pub quality_score: Option<f64>,
298 pub cost_usd: Option<f64>,
299}
300
301impl ProjectItem {
302 fn from_node(node: ProjectItemNode) -> Self {
303 let (content_type, number, title, state) = match node.content {
304 Content::Issue(issue) => (ContentType::Issue, issue.number, issue.title, issue.state),
305 Content::PullRequest(pr) => (ContentType::PullRequest, pr.number, pr.title, pr.state),
306 };
307
308 let mut agent = None;
310 let mut status = String::from("Pending");
311 let mut priority = None;
312 let mut phase = None;
313 let mut estimated_hours = None;
314 let mut actual_hours = None;
315 let mut quality_score = None;
316 let mut cost_usd = None;
317
318 for field_value in node.field_values.nodes {
319 match field_value {
320 FieldValue::SingleSelect { name, field } => match field.name.as_str() {
321 "Agent" => agent = Some(name),
322 "Status" => status = name,
323 "Priority" => priority = Some(name),
324 "Phase" => phase = Some(name),
325 _ => {},
326 },
327 FieldValue::Number { number, field } => match field.name.as_str() {
328 "Estimated Hours" => estimated_hours = Some(number),
329 "Actual Hours" => actual_hours = Some(number),
330 "Quality Score" => quality_score = Some(number),
331 "Cost (USD)" => cost_usd = Some(number),
332 _ => {},
333 },
334 }
335 }
336
337 Self {
338 id: node.id,
339 content_type,
340 number,
341 title,
342 state,
343 agent,
344 status,
345 priority,
346 phase,
347 estimated_hours,
348 actual_hours,
349 quality_score,
350 cost_usd,
351 }
352 }
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
357pub enum ContentType {
358 Issue,
359 PullRequest,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct KPIReport {
365 pub total_tasks: usize,
366 pub completed_tasks: usize,
367 pub completion_rate: f64,
368 pub total_hours: f64,
369 pub total_cost: f64,
370 pub avg_quality_score: f64,
371 pub by_agent: std::collections::HashMap<String, usize>,
372 pub by_phase: std::collections::HashMap<String, usize>,
373}
374
375#[derive(Debug, Deserialize)]
378struct ProjectResponse {
379 data: ProjectData,
380}
381
382#[derive(Debug, Deserialize)]
383struct ProjectData {
384 user: User,
385}
386
387#[derive(Debug, Deserialize)]
388struct User {
389 #[serde(rename = "projectV2")]
390 project_v2: ProjectV2,
391}
392
393#[derive(Debug, Deserialize)]
394struct ProjectV2 {
395 #[allow(dead_code)]
396 id: String,
397 items: Items,
398}
399
400#[derive(Debug, Deserialize)]
401struct Items {
402 nodes: Vec<ProjectItemNode>,
403}
404
405#[derive(Debug, Deserialize)]
406struct ProjectItemNode {
407 id: String,
408 content: Content,
409 #[serde(rename = "fieldValues")]
410 field_values: FieldValues,
411}
412
413#[derive(Debug, Deserialize)]
414#[serde(untagged)]
415enum Content {
416 Issue(IssueContent),
417 PullRequest(PRContent),
418}
419
420#[derive(Debug, Deserialize)]
421struct IssueContent {
422 number: u64,
423 title: String,
424 state: String,
425 #[allow(dead_code)]
426 labels: Labels,
427}
428
429#[derive(Debug, Deserialize)]
430struct PRContent {
431 number: u64,
432 title: String,
433 state: String,
434}
435
436#[derive(Debug, Deserialize)]
437struct Labels {
438 #[allow(dead_code)]
439 nodes: Vec<LabelNode>,
440}
441
442#[derive(Debug, Deserialize)]
443struct LabelNode {
444 #[allow(dead_code)]
445 name: String,
446}
447
448#[derive(Debug, Deserialize)]
449struct FieldValues {
450 nodes: Vec<FieldValue>,
451}
452
453#[derive(Debug, Deserialize)]
454#[serde(untagged)]
455enum FieldValue {
456 SingleSelect { name: String, field: FieldName },
457 Number { number: f64, field: FieldName },
458}
459
460#[derive(Debug, Deserialize)]
461struct FieldName {
462 name: String,
463}
464
465#[derive(Debug, Deserialize)]
468struct FieldQueryResponse {
469 data: FieldQueryData,
470}
471
472#[derive(Debug, Deserialize)]
473struct FieldQueryData {
474 node: FieldQueryNode,
475}
476
477#[derive(Debug, Deserialize)]
478struct FieldQueryNode {
479 field: Option<FieldInfo>,
480}
481
482#[derive(Debug, Deserialize)]
483struct FieldInfo {
484 id: String,
485 options: Vec<FieldOption>,
486}
487
488#[derive(Debug, Deserialize)]
489struct FieldOption {
490 id: String,
491 name: String,
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn test_project_item_creation() {
500 let item = ProjectItem {
502 id: "PVTI_lADO...".to_string(),
503 content_type: ContentType::Issue,
504 number: 270,
505 title: "Test Issue".to_string(),
506 state: "OPEN".to_string(),
507 agent: Some("CoordinatorAgent".to_string()),
508 status: "In Progress".to_string(),
509 priority: Some("P1-High".to_string()),
510 phase: Some("Phase 5".to_string()),
511 estimated_hours: Some(8.0),
512 actual_hours: Some(6.5),
513 quality_score: Some(85.0),
514 cost_usd: Some(1.25),
515 };
516
517 assert_eq!(item.content_type, ContentType::Issue);
518 assert_eq!(item.number, 270);
519 assert_eq!(item.status, "In Progress");
520 }
521
522 #[test]
523 fn test_kpi_report_creation() {
524 let report = KPIReport {
525 total_tasks: 100,
526 completed_tasks: 45,
527 completion_rate: 45.0,
528 total_hours: 450.0,
529 total_cost: 12.50,
530 avg_quality_score: 87.5,
531 by_agent: std::collections::HashMap::new(),
532 by_phase: std::collections::HashMap::new(),
533 };
534
535 assert_eq!(report.completion_rate, 45.0);
536 assert_eq!(report.total_tasks, 100);
537 }
538}