1use crate::backends::Status;
7use crate::config;
8use crate::github::http::GhHttp;
9use std::collections::HashMap;
10
11pub(crate) mod label_status;
12
13#[derive(Debug, Clone)]
15pub struct ProjectInfo {
16 pub id: String,
17 pub number: u64,
18 pub title: String,
19}
20
21pub struct ProjectSync {
35 project_id: String,
36 status_field_id: String,
37 status_map: HashMap<String, String>,
38 estimate_field_id: Option<String>,
40 gh: GhHttp,
41}
42
43impl ProjectSync {
44 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 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 pub async fn discover_fields(project_id: &str) -> anyhow::Result<Self> {
87 let gh = GhHttp::new()?;
88 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 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 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 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 pub async fn list_projects() -> anyhow::Result<Vec<ProjectInfo>> {
168 let gh = GhHttp::new()?;
169 let mut projects = Vec::new();
170
171 let user = gh.get_whoami().await?;
173
174 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 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 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 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 pub async fn sync_item_status(
268 &self,
269 issue_node_id: &str,
270 status: &Status,
271 ) -> anyhow::Result<()> {
272 let item_id = self.add_item(issue_node_id).await?;
274
275 self.update_item_status(&item_id, status).await
277 }
278
279 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 pub fn status_field_id(&self) -> &str {
291 &self.status_field_id
292 }
293
294 pub fn status_map(&self) -> &HashMap<String, String> {
296 &self.status_map
297 }
298
299 pub fn estimate_field_id(&self) -> Option<&str> {
301 self.estimate_field_id.as_deref()
302 }
303
304 pub fn project_id(&self) -> &str {
306 &self.project_id
307 }
308
309 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 let item_id = self.add_item(issue_node_id).await?;
323
324 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 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 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 let content = match item.get("content") {
380 Some(c) => c,
381 None => continue, };
383 let content_id = match content.get("id").and_then(|v| v.as_str()) {
384 Some(id) => id,
385 None => continue,
386 };
387
388 if !issue_set.contains(content_id) {
390 continue;
391 }
392
393 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 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
425fn 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
434pub 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 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 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 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 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 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 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 const FIBONACCI: &[u8] = &[0, 1, 2, 3, 5, 8, 13, 21];
583
584 for &v in FIBONACCI {
586 assert!(
587 FIBONACCI.contains(&v),
588 "Fibonacci value {v} should be accepted"
589 );
590 }
591
592 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 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 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}