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