mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-05 08:10:39 +00:00
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:
parent
1fc3495ba9
commit
35df8583db
8 changed files with 1523 additions and 1508 deletions
22
Cargo.lock
generated
22
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"] }
|
||||
|
|
@ -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"] }
|
||||
1181
lib/crates/fabro-tracker/src/github.rs
Normal file
1181
lib/crates/fabro-tracker/src/github.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue