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
186 .options
187 .iter()
188 .find(|opt| opt.name == value)
189 .ok_or_else(|| {
190 MiyabiError::GitHub(format!(
191 "Option '{}' not found in field '{}'",
192 value, field_name
193 ))
194 })?;
195
196 let update_mutation = r#"
198 mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
199 updateProjectV2ItemFieldValue(input: {
200 projectId: $projectId
201 itemId: $itemId
202 fieldId: $fieldId
203 value: { singleSelectOptionId: $optionId }
204 }) {
205 projectV2Item {
206 id
207 }
208 }
209 }
210 "#;
211
212 let update_vars = serde_json::json!({
213 "projectId": project_id,
214 "itemId": item_id,
215 "fieldId": field.id,
216 "optionId": option.id,
217 });
218
219 self.client
220 .graphql::<serde_json::Value>(&serde_json::json!({
221 "query": update_mutation,
222 "variables": update_vars
223 }))
224 .await
225 .map_err(|e| {
226 MiyabiError::GitHub(format!("Failed to update field {}: {}", field_name, e))
227 })?;
228
229 Ok(())
230 }
231
232 pub async fn calculate_project_kpis(&self, project_number: u32) -> Result<KPIReport> {
240 let items = self.get_project_items(project_number).await?;
241
242 let total_tasks = items.len();
243 let completed_tasks = items.iter().filter(|i| i.status == "Done").count();
244 let completion_rate = if total_tasks > 0 {
245 (completed_tasks as f64 / total_tasks as f64) * 100.0
246 } else {
247 0.0
248 };
249
250 let total_hours: f64 = items.iter().filter_map(|i| i.actual_hours).sum();
251 let total_cost: f64 = items.iter().filter_map(|i| i.cost_usd).sum();
252
253 let quality_scores: Vec<f64> = items.iter().filter_map(|i| i.quality_score).collect();
254 let avg_quality_score = if !quality_scores.is_empty() {
255 quality_scores.iter().sum::<f64>() / quality_scores.len() as f64
256 } else {
257 0.0
258 };
259
260 let mut by_agent = std::collections::HashMap::new();
262 for item in &items {
263 if let Some(ref agent) = item.agent {
264 *by_agent.entry(agent.clone()).or_insert(0) += 1;
265 }
266 }
267
268 let mut by_phase = std::collections::HashMap::new();
270 for item in &items {
271 if let Some(ref phase) = item.phase {
272 *by_phase.entry(phase.clone()).or_insert(0) += 1;
273 }
274 }
275
276 Ok(KPIReport {
277 total_tasks,
278 completed_tasks,
279 completion_rate,
280 total_hours,
281 total_cost,
282 avg_quality_score,
283 by_agent,
284 by_phase,
285 })
286 }
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct ProjectItem {
292 pub id: String,
293 pub content_type: ContentType,
294 pub number: u64,
295 pub title: String,
296 pub state: String,
297 pub agent: Option<String>,
299 pub status: String,
300 pub priority: Option<String>,
301 pub phase: Option<String>,
302 pub estimated_hours: Option<f64>,
303 pub actual_hours: Option<f64>,
304 pub quality_score: Option<f64>,
305 pub cost_usd: Option<f64>,
306}
307
308impl ProjectItem {
309 fn from_node(node: ProjectItemNode) -> Self {
310 let (content_type, number, title, state) = match node.content {
311 Content::Issue(issue) => (ContentType::Issue, issue.number, issue.title, issue.state),
312 Content::PullRequest(pr) => (ContentType::PullRequest, pr.number, pr.title, pr.state),
313 };
314
315 let mut agent = None;
317 let mut status = String::from("Pending");
318 let mut priority = None;
319 let mut phase = None;
320 let mut estimated_hours = None;
321 let mut actual_hours = None;
322 let mut quality_score = None;
323 let mut cost_usd = None;
324
325 for field_value in node.field_values.nodes {
326 match field_value {
327 FieldValue::SingleSelect { name, field } => match field.name.as_str() {
328 "Agent" => agent = Some(name),
329 "Status" => status = name,
330 "Priority" => priority = Some(name),
331 "Phase" => phase = Some(name),
332 _ => {}
333 },
334 FieldValue::Number { number, field } => match field.name.as_str() {
335 "Estimated Hours" => estimated_hours = Some(number),
336 "Actual Hours" => actual_hours = Some(number),
337 "Quality Score" => quality_score = Some(number),
338 "Cost (USD)" => cost_usd = Some(number),
339 _ => {}
340 },
341 }
342 }
343
344 Self {
345 id: node.id,
346 content_type,
347 number,
348 title,
349 state,
350 agent,
351 status,
352 priority,
353 phase,
354 estimated_hours,
355 actual_hours,
356 quality_score,
357 cost_usd,
358 }
359 }
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
364pub enum ContentType {
365 Issue,
366 PullRequest,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct KPIReport {
372 pub total_tasks: usize,
373 pub completed_tasks: usize,
374 pub completion_rate: f64,
375 pub total_hours: f64,
376 pub total_cost: f64,
377 pub avg_quality_score: f64,
378 pub by_agent: std::collections::HashMap<String, usize>,
379 pub by_phase: std::collections::HashMap<String, usize>,
380}
381
382#[derive(Debug, Deserialize)]
385struct ProjectResponse {
386 data: ProjectData,
387}
388
389#[derive(Debug, Deserialize)]
390struct ProjectData {
391 user: User,
392}
393
394#[derive(Debug, Deserialize)]
395struct User {
396 #[serde(rename = "projectV2")]
397 project_v2: ProjectV2,
398}
399
400#[derive(Debug, Deserialize)]
401struct ProjectV2 {
402 #[allow(dead_code)]
403 id: String,
404 items: Items,
405}
406
407#[derive(Debug, Deserialize)]
408struct Items {
409 nodes: Vec<ProjectItemNode>,
410}
411
412#[derive(Debug, Deserialize)]
413struct ProjectItemNode {
414 id: String,
415 content: Content,
416 #[serde(rename = "fieldValues")]
417 field_values: FieldValues,
418}
419
420#[derive(Debug, Deserialize)]
421#[serde(untagged)]
422enum Content {
423 Issue(IssueContent),
424 PullRequest(PRContent),
425}
426
427#[derive(Debug, Deserialize)]
428struct IssueContent {
429 number: u64,
430 title: String,
431 state: String,
432 #[allow(dead_code)]
433 labels: Labels,
434}
435
436#[derive(Debug, Deserialize)]
437struct PRContent {
438 number: u64,
439 title: String,
440 state: String,
441}
442
443#[derive(Debug, Deserialize)]
444struct Labels {
445 #[allow(dead_code)]
446 nodes: Vec<LabelNode>,
447}
448
449#[derive(Debug, Deserialize)]
450struct LabelNode {
451 #[allow(dead_code)]
452 name: String,
453}
454
455#[derive(Debug, Deserialize)]
456struct FieldValues {
457 nodes: Vec<FieldValue>,
458}
459
460#[derive(Debug, Deserialize)]
461#[serde(untagged)]
462enum FieldValue {
463 SingleSelect { name: String, field: FieldName },
464 Number { number: f64, field: FieldName },
465}
466
467#[derive(Debug, Deserialize)]
468struct FieldName {
469 name: String,
470}
471
472#[derive(Debug, Deserialize)]
475struct FieldQueryResponse {
476 data: FieldQueryData,
477}
478
479#[derive(Debug, Deserialize)]
480struct FieldQueryData {
481 node: FieldQueryNode,
482}
483
484#[derive(Debug, Deserialize)]
485struct FieldQueryNode {
486 field: Option<FieldInfo>,
487}
488
489#[derive(Debug, Deserialize)]
490struct FieldInfo {
491 id: String,
492 options: Vec<FieldOption>,
493}
494
495#[derive(Debug, Deserialize)]
496struct FieldOption {
497 id: String,
498 name: String,
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn test_project_item_creation() {
507 let item = ProjectItem {
509 id: "PVTI_lADO...".to_string(),
510 content_type: ContentType::Issue,
511 number: 270,
512 title: "Test Issue".to_string(),
513 state: "OPEN".to_string(),
514 agent: Some("CoordinatorAgent".to_string()),
515 status: "In Progress".to_string(),
516 priority: Some("P1-High".to_string()),
517 phase: Some("Phase 5".to_string()),
518 estimated_hours: Some(8.0),
519 actual_hours: Some(6.5),
520 quality_score: Some(85.0),
521 cost_usd: Some(1.25),
522 };
523
524 assert_eq!(item.content_type, ContentType::Issue);
525 assert_eq!(item.number, 270);
526 assert_eq!(item.status, "In Progress");
527 }
528
529 #[test]
530 fn test_kpi_report_creation() {
531 let report = KPIReport {
532 total_tasks: 100,
533 completed_tasks: 45,
534 completion_rate: 45.0,
535 total_hours: 450.0,
536 total_cost: 12.50,
537 avg_quality_score: 87.5,
538 by_agent: std::collections::HashMap::new(),
539 by_phase: std::collections::HashMap::new(),
540 };
541
542 assert_eq!(report.completion_rate, 45.0);
543 assert_eq!(report.total_tasks, 100);
544 }
545}