Skip to content
Merged
19 changes: 16 additions & 3 deletions src/api/jira/issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,19 @@ impl JiraClient {
}

/// Add a comment to an issue.
pub async fn add_comment(&self, key: &str, body: Value) -> Result<Comment> {
///
/// When `internal` is true, sets the `sd.public.comment` entity property
/// to mark the comment as internal (agent-only) on JSM projects.
/// On non-JSM projects, the property is silently accepted with no effect.
pub async fn add_comment(&self, key: &str, body: Value, internal: bool) -> Result<Comment> {
let path = format!("/rest/api/3/issue/{}/comment", urlencoding::encode(key));
let payload = serde_json::json!({ "body": body });
let mut payload = serde_json::json!({ "body": body });
if internal {
payload["properties"] = serde_json::json!([{
"key": "sd.public.comment",
"value": { "internal": true }
}]);
}
self.post(&path, &payload).await
}

Expand All @@ -180,7 +190,10 @@ impl JiraClient {
}
None => max_page_size,
};
let path = format!("{}?startAt={}&maxResults={}", base, start_at, page_size);
let path = format!(
"{}?startAt={}&maxResults={}&expand=properties",
base, start_at, page_size
);
let page: OffsetPage<Comment> = self.get(&path).await?;
let has_more = page.has_more();
let next = page.next_start();
Expand Down
60 changes: 49 additions & 11 deletions src/cli/issue/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use crate::error::JrError;
use crate::output;
use crate::types::assets::LinkedAsset;
use crate::types::assets::linked::format_linked_assets;
use crate::types::jira::issue::Comment;

use super::format;
use super::helpers;
Expand Down Expand Up @@ -609,6 +610,22 @@ fn format_comment_row(
]
}

/// Extract the internal/external visibility from a comment's `sd.public.comment` property.
/// Returns `Some("Internal")` or `Some("External")` if the property exists, `None` otherwise.
fn comment_visibility(comment: &Comment) -> Option<&'static str> {
comment
.properties
.iter()
.find(|p| p.key == "sd.public.comment")
.map(|p| {
if p.value.get("internal") == Some(&serde_json::Value::Bool(true)) {
"Internal"
} else {
"External"
}
})
}

pub(super) async fn handle_comments(
key: &str,
limit: Option<u32>,
Expand All @@ -617,22 +634,43 @@ pub(super) async fn handle_comments(
) -> Result<()> {
let comments = client.list_comments(key, limit).await?;

// Show Visibility column only if any comment has sd.public.comment property
let has_visibility = comments.iter().any(|c| comment_visibility(c).is_some());

match output_format {
OutputFormat::Json => {
output::print_output(output_format, &["Author", "Date", "Body"], &[], &comments)?;
output::print_output(output_format, &[], &[], &comments)?;
}
OutputFormat::Table => {
let rows: Vec<Vec<String>> = comments
.iter()
.map(|c| {
let author = c.author.as_ref().map(|a| a.display_name.as_str());
let created = c.created.as_deref();
let body_text = c.body.as_ref().map(adf::adf_to_text);
format_comment_row(author, created, body_text.as_deref())
})
.collect();
let (headers, rows) = if has_visibility {
let rows: Vec<Vec<String>> = comments
.iter()
.map(|c| {
let author = c.author.as_ref().map(|a| a.display_name.as_str());
let created = c.created.as_deref();
let body_text = c.body.as_ref().map(adf::adf_to_text);
let visibility = comment_visibility(c).unwrap_or("External");
let mut row = format_comment_row(author, created, body_text.as_deref());
// Insert Visibility before Body (index 2)
row.insert(2, visibility.to_string());
row
})
.collect();
(vec!["Author", "Date", "Visibility", "Body"], rows)
} else {
let rows: Vec<Vec<String>> = comments
.iter()
.map(|c| {
let author = c.author.as_ref().map(|a| a.display_name.as_str());
let created = c.created.as_deref();
let body_text = c.body.as_ref().map(adf::adf_to_text);
format_comment_row(author, created, body_text.as_deref())
})
.collect();
(vec!["Author", "Date", "Body"], rows)
};

output::print_output(output_format, &["Author", "Date", "Body"], &rows, &comments)?;
output::print_output(output_format, &headers, &rows, &comments)?;
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/cli/issue/workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ pub(super) async fn handle_comment(
markdown,
file,
stdin,
internal,
} = command
else {
unreachable!()
Expand Down Expand Up @@ -419,7 +420,7 @@ pub(super) async fn handle_comment(
adf::text_to_adf(&text)
};

let comment = client.add_comment(&key, adf_body).await?;
let comment = client.add_comment(&key, adf_body, internal).await?;

match output_format {
OutputFormat::Json => {
Expand Down
3 changes: 3 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,9 @@ pub enum IssueCommand {
/// Read comment from stdin (for piping)
#[arg(long)]
stdin: bool,
/// Mark comment as internal (agent-only, not visible to customers on JSM projects)
#[arg(long)]
internal: bool,
},
/// List comments on an issue
Comments {
Expand Down
44 changes: 44 additions & 0 deletions src/types/jira/issue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,20 @@ pub struct TransitionsResponse {
pub transitions: Vec<Transition>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct EntityProperty {
pub key: String,
pub value: Value,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Comment {
pub id: Option<String>,
pub body: Option<Value>,
pub author: Option<User>,
pub created: Option<String>,
#[serde(default)]
pub properties: Vec<EntityProperty>,
}

#[derive(Debug, Deserialize, Serialize)]
Expand Down Expand Up @@ -384,4 +392,40 @@ mod tests {
assert!(v.released.is_none());
assert!(v.release_date.is_none());
}

#[test]
fn comment_deserialize_with_properties() {
let json = json!({
"id": "10001",
"body": null,
"properties": [
{"key": "sd.public.comment", "value": {"internal": true}}
]
});
let comment: Comment = serde_json::from_value(json).unwrap();
assert_eq!(comment.properties.len(), 1);
assert_eq!(comment.properties[0].key, "sd.public.comment");
assert_eq!(comment.properties[0].value["internal"], true);
}

#[test]
fn comment_deserialize_without_properties() {
let json = json!({
"id": "10002",
"body": null
});
let comment: Comment = serde_json::from_value(json).unwrap();
assert!(comment.properties.is_empty());
}

#[test]
fn comment_deserialize_empty_properties() {
let json = json!({
"id": "10003",
"body": null,
"properties": []
});
let comment: Comment = serde_json::from_value(json).unwrap();
assert!(comment.properties.is_empty());
}
}
152 changes: 152 additions & 0 deletions tests/cli_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -841,3 +841,155 @@ async fn test_handler_list_asset_key_passthrough_skips_assets_api() {
.assert()
.success();
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handler_comment_internal_flag_adds_property() {
let server = MockServer::start().await;

Mock::given(method("POST"))
.and(path("/rest/api/3/issue/HELP-42/comment"))
.and(body_partial_json(serde_json::json!({
"properties": [{
"key": "sd.public.comment",
"value": { "internal": true }
}]
})))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "10001",
"created": "2026-04-05T12:00:00.000+0000"
})))
.expect(1)
.mount(&server)
.await;

Command::cargo_bin("jr")
.unwrap()
.env("JR_BASE_URL", server.uri())
.env("JR_AUTH_HEADER", "Basic dGVzdDp0ZXN0")
.args([
"issue",
"comment",
"HELP-42",
"Internal note",
"--internal",
"--no-input",
])
.assert()
.success();
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handler_comment_without_internal_omits_property() {
let server = MockServer::start().await;

Mock::given(method("POST"))
.and(path("/rest/api/3/issue/HELP-42/comment"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "10002",
"created": "2026-04-05T12:00:00.000+0000"
})))
.expect(1)
.named("comment without internal")
.mount(&server)
.await;

Command::cargo_bin("jr")
.unwrap()
.env("JR_BASE_URL", server.uri())
.env("JR_AUTH_HEADER", "Basic dGVzdDp0ZXN0")
.args(["issue", "comment", "HELP-42", "External note", "--no-input"])
.assert()
.success();
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handler_comments_shows_visibility_column_for_jsm() {
let server = MockServer::start().await;

Mock::given(method("GET"))
.and(path("/rest/api/3/issue/HELP-42/comment"))
.and(query_param("expand", "properties"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"comments": [
{
"id": "10001",
"author": { "accountId": "abc", "displayName": "Agent", "active": true },
"body": { "type": "doc", "version": 1, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Agent investigation notes" }] }] },
"created": "2026-04-05T10:00:00.000+0000",
"properties": [{"key": "sd.public.comment", "value": {"internal": true}}]
},
{
"id": "10002",
"author": { "accountId": "def", "displayName": "Agent", "active": true },
"body": { "type": "doc", "version": 1, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Customer reply" }] }] },
"created": "2026-04-05T11:00:00.000+0000",
"properties": [{"key": "sd.public.comment", "value": {"internal": false}}]
}
],
"startAt": 0,
"maxResults": 100,
"total": 2
})))
.mount(&server)
.await;

Command::cargo_bin("jr")
.unwrap()
.env("JR_BASE_URL", server.uri())
.env("JR_AUTH_HEADER", "Basic dGVzdDp0ZXN0")
.args(["issue", "comments", "HELP-42", "--no-input"])
.assert()
.success()
.stdout(predicates::prelude::predicate::str::contains("Visibility"))
.stdout(predicates::prelude::predicate::str::contains("Internal"))
.stdout(predicates::prelude::predicate::str::contains("External"));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handler_comments_hides_visibility_column_for_non_jsm() {
let server = MockServer::start().await;

Mock::given(method("GET"))
.and(path("/rest/api/3/issue/DEV-99/comment"))
.and(query_param("expand", "properties"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"comments": [
{
"id": "10001",
"author": { "accountId": "abc", "displayName": "Dev", "active": true },
"body": { "type": "doc", "version": 1, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Fixed in commit abc123" }] }] },
"created": "2026-04-05T10:00:00.000+0000",
"properties": []
}
],
"startAt": 0,
"maxResults": 100,
"total": 1
})))
.mount(&server)
.await;

let output = Command::cargo_bin("jr")
.unwrap()
.env("JR_BASE_URL", server.uri())
.env("JR_AUTH_HEADER", "Basic dGVzdDp0ZXN0")
.args(["issue", "comments", "DEV-99", "--no-input"])
.output()
.unwrap();

assert!(
output.status.success(),
"Expected success, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("Visibility"),
"Non-JSM comments should not show Visibility column, got: {stdout}"
);
assert!(
!stdout.contains("Internal"),
"Non-JSM comments should not show Internal, got: {stdout}"
);
}
Loading