Skip to content
Open
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
56 changes: 42 additions & 14 deletions agents/case-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <case-id> --body "..."`)
- **Delete Comment**: Remove a comment by ID (`pup cases delete-comment <case-id> --comment-id <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 <case-id>`)
- **Get Comment**: Read a single comment by ID (`pup cases comments get <case-id> --comment-id <id>`)
- **Create Comment**: Post a comment to a case (`pup cases comments create <case-id> --body "..."`)
- **Update Comment**: Edit a comment's body (`pup cases comments update <case-id> --comment-id <id> --body "..."`)
- **Delete Comment**: Remove a comment by ID (`pup cases comments delete <case-id> --comment-id <id>`)

### Timeline
- **Get Timeline**: Fetch the full activity feed for a case — comments, attribute changes, status transitions (`pup cases timeline <case-id>`)

### Projects
- **List / Get / Create / Delete / Update**: Manage the case projects that group cases together
Expand Down Expand Up @@ -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 <case-id>` (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 "<text>"` 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 <case-id> | 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
Expand Down Expand Up @@ -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"
Expand All @@ -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 <case-id>`.

## 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 <case-id>` 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:<uuid>` 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

Expand Down
4 changes: 2 additions & 2 deletions agents/incident-response.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ pup cases create \
--project-id "<project-uuid>"

# Track investigation progress
pup cases comment <case-id> --body "Investigation update: ..."
pup cases comments create <case-id> --body "Investigation update: ..."
pup cases update-status <case-id> --status IN_PROGRESS

# Close out after resolution
Expand Down Expand Up @@ -521,7 +521,7 @@ pup cases assign CASE-XXX --user-id <user-uuid>
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 <page-id>
Expand Down
204 changes: 198 additions & 6 deletions src/commands/cases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use datadog_api_client::datadogV2::model::{
ServiceNowTicketCreateRequest,
};

use crate::client;
use crate::config::Config;
use crate::formatter;

Expand Down Expand Up @@ -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()),
Expand All @@ -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
Expand All @@ -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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add it to the spec internally?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't that usually up to the team that owns the API? I'll flag it with them (they were the ones that told me it existed in the first place, as well as PUT on a comment)

// 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<serde_json::Value> {
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<serde_json::Value> {
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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();
}

Expand All @@ -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();
}

Expand Down
Loading
Loading