fabro/lib/components/fabro-tracker/src/lib.rs
2026-07-23 17:59:34 -04:00

156 lines
5.2 KiB
Rust

use anyhow::{Context as _, bail};
use async_trait::async_trait;
pub mod github;
pub mod linear;
pub use github::GitHubTracker;
pub use linear::{LINEAR_API_ENDPOINT, LinearOptions, LinearTracker};
/// 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: &fabro_http::HttpClient,
endpoint: &str,
auth_header: &str,
provider: &str,
query: &str,
variables: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
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
.with_context(|| format!("{provider} GraphQL request failed"))?;
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");
bail!("{provider} GraphQL API returned HTTP {status}: {body_text}");
}
let response: serde_json::Value = resp
.json()
.await
.with_context(|| format!("Failed to parse {provider} GraphQL response"))?;
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();
bail!("{provider} GraphQL errors: {}", messages.join("; "));
}
}
Ok(response)
}
#[derive(Debug, Clone)]
pub struct BlockerRef {
pub id: String,
pub identifier: String,
pub state: String,
}
#[derive(Debug, Clone)]
pub struct Issue {
/// Provider-native issue node ID.
pub id: String,
/// Provider-native project-item ID for status updates.
/// None for providers where the issue ID is sufficient (e.g. Linear).
/// For GitHub Projects, this is the ProjectV2Item node ID.
/// Each Tracker impl is scoped to a single project, so this is unambiguous
/// even when an issue belongs to multiple project boards.
pub project_item_id: Option<String>,
/// Human-readable identifier (e.g. "ABC-123" or "#42").
pub identifier: String,
pub title: String,
pub description: Option<String>,
pub priority: Option<i32>,
pub state: String,
pub branch_name: Option<String>,
pub url: String,
pub assignee_id: Option<String>,
pub labels: Vec<String>,
pub blocked_by: Vec<BlockerRef>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
/// Unified interface for project management / issue tracking systems.
///
/// Implementations are constructed with provider-specific config and used as
/// `Arc<dyn Tracker>` or `Box<dyn Tracker>`.
#[async_trait]
pub trait Tracker: Send + Sync {
/// Return the authenticated user's ID in the provider's system.
async fn fetch_viewer_id(&self) -> anyhow::Result<String>;
/// Add a comment to an issue. Each impl extracts the appropriate ID.
async fn create_comment(&self, issue: &Issue, body: &str) -> anyhow::Result<()>;
/// Transition an issue to a new state by name.
/// Each impl extracts the appropriate ID (issue ID or project item ID).
async fn update_issue_state(&self, issue: &Issue, state_name: &str) -> anyhow::Result<()>;
/// Fetch issues matching any of the given state names.
/// Project identity is in the impl's config, not here.
async fn fetch_candidate_issues(&self, state_names: &[&str]) -> anyhow::Result<Vec<Issue>>;
/// Fetch specific issues by their provider-native IDs (`Issue::id` values).
async fn fetch_issues_by_ids(&self, ids: &[&str]) -> anyhow::Result<Vec<Issue>>;
}
#[cfg(test)]
mod tests {
use tokio::net::TcpListener;
use super::execute_graphql_request;
#[tokio::test]
async fn execute_graphql_request_preserves_transport_source_chain() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}/graphql", listener.local_addr().unwrap());
drop(listener);
let client = fabro_http::test_http_client().unwrap();
let err = execute_graphql_request(
&client,
&endpoint,
"Bearer test",
"Test",
"query { viewer { id } }",
serde_json::json!({}),
)
.await
.unwrap_err();
let chain = err.chain().map(ToString::to_string).collect::<Vec<_>>();
assert!(
chain.len() >= 2,
"expected transport source chain, got {chain:#?}"
);
assert!(
chain
.iter()
.any(|cause| cause.contains("error sending request")),
"expected reqwest source in chain, got {chain:#?}"
);
}
}