diff --git a/agents/case-management.md b/agents/case-management.md index 8d633f9..92b5674 100644 --- a/agents/case-management.md +++ b/agents/case-management.md @@ -41,9 +41,14 @@ Security Signals, and ad-hoc operator workflows. If the user's request is incide - **Archive / Unarchive**: Archive resolved cases or restore archived ones ### Comments -- **Add Comment**: Post a comment to a case (`pup cases comment --body "..."`) -- **Delete Comment**: Remove a comment by ID (`pup cases delete-comment --comment-id `) -- **Note**: there is no list-comments endpoint in the API at this revision. Comments must be read in the Datadog UI. +- **List Comments**: List all comments on a case (`pup cases comments list `) +- **Get Comment**: Read a single comment by ID (`pup cases comments get --comment-id `) +- **Create Comment**: Post a comment to a case (`pup cases comments create --body "..."`) +- **Update Comment**: Edit a comment's body (`pup cases comments update --comment-id --body "..."`) +- **Delete Comment**: Remove a comment by ID (`pup cases comments delete --comment-id `) + +### Timeline +- **Get Timeline**: Fetch the full activity feed for a case — comments, attribute changes, status transitions (`pup cases timeline `) ### Projects - **List / Get / Create / Delete / Update**: Manage the case projects that group cases together @@ -126,17 +131,40 @@ JSON:API shape: ### Comments ```bash -# Add a comment -pup cases comment PROJ-123 --body "Root cause: Redis cache miss" +# List all comments on a case +pup cases comments list PROJ-123 + +# Get a single comment by ID +pup cases comments get PROJ-123 --comment-id 0521a6f2-4247-45db-9ad9-999628a30e2e + +# Create a comment +pup cases comments create PROJ-123 --body "Root cause: Redis cache miss" -# Delete a comment (need the comment UUID, returned from the comment_case response) -pup cases delete-comment PROJ-123 --comment-id 0521a6f2-4247-45db-9ad9-999628a30e2e +# Update a comment's body +pup cases comments update PROJ-123 --comment-id 0521a6f2-4247-45db-9ad9-999628a30e2e --body "Updated: actually a connection pool exhaustion" + +# Delete a comment (need the comment UUID, returned from the create response) +pup cases comments delete PROJ-123 --comment-id 0521a6f2-4247-45db-9ad9-999628a30e2e ``` -The API at this revision does not expose a list-comments endpoint. To read comments, use the Datadog UI. Two related gaps make comments hard to manage via CLI: +Implementation notes: +- `list` and `get` are derived from `pup cases timeline ` (the full activity feed) by filtering for `COMMENT` cells. Deleted comments are still returned by the API with `deleted_at` set and a scrubbed body — filter client-side if you only want active ones. +- The `create` response is a TimelineResponse — the new comment's UUID is at `.data[0].id`. Capture it if you'll want to update or delete the comment later. +Known caveats: - `pup cases search --query ""` matches against **title and description only** — comment text is not indexed. You cannot find a case via a unique substring of its comments. -- **`comment_count` is always `0` on `pup cases get`.** The field is populated only in `pup cases search` results. To get the true comment count for a case, find it via search (e.g. by `project_id` + filter client-side) and read `comment_count` from there. +- **`comment_count` is always `0` on `pup cases get`.** The field is populated only in `pup cases search` results. To get the true comment count for a case, find it via search (e.g. by `project_id` + filter client-side) and read `comment_count` from there — or just `pup cases comments list | jq '.data | length'`. + +### Timeline +```bash +# Full activity feed: comments, attribute updates, status transitions, etc. +pup cases timeline PROJ-123 + +# Filter to a specific cell type +pup cases timeline PROJ-123 | jq '.data[] | select(.attributes.type == "ATTRIBUTE_UPDATE")' +``` + +Cell types observed: `CASE_CREATED`, `COMMENT`, `ATTRIBUTE_UPDATE`. Each cell has `id`, `attributes.type`, `attributes.author`, `attributes.created_at`, and a type-specific `attributes.cell_content`. ### Update ```bash @@ -253,7 +281,7 @@ pup cases update-status CASE-123 --status IN_PROGRESS ### "Add a comment" ```bash -pup cases comment CASE-123 --body "Investigation findings: ..." +pup cases comments create CASE-123 --body "Investigation findings: ..." ``` ### "Close a case" @@ -275,17 +303,17 @@ A required attribute is missing or malformed. Verify `--title`, `--type-id`, and The priority parser is case-insensitive but rejects unknown values. **`failed to delete comment: ResponseError(... 404 ...)`** -The comment ID doesn't exist (already deleted). Use the `id` field from the `pup cases comment` response (the new comment's id is returned in the response payload). +The comment ID doesn't exist (already deleted). Use the `id` field from the `pup cases comments create` response (the new comment's id is returned in the response payload), or list comments first with `pup cases comments list `. ## Best Practices 1. **Always set `--project-id` when creating cases.** Cases without a project assignment are hard to search for later — most well-supported search facets require `project_id`. 2. **Use the human key (e.g. `PROJ-123`) for references.** It's more readable than a UUID in PR descriptions, Slack, etc. CLI subcommands accept both. -3. **Capture the comment ID when posting** if there's any chance you'll want to revoke. The API only exposes single-comment delete, not list — the `pup cases comment` response is the only place the new comment's ID appears. -4. **Don't rely on `comment_count`** to verify writes. It doesn't refresh promptly. Verify state via the Datadog UI. +3. **Capture the comment ID when posting** if you may want to update or delete it. `pup cases comments create` returns the new comment's id at `.data[0].id`. You can also recover it later via `pup cases comments list`. +4. **Don't rely on `comment_count`** to verify writes — it doesn't refresh promptly. Verify state via `pup cases comments list ` instead. 5. **Prefer flag-based create over `--file`** when possible. Reach for `--file` only when you need custom attributes, `status_name`, or other less-common fields not exposed as flags. 6. **Search facet vocabulary is limited.** `project_id:` is the reliable facet. Free text matches title/description. Don't rely on `project.key:`, `status:`, or `key:`-based facets — they often return empty. -7. **Synthesize status in the description, not in comments.** For tracking cases (rollup of PR links, sub-task state, etc.), update the description as work progresses. The description is indexed by search and returned by `pup cases get`; comments are not indexed and have no list endpoint, making them effectively write-only via CLI. Reserve comments for granular per-event records that operators read in the UI. +7. **Synthesize status in the description, not in comments.** For tracking cases (rollup of PR links, sub-task state, etc.), update the description as work progresses. The description is indexed by search and returned by `pup cases get`; comment text is not indexed, so a substring of a comment will not match `pup cases search`. Reserve comments for granular per-event records. ## Integration with Other Agents diff --git a/agents/incident-response.md b/agents/incident-response.md index 6bd8c53..0408faf 100644 --- a/agents/incident-response.md +++ b/agents/incident-response.md @@ -369,7 +369,7 @@ pup cases create \ --project-id "" # Track investigation progress -pup cases comment --body "Investigation update: ..." +pup cases comments create --body "Investigation update: ..." pup cases update-status --status IN_PROGRESS # Close out after resolution @@ -521,7 +521,7 @@ pup cases assign CASE-XXX --user-id pup cases update-status CASE-XXX --status IN_PROGRESS # 7. COLLABORATION: Add investigation findings -pup cases comment CASE-XXX --body "Root cause: Database connection pool exhaustion" +pup cases comments create CASE-XXX --body "Root cause: Database connection pool exhaustion" # 8. ESCALATION: If needed, escalate page pup on-call page escalate diff --git a/src/commands/cases.rs b/src/commands/cases.rs index fcd0eb1..65adb5a 100644 --- a/src/commands/cases.rs +++ b/src/commands/cases.rs @@ -16,6 +16,7 @@ use datadog_api_client::datadogV2::model::{ ServiceNowTicketCreateRequest, }; +use crate::client; use crate::config::Config; use crate::formatter; @@ -123,7 +124,7 @@ pub async fn create_from_flags( // Comments // --------------------------------------------------------------------------- -pub async fn comment(cfg: &Config, case_id: &str, body: &str) -> Result<()> { +pub async fn comments_create(cfg: &Config, case_id: &str, body: &str) -> Result<()> { let api = make_api(cfg); let req = CaseCommentRequest::new(CaseComment::new( CaseCommentAttributes::new(body.to_string()), @@ -136,7 +137,7 @@ pub async fn comment(cfg: &Config, case_id: &str, body: &str) -> Result<()> { formatter::output(cfg, &resp) } -pub async fn delete_comment(cfg: &Config, case_id: &str, comment_id: &str) -> Result<()> { +pub async fn comments_delete(cfg: &Config, case_id: &str, comment_id: &str) -> Result<()> { let api = make_api(cfg); api.delete_case_comment(case_id.to_string(), comment_id.to_string()) .await @@ -145,6 +146,97 @@ pub async fn delete_comment(cfg: &Config, case_id: &str, comment_id: &str) -> Re Ok(()) } +/// Update a comment's body. The endpoint is `PUT /api/v2/cases/{case_id}/ +/// comment/{cell_id}` — not yet in the public spec. Responds 200 with an +/// empty body on success, so we use `raw_request` rather than `raw_put` +/// (which expects parseable JSON). +pub async fn comments_update( + cfg: &Config, + case_id: &str, + comment_id: &str, + body: &str, +) -> Result<()> { + let path = format!("/api/v2/cases/{case_id}/comment/{comment_id}"); + let payload = serde_json::json!({ + "data": { + "type": "case", + "attributes": { "comment": body }, + } + }); + let payload_bytes = serde_json::to_vec(&payload)?; + client::raw_request( + cfg, + "PUT", + &path, + &[], + Some(payload_bytes), + Some("application/json"), + "application/json", + &[], + ) + .await + .map_err(|e| anyhow::anyhow!("failed to update comment: {e:?}"))?; + println!("Comment {comment_id} updated on case {case_id}."); + Ok(()) +} + +/// List a case's comments by filtering the timeline to `COMMENT` cells. +pub async fn comments_list(cfg: &Config, case_id: &str) -> Result<()> { + let resp = fetch_timeline(cfg, case_id).await?; + let comments = comment_cells(&resp); + formatter::output(cfg, &serde_json::json!({ "data": comments })) +} + +/// Get a single comment by id by filtering the timeline. Errors if no cell +/// with the given id exists — matches the typical `GET /resource/{id}` shape. +pub async fn comments_get(cfg: &Config, case_id: &str, comment_id: &str) -> Result<()> { + let resp = fetch_timeline(cfg, case_id).await?; + let found = comment_cells(&resp) + .into_iter() + .find(|c| c.get("id").and_then(|v| v.as_str()) == Some(comment_id)) + .ok_or_else(|| { + anyhow::anyhow!("comment {comment_id} not found in timeline for case {case_id}") + })?; + formatter::output(cfg, &serde_json::json!({ "data": found })) +} + +// --------------------------------------------------------------------------- +// Timeline (uses raw HTTP — the DD API client doesn't expose this endpoint +// yet, even though it declares the response model). +// --------------------------------------------------------------------------- + +/// Fetch the full timeline for a case. Returns every cell (comments, +/// attribute updates, status changes, etc.). +pub async fn timeline(cfg: &Config, case_id: &str) -> Result<()> { + let resp = fetch_timeline(cfg, case_id).await?; + formatter::output(cfg, &resp) +} + +async fn fetch_timeline(cfg: &Config, case_id: &str) -> Result { + let path = format!("/api/v2/cases/{case_id}/timelines"); + client::raw_get(cfg, &path, &[]) + .await + .map_err(|e| anyhow::anyhow!("failed to get case timeline: {e:?}")) +} + +/// Return all `COMMENT` cells from a timeline response, preserving order. +fn comment_cells(resp: &serde_json::Value) -> Vec { + resp.get("data") + .and_then(|d| d.as_array()) + .map(|arr| { + arr.iter() + .filter(|cell| { + cell.get("attributes") + .and_then(|a| a.get("type")) + .and_then(|t| t.as_str()) + == Some("COMMENT") + }) + .cloned() + .collect() + }) + .unwrap_or_default() +} + // --------------------------------------------------------------------------- // Projects // --------------------------------------------------------------------------- @@ -543,12 +635,12 @@ mod tests { } #[tokio::test] - async fn test_cases_comment() { + async fn test_cases_comments_create() { let _lock = lock_env().await; let mut s = mockito::Server::new_async().await; let cfg = test_config(&s.url()); mock_all(&mut s, r#"{"data": {}}"#).await; - let _ = super::comment(&cfg, "case1", "hello").await; + let _ = super::comments_create(&cfg, "case1", "hello").await; cleanup_env(); } @@ -563,12 +655,112 @@ mod tests { } #[tokio::test] - async fn test_cases_delete_comment() { + async fn test_cases_comments_delete() { let _lock = lock_env().await; let mut s = mockito::Server::new_async().await; let cfg = test_config(&s.url()); mock_all(&mut s, r#"{}"#).await; - let _ = super::delete_comment(&cfg, "case1", "comment1").await; + let _ = super::comments_delete(&cfg, "case1", "comment1").await; + cleanup_env(); + } + + #[tokio::test] + async fn test_cases_comments_update() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + super::comments_update(&cfg, "case1", "comment1", "new body") + .await + .expect("update should succeed against mock"); + cleanup_env(); + } + + #[tokio::test] + async fn test_cases_timeline() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data": []}"#).await; + super::timeline(&cfg, "case1") + .await + .expect("timeline should succeed"); + cleanup_env(); + } + + #[tokio::test] + async fn test_cases_comments_list_filters_to_comment_cells() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + // Mixed-cell timeline: only the COMMENT cell should be retained. + let body = r#"{"data": [ + {"id": "a", "type": "timeline_cell", "attributes": {"type": "COMMENT", "cell_content": {"message": "hi"}}}, + {"id": "b", "type": "timeline_cell", "attributes": {"type": "ATTRIBUTE_UPDATE"}}, + {"id": "c", "type": "timeline_cell", "attributes": {"type": "CASE_CREATED"}} + ]}"#; + mock_all(&mut s, body).await; + super::comments_list(&cfg, "case1") + .await + .expect("comments_list should succeed"); + // Also verify the helper directly so the filtering contract is asserted. + let resp: serde_json::Value = serde_json::from_str(body).unwrap(); + let cells = super::comment_cells(&resp); + assert_eq!(cells.len(), 1, "only one COMMENT cell expected"); + assert_eq!(cells[0]["id"], "a"); + cleanup_env(); + } + + #[tokio::test] + async fn test_cases_comments_get_found() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + let body = r#"{"data": [ + {"id": "target", "type": "timeline_cell", "attributes": {"type": "COMMENT", "cell_content": {"message": "found me"}}} + ]}"#; + mock_all(&mut s, body).await; + super::comments_get(&cfg, "case1", "target") + .await + .expect("comments_get should find the comment"); + cleanup_env(); + } + + #[tokio::test] + async fn test_cases_comments_get_not_found() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + // Empty timeline — get should report not found. + mock_all(&mut s, r#"{"data": []}"#).await; + let err = super::comments_get(&cfg, "case1", "missing") + .await + .expect_err("missing comment should error"); + assert!( + err.to_string().contains("not found"), + "expected 'not found' in error, got: {err}" + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_cases_comments_get_skips_non_comment_cell_with_same_id() { + // If a non-COMMENT cell happens to share an id (unlikely but worth + // defending against), comments_get should ignore it and report not found. + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + let body = r#"{"data": [ + {"id": "shared", "type": "timeline_cell", "attributes": {"type": "ATTRIBUTE_UPDATE"}} + ]}"#; + mock_all(&mut s, body).await; + let err = super::comments_get(&cfg, "case1", "shared") + .await + .expect_err("non-comment cell should not match comments_get"); + assert!( + err.to_string().contains("not found"), + "expected 'not found' in error, got: {err}" + ); cleanup_env(); } diff --git a/src/main.rs b/src/main.rs index 4462dca..d5daed7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5245,19 +5245,13 @@ enum CaseActions { #[arg(long, help = "JSON file with request body (required)", conflicts_with_all = ["title", "type-id", "project-id"])] file: Option, }, - /// Add a comment to a case - Comment { - case_id: String, - #[arg(long, help = "Comment body (required)")] - body: String, - }, - /// Delete a comment from a case - #[command(name = "delete-comment")] - DeleteComment { - case_id: String, - #[arg(long, name = "comment-id", help = "Comment UUID (required)")] - comment_id: String, + /// Manage comments on a case + Comments { + #[command(subcommand)] + action: CaseCommentActions, }, + /// Get the full timeline for a case (comments, attribute updates, etc.) + Timeline { case_id: String }, /// Archive a case Archive { case_id: String }, /// Unarchive a case @@ -5319,6 +5313,38 @@ enum CaseActions { }, } +#[derive(Subcommand)] +enum CaseCommentActions { + /// List comments on a case + List { case_id: String }, + /// Get a single comment by ID + Get { + case_id: String, + #[arg(long, name = "comment-id", help = "Comment UUID (required)")] + comment_id: String, + }, + /// Create a comment on a case + Create { + case_id: String, + #[arg(long, help = "Comment body (required)")] + body: String, + }, + /// Update a comment's body + Update { + case_id: String, + #[arg(long, name = "comment-id", help = "Comment UUID (required)")] + comment_id: String, + #[arg(long, help = "New comment body (required)")] + body: String, + }, + /// Delete a comment from a case + Delete { + case_id: String, + #[arg(long, name = "comment-id", help = "Comment UUID (required)")] + comment_id: String, + }, +} + #[derive(Subcommand)] enum CaseProjectActions { /// List all projects @@ -12052,14 +12078,36 @@ async fn main_inner() -> anyhow::Result<()> { .await?; } } - CaseActions::Comment { case_id, body } => { - commands::cases::comment(&cfg, &case_id, &body).await?; - } - CaseActions::DeleteComment { - case_id, - comment_id, - } => { - commands::cases::delete_comment(&cfg, &case_id, &comment_id).await?; + CaseActions::Comments { action } => match action { + CaseCommentActions::List { case_id } => { + commands::cases::comments_list(&cfg, &case_id).await?; + } + CaseCommentActions::Get { + case_id, + comment_id, + } => { + commands::cases::comments_get(&cfg, &case_id, &comment_id).await?; + } + CaseCommentActions::Create { case_id, body } => { + commands::cases::comments_create(&cfg, &case_id, &body).await?; + } + CaseCommentActions::Update { + case_id, + comment_id, + body, + } => { + commands::cases::comments_update(&cfg, &case_id, &comment_id, &body) + .await?; + } + CaseCommentActions::Delete { + case_id, + comment_id, + } => { + commands::cases::comments_delete(&cfg, &case_id, &comment_id).await?; + } + }, + CaseActions::Timeline { case_id } => { + commands::cases::timeline(&cfg, &case_id).await?; } CaseActions::Archive { case_id } => { commands::cases::archive(&cfg, &case_id).await?;