diff --git a/examples/comment.py b/examples/comment.py new file mode 100644 index 00000000..59626ba6 --- /dev/null +++ b/examples/comment.py @@ -0,0 +1,72 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import CommentCreateOptions + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser(description="Comments demo for python-tfe SDK") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--run-id", required=True, help="Run ID (e.g. run-xxxxx)") + parser.add_argument("--create", action="store_true", help="Create a new comment") + parser.add_argument("--body", help="Comment body text (required with --create)") + parser.add_argument("--read", action="store_true", help="Read a specific comment") + parser.add_argument("--id", help="Comment ID (e.g. com-xxxxx), required for --read") + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # 1) Always list existing comments for the run + _print_header(f"Listing comments for run: {args.run_id}") + comment_count = 0 + for comment in client.comments.list(run_id=args.run_id): + comment_count += 1 + print(f"- ID: {comment.id}") + print(f" Body: {comment.body}") + print() + + if comment_count == 0: + print("No comments found.") + else: + print(f"Total: {comment_count} comments") + + # 2) Create a new comment + if args.create: + if not args.body: + print("--body is required for --create") + else: + _print_header(f"Creating a comment on run: {args.run_id}") + opts = CommentCreateOptions(body=args.body) + comment = client.comments.create(run_id=args.run_id, options=opts) + print(f"Created comment: {comment.id}") + print(f" Body: {comment.body}") + + # 3) Read a specific comment + if args.read: + if not args.id: + print("--id is required for --read") + else: + _print_header(f"Reading comment: {args.id}") + comment = client.comments.read(comment_id=args.id) + print(f"ID: {comment.id}") + print(f"Body: {comment.body}") + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 4642d9a8..4cb37fb0 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -8,6 +8,7 @@ from .resources.agent_pools import AgentPools from .resources.agents import Agents, AgentTokens from .resources.apply import Applies +from .resources.comment import Comments from .resources.configuration_version import ConfigurationVersions from .resources.notification_configuration import NotificationConfigurations from .resources.oauth_client import OAuthClients @@ -104,6 +105,7 @@ def __init__(self, config: TFEConfig | None = None): self.runs = Runs(self._transport) self.query_runs = QueryRuns(self._transport) self.run_events = RunEvents(self._transport) + self.comments = Comments(self._transport) self.policies = Policies(self._transport) self.policy_evaluations = PolicyEvaluations(self._transport) self.policy_checks = PolicyChecks(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 113dee9a..75bd9165 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -642,3 +642,18 @@ class InvalidStackConfigurationIDError(InvalidValues): def __init__(self, message: str = "invalid value for stack configuration ID"): super().__init__(message) + + +# Comment errors +class InvalidCommentIDError(InvalidValues): + """Raised when an invalid comment ID is provided.""" + + def __init__(self, message: str = "invalid value for comment ID"): + super().__init__(message) + + +class RequiredCommentBodyError(TFEError): + """Raised when comment body is empty or missing.""" + + def __init__(self, message: str = "comment body is required"): + super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index d2e8648c..354bdf16 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -21,6 +21,10 @@ AgentTokenCreateOptions, AgentTokenListOptions, ) +from .comment import ( + Comment, + CommentCreateOptions, +) # ── Core models split out of old types.py ───────────────────────────────────── # Adjust these imports to match where you placed them during the split. @@ -642,6 +646,9 @@ "RunEventList", "RunEventListOptions", "RunEventReadOptions", + # Comments + "Comment", + "CommentCreateOptions", # Run tasks "RunTask", "RunTaskIncludeOptions", diff --git a/src/pytfe/models/comment.py b/src/pytfe/models/comment.py index 19cc25ca..8bc0d110 100644 --- a/src/pytfe/models/comment.py +++ b/src/pytfe/models/comment.py @@ -3,7 +3,10 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import RequiredCommentBodyError +from ..utils import valid_string class Comment(BaseModel): @@ -11,3 +14,17 @@ class Comment(BaseModel): id: str body: str = Field(default="", alias="body") + + +class CommentCreateOptions(BaseModel): + """Options for creating a comment on a run.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + body: str = Field(alias="body") + + @model_validator(mode="after") + def valid(self) -> CommentCreateOptions: + if not valid_string(self.body): + raise RequiredCommentBodyError() + return self diff --git a/src/pytfe/resources/comment.py b/src/pytfe/resources/comment.py new file mode 100644 index 00000000..e079366a --- /dev/null +++ b/src/pytfe/resources/comment.py @@ -0,0 +1,54 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ..errors import InvalidCommentIDError, InvalidRunIDError +from ..models.comment import Comment, CommentCreateOptions +from ..utils import valid_string_id +from ._base import _Service + + +class Comments(_Service): + """Service for managing run comments.""" + + def list(self, run_id: str) -> Iterator[Comment]: + """List all comments for the given run.""" + if not valid_string_id(run_id): + raise InvalidRunIDError() + path = f"/api/v2/runs/{run_id}/comments" + for item in self._list(path=path): + yield self._comment_from(item) + + def read(self, comment_id: str) -> Comment: + """Read a comment by its ID.""" + if not valid_string_id(comment_id): + raise InvalidCommentIDError() + r = self.t.request("GET", path=f"/api/v2/comments/{comment_id}") + data = r.json().get("data", {}) + return self._comment_from(data) + + def create(self, run_id: str, options: CommentCreateOptions) -> Comment: + """Create a new comment on the given run.""" + if not valid_string_id(run_id): + raise InvalidRunIDError() + payload = { + "data": { + "type": "comments", + "attributes": options.model_dump(by_alias=True, exclude_none=True), + } + } + r = self.t.request( + "POST", path=f"/api/v2/runs/{run_id}/comments", json_body=payload + ) + data = r.json().get("data", {}) + return self._comment_from(data) + + def _comment_from(self, data: dict[str, Any]) -> Comment: + """Parse a Comment from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + return Comment.model_validate(attrs) diff --git a/tests/units/test_comment.py b/tests/units/test_comment.py new file mode 100644 index 00000000..8e6d5b9a --- /dev/null +++ b/tests/units/test_comment.py @@ -0,0 +1,164 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the comment module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidCommentIDError, + InvalidRunIDError, + RequiredCommentBodyError, +) +from pytfe.models.comment import Comment, CommentCreateOptions +from pytfe.resources.comment import Comments + + +class TestComments: + """Test the Comments service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + """Create a Comments service with mocked transport.""" + return Comments(mock_transport) + + @pytest.fixture + def comment_api_data(self): + """Typical API response for a single comment.""" + return { + "id": "com-abc123", + "type": "comments", + "attributes": { + "body": "This is a test comment.", + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_create_options_valid(self): + """CommentCreateOptions accepts a valid body.""" + opts = CommentCreateOptions(body="Hello world") + assert opts.body == "Hello world" + + def test_create_options_empty_body_raises(self): + """CommentCreateOptions raises RequiredCommentBodyError when body is empty.""" + with pytest.raises(RequiredCommentBodyError): + CommentCreateOptions(body="") + + def test_create_options_serializes_with_alias(self): + """CommentCreateOptions serialises using the API alias.""" + opts = CommentCreateOptions(body="My comment") + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"body": "My comment"} + + def test_comment_model_fields(self): + """Comment model stores id and body.""" + c = Comment(id="com-123", body="test") + assert c.id == "com-123" + assert c.body == "test" + + def test_comment_model_default_body(self): + """Comment body defaults to empty string.""" + c = Comment(id="com-123") + assert c.body == "" + + # ── Parser tests ───────────────────────────────────────────────────────── + + def test_comment_from_full_data(self, service, comment_api_data): + """_comment_from parses id and body from API data.""" + result = service._comment_from(comment_api_data) + + assert isinstance(result, Comment) + assert result.id == "com-abc123" + assert result.body == "This is a test comment." + + def test_comment_from_missing_body(self, service): + """_comment_from handles missing body attribute gracefully.""" + data = {"id": "com-xyz", "attributes": {}} + result = service._comment_from(data) + + assert result.id == "com-xyz" + assert result.body == "" + + # ── Resource method tests ───────────────────────────────────────────────── + + def test_list_success(self, service, comment_api_data): + """list() yields Comment objects from paginated results.""" + service._list = Mock(return_value=[comment_api_data]) + + results = list(service.list(run_id="run-abc123")) + + service._list.assert_called_once_with(path="/api/v2/runs/run-abc123/comments") + assert len(results) == 1 + assert isinstance(results[0], Comment) + assert results[0].id == "com-abc123" + assert results[0].body == "This is a test comment." + + def test_list_empty(self, service): + """list() returns empty iterator when no comments exist.""" + service._list = Mock(return_value=[]) + + results = list(service.list(run_id="run-abc123")) + assert results == [] + + def test_list_invalid_run_id(self, service): + """list() raises InvalidRunIDError for a bad run ID.""" + with pytest.raises(InvalidRunIDError): + list(service.list(run_id="not valid!")) + + def test_read_success(self, service, mock_transport, comment_api_data): + """read() GETs the correct path and returns a Comment.""" + mock_response = Mock() + mock_response.json.return_value = {"data": comment_api_data} + mock_transport.request.return_value = mock_response + + result = service.read(comment_id="com-abc123") + + mock_transport.request.assert_called_once_with( + "GET", path="/api/v2/comments/com-abc123" + ) + assert isinstance(result, Comment) + assert result.id == "com-abc123" + assert result.body == "This is a test comment." + + def test_read_invalid_comment_id(self, service): + """read() raises InvalidCommentIDError for a bad comment ID.""" + with pytest.raises(InvalidCommentIDError): + service.read(comment_id="not valid!") + + def test_create_success(self, service, mock_transport, comment_api_data): + """create() POSTs the correct payload and returns a Comment.""" + mock_response = Mock() + mock_response.json.return_value = {"data": comment_api_data} + mock_transport.request.return_value = mock_response + + opts = CommentCreateOptions(body="This is a test comment.") + result = service.create(run_id="run-abc123", options=opts) + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/runs/run-abc123/comments", + json_body={ + "data": { + "type": "comments", + "attributes": {"body": "This is a test comment."}, + } + }, + ) + assert isinstance(result, Comment) + assert result.id == "com-abc123" + assert result.body == "This is a test comment." + + def test_create_invalid_run_id(self, service): + """create() raises InvalidRunIDError for a bad run ID.""" + opts = CommentCreateOptions(body="Hello") + with pytest.raises(InvalidRunIDError): + service.create(run_id="not valid!", options=opts)