1use crate::backends::Status;
7use crate::config;
8use crate::github::http::GhHttp;
9use std::collections::HashMap;
10
11#[derive(Debug, Clone)]
13pub struct ProjectInfo {
14 pub id: String,
15 pub number: u64,
16 pub title: String,
17}
18
19pub struct ProjectSync {
33 project_id: String,
34 status_field_id: String,
35 status_map: HashMap<String, String>,
36 estimate_field_id: Option<String>,
38 gh: GhHttp,
39}
40
41impl ProjectSync {
42 pub fn from_config() -> Option<Self> {
44 let project_id = config::get("gh.project_id").ok()?;
45 if project_id.is_empty() {
46 return None;
47 }
48 let status_field_id = config::get("gh.project_status_field_id").ok()?;
49
50 let mut status_map = HashMap::new();
51 for key in &["backlog", "in_progress", "review", "done"] {
52 if let Ok(val) = config::get(&format!("gh.project_status_map.{key}")) {
53 status_map.insert(key.to_string(), val);
54 }
55 }
56
57 let estimate_field_id = config::get("gh.project_estimate_field_id")
59 .ok()
60 .filter(|v| !v.is_empty());
61
62 let gh = match GhHttp::new() {
63 Ok(gh) => gh,
64 Err(e) => {
65 tracing::warn!(error = %e, "project sync disabled: failed to build HTTP client");
66 return None;
67 }
68 };
69
70 Some(Self {
71 project_id,
72 status_field_id,
73 status_map,
74 estimate_field_id,
75 gh,
76 })
77 }
78
79 pub async fn discover_fields(project_id: &str) -> anyhow::Result<Self> {
85 let gh = GhHttp::new()?;
86 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 } } } } } }"#;
88
89 let result = gh
90 .graphql_with_vars(query, serde_json::json!({ "projectId": project_id }))
91 .await?;
92
93 let fields = result
94 .pointer("/data/node/fields/nodes")
95 .and_then(|n| n.as_array())
96 .ok_or_else(|| anyhow::anyhow!("failed to parse project fields response"))?;
97
98 let status_field = fields
100 .iter()
101 .find(|f| {
102 f.get("name")
103 .and_then(|n| n.as_str())
104 .map(|n| n.eq_ignore_ascii_case("status"))
105 .unwrap_or(false)
106 })
107 .ok_or_else(|| anyhow::anyhow!("no 'Status' field found in project"))?;
108
109 let field_id = status_field
110 .get("id")
111 .and_then(|v| v.as_str())
112 .ok_or_else(|| anyhow::anyhow!("Status field missing id"))?
113 .to_string();
114
115 let options = status_field
116 .get("options")
117 .and_then(|o| o.as_array())
118 .ok_or_else(|| anyhow::anyhow!("Status field missing options"))?;
119
120 let mut status_map = HashMap::new();
122 for opt in options {
123 let opt_id = opt.get("id").and_then(|v| v.as_str()).unwrap_or("");
124 let opt_name = opt.get("name").and_then(|v| v.as_str()).unwrap_or("");
125 let lower = opt_name.to_lowercase();
126
127 let key = if lower.contains("backlog") || lower.contains("todo") || lower == "new" {
128 "backlog"
129 } else if lower.contains("progress") || lower.contains("doing") {
130 "in_progress"
131 } else if lower.contains("review") {
132 "review"
133 } else if lower.contains("done") || lower.contains("complete") {
134 "done"
135 } else {
136 continue;
137 };
138
139 status_map.insert(key.to_string(), opt_id.to_string());
140 }
141
142 let estimate_field_id = fields
144 .iter()
145 .find(|f| {
146 f.get("name")
147 .and_then(|n| n.as_str())
148 .map(|n| n.eq_ignore_ascii_case("estimate"))
149 .unwrap_or(false)
150 })
151 .and_then(|f| f.get("id").and_then(|v| v.as_str()))
152 .filter(|id| !id.is_empty())
153 .map(String::from);
154
155 Ok(Self {
156 project_id: project_id.to_string(),
157 status_field_id: field_id,
158 status_map,
159 estimate_field_id,
160 gh,
161 })
162 }
163
164 pub async fn list_projects() -> anyhow::Result<Vec<ProjectInfo>> {
166 let gh = GhHttp::new()?;
167 let mut projects = Vec::new();
168
169 let user = gh.get_whoami().await?;
171
172 let query = r#"query($login: String!) { user(login: $login) { projectsV2(first: 100) { nodes { id number title } } } }"#;
174 if let Ok(result) = gh
175 .graphql_with_vars(query, serde_json::json!({ "login": user }))
176 .await
177 {
178 if 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
190 if let Ok(repo) = config::get_current_repo() {
192 if let Some(owner) = repo.split('/').next() {
193 if owner != user {
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 {
199 if let Some(nodes) = result
200 .pointer("/data/organization/projectsV2/nodes")
201 .and_then(|n| n.as_array())
202 {
203 for node in nodes {
204 if let Some(info) = parse_project_node(node) {
205 projects.push(info);
206 }
207 }
208 }
209 }
210 }
211 }
212 }
213
214 Ok(projects)
215 }
216
217 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 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 pub async fn sync_item_status(
266 &self,
267 issue_node_id: &str,
268 status: &Status,
269 ) -> anyhow::Result<()> {
270 let item_id = self.add_item(issue_node_id).await?;
272
273 self.update_item_status(&item_id, status).await
275 }
276
277 pub fn status_to_column(status: &Status) -> &'static str {
279 match status {
280 Status::New | Status::Routed => "backlog",
281 Status::InProgress | Status::Blocked => "in_progress",
282 Status::InReview | Status::NeedsReview => "review",
283 Status::Done => "done",
284 }
285 }
286
287 pub fn status_field_id(&self) -> &str {
289 &self.status_field_id
290 }
291
292 pub fn status_map(&self) -> &HashMap<String, String> {
294 &self.status_map
295 }
296
297 pub fn estimate_field_id(&self) -> Option<&str> {
299 self.estimate_field_id.as_deref()
300 }
301
302 pub fn project_id(&self) -> &str {
304 &self.project_id
305 }
306
307 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 let item_id = self.add_item(issue_node_id).await?;
321
322 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 pub async fn get_estimates_for_issues(
350 &self,
351 issue_node_ids: &[String],
352 ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
353 let Some(field_id) = &self.estimate_field_id else {
354 return Ok(std::collections::HashMap::new());
355 };
356
357 let issue_set: std::collections::HashSet<&str> =
358 issue_node_ids.iter().map(|s| s.as_str()).collect();
359
360 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 let content = match item.get("content") {
378 Some(c) => c,
379 None => continue, };
381 let content_id = match content.get("id").and_then(|v| v.as_str()) {
382 Some(id) => id,
383 None => continue,
384 };
385
386 if !issue_set.contains(content_id) {
388 continue;
389 }
390
391 let field_values = match item
393 .get("fieldValues")
394 .and_then(|fv| fv.get("nodes"))
395 .and_then(|n| n.as_array())
396 {
397 Some(fv) => fv,
398 None => continue,
399 };
400 let estimate = field_values
401 .iter()
402 .find(|fv| {
403 fv.get("field")
404 .and_then(|f| f.get("id"))
405 .and_then(|id| id.as_str())
406 .is_some_and(|id| id == field_id)
407 })
408 .and_then(|fv| fv.get("number"))
409 .and_then(|n| n.as_f64())
410 .unwrap_or(0.0) as u8;
411
412 const FIBONACCI: &[u8] = &[0, 1, 2, 3, 5, 8, 13, 21];
414 if FIBONACCI.contains(&estimate) {
415 estimates.insert(content_id.to_string(), estimate);
416 }
417 }
418
419 Ok(estimates)
420 }
421}
422
423fn parse_project_node(node: &serde_json::Value) -> Option<ProjectInfo> {
425 Some(ProjectInfo {
426 id: node.get("id")?.as_str()?.to_string(),
427 number: node.get("number")?.as_u64()?,
428 title: node.get("title")?.as_str()?.to_string(),
429 })
430}
431
432pub async fn write_project_config(sync: &ProjectSync) -> anyhow::Result<()> {
434 let config_path = crate::home::config_path()?;
435 let content = if config_path.exists() {
436 tokio::fs::read_to_string(&config_path).await?
437 } else {
438 String::new()
439 };
440
441 let mut doc: serde_norway::Value = if content.is_empty() {
443 serde_norway::Value::Mapping(serde_norway::Mapping::new())
444 } else {
445 serde_norway::from_str(&content)?
446 };
447
448 let root = doc
449 .as_mapping_mut()
450 .ok_or_else(|| anyhow::anyhow!("config is not a YAML mapping"))?;
451
452 let gh_key = serde_norway::Value::String("gh".to_string());
454 if !root.contains_key(&gh_key) {
455 root.insert(
456 gh_key.clone(),
457 serde_norway::Value::Mapping(serde_norway::Mapping::new()),
458 );
459 }
460 let gh = root
461 .get_mut(&gh_key)
462 .and_then(|v| v.as_mapping_mut())
463 .ok_or_else(|| anyhow::anyhow!("gh config is not a mapping"))?;
464
465 gh.insert(
467 serde_norway::Value::String("project_id".to_string()),
468 serde_norway::Value::String(sync.project_id.clone()),
469 );
470 gh.insert(
471 serde_norway::Value::String("project_status_field_id".to_string()),
472 serde_norway::Value::String(sync.status_field_id.clone()),
473 );
474
475 let mut map = serde_norway::Mapping::new();
477 for (key, val) in &sync.status_map {
478 map.insert(
479 serde_norway::Value::String(key.clone()),
480 serde_norway::Value::String(val.clone()),
481 );
482 }
483 gh.insert(
484 serde_norway::Value::String("project_status_map".to_string()),
485 serde_norway::Value::Mapping(map),
486 );
487
488 if let Some(ref field_id) = sync.estimate_field_id {
490 gh.insert(
491 serde_norway::Value::String("project_estimate_field_id".to_string()),
492 serde_norway::Value::String(field_id.clone()),
493 );
494 }
495
496 tokio::fs::write(&config_path, serde_norway::to_string(&doc)?).await?;
497 Ok(())
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::backends::Status;
504
505 #[test]
506 fn status_to_column_mapping() {
507 assert_eq!(ProjectSync::status_to_column(&Status::New), "backlog");
508 assert_eq!(ProjectSync::status_to_column(&Status::Routed), "backlog");
509 assert_eq!(
510 ProjectSync::status_to_column(&Status::InProgress),
511 "in_progress"
512 );
513 assert_eq!(
514 ProjectSync::status_to_column(&Status::Blocked),
515 "in_progress"
516 );
517 assert_eq!(ProjectSync::status_to_column(&Status::InReview), "review");
518 assert_eq!(
519 ProjectSync::status_to_column(&Status::NeedsReview),
520 "review"
521 );
522 assert_eq!(ProjectSync::status_to_column(&Status::Done), "done");
523 }
524
525 #[test]
526 fn parse_project_node_valid() {
527 let node = serde_json::json!({
528 "id": "PVT_kwHOA123",
529 "number": 42,
530 "title": "My Project"
531 });
532 let info = parse_project_node(&node).unwrap();
533 assert_eq!(info.id, "PVT_kwHOA123");
534 assert_eq!(info.number, 42);
535 assert_eq!(info.title, "My Project");
536 }
537
538 #[test]
539 fn parse_project_node_missing_field() {
540 let node = serde_json::json!({ "id": "PVT_kwHOA123" });
541 assert!(parse_project_node(&node).is_none());
542 }
543
544 #[test]
545 fn from_config_returns_none_when_not_configured() {
546 let original_cwd = std::env::current_dir().unwrap();
551 let prev_orch_home = std::env::var("ORCH_HOME").ok();
552
553 let temp_cwd = tempfile::tempdir().unwrap();
554 let temp_orch_home = tempfile::tempdir().unwrap();
555 let orch_dir = temp_orch_home.path().join(".orch");
556 std::fs::create_dir_all(&orch_dir).unwrap();
557
558 std::env::set_current_dir(temp_cwd.path()).unwrap();
559 std::env::set_var("ORCH_HOME", &orch_dir);
560 config::clear_test_cache();
561
562 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
563 assert!(ProjectSync::from_config().is_none());
564 }));
565
566 std::env::set_current_dir(&original_cwd).unwrap();
567 if let Some(prev) = prev_orch_home {
568 std::env::set_var("ORCH_HOME", prev);
569 } else {
570 std::env::remove_var("ORCH_HOME");
571 }
572 result.unwrap();
573 }
574
575 #[test]
576 fn get_estimates_filters_non_fibonacci_values() {
577 const FIBONACCI: &[u8] = &[0, 1, 2, 3, 5, 8, 13, 21];
581
582 for &v in FIBONACCI {
584 assert!(
585 FIBONACCI.contains(&v),
586 "Fibonacci value {v} should be accepted"
587 );
588 }
589
590 let non_fib = [4, 6, 7, 9, 10, 11, 12, 14, 15, 16, 17, 18, 19, 20, 22];
592 for &v in &non_fib {
593 assert!(
594 !FIBONACCI.contains(&v),
595 "Non-Fibonacci value {v} should NOT be accepted"
596 );
597 }
598 }
599
600 #[test]
601 fn estimate_field_id_is_optional() {
602 let original_cwd = std::env::current_dir().unwrap();
605 let prev_orch_home = std::env::var("ORCH_HOME").ok();
606
607 let temp_cwd = tempfile::tempdir().unwrap();
608 let temp_orch_home = tempfile::tempdir().unwrap();
609 let orch_dir = temp_orch_home.path().join(".orch");
610 std::fs::create_dir_all(&orch_dir).unwrap();
611
612 std::env::set_current_dir(temp_cwd.path()).unwrap();
613 std::env::set_var("ORCH_HOME", &orch_dir);
614 config::clear_test_cache();
615
616 let config_path = orch_dir.join("config.yml");
618 std::fs::write(
619 &config_path,
620 r#"
621gh:
622 project_id: "PVT_kwHOAB123"
623 project_status_field_id: "PVTSSF_123"
624 project_status_map:
625 backlog: "opt1"
626"#,
627 )
628 .unwrap();
629
630 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
631 let sync = ProjectSync::from_config();
632 assert!(
633 sync.is_some(),
634 "should be Some when project_id is configured"
635 );
636 assert_eq!(
637 sync.unwrap().estimate_field_id(),
638 None,
639 "estimate_field_id should be None when not configured"
640 );
641 }));
642
643 std::env::set_current_dir(&original_cwd).unwrap();
644 if let Some(prev) = prev_orch_home {
645 std::env::set_var("ORCH_HOME", prev);
646 } else {
647 std::env::remove_var("ORCH_HOME");
648 }
649 result.unwrap();
650 }
651}