miyabi_github/
projects.rs

1//! GitHub Projects V2 API integration
2//!
3//! Provides GraphQL-based access to GitHub Projects V2 (Project Boards)
4//! for use as Miyabi's data persistence layer.
5//!
6//! # Features
7//!
8//! - Query project items (issues/PRs) with custom fields
9//! - Update custom field values (Status, Agent, Priority, etc.)
10//! - Calculate KPIs from project data
11//! - Support for 8 custom fields defined in Phase A
12
13use miyabi_types::error::{MiyabiError, Result};
14use serde::{Deserialize, Serialize};
15
16use crate::GitHubClient;
17
18/// GitHub Projects V2 client
19impl GitHubClient {
20    /// Get all items from a GitHub Project V2
21    ///
22    /// # Arguments
23    /// * `project_number` - Project number (e.g., 1 for /projects/1)
24    ///
25    /// # Example
26    /// ```no_run
27    /// use miyabi_github::GitHubClient;
28    ///
29    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
30    /// let client = GitHubClient::new("ghp_xxx", "owner", "repo")?;
31    /// let items = client.get_project_items(1).await?;
32    /// println!("Found {} items", items.len());
33    /// # Ok(())
34    /// # }
35    /// ```
36    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    /// Update a custom field value for a project item
115    ///
116    /// # Arguments
117    /// * `project_id` - Project node ID (e.g., "PVT_kwDOAB...")
118    /// * `item_id` - Project item node ID
119    /// * `field_name` - Custom field name (e.g., "Status", "Agent")
120    /// * `value` - New value
121    ///
122    /// # Example
123    /// ```no_run
124    /// use miyabi_github::GitHubClient;
125    ///
126    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
127    /// let client = GitHubClient::new("ghp_xxx", "owner", "repo")?;
128    /// client.update_project_field(
129    ///     "PVT_kwDOAB...",
130    ///     "PVTI_lADO...",
131    ///     "Status",
132    ///     "Done"
133    /// ).await?;
134    /// # Ok(())
135    /// # }
136    /// ```
137    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        // First, get field ID and option ID
145        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        // Update the field value
197        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    /// Calculate KPIs from project data
233    ///
234    /// # Arguments
235    /// * `project_number` - Project number
236    ///
237    /// # Returns
238    /// KPIReport with aggregated metrics
239    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        // Group by agent
261        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        // Group by phase
269        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/// Project item (Issue or PR) with custom fields
290#[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    // Custom fields (Phase A)
298    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        // Extract custom fields
316        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/// Content type (Issue or PR)
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
364pub enum ContentType {
365    Issue,
366    PullRequest,
367}
368
369/// KPI report from project data
370#[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// GraphQL response types (internal)
383
384#[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// Field query response types
473
474#[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        // Test ProjectItem structure
508        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}