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
38 changes: 38 additions & 0 deletions limacharlie/commands/case_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,44 @@ def add_note(ctx, case_number, content, input_file, note_type, is_public) -> Non
_output(ctx, data)


# ---------------------------------------------------------------------------
# log-time
# ---------------------------------------------------------------------------

@group.command("log-time")
@click.option("--case-number", "case_number", required=True, type=int, help="Case number.")
@click.option("--seconds", "billable_seconds", required=True, type=int,
help="Billable human time spent, in seconds (must be positive).")
@click.option("--cost-profile", default=None,
help="ai_cost_model profile key to value this time at (optional).")
@click.option("--assignee", default=None, help="Analyst the time is attributed to (optional).")
@click.option("--note", default=None, help="Free-text description of the work (optional).")
@pass_context
def log_time(ctx, case_number, billable_seconds, cost_profile, assignee, note) -> None:
"""Log billable human time spent on a case.

Feeds the AI cost/savings model: logged human time nets down the savings
credited to AI. Tag it with a cost profile to value it at that profile's
rate.

Examples:
limacharlie case log-time --case-number 42 --seconds 1800
limacharlie case log-time --case-number 42 --seconds 3600 \\
--cost-profile ir-analyst --note "Containment + eradication"
"""
if billable_seconds <= 0:
raise click.UsageError("--seconds must be a positive integer.")
t = _get_cases(ctx)
data = t.log_time(
case_number,
billable_seconds,
cost_profile=cost_profile,
assignee=assignee,
note=note,
)
_output(ctx, data)


# ---------------------------------------------------------------------------
# update-note
# ---------------------------------------------------------------------------
Expand Down
36 changes: 36 additions & 0 deletions limacharlie/sdk/cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,42 @@ def add_note(
body=body,
)

def log_time(
self,
case_number: int,
billable_seconds: int,
cost_profile: str | None = None,
assignee: str | None = None,
note: str | None = None,
) -> dict[str, Any]:
"""Log billable human time spent on a case.

This is the measured human-effort input to the AI cost/savings model:
logged human time nets down the savings credited to AI.

Args:
case_number: Case number.
billable_seconds: Human time spent, in seconds (must be positive).
cost_profile: Optional ``ai_cost_model`` profile key, so this time
is valued at that profile's rate (otherwise the report values it
at the selected profile's rate).
assignee: Optional analyst the time is attributed to.
note: Optional free-text description of the work.
"""
body: dict[str, Any] = {"billable_seconds": billable_seconds}
if cost_profile:
body["cost_profile"] = cost_profile
if assignee:
body["assignee"] = assignee
if note:
body["note"] = note
return self._request(
"POST",
f"cases/{case_number}/time-logs",
query_params={"oid": self.oid},
body=body,
)

def update_note_visibility(
self,
case_number: int,
Expand Down
49 changes: 47 additions & 2 deletions tests/unit/test_cli_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def _invoke(args, mock_cases_cls, return_value=None):
for name in [
"create_case",
"list_cases", "get_case", "export_case",
"update_case", "add_note", "update_note_visibility",
"update_case", "add_note", "log_time", "update_note_visibility",
"bulk_update", "merge",
"list_detections", "add_detection", "remove_detection",
"list_entities", "add_entity", "update_entity", "remove_entity",
Expand Down Expand Up @@ -63,7 +63,7 @@ def test_case_group_help(self):
assert result.exit_code == 0
assert "Manage SOC cases" in result.output
for cmd in ["create", "list", "get", "export", "update", "add-note",
"update-note", "merge",
"log-time", "update-note", "merge",
"entity", "telemetry", "artifact", "detection", "tag",
"report", "dashboard", "config-get", "config-set",
"assignees", "orgs", "bulk-update"]:
Expand Down Expand Up @@ -705,6 +705,51 @@ def test_add_note_invalid_type_rejected(self):
assert result.exit_code != 0


# ---------------------------------------------------------------------------
# case log-time
# ---------------------------------------------------------------------------


class TestCaseLogTime:
def test_log_time_minimal(self):
p1, p2, p3 = _patch_cases()
with p1, p2, p3 as mock_t_cls:
result, mock_t = _invoke(
["case", "log-time", "--case-number", "42", "--seconds", "1800"],
mock_t_cls,
return_value={},
)
assert result.exit_code == 0
mock_t.log_time.assert_called_once_with(
42, 1800, cost_profile=None, assignee=None, note=None
)

def test_log_time_with_options(self):
p1, p2, p3 = _patch_cases()
with p1, p2, p3 as mock_t_cls:
result, mock_t = _invoke(
["case", "log-time", "--case-number", "42", "--seconds", "3600",
"--cost-profile", "ir-analyst", "--assignee", "a@corp", "--note", "work"],
mock_t_cls,
return_value={},
)
assert result.exit_code == 0
mock_t.log_time.assert_called_once_with(
42, 3600, cost_profile="ir-analyst", assignee="a@corp", note="work"
)

def test_log_time_rejects_non_positive_seconds(self):
p1, p2, p3 = _patch_cases()
with p1, p2, p3 as mock_t_cls:
result, mock_t = _invoke(
["case", "log-time", "--case-number", "42", "--seconds", "0"],
mock_t_cls,
return_value={},
)
assert result.exit_code != 0
mock_t.log_time.assert_not_called()


# ---------------------------------------------------------------------------
# case update-note
# ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_cli_lazy_loading_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
"case": frozenset({
"add-note", "artifact", "assignees", "bulk-update",
"config-get", "config-set", "create", "dashboard", "detection",
"entity", "export", "get", "list", "merge", "orgs", "report",
"entity", "export", "get", "list", "log-time", "merge", "orgs", "report",
"tag", "telemetry", "update", "update-note",
}),
"cloud-adapter": frozenset({"delete", "disable", "enable", "get", "list", "list-types", "schema", "sensors", "set", "tag"}),
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/test_sdk_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,3 +823,33 @@ def test_post_sends_json(self, cases, mock_org):
assert isinstance(kwargs["raw_body"], bytes)
parsed = json.loads(kwargs["raw_body"])
assert isinstance(parsed, dict)


class TestLogTime:
def test_minimal(self, cases, mock_org):
mock_org.client.request.return_value = {}
cases.log_time(42, 1800)
args, kwargs = _extract_call(mock_org)
assert args == ("POST", "api/v1/cases/42/time-logs")
assert kwargs["query_params"] == {"oid": "test-oid"}
assert json.loads(kwargs["raw_body"]) == {"billable_seconds": 1800}

def test_with_all_optionals(self, cases, mock_org):
mock_org.client.request.return_value = {}
cases.log_time(
42, 3600, cost_profile="ir-analyst", assignee="a@corp", note="eradication"
)
assert _extract_body(mock_org) == {
"billable_seconds": 3600,
"cost_profile": "ir-analyst",
"assignee": "a@corp",
"note": "eradication",
}

def test_none_optionals_excluded(self, cases, mock_org):
mock_org.client.request.return_value = {}
cases.log_time(42, 600, cost_profile=None, assignee=None, note=None)
body = _extract_body(mock_org)
assert body == {"billable_seconds": 600}
for k in ("cost_profile", "assignee", "note"):
assert k not in body