1pub(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#[derive(Debug, Clone)]
19pub struct ProjectInfo {
20 pub id: String,
21 pub number: u64,
22 pub title: String,
23}
24
25#[derive(Debug)]
39pub struct ProjectSync {
40 project_id: String,
41 status_field_id: String,
42 status_map: HashMap<String, String>,
43 estimate_field_id: Option<String>,
45 gh: GhHttp,
46}
47
48impl ProjectSync {
49 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 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 pub async fn discover_fields(project_id: &str) -> anyhow::Result<Self> {
92 let gh = GhHttp::owner()?;
93 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 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 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 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 pub async fn list_projects() -> anyhow::Result<Vec<ProjectInfo>> {
171 let gh = GhHttp::owner()?;
172 let mut projects = Vec::new();
173
174 let user = gh.get_whoami().await?;
176
177 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 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 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 | 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 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<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 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 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 if !issue_set.contains(content_id) {
387 continue;
388 }
389
390 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 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 = 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 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 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 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 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 unsafe { env::set_var("ORCH_HOME", prev) };
579 } else {
580 unsafe { env::remove_var("ORCH_HOME") };
582 }
583 result.unwrap();
584 }
585
586 #[test]
587 fn get_estimates_filters_non_fibonacci_values() {
588 const FIBONACCI: &[u8] = &[0, 1, 2, 3, 5, 8, 13, 21];
592
593 for &v in FIBONACCI {
595 assert!(
596 FIBONACCI.contains(&v),
597 "Fibonacci value {v} should be accepted"
598 );
599 }
600
601 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 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 unsafe { env::set_var("ORCH_HOME", &orch_dir) };
626 config::clear_test_cache();
627
628 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 unsafe { env::set_var("ORCH_HOME", prev) };
659 } else {
660 unsafe { env::remove_var("ORCH_HOME") };
662 }
663 result.unwrap();
664 }
665}