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(|node| ProjectItem::from_node(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) => (
312 ContentType::Issue,
313 issue.number,
314 issue.title,
315 issue.state,
316 ),
317 Content::PullRequest(pr) => (ContentType::PullRequest, pr.number, pr.title, pr.state),
318 };
319
320 let mut agent = None;
322 let mut status = String::from("Pending");
323 let mut priority = None;
324 let mut phase = None;
325 let mut estimated_hours = None;
326 let mut actual_hours = None;
327 let mut quality_score = None;
328 let mut cost_usd = None;
329
330 for field_value in node.field_values.nodes {
331 match field_value {
332 FieldValue::SingleSelect { name, field } => match field.name.as_str() {
333 "Agent" => agent = Some(name),
334 "Status" => status = name,
335 "Priority" => priority = Some(name),
336 "Phase" => phase = Some(name),
337 _ => {}
338 },
339 FieldValue::Number { number, field } => match field.name.as_str() {
340 "Estimated Hours" => estimated_hours = Some(number),
341 "Actual Hours" => actual_hours = Some(number),
342 "Quality Score" => quality_score = Some(number),
343 "Cost (USD)" => cost_usd = Some(number),
344 _ => {}
345 },
346 }
347 }
348
349 Self {
350 id: node.id,
351 content_type,
352 number,
353 title,
354 state,
355 agent,
356 status,
357 priority,
358 phase,
359 estimated_hours,
360 actual_hours,
361 quality_score,
362 cost_usd,
363 }
364 }
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
369pub enum ContentType {
370 Issue,
371 PullRequest,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct KPIReport {
377 pub total_tasks: usize,
378 pub completed_tasks: usize,
379 pub completion_rate: f64,
380 pub total_hours: f64,
381 pub total_cost: f64,
382 pub avg_quality_score: f64,
383 pub by_agent: std::collections::HashMap<String, usize>,
384 pub by_phase: std::collections::HashMap<String, usize>,
385}
386
387#[derive(Debug, Deserialize)]
390struct ProjectResponse {
391 data: ProjectData,
392}
393
394#[derive(Debug, Deserialize)]
395struct ProjectData {
396 user: User,
397}
398
399#[derive(Debug, Deserialize)]
400struct User {
401 #[serde(rename = "projectV2")]
402 project_v2: ProjectV2,
403}
404
405#[derive(Debug, Deserialize)]
406struct ProjectV2 {
407 id: String,
408 items: Items,
409}
410
411#[derive(Debug, Deserialize)]
412struct Items {
413 nodes: Vec<ProjectItemNode>,
414}
415
416#[derive(Debug, Deserialize)]
417struct ProjectItemNode {
418 id: String,
419 content: Content,
420 #[serde(rename = "fieldValues")]
421 field_values: FieldValues,
422}
423
424#[derive(Debug, Deserialize)]
425#[serde(untagged)]
426enum Content {
427 Issue(IssueContent),
428 PullRequest(PRContent),
429}
430
431#[derive(Debug, Deserialize)]
432struct IssueContent {
433 number: u64,
434 title: String,
435 state: String,
436 labels: Labels,
437}
438
439#[derive(Debug, Deserialize)]
440struct PRContent {
441 number: u64,
442 title: String,
443 state: String,
444}
445
446#[derive(Debug, Deserialize)]
447struct Labels {
448 nodes: Vec<LabelNode>,
449}
450
451#[derive(Debug, Deserialize)]
452struct LabelNode {
453 name: String,
454}
455
456#[derive(Debug, Deserialize)]
457struct FieldValues {
458 nodes: Vec<FieldValue>,
459}
460
461#[derive(Debug, Deserialize)]
462#[serde(untagged)]
463enum FieldValue {
464 SingleSelect {
465 name: String,
466 field: FieldName,
467 },
468 Number {
469 number: f64,
470 field: FieldName,
471 },
472}
473
474#[derive(Debug, Deserialize)]
475struct FieldName {
476 name: String,
477}
478
479#[derive(Debug, Deserialize)]
482struct FieldQueryResponse {
483 data: FieldQueryData,
484}
485
486#[derive(Debug, Deserialize)]
487struct FieldQueryData {
488 node: FieldQueryNode,
489}
490
491#[derive(Debug, Deserialize)]
492struct FieldQueryNode {
493 field: Option<FieldInfo>,
494}
495
496#[derive(Debug, Deserialize)]
497struct FieldInfo {
498 id: String,
499 options: Vec<FieldOption>,
500}
501
502#[derive(Debug, Deserialize)]
503struct FieldOption {
504 id: String,
505 name: String,
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn test_project_item_creation() {
514 let item = ProjectItem {
516 id: "PVTI_lADO...".to_string(),
517 content_type: ContentType::Issue,
518 number: 270,
519 title: "Test Issue".to_string(),
520 state: "OPEN".to_string(),
521 agent: Some("CoordinatorAgent".to_string()),
522 status: "In Progress".to_string(),
523 priority: Some("P1-High".to_string()),
524 phase: Some("Phase 5".to_string()),
525 estimated_hours: Some(8.0),
526 actual_hours: Some(6.5),
527 quality_score: Some(85.0),
528 cost_usd: Some(1.25),
529 };
530
531 assert_eq!(item.content_type, ContentType::Issue);
532 assert_eq!(item.number, 270);
533 assert_eq!(item.status, "In Progress");
534 }
535
536 #[test]
537 fn test_kpi_report_creation() {
538 let report = KPIReport {
539 total_tasks: 100,
540 completed_tasks: 45,
541 completion_rate: 45.0,
542 total_hours: 450.0,
543 total_cost: 12.50,
544 avg_quality_score: 87.5,
545 by_agent: std::collections::HashMap::new(),
546 by_phase: std::collections::HashMap::new(),
547 };
548
549 assert_eq!(report.completion_rate, 45.0);
550 assert_eq!(report.total_tasks, 100);
551 }
552}