diff --git a/crates/lineark-sdk/src/generated/client_impl.rs b/crates/lineark-sdk/src/generated/client_impl.rs index 5dd5008..688b443 100644 --- a/crates/lineark-sdk/src/generated/client_impl.rs +++ b/crates/lineark-sdk/src/generated/client_impl.rs @@ -205,10 +205,49 @@ impl Client { ) -> Result { crate::generated::mutations::comment_create::(self, input).await } + /// Updates a comment. + /// + /// Full type: [`Comment`](super::types::Comment) + pub async fn comment_update< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + skip_edited_at: Option, + input: CommentUpdateInput, + id: String, + ) -> Result { + crate::generated::mutations::comment_update::(self, skip_edited_at, input, id).await + } /// Deletes a comment. pub async fn comment_delete(&self, id: String) -> Result { crate::generated::mutations::comment_delete(self, id).await } + /// Resolves a comment. + /// + /// Full type: [`Comment`](super::types::Comment) + pub async fn comment_resolve< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + resolving_comment_id: Option, + id: String, + ) -> Result { + crate::generated::mutations::comment_resolve::(self, resolving_comment_id, id).await + } + /// Unresolves a comment. + /// + /// Full type: [`Comment`](super::types::Comment) + pub async fn comment_unresolve< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + id: String, + ) -> Result { + crate::generated::mutations::comment_unresolve::(self, id).await + } /// Creates a new project. /// /// Full type: [`Project`](super::types::Project) diff --git a/crates/lineark-sdk/src/generated/mutations.rs b/crates/lineark-sdk/src/generated/mutations.rs index 97c4ec2..b03720c 100644 --- a/crates/lineark-sdk/src/generated/mutations.rs +++ b/crates/lineark-sdk/src/generated/mutations.rs @@ -67,6 +67,28 @@ pub async fn comment_create< .execute_mutation::(&query, variables, "commentCreate", "comment") .await } +/// Updates a comment. +/// +/// Full type: [`Comment`](super::types::Comment) +pub async fn comment_update< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + skip_edited_at: Option, + input: CommentUpdateInput, + id: String, +) -> Result { + let variables = serde_json::json!( + { "skipEditedAt" : skip_edited_at, "input" : input, "id" : id } + ); + let query = String::from( + "mutation CommentUpdate($skipEditedAt: Boolean, $input: CommentUpdateInput!, $id: String!) { commentUpdate(skipEditedAt: $skipEditedAt, input: $input, id: $id) { success comment { ", + ) + &T::selection() + " } } }"; + client + .execute_mutation::(&query, variables, "commentUpdate", "comment") + .await +} /// Deletes a comment. pub async fn comment_delete(client: &Client, id: String) -> Result { let variables = serde_json::json!({ "id" : id }); @@ -78,6 +100,46 @@ pub async fn comment_delete(client: &Client, id: String) -> Result(&query, variables, "commentDelete") .await } +/// Resolves a comment. +/// +/// Full type: [`Comment`](super::types::Comment) +pub async fn comment_resolve< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + resolving_comment_id: Option, + id: String, +) -> Result { + let variables = serde_json::json!( + { "resolvingCommentId" : resolving_comment_id, "id" : id } + ); + let query = String::from( + "mutation CommentResolve($resolvingCommentId: String, $id: String!) { commentResolve(resolvingCommentId: $resolvingCommentId, id: $id) { success comment { ", + ) + &T::selection() + " } } }"; + client + .execute_mutation::(&query, variables, "commentResolve", "comment") + .await +} +/// Unresolves a comment. +/// +/// Full type: [`Comment`](super::types::Comment) +pub async fn comment_unresolve< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + id: String, +) -> Result { + let variables = serde_json::json!({ "id" : id }); + let query = String::from( + "mutation CommentUnresolve($id: String!) { commentUnresolve(id: $id) { success comment { ", + ) + &T::selection() + + " } } }"; + client + .execute_mutation::(&query, variables, "commentUnresolve", "comment") + .await +} /// Creates a new project. /// /// Full type: [`Project`](super::types::Project) diff --git a/crates/lineark-sdk/tests/online.rs b/crates/lineark-sdk/tests/online.rs index b83511d..2cb8674 100644 --- a/crates/lineark-sdk/tests/online.rs +++ b/crates/lineark-sdk/tests/online.rs @@ -1068,6 +1068,132 @@ mod online { client.team_delete(team_id).await.unwrap(); } + // ── Comment Update ──────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn comment_update_changes_body() { + use lineark_sdk::generated::inputs::{ + CommentCreateInput, CommentUpdateInput, IssueCreateInput, + }; + + let client = test_client(); + let (team_id, _team_guard) = create_test_team(&client).await; + + // Create an issue to comment on. + let issue_input = IssueCreateInput { + title: Some(format!( + "[test] SDK comment_update_changes_body {}", + &uuid::Uuid::new_v4().to_string()[..8] + )), + team_id: Some(team_id), + priority: Some(4), + ..Default::default() + }; + let issue_entity = client.issue_create::(issue_input).await.unwrap(); + let issue_id = issue_entity.id.clone().unwrap(); + let _issue_guard = IssueGuard { + token: test_token(), + id: issue_id.clone(), + }; + + // Create a comment with the original body. + let comment_input = CommentCreateInput { + body: Some("Original body".to_string()), + issue_id: Some(issue_id.clone()), + ..Default::default() + }; + let comment_entity = client + .comment_create::(comment_input) + .await + .unwrap(); + let comment_id = comment_entity.id.clone().unwrap(); + assert_eq!(comment_entity.body.as_deref(), Some("Original body")); + + // Update the comment body. + let update_input = CommentUpdateInput { + body: Some("Updated body".to_string()), + ..Default::default() + }; + let updated = client + .comment_update::(None, update_input, comment_id) + .await + .unwrap(); + assert_eq!(updated.body.as_deref(), Some("Updated body")); + + // Clean up: permanently delete the issue (cascades the comment). + client + .issue_delete::(Some(true), issue_id) + .await + .unwrap(); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn comment_resolve_and_unresolve() { + use lineark_sdk::generated::inputs::{CommentCreateInput, IssueCreateInput}; + + let client = test_client(); + let (team_id, _team_guard) = create_test_team(&client).await; + + // Create an issue to comment on. + let issue_input = IssueCreateInput { + title: Some(format!( + "[test] SDK comment_resolve_and_unresolve {}", + &uuid::Uuid::new_v4().to_string()[..8] + )), + team_id: Some(team_id), + priority: Some(4), + ..Default::default() + }; + let issue_entity = client.issue_create::(issue_input).await.unwrap(); + let issue_id = issue_entity.id.clone().unwrap(); + let _issue_guard = IssueGuard { + token: test_token(), + id: issue_id.clone(), + }; + + // Create a comment. + let comment_input = CommentCreateInput { + body: Some("Thread to resolve".to_string()), + issue_id: Some(issue_id.clone()), + ..Default::default() + }; + let comment_entity = client + .comment_create::(comment_input) + .await + .unwrap(); + let comment_id = comment_entity.id.clone().unwrap(); + assert!( + comment_entity.resolved_at.is_none(), + "new comment should not be resolved" + ); + + // Resolve the comment thread. + let resolved = client + .comment_resolve::(None, comment_id.clone()) + .await + .unwrap(); + assert!( + resolved.resolved_at.is_some(), + "comment should have resolvedAt after resolve" + ); + + // Unresolve the comment thread. + let unresolved = client + .comment_unresolve::(comment_id) + .await + .unwrap(); + assert!( + unresolved.resolved_at.is_none(), + "comment should not have resolvedAt after unresolve" + ); + + // Clean up: permanently delete the issue. + client + .issue_delete::(Some(true), issue_id) + .await + .unwrap(); + } + // ── Issue VCS Branch Search ───────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)] @@ -1075,11 +1201,9 @@ mod online { use lineark_sdk::generated::inputs::IssueCreateInput; let client = test_client(); + let (team_id, _team_guard) = create_test_team(&client).await; // Create an issue so we can look up its branchName. - let teams = client.teams::().first(1).send().await.unwrap(); - let team_id = teams.nodes[0].id.clone().unwrap(); - let uid = &uuid::Uuid::new_v4().to_string()[..8]; let input = IssueCreateInput { title: Some(format!("[test] SDK branch search {uid}")), diff --git a/crates/lineark/src/commands/comments.rs b/crates/lineark/src/commands/comments.rs index c5c28a5..a4bcb98 100644 --- a/crates/lineark/src/commands/comments.rs +++ b/crates/lineark/src/commands/comments.rs @@ -1,5 +1,5 @@ use clap::Args; -use lineark_sdk::generated::inputs::CommentCreateInput; +use lineark_sdk::generated::inputs::{CommentCreateInput, CommentUpdateInput}; use lineark_sdk::generated::types::Comment; use lineark_sdk::{Client, GraphQLFields}; use serde::{Deserialize, Serialize}; @@ -28,6 +28,17 @@ pub enum CommentsAction { #[arg(long)] body: String, }, + /// Update a comment's body. + /// + /// Examples: + /// lineark comments update COMMENT-UUID --body "Updated text" + Update { + /// Comment UUID. + id: String, + /// New comment body in markdown format. + #[arg(long)] + body: Option, + }, /// Delete a comment. /// /// Examples: @@ -36,6 +47,26 @@ pub enum CommentsAction { /// Comment UUID. id: String, }, + /// Resolve a comment thread. + /// + /// Examples: + /// lineark comments resolve COMMENT-UUID + /// lineark comments resolve COMMENT-UUID --resolving-comment REPLY-UUID + Resolve { + /// Comment UUID (the thread root to resolve). + id: String, + /// Optional UUID of the reply comment that resolves this thread. + #[arg(long)] + resolving_comment: Option, + }, + /// Unresolve a previously resolved comment thread. + /// + /// Examples: + /// lineark comments unresolve COMMENT-UUID + Unresolve { + /// Comment UUID. + id: String, + }, } /// Lean result type for comment mutations. @@ -45,6 +76,7 @@ pub enum CommentsAction { struct CommentRef { id: Option, body: Option, + resolved_at: Option, } pub async fn run(cmd: CommentsCmd, client: &Client, format: Format) -> anyhow::Result<()> { @@ -66,6 +98,23 @@ pub async fn run(cmd: CommentsCmd, client: &Client, format: Format) -> anyhow::R output::print_one(&comment, format); } + CommentsAction::Update { id, body } => { + if body.is_none() { + anyhow::bail!("No update fields provided. Use --body to set the new comment body."); + } + + let input = CommentUpdateInput { + body, + ..Default::default() + }; + + let comment = client + .comment_update::(None, input, id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&comment, format); + } CommentsAction::Delete { id } => { client .comment_delete(id) @@ -74,6 +123,25 @@ pub async fn run(cmd: CommentsCmd, client: &Client, format: Format) -> anyhow::R output::print_one(&serde_json::json!({ "success": true }), format); } + CommentsAction::Resolve { + id, + resolving_comment, + } => { + let comment = client + .comment_resolve::(resolving_comment, id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&comment, format); + } + CommentsAction::Unresolve { id } => { + let comment = client + .comment_unresolve::(id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&comment, format); + } } Ok(()) } diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index 20ff355..aba9d72 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -79,6 +79,11 @@ COMMANDS: lineark issues delete Delete (trash) an issue [--permanently] Permanently delete instead of trashing lineark comments create --body TEXT Comment on an issue + lineark comments update Update a comment + --body TEXT New body in markdown + lineark comments resolve Resolve a comment thread + [--resolving-comment UUID] Reply that resolves thread + lineark comments unresolve Unresolve a comment thread lineark comments delete Delete a comment lineark relations create Create an issue relation --blocks Source blocks target diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 79c1b64..1b5f109 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -917,6 +917,98 @@ fn usage_includes_comments_delete() { .stdout(predicate::str::contains("comments delete")); } +// ── Comments update/resolve/unresolve ────────────────────────────────────── + +#[test] +fn comments_help_shows_update_subcommand() { + lineark() + .args(["comments", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("update")); +} + +#[test] +fn comments_update_help_shows_flags() { + lineark() + .args(["comments", "update", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--body")) + .stdout(predicate::str::contains("")); +} + +#[test] +fn comments_update_no_flags_prints_error() { + lineark() + .args([ + "--api-token", + "fake-token", + "comments", + "update", + "some-uuid", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("No update fields provided")); +} + +#[test] +fn comments_help_shows_resolve_subcommand() { + lineark() + .args(["comments", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("resolve")); +} + +#[test] +fn comments_help_shows_unresolve_subcommand() { + lineark() + .args(["comments", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("unresolve")); +} + +#[test] +fn comments_resolve_help_shows_flags() { + lineark() + .args(["comments", "resolve", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("")) + .stdout(predicate::str::contains("--resolving-comment")); +} + +#[test] +fn comments_unresolve_help_shows_id() { + lineark() + .args(["comments", "unresolve", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("")); +} + +#[test] +fn usage_includes_comments_update() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("comments update")); +} + +#[test] +fn usage_includes_comments_resolve() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("comments resolve")) + .stdout(predicate::str::contains("comments unresolve")); +} + // ── Issues list --project filter ──────────────────────────────────────────── #[test] diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index e04a4ed..499a9e5 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -5,7 +5,7 @@ use assert_cmd::Command; use lineark_sdk::generated::inputs::ProjectCreateInput; -use lineark_sdk::generated::types::{Issue, IssueRelation, Project, Team}; +use lineark_sdk::generated::types::{Comment, Issue, IssueRelation, Project, Team}; use lineark_sdk::Client; use predicates::prelude::*; @@ -3349,6 +3349,261 @@ mod online { assert_eq!(result["success"].as_bool(), Some(true)); } + // ── Comments update/resolve/unresolve ────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn comments_update_resolve_unresolve_lifecycle() { + let token = api_token(); + let client = Client::from_token(token.clone()).unwrap(); + let (_team_key, team_id, _team_guard) = create_test_team(); + + // Create an issue via SDK (more reliable than CLI for setup). + let issue = tokio::runtime::Runtime::new().unwrap().block_on(async { + client + .issue_create::(lineark_sdk::generated::inputs::IssueCreateInput { + title: Some(format!( + "[test] CLI comments_lifecycle {}", + &uuid::Uuid::new_v4().to_string()[..8] + )), + team_id: Some(team_id), + priority: Some(4), + ..Default::default() + }) + .await + .unwrap() + }); + let issue_id = issue.id.clone().unwrap(); + let _issue_guard = IssueGuard { + token: token.clone(), + id: issue_id.clone(), + }; + + // Create a comment (retry to allow issue propagation). + let comment_id = retry_with_backoff(8, || { + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "comments", + "create", + &issue_id, + "--body", + "Original body", + ]) + .output() + .unwrap(); + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).to_string()); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let comment: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + Ok(comment["id"].as_str().unwrap().to_string()) + }) + .expect("comment create should succeed (after retries)"); + + // Update the comment body. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "comments", + "update", + &comment_id, + "--body", + "Updated body", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "comment update should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let updated: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + updated["body"].as_str(), + Some("Updated body"), + "body should be updated" + ); + + // Resolve the comment. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "comments", + "resolve", + &comment_id, + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "comment resolve should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let resolved: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + resolved["resolvedAt"].as_str().is_some(), + "resolvedAt should be set after resolve" + ); + + // Unresolve the comment. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "comments", + "unresolve", + &comment_id, + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "comment unresolve should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let unresolved: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + unresolved["resolvedAt"].is_null(), + "resolvedAt should be null after unresolve" + ); + + // Delete the comment. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "comments", + "delete", + &comment_id, + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "comment delete should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + + // Clean up: permanently delete the issue. + delete_issue(&issue_id); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn comments_resolve_with_resolving_comment() { + use lineark_sdk::generated::inputs::{CommentCreateInput, IssueCreateInput}; + + let token = api_token(); + let client = Client::from_token(token.clone()).unwrap(); + let (_team_key, team_id, _team_guard) = create_test_team(); + + // Create an issue via SDK. + let issue = tokio::runtime::Runtime::new().unwrap().block_on(async { + client + .issue_create::(IssueCreateInput { + title: Some(format!( + "[test] CLI comments_resolve_with_resolving_comment {}", + &uuid::Uuid::new_v4().to_string()[..8] + )), + team_id: Some(team_id), + priority: Some(4), + ..Default::default() + }) + .await + .unwrap() + }); + let issue_id = issue.id.clone().unwrap(); + let _issue_guard = IssueGuard { + token: token.clone(), + id: issue_id.clone(), + }; + + // Create a parent comment via CLI (retry to allow issue propagation). + let parent_id = retry_with_backoff(8, || { + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "comments", + "create", + &issue_id, + "--body", + "Parent comment thread", + ]) + .output() + .unwrap(); + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).to_string()); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let parent: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + Ok(parent["id"].as_str().unwrap().to_string()) + }) + .expect("parent comment create should succeed (after retries)"); + + // Create a reply comment via SDK (using parent_id). + let reply = tokio::runtime::Runtime::new().unwrap().block_on(async { + client + .comment_create::(CommentCreateInput { + body: Some("Reply that resolves thread".to_string()), + issue_id: Some(issue_id.clone()), + parent_id: Some(parent_id.clone()), + ..Default::default() + }) + .await + .unwrap() + }); + let reply_id = reply.id.clone().unwrap(); + + // Resolve parent with --resolving-comment. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "comments", + "resolve", + &parent_id, + "--resolving-comment", + &reply_id, + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "comment resolve with resolving-comment should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let resolved: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + resolved["resolvedAt"].as_str().is_some(), + "resolvedAt should be set after resolve with resolving-comment" + ); + + // Clean up: permanently delete the issue. + delete_issue(&issue_id); + } + // ── Issues find-branch ────────────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)] @@ -3357,13 +3612,11 @@ mod online { let token = api_token(); let client = Client::from_token(&token).unwrap(); + let (_team_key, team_id, _team_guard) = create_test_team(); // Create an issue via SDK to get the branch name. let rt = tokio::runtime::Runtime::new().unwrap(); let (issue_id, branch_name) = rt.block_on(async { - let teams = client.teams::().first(1).send().await.unwrap(); - let team_id = teams.nodes[0].id.clone().unwrap(); - let input = IssueCreateInput { title: Some(format!( "[test] CLI find-branch {}", diff --git a/schema/operations.toml b/schema/operations.toml index 7b9ed35..d56c528 100644 --- a/schema/operations.toml +++ b/schema/operations.toml @@ -28,7 +28,10 @@ projectMilestone = true issueCreate = true issueUpdate = true commentCreate = true +commentUpdate = true commentDelete = true +commentResolve = true +commentUnresolve = true # Phase 3 — Issue lifecycle issueArchive = true