Merge fabro-linear and GitHub tracker into fabro-tracker (#108)

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

<details>
<summary>Ran 9 stages in 24m 3s for $6.93</summary>

| 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** |

</details>

<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (12 nodes and 15
edges)</summary>

```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
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
brynary-fabro[bot] 2026-03-19 22:26:32 -04:00 committed by GitHub
parent 1fc3495ba9
commit 35df8583db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1523 additions and 1508 deletions

22
Cargo.lock generated
View file

@ -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]]

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"] }

View file

@ -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"] }

File diff suppressed because it is too large Load diff

View file

@ -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<serde_json::Value, String> {
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,

View file

@ -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<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>,
}
#[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<Issue, String> {
Ok(Issue {
id,
project_item_id: None,
identifier,
title,
description,
@ -143,150 +123,15 @@ async fn execute_graphql(
query: &str,
variables: Value,
) -> Result<Value, String> {
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<String, String> {
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<Vec<Issue>, String> {
@ -297,95 +142,202 @@ fn extract_issues(response: &Value) -> Result<Vec<Issue>, 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<Vec<Issue>, 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<String> = 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<Vec<Issue>, 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<String, Issue> = 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<String, String> {
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<Vec<Issue>, 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<String> = 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<Vec<Issue>, 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<String, Issue> = 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());
}
}