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.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        // Update the field value
190        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    /// Calculate KPIs from project data
226    ///
227    /// # Arguments
228    /// * `project_number` - Project number
229    ///
230    /// # Returns
231    /// KPIReport with aggregated metrics
232    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        // Group by agent
254        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        // Group by phase
262        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/// Project item (Issue or PR) with custom fields
283#[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    // Custom fields (Phase A)
291    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        // Extract custom fields
309        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/// Content type (Issue or PR)
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
357pub enum ContentType {
358    Issue,
359    PullRequest,
360}
361
362/// KPI report from project data
363#[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// GraphQL response types (internal)
376
377#[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// Field query response types
466
467#[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        // Test ProjectItem structure
501        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}