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(|node| ProjectItem::from_node(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) => (
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        // Extract custom fields
321        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/// Content type (Issue or PR)
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
369pub enum ContentType {
370    Issue,
371    PullRequest,
372}
373
374/// KPI report from project data
375#[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// GraphQL response types (internal)
388
389#[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// Field query response types
480
481#[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        // Test ProjectItem structure
515        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}