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