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
21#[derive(Debug)]
35pub struct ProjectSync {
36 project_id: String,
37 status_field_id: String,
38 status_map: HashMap<String, String>,
39 estimate_field_id: Option<String>,
41 gh: GhHttp,
42}
43
44impl ProjectSync {
45 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 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 pub async fn discover_fields(project_id: &str) -> anyhow::Result<Self> {
88 let gh = GhHttp::owner()?;
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 } } } } } }";
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 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 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 .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 pub async fn list_projects() -> anyhow::Result<Vec<ProjectInfo>> {
167 let gh = GhHttp::owner()?;
168 let mut projects = Vec::new();
169
170 let user = gh.get_whoami().await?;
172
173 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 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 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 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 pub async fn sync_item_status(
262 &self,
263 issue_node_id: &str,
264 status: &Status,
265 ) -> anyhow::Result<()> {
266 let item_id = self.add_item(issue_node_id).await?;
268
269 self.update_item_status(&item_id, status).await
271 }
272
273 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 pub fn status_field_id(&self) -> &str {
285 &self.status_field_id
286 }
287
288 pub fn status_map(&self) -> &HashMap<String, String> {
290 &self.status_map
291 }
292
293 pub fn estimate_field_id(&self) -> Option<&str> {
295 self.estimate_field_id.as_deref()
296 }
297
298 pub fn project_id(&self) -> &str {
300 &self.project_id
301 }
302
303 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 let item_id = self.add_item(issue_node_id).await?;
317
318 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 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 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 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 if !issue_set.contains(content_id) {
386 continue;
387 }
388
389 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 if FIBONACCI.contains(&estimate) {
416 estimates.insert(content_id.to_string(), estimate);
417 }
418 }
419
420 Ok(estimates)
421 }
422}
423
424fn 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
433pub 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 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 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 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 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 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 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 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 unsafe { std::env::set_var("ORCH_HOME", prev) };
573 } else {
574 unsafe { std::env::remove_var("ORCH_HOME") };
576 }
577 result.unwrap();
578 }
579
580 #[test]
581 fn get_estimates_filters_non_fibonacci_values() {
582 const FIBONACCI: &[u8] = &[0, 1, 2, 3, 5, 8, 13, 21];
586
587 for &v in FIBONACCI {
589 assert!(
590 FIBONACCI.contains(&v),
591 "Fibonacci value {v} should be accepted"
592 );
593 }
594
595 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 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 unsafe { std::env::set_var("ORCH_HOME", &orch_dir) };
620 config::clear_test_cache();
621
622 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 unsafe { std::env::set_var("ORCH_HOME", prev) };
653 } else {
654 unsafe { std::env::remove_var("ORCH_HOME") };
656 }
657 result.unwrap();
658 }
659}