use anyhow::{Context as _, anyhow}; use async_trait::async_trait; use fabro_github::{ GitHubCredentials, create_installation_access_token_for_projects, sign_app_jwt, }; use tokio::sync::OnceCell; use crate::{Issue, Tracker, execute_graphql_request}; /// Execute a GitHub GraphQL request and return the response JSON. async fn execute_github_graphql( client: &fabro_http::HttpClient, token: &str, endpoint: &str, query: &str, variables: serde_json::Value, ) -> anyhow::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: GitHubCredentials, client: fabro_http::HttpClient, owner: String, repo: String, project_number: u64, base_url: String, project_node_id: OnceCell, } impl GitHubTracker { pub fn new( creds: GitHubCredentials, client: fabro_http::HttpClient, 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) -> anyhow::Result { match &self.creds { GitHubCredentials::App(creds) => { let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem) .context("signing GitHub App JWT")?; create_installation_access_token_for_projects( &self.client, &jwt, &self.owner, &self.repo, &self.base_url, ) .await .context("creating GitHub App installation token") } GitHubCredentials::Pat(token) => Ok(token.clone()), GitHubCredentials::Installation(token) => token.valid_token().map(str::to_owned), } } async fn resolve_project_node_id(&self, token: &str) -> anyhow::Result<&str> { 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(std::string::ToString::to_string) .ok_or_else(|| { anyhow!( "Project #{} not found for owner '{}'", self.project_number, self.owner ) }) }) .await .map(std::string::String::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(std::string::ToString::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(std::string::ToString::to_string); let labels = content["labels"]["nodes"] .as_array() .map(|arr| { arr.iter() .filter_map(|l| l["name"].as_str()) .map(str::to_lowercase) .collect() }) .unwrap_or_default(); let created_at = content["createdAt"] .as_str() .map(std::string::ToString::to_string); let updated_at = content["updatedAt"] .as_str() .map(std::string::ToString::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: &fabro_http::HttpClient, token: &str, graphql_url: &str, project_node_id: &str, cursor: Option<&str>, ) -> anyhow::Result<(Vec, bool, Option)> { 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(std::string::ToString::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) -> anyhow::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(std::string::ToString::to_string) .ok_or_else(|| anyhow!("Missing viewer id in GitHub response")) } async fn create_comment(&self, issue: &Issue, body: &str) -> anyhow::Result<()> { 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) -> anyhow::Result<()> { let project_item_id = issue .project_item_id .as_deref() .ok_or_else(|| anyhow!("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_else(|| anyhow!("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(|| anyhow!("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]) -> anyhow::Result> { 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]) -> anyhow::Result> { 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 fabro_github::{GitHubAppCredentials, GitHubCredentials}; use super::*; use crate::Issue; fn test_http_client() -> fabro_http::HttpClient { fabro_http::test_http_client().unwrap() } fn test_rsa_key() -> String { include_str!("fixtures/github-app-test-key.pem").to_string() } // ----------------------------------------------------------------------- // execute_github_graphql // ----------------------------------------------------------------------- #[tokio::test] async fn execute_github_graphql_success() { let server = httpmock::MockServer::start_async().await; let mock = server .mock_async(|when, then| { when.method("POST") .path("/graphql") .header("Authorization", "Bearer test-token") .header("Content-Type", "application/json") .header("User-Agent", "fabro"); then.status(200) .body(r#"{"data": {"viewer": {"id": "U_abc"}}}"#); }) .await; let client = test_http_client(); let result = execute_github_graphql( &client, "test-token", &server.url("/graphql"), "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 server = httpmock::MockServer::start_async().await; server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(401).body("Unauthorized"); }) .await; let client = test_http_client(); let err = execute_github_graphql( &client, "bad-token", &server.url("/graphql"), "query { viewer { id } }", serde_json::json!({}), ) .await .unwrap_err(); assert!(err.to_string().contains("401"), "got: {err}"); } #[tokio::test] async fn execute_github_graphql_errors_array() { let server = httpmock::MockServer::start_async().await; server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(200) .body(r#"{"data": null, "errors": [{"message": "Not found"}]}"#); }) .await; let client = test_http_client(); let err = execute_github_graphql( &client, "token", &server.url("/graphql"), "query { bad }", serde_json::json!({}), ) .await .unwrap_err(); assert!(err.to_string().contains("Not found"), "got: {err}"); } #[tokio::test] async fn execute_github_graphql_correct_headers() { let server = httpmock::MockServer::start_async().await; let mock = server .mock_async(|when, then| { when.method("POST") .path("/graphql") .header("Authorization", "Bearer my-token") .header("Content-Type", "application/json") .header("User-Agent", "fabro"); then.status(200).body(r#"{"data": {}}"#); }) .await; let client = test_http_client(); execute_github_graphql( &client, "my-token", &server.url("/graphql"), "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( GitHubCredentials::App(GitHubAppCredentials { app_id: "test-app".to_string(), private_key_pem: pem, slug: None, }), test_http_client(), "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 server = httpmock::MockServer::start_async().await; let pem = test_rsa_key(); let tracker = mock_github_tracker(&server.url(""), pem); server .mock_async(|when, then| { when.method("GET").path("/repos/owner/repo/installation"); then.status(200).body(r#"{"id": 1}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/app/installations/1/access_tokens"); then.status(201) .body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("organization(login:"); then.status(200).body(org_project_node_id_response()); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("items(first:"); then.status(200).body(empty_items_response()); }) .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 server = httpmock::MockServer::start_async().await; let pem = test_rsa_key(); let tracker = mock_github_tracker(&server.url(""), pem); server .mock_async(|when, then| { when.method("GET").path("/repos/owner/repo/installation"); then.status(200).body(r#"{"id": 1}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/app/installations/1/access_tokens"); then.status(201) .body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("organization(login:"); then.status(200).body(r#"{"data": {"organization": null}}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("user(login:"); then.status(200) .body(r#"{"data": {"user": {"projectV2": {"id": "PVT_user1"}}}}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("items(first:"); then.status(200).body(empty_items_response()); }) .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 server = httpmock::MockServer::start_async().await; let pem = test_rsa_key(); let tracker = mock_github_tracker(&server.url(""), pem); server .mock_async(|when, then| { when.method("GET").path("/repos/owner/repo/installation"); then.status(200).body(r#"{"id": 1}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/app/installations/1/access_tokens"); then.status(201) .body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#); }) .await; server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(200) .body(r#"{"data": {"viewer": {"id": "U_xyz"}}}"#); }) .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 server = httpmock::MockServer::start_async().await; let pem = test_rsa_key(); let tracker = mock_github_tracker(&server.url(""), pem); server .mock_async(|when, then| { when.method("GET").path("/repos/owner/repo/installation"); then.status(200).body(r#"{"id": 1}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/app/installations/1/access_tokens"); then.status(201) .body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#); }) .await; server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(200) .body(r#"{"data": {"addComment": {"clientMutationId": null}}}"#); }) .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 server = httpmock::MockServer::start_async().await; let pem = test_rsa_key(); let tracker = mock_github_tracker(&server.url(""), pem); server .mock_async(|when, then| { when.method("GET").path("/repos/owner/repo/installation"); then.status(200).body(r#"{"id": 1}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/app/installations/1/access_tokens"); then.status(201) .body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("organization(login:"); then.status(200).body(org_project_node_id_response()); }) .await; // Field query server.mock_async(|when, then| { when.method("POST").path("/graphql").body_includes("field(name:"); then.status(200).body(r#"{"data": {"node": {"field": {"id": "FLD_1", "options": [{"id": "opt-done", "name": "Done"}, {"id": "opt-todo", "name": "Todo"}]}}}}"#); }).await; server.mock_async(|when, then| { when.method("POST").path("/graphql").body_includes("updateProjectV2ItemFieldValue"); then.status(200).body(r#"{"data": {"updateProjectV2ItemFieldValue": {"projectV2Item": {"id": "PVTI_item1"}}}}"#); }).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 server = httpmock::MockServer::start_async().await; let pem = test_rsa_key(); let tracker = mock_github_tracker(&server.url(""), pem); server .mock_async(|when, then| { when.method("GET").path("/repos/owner/repo/installation"); then.status(200).body(r#"{"id": 1}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/app/installations/1/access_tokens"); then.status(201) .body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("organization(login:"); then.status(200).body(org_project_node_id_response()); }) .await; // Field query — options don't include "Nonexistent" server.mock_async(|when, then| { when.method("POST").path("/graphql").body_includes("field(name:"); then.status(200).body(r#"{"data": {"node": {"field": {"id": "FLD_1", "options": [{"id": "opt-done", "name": "Done"}]}}}}"#); }).await; let issue = make_test_issue("Todo"); let err = tracker .update_issue_state(&issue, "Nonexistent") .await .unwrap_err(); assert!(err.to_string().contains("Nonexistent"), "got: {err}"); assert!(err.to_string().contains("not found"), "got: {err}"); } // ----------------------------------------------------------------------- // fetch_candidate_issues (GitHubTracker) // ----------------------------------------------------------------------- #[tokio::test] async fn github_tracker_fetch_candidate_issues_single_page() { let server = httpmock::MockServer::start_async().await; let pem = test_rsa_key(); let tracker = mock_github_tracker(&server.url(""), pem); server .mock_async(|when, then| { when.method("GET").path("/repos/owner/repo/installation"); then.status(200).body(r#"{"id": 1}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/app/installations/1/access_tokens"); then.status(201) .body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("organization(login:"); then.status(200).body(org_project_node_id_response()); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("items(first:"); then.status(200).body(single_item_response("In Progress")); }) .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 server = httpmock::MockServer::start_async().await; let pem = test_rsa_key(); let tracker = mock_github_tracker(&server.url(""), pem); server .mock_async(|when, then| { when.method("GET").path("/repos/owner/repo/installation"); then.status(200).body(r#"{"id": 1}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/app/installations/1/access_tokens"); then.status(201) .body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("organization(login:"); then.status(200).body(org_project_node_id_response()); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("items(first:"); then.status(200).body(empty_items_response()); }) .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 server = httpmock::MockServer::start_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_async(|when, then| { when.method("GET").path("/repos/owner/repo/installation"); then.status(200).body(r#"{"id": 1}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/app/installations/1/access_tokens"); then.status(201) .body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("organization(login:"); then.status(200).body(org_project_node_id_response()); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("items(first:"); then.status(200).body(items_body); }) .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 server = httpmock::MockServer::start_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_async(|when, then| { when.method("GET").path("/repos/owner/repo/installation"); then.status(200).body(r#"{"id": 1}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/app/installations/1/access_tokens"); then.status(201) .body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("organization(login:"); then.status(200).body(org_project_node_id_response()); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("items(first:"); then.status(200).body(items_body); }) .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()); } }