Skip to main content

orch/github/
projects.rs

1//! GitHub Projects V2 integration — keeps project board columns in sync with task status.
2//!
3//! Uses GitHub's Projects V2 GraphQL API via the native `GhHttp` reqwest client.
4//! All operations are best-effort: failures are logged but never block task execution.
5
6use crate::backends::Status;
7use crate::config;
8use crate::github::http::GhHttp;
9use std::collections::HashMap;
10
11pub(crate) mod label_status;
12
13/// Project board info returned by list queries.
14#[derive(Debug, Clone)]
15pub struct ProjectInfo {
16    pub id: String,
17    pub number: u64,
18    pub title: String,
19}
20
21/// Keeps a GitHub Projects V2 board in sync with orch task statuses.
22///
23/// Config keys (in `~/.orch/config.yml`):
24/// ```yaml
25/// gh:
26///   project_id: "PVT_kwHO..."
27///   project_status_field_id: "PVTSSF_..."
28///   project_status_map:
29///     backlog: "option-id-1"
30///     in_progress: "option-id-2"
31///     review: "option-id-3"
32///     done: "option-id-4"
33/// ```
34pub struct ProjectSync {
35    project_id: String,
36    status_field_id: String,
37    status_map: HashMap<String, String>,
38    /// GitHub Projects V2 "Estimate" number field ID (optional — not all projects have one).
39    estimate_field_id: Option<String>,
40    gh: GhHttp,
41}
42
43impl ProjectSync {
44    /// Load from config. Returns `None` if project integration is not configured.
45    pub fn from_config() -> Option<Self> {
46        let project_id = config::get("gh.project_id").ok()?;
47        if project_id.is_empty() {
48            return None;
49        }
50        let status_field_id = config::get("gh.project_status_field_id").ok()?;
51
52        let mut status_map = HashMap::new();
53        for key in &["backlog", "in_progress", "review", "done"] {
54            if let Ok(val) = config::get(&format!("gh.project_status_map.{key}")) {
55                status_map.insert(key.to_string(), val);
56            }
57        }
58
59        // Estimate field ID is optional — not all projects have an Estimate field.
60        let estimate_field_id = config::get("gh.project_estimate_field_id")
61            .ok()
62            .filter(|v| !v.is_empty());
63
64        let gh = match GhHttp::new() {
65            Ok(gh) => gh,
66            Err(e) => {
67                tracing::warn!(error = %e, "project sync disabled: failed to build HTTP client");
68                return None;
69            }
70        };
71
72        Some(Self {
73            project_id,
74            status_field_id,
75            status_map,
76            estimate_field_id,
77            gh,
78        })
79    }
80
81    /// Discover the Status field ID and option IDs from a project.
82    ///
83    /// Queries the project's fields via GraphQL and finds the single-select
84    /// "Status" field, returning a `ProjectSync` populated with field/option IDs.
85    /// Also discovers the "Estimate" number field if present.
86    pub async fn discover_fields(project_id: &str) -> anyhow::Result<Self> {
87        let gh = GhHttp::new()?;
88        // Fetch all field types so we can find both Status (single-select) and Estimate (number).
89        let query = r#"query($projectId: ID!) { node(id: $projectId) { ... on ProjectV2 { fields(first: 100) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } ... on ProjectV2Field { id name } ... on ProjectV2FieldNumber { id name } } } } } }"#;
90
91        let result = gh
92            .graphql_with_vars(query, serde_json::json!({ "projectId": project_id }))
93            .await?;
94
95        let fields = result
96            .pointer("/data/node/fields/nodes")
97            .and_then(|n| n.as_array())
98            .ok_or_else(|| anyhow::anyhow!("failed to parse project fields response"))?;
99
100        // Find the "Status" field (case-insensitive single-select).
101        let status_field = fields
102            .iter()
103            .find(|f| {
104                f.get("name")
105                    .and_then(|n| n.as_str())
106                    .map(|n| n.eq_ignore_ascii_case("status"))
107                    .unwrap_or(false)
108            })
109            .ok_or_else(|| anyhow::anyhow!("no 'Status' field found in project"))?;
110
111        let field_id = status_field
112            .get("id")
113            .and_then(|v| v.as_str())
114            .ok_or_else(|| anyhow::anyhow!("Status field missing id"))?
115            .to_string();
116
117        let options = status_field
118            .get("options")
119            .and_then(|o| o.as_array())
120            .ok_or_else(|| anyhow::anyhow!("Status field missing options"))?;
121
122        // Map option names to their IDs, normalizing to our column keys.
123        let mut status_map = HashMap::new();
124        for opt in options {
125            let opt_id = opt.get("id").and_then(|v| v.as_str()).unwrap_or("");
126            let opt_name = opt.get("name").and_then(|v| v.as_str()).unwrap_or("");
127            let lower = opt_name.to_lowercase();
128
129            let key = if lower.contains("backlog") || lower.contains("todo") || lower == "new" {
130                "backlog"
131            } else if lower.contains("progress") || lower.contains("doing") {
132                "in_progress"
133            } else if lower.contains("review") {
134                "review"
135            } else if lower.contains("done") || lower.contains("complete") {
136                "done"
137            } else {
138                continue;
139            };
140
141            status_map.insert(key.to_string(), opt_id.to_string());
142        }
143
144        // Find the "Estimate" number field (case-insensitive).
145        let estimate_field_id = fields
146            .iter()
147            .find(|f| {
148                f.get("name")
149                    .and_then(|n| n.as_str())
150                    .map(|n| n.eq_ignore_ascii_case("estimate"))
151                    .unwrap_or(false)
152            })
153            .and_then(|f| f.get("id").and_then(|v| v.as_str()))
154            .filter(|id| !id.is_empty())
155            .map(String::from);
156
157        Ok(Self {
158            project_id: project_id.to_string(),
159            status_field_id: field_id,
160            status_map,
161            estimate_field_id,
162            gh,
163        })
164    }
165
166    /// List all accessible projects for the authenticated user and their orgs.
167    pub async fn list_projects() -> anyhow::Result<Vec<ProjectInfo>> {
168        let gh = GhHttp::new()?;
169        let mut projects = Vec::new();
170
171        // Get current user login
172        let user = gh.get_whoami().await?;
173
174        // User projects
175        let query = r#"query($login: String!) { user(login: $login) { projectsV2(first: 100) { nodes { id number title } } } }"#;
176        if let Ok(result) = gh
177            .graphql_with_vars(query, serde_json::json!({ "login": user }))
178            .await
179        {
180            if let Some(nodes) = result
181                .pointer("/data/user/projectsV2/nodes")
182                .and_then(|n| n.as_array())
183            {
184                for node in nodes {
185                    if let Some(info) = parse_project_node(node) {
186                        projects.push(info);
187                    }
188                }
189            }
190        }
191
192        // Try org projects for the repo owner (if different from user)
193        if let Ok(repo) = config::get_current_repo() {
194            if let Some(owner) = repo.split('/').next() {
195                if owner != user {
196                    let query = r#"query($login: String!) { organization(login: $login) { projectsV2(first: 100) { nodes { id number title } } } }"#;
197                    if let Ok(result) = gh
198                        .graphql_with_vars(query, serde_json::json!({ "login": owner }))
199                        .await
200                    {
201                        if let Some(nodes) = result
202                            .pointer("/data/organization/projectsV2/nodes")
203                            .and_then(|n| n.as_array())
204                        {
205                            for node in nodes {
206                                if let Some(info) = parse_project_node(node) {
207                                    projects.push(info);
208                                }
209                            }
210                        }
211                    }
212                }
213            }
214        }
215
216        Ok(projects)
217    }
218
219    /// Add an issue to the project board. Returns the project item ID.
220    pub async fn add_item(&self, issue_node_id: &str) -> anyhow::Result<String> {
221        let query = r#"mutation($projectId: ID!, $contentId: ID!) { addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { item { id } } }"#;
222
223        let result = self
224            .gh
225            .graphql_with_vars(
226                query,
227                serde_json::json!({
228                    "projectId": self.project_id,
229                    "contentId": issue_node_id,
230                }),
231            )
232            .await?;
233        result
234            .pointer("/data/addProjectV2ItemById/item/id")
235            .and_then(|v| v.as_str())
236            .map(String::from)
237            .ok_or_else(|| anyhow::anyhow!("failed to add item to project"))
238    }
239
240    /// Update an item's status column on the project board.
241    pub async fn update_item_status(&self, item_id: &str, status: &Status) -> anyhow::Result<()> {
242        let column = Self::status_to_column(status);
243        let option_id = self
244            .status_map
245            .get(column)
246            .ok_or_else(|| anyhow::anyhow!("no option ID for column: {column}"))?;
247
248        let query = r#"mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { updateProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } }) { projectV2Item { id } } }"#;
249
250        self.gh
251            .graphql_with_vars(
252                query,
253                serde_json::json!({
254                    "projectId": self.project_id,
255                    "itemId": item_id,
256                    "fieldId": self.status_field_id,
257                    "optionId": option_id,
258                }),
259            )
260            .await?;
261        Ok(())
262    }
263
264    /// Sync an issue's project board status: add to project if needed, then update column.
265    ///
266    /// This is the main entry point called from `update_status()`.
267    pub async fn sync_item_status(
268        &self,
269        issue_node_id: &str,
270        status: &Status,
271    ) -> anyhow::Result<()> {
272        // Add item to project (idempotent — returns existing item if already added)
273        let item_id = self.add_item(issue_node_id).await?;
274
275        // Update the status column
276        self.update_item_status(&item_id, status).await
277    }
278
279    /// Map orch `Status` to a project board column key.
280    pub fn status_to_column(status: &Status) -> &'static str {
281        match status {
282            Status::New | Status::Routed | Status::Waiting => "backlog",
283            Status::InProgress | Status::Blocked => "in_progress",
284            Status::InReview | Status::NeedsReview => "review",
285            Status::Done => "done",
286        }
287    }
288
289    /// Get the status field ID.
290    pub fn status_field_id(&self) -> &str {
291        &self.status_field_id
292    }
293
294    /// Get the status map (column key → option ID).
295    pub fn status_map(&self) -> &HashMap<String, String> {
296        &self.status_map
297    }
298
299    /// Get the estimate field ID, if configured.
300    pub fn estimate_field_id(&self) -> Option<&str> {
301        self.estimate_field_id.as_deref()
302    }
303
304    /// Get the project ID.
305    pub fn project_id(&self) -> &str {
306        &self.project_id
307    }
308
309    /// Update an item's Estimate number field on the project board.
310    ///
311    /// Returns the project item ID (either found or newly added).
312    pub async fn sync_item_estimate(
313        &self,
314        issue_node_id: &str,
315        estimate: u8,
316    ) -> anyhow::Result<String> {
317        let Some(field_id) = &self.estimate_field_id else {
318            return Err(anyhow::anyhow!("estimate field not configured"));
319        };
320
321        // Add item to project (idempotent — returns existing item if already added).
322        let item_id = self.add_item(issue_node_id).await?;
323
324        // Update the estimate number field.
325        let query = r#"mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: Float!) { updateProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { number: $value } }) { projectV2Item { id } } }"#;
326
327        self.gh
328            .graphql_with_vars(
329                query,
330                serde_json::json!({
331                    "projectId": self.project_id,
332                    "itemId": item_id,
333                    "fieldId": field_id,
334                    "value": f64::from(estimate),
335                }),
336            )
337            .await?;
338
339        Ok(item_id)
340    }
341
342    /// Batch-fetch estimate values for multiple project items by their content (issue) node IDs.
343    ///
344    /// Returns a map from content node ID → estimate value (0 if not set or field absent).
345    /// Only queries the estimate field — the result is used to populate orch's estimate
346    /// during task ingestion when `tasks.estimate` is still 0.
347    ///
348    /// GitHub's GraphQL API doesn't support querying items by arbitrary node IDs directly,
349    /// so we fetch all project items and their field values, then match by content ID.
350    /// This is acceptable for projects with reasonable item counts (< 1000).
351    pub async fn get_estimates_for_issues(
352        &self,
353        issue_node_ids: &[String],
354    ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
355        let Some(field_id) = &self.estimate_field_id else {
356            return Ok(std::collections::HashMap::new());
357        };
358
359        let issue_set: std::collections::HashSet<&str> =
360            issue_node_ids.iter().map(|s| s.as_str()).collect();
361
362        // Fetch all items in the project with their fieldValues filtered to the estimate field.
363        let query = r#"query($projectId: ID!) { node(id: $projectId) { ... on ProjectV2 { items(first: 100) { nodes { id content { ... on Issue { id } ... on PullRequest { id } } fieldValues(first: 10) { nodes { ... on ProjectV2ItemFieldNumberValue { field { id } number } } } } } } } }"#;
364
365        let result = self
366            .gh
367            .graphql_with_vars(query, serde_json::json!({ "projectId": self.project_id }))
368            .await?;
369
370        let items = result
371            .pointer("/data/node/items/nodes")
372            .and_then(|n| n.as_array())
373            .ok_or_else(|| anyhow::anyhow!("failed to parse project items"))?;
374
375        let mut estimates = std::collections::HashMap::new();
376
377        for item in items {
378            // Extract the content node ID (issue or PR).
379            let content = match item.get("content") {
380                Some(c) => c,
381                None => continue, // Item has no content (e.g. draft).
382            };
383            let content_id = match content.get("id").and_then(|v| v.as_str()) {
384                Some(id) => id,
385                None => continue,
386            };
387
388            // Skip if this content ID is not in our target set.
389            if !issue_set.contains(content_id) {
390                continue;
391            }
392
393            // Find the estimate field value.
394            let field_values = match item
395                .get("fieldValues")
396                .and_then(|fv| fv.get("nodes"))
397                .and_then(|n| n.as_array())
398            {
399                Some(fv) => fv,
400                None => continue,
401            };
402            let estimate = field_values
403                .iter()
404                .find(|fv| {
405                    fv.get("field")
406                        .and_then(|f| f.get("id"))
407                        .and_then(|id| id.as_str())
408                        .is_some_and(|id| id == field_id)
409                })
410                .and_then(|fv| fv.get("number"))
411                .and_then(|n| n.as_f64())
412                .unwrap_or(0.0) as u8;
413
414            // Only accept Fibonacci values (0 = not set, or 1/2/3/5/8/13/21).
415            const FIBONACCI: &[u8] = &[0, 1, 2, 3, 5, 8, 13, 21];
416            if FIBONACCI.contains(&estimate) {
417                estimates.insert(content_id.to_string(), estimate);
418            }
419        }
420
421        Ok(estimates)
422    }
423}
424
425/// Parse a project node from GraphQL response.
426fn parse_project_node(node: &serde_json::Value) -> Option<ProjectInfo> {
427    Some(ProjectInfo {
428        id: node.get("id")?.as_str()?.to_string(),
429        number: node.get("number")?.as_u64()?,
430        title: node.get("title")?.as_str()?.to_string(),
431    })
432}
433
434/// Write project config fields to `~/.orch/config.yml`.
435pub async fn write_project_config(sync: &ProjectSync) -> anyhow::Result<()> {
436    let config_path = crate::home::config_path()?;
437    let content = if config_path.exists() {
438        tokio::fs::read_to_string(&config_path).await?
439    } else {
440        String::new()
441    };
442
443    // Parse existing YAML or start fresh
444    let mut doc: serde_norway::Value = if content.is_empty() {
445        serde_norway::Value::Mapping(serde_norway::Mapping::new())
446    } else {
447        serde_norway::from_str(&content)?
448    };
449
450    let root = doc
451        .as_mapping_mut()
452        .ok_or_else(|| anyhow::anyhow!("config is not a YAML mapping"))?;
453
454    // Ensure gh section exists
455    let gh_key = serde_norway::Value::String("gh".to_string());
456    if !root.contains_key(&gh_key) {
457        root.insert(
458            gh_key.clone(),
459            serde_norway::Value::Mapping(serde_norway::Mapping::new()),
460        );
461    }
462    let gh = root
463        .get_mut(&gh_key)
464        .and_then(|v| v.as_mapping_mut())
465        .ok_or_else(|| anyhow::anyhow!("gh config is not a mapping"))?;
466
467    // Set project fields
468    gh.insert(
469        serde_norway::Value::String("project_id".to_string()),
470        serde_norway::Value::String(sync.project_id.clone()),
471    );
472    gh.insert(
473        serde_norway::Value::String("project_status_field_id".to_string()),
474        serde_norway::Value::String(sync.status_field_id.clone()),
475    );
476
477    // Build status map
478    let mut map = serde_norway::Mapping::new();
479    for (key, val) in &sync.status_map {
480        map.insert(
481            serde_norway::Value::String(key.clone()),
482            serde_norway::Value::String(val.clone()),
483        );
484    }
485    gh.insert(
486        serde_norway::Value::String("project_status_map".to_string()),
487        serde_norway::Value::Mapping(map),
488    );
489
490    // Persist the estimate field ID (optional — not all projects have one).
491    if let Some(ref field_id) = sync.estimate_field_id {
492        gh.insert(
493            serde_norway::Value::String("project_estimate_field_id".to_string()),
494            serde_norway::Value::String(field_id.clone()),
495        );
496    }
497
498    tokio::fs::write(&config_path, serde_norway::to_string(&doc)?).await?;
499    Ok(())
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::backends::Status;
506
507    #[test]
508    fn status_to_column_mapping() {
509        assert_eq!(ProjectSync::status_to_column(&Status::New), "backlog");
510        assert_eq!(ProjectSync::status_to_column(&Status::Routed), "backlog");
511        assert_eq!(
512            ProjectSync::status_to_column(&Status::InProgress),
513            "in_progress"
514        );
515        assert_eq!(
516            ProjectSync::status_to_column(&Status::Blocked),
517            "in_progress"
518        );
519        assert_eq!(ProjectSync::status_to_column(&Status::InReview), "review");
520        assert_eq!(
521            ProjectSync::status_to_column(&Status::NeedsReview),
522            "review"
523        );
524        assert_eq!(ProjectSync::status_to_column(&Status::Done), "done");
525    }
526
527    #[test]
528    fn parse_project_node_valid() {
529        let node = serde_json::json!({
530            "id": "PVT_kwHOA123",
531            "number": 42,
532            "title": "My Project"
533        });
534        let info = parse_project_node(&node).unwrap();
535        assert_eq!(info.id, "PVT_kwHOA123");
536        assert_eq!(info.number, 42);
537        assert_eq!(info.title, "My Project");
538    }
539
540    #[test]
541    fn parse_project_node_missing_field() {
542        let node = serde_json::json!({ "id": "PVT_kwHOA123" });
543        assert!(parse_project_node(&node).is_none());
544    }
545
546    #[test]
547    fn from_config_returns_none_when_not_configured() {
548        // Isolate from both the repo's .orch.yml and the global ~/.orch/config.yml:
549        // 1. Change to a temp dir so project config lookup finds no .orch.yml
550        // 2. Point ORCH_HOME at a separate temp dir so the global config is not found
551        // 3. Clear the config cache so no previously-cached values are reused
552        let original_cwd = std::env::current_dir().unwrap();
553        let prev_orch_home = std::env::var("ORCH_HOME").ok();
554
555        let temp_cwd = tempfile::tempdir().unwrap();
556        let temp_orch_home = tempfile::tempdir().unwrap();
557        let orch_dir = temp_orch_home.path().join(".orch");
558        std::fs::create_dir_all(&orch_dir).unwrap();
559
560        std::env::set_current_dir(temp_cwd.path()).unwrap();
561        std::env::set_var("ORCH_HOME", &orch_dir);
562        config::clear_test_cache();
563
564        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
565            assert!(ProjectSync::from_config().is_none());
566        }));
567
568        std::env::set_current_dir(&original_cwd).unwrap();
569        if let Some(prev) = prev_orch_home {
570            std::env::set_var("ORCH_HOME", prev);
571        } else {
572            std::env::remove_var("ORCH_HOME");
573        }
574        result.unwrap();
575    }
576
577    #[test]
578    fn get_estimates_filters_non_fibonacci_values() {
579        // The get_estimates_for_issues function should only accept Fibonacci values.
580        // Non-Fibonacci values should be silently dropped.
581        // We test this by verifying the parsing logic directly.
582        const FIBONACCI: &[u8] = &[0, 1, 2, 3, 5, 8, 13, 21];
583
584        // Fibonacci values should be accepted
585        for &v in FIBONACCI {
586            assert!(
587                FIBONACCI.contains(&v),
588                "Fibonacci value {v} should be accepted"
589            );
590        }
591
592        // Non-Fibonacci values should NOT be in the list
593        let non_fib = [4, 6, 7, 9, 10, 11, 12, 14, 15, 16, 17, 18, 19, 20, 22];
594        for &v in &non_fib {
595            assert!(
596                !FIBONACCI.contains(&v),
597                "Non-Fibonacci value {v} should NOT be accepted"
598            );
599        }
600    }
601
602    #[test]
603    fn estimate_field_id_is_optional() {
604        // ProjectSync should be constructable with no estimate field.
605        // estimate_field_id() should return None when not configured.
606        let original_cwd = std::env::current_dir().unwrap();
607        let prev_orch_home = std::env::var("ORCH_HOME").ok();
608
609        let temp_cwd = tempfile::tempdir().unwrap();
610        let temp_orch_home = tempfile::tempdir().unwrap();
611        let orch_dir = temp_orch_home.path().join(".orch");
612        std::fs::create_dir_all(&orch_dir).unwrap();
613
614        std::env::set_current_dir(temp_cwd.path()).unwrap();
615        std::env::set_var("ORCH_HOME", &orch_dir);
616        config::clear_test_cache();
617
618        // Write a config with project ID and status field but NO estimate field.
619        let config_path = orch_dir.join("config.yml");
620        std::fs::write(
621            &config_path,
622            r#"
623gh:
624  project_id: "PVT_kwHOAB123"
625  project_status_field_id: "PVTSSF_123"
626  project_status_map:
627    backlog: "opt1"
628"#,
629        )
630        .unwrap();
631
632        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
633            let sync = ProjectSync::from_config();
634            assert!(
635                sync.is_some(),
636                "should be Some when project_id is configured"
637            );
638            assert_eq!(
639                sync.unwrap().estimate_field_id(),
640                None,
641                "estimate_field_id should be None when not configured"
642            );
643        }));
644
645        std::env::set_current_dir(&original_cwd).unwrap();
646        if let Some(prev) = prev_orch_home {
647            std::env::set_var("ORCH_HOME", prev);
648        } else {
649            std::env::remove_var("ORCH_HOME");
650        }
651        result.unwrap();
652    }
653}