diff --git a/Cargo.lock b/Cargo.lock index a2a3ba951..36ed228c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1458,10 +1458,8 @@ dependencies = [ name = "fabro-github" version = "0.176.2" dependencies = [ - "async-trait", "base64", "chrono", - "fabro-tracker", "jsonwebtoken", "mockito", "reqwest 0.12.28", @@ -1514,20 +1512,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "fabro-linear" -version = "0.176.2" -dependencies = [ - "async-trait", - "fabro-tracker", - "mockito", - "reqwest 0.12.28", - "serde", - "serde_json", - "tokio", - "tracing", -] - [[package]] name = "fabro-llm" version = "0.176.2" @@ -1683,6 +1667,12 @@ name = "fabro-tracker" version = "0.176.2" dependencies = [ "async-trait", + "fabro-github", + "mockito", + "reqwest 0.12.28", + "serde_json", + "tokio", + "tracing", ] [[package]] diff --git a/lib/crates/fabro-github/Cargo.toml b/lib/crates/fabro-github/Cargo.toml index abf39218f..25a672417 100644 --- a/lib/crates/fabro-github/Cargo.toml +++ b/lib/crates/fabro-github/Cargo.toml @@ -9,8 +9,6 @@ description = "GitHub App authentication and API helpers for Fabro" doctest = false [dependencies] -fabro-tracker = { path = "../fabro-tracker" } -async-trait.workspace = true serde.workspace = true serde_json.workspace = true reqwest.workspace = true diff --git a/lib/crates/fabro-github/src/lib.rs b/lib/crates/fabro-github/src/lib.rs index 9f0533ccc..18e9c0eb7 100644 --- a/lib/crates/fabro-github/src/lib.rs +++ b/lib/crates/fabro-github/src/lib.rs @@ -1,8 +1,4 @@ -use async_trait::async_trait; use serde::Deserialize; -use tokio::sync::OnceCell; - -pub use fabro_tracker::{BlockerRef, Issue, Tracker}; pub const GITHUB_API_BASE_URL: &str = "https://api.github.com"; @@ -792,57 +788,6 @@ pub async fn close_pull_request( } } -/// Execute a GitHub GraphQL request and return the response JSON. -async fn execute_github_graphql( - client: &reqwest::Client, - token: &str, - endpoint: &str, - query: &str, - variables: serde_json::Value, -) -> Result { - let body = serde_json::json!({ - "query": query, - "variables": variables, - }); - - let resp = client - .post(endpoint) - .header("Authorization", format!("Bearer {token}")) - .header("Content-Type", "application/json") - .header("User-Agent", "fabro") - .timeout(std::time::Duration::from_secs(30)) - .json(&body) - .send() - .await - .map_err(|e| format!("GitHub GraphQL request failed: {e}"))?; - - let status = resp.status(); - if !status.is_success() { - let body_text = resp.text().await.unwrap_or_default(); - tracing::warn!(status = %status, "GitHub GraphQL API error"); - return Err(format!( - "GitHub GraphQL API returned HTTP {status}: {body_text}" - )); - } - - let response: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("Failed to parse GitHub GraphQL response: {e}"))?; - - if let Some(errors) = response["errors"].as_array() { - if !errors.is_empty() { - let messages: Vec<&str> = errors - .iter() - .filter_map(|e| e["message"].as_str()) - .collect(); - return Err(format!("GitHub GraphQL errors: {}", messages.join("; "))); - } - } - - Ok(response) -} - /// Request a scoped Installation Access Token with `issues: write` /// and `organization_projects: write`. Used for GitHub Projects V2. pub async fn create_installation_access_token_for_projects( @@ -863,457 +808,6 @@ pub async fn create_installation_access_token_for_projects( .await } -/// A `Tracker` implementation backed by GitHub Projects V2. -/// -/// Scoped to a single project board identified by `project_number`. -pub struct GitHubTracker { - creds: GitHubAppCredentials, - client: reqwest::Client, - owner: String, - repo: String, - project_number: u64, - base_url: String, - project_node_id: OnceCell, -} - -impl GitHubTracker { - pub fn new( - creds: GitHubAppCredentials, - client: reqwest::Client, - owner: String, - repo: String, - project_number: u64, - base_url: String, - ) -> Self { - Self { - creds, - client, - owner, - repo, - project_number, - base_url, - project_node_id: OnceCell::new(), - } - } - - fn graphql_url(&self) -> String { - format!("{}/graphql", self.base_url) - } - - async fn fresh_token(&self) -> Result { - let jwt = sign_app_jwt(&self.creds.app_id, &self.creds.private_key_pem)?; - create_installation_access_token_for_projects( - &self.client, - &jwt, - &self.owner, - &self.repo, - &self.base_url, - ) - .await - } - - async fn resolve_project_node_id(&self, token: &str) -> Result<&str, String> { - self.project_node_id - .get_or_try_init(|| async { - tracing::debug!( - owner = %self.owner, - project_number = self.project_number, - "Resolving GitHub project node ID" - ); - let graphql_url = self.graphql_url(); - let query = r#" - query($owner: String!, $number: Int!) { - organization(login: $owner) { - projectV2(number: $number) { id } - } - } - "#; - let variables = serde_json::json!({ - "owner": self.owner, - "number": self.project_number, - }); - - let resp = execute_github_graphql( - &self.client, - token, - &graphql_url, - query, - variables.clone(), - ) - .await?; - - // Try org path first, fall back to user path - if let Some(id) = resp["data"]["organization"]["projectV2"]["id"].as_str() { - return Ok(id.to_string()); - } - - let user_query = r#" - query($owner: String!, $number: Int!) { - user(login: $owner) { - projectV2(number: $number) { id } - } - } - "#; - let user_resp = execute_github_graphql( - &self.client, - token, - &graphql_url, - user_query, - variables, - ) - .await?; - - user_resp["data"]["user"]["projectV2"]["id"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| { - format!( - "Project #{} not found for owner '{}'", - self.project_number, self.owner - ) - }) - }) - .await - .map(|s| s.as_str()) - } -} - -fn normalize_github_item(item: &serde_json::Value) -> Option { - let project_item_id = item["id"].as_str()?.to_string(); - let content = &item["content"]; - - let id = content["id"].as_str()?.to_string(); - let number = content["number"].as_u64()?; - let identifier = format!("#{number}"); - let title = content["title"].as_str()?.to_string(); - let url = content["url"].as_str()?.to_string(); - let description = content["body"].as_str().map(|s| s.to_string()); - - let state = item["fieldValueByName"]["name"] - .as_str() - .unwrap_or("") - .to_string(); - - let assignee_id = content["assignees"]["nodes"] - .as_array() - .and_then(|arr| arr.first()) - .and_then(|a| a["id"].as_str()) - .map(|s| s.to_string()); - - let labels = content["labels"]["nodes"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|l| l["name"].as_str()) - .map(|s| s.to_lowercase()) - .collect() - }) - .unwrap_or_default(); - - let created_at = content["createdAt"].as_str().map(|s| s.to_string()); - let updated_at = content["updatedAt"].as_str().map(|s| s.to_string()); - - Some(Issue { - id, - project_item_id: Some(project_item_id), - identifier, - title, - description, - priority: None, - state, - branch_name: None, - url, - assignee_id, - labels, - blocked_by: vec![], - created_at, - updated_at, - }) -} - -/// Fetch one page of project items. Returns (items, has_next_page, end_cursor). -async fn fetch_project_items_page( - client: &reqwest::Client, - token: &str, - graphql_url: &str, - project_node_id: &str, - cursor: Option<&str>, -) -> Result<(Vec, bool, Option), String> { - let query = r#" - query($projectId: ID!, $cursor: String) { - node(id: $projectId) { - ... on ProjectV2 { - items(first: 100, after: $cursor) { - nodes { - id - fieldValueByName(name: "Status") { - ... on ProjectV2ItemFieldSingleSelectValue { - name - } - } - content { - ... on Issue { - id - number - title - body - url - createdAt - updatedAt - assignees(first: 1) { nodes { id } } - labels(first: 20) { nodes { name } } - } - } - } - pageInfo { hasNextPage endCursor } - } - } - } - } - "#; - - let variables = serde_json::json!({ - "projectId": project_node_id, - "cursor": cursor, - }); - - let resp = execute_github_graphql(client, token, graphql_url, query, variables).await?; - - let items_node = &resp["data"]["node"]["items"]; - let nodes = items_node["nodes"].as_array().cloned().unwrap_or_default(); - let has_next = items_node["pageInfo"]["hasNextPage"] - .as_bool() - .unwrap_or(false); - let end_cursor = items_node["pageInfo"]["endCursor"] - .as_str() - .map(|s| s.to_string()); - - Ok((nodes, has_next, end_cursor)) -} - -#[async_trait] -impl Tracker for GitHubTracker { - async fn fetch_viewer_id(&self) -> Result { - tracing::debug!("Fetching viewer ID from GitHub"); - let token = self.fresh_token().await?; - let query = "query { viewer { id } }"; - let resp = execute_github_graphql( - &self.client, - &token, - &self.graphql_url(), - query, - serde_json::json!({}), - ) - .await?; - - resp["data"]["viewer"]["id"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| "Missing viewer id in GitHub response".to_string()) - } - - async fn create_comment(&self, issue: &Issue, body: &str) -> Result<(), String> { - tracing::debug!(issue_id = %issue.id, "Creating comment on GitHub issue"); - let token = self.fresh_token().await?; - let query = r#" - mutation($subjectId: ID!, $body: String!) { - addComment(input: { subjectId: $subjectId, body: $body }) { - clientMutationId - } - } - "#; - let variables = serde_json::json!({ - "subjectId": issue.id, - "body": body, - }); - execute_github_graphql(&self.client, &token, &self.graphql_url(), query, variables).await?; - Ok(()) - } - - async fn update_issue_state(&self, issue: &Issue, state_name: &str) -> Result<(), String> { - let project_item_id = issue - .project_item_id - .as_deref() - .ok_or("update_issue_state requires project_item_id")?; - - tracing::debug!( - project_item_id, - state_name, - "Updating GitHub project item status" - ); - - let token = self.fresh_token().await?; - let project_node_id = self.resolve_project_node_id(&token).await?; - let graphql_url = self.graphql_url(); - - // Step 1: Get the Status field ID and the target option ID - let field_query = r#" - query($projectId: ID!) { - node(id: $projectId) { - ... on ProjectV2 { - field(name: "Status") { - ... on ProjectV2SingleSelectField { - id - options { id name } - } - } - } - } - } - "#; - let field_resp = execute_github_graphql( - &self.client, - &token, - &graphql_url, - field_query, - serde_json::json!({ "projectId": project_node_id }), - ) - .await?; - - let field = &field_resp["data"]["node"]["field"]; - let field_id = field["id"] - .as_str() - .ok_or("Missing Status field id")? - .to_string(); - - let option_id = field["options"] - .as_array() - .and_then(|opts| { - opts.iter().find(|o| { - o["name"] - .as_str() - .is_some_and(|n| n.eq_ignore_ascii_case(state_name)) - }) - }) - .and_then(|o| o["id"].as_str()) - .ok_or_else(|| format!("Status option '{state_name}' not found in project"))? - .to_string(); - - // Step 2: Update the field value - let update_query = r#" - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { singleSelectOptionId: $optionId } - }) { - projectV2Item { id } - } - } - "#; - execute_github_graphql( - &self.client, - &token, - &graphql_url, - update_query, - serde_json::json!({ - "projectId": project_node_id, - "itemId": project_item_id, - "fieldId": field_id, - "optionId": option_id, - }), - ) - .await?; - - Ok(()) - } - - async fn fetch_candidate_issues(&self, state_names: &[&str]) -> Result, String> { - tracing::debug!( - owner = %self.owner, - project_number = self.project_number, - ?state_names, - "Fetching candidate issues from GitHub project" - ); - - let token = self.fresh_token().await?; - let project_node_id = self.resolve_project_node_id(&token).await?; - let graphql_url = self.graphql_url(); - - let mut all_issues = Vec::new(); - let mut cursor: Option = None; - - loop { - let (nodes, has_next, end_cursor) = fetch_project_items_page( - &self.client, - &token, - &graphql_url, - project_node_id, - cursor.as_deref(), - ) - .await?; - - for node in &nodes { - if let Some(issue) = normalize_github_item(node) { - if state_names - .iter() - .any(|s| s.eq_ignore_ascii_case(&issue.state)) - { - all_issues.push(issue); - } - } - } - - if has_next { - cursor = end_cursor; - } else { - break; - } - } - - Ok(all_issues) - } - - async fn fetch_issues_by_ids(&self, ids: &[&str]) -> Result, String> { - if ids.is_empty() { - return Ok(Vec::new()); - } - - tracing::debug!( - count = ids.len(), - "Fetching GitHub issues by ID from project" - ); - - let token = self.fresh_token().await?; - let project_node_id = self.resolve_project_node_id(&token).await?; - let graphql_url = self.graphql_url(); - - let id_set: std::collections::HashSet<&str> = ids.iter().copied().collect(); - let mut issue_map: std::collections::HashMap = - std::collections::HashMap::new(); - let mut cursor: Option = None; - - loop { - let (nodes, has_next, end_cursor) = fetch_project_items_page( - &self.client, - &token, - &graphql_url, - project_node_id, - cursor.as_deref(), - ) - .await?; - - for node in &nodes { - if let Some(issue) = normalize_github_item(node) { - if id_set.contains(issue.id.as_str()) { - issue_map.insert(issue.id.clone(), issue); - } - } - } - - if has_next { - cursor = end_cursor; - } else { - break; - } - } - - // Return in the same order as the input IDs - Ok(ids.iter().filter_map(|id| issue_map.remove(*id)).collect()) - } -} - #[cfg(test)] mod tests { use super::*; @@ -2168,674 +1662,4 @@ mod tests { assert!(err.contains("not found"), "got: {err}"); assert!(err.contains("#999"), "got: {err}"); } - - // ----------------------------------------------------------------------- - // execute_github_graphql - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn execute_github_graphql_success() { - let mut server = mockito::Server::new_async().await; - - let mock = server - .mock("POST", "/graphql") - .match_header("Authorization", "Bearer test-token") - .match_header("Content-Type", "application/json") - .match_header("User-Agent", "fabro") - .with_status(200) - .with_body(r#"{"data": {"viewer": {"id": "U_abc"}}}"#) - .create_async() - .await; - - let client = reqwest::Client::new(); - let result = execute_github_graphql( - &client, - "test-token", - &format!("{}/graphql", server.url()), - "query { viewer { id } }", - serde_json::json!({}), - ) - .await - .unwrap(); - - assert_eq!(result["data"]["viewer"]["id"], "U_abc"); - mock.assert_async().await; - } - - #[tokio::test] - async fn execute_github_graphql_http_error() { - let mut server = mockito::Server::new_async().await; - - server - .mock("POST", "/graphql") - .with_status(500) - .with_body("Internal Server Error") - .create_async() - .await; - - let client = reqwest::Client::new(); - let err = execute_github_graphql( - &client, - "token", - &format!("{}/graphql", server.url()), - "query { viewer { id } }", - serde_json::json!({}), - ) - .await - .unwrap_err(); - - assert!(err.contains("500"), "got: {err}"); - } - - #[tokio::test] - async fn execute_github_graphql_graphql_errors() { - let mut server = mockito::Server::new_async().await; - - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(r#"{"data": null, "errors": [{"message": "Field 'foo' doesn't exist"}]}"#) - .create_async() - .await; - - let client = reqwest::Client::new(); - let err = execute_github_graphql( - &client, - "token", - &format!("{}/graphql", server.url()), - "query { foo }", - serde_json::json!({}), - ) - .await - .unwrap_err(); - - assert!(err.contains("Field 'foo' doesn't exist"), "got: {err}"); - } - - #[tokio::test] - async fn execute_github_graphql_correct_headers() { - let mut server = mockito::Server::new_async().await; - - let mock = server - .mock("POST", "/graphql") - .match_header("Authorization", "Bearer my-token") - .match_header("Content-Type", "application/json") - .match_header("User-Agent", "fabro") - .with_status(200) - .with_body(r#"{"data": {}}"#) - .create_async() - .await; - - let client = reqwest::Client::new(); - execute_github_graphql( - &client, - "my-token", - &format!("{}/graphql", server.url()), - "query { viewer { id } }", - serde_json::json!({}), - ) - .await - .unwrap(); - - mock.assert_async().await; - } - - // ----------------------------------------------------------------------- - // GitHubTracker helpers - // ----------------------------------------------------------------------- - - fn mock_github_tracker(server_url: &str, pem: String) -> GitHubTracker { - GitHubTracker::new( - GitHubAppCredentials { - app_id: "test-app".to_string(), - private_key_pem: pem, - }, - reqwest::Client::new(), - "owner".to_string(), - "repo".to_string(), - 1, - server_url.to_string(), - ) - } - - fn make_test_issue(state: &str) -> Issue { - Issue { - id: "I_issue1".to_string(), - project_item_id: Some("PVTI_item1".to_string()), - identifier: "#42".to_string(), - title: "Fix bug".to_string(), - description: None, - priority: None, - state: state.to_string(), - branch_name: None, - url: "https://github.com/owner/repo/issues/42".to_string(), - assignee_id: None, - labels: vec![], - blocked_by: vec![], - created_at: None, - updated_at: None, - } - } - - fn org_project_node_id_response() -> &'static str { - r#"{"data": {"organization": {"projectV2": {"id": "PVT_abc123"}}}}"# - } - - fn empty_items_response() -> &'static str { - r#"{"data": {"node": {"items": {"nodes": [], "pageInfo": {"hasNextPage": false, "endCursor": null}}}}}"# - } - - fn single_item_response(status: &str) -> String { - serde_json::json!({ - "data": { - "node": { - "items": { - "nodes": [ - { - "id": "PVTI_item1", - "fieldValueByName": {"name": status}, - "content": { - "id": "I_issue1", - "number": 42, - "title": "Fix bug", - "body": "Description", - "url": "https://github.com/owner/repo/issues/42", - "createdAt": "2026-01-01T00:00:00Z", - "updatedAt": "2026-01-02T00:00:00Z", - "assignees": {"nodes": []}, - "labels": {"nodes": [{"name": "bug"}]} - } - } - ], - "pageInfo": {"hasNextPage": false, "endCursor": null} - } - } - } - }) - .to_string() - } - - // ----------------------------------------------------------------------- - // project node ID resolution - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn project_node_id_resolved_via_org() { - let mut server = mockito::Server::new_async().await; - let pem = test_rsa_key(); - let tracker = mock_github_tracker(&server.url(), pem); - - server - .mock("GET", "/repos/owner/repo/installation") - .with_status(200) - .with_body(r#"{"id": 1}"#) - .create_async() - .await; - server - .mock("POST", "/app/installations/1/access_tokens") - .with_status(201) - .with_body(r#"{"token": "ghs_test"}"#) - .create_async() - .await; - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(org_project_node_id_response()) - .create_async() - .await; - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(empty_items_response()) - .create_async() - .await; - - let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap(); - assert!(issues.is_empty()); - } - - #[tokio::test] - async fn project_node_id_falls_back_to_user() { - let mut server = mockito::Server::new_async().await; - let pem = test_rsa_key(); - let tracker = mock_github_tracker(&server.url(), pem); - - server - .mock("GET", "/repos/owner/repo/installation") - .with_status(200) - .with_body(r#"{"id": 1}"#) - .create_async() - .await; - server - .mock("POST", "/app/installations/1/access_tokens") - .with_status(201) - .with_body(r#"{"token": "ghs_test"}"#) - .create_async() - .await; - // Org query returns null → fall back to user - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(r#"{"data": {"organization": null}}"#) - .create_async() - .await; - // User query succeeds - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(r#"{"data": {"user": {"projectV2": {"id": "PVT_user1"}}}}"#) - .create_async() - .await; - // Items page (empty) - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(empty_items_response()) - .create_async() - .await; - - let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap(); - assert!(issues.is_empty()); - } - - // ----------------------------------------------------------------------- - // fetch_viewer_id (GitHubTracker) - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn github_tracker_fetch_viewer_id_success() { - let mut server = mockito::Server::new_async().await; - let pem = test_rsa_key(); - let tracker = mock_github_tracker(&server.url(), pem); - - server - .mock("GET", "/repos/owner/repo/installation") - .with_status(200) - .with_body(r#"{"id": 1}"#) - .create_async() - .await; - server - .mock("POST", "/app/installations/1/access_tokens") - .with_status(201) - .with_body(r#"{"token": "ghs_test"}"#) - .create_async() - .await; - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(r#"{"data": {"viewer": {"id": "U_xyz"}}}"#) - .create_async() - .await; - - let id = tracker.fetch_viewer_id().await.unwrap(); - assert_eq!(id, "U_xyz"); - } - - // ----------------------------------------------------------------------- - // create_comment (GitHubTracker) - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn github_tracker_create_comment_success() { - let mut server = mockito::Server::new_async().await; - let pem = test_rsa_key(); - let tracker = mock_github_tracker(&server.url(), pem); - - server - .mock("GET", "/repos/owner/repo/installation") - .with_status(200) - .with_body(r#"{"id": 1}"#) - .create_async() - .await; - server - .mock("POST", "/app/installations/1/access_tokens") - .with_status(201) - .with_body(r#"{"token": "ghs_test"}"#) - .create_async() - .await; - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(r#"{"data": {"addComment": {"clientMutationId": null}}}"#) - .create_async() - .await; - - let issue = make_test_issue("In Progress"); - tracker.create_comment(&issue, "Great work!").await.unwrap(); - } - - // ----------------------------------------------------------------------- - // update_issue_state (GitHubTracker) - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn github_tracker_update_issue_state_success() { - let mut server = mockito::Server::new_async().await; - let pem = test_rsa_key(); - let tracker = mock_github_tracker(&server.url(), pem); - - server - .mock("GET", "/repos/owner/repo/installation") - .with_status(200) - .with_body(r#"{"id": 1}"#) - .create_async() - .await; - server - .mock("POST", "/app/installations/1/access_tokens") - .with_status(201) - .with_body(r#"{"token": "ghs_test"}"#) - .create_async() - .await; - // Resolve project node ID (org path) - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(org_project_node_id_response()) - .create_async() - .await; - // Field query - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(r#"{"data": {"node": {"field": {"id": "FLD_1", "options": [{"id": "opt-done", "name": "Done"}, {"id": "opt-todo", "name": "Todo"}]}}}}"#) - .create_async() - .await; - // Update mutation - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(r#"{"data": {"updateProjectV2ItemFieldValue": {"projectV2Item": {"id": "PVTI_item1"}}}}"#) - .create_async() - .await; - - let issue = make_test_issue("In Progress"); - tracker.update_issue_state(&issue, "Done").await.unwrap(); - } - - #[tokio::test] - async fn github_tracker_update_issue_state_status_not_found() { - let mut server = mockito::Server::new_async().await; - let pem = test_rsa_key(); - let tracker = mock_github_tracker(&server.url(), pem); - - server - .mock("GET", "/repos/owner/repo/installation") - .with_status(200) - .with_body(r#"{"id": 1}"#) - .create_async() - .await; - server - .mock("POST", "/app/installations/1/access_tokens") - .with_status(201) - .with_body(r#"{"token": "ghs_test"}"#) - .create_async() - .await; - // Resolve project node ID - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(org_project_node_id_response()) - .create_async() - .await; - // Field query — options don't include "Nonexistent" - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(r#"{"data": {"node": {"field": {"id": "FLD_1", "options": [{"id": "opt-done", "name": "Done"}]}}}}"#) - .create_async() - .await; - - let issue = make_test_issue("Todo"); - let err = tracker - .update_issue_state(&issue, "Nonexistent") - .await - .unwrap_err(); - assert!(err.contains("Nonexistent"), "got: {err}"); - assert!(err.contains("not found"), "got: {err}"); - } - - // ----------------------------------------------------------------------- - // fetch_candidate_issues (GitHubTracker) - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn github_tracker_fetch_candidate_issues_single_page() { - let mut server = mockito::Server::new_async().await; - let pem = test_rsa_key(); - let tracker = mock_github_tracker(&server.url(), pem); - - server - .mock("GET", "/repos/owner/repo/installation") - .with_status(200) - .with_body(r#"{"id": 1}"#) - .create_async() - .await; - server - .mock("POST", "/app/installations/1/access_tokens") - .with_status(201) - .with_body(r#"{"token": "ghs_test"}"#) - .create_async() - .await; - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(org_project_node_id_response()) - .create_async() - .await; - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(single_item_response("In Progress")) - .create_async() - .await; - - let issues = tracker - .fetch_candidate_issues(&["In Progress"]) - .await - .unwrap(); - - assert_eq!(issues.len(), 1); - assert_eq!(issues[0].identifier, "#42"); - assert_eq!(issues[0].state, "In Progress"); - assert_eq!(issues[0].id, "I_issue1"); - assert_eq!(issues[0].project_item_id.as_deref(), Some("PVTI_item1")); - assert_eq!(issues[0].labels, vec!["bug"]); - assert!(issues[0].branch_name.is_none()); - assert!(issues[0].priority.is_none()); - } - - #[tokio::test] - async fn github_tracker_fetch_candidate_issues_empty() { - let mut server = mockito::Server::new_async().await; - let pem = test_rsa_key(); - let tracker = mock_github_tracker(&server.url(), pem); - - server - .mock("GET", "/repos/owner/repo/installation") - .with_status(200) - .with_body(r#"{"id": 1}"#) - .create_async() - .await; - server - .mock("POST", "/app/installations/1/access_tokens") - .with_status(201) - .with_body(r#"{"token": "ghs_test"}"#) - .create_async() - .await; - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(org_project_node_id_response()) - .create_async() - .await; - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(empty_items_response()) - .create_async() - .await; - - let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap(); - assert!(issues.is_empty()); - } - - #[tokio::test] - async fn github_tracker_fetch_candidate_issues_status_filtering() { - let mut server = mockito::Server::new_async().await; - let pem = test_rsa_key(); - let tracker = mock_github_tracker(&server.url(), pem); - - let items_body = serde_json::json!({ - "data": { - "node": { - "items": { - "nodes": [ - { - "id": "PVTI_done", - "fieldValueByName": {"name": "Done"}, - "content": { - "id": "I_done1", "number": 10, "title": "Done issue", - "body": null, "url": "https://github.com/owner/repo/issues/10", - "createdAt": null, "updatedAt": null, - "assignees": {"nodes": []}, "labels": {"nodes": []} - } - }, - { - "id": "PVTI_inprog", - "fieldValueByName": {"name": "In Progress"}, - "content": { - "id": "I_inprog1", "number": 20, "title": "Active issue", - "body": null, "url": "https://github.com/owner/repo/issues/20", - "createdAt": null, "updatedAt": null, - "assignees": {"nodes": []}, "labels": {"nodes": []} - } - } - ], - "pageInfo": {"hasNextPage": false, "endCursor": null} - } - } - } - }) - .to_string(); - - server - .mock("GET", "/repos/owner/repo/installation") - .with_status(200) - .with_body(r#"{"id": 1}"#) - .create_async() - .await; - server - .mock("POST", "/app/installations/1/access_tokens") - .with_status(201) - .with_body(r#"{"token": "ghs_test"}"#) - .create_async() - .await; - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(org_project_node_id_response()) - .create_async() - .await; - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(items_body) - .create_async() - .await; - - let issues = tracker - .fetch_candidate_issues(&["In Progress"]) - .await - .unwrap(); - - assert_eq!(issues.len(), 1); - assert_eq!(issues[0].identifier, "#20"); - } - - // ----------------------------------------------------------------------- - // fetch_issues_by_ids (GitHubTracker) - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn github_tracker_fetch_issues_by_ids_ordering() { - let mut server = mockito::Server::new_async().await; - let pem = test_rsa_key(); - let tracker = mock_github_tracker(&server.url(), pem); - - // Page returns issues in reverse order of what we request - let items_body = serde_json::json!({ - "data": { - "node": { - "items": { - "nodes": [ - { - "id": "PVTI_b", - "fieldValueByName": {"name": "Todo"}, - "content": { - "id": "I_b", "number": 2, "title": "B", - "body": null, "url": "https://github.com/owner/repo/issues/2", - "createdAt": null, "updatedAt": null, - "assignees": {"nodes": []}, "labels": {"nodes": []} - } - }, - { - "id": "PVTI_a", - "fieldValueByName": {"name": "Todo"}, - "content": { - "id": "I_a", "number": 1, "title": "A", - "body": null, "url": "https://github.com/owner/repo/issues/1", - "createdAt": null, "updatedAt": null, - "assignees": {"nodes": []}, "labels": {"nodes": []} - } - } - ], - "pageInfo": {"hasNextPage": false, "endCursor": null} - } - } - } - }) - .to_string(); - - server - .mock("GET", "/repos/owner/repo/installation") - .with_status(200) - .with_body(r#"{"id": 1}"#) - .create_async() - .await; - server - .mock("POST", "/app/installations/1/access_tokens") - .with_status(201) - .with_body(r#"{"token": "ghs_test"}"#) - .create_async() - .await; - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(org_project_node_id_response()) - .create_async() - .await; - server - .mock("POST", "/graphql") - .with_status(200) - .with_body(items_body) - .create_async() - .await; - - // Request in A, B order — should get back in A, B order despite page returning B, A - let issues = tracker.fetch_issues_by_ids(&["I_a", "I_b"]).await.unwrap(); - - assert_eq!(issues.len(), 2); - assert_eq!(issues[0].id, "I_a"); - assert_eq!(issues[1].id, "I_b"); - } - - #[tokio::test] - async fn github_tracker_fetch_issues_by_ids_empty() { - let pem = test_rsa_key(); - let tracker = mock_github_tracker("http://unused", pem); - - // Empty input → no HTTP calls at all - let issues = tracker.fetch_issues_by_ids(&[]).await.unwrap(); - assert!(issues.is_empty()); - } } diff --git a/lib/crates/fabro-linear/Cargo.toml b/lib/crates/fabro-linear/Cargo.toml deleted file mode 100644 index 113f8d193..000000000 --- a/lib/crates/fabro-linear/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "fabro-linear" -edition.workspace = true -version.workspace = true -license.workspace = true -description = "Linear issue tracker API helpers for Fabro" - -[lib] -doctest = false - -[dependencies] -fabro-tracker = { path = "../fabro-tracker" } -async-trait.workspace = true -serde.workspace = true -serde_json.workspace = true -reqwest.workspace = true -tracing.workspace = true - -[dev-dependencies] -mockito = "1" -tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/lib/crates/fabro-tracker/Cargo.toml b/lib/crates/fabro-tracker/Cargo.toml index dc4092e6b..459189ba5 100644 --- a/lib/crates/fabro-tracker/Cargo.toml +++ b/lib/crates/fabro-tracker/Cargo.toml @@ -9,4 +9,13 @@ description = "Tracker trait and types for issue tracking integrations" doctest = false [dependencies] +fabro-github = { path = "../fabro-github" } async-trait.workspace = true +serde_json.workspace = true +reqwest.workspace = true +tracing.workspace = true +tokio = { workspace = true } + +[dev-dependencies] +mockito = "1" +tokio = { workspace = true, features = ["test-util", "macros"] } \ No newline at end of file diff --git a/lib/crates/fabro-tracker/src/github.rs b/lib/crates/fabro-tracker/src/github.rs new file mode 100644 index 000000000..5e0e15f8a --- /dev/null +++ b/lib/crates/fabro-tracker/src/github.rs @@ -0,0 +1,1181 @@ +use async_trait::async_trait; +use tokio::sync::OnceCell; + +use fabro_github::{ + create_installation_access_token_for_projects, sign_app_jwt, GitHubAppCredentials, +}; + +use crate::{execute_graphql_request, Issue, Tracker}; + +/// Execute a GitHub GraphQL request and return the response JSON. +async fn execute_github_graphql( + client: &reqwest::Client, + token: &str, + endpoint: &str, + query: &str, + variables: serde_json::Value, +) -> Result { + execute_graphql_request( + client, + endpoint, + &format!("Bearer {token}"), + "GitHub", + query, + variables, + ) + .await +} + +/// A `Tracker` implementation backed by GitHub Projects V2. +/// +/// Scoped to a single project board identified by `project_number`. +pub struct GitHubTracker { + creds: GitHubAppCredentials, + client: reqwest::Client, + owner: String, + repo: String, + project_number: u64, + base_url: String, + project_node_id: OnceCell, +} + +impl GitHubTracker { + pub fn new( + creds: GitHubAppCredentials, + client: reqwest::Client, + owner: String, + repo: String, + project_number: u64, + base_url: String, + ) -> Self { + Self { + creds, + client, + owner, + repo, + project_number, + base_url, + project_node_id: OnceCell::new(), + } + } + + fn graphql_url(&self) -> String { + format!("{}/graphql", self.base_url) + } + + async fn fresh_token(&self) -> Result { + let jwt = sign_app_jwt(&self.creds.app_id, &self.creds.private_key_pem)?; + create_installation_access_token_for_projects( + &self.client, + &jwt, + &self.owner, + &self.repo, + &self.base_url, + ) + .await + } + + async fn resolve_project_node_id(&self, token: &str) -> Result<&str, String> { + self.project_node_id + .get_or_try_init(|| async { + tracing::debug!( + owner = %self.owner, + project_number = self.project_number, + "Resolving GitHub project node ID" + ); + let graphql_url = self.graphql_url(); + let query = r#" + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { id } + } + } + "#; + let variables = serde_json::json!({ + "owner": self.owner, + "number": self.project_number, + }); + + let resp = execute_github_graphql( + &self.client, + token, + &graphql_url, + query, + variables.clone(), + ) + .await?; + + // Try org path first, fall back to user path + if let Some(id) = resp["data"]["organization"]["projectV2"]["id"].as_str() { + return Ok(id.to_string()); + } + + let user_query = r#" + query($owner: String!, $number: Int!) { + user(login: $owner) { + projectV2(number: $number) { id } + } + } + "#; + let user_resp = execute_github_graphql( + &self.client, + token, + &graphql_url, + user_query, + variables, + ) + .await?; + + user_resp["data"]["user"]["projectV2"]["id"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| { + format!( + "Project #{} not found for owner '{}'", + self.project_number, self.owner + ) + }) + }) + .await + .map(|s| s.as_str()) + } +} + +fn normalize_github_item(item: &serde_json::Value) -> Option { + let project_item_id = item["id"].as_str()?.to_string(); + let content = &item["content"]; + + let id = content["id"].as_str()?.to_string(); + let number = content["number"].as_u64()?; + let identifier = format!("#{number}"); + let title = content["title"].as_str()?.to_string(); + let url = content["url"].as_str()?.to_string(); + let description = content["body"].as_str().map(|s| s.to_string()); + + let state = item["fieldValueByName"]["name"] + .as_str() + .unwrap_or("") + .to_string(); + + let assignee_id = content["assignees"]["nodes"] + .as_array() + .and_then(|arr| arr.first()) + .and_then(|a| a["id"].as_str()) + .map(|s| s.to_string()); + + let labels = content["labels"]["nodes"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|l| l["name"].as_str()) + .map(|s| s.to_lowercase()) + .collect() + }) + .unwrap_or_default(); + + let created_at = content["createdAt"].as_str().map(|s| s.to_string()); + let updated_at = content["updatedAt"].as_str().map(|s| s.to_string()); + + Some(Issue { + id, + project_item_id: Some(project_item_id), + identifier, + title, + description, + priority: None, + state, + branch_name: None, + url, + assignee_id, + labels, + blocked_by: vec![], + created_at, + updated_at, + }) +} + +/// Fetch one page of project items. Returns (items, has_next_page, end_cursor). +async fn fetch_project_items_page( + client: &reqwest::Client, + token: &str, + graphql_url: &str, + project_node_id: &str, + cursor: Option<&str>, +) -> Result<(Vec, bool, Option), String> { + let query = r#" + query($projectId: ID!, $cursor: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100, after: $cursor) { + nodes { + id + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { + name + } + } + content { + ... on Issue { + id + number + title + body + url + createdAt + updatedAt + assignees(first: 1) { nodes { id } } + labels(first: 20) { nodes { name } } + } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + } + "#; + + let variables = serde_json::json!({ + "projectId": project_node_id, + "cursor": cursor, + }); + + let mut resp = execute_github_graphql(client, token, graphql_url, query, variables).await?; + + let has_next = resp["data"]["node"]["items"]["pageInfo"]["hasNextPage"] + .as_bool() + .unwrap_or(false); + let end_cursor = resp["data"]["node"]["items"]["pageInfo"]["endCursor"] + .as_str() + .map(|s| s.to_string()); + + // Take ownership of the nodes array in-place instead of deep-cloning it. + let nodes = resp + .pointer_mut("/data/node/items/nodes") + .and_then(|v| v.as_array_mut()) + .map(std::mem::take) + .unwrap_or_default(); + + Ok((nodes, has_next, end_cursor)) +} + +#[async_trait] +impl Tracker for GitHubTracker { + async fn fetch_viewer_id(&self) -> Result { + tracing::debug!("Fetching viewer ID from GitHub"); + let token = self.fresh_token().await?; + let query = "query { viewer { id } }"; + let resp = execute_github_graphql( + &self.client, + &token, + &self.graphql_url(), + query, + serde_json::json!({}), + ) + .await?; + + resp["data"]["viewer"]["id"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "Missing viewer id in GitHub response".to_string()) + } + + async fn create_comment(&self, issue: &Issue, body: &str) -> Result<(), String> { + tracing::debug!(issue_id = %issue.id, "Creating comment on GitHub issue"); + let token = self.fresh_token().await?; + let query = r#" + mutation($subjectId: ID!, $body: String!) { + addComment(input: { subjectId: $subjectId, body: $body }) { + clientMutationId + } + } + "#; + let variables = serde_json::json!({ + "subjectId": issue.id, + "body": body, + }); + execute_github_graphql(&self.client, &token, &self.graphql_url(), query, variables).await?; + Ok(()) + } + + async fn update_issue_state(&self, issue: &Issue, state_name: &str) -> Result<(), String> { + let project_item_id = issue + .project_item_id + .as_deref() + .ok_or("update_issue_state requires project_item_id")?; + + tracing::debug!( + project_item_id, + state_name, + "Updating GitHub project item status" + ); + + let token = self.fresh_token().await?; + let project_node_id = self.resolve_project_node_id(&token).await?; + let graphql_url = self.graphql_url(); + + // Step 1: Get the Status field ID and the target option ID + let field_query = r#" + query($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + field(name: "Status") { + ... on ProjectV2SingleSelectField { + id + options { id name } + } + } + } + } + } + "#; + let field_resp = execute_github_graphql( + &self.client, + &token, + &graphql_url, + field_query, + serde_json::json!({ "projectId": project_node_id }), + ) + .await?; + + let field = &field_resp["data"]["node"]["field"]; + let field_id = field["id"] + .as_str() + .ok_or("Missing Status field id")? + .to_string(); + + let option_id = field["options"] + .as_array() + .and_then(|opts| { + opts.iter().find(|o| { + o["name"] + .as_str() + .is_some_and(|n| n.eq_ignore_ascii_case(state_name)) + }) + }) + .and_then(|o| o["id"].as_str()) + .ok_or_else(|| format!("Status option '{state_name}' not found in project"))? + .to_string(); + + // Step 2: Update the field value + let update_query = r#" + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + } + "#; + execute_github_graphql( + &self.client, + &token, + &graphql_url, + update_query, + serde_json::json!({ + "projectId": project_node_id, + "itemId": project_item_id, + "fieldId": field_id, + "optionId": option_id, + }), + ) + .await?; + + Ok(()) + } + + async fn fetch_candidate_issues(&self, state_names: &[&str]) -> Result, String> { + tracing::debug!( + owner = %self.owner, + project_number = self.project_number, + ?state_names, + "Fetching candidate issues from GitHub project" + ); + + let token = self.fresh_token().await?; + let project_node_id = self.resolve_project_node_id(&token).await?; + let graphql_url = self.graphql_url(); + + let mut all_issues = Vec::new(); + let mut cursor: Option = None; + + loop { + let (nodes, has_next, end_cursor) = fetch_project_items_page( + &self.client, + &token, + &graphql_url, + project_node_id, + cursor.as_deref(), + ) + .await?; + + for node in &nodes { + if let Some(issue) = normalize_github_item(node) { + if state_names + .iter() + .any(|s| s.eq_ignore_ascii_case(&issue.state)) + { + all_issues.push(issue); + } + } + } + + if has_next { + cursor = end_cursor; + } else { + break; + } + } + + Ok(all_issues) + } + + async fn fetch_issues_by_ids(&self, ids: &[&str]) -> Result, String> { + if ids.is_empty() { + return Ok(Vec::new()); + } + + tracing::debug!( + count = ids.len(), + "Fetching GitHub issues by ID from project" + ); + + let token = self.fresh_token().await?; + let project_node_id = self.resolve_project_node_id(&token).await?; + let graphql_url = self.graphql_url(); + + let id_set: std::collections::HashSet<&str> = ids.iter().copied().collect(); + let mut issue_map: std::collections::HashMap = + std::collections::HashMap::with_capacity(ids.len()); + let mut cursor: Option = None; + + loop { + let (nodes, has_next, end_cursor) = fetch_project_items_page( + &self.client, + &token, + &graphql_url, + project_node_id, + cursor.as_deref(), + ) + .await?; + + for node in &nodes { + if let Some(issue) = normalize_github_item(node) { + if id_set.contains(issue.id.as_str()) { + issue_map.insert(issue.id.clone(), issue); + } + } + } + + if issue_map.len() == id_set.len() { + break; + } + + if has_next { + cursor = end_cursor; + } else { + break; + } + } + + // Return in the same order as the input IDs + Ok(ids.iter().filter_map(|id| issue_map.remove(*id)).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::Issue; + use fabro_github::GitHubAppCredentials; + + fn test_rsa_key() -> String { + use std::process::Command; + let output = Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + ]) + .output() + .expect("openssl should be available"); + assert!(output.status.success(), "openssl keygen failed"); + String::from_utf8(output.stdout).unwrap() + } + + // ----------------------------------------------------------------------- + // execute_github_graphql + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn execute_github_graphql_success() { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("POST", "/graphql") + .match_header("Authorization", "Bearer test-token") + .match_header("Content-Type", "application/json") + .match_header("User-Agent", "fabro") + .with_status(200) + .with_body(r#"{"data": {"viewer": {"id": "U_abc"}}}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + let result = execute_github_graphql( + &client, + "test-token", + &format!("{}/graphql", server.url()), + "query { viewer { id } }", + serde_json::json!({}), + ) + .await + .unwrap(); + + assert_eq!(result["data"]["viewer"]["id"], "U_abc"); + mock.assert_async().await; + } + + #[tokio::test] + async fn execute_github_graphql_http_error() { + let mut server = mockito::Server::new_async().await; + + server + .mock("POST", "/graphql") + .with_status(401) + .with_body("Unauthorized") + .create_async() + .await; + + let client = reqwest::Client::new(); + let err = execute_github_graphql( + &client, + "bad-token", + &format!("{}/graphql", server.url()), + "query { viewer { id } }", + serde_json::json!({}), + ) + .await + .unwrap_err(); + + assert!(err.contains("401"), "got: {err}"); + } + + #[tokio::test] + async fn execute_github_graphql_errors_array() { + let mut server = mockito::Server::new_async().await; + + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": null, "errors": [{"message": "Not found"}]}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + let err = execute_github_graphql( + &client, + "token", + &format!("{}/graphql", server.url()), + "query { bad }", + serde_json::json!({}), + ) + .await + .unwrap_err(); + + assert!(err.contains("Not found"), "got: {err}"); + } + + #[tokio::test] + async fn execute_github_graphql_correct_headers() { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("POST", "/graphql") + .match_header("Authorization", "Bearer my-token") + .match_header("Content-Type", "application/json") + .match_header("User-Agent", "fabro") + .with_status(200) + .with_body(r#"{"data": {}}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + execute_github_graphql( + &client, + "my-token", + &format!("{}/graphql", server.url()), + "query { viewer { id } }", + serde_json::json!({}), + ) + .await + .unwrap(); + + mock.assert_async().await; + } + + // ----------------------------------------------------------------------- + // GitHubTracker helpers + // ----------------------------------------------------------------------- + + fn mock_github_tracker(server_url: &str, pem: String) -> GitHubTracker { + GitHubTracker::new( + GitHubAppCredentials { + app_id: "test-app".to_string(), + private_key_pem: pem, + }, + reqwest::Client::new(), + "owner".to_string(), + "repo".to_string(), + 1, + server_url.to_string(), + ) + } + + fn make_test_issue(state: &str) -> Issue { + Issue { + id: "I_issue1".to_string(), + project_item_id: Some("PVTI_item1".to_string()), + identifier: "#42".to_string(), + title: "Fix bug".to_string(), + description: None, + priority: None, + state: state.to_string(), + branch_name: None, + url: "https://github.com/owner/repo/issues/42".to_string(), + assignee_id: None, + labels: vec![], + blocked_by: vec![], + created_at: None, + updated_at: None, + } + } + + fn org_project_node_id_response() -> &'static str { + r#"{"data": {"organization": {"projectV2": {"id": "PVT_abc123"}}}}"# + } + + fn empty_items_response() -> &'static str { + r#"{"data": {"node": {"items": {"nodes": [], "pageInfo": {"hasNextPage": false, "endCursor": null}}}}}"# + } + + fn single_item_response(status: &str) -> String { + serde_json::json!({ + "data": { + "node": { + "items": { + "nodes": [ + { + "id": "PVTI_item1", + "fieldValueByName": {"name": status}, + "content": { + "id": "I_issue1", + "number": 42, + "title": "Fix bug", + "body": "Description", + "url": "https://github.com/owner/repo/issues/42", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-02T00:00:00Z", + "assignees": {"nodes": []}, + "labels": {"nodes": [{"name": "bug"}]} + } + } + ], + "pageInfo": {"hasNextPage": false, "endCursor": null} + } + } + } + }) + .to_string() + } + + // ----------------------------------------------------------------------- + // project node ID resolution + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn project_node_id_resolved_via_org() { + let mut server = mockito::Server::new_async().await; + let pem = test_rsa_key(); + let tracker = mock_github_tracker(&server.url(), pem); + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(org_project_node_id_response()) + .create_async() + .await; + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(empty_items_response()) + .create_async() + .await; + + let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap(); + assert!(issues.is_empty()); + } + + #[tokio::test] + async fn project_node_id_falls_back_to_user() { + let mut server = mockito::Server::new_async().await; + let pem = test_rsa_key(); + let tracker = mock_github_tracker(&server.url(), pem); + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + // Org query returns null → fall back to user + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": {"organization": null}}"#) + .create_async() + .await; + // User query succeeds + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": {"user": {"projectV2": {"id": "PVT_user1"}}}}"#) + .create_async() + .await; + // Items page (empty) + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(empty_items_response()) + .create_async() + .await; + + let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap(); + assert!(issues.is_empty()); + } + + // ----------------------------------------------------------------------- + // fetch_viewer_id (GitHubTracker) + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn github_tracker_fetch_viewer_id_success() { + let mut server = mockito::Server::new_async().await; + let pem = test_rsa_key(); + let tracker = mock_github_tracker(&server.url(), pem); + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": {"viewer": {"id": "U_xyz"}}}"#) + .create_async() + .await; + + let id = tracker.fetch_viewer_id().await.unwrap(); + assert_eq!(id, "U_xyz"); + } + + // ----------------------------------------------------------------------- + // create_comment (GitHubTracker) + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn github_tracker_create_comment_success() { + let mut server = mockito::Server::new_async().await; + let pem = test_rsa_key(); + let tracker = mock_github_tracker(&server.url(), pem); + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": {"addComment": {"clientMutationId": null}}}"#) + .create_async() + .await; + + let issue = make_test_issue("In Progress"); + tracker.create_comment(&issue, "Great work!").await.unwrap(); + } + + // ----------------------------------------------------------------------- + // update_issue_state (GitHubTracker) + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn github_tracker_update_issue_state_success() { + let mut server = mockito::Server::new_async().await; + let pem = test_rsa_key(); + let tracker = mock_github_tracker(&server.url(), pem); + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + // Resolve project node ID (org path) + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(org_project_node_id_response()) + .create_async() + .await; + // Field query + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": {"node": {"field": {"id": "FLD_1", "options": [{"id": "opt-done", "name": "Done"}, {"id": "opt-todo", "name": "Todo"}]}}}}"#) + .create_async() + .await; + // Update mutation + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": {"updateProjectV2ItemFieldValue": {"projectV2Item": {"id": "PVTI_item1"}}}}"#) + .create_async() + .await; + + let issue = make_test_issue("In Progress"); + tracker.update_issue_state(&issue, "Done").await.unwrap(); + } + + #[tokio::test] + async fn github_tracker_update_issue_state_status_not_found() { + let mut server = mockito::Server::new_async().await; + let pem = test_rsa_key(); + let tracker = mock_github_tracker(&server.url(), pem); + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + // Resolve project node ID + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(org_project_node_id_response()) + .create_async() + .await; + // Field query — options don't include "Nonexistent" + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": {"node": {"field": {"id": "FLD_1", "options": [{"id": "opt-done", "name": "Done"}]}}}}"#) + .create_async() + .await; + + let issue = make_test_issue("Todo"); + let err = tracker + .update_issue_state(&issue, "Nonexistent") + .await + .unwrap_err(); + assert!(err.contains("Nonexistent"), "got: {err}"); + assert!(err.contains("not found"), "got: {err}"); + } + + // ----------------------------------------------------------------------- + // fetch_candidate_issues (GitHubTracker) + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn github_tracker_fetch_candidate_issues_single_page() { + let mut server = mockito::Server::new_async().await; + let pem = test_rsa_key(); + let tracker = mock_github_tracker(&server.url(), pem); + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(org_project_node_id_response()) + .create_async() + .await; + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(single_item_response("In Progress")) + .create_async() + .await; + + let issues = tracker + .fetch_candidate_issues(&["In Progress"]) + .await + .unwrap(); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].identifier, "#42"); + assert_eq!(issues[0].state, "In Progress"); + assert_eq!(issues[0].id, "I_issue1"); + assert_eq!(issues[0].project_item_id.as_deref(), Some("PVTI_item1")); + assert_eq!(issues[0].labels, vec!["bug"]); + assert!(issues[0].branch_name.is_none()); + assert!(issues[0].priority.is_none()); + } + + #[tokio::test] + async fn github_tracker_fetch_candidate_issues_empty() { + let mut server = mockito::Server::new_async().await; + let pem = test_rsa_key(); + let tracker = mock_github_tracker(&server.url(), pem); + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(org_project_node_id_response()) + .create_async() + .await; + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(empty_items_response()) + .create_async() + .await; + + let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap(); + assert!(issues.is_empty()); + } + + #[tokio::test] + async fn github_tracker_fetch_candidate_issues_status_filtering() { + let mut server = mockito::Server::new_async().await; + let pem = test_rsa_key(); + let tracker = mock_github_tracker(&server.url(), pem); + + let items_body = serde_json::json!({ + "data": { + "node": { + "items": { + "nodes": [ + { + "id": "PVTI_done", + "fieldValueByName": {"name": "Done"}, + "content": { + "id": "I_done1", "number": 10, "title": "Done issue", + "body": null, "url": "https://github.com/owner/repo/issues/10", + "createdAt": null, "updatedAt": null, + "assignees": {"nodes": []}, "labels": {"nodes": []} + } + }, + { + "id": "PVTI_inprog", + "fieldValueByName": {"name": "In Progress"}, + "content": { + "id": "I_inprog1", "number": 20, "title": "Active issue", + "body": null, "url": "https://github.com/owner/repo/issues/20", + "createdAt": null, "updatedAt": null, + "assignees": {"nodes": []}, "labels": {"nodes": []} + } + } + ], + "pageInfo": {"hasNextPage": false, "endCursor": null} + } + } + } + }) + .to_string(); + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(org_project_node_id_response()) + .create_async() + .await; + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(items_body) + .create_async() + .await; + + let issues = tracker + .fetch_candidate_issues(&["In Progress"]) + .await + .unwrap(); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].identifier, "#20"); + } + + // ----------------------------------------------------------------------- + // fetch_issues_by_ids (GitHubTracker) + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn github_tracker_fetch_issues_by_ids_ordering() { + let mut server = mockito::Server::new_async().await; + let pem = test_rsa_key(); + let tracker = mock_github_tracker(&server.url(), pem); + + // Page returns issues in reverse order of what we request + let items_body = serde_json::json!({ + "data": { + "node": { + "items": { + "nodes": [ + { + "id": "PVTI_b", + "fieldValueByName": {"name": "Todo"}, + "content": { + "id": "I_b", "number": 2, "title": "B", + "body": null, "url": "https://github.com/owner/repo/issues/2", + "createdAt": null, "updatedAt": null, + "assignees": {"nodes": []}, "labels": {"nodes": []} + } + }, + { + "id": "PVTI_a", + "fieldValueByName": {"name": "Todo"}, + "content": { + "id": "I_a", "number": 1, "title": "A", + "body": null, "url": "https://github.com/owner/repo/issues/1", + "createdAt": null, "updatedAt": null, + "assignees": {"nodes": []}, "labels": {"nodes": []} + } + } + ], + "pageInfo": {"hasNextPage": false, "endCursor": null} + } + } + } + }) + .to_string(); + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(org_project_node_id_response()) + .create_async() + .await; + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(items_body) + .create_async() + .await; + + // Request in A, B order — should get back in A, B order despite page returning B, A + let issues = tracker.fetch_issues_by_ids(&["I_a", "I_b"]).await.unwrap(); + + assert_eq!(issues.len(), 2); + assert_eq!(issues[0].id, "I_a"); + assert_eq!(issues[1].id, "I_b"); + } + + #[tokio::test] + async fn github_tracker_fetch_issues_by_ids_empty() { + let pem = test_rsa_key(); + let tracker = mock_github_tracker("http://unused", pem); + + // Empty input → no HTTP calls at all + let issues = tracker.fetch_issues_by_ids(&[]).await.unwrap(); + assert!(issues.is_empty()); + } +} diff --git a/lib/crates/fabro-tracker/src/lib.rs b/lib/crates/fabro-tracker/src/lib.rs index 1dc9554d7..fa7df360b 100644 --- a/lib/crates/fabro-tracker/src/lib.rs +++ b/lib/crates/fabro-tracker/src/lib.rs @@ -1,5 +1,70 @@ use async_trait::async_trait; +pub mod github; +pub mod linear; + +pub use github::GitHubTracker; +pub use linear::{LinearConfig, LinearTracker, LINEAR_API_ENDPOINT}; + +/// Shared GraphQL execution used by both provider modules. +/// +/// Posts a query + variables to `endpoint`, attaches the given `auth_header` +/// as the `Authorization` value, and returns the parsed JSON response. +/// Provider-specific error messages use `provider` as a label. +pub(crate) async fn execute_graphql_request( + client: &reqwest::Client, + endpoint: &str, + auth_header: &str, + provider: &str, + query: &str, + variables: serde_json::Value, +) -> Result { + let body = serde_json::json!({ + "query": query, + "variables": variables, + }); + + let resp = client + .post(endpoint) + .header("Authorization", auth_header) + .header("Content-Type", "application/json") + .header("User-Agent", "fabro") + .timeout(std::time::Duration::from_secs(30)) + .json(&body) + .send() + .await + .map_err(|e| format!("{provider} GraphQL request failed: {e}"))?; + + let status = resp.status(); + if !status.is_success() { + let body_text = resp.text().await.unwrap_or_default(); + tracing::warn!(status = %status, provider, "GraphQL API error"); + return Err(format!( + "{provider} GraphQL API returned HTTP {status}: {body_text}" + )); + } + + let response: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse {provider} GraphQL response: {e}"))?; + + if let Some(errors) = response["errors"].as_array() { + if !errors.is_empty() { + let messages: Vec<&str> = errors + .iter() + .filter_map(|e| e["message"].as_str()) + .collect(); + return Err(format!( + "{provider} GraphQL errors: {}", + messages.join("; ") + )); + } + } + + Ok(response) +} + #[derive(Debug, Clone)] pub struct BlockerRef { pub id: String, diff --git a/lib/crates/fabro-linear/src/lib.rs b/lib/crates/fabro-tracker/src/linear.rs similarity index 72% rename from lib/crates/fabro-linear/src/lib.rs rename to lib/crates/fabro-tracker/src/linear.rs index 02b4b6667..1238af464 100644 --- a/lib/crates/fabro-linear/src/lib.rs +++ b/lib/crates/fabro-tracker/src/linear.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; +use async_trait::async_trait; use serde_json::Value; +use crate::{execute_graphql_request, BlockerRef, Issue, Tracker}; + pub const LINEAR_API_ENDPOINT: &str = "https://api.linear.app/graphql"; const BLOCKS_RELATION_TYPE: &str = "blocks"; @@ -21,30 +24,6 @@ impl LinearConfig { } } -#[derive(Debug, Clone)] -pub struct Issue { - pub id: String, - pub identifier: String, - pub title: String, - pub description: Option, - pub priority: Option, - pub state: String, - pub branch_name: Option, - pub url: String, - pub assignee_id: Option, - pub labels: Vec, - pub blocked_by: Vec, - pub created_at: Option, - pub updated_at: Option, -} - -#[derive(Debug, Clone)] -pub struct BlockerRef { - pub id: String, - pub identifier: String, - pub state: String, -} - const ISSUE_FIELDS: &str = " id identifier @@ -122,6 +101,7 @@ fn normalize_issue(node: &Value) -> Result { Ok(Issue { id, + project_item_id: None, identifier, title, description, @@ -143,150 +123,15 @@ async fn execute_graphql( query: &str, variables: Value, ) -> Result { - let body = serde_json::json!({ - "query": query, - "variables": variables, - }); - - let resp = client - .post(&config.endpoint) - .header("Authorization", &config.api_key) - .header("Content-Type", "application/json") - .timeout(std::time::Duration::from_secs(30)) - .json(&body) - .send() - .await - .map_err(|e| format!("Linear API request failed: {e}"))?; - - let status = resp.status(); - if !status.is_success() { - let body_text = resp.text().await.unwrap_or_default(); - tracing::warn!(status = %status, "Linear API error"); - return Err(format!("Linear API returned HTTP {status}: {body_text}")); - } - - let response: Value = resp - .json() - .await - .map_err(|e| format!("Failed to parse Linear API response: {e}"))?; - - if let Some(errors) = response["errors"].as_array() { - if !errors.is_empty() { - let messages: Vec<&str> = errors - .iter() - .filter_map(|e| e["message"].as_str()) - .collect(); - return Err(format!("Linear GraphQL errors: {}", messages.join("; "))); - } - } - - Ok(response) -} - -pub async fn fetch_viewer_id( - client: &reqwest::Client, - config: &LinearConfig, -) -> Result { - tracing::debug!("Fetching viewer ID from Linear"); - let query = "query { viewer { id } }"; - let response = execute_graphql(client, config, query, serde_json::json!({})).await?; - - response["data"]["viewer"]["id"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| "Missing viewer id in response".to_string()) -} - -pub async fn create_comment( - client: &reqwest::Client, - config: &LinearConfig, - issue_id: &str, - body: &str, -) -> Result<(), String> { - tracing::debug!(issue_id, "Creating comment on Linear issue"); - let query = r#" - mutation($issueId: String!, $body: String!) { - commentCreate(input: { issueId: $issueId, body: $body }) { - success - } - } - "#; - - let variables = serde_json::json!({ - "issueId": issue_id, - "body": body, - }); - - let response = execute_graphql(client, config, query, variables).await?; - - let success = response["data"]["commentCreate"]["success"] - .as_bool() - .unwrap_or(false); - if !success { - return Err("Linear commentCreate returned success: false".to_string()); - } - - Ok(()) -} - -pub async fn update_issue_state( - client: &reqwest::Client, - config: &LinearConfig, - issue_id: &str, - state_name: &str, -) -> Result<(), String> { - tracing::debug!(issue_id, state_name, "Updating Linear issue state"); - // Step 1: Resolve state name to ID via the issue's team - let resolve_query = r#" - query($issueId: String!, $stateName: String!) { - issue(id: $issueId) { - team { - states(filter: { name: { eq: $stateName } }) { - nodes { id } - } - } - } - } - "#; - - let resolve_vars = serde_json::json!({ - "issueId": issue_id, - "stateName": state_name, - }); - - let resolve_resp = execute_graphql(client, config, resolve_query, resolve_vars).await?; - - let state_id = resolve_resp["data"]["issue"]["team"]["states"]["nodes"] - .as_array() - .and_then(|arr| arr.first()) - .and_then(|node| node["id"].as_str()) - .ok_or_else(|| format!("State '{state_name}' not found for issue {issue_id}"))? - .to_string(); - - // Step 2: Update the issue - let update_query = r#" - mutation($issueId: String!, $stateId: String!) { - issueUpdate(id: $issueId, input: { stateId: $stateId }) { - success - } - } - "#; - - let update_vars = serde_json::json!({ - "issueId": issue_id, - "stateId": state_id, - }); - - let update_resp = execute_graphql(client, config, update_query, update_vars).await?; - - let success = update_resp["data"]["issueUpdate"]["success"] - .as_bool() - .unwrap_or(false); - if !success { - return Err("Linear issueUpdate returned success: false".to_string()); - } - - Ok(()) + execute_graphql_request( + client, + &config.endpoint, + &config.api_key, + "Linear", + query, + variables, + ) + .await } fn extract_issues(response: &Value) -> Result, String> { @@ -297,95 +142,202 @@ fn extract_issues(response: &Value) -> Result, String> { nodes.iter().map(normalize_issue).collect() } -pub async fn fetch_candidate_issues( - client: &reqwest::Client, - config: &LinearConfig, - project_slug: &str, - state_names: &[&str], -) -> Result, String> { - tracing::debug!( - project_slug, - ?state_names, - "Fetching candidate issues from Linear" - ); - - let query = format!( - r#" - query($slug: String!, $states: [String!]!, $cursor: String) {{ - issues( - first: 50 - after: $cursor - filter: {{ - project: {{ slugId: {{ eq: $slug }} }} - state: {{ name: {{ in: $states }} }} - }} - ) {{ - nodes {{ {ISSUE_FIELDS} }} - pageInfo {{ hasNextPage endCursor }} - }} - }} - "# - ); - - let mut all_issues = Vec::new(); - let mut cursor: Option = None; - - loop { - let variables = serde_json::json!({ - "slug": project_slug, - "states": state_names, - "cursor": cursor, - }); - - let response = execute_graphql(client, config, &query, variables).await?; - - all_issues.extend(extract_issues(&response)?); - - let page_info = &response["data"]["issues"]["pageInfo"]; - if page_info["hasNextPage"].as_bool() == Some(true) { - cursor = page_info["endCursor"].as_str().map(|s| s.to_string()); - } else { - break; - } - } - - Ok(all_issues) +/// A `Tracker` implementation backed by Linear. +pub struct LinearTracker { + config: LinearConfig, + client: reqwest::Client, + project_slug: String, } -pub async fn fetch_issues_by_ids( - client: &reqwest::Client, - config: &LinearConfig, - ids: &[&str], -) -> Result, String> { - if ids.is_empty() { - return Ok(Vec::new()); - } - - tracing::debug!(count = ids.len(), "Fetching issues by ID from Linear"); - - let query = format!( - r#" - query($ids: [ID!]!) {{ - issues(filter: {{ id: {{ in: $ids }} }}) {{ - nodes {{ {ISSUE_FIELDS} }} - }} - }} - "# - ); - - let mut issue_map: HashMap = HashMap::with_capacity(ids.len()); - - for batch in ids.chunks(50) { - let variables = serde_json::json!({ "ids": batch }); - let response = execute_graphql(client, config, &query, variables).await?; - - for issue in extract_issues(&response)? { - issue_map.insert(issue.id.clone(), issue); +impl LinearTracker { + pub fn new(config: LinearConfig, client: reqwest::Client, project_slug: String) -> Self { + Self { + config, + client, + project_slug, } } +} - // Return in the same order as the input IDs - Ok(ids.iter().filter_map(|id| issue_map.remove(*id)).collect()) +#[async_trait] +impl Tracker for LinearTracker { + async fn fetch_viewer_id(&self) -> Result { + tracing::debug!("Fetching viewer ID from Linear"); + let query = "query { viewer { id } }"; + let response = + execute_graphql(&self.client, &self.config, query, serde_json::json!({})).await?; + + response["data"]["viewer"]["id"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "Missing viewer id in response".to_string()) + } + + async fn create_comment(&self, issue: &Issue, body: &str) -> Result<(), String> { + tracing::debug!(issue_id = %issue.id, "Creating comment on Linear issue"); + let query = r#" + mutation($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + success + } + } + "#; + + let variables = serde_json::json!({ + "issueId": issue.id, + "body": body, + }); + + let response = execute_graphql(&self.client, &self.config, query, variables).await?; + + let success = response["data"]["commentCreate"]["success"] + .as_bool() + .unwrap_or(false); + if !success { + return Err("Linear commentCreate returned success: false".to_string()); + } + + Ok(()) + } + + async fn update_issue_state(&self, issue: &Issue, state_name: &str) -> Result<(), String> { + tracing::debug!(issue_id = %issue.id, state_name, "Updating Linear issue state"); + // Step 1: Resolve state name to ID via the issue's team + let resolve_query = r#" + query($issueId: String!, $stateName: String!) { + issue(id: $issueId) { + team { + states(filter: { name: { eq: $stateName } }) { + nodes { id } + } + } + } + } + "#; + + let resolve_vars = serde_json::json!({ + "issueId": issue.id, + "stateName": state_name, + }); + + let resolve_resp = + execute_graphql(&self.client, &self.config, resolve_query, resolve_vars).await?; + + let state_id = resolve_resp["data"]["issue"]["team"]["states"]["nodes"] + .as_array() + .and_then(|arr| arr.first()) + .and_then(|node| node["id"].as_str()) + .ok_or_else(|| format!("State '{state_name}' not found for issue {}", issue.id))? + .to_string(); + + // Step 2: Update the issue + let update_query = r#" + mutation($issueId: String!, $stateId: String!) { + issueUpdate(id: $issueId, input: { stateId: $stateId }) { + success + } + } + "#; + + let update_vars = serde_json::json!({ + "issueId": issue.id, + "stateId": state_id, + }); + + let update_resp = + execute_graphql(&self.client, &self.config, update_query, update_vars).await?; + + let success = update_resp["data"]["issueUpdate"]["success"] + .as_bool() + .unwrap_or(false); + if !success { + return Err("Linear issueUpdate returned success: false".to_string()); + } + + Ok(()) + } + + async fn fetch_candidate_issues(&self, state_names: &[&str]) -> Result, String> { + tracing::debug!( + project_slug = %self.project_slug, + ?state_names, + "Fetching candidate issues from Linear" + ); + + let query = format!( + r#" + query($slug: String!, $states: [String!]!, $cursor: String) {{ + issues( + first: 50 + after: $cursor + filter: {{ + project: {{ slugId: {{ eq: $slug }} }} + state: {{ name: {{ in: $states }} }} + }} + ) {{ + nodes {{ {ISSUE_FIELDS} }} + pageInfo {{ hasNextPage endCursor }} + }} + }} + "# + ); + + let mut all_issues = Vec::new(); + let mut cursor: Option = None; + + loop { + let variables = serde_json::json!({ + "slug": self.project_slug, + "states": state_names, + "cursor": cursor, + }); + + let response = execute_graphql(&self.client, &self.config, &query, variables).await?; + + all_issues.extend(extract_issues(&response)?); + + let page_info = &response["data"]["issues"]["pageInfo"]; + if page_info["hasNextPage"].as_bool() == Some(true) { + cursor = page_info["endCursor"].as_str().map(|s| s.to_string()); + } else { + break; + } + } + + Ok(all_issues) + } + + async fn fetch_issues_by_ids(&self, ids: &[&str]) -> Result, String> { + if ids.is_empty() { + return Ok(Vec::new()); + } + + tracing::debug!(count = ids.len(), "Fetching issues by ID from Linear"); + + let query = format!( + r#" + query($ids: [ID!]!) {{ + issues(filter: {{ id: {{ in: $ids }} }}) {{ + nodes {{ {ISSUE_FIELDS} }} + }} + }} + "# + ); + + let mut issue_map: HashMap = HashMap::with_capacity(ids.len()); + + for batch in ids.chunks(50) { + let variables = serde_json::json!({ "ids": batch }); + let response = execute_graphql(&self.client, &self.config, &query, variables).await?; + + for issue in extract_issues(&response)? { + issue_map.insert(issue.id.clone(), issue); + } + } + + // Return in the same order as the input IDs + Ok(ids.iter().filter_map(|id| issue_map.remove(*id)).collect()) + } } #[cfg(test)] @@ -399,6 +351,25 @@ mod tests { } } + fn make_test_issue() -> Issue { + Issue { + id: "issue-1".to_string(), + project_item_id: None, + identifier: "T-1".to_string(), + title: "Test".to_string(), + description: None, + priority: None, + state: "Todo".to_string(), + branch_name: None, + url: "https://linear.app/t".to_string(), + assignee_id: None, + labels: vec![], + blocked_by: vec![], + created_at: None, + updated_at: None, + } + } + fn complete_issue_json() -> Value { serde_json::json!({ "id": "issue-1", @@ -438,6 +409,7 @@ mod tests { let issue = normalize_issue(&node).unwrap(); assert_eq!(issue.id, "issue-1"); + assert!(issue.project_item_id.is_none()); assert_eq!(issue.identifier, "ABC-123"); assert_eq!(issue.title, "Fix the bug"); assert_eq!(issue.description.as_deref(), Some("Detailed description")); @@ -479,6 +451,7 @@ mod tests { let issue = normalize_issue(&node).unwrap(); assert_eq!(issue.id, "issue-2"); + assert!(issue.project_item_id.is_none()); assert!(issue.description.is_none()); assert!(issue.priority.is_none()); assert!(issue.branch_name.is_none()); @@ -716,7 +689,7 @@ mod tests { } // ----------------------------------------------------------------------- - // fetch_viewer_id + // LinearTracker::fetch_viewer_id // ----------------------------------------------------------------------- #[tokio::test] @@ -731,8 +704,8 @@ mod tests { .create_async() .await; - let client = reqwest::Client::new(); - let id = fetch_viewer_id(&client, &config).await.unwrap(); + let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string()); + let id = tracker.fetch_viewer_id().await.unwrap(); assert_eq!(id, "user-abc"); } @@ -748,13 +721,13 @@ mod tests { .create_async() .await; - let client = reqwest::Client::new(); - let err = fetch_viewer_id(&client, &config).await.unwrap_err(); + let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string()); + let err = tracker.fetch_viewer_id().await.unwrap_err(); assert!(err.contains("401"), "got: {err}"); } // ----------------------------------------------------------------------- - // create_comment + // LinearTracker::create_comment // ----------------------------------------------------------------------- #[tokio::test] @@ -769,10 +742,9 @@ mod tests { .create_async() .await; - let client = reqwest::Client::new(); - create_comment(&client, &config, "issue-1", "Hello world") - .await - .unwrap(); + let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string()); + let issue = make_test_issue(); + tracker.create_comment(&issue, "Hello world").await.unwrap(); } #[tokio::test] @@ -787,15 +759,14 @@ mod tests { .create_async() .await; - let client = reqwest::Client::new(); - let err = create_comment(&client, &config, "issue-1", "Hello") - .await - .unwrap_err(); + let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string()); + let issue = make_test_issue(); + let err = tracker.create_comment(&issue, "Hello").await.unwrap_err(); assert!(err.contains("success: false"), "got: {err}"); } // ----------------------------------------------------------------------- - // update_issue_state + // LinearTracker::update_issue_state // ----------------------------------------------------------------------- #[tokio::test] @@ -821,10 +792,9 @@ mod tests { .create_async() .await; - let client = reqwest::Client::new(); - update_issue_state(&client, &config, "issue-1", "Done") - .await - .unwrap(); + let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string()); + let issue = make_test_issue(); + tracker.update_issue_state(&issue, "Done").await.unwrap(); resolve_mock.assert_async().await; update_mock.assert_async().await; @@ -842,8 +812,10 @@ mod tests { .create_async() .await; - let client = reqwest::Client::new(); - let err = update_issue_state(&client, &config, "issue-1", "Nonexistent") + let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string()); + let issue = make_test_issue(); + let err = tracker + .update_issue_state(&issue, "Nonexistent") .await .unwrap_err(); assert!(err.contains("Nonexistent"), "got: {err}"); @@ -851,7 +823,7 @@ mod tests { } // ----------------------------------------------------------------------- - // fetch_candidate_issues + // LinearTracker::fetch_candidate_issues // ----------------------------------------------------------------------- #[tokio::test] @@ -876,13 +848,15 @@ mod tests { .create_async() .await; - let client = reqwest::Client::new(); - let issues = fetch_candidate_issues(&client, &config, "my-project", &["In Progress"]) + let tracker = LinearTracker::new(config, reqwest::Client::new(), "my-project".to_string()); + let issues = tracker + .fetch_candidate_issues(&["In Progress"]) .await .unwrap(); assert_eq!(issues.len(), 1); assert_eq!(issues[0].identifier, "ABC-123"); + assert!(issues[0].project_item_id.is_none()); } #[tokio::test] @@ -937,10 +911,8 @@ mod tests { .create_async() .await; - let client = reqwest::Client::new(); - let issues = fetch_candidate_issues(&client, &config, "proj", &["Todo"]) - .await - .unwrap(); + let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string()); + let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap(); assert_eq!(issues.len(), 2); assert_eq!(issues[0].identifier, "T-1"); @@ -968,16 +940,14 @@ mod tests { .create_async() .await; - let client = reqwest::Client::new(); - let issues = fetch_candidate_issues(&client, &config, "proj", &["Todo"]) - .await - .unwrap(); + let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string()); + let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap(); assert!(issues.is_empty()); } // ----------------------------------------------------------------------- - // fetch_issues_by_ids + // LinearTracker::fetch_issues_by_ids // ----------------------------------------------------------------------- #[tokio::test] @@ -1012,8 +982,9 @@ mod tests { .create_async() .await; - let client = reqwest::Client::new(); - let issues = fetch_issues_by_ids(&client, &config, &["id-a", "id-b"]) + let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string()); + let issues = tracker + .fetch_issues_by_ids(&["id-a", "id-b"]) .await .unwrap(); @@ -1071,10 +1042,8 @@ mod tests { .create_async() .await; - let client = reqwest::Client::new(); - let issues = fetch_issues_by_ids(&client, &config, &id_refs) - .await - .unwrap(); + let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string()); + let issues = tracker.fetch_issues_by_ids(&id_refs).await.unwrap(); assert_eq!(issues.len(), 51); assert_eq!(issues[0].id, "id-0"); @@ -1083,9 +1052,9 @@ mod tests { #[tokio::test] async fn fetch_issues_by_ids_empty() { - let client = reqwest::Client::new(); let config = LinearConfig::new("unused".to_string()); - let issues = fetch_issues_by_ids(&client, &config, &[]).await.unwrap(); + let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string()); + let issues = tracker.fetch_issues_by_ids(&[]).await.unwrap(); assert!(issues.is_empty()); } }