From 35df8583dbc94a5efb103d28027fefd4f3874d54 Mon Sep 17 00:00:00 2001
From: "brynary-fabro[bot]"
<265161896+brynary-fabro[bot]@users.noreply.github.com>
Date: Thu, 19 Mar 2026 22:26:32 -0400
Subject: [PATCH] Merge fabro-linear and GitHub tracker into fabro-tracker
(#108)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR consolidates the tracker ecosystem from three crates
(`fabro-tracker`, `fabro-linear`, `fabro-github`) into two by merging
both tracker implementations into `fabro-tracker` and deleting
`fabro-linear`. The `GitHubTracker` and its supporting functions
(`execute_github_graphql`, `normalize_github_item`,
`fetch_project_items_page`) have been moved from `fabro-github` into a
new `fabro-tracker/src/github.rs` module, while the Linear
implementation from `fabro-linear` moves into
`fabro-tracker/src/linear.rs`. The duplicate `Issue` and `BlockerRef`
type definitions that existed in `fabro-linear` are removed in favor of
the canonical types already defined in `fabro-tracker`.
The dependency direction between `fabro-github` and `fabro-tracker` is
intentionally reversed: `fabro-tracker` now depends on `fabro-github`
for auth primitives (`GitHubAppCredentials`, `sign_app_jwt`,
`create_installation_access_token_for_projects`), while `fabro-github`
drops its dependency on `fabro-tracker` entirely. This eliminates the
circular dependency risk and keeps `fabro-github` focused on its core
responsibility of GitHub App authentication and REST/GraphQL transport.
A shared `execute_graphql_request` helper is introduced in
`fabro-tracker` to reduce duplication between the GitHub and Linear
GraphQL implementations.
All tests that previously lived in `fabro-github` and `fabro-linear` are
relocated to their respective new modules in `fabro-tracker`. The
`test_rsa_key()` helper used in GitHub tracker tests is duplicated in
`fabro-tracker/src/github.rs` since test utilities are not importable
across crate boundaries. The Linear `normalize_issue` function is
updated to set `project_item_id: None` to conform to the shared `Issue`
type, and existing Linear tests are updated accordingly.
### Fabro Details
Ran 9 stages in 24m 3s for $6.93
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 1m 15s | – | 0 |
| preflight_lint | 13s | – | 0 |
| implement | 14m 56s | $4.58 | 0 |
| simplify_opus | 6m 50s | $2.35 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 19s | – | 0 |
| fmt | 1s | – | 0 |
| **Total** | **24m 3s** | **$6.93** | **0** |
Ran ImplementAndSimplify.fabro (12 nodes and 15
edges)
```dot
digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-54)", prompt="@prompts/simplify.md", model="gpt-54"]
verify [label="Verify", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo fmt --all 2>&1", max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=success"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=success"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=success"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> fmt [condition="outcome=success"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro
---
Cargo.lock | 22 +-
lib/crates/fabro-github/Cargo.toml | 2 -
lib/crates/fabro-github/src/lib.rs | 1176 ----------------
lib/crates/fabro-linear/Cargo.toml | 21 -
lib/crates/fabro-tracker/Cargo.toml | 9 +
lib/crates/fabro-tracker/src/github.rs | 1181 +++++++++++++++++
lib/crates/fabro-tracker/src/lib.rs | 65 +
.../lib.rs => fabro-tracker/src/linear.rs} | 555 ++++----
8 files changed, 1523 insertions(+), 1508 deletions(-)
delete mode 100644 lib/crates/fabro-linear/Cargo.toml
create mode 100644 lib/crates/fabro-tracker/src/github.rs
rename lib/crates/{fabro-linear/src/lib.rs => fabro-tracker/src/linear.rs} (72%)
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());
}
}