feat(tracker): add Tracker trait with Linear and GitHub Projects V2 impls

- New arc-tracker crate defines the Tracker trait and shared Issue/BlockerRef types
- arc-linear: replace Issue/BlockerRef with arc-tracker re-exports, add LinearTracker struct implementing Tracker, remove free functions
- arc-github: add GitHubTracker implementing Tracker via Projects V2 GraphQL API, with lazy project node ID resolution (org → user fallback) and create_installation_access_token_for_projects helper
- 47 tests in arc-github (15 new), 24 tests in arc-linear (updated to use LinearTracker)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-11 23:32:03 -04:00
parent 10f7316f28
commit 111b730eef
6 changed files with 1308 additions and 0 deletions

11
Cargo.lock generated
View file

@ -303,6 +303,8 @@ dependencies = [
name = "arc-github"
version = "0.2.0"
dependencies = [
"arc-tracker",
"async-trait",
"base64",
"chrono",
"jsonwebtoken",
@ -318,6 +320,8 @@ dependencies = [
name = "arc-linear"
version = "0.2.0"
dependencies = [
"arc-tracker",
"async-trait",
"mockito",
"reqwest 0.12.28",
"serde",
@ -439,6 +443,13 @@ dependencies = [
"tracing",
]
[[package]]
name = "arc-tracker"
version = "0.2.0"
dependencies = [
"async-trait",
]
[[package]]
name = "arc-types"
version = "0.2.0"

View file

@ -9,12 +9,15 @@ description = "GitHub App authentication and API helpers for Arc"
doctest = false
[dependencies]
arc-tracker = { path = "../arc-tracker" }
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true
jsonwebtoken.workspace = true
chrono.workspace = true
tracing.workspace = true
tokio = { workspace = true }
[dev-dependencies]
mockito = "1"

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,8 @@ description = "Linear issue tracker API helpers for Arc"
doctest = false
[dependencies]
arc-tracker = { path = "../arc-tracker" }
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true

View file

@ -0,0 +1,12 @@
[package]
name = "arc-tracker"
edition.workspace = true
version.workspace = true
license.workspace = true
description = "Tracker trait and types for issue tracking integrations"
[lib]
doctest = false
[dependencies]
async-trait.workspace = true

View file

@ -0,0 +1,57 @@
use async_trait::async_trait;
#[derive(Debug, Clone)]
pub struct BlockerRef {
pub id: String,
pub identifier: String,
pub state: String,
}
#[derive(Debug, Clone)]
pub struct Issue {
/// Provider-native issue node ID.
pub id: String,
/// Provider-native project-item ID for status updates.
/// None for providers where the issue ID is sufficient (e.g. Linear).
/// For GitHub Projects, this is the ProjectV2Item node ID.
/// Each Tracker impl is scoped to a single project, so this is unambiguous
/// even when an issue belongs to multiple project boards.
pub project_item_id: Option<String>,
/// Human-readable identifier (e.g. "ABC-123" or "#42").
pub identifier: String,
pub title: String,
pub description: Option<String>,
pub priority: Option<i32>,
pub state: String,
pub branch_name: Option<String>,
pub url: String,
pub assignee_id: Option<String>,
pub labels: Vec<String>,
pub blocked_by: Vec<BlockerRef>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
/// Unified interface for project management / issue tracking systems.
///
/// Implementations are constructed with provider-specific config and used as
/// `Arc<dyn Tracker>` or `Box<dyn Tracker>`.
#[async_trait]
pub trait Tracker: Send + Sync {
/// Return the authenticated user's ID in the provider's system.
async fn fetch_viewer_id(&self) -> Result<String, String>;
/// Add a comment to an issue. Each impl extracts the appropriate ID.
async fn create_comment(&self, issue: &Issue, body: &str) -> Result<(), String>;
/// Transition an issue to a new state by name.
/// Each impl extracts the appropriate ID (issue ID or project item ID).
async fn update_issue_state(&self, issue: &Issue, state_name: &str) -> Result<(), String>;
/// Fetch issues matching any of the given state names.
/// Project identity is in the impl's config, not here.
async fn fetch_candidate_issues(&self, state_names: &[&str]) -> Result<Vec<Issue>, String>;
/// Fetch specific issues by their provider-native IDs (`Issue::id` values).
async fn fetch_issues_by_ids(&self, ids: &[&str]) -> Result<Vec<Issue>, String>;
}