use crate::backends::Status;
use crate::config;
use crate::github::http::GhHttp;
use std::collections::HashMap;
pub(crate) mod label_status;
#[derive(Debug, Clone)]
pub struct ProjectInfo {
pub id: String,
pub number: u64,
pub title: String,
}
pub struct ProjectSync {
project_id: String,
status_field_id: String,
status_map: HashMap<String, String>,
estimate_field_id: Option<String>,
gh: GhHttp,
}
impl ProjectSync {
pub fn from_config() -> Option<Self> {
let project_id = config::get("gh.project_id").ok()?;
if project_id.is_empty() {
return None;
}
let status_field_id = config::get("gh.project_status_field_id").ok()?;
let mut status_map = HashMap::new();
for key in &["backlog", "in_progress", "review", "done"] {
if let Ok(val) = config::get(&format!("gh.project_status_map.{key}")) {
status_map.insert(key.to_string(), val);
}
}
let estimate_field_id = config::get("gh.project_estimate_field_id")
.ok()
.filter(|v| !v.is_empty());
let gh = match GhHttp::new() {
Ok(gh) => gh,
Err(e) => {
tracing::warn!(error = %e, "project sync disabled: failed to build HTTP client");
return None;
}
};
Some(Self {
project_id,
status_field_id,
status_map,
estimate_field_id,
gh,
})
}
pub async fn discover_fields(project_id: &str) -> anyhow::Result<Self> {
let gh = GhHttp::new()?;
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 } } } } } }"#;
let result = gh
.graphql_with_vars(query, serde_json::json!({ "projectId": project_id }))
.await?;
let fields = result
.pointer("/data/node/fields/nodes")
.and_then(|n| n.as_array())
.ok_or_else(|| anyhow::anyhow!("failed to parse project fields response"))?;
let status_field = fields
.iter()
.find(|f| {
f.get("name")
.and_then(|n| n.as_str())
.map(|n| n.eq_ignore_ascii_case("status"))
.unwrap_or(false)
})
.ok_or_else(|| anyhow::anyhow!("no 'Status' field found in project"))?;
let field_id = status_field
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Status field missing id"))?
.to_string();
let options = status_field
.get("options")
.and_then(|o| o.as_array())
.ok_or_else(|| anyhow::anyhow!("Status field missing options"))?;
let mut status_map = HashMap::new();
for opt in options {
let opt_id = opt.get("id").and_then(|v| v.as_str()).unwrap_or("");
let opt_name = opt.get("name").and_then(|v| v.as_str()).unwrap_or("");
let lower = opt_name.to_lowercase();
let key = if lower.contains("backlog") || lower.contains("todo") || lower == "new" {
"backlog"
} else if lower.contains("progress") || lower.contains("doing") {
"in_progress"
} else if lower.contains("review") {
"review"
} else if lower.contains("done") || lower.contains("complete") {
"done"
} else {
continue;
};
status_map.insert(key.to_string(), opt_id.to_string());
}
let estimate_field_id = fields
.iter()
.find(|f| {
f.get("name")
.and_then(|n| n.as_str())
.map(|n| n.eq_ignore_ascii_case("estimate"))
.unwrap_or(false)
})
.and_then(|f| f.get("id").and_then(|v| v.as_str()))
.filter(|id| !id.is_empty())
.map(String::from);
Ok(Self {
project_id: project_id.to_string(),
status_field_id: field_id,
status_map,
estimate_field_id,
gh,
})
}
pub async fn list_projects() -> anyhow::Result<Vec<ProjectInfo>> {
let gh = GhHttp::new()?;
let mut projects = Vec::new();
let user = gh.get_whoami().await?;
let query = r#"query($login: String!) { user(login: $login) { projectsV2(first: 100) { nodes { id number title } } } }"#;
if let Ok(result) = gh
.graphql_with_vars(query, serde_json::json!({ "login": user }))
.await
{
if let Some(nodes) = result
.pointer("/data/user/projectsV2/nodes")
.and_then(|n| n.as_array())
{
for node in nodes {
if let Some(info) = parse_project_node(node) {
projects.push(info);
}
}
}
}
if let Ok(repo) = config::get_current_repo() {
if let Some(owner) = repo.split('/').next() {
if owner != user {
let query = r#"query($login: String!) { organization(login: $login) { projectsV2(first: 100) { nodes { id number title } } } }"#;
if let Ok(result) = gh
.graphql_with_vars(query, serde_json::json!({ "login": owner }))
.await
{
if let Some(nodes) = result
.pointer("/data/organization/projectsV2/nodes")
.and_then(|n| n.as_array())
{
for node in nodes {
if let Some(info) = parse_project_node(node) {
projects.push(info);
}
}
}
}
}
}
}
Ok(projects)
}
pub async fn add_item(&self, issue_node_id: &str) -> anyhow::Result<String> {
let query = r#"mutation($projectId: ID!, $contentId: ID!) { addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { item { id } } }"#;
let result = self
.gh
.graphql_with_vars(
query,
serde_json::json!({
"projectId": self.project_id,
"contentId": issue_node_id,
}),
)
.await?;
result
.pointer("/data/addProjectV2ItemById/item/id")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("failed to add item to project"))
}
pub async fn update_item_status(&self, item_id: &str, status: &Status) -> anyhow::Result<()> {
let column = Self::status_to_column(status);
let option_id = self
.status_map
.get(column)
.ok_or_else(|| anyhow::anyhow!("no option ID for column: {column}"))?;
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 } } }"#;
self.gh
.graphql_with_vars(
query,
serde_json::json!({
"projectId": self.project_id,
"itemId": item_id,
"fieldId": self.status_field_id,
"optionId": option_id,
}),
)
.await?;
Ok(())
}
pub async fn sync_item_status(
&self,
issue_node_id: &str,
status: &Status,
) -> anyhow::Result<()> {
let item_id = self.add_item(issue_node_id).await?;
self.update_item_status(&item_id, status).await
}
pub fn status_to_column(status: &Status) -> &'static str {
match status {
Status::New | Status::Routed => "backlog",
Status::InProgress | Status::Blocked => "in_progress",
Status::InReview | Status::NeedsReview => "review",
Status::Done => "done",
}
}
pub fn status_field_id(&self) -> &str {
&self.status_field_id
}
pub fn status_map(&self) -> &HashMap<String, String> {
&self.status_map
}
pub fn estimate_field_id(&self) -> Option<&str> {
self.estimate_field_id.as_deref()
}
pub fn project_id(&self) -> &str {
&self.project_id
}
pub async fn sync_item_estimate(
&self,
issue_node_id: &str,
estimate: u8,
) -> anyhow::Result<String> {
let Some(field_id) = &self.estimate_field_id else {
return Err(anyhow::anyhow!("estimate field not configured"));
};
let item_id = self.add_item(issue_node_id).await?;
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 } } }"#;
self.gh
.graphql_with_vars(
query,
serde_json::json!({
"projectId": self.project_id,
"itemId": item_id,
"fieldId": field_id,
"value": f64::from(estimate),
}),
)
.await?;
Ok(item_id)
}
pub async fn get_estimates_for_issues(
&self,
issue_node_ids: &[String],
) -> anyhow::Result<std::collections::HashMap<String, u8>> {
let Some(field_id) = &self.estimate_field_id else {
return Ok(std::collections::HashMap::new());
};
let issue_set: std::collections::HashSet<&str> =
issue_node_ids.iter().map(|s| s.as_str()).collect();
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 } } } } } } } }"#;
let result = self
.gh
.graphql_with_vars(query, serde_json::json!({ "projectId": self.project_id }))
.await?;
let items = result
.pointer("/data/node/items/nodes")
.and_then(|n| n.as_array())
.ok_or_else(|| anyhow::anyhow!("failed to parse project items"))?;
let mut estimates = std::collections::HashMap::new();
for item in items {
let content = match item.get("content") {
Some(c) => c,
None => continue, };
let content_id = match content.get("id").and_then(|v| v.as_str()) {
Some(id) => id,
None => continue,
};
if !issue_set.contains(content_id) {
continue;
}
let field_values = match item
.get("fieldValues")
.and_then(|fv| fv.get("nodes"))
.and_then(|n| n.as_array())
{
Some(fv) => fv,
None => continue,
};
let estimate = field_values
.iter()
.find(|fv| {
fv.get("field")
.and_then(|f| f.get("id"))
.and_then(|id| id.as_str())
.is_some_and(|id| id == field_id)
})
.and_then(|fv| fv.get("number"))
.and_then(|n| n.as_f64())
.unwrap_or(0.0) as u8;
const FIBONACCI: &[u8] = &[0, 1, 2, 3, 5, 8, 13, 21];
if FIBONACCI.contains(&estimate) {
estimates.insert(content_id.to_string(), estimate);
}
}
Ok(estimates)
}
}
fn parse_project_node(node: &serde_json::Value) -> Option<ProjectInfo> {
Some(ProjectInfo {
id: node.get("id")?.as_str()?.to_string(),
number: node.get("number")?.as_u64()?,
title: node.get("title")?.as_str()?.to_string(),
})
}
pub async fn write_project_config(sync: &ProjectSync) -> anyhow::Result<()> {
let config_path = crate::home::config_path()?;
let content = if config_path.exists() {
tokio::fs::read_to_string(&config_path).await?
} else {
String::new()
};
let mut doc: serde_norway::Value = if content.is_empty() {
serde_norway::Value::Mapping(serde_norway::Mapping::new())
} else {
serde_norway::from_str(&content)?
};
let root = doc
.as_mapping_mut()
.ok_or_else(|| anyhow::anyhow!("config is not a YAML mapping"))?;
let gh_key = serde_norway::Value::String("gh".to_string());
if !root.contains_key(&gh_key) {
root.insert(
gh_key.clone(),
serde_norway::Value::Mapping(serde_norway::Mapping::new()),
);
}
let gh = root
.get_mut(&gh_key)
.and_then(|v| v.as_mapping_mut())
.ok_or_else(|| anyhow::anyhow!("gh config is not a mapping"))?;
gh.insert(
serde_norway::Value::String("project_id".to_string()),
serde_norway::Value::String(sync.project_id.clone()),
);
gh.insert(
serde_norway::Value::String("project_status_field_id".to_string()),
serde_norway::Value::String(sync.status_field_id.clone()),
);
let mut map = serde_norway::Mapping::new();
for (key, val) in &sync.status_map {
map.insert(
serde_norway::Value::String(key.clone()),
serde_norway::Value::String(val.clone()),
);
}
gh.insert(
serde_norway::Value::String("project_status_map".to_string()),
serde_norway::Value::Mapping(map),
);
if let Some(ref field_id) = sync.estimate_field_id {
gh.insert(
serde_norway::Value::String("project_estimate_field_id".to_string()),
serde_norway::Value::String(field_id.clone()),
);
}
tokio::fs::write(&config_path, serde_norway::to_string(&doc)?).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backends::Status;
#[test]
fn status_to_column_mapping() {
assert_eq!(ProjectSync::status_to_column(&Status::New), "backlog");
assert_eq!(ProjectSync::status_to_column(&Status::Routed), "backlog");
assert_eq!(
ProjectSync::status_to_column(&Status::InProgress),
"in_progress"
);
assert_eq!(
ProjectSync::status_to_column(&Status::Blocked),
"in_progress"
);
assert_eq!(ProjectSync::status_to_column(&Status::InReview), "review");
assert_eq!(
ProjectSync::status_to_column(&Status::NeedsReview),
"review"
);
assert_eq!(ProjectSync::status_to_column(&Status::Done), "done");
}
#[test]
fn parse_project_node_valid() {
let node = serde_json::json!({
"id": "PVT_kwHOA123",
"number": 42,
"title": "My Project"
});
let info = parse_project_node(&node).unwrap();
assert_eq!(info.id, "PVT_kwHOA123");
assert_eq!(info.number, 42);
assert_eq!(info.title, "My Project");
}
#[test]
fn parse_project_node_missing_field() {
let node = serde_json::json!({ "id": "PVT_kwHOA123" });
assert!(parse_project_node(&node).is_none());
}
#[test]
fn from_config_returns_none_when_not_configured() {
let original_cwd = std::env::current_dir().unwrap();
let prev_orch_home = std::env::var("ORCH_HOME").ok();
let temp_cwd = tempfile::tempdir().unwrap();
let temp_orch_home = tempfile::tempdir().unwrap();
let orch_dir = temp_orch_home.path().join(".orch");
std::fs::create_dir_all(&orch_dir).unwrap();
std::env::set_current_dir(temp_cwd.path()).unwrap();
std::env::set_var("ORCH_HOME", &orch_dir);
config::clear_test_cache();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
assert!(ProjectSync::from_config().is_none());
}));
std::env::set_current_dir(&original_cwd).unwrap();
if let Some(prev) = prev_orch_home {
std::env::set_var("ORCH_HOME", prev);
} else {
std::env::remove_var("ORCH_HOME");
}
result.unwrap();
}
#[test]
fn get_estimates_filters_non_fibonacci_values() {
const FIBONACCI: &[u8] = &[0, 1, 2, 3, 5, 8, 13, 21];
for &v in FIBONACCI {
assert!(
FIBONACCI.contains(&v),
"Fibonacci value {v} should be accepted"
);
}
let non_fib = [4, 6, 7, 9, 10, 11, 12, 14, 15, 16, 17, 18, 19, 20, 22];
for &v in &non_fib {
assert!(
!FIBONACCI.contains(&v),
"Non-Fibonacci value {v} should NOT be accepted"
);
}
}
#[test]
fn estimate_field_id_is_optional() {
let original_cwd = std::env::current_dir().unwrap();
let prev_orch_home = std::env::var("ORCH_HOME").ok();
let temp_cwd = tempfile::tempdir().unwrap();
let temp_orch_home = tempfile::tempdir().unwrap();
let orch_dir = temp_orch_home.path().join(".orch");
std::fs::create_dir_all(&orch_dir).unwrap();
std::env::set_current_dir(temp_cwd.path()).unwrap();
std::env::set_var("ORCH_HOME", &orch_dir);
config::clear_test_cache();
let config_path = orch_dir.join("config.yml");
std::fs::write(
&config_path,
r#"
gh:
project_id: "PVT_kwHOAB123"
project_status_field_id: "PVTSSF_123"
project_status_map:
backlog: "opt1"
"#,
)
.unwrap();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let sync = ProjectSync::from_config();
assert!(
sync.is_some(),
"should be Some when project_id is configured"
);
assert_eq!(
sync.unwrap().estimate_field_id(),
None,
"estimate_field_id should be None when not configured"
);
}));
std::env::set_current_dir(&original_cwd).unwrap();
if let Some(prev) = prev_orch_home {
std::env::set_var("ORCH_HOME", prev);
} else {
std::env::remove_var("ORCH_HOME");
}
result.unwrap();
}
}