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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,16 @@ That's it. Your agent discovers all commands at runtime by running `lineark usag

| Area | Commands |
|------|----------|
| **Issues** | `list`, `read`, `search`, `create`, `update`, `archive`, `unarchive`, `delete` |
| **Comments** | `create`, `delete` |
| **Issues** | `list`, `read`, `search`, `find-branch`, `create`, `update`, `batch-update`, `archive`, `unarchive`, `delete` |
| **Comments** | `create`, `update`, `resolve`, `unresolve`, `delete` |
| **Relations** | `create` (blocks, blocked-by, related, duplicate, similar), `delete` |
| **Labels** | `list`, `create`, `update`, `delete` (groups, parent labels, team-scoped) |
| **Projects** | `list`, `read`, `create` |
| **Milestones** | `list`, `read`, `create`, `update`, `delete` |
| **Cycles** | `list`, `read` |
| **Documents** | `list`, `read`, `create`, `update`, `delete` |
| **Teams** | `list`, `read`, `create`, `update`, `delete`, `members add`, `members remove` |
| **Users / Labels** | `list` |
| **Users** | `list` |
| **File embeds** | `upload`, `download` |

Every command supports `--help` for full details. Most flags accept human-readable names — `--team ENG`, `--assignee "Jane Doe"`, `--labels "Bug,P0"` — no UUIDs required.
Expand Down
12 changes: 10 additions & 2 deletions crates/lineark-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,21 @@ let payload = client.issue_create::<Issue>(IssueCreateInput {
|--------|-------------|
| `issue_create(input)` | Create an issue |
| `issue_update(input, id)` | Update an issue |
| `issue_batch_update(input, ids)` | Batch update multiple issues |
| `issue_archive(trash, id)` | Archive an issue |
| `issue_unarchive(id)` | Unarchive a previously archived issue |
| `issue_delete(permanently, id)` | Delete an issue |
| `issue_vcs_branch_search(branch)` | Find issue by Git branch name |
| `comment_create(input)` | Create a comment |
| `comment_update(input, id)` | Update a comment |
| `comment_resolve(input, id)` | Resolve a comment thread |
| `comment_unresolve(id)` | Unresolve a comment thread |
| `comment_delete(id)` | Delete a comment |
| `issue_label_create(input)` | Create an issue label |
| `issue_label_update(input, id)` | Update an issue label |
| `issue_label_delete(id)` | Delete an issue label |
| `issue_relation_create(override_created_at, input)` | Create an issue relation |
| `issue_relation_delete(id)` | Delete an issue relation |
| `document_create(input)` | Create a document |
| `document_update(input, id)` | Update a document |
| `document_delete(id)` | Delete a document |
Expand All @@ -183,8 +193,6 @@ let payload = client.issue_create::<Issue>(IssueCreateInput {
| `team_delete(id)` | Delete a team |
| `team_membership_create(input)` | Create a team membership |
| `team_membership_delete(also_leave_parent_teams, id)` | Delete a team membership |
| `issue_relation_create(override_created_at, input)` | Create an issue relation |
| `issue_relation_delete(id)` | Delete an issue relation |
| `file_upload(meta, public, size, type, name)` | Request a signed upload URL |
| `image_upload_from_url(url)` | Upload image from URL |

Expand Down
32 changes: 32 additions & 0 deletions crates/lineark-sdk/src/generated/client_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,38 @@ impl Client {
) -> Result<serde_json::Value, LinearError> {
crate::generated::mutations::issue_relation_delete(self, id).await
}
/// Creates a new label.
///
/// Full type: [`IssueLabel`](super::types::IssueLabel)
pub async fn issue_label_create<
T: serde::de::DeserializeOwned
+ crate::field_selection::GraphQLFields<FullType = super::types::IssueLabel>,
>(
&self,
replace_team_labels: Option<bool>,
input: IssueLabelCreateInput,
) -> Result<T, LinearError> {
crate::generated::mutations::issue_label_create::<T>(self, replace_team_labels, input).await
}
/// Updates a label.
///
/// Full type: [`IssueLabel`](super::types::IssueLabel)
pub async fn issue_label_update<
T: serde::de::DeserializeOwned
+ crate::field_selection::GraphQLFields<FullType = super::types::IssueLabel>,
>(
&self,
replace_team_labels: Option<bool>,
input: IssueLabelUpdateInput,
id: String,
) -> Result<T, LinearError> {
crate::generated::mutations::issue_label_update::<T>(self, replace_team_labels, input, id)
.await
}
/// Deletes an issue label.
pub async fn issue_label_delete(&self, id: String) -> Result<serde_json::Value, LinearError> {
crate::generated::mutations::issue_label_delete(self, id).await
}
/// Creates a new document.
///
/// Full type: [`Document`](super::types::Document)
Expand Down
58 changes: 58 additions & 0 deletions crates/lineark-sdk/src/generated/mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,64 @@ pub async fn issue_relation_delete(
.execute::<serde_json::Value>(&query, variables, "issueRelationDelete")
.await
}
/// Creates a new label.
///
/// Full type: [`IssueLabel`](super::types::IssueLabel)
pub async fn issue_label_create<
T: serde::de::DeserializeOwned
+ crate::field_selection::GraphQLFields<FullType = super::types::IssueLabel>,
>(
client: &Client,
replace_team_labels: Option<bool>,
input: IssueLabelCreateInput,
) -> Result<T, LinearError> {
let variables = serde_json::json!(
{ "replaceTeamLabels" : replace_team_labels, "input" : input }
);
let query = String::from(
"mutation IssueLabelCreate($replaceTeamLabels: Boolean, $input: IssueLabelCreateInput!) { issueLabelCreate(replaceTeamLabels: $replaceTeamLabels, input: $input) { success issueLabel { ",
) + &T::selection() + " } } }";
client
.execute_mutation::<T>(&query, variables, "issueLabelCreate", "issueLabel")
.await
}
/// Updates a label.
///
/// Full type: [`IssueLabel`](super::types::IssueLabel)
pub async fn issue_label_update<
T: serde::de::DeserializeOwned
+ crate::field_selection::GraphQLFields<FullType = super::types::IssueLabel>,
>(
client: &Client,
replace_team_labels: Option<bool>,
input: IssueLabelUpdateInput,
id: String,
) -> Result<T, LinearError> {
let variables = serde_json::json!(
{ "replaceTeamLabels" : replace_team_labels, "input" : input, "id" : id }
);
let query = String::from(
"mutation IssueLabelUpdate($replaceTeamLabels: Boolean, $input: IssueLabelUpdateInput!, $id: String!) { issueLabelUpdate(replaceTeamLabels: $replaceTeamLabels, input: $input, id: $id) { success issueLabel { ",
) + &T::selection() + " } } }";
client
.execute_mutation::<T>(&query, variables, "issueLabelUpdate", "issueLabel")
.await
}
/// Deletes an issue label.
pub async fn issue_label_delete(
client: &Client,
id: String,
) -> Result<serde_json::Value, LinearError> {
let variables = serde_json::json!({ "id" : id });
let response_parts: Vec<String> = vec!["success".to_string(), "entityId".to_string()];
let query =
String::from("mutation IssueLabelDelete($id: String!) { issueLabelDelete(id: $id) { ")
+ &response_parts.join(" ")
+ " } }";
client
.execute::<serde_json::Value>(&query, variables, "issueLabelDelete")
.await
}
/// Creates a new document.
///
/// Full type: [`Document`](super::types::Document)
Expand Down
66 changes: 66 additions & 0 deletions crates/lineark-sdk/tests/online.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,27 @@ impl Drop for DocumentGuard {
}
}

/// RAII guard — deletes an issue label on drop.
struct LabelGuard {
token: String,
id: String,
}

impl Drop for LabelGuard {
fn drop(&mut self) {
let token = self.token.clone();
let id = self.id.clone();
let _ = std::thread::spawn(move || {
tokio::runtime::Runtime::new().unwrap().block_on(async {
if let Ok(client) = Client::from_token(token) {
let _ = client.issue_label_delete(id).await;
}
});
})
.join();
}
}

/// Helper: create a fresh test team and return its ID + RAII guard.
async fn create_test_team(client: &Client) -> (String, TeamGuard) {
use lineark_sdk::generated::inputs::TeamCreateInput;
Expand Down Expand Up @@ -228,6 +249,51 @@ mod online {
}
}

#[test_with::runtime_ignore_if(no_online_test_token)]
async fn issue_label_create_update_and_delete() {
use lineark_sdk::generated::inputs::{IssueLabelCreateInput, IssueLabelUpdateInput};

let client = test_client();

// Create a workspace-level label with a unique name.
let unique = format!(
"[test] sdk-label {}",
&uuid::Uuid::new_v4().to_string()[..8]
);
let input = IssueLabelCreateInput {
name: Some(unique.clone()),
color: Some("#eb5757".to_string()),
..Default::default()
};
let label = client
.issue_label_create::<IssueLabel>(None, input)
.await
.unwrap();
let label_id = label.id.clone().unwrap();
let _label_guard = LabelGuard {
token: test_token(),
id: label_id.clone(),
};
assert!(!label_id.is_empty());
assert_eq!(label.name, Some(unique));
assert_eq!(label.color, Some("#eb5757".to_string()));

// Update the label's color.
let update_input = IssueLabelUpdateInput {
color: Some("#4ea7fc".to_string()),
..Default::default()
};
let updated = client
.issue_label_update::<IssueLabel>(None, update_input, label_id.clone())
.await
.unwrap();
assert!(updated.id.is_some());
assert_eq!(updated.color, Some("#4ea7fc".to_string()));

// Delete the label.
client.issue_label_delete(label_id).await.unwrap();
}

// ── Cycles ──────────────────────────────────────────────────────────────

#[test_with::runtime_ignore_if(no_online_test_token)]
Expand Down
27 changes: 23 additions & 4 deletions crates/lineark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,26 +55,45 @@ lineark projects create <NAME> --team KEY Create a project
[--members NAME,...|me] Project members (comma-separated)
[--start-date DATE] [--target-date DATE] Priority, content, icon, color
[-p 0-4] [--content TEXT] ... See --help for all options
lineark labels list [--team KEY] List issue labels
lineark labels list [--team KEY] List labels (group, team, parent)
lineark labels create <NAME> Create a label
[--team KEY] [--color HEX] Team, color
[--description TEXT] Description
[--parent-label-group ID] Nest under a group label
[--make-label-group] Create as a group label
lineark labels update <ID> Update a label
[--name TEXT] [--color HEX] Name, color
[--parent-label-group ID] Nest under a group label
[--clear-parent-label-group] Remove parent group
[--make-label-group] [--clear-label-group] Promote/demote group
lineark labels delete <ID> Delete a label
lineark cycles list [-l N] [--team KEY] List cycles
[--active] [--around-active N] Active cycle / ± N neighbors
lineark cycles read <ID> [--team KEY] Read cycle (UUID, name, number)
lineark issues list [-l N] [--team KEY] Active issues, newest first
[--mine] [--show-done] Filter by assignee / state
lineark issues read <IDENTIFIER> Full issue detail incl. sub-issues & comments
lineark issues find-branch <BRANCH> Find issue by Git branch name
lineark issues search <QUERY> [-l N] Full-text search
[--team KEY] [--assignee NAME-OR-ID|me] Filter by team, assignee, status
[--status NAME,...] [--show-done]
lineark issues create <TITLE> --team KEY Create an issue
[-p 0-4] [--assignee NAME-OR-ID|me] Priority, assignee, labels, status
[--labels NAME,...] [-s NAME] ... Project, cycle — see --help
[-p 0-4] [-e N] [--assignee NAME-OR-ID|me] Priority, estimate, assignee
[--labels NAME,...] [-s NAME] ... Labels, status — see --help
lineark issues update <IDENTIFIER> Update an issue
[-s NAME] [-p 0-4] [--assignee NAME-OR-ID|me] Status, priority, assignee
[-s NAME] [-p 0-4] [-e N] Status, priority, estimate
[--assignee NAME-OR-ID|me] Assignee
[--clear-parent] [--project NAME-OR-ID] ... See --help for all options
lineark issues batch-update ID [ID ...] Batch update multiple issues
[-s NAME] [-p 0-4] [--assignee NAME-OR-ID|me] Status, priority, assignee
lineark issues archive <IDENTIFIER> Archive an issue
lineark issues unarchive <IDENTIFIER> Unarchive an issue
lineark issues delete <IDENTIFIER> Delete (trash) an issue
lineark comments create <ISSUE-ID> --body TEXT Comment on an issue
lineark comments update <COMMENT-UUID> Update a comment
--body TEXT New body in markdown
lineark comments resolve <COMMENT-UUID> Resolve a comment thread
lineark comments unresolve <COMMENT-UUID> Unresolve a comment thread
lineark comments delete <ID> Delete a comment
lineark relations create <ISSUE> Create an issue relation
--blocks <ISSUE> Source blocks target
Expand Down
42 changes: 36 additions & 6 deletions crates/lineark/src/commands/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,22 +240,52 @@ pub async fn resolve_label_ids(
return Ok(names_or_ids.to_vec());
}

// Fetch labels, optionally filtered by team.
let mut builder = client.issue_labels::<IssueLabel>().first(250);
// Fetch labels. When a team is provided, fetch both team-scoped and
// workspace-wide labels so that workspace labels can be used on any team's issues.
let mut all_labels: Vec<IssueLabel> = Vec::new();

if let Some(tid) = team_id {
// Team-scoped labels first.
let filter: lineark_sdk::generated::inputs::IssueLabelFilter =
serde_json::from_value(serde_json::json!({ "team": { "id": { "eq": tid } } }))
.expect("valid IssueLabelFilter");
builder = builder.filter(filter);
let conn = client
.issue_labels::<IssueLabel>()
.first(250)
.filter(filter)
.send()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
all_labels.extend(conn.nodes);

// Workspace-wide labels (no team).
let ws_filter: lineark_sdk::generated::inputs::IssueLabelFilter =
serde_json::from_value(serde_json::json!({ "team": { "null": true } }))
.expect("valid IssueLabelFilter");
let ws_conn = client
.issue_labels::<IssueLabel>()
.first(250)
.filter(ws_filter)
.send()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
all_labels.extend(ws_conn.nodes);
} else {
let conn = client
.issue_labels::<IssueLabel>()
.first(250)
.send()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
all_labels = conn.nodes;
}
let conn = builder.send().await.map_err(|e| anyhow::anyhow!("{}", e))?;

let mut resolved = Vec::with_capacity(names_or_ids.len());
for item in names_or_ids {
if uuid::Uuid::parse_str(item).is_ok() {
resolved.push(item.clone());
} else {
let found = conn.nodes.iter().find(|l| {
let found = all_labels.iter().find(|l| {
l.name
.as_deref()
.is_some_and(|n| n.eq_ignore_ascii_case(item))
Expand All @@ -264,7 +294,7 @@ pub async fn resolve_label_ids(
Some(label) => resolved.push(label.id.clone().unwrap_or_default()),
None => {
let available: Vec<String> =
conn.nodes.iter().filter_map(|l| l.name.clone()).collect();
all_labels.iter().filter_map(|l| l.name.clone()).collect();
return Err(anyhow::anyhow!(
"Label '{}' not found. Available: {}",
item,
Expand Down
Loading