use std::any::Any;
use std::panic::AssertUnwindSafe;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use futures::{FutureExt, StreamExt, TryStreamExt};
use linear_api::comments::{CommentCreateInput, CommentListRequest};
use linear_api::issues::{
Issue, IssueCreateInput, IssueUpdateInput, ListIssuesRequest, SearchIssuesRequest,
};
use linear_api::labels::{LabelCreateInput, LabelUpdateInput, ListLabelsRequest};
use linear_api::projects::{
ListProjectsRequest, ProjectCreateInput, ProjectMilestoneCreateInput,
ProjectMilestoneUpdateInput, ProjectUpdateInput,
};
use linear_api::workspace::{ListTeamsRequest, ListUsersRequest, ListWorkflowStatesRequest, Team};
use linear_api::{
CommentId, Error, IdComparator, IssueFilter, IssueId, IssueLabelFilter, IssueRef,
IssueRelationId, IssueRelationType, LabelId, LinearClient, Priority, ProjectFilter, ProjectId,
ProjectMilestoneId, StringComparator, TeamFilter, TeamId, TimelessDate, Undefinable,
WorkflowStateFilter,
};
const MARKER_DESCRIPTION: &str = "temporary linear-api SDK verification — safe to delete";
fn live_client() -> Option<LinearClient> {
if std::env::var("LINEAR_API_KEY").is_err() {
eprintln!("skipping live mutation test: LINEAR_API_KEY is not set");
return None;
}
Some(LinearClient::from_env().expect("LINEAR_API_KEY should build a client"))
}
fn stamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after epoch")
.as_secs()
}
async fn pick_test_team(client: &LinearClient) -> Team {
if let Ok(key) = std::env::var("LINEAR_LIVE_TEAM_KEY") {
let team = client
.teams()
.get(&TeamId::new(key))
.await
.expect("LINEAR_LIVE_TEAM_KEY should resolve to a team");
eprintln!(
"[team] using LINEAR_LIVE_TEAM_KEY override: {} ({} — {})",
team.key, team.name, team.id
);
return team;
}
let data = client
.execute_raw(
"query { teams(first: 50) { nodes { id key private retiredAt \
latest: issues(first: 1, orderBy: updatedAt) { nodes { updatedAt } } } } }",
serde_json::json!({}),
)
.await
.expect("team activity survey should succeed");
let nodes = data["teams"]["nodes"]
.as_array()
.expect("teams.nodes should be an array")
.clone();
let mut best: Option<(String, String)> = None;
for node in &nodes {
if node["private"].as_bool() == Some(true) || !node["retiredAt"].is_null() {
continue;
}
let id = node["id"].as_str().expect("team id").to_owned();
let latest = node["latest"]["nodes"][0]["updatedAt"]
.as_str()
.unwrap_or("")
.to_owned();
let replace = match &best {
None => true,
Some((current, _)) => latest < *current,
};
if replace {
best = Some((latest, id));
}
}
let (_, id) = best.expect("workspace should have a non-private team");
let team = client
.teams()
.get(&TeamId::new(id))
.await
.expect("picked team should resolve");
eprintln!(
"[team] auto-picked least-active team: {} ({} — {})",
team.key, team.name, team.id
);
team
}
#[derive(Default)]
struct Registry {
relations: Vec<(IssueRelationId, IssueId)>,
comments: Vec<(CommentId, IssueId)>,
milestones: Vec<(ProjectMilestoneId, ProjectId)>,
issues: Vec<IssueId>,
labels: Vec<LabelId>,
projects: Vec<ProjectId>,
}
type Shared = Arc<Mutex<Registry>>;
fn track(registry: &Shared, register: impl FnOnce(&mut Registry)) {
register(&mut registry.lock().expect("registry lock"));
}
fn already_gone(err: &Error) -> bool {
matches!(err, Error::Api { .. } | Error::MissingData { .. })
}
async fn issue_gone(client: &LinearClient, id: &IssueId) -> Result<bool, String> {
match client
.execute_raw(
"query($id: String!) { issue(id: $id) { id trashed archivedAt } }",
serde_json::json!({ "id": id.as_str() }),
)
.await
{
Ok(data) => Ok(data["issue"]["trashed"].as_bool() == Some(true)
|| !data["issue"]["archivedAt"].is_null()),
Err(err) if already_gone(&err) => Ok(true),
Err(err) => Err(format!("issue {id}: verify fetch failed: {err}")),
}
}
async fn project_gone(client: &LinearClient, id: &ProjectId) -> Result<bool, String> {
match client
.execute_raw(
"query($id: String!) { project(id: $id) { id trashed archivedAt } }",
serde_json::json!({ "id": id.as_str() }),
)
.await
{
Ok(data) => Ok(data["project"]["trashed"].as_bool() == Some(true)
|| !data["project"]["archivedAt"].is_null()),
Err(err) if already_gone(&err) => Ok(true),
Err(err) => Err(format!("project {id}: verify fetch failed: {err}")),
}
}
async fn cleanup(client: &LinearClient, registry: Registry) -> Vec<String> {
let mut failures = Vec::new();
for (relation, source) in ®istry.relations {
match client.relations().delete(relation).await {
Ok(()) => {}
Err(err) if already_gone(&err) => {}
Err(err) => {
failures.push(format!("relation {relation}: delete failed: {err}"));
continue;
}
}
match client.relations().of_issue(source).await {
Ok(edges) => {
let still_there = edges
.outgoing
.iter()
.chain(&edges.incoming)
.any(|edge| &edge.id == relation);
if still_there {
failures.push(format!("relation {relation}: still present after delete"));
} else {
eprintln!("[cleanup] relation {relation}: deleted, verified gone");
}
}
Err(err) => failures.push(format!("relation {relation}: verify fetch failed: {err}")),
}
}
for (comment, host) in ®istry.comments {
match client.comments().delete(comment).await {
Ok(()) => {}
Err(err) if already_gone(&err) => {}
Err(err) => {
failures.push(format!("comment {comment}: delete failed: {err}"));
continue;
}
}
let request = CommentListRequest::builder().first(50).build();
match client.comments().list_for_issue(host, request).await {
Ok(page) => {
if page.nodes.iter().any(|c| &c.id == comment) {
failures.push(format!("comment {comment}: still listed after delete"));
} else {
eprintln!("[cleanup] comment {comment}: deleted, verified gone");
}
}
Err(err) => failures.push(format!("comment {comment}: verify fetch failed: {err}")),
}
}
for (milestone, project) in ®istry.milestones {
match client.projects().delete_milestone(milestone).await {
Ok(()) => {}
Err(err) if already_gone(&err) => {}
Err(err) => {
failures.push(format!("milestone {milestone}: delete failed: {err}"));
continue;
}
}
match client.projects().milestones(project).await {
Ok(milestones) => {
if milestones.iter().any(|m| &m.id == milestone) {
failures.push(format!("milestone {milestone}: still listed after delete"));
} else {
eprintln!("[cleanup] milestone {milestone}: deleted, verified gone");
}
}
Err(err) => failures.push(format!("milestone {milestone}: verify fetch failed: {err}")),
}
}
for issue in ®istry.issues {
match client.issues().delete(issue).await {
Ok(()) => {}
Err(err) if already_gone(&err) => {}
Err(err) => {
failures.push(format!("issue {issue}: delete failed: {err}"));
continue;
}
}
match issue_gone(client, issue).await {
Ok(true) => eprintln!("[cleanup] issue {issue}: trashed, verified gone"),
Ok(false) => failures.push(format!("issue {issue}: still live after delete")),
Err(message) => failures.push(message),
}
}
for label in ®istry.labels {
match client.labels().delete(label).await {
Ok(()) => {}
Err(err) if already_gone(&err) => {}
Err(err) => {
failures.push(format!("label {label}: delete failed: {err}"));
continue;
}
}
let request = ListLabelsRequest::builder()
.filter(
IssueLabelFilter::builder()
.id(IdComparator::builder().eq(label.to_string()).build())
.build(),
)
.build();
match client.labels().list(request).await {
Ok(page) => {
if page.nodes.is_empty() {
eprintln!("[cleanup] label {label}: deleted, verified gone");
} else {
failures.push(format!("label {label}: still listed after delete"));
}
}
Err(err) => failures.push(format!("label {label}: verify fetch failed: {err}")),
}
}
for project in ®istry.projects {
let deleted = client
.execute_raw(
"mutation($id: String!) { projectDelete(id: $id) { success } }",
serde_json::json!({ "id": project.as_str() }),
)
.await;
match deleted {
Ok(_) => {}
Err(err) if already_gone(&err) => {}
Err(err) => {
failures.push(format!("project {project}: projectDelete failed: {err}"));
continue;
}
}
match project_gone(client, project).await {
Ok(true) => eprintln!("[cleanup] project {project}: trashed, verified gone"),
Ok(false) => failures.push(format!("project {project}: still live after delete")),
Err(message) => failures.push(message),
}
}
failures
}
async fn finish(client: &LinearClient, registry: Shared, outcome: Result<(), Box<dyn Any + Send>>) {
let taken = std::mem::take(&mut *registry.lock().expect("registry lock"));
let failures = cleanup(client, taken).await;
if let Err(panic) = outcome {
if !failures.is_empty() {
eprintln!("cleanup failures after test panic: {failures:#?}");
}
std::panic::resume_unwind(panic);
}
assert!(failures.is_empty(), "cleanup failures: {failures:#?}");
}
fn team_issue_filter(team: &Team) -> IssueFilter {
IssueFilter::builder()
.team(
TeamFilter::builder()
.id(IdComparator::builder().eq(team.id.to_string()).build())
.build(),
)
.build()
}
#[tokio::test]
#[ignore = "live API test; requires LINEAR_API_KEY"]
async fn live_workspace_reads() {
let Some(client) = live_client() else { return };
let team = pick_test_team(&client).await;
let teams: Vec<Team> = client
.teams()
.list_stream(ListTeamsRequest::builder().first(10).build())
.try_collect()
.await
.expect("teams.list_stream should drain");
assert!(
teams.iter().any(|t| t.id == team.id),
"stream should include the test team"
);
eprintln!("[workspace] {} teams streamed", teams.len());
let by_id = client
.teams()
.get(&team.id)
.await
.expect("teams.get by UUID");
assert_eq!(by_id.id, team.id);
let by_key = client
.teams()
.get(&TeamId::new(team.key.clone()))
.await
.expect("teams.get by key");
assert_eq!(
by_key.id, team.id,
"team key should resolve to the same team"
);
let states = client
.workflow_states()
.list(
ListWorkflowStatesRequest::builder()
.filter(
WorkflowStateFilter::builder()
.team(
TeamFilter::builder()
.id(IdComparator::builder().eq(team.id.to_string()).build())
.build(),
)
.build(),
)
.first(50)
.build(),
)
.await
.expect("workflow_states.list");
assert!(!states.nodes.is_empty(), "team should have workflow states");
assert!(states.nodes.iter().all(|s| s.team.id == team.id));
eprintln!(
"[workspace] {} workflow states on {}",
states.nodes.len(),
team.key
);
let page1 = client
.users()
.list(ListUsersRequest::builder().first(1).build())
.await
.expect("users.list");
assert_eq!(page1.nodes.len(), 1);
let first_user = &page1.nodes[0];
let fetched = client.users().get(&first_user.id).await.expect("users.get");
assert_eq!(fetched.id, first_user.id);
assert!(!fetched.email.is_empty(), "user should carry an email");
if page1.page_info.has_next_page {
let cursor = page1
.page_info
.end_cursor
.clone()
.expect("page with next page should carry an end cursor");
let two = client
.users()
.list(ListUsersRequest::builder().first(2).build())
.await
.expect("users.list first(2)");
let seeded: Vec<_> = client
.users()
.list_stream(ListUsersRequest::builder().first(1).after(cursor).build())
.take(1)
.try_collect()
.await
.expect("users.list_stream seeded");
assert_eq!(seeded.len(), 1);
assert_eq!(
seeded[0].id, two.nodes[1].id,
"seeded stream should resume exactly after the cursor"
);
assert_ne!(seeded[0].id, first_user.id);
} else {
let all: Vec<_> = client
.users()
.list_stream(ListUsersRequest::builder().first(1).build())
.try_collect()
.await
.expect("users.list_stream");
assert_eq!(all.len(), 1, "single-user workspace");
}
}
#[tokio::test]
#[ignore = "live mutation test; requires LINEAR_API_KEY"]
async fn live_label_lifecycle() {
let Some(client) = live_client() else { return };
let team = pick_test_team(&client).await;
let registry: Shared = Arc::default();
let body = {
let client = client.clone();
let registry = registry.clone();
let team = team.clone();
async move {
let marker = format!("[sdk-smoke] {}", stamp());
let name = format!("{marker} label");
let label = client
.labels()
.create(
LabelCreateInput::builder()
.name(name.clone())
.color("#5E6AD2")
.description(MARKER_DESCRIPTION)
.team_id(team.id.clone())
.build(),
)
.await
.expect("labels.create");
track(®istry, |r| r.labels.push(label.id.clone()));
eprintln!("[created] label {} ({})", label.id, label.name);
assert_eq!(label.name, name);
assert_eq!(label.description.as_deref(), Some(MARKER_DESCRIPTION));
assert_eq!(
label.team.as_ref().map(|t| &t.id),
Some(&team.id),
"label should be scoped to the test team"
);
let listed: Vec<_> = client
.labels()
.list_stream(
ListLabelsRequest::builder()
.filter(
IssueLabelFilter::builder()
.name(StringComparator::builder().eq(name.clone()).build())
.build(),
)
.first(10)
.build(),
)
.try_collect()
.await
.expect("labels.list_stream");
assert_eq!(listed.len(), 1, "exactly the created label should match");
assert_eq!(listed[0].id, label.id);
let updated = client
.labels()
.update(
&label.id,
LabelUpdateInput::builder()
.color("#26B5CE")
.description(format!("updated — {MARKER_DESCRIPTION}"))
.build(),
)
.await
.expect("labels.update set");
assert_eq!(updated.color.to_uppercase(), "#26B5CE");
assert_eq!(
updated.description.as_deref(),
Some(format!("updated — {MARKER_DESCRIPTION}").as_str())
);
let renamed = format!("{marker} label (renamed)");
let cleared = client
.labels()
.update(
&label.id,
LabelUpdateInput::builder()
.name(renamed.clone())
.description(Undefinable::Null)
.build(),
)
.await
.expect("labels.update clear");
assert_eq!(cleared.name, renamed);
assert!(
cleared.description.as_deref().unwrap_or("").is_empty(),
"Undefinable::Null should clear the description, got {:?}",
cleared.description
);
assert_eq!(
cleared.color.to_uppercase(),
"#26B5CE",
"omitted (Undefined) fields should stay unchanged"
);
let ensure_name = format!("{marker} ensure");
let ensured = client
.labels()
.ensure(Some(&team.id), &ensure_name)
.await
.expect("labels.ensure (create path)");
track(®istry, |r| r.labels.push(ensured.id.clone()));
eprintln!("[created] label {} ({})", ensured.id, ensured.name);
assert_eq!(ensured.name, ensure_name);
let again = client
.labels()
.ensure(Some(&team.id), &ensure_name)
.await
.expect("labels.ensure (find path)");
assert_eq!(
again.id, ensured.id,
"ensure should find the existing label, not duplicate it"
);
client
.labels()
.delete(&label.id)
.await
.expect("labels.delete");
let after: Vec<_> = client
.labels()
.list_stream(
ListLabelsRequest::builder()
.filter(
IssueLabelFilter::builder()
.id(IdComparator::builder().eq(label.id.to_string()).build())
.build(),
)
.build(),
)
.try_collect()
.await
.expect("labels.list_stream after delete");
assert!(after.is_empty(), "deleted label should not be listed");
}
};
let outcome = AssertUnwindSafe(Box::pin(body)).catch_unwind().await;
finish(&client, registry, outcome).await;
}
#[tokio::test]
#[ignore = "live mutation test; requires LINEAR_API_KEY"]
async fn live_issue_lifecycle() {
let Some(client) = live_client() else { return };
let team = pick_test_team(&client).await;
let registry: Shared = Arc::default();
let body = {
let client = client.clone();
let registry = registry.clone();
let team = team.clone();
async move {
let run_stamp = stamp();
let marker = format!("[sdk-smoke] {run_stamp}");
let alpha = Box::pin(issue_crud_phase(&client, ®istry, &team, &marker)).await;
let beta = Box::pin(issue_batch_stream_phase(
&client, ®istry, &team, &marker, &alpha,
))
.await;
Box::pin(issue_label_phase(
&client, ®istry, &team, &marker, &alpha,
))
.await;
Box::pin(issue_search_phase(&client, run_stamp, &alpha)).await;
Box::pin(issue_archive_delete_phase(&client, &beta)).await;
}
};
let outcome = AssertUnwindSafe(Box::pin(body)).catch_unwind().await;
finish(&client, registry, outcome).await;
}
async fn issue_crud_phase(
client: &LinearClient,
registry: &Shared,
team: &Team,
marker: &str,
) -> Issue {
let alpha = client
.issues()
.create(
IssueCreateInput::builder()
.team_id(team.id.clone())
.title(format!("{marker} issue alpha"))
.description(MARKER_DESCRIPTION)
.priority(Priority::Low)
.build(),
)
.await
.expect("issues.create");
track(registry, |r| r.issues.push(alpha.id.clone()));
eprintln!("[created] issue {} ({})", alpha.id, alpha.identifier);
assert_eq!(alpha.title, format!("{marker} issue alpha"));
assert_eq!(alpha.team.id, team.id);
assert_eq!(alpha.priority, Priority::Low);
assert_eq!(alpha.description.as_deref(), Some(MARKER_DESCRIPTION));
let by_id = client
.issues()
.get(&alpha.id)
.await
.expect("issues.get by UUID");
assert_eq!(by_id.id, alpha.id);
let by_identifier = client
.issues()
.get(IssueRef::identifier(alpha.identifier.clone()))
.await
.expect("issues.get by identifier");
assert_eq!(
by_identifier.id, alpha.id,
"identifier should resolve to the same issue"
);
let renamed = format!("{marker} issue alpha (renamed)");
let due: TimelessDate = "2030-01-02".parse().expect("valid date");
let updated = client
.issues()
.update(
&alpha.id,
IssueUpdateInput::builder()
.title(renamed.clone())
.description(format!("updated — {MARKER_DESCRIPTION}"))
.due_date(due)
.build(),
)
.await
.expect("issues.update set");
assert_eq!(updated.title, renamed);
assert_eq!(updated.due_date, Some(due), "due date should be set");
assert_eq!(
updated.description.as_deref(),
Some(format!("updated — {MARKER_DESCRIPTION}").as_str())
);
let cleared = client
.issues()
.update(
&alpha.id,
IssueUpdateInput::builder()
.description(Undefinable::Null)
.due_date(Undefinable::Null)
.build(),
)
.await
.expect("issues.update clear");
assert_eq!(
cleared.title, renamed,
"omitted (Undefined) fields should stay unchanged"
);
assert!(
cleared.due_date.is_none(),
"Undefinable::Null should clear dueDate"
);
assert_eq!(
cleared.description.as_deref(),
Some(format!("updated — {MARKER_DESCRIPTION}").as_str()),
"Linear ignores `description: null` (document-backed field)"
);
let desc_cleared = client
.issues()
.update(
&alpha.id,
IssueUpdateInput::builder()
.description(String::new())
.build(),
)
.await
.expect("issues.update clear description via empty string");
assert!(
desc_cleared.description.as_deref().unwrap_or("").is_empty(),
"an empty string should clear the description, got {:?}",
desc_cleared.description
);
alpha
}
async fn issue_batch_stream_phase(
client: &LinearClient,
registry: &Shared,
team: &Team,
marker: &str,
alpha: &Issue,
) -> IssueId {
let batch = client
.issues()
.batch_create(vec![
IssueCreateInput::builder()
.team_id(team.id.clone())
.title(format!("{marker} issue beta"))
.description(MARKER_DESCRIPTION)
.build(),
IssueCreateInput::builder()
.team_id(team.id.clone())
.title(format!("{marker} issue gamma"))
.description(MARKER_DESCRIPTION)
.build(),
])
.await
.expect("issues.batch_create");
assert_eq!(batch.len(), 2);
for issue in &batch {
track(registry, |r| r.issues.push(issue.id.clone()));
eprintln!("[created] issue {} ({})", issue.id, issue.identifier);
assert_eq!(issue.team.id, team.id);
}
assert_eq!(
batch[0].title,
format!("{marker} issue beta"),
"batch_create should preserve input order"
);
assert_eq!(batch[1].title, format!("{marker} issue gamma"));
let beta = batch[0].id.clone();
let gamma = batch[1].id.clone();
let filter = team_issue_filter(team);
let all: Vec<_> = client
.issues()
.list_stream(
ListIssuesRequest::builder()
.filter(filter.clone())
.first(1)
.build(),
)
.try_collect()
.await
.expect("issues.list_stream");
assert!(all.len() >= 3, "the three created issues should be live");
for wanted in [&alpha.id, &beta, &gamma] {
assert!(
all.iter().any(|i| &i.id == wanted),
"stream should contain created issue {wanted}"
);
}
let page1 = client
.issues()
.list(
ListIssuesRequest::builder()
.filter(filter.clone())
.first(1)
.build(),
)
.await
.expect("issues.list page 1");
assert_eq!(page1.nodes.len(), 1);
assert!(page1.page_info.has_next_page);
assert_eq!(all[0].id, page1.nodes[0].id);
let cursor = page1.page_info.end_cursor.clone().expect("end cursor");
let seeded: Vec<_> = client
.issues()
.list_stream(
ListIssuesRequest::builder()
.filter(filter)
.first(1)
.after(cursor)
.build(),
)
.try_collect()
.await
.expect("issues.list_stream seeded");
let seeded_ids: Vec<_> = seeded.iter().map(|i| i.id.clone()).collect();
let tail_ids: Vec<_> = all[1..].iter().map(|i| i.id.clone()).collect();
assert_eq!(
seeded_ids, tail_ids,
"seeded stream should resume exactly after page 1"
);
beta
}
async fn issue_label_phase(
client: &LinearClient,
registry: &Shared,
team: &Team,
marker: &str,
alpha: &Issue,
) {
let label = client
.labels()
.create(
LabelCreateInput::builder()
.name(format!("{marker} issue-label"))
.color("#5E6AD2")
.description(MARKER_DESCRIPTION)
.team_id(team.id.clone())
.build(),
)
.await
.expect("labels.create (for add_label)");
track(registry, |r| r.labels.push(label.id.clone()));
eprintln!("[created] label {} ({})", label.id, label.name);
let with_label = client
.issues()
.add_label(&alpha.id, &label.id)
.await
.expect("issues.add_label");
assert!(
with_label.labels.iter().any(|l| l.id == label.id),
"issue should carry the added label"
);
let without_label = client
.issues()
.remove_label(&alpha.id, &label.id)
.await
.expect("issues.remove_label");
assert!(
without_label.labels.iter().all(|l| l.id != label.id),
"issue should no longer carry the removed label"
);
let refetched = client.issues().get(&alpha.id).await.expect("issues.get");
assert!(refetched.labels.iter().all(|l| l.id != label.id));
}
async fn issue_search_phase(client: &LinearClient, run_stamp: u64, alpha: &Issue) {
let term = format!("sdk-smoke {run_stamp}");
let mut found = false;
for attempt in 1..=4u32 {
let hits = client
.issues()
.search(
SearchIssuesRequest::builder()
.term(term.clone())
.first(10)
.build(),
)
.await
.expect("issues.search");
eprintln!("[search] attempt {attempt}: {} hit(s)", hits.nodes.len());
if hits.nodes.iter().any(|hit| hit.id == alpha.id) {
found = true;
break;
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
if !found {
eprintln!("[search] created issue not indexed yet — searchIssues call itself verified");
}
}
async fn issue_archive_delete_phase(client: &LinearClient, beta: &IssueId) {
client.issues().archive(beta).await.expect("issues.archive");
let archived = client
.issues()
.get(beta)
.await
.expect("issues.get on archived issue");
assert!(
archived.archived_at.is_some(),
"archive should set archivedAt"
);
client.issues().delete(beta).await.expect("issues.delete");
assert_eq!(
issue_gone(client, beta).await,
Ok(true),
"deleted issue should be trashed"
);
}
#[tokio::test]
#[ignore = "live mutation test; requires LINEAR_API_KEY"]
async fn live_relation_lifecycle() {
let Some(client) = live_client() else { return };
let team = pick_test_team(&client).await;
let registry: Shared = Arc::default();
let body = {
let client = client.clone();
let registry = registry.clone();
let team = team.clone();
async move {
let marker = format!("[sdk-smoke] {}", stamp());
let pair = client
.issues()
.batch_create(vec![
IssueCreateInput::builder()
.team_id(team.id.clone())
.title(format!("{marker} relation source"))
.description(MARKER_DESCRIPTION)
.build(),
IssueCreateInput::builder()
.team_id(team.id.clone())
.title(format!("{marker} relation target"))
.description(MARKER_DESCRIPTION)
.build(),
])
.await
.expect("issues.batch_create (relation hosts)");
let source = pair[0].clone();
let target = pair[1].clone();
for issue in &pair {
track(®istry, |r| r.issues.push(issue.id.clone()));
eprintln!("[created] issue {} ({})", issue.id, issue.identifier);
}
let related = client
.relations()
.create(
IssueRef::identifier(source.identifier.clone()),
IssueRef::identifier(target.identifier.clone()),
IssueRelationType::Related,
)
.await
.expect("relations.create");
track(®istry, |r| {
r.relations.push((related.id.clone(), source.id.clone()));
});
eprintln!("[created] relation {} (related)", related.id);
assert_eq!(related.relation_type, IssueRelationType::Related);
assert_eq!(related.issue.id, source.id);
assert_eq!(related.related_issue.id, target.id);
let edges = client
.relations()
.of_issue(&source.id)
.await
.expect("relations.of_issue");
assert!(edges.outgoing.iter().any(|e| e.id == related.id));
client
.relations()
.delete(&related.id)
.await
.expect("relations.delete");
let edges = client
.relations()
.of_issue(&source.id)
.await
.expect("relations.of_issue after delete");
assert!(
edges.outgoing.iter().all(|e| e.id != related.id),
"deleted relation should disappear"
);
let blocks = client
.relations()
.create_blocks(&source.id, &target.id)
.await
.expect("relations.create_blocks");
track(®istry, |r| {
r.relations.push((blocks.id.clone(), source.id.clone()));
});
eprintln!("[created] relation {} (blocks)", blocks.id);
assert_eq!(blocks.relation_type, IssueRelationType::Blocks);
assert_eq!(blocks.issue.id, source.id);
assert_eq!(blocks.related_issue.id, target.id);
let source_edges = client
.relations()
.of_issue(&source.id)
.await
.expect("relations.of_issue (blocker)");
assert!(
source_edges.blocks().iter().any(|s| s.id == target.id),
"blocker's blocks() should contain the blocked issue"
);
let target_edges = client
.relations()
.of_issue(&target.id)
.await
.expect("relations.of_issue (blocked)");
assert!(
target_edges.blocked_by().iter().any(|s| s.id == source.id),
"blocked issue's blocked_by() should contain the blocker"
);
client
.relations()
.delete(&blocks.id)
.await
.expect("relations.delete (blocks)");
let source_edges = client
.relations()
.of_issue(&source.id)
.await
.expect("relations.of_issue after blocks delete");
assert!(source_edges.blocks().is_empty());
let target_edges = client
.relations()
.of_issue(&target.id)
.await
.expect("relations.of_issue after blocks delete (target)");
assert!(target_edges.blocked_by().is_empty());
}
};
let outcome = AssertUnwindSafe(Box::pin(body)).catch_unwind().await;
finish(&client, registry, outcome).await;
}
#[tokio::test]
#[ignore = "live mutation test; requires LINEAR_API_KEY"]
async fn live_comment_lifecycle() {
let Some(client) = live_client() else { return };
let team = pick_test_team(&client).await;
let registry: Shared = Arc::default();
let body = {
let client = client.clone();
let registry = registry.clone();
let team = team.clone();
async move {
let marker = format!("[sdk-smoke] {}", stamp());
let host = client
.issues()
.create(
IssueCreateInput::builder()
.team_id(team.id.clone())
.title(format!("{marker} comment host"))
.description(MARKER_DESCRIPTION)
.build(),
)
.await
.expect("issues.create (comment host)");
track(®istry, |r| r.issues.push(host.id.clone()));
eprintln!("[created] issue {} ({})", host.id, host.identifier);
let root = client
.comments()
.create(
CommentCreateInput::builder()
.issue_id(host.id.to_string())
.body(format!("{marker} root comment — {MARKER_DESCRIPTION}"))
.build(),
)
.await
.expect("comments.create");
track(®istry, |r| {
r.comments.push((root.id.clone(), host.id.clone()));
});
eprintln!("[created] comment {} (root)", root.id);
assert!(root.parent.is_none());
assert!(root.body.contains("root comment"));
let second = client
.comments()
.create_on(
&host.id,
format!("{marker} second comment — {MARKER_DESCRIPTION}"),
)
.await
.expect("comments.create_on");
track(®istry, |r| {
r.comments.push((second.id.clone(), host.id.clone()));
});
eprintln!("[created] comment {} (create_on)", second.id);
let reply = client
.comments()
.create(
CommentCreateInput::builder()
.issue_id(host.id.to_string())
.body(format!("{marker} threaded reply — {MARKER_DESCRIPTION}"))
.parent_id(root.id.clone())
.build(),
)
.await
.expect("comments.create (threaded)");
track(®istry, |r| {
r.comments.push((reply.id.clone(), host.id.clone()));
});
eprintln!("[created] comment {} (reply to {})", reply.id, root.id);
assert_eq!(
reply.parent.as_ref().map(|p| &p.id),
Some(&root.id),
"threaded reply should carry its parent id"
);
let page = client
.comments()
.list_for_issue(&host.id, CommentListRequest::builder().first(50).build())
.await
.expect("comments.list_for_issue");
for wanted in [&root.id, &second.id, &reply.id] {
assert!(
page.nodes.iter().any(|c| &c.id == wanted),
"thread should contain comment {wanted}"
);
}
let listed_reply = page
.nodes
.iter()
.find(|c| c.id == reply.id)
.expect("reply should be listed");
assert_eq!(listed_reply.parent.as_ref().map(|p| &p.id), Some(&root.id));
let new_body = format!("{marker} threaded reply (edited) — {MARKER_DESCRIPTION}");
let edited = client
.comments()
.update(&reply.id, new_body.clone())
.await
.expect("comments.update");
assert_eq!(edited.body, new_body);
let streamed: Vec<_> = client
.comments()
.list_for_issue_stream(&host.id, CommentListRequest::builder().first(1).build())
.try_collect()
.await
.expect("comments.list_for_issue_stream");
assert!(streamed.len() >= 3);
let streamed_reply = streamed
.iter()
.find(|c| c.id == reply.id)
.expect("stream should contain the reply");
assert_eq!(streamed_reply.body, new_body, "update should persist");
client
.comments()
.delete(&reply.id)
.await
.expect("comments.delete");
let after = client
.comments()
.list_for_issue(&host.id, CommentListRequest::builder().first(50).build())
.await
.expect("comments.list_for_issue after delete");
assert!(
after.nodes.iter().all(|c| c.id != reply.id),
"deleted comment should leave the thread"
);
}
};
let outcome = AssertUnwindSafe(Box::pin(body)).catch_unwind().await;
finish(&client, registry, outcome).await;
}
#[tokio::test]
#[ignore = "live mutation test; requires LINEAR_API_KEY"]
async fn live_project_lifecycle() {
let Some(client) = live_client() else { return };
let team = pick_test_team(&client).await;
let registry: Shared = Arc::default();
let body = {
let client = client.clone();
let registry = registry.clone();
let team = team.clone();
async move {
let marker = format!("[sdk-smoke] {}", stamp());
let project = Box::pin(project_create_phase(&client, ®istry, &team, &marker)).await;
Box::pin(project_update_phase(&client, &project, &marker)).await;
Box::pin(project_milestone_phase(
&client, ®istry, &project, &marker,
))
.await;
Box::pin(project_archive_phase(&client, &project)).await;
}
};
let outcome = AssertUnwindSafe(Box::pin(body)).catch_unwind().await;
finish(&client, registry, outcome).await;
}
async fn project_create_phase(
client: &LinearClient,
registry: &Shared,
team: &Team,
marker: &str,
) -> ProjectId {
let statuses = client
.projects()
.statuses()
.await
.expect("projects.statuses");
assert!(
!statuses.is_empty(),
"workspace should define project statuses"
);
eprintln!("[projects] {} workspace project statuses", statuses.len());
let name = format!("{marker} project");
let project = client
.projects()
.create(
ProjectCreateInput::builder()
.name(name.clone())
.team_ids(vec![team.id.clone()])
.description(MARKER_DESCRIPTION.to_owned())
.build(),
)
.await
.expect("projects.create");
track(registry, |r| r.projects.push(project.id.clone()));
eprintln!("[created] project {} ({})", project.id, project.name);
assert_eq!(project.name, name);
assert!(project.teams.iter().any(|t| t.id == team.id));
assert_eq!(project.description, MARKER_DESCRIPTION);
let fetched = client
.projects()
.get(&project.id)
.await
.expect("projects.get");
assert_eq!(fetched.id, project.id);
assert_eq!(fetched.name, name);
let listed: Vec<_> = client
.projects()
.list_stream(
ListProjectsRequest::builder()
.filter(
ProjectFilter::builder()
.name(StringComparator::builder().eq(name.clone()).build())
.build(),
)
.first(1)
.build(),
)
.try_collect()
.await
.expect("projects.list_stream");
assert!(
listed.iter().any(|p| p.id == project.id),
"stream should find the created project by name"
);
project.id
}
async fn project_update_phase(client: &LinearClient, project: &ProjectId, marker: &str) {
let renamed = format!("{marker} project (renamed)");
let target: TimelessDate = "2030-06-30".parse().expect("valid date");
let updated = client
.projects()
.update(
project,
ProjectUpdateInput::builder()
.name(renamed.clone())
.description(format!("updated — {MARKER_DESCRIPTION}"))
.content(format!("Long-form content. {MARKER_DESCRIPTION}"))
.target_date(target)
.build(),
)
.await
.expect("projects.update set");
assert_eq!(updated.name, renamed);
assert_eq!(updated.target_date, Some(target));
assert!(
updated
.content
.as_deref()
.unwrap_or("")
.contains("Long-form"),
"content should be set"
);
let cleared = client
.projects()
.update(
project,
ProjectUpdateInput::builder()
.target_date(Undefinable::Null)
.content(Undefinable::Null)
.build(),
)
.await
.expect("projects.update clear");
assert_eq!(
cleared.name, renamed,
"omitted fields should stay unchanged"
);
assert!(
cleared.target_date.is_none(),
"Null should clear targetDate"
);
assert!(
cleared
.content
.as_deref()
.unwrap_or("")
.contains("Long-form"),
"Linear ignores `content: null` (document-backed field), got {:?}",
cleared.content
);
}
async fn project_milestone_phase(
client: &LinearClient,
registry: &Shared,
project: &ProjectId,
marker: &str,
) {
let milestone_target: TimelessDate = "2030-03-31".parse().expect("valid date");
let milestone = client
.projects()
.create_milestone(
ProjectMilestoneCreateInput::builder()
.project_id(project.clone())
.name(format!("{marker} milestone"))
.description(MARKER_DESCRIPTION.to_owned())
.target_date(milestone_target)
.build(),
)
.await
.expect("projects.create_milestone");
track(registry, |r| {
r.milestones.push((milestone.id.clone(), project.clone()));
});
eprintln!("[created] milestone {} ({})", milestone.id, milestone.name);
assert_eq!(milestone.target_date, Some(milestone_target));
let milestones = client
.projects()
.milestones(project)
.await
.expect("projects.milestones");
assert!(
milestones
.iter()
.any(|m| m.id == milestone.id && m.target_date == Some(milestone_target)),
"milestones should list the created milestone"
);
let milestone_renamed = format!("{marker} milestone (renamed)");
let ms_updated = client
.projects()
.update_milestone(
&milestone.id,
ProjectMilestoneUpdateInput::builder()
.name(milestone_renamed.clone())
.description(format!("updated — {MARKER_DESCRIPTION}"))
.build(),
)
.await
.expect("projects.update_milestone set");
assert_eq!(ms_updated.name, milestone_renamed);
let ms_cleared = client
.projects()
.update_milestone(
&milestone.id,
ProjectMilestoneUpdateInput::builder()
.description(Undefinable::Null)
.target_date(Undefinable::Null)
.build(),
)
.await
.expect("projects.update_milestone clear");
assert_eq!(ms_cleared.name, milestone_renamed);
assert_eq!(
ms_cleared.description.as_deref(),
Some(format!("updated — {MARKER_DESCRIPTION}").as_str()),
"Linear ignores milestone `description: null` (document-backed field)"
);
assert!(
ms_cleared.target_date.is_none(),
"Null should clear targetDate"
);
client
.projects()
.delete_milestone(&milestone.id)
.await
.expect("projects.delete_milestone");
let remaining = client
.projects()
.milestones(project)
.await
.expect("projects.milestones after delete");
assert!(
remaining.iter().all(|m| m.id != milestone.id),
"deleted milestone should disappear"
);
}
async fn project_archive_phase(client: &LinearClient, project: &ProjectId) {
client
.projects()
.archive(project)
.await
.expect("projects.archive");
let archived = client
.projects()
.get(project)
.await
.expect("projects.get on archived project");
assert!(
archived.archived_at.is_some(),
"archive should set archivedAt"
);
}