diff --git a/limacharlie/commands/case_cmd.py b/limacharlie/commands/case_cmd.py index cc2dd723..f397ec12 100644 --- a/limacharlie/commands/case_cmd.py +++ b/limacharlie/commands/case_cmd.py @@ -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 # --------------------------------------------------------------------------- diff --git a/limacharlie/sdk/cases.py b/limacharlie/sdk/cases.py index b2923dbb..30bdb315 100644 --- a/limacharlie/sdk/cases.py +++ b/limacharlie/sdk/cases.py @@ -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, diff --git a/tests/unit/test_cli_case.py b/tests/unit/test_cli_case.py index 363d1c4f..fd02001a 100644 --- a/tests/unit/test_cli_case.py +++ b/tests/unit/test_cli_case.py @@ -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", @@ -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"]: @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index 8115210b..88689e4b 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -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"}), diff --git a/tests/unit/test_sdk_cases.py b/tests/unit/test_sdk_cases.py index 91f705d1..f4d278aa 100644 --- a/tests/unit/test_sdk_cases.py +++ b/tests/unit/test_sdk_cases.py @@ -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