Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,8 +449,9 @@ Config is stored at `~/.config/linear-cli/config.toml` (Linux/macOS) or `%APPDAT

```bash
linear-cli config show # Show current config
linear-cli config get default_team # Get a value
linear-cli config set default_team ENG # Set a value
linear-cli config get default-team # Get default team
linear-cli config set default-team ENG # Set default team
# also accepted: default_team, team

# Multiple workspaces
linear-cli config workspace-add work # Add workspace profile
Expand Down
26 changes: 26 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,32 @@ pub async fn resolve_team_id(
resolve_id(client, team, cache_opts, &config, find_team_id).await
}

/// Fetch every team the viewer can see (cursor-paginated).
pub async fn fetch_all_teams(client: &LinearClient) -> Result<Vec<Value>> {
let query = r#"
query($first: Int, $after: String) {
teams(first: $first, after: $after) {
nodes { id name key }
pageInfo { hasNextPage endCursor }
}
}
"#;
paginate_nodes(
client,
query,
serde_json::Map::new(),
&["data", "teams", "nodes"],
&["data", "teams", "pageInfo"],
&PaginationOptions {
all: true,
page_size: Some(100),
..Default::default()
},
100,
)
.await
}

/// Resolve a user identifier to a UUID.
/// Handles "me", UUIDs, names, and emails.
pub async fn resolve_user_id(
Expand Down
27 changes: 18 additions & 9 deletions src/commands/cycles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ pub enum CycleCommands {
/// List cycles for a team
#[command(alias = "ls")]
List {
/// Team ID or name
/// Team ID or name (falls back to default-team / LINEAR_CLI_TEAM)
#[arg(short, long)]
team: String,
team: Option<String>,
/// Include completed cycles
#[arg(short, long)]
all: bool,
Expand All @@ -33,15 +33,15 @@ pub enum CycleCommands {
},
/// Show the current active cycle
Current {
/// Team ID or name
/// Team ID or name (falls back to default-team / LINEAR_CLI_TEAM)
#[arg(short, long)]
team: String,
team: Option<String>,
},
/// Create a new cycle
Create {
/// Team key, name, or ID
/// Team key, name, or ID (falls back to default-team / LINEAR_CLI_TEAM)
#[arg(short, long)]
team: String,
team: Option<String>,
/// Cycle name
#[arg(short, long)]
name: Option<String>,
Expand Down Expand Up @@ -110,16 +110,25 @@ struct CycleRow {

pub async fn handle(cmd: CycleCommands, output: &OutputOptions) -> Result<()> {
match cmd {
CycleCommands::List { team, all } => list_cycles(&team, all, output).await,
CycleCommands::List { team, all } => {
let team = crate::config::require_team(team)?;
list_cycles(&team, all, output).await
}
CycleCommands::Get { id } => get_cycle(&id, output).await,
CycleCommands::Current { team } => current_cycle(&team, output).await,
CycleCommands::Current { team } => {
let team = crate::config::require_team(team)?;
current_cycle(&team, output).await
}
CycleCommands::Create {
team,
name,
description,
starts_at,
ends_at,
} => create_cycle(&team, name, description, starts_at, ends_at, output).await,
} => {
let team = crate::config::require_team(team)?;
create_cycle(&team, name, description, starts_at, ends_at, output).await
}
CycleCommands::Update {
id,
name,
Expand Down
18 changes: 1 addition & 17 deletions src/commands/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,23 +149,7 @@ pub async fn run(default_team: Option<String>) -> Result<()> {
}

async fn fetch_teams(client: &LinearClient) -> Result<Vec<Team>> {
let query = r#"
query {
teams(first: 100) {
nodes {
id
name
key
}
}
}
"#;

let result = client.query(query, None).await?;
let empty = vec![];
let teams_json = result["data"]["teams"]["nodes"]
.as_array()
.unwrap_or(&empty);
let teams_json = crate::api::fetch_all_teams(client).await?;

let teams: Vec<Team> = teams_json
.iter()
Expand Down
5 changes: 4 additions & 1 deletion src/commands/issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,11 @@ pub async fn handle(
.or(tpl.team.clone())
.or(data_team)
.or(data_team_id)
.or_else(|| crate::config::resolve_team_arg(None))
.ok_or_else(|| {
anyhow::anyhow!("--team is required (or use a template with a default team)")
anyhow::anyhow!(
"--team is required (or set a default: linear config set default-team TEAM, or use a template with a default team)"
)
})?;

// Build title with optional prefix from template
Expand Down
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub mod projects;
pub mod relations;
pub mod roadmaps;
pub mod search;
pub mod setup;
pub mod sprint;
pub mod statuses;
pub mod sync;
Expand Down
75 changes: 65 additions & 10 deletions src/commands/relations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,46 @@ pub enum RelationType {
}

impl RelationType {
/// Linear's `IssueRelationType` only has `blocks`, `related`, and `duplicate`.
/// There is no `blockedBy` — "A is blocked by B" is stored as "B blocks A".
fn to_api_string(self) -> &'static str {
match self {
RelationType::Blocks | RelationType::BlockedBy => "blocks",
RelationType::Related => "related",
RelationType::Duplicate => "duplicate",
}
}

/// Whether this CLI relation is the inverse of the Linear API direction.
fn is_inverted(self) -> bool {
matches!(self, RelationType::BlockedBy)
}

fn display_name(self) -> &'static str {
match self {
RelationType::Blocks => "blocks",
RelationType::BlockedBy => "blockedBy",
RelationType::BlockedBy => "blocked-by",
RelationType::Related => "related",
RelationType::Duplicate => "duplicate",
}
}
}

/// Resolve issue endpoints and API type for `issueRelationCreate`.
///
/// `blocked-by` is sugar for inverted `blocks`: `A blocked-by B` → `B blocks A`.
fn resolve_relation_endpoints<'a>(
from: &'a str,
relation: RelationType,
to: &'a str,
) -> (&'a str, &'a str, &'static str) {
if relation.is_inverted() {
(to, from, "blocks")
} else {
(from, to, relation.to_api_string())
}
}

#[derive(Subcommand, Debug)]
pub enum RelationCommands {
/// List issue relationships
Expand Down Expand Up @@ -294,6 +324,7 @@ async fn add_relation(
output: &OutputOptions,
) -> Result<()> {
let client = LinearClient::new()?;
let (issue_id, related_issue_id, api_type) = resolve_relation_endpoints(from, relation, to);

let mutation = r#"
mutation($issueId: String!, $relatedIssueId: String!, $type: IssueRelationType!) {
Expand All @@ -317,22 +348,22 @@ async fn add_relation(
.mutate(
mutation,
Some(json!({
"issueId": from,
"relatedIssueId": to,
"type": relation.to_api_string()
"issueId": issue_id,
"relatedIssueId": related_issue_id,
"type": api_type
})),
)
.await?;

if output.is_json() {
print_json(&result["data"]["issueRelationCreate"], output)?;
} else {
let rel = &result["data"]["issueRelationCreate"]["issueRelation"];
// Keep the user's argument order; blocked-by is sugar over inverted blocks.
println!(
"Created relation: {} {} {}",
rel["issue"]["identifier"].as_str().unwrap_or(from),
relation.to_api_string(),
rel["relatedIssue"]["identifier"].as_str().unwrap_or(to)
from,
relation.display_name(),
to
);
}

Expand Down Expand Up @@ -433,11 +464,16 @@ mod tests {
#[test]
fn test_relation_type_blocks() {
assert_eq!(RelationType::Blocks.to_api_string(), "blocks");
assert!(!RelationType::Blocks.is_inverted());
assert_eq!(RelationType::Blocks.display_name(), "blocks");
}

#[test]
fn test_relation_type_blocked_by() {
assert_eq!(RelationType::BlockedBy.to_api_string(), "blockedBy");
fn test_relation_type_blocked_by_maps_to_blocks() {
// Linear has no blockedBy enum; CLI maps it to inverted blocks.
assert_eq!(RelationType::BlockedBy.to_api_string(), "blocks");
assert!(RelationType::BlockedBy.is_inverted());
assert_eq!(RelationType::BlockedBy.display_name(), "blocked-by");
}

#[test]
Expand All @@ -450,6 +486,25 @@ mod tests {
assert_eq!(RelationType::Duplicate.to_api_string(), "duplicate");
}

#[test]
fn test_resolve_relation_endpoints_blocks() {
let (issue_id, related_id, api_type) =
resolve_relation_endpoints("A", RelationType::Blocks, "B");
assert_eq!(issue_id, "A");
assert_eq!(related_id, "B");
assert_eq!(api_type, "blocks");
}

#[test]
fn test_resolve_relation_endpoints_blocked_by_swaps() {
// A blocked-by B == B blocks A
let (issue_id, related_id, api_type) =
resolve_relation_endpoints("A", RelationType::BlockedBy, "B");
assert_eq!(issue_id, "B");
assert_eq!(related_id, "A");
assert_eq!(api_type, "blocks");
}

#[test]
fn test_relation_node_deserializes_with_state_id() {
use crate::types::IssueRelation;
Expand Down
Loading
Loading