diff --git a/docs/user/api/v3.rst b/docs/user/api/v3.rst index 65600f8b0df..82da78f0b80 100644 --- a/docs/user/api/v3.rst +++ b/docs/user/api/v3.rst @@ -744,6 +744,21 @@ Build details "success": true, "error": null, "commit": "6f808d743fd6f6907ad3e2e969c88a549e76db30", + "commit_url": "https://github.com/pypa/pip/commit/6f808d743fd6f6907ad3e2e969c88a549e76db30", + "docs_url": "https://pip.readthedocs.io/en/latest/", + "builder": "build-default-6fccbf5cb-xtzkc", + "commands": [ + { + "id": 1, + "command": "git clone --depth 1 https://github.com/pypa/pip .", + "description": "Clone repository", + "output": "Cloning into '.'...", + "exit_code": 0, + "start_time": "2018-06-19T15:16:01+00:00", + "end_time": "2018-06-19T15:16:05+00:00", + "run_time": 4 + } + ], "config": { "version": "1", "formats": [ @@ -791,9 +806,22 @@ Build details :>json integer duration: The length of the build in seconds. :>json string state: The state of the build (one of ``triggered``, ``building``, ``installing``, ``cloning``, ``finished`` or ``cancelled``) :>json string error: An error message if the build was unsuccessful + :>json string commit_url: URL to the commit in the version control system + :>json string docs_url: URL to the built documentation for this build + :>json string builder: Identifier of the builder instance that executed this build (useful for debugging) + :>json array commands: List of build commands executed during the build process. Each command includes: + + * ``id`` - Command identifier + * ``command`` - The actual command that was executed + * ``description`` - Human-readable description of the command + * ``output`` - Output from the command execution + * ``exit_code`` - Exit code of the command (0 for success) + * ``start_time`` - ISO-8601 datetime when the command started + * ``end_time`` - ISO-8601 datetime when the command finished + * ``run_time`` - Duration of the command in seconds :query string expand: Add additional fields in the response. - Allowed value is ``config``. + Allowed values are ``config`` and ``notifications``. Builds listing diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py index be78b46b8f0..bb850a32b1f 100644 --- a/readthedocs/api/v3/serializers.py +++ b/readthedocs/api/v3/serializers.py @@ -15,6 +15,7 @@ from readthedocs.builds.constants import LATEST from readthedocs.builds.constants import STABLE from readthedocs.builds.models import Build +from readthedocs.builds.models import BuildCommandResult from readthedocs.builds.models import Version from readthedocs.core.permissions import AdminPermission from readthedocs.core.resolver import Resolver @@ -143,6 +144,25 @@ def get_version(self, obj): return None +class BuildCommandSerializer(serializers.ModelSerializer): + """Serializer for BuildCommandResult objects.""" + + run_time = serializers.ReadOnlyField() + + class Meta: + model = BuildCommandResult + fields = [ + "id", + "command", + "description", + "output", + "exit_code", + "start_time", + "end_time", + "run_time", + ] + + class BuildConfigSerializer(FlexFieldsSerializerMixin, serializers.Serializer): """ Render ``Build.config`` property without modifying it. @@ -168,6 +188,13 @@ def get_name(self, obj): class BuildSerializer(FlexFieldsModelSerializer): + """ + Serializer for Build objects. + + Includes build commands, documentation URL, commit URL, and builder information. + Supports expanding ``config`` and ``notifications`` via the ``?expand=`` query parameter. + """ + project = serializers.SlugRelatedField(slug_field="slug", read_only=True) version = serializers.SlugRelatedField(slug_field="slug", read_only=True) created = serializers.DateTimeField(source="date") @@ -177,6 +204,10 @@ class BuildSerializer(FlexFieldsModelSerializer): state = BuildStateSerializer(source="*") _links = BuildLinksSerializer(source="*") urls = BuildURLsSerializer(source="*") + commands = BuildCommandSerializer(many=True, read_only=True) + docs_url = serializers.SerializerMethodField() + commit_url = serializers.ReadOnlyField(source="get_commit_url") + builder = serializers.CharField(read_only=True) class Meta: model = Build @@ -191,11 +222,21 @@ class Meta: "success", "error", "commit", + "commit_url", + "docs_url", + "builder", + "commands", "_links", "urls", ] - expandable_fields = {"config": (BuildConfigSerializer,)} + expandable_fields = { + "config": (BuildConfigSerializer,), + "notifications": ( + "readthedocs.api.v3.serializers.NotificationSerializer", + {"many": True}, + ), + } def get_finished(self, obj): if obj.date and obj.length: @@ -212,6 +253,12 @@ def get_success(self, obj): return None + def get_docs_url(self, obj): + """Return the URL to the documentation built by this build.""" + if obj.version: + return obj.version.get_absolute_url() + return None + class NotificationMessageSerializer(serializers.Serializer): id = serializers.SlugField() diff --git a/readthedocs/api/v3/tests/mixins.py b/readthedocs/api/v3/tests/mixins.py index 0b0d2293c92..870a7926d83 100644 --- a/readthedocs/api/v3/tests/mixins.py +++ b/readthedocs/api/v3/tests/mixins.py @@ -13,7 +13,7 @@ from rest_framework.test import APIClient from readthedocs.builds.constants import LATEST, TAG -from readthedocs.builds.models import Build, Version +from readthedocs.builds.models import Build, BuildCommandResult, Version from readthedocs.core.notifications import MESSAGE_EMAIL_VALIDATION_PENDING from readthedocs.doc_builder.exceptions import BuildCancelled from readthedocs.notifications.models import Notification @@ -112,6 +112,18 @@ def setUp(self): length=60, ) + # Create some build commands for testing + self.build_command = fixture.get( + BuildCommandResult, + build=self.build, + command="python setup.py install", + description="Install", + output="Successfully installed", + exit_code=0, + start_time=self.created, + end_time=self.created + datetime.timedelta(seconds=5), + ) + self.other = fixture.get(User, projects=[]) self.others_token = fixture.get(Token, key="other", user=self.other) self.others_project = fixture.get( diff --git a/readthedocs/api/v3/tests/responses/projects-builds-detail.json b/readthedocs/api/v3/tests/responses/projects-builds-detail.json index 996bcef6e61..9e77e4194f0 100644 --- a/readthedocs/api/v3/tests/responses/projects-builds-detail.json +++ b/readthedocs/api/v3/tests/responses/projects-builds-detail.json @@ -1,10 +1,25 @@ { "commit": "a1b2c3", + "commit_url": "", "created": "2019-04-29T10:00:00Z", + "docs_url": "http://project.readthedocs.io/en/v1.0/", "duration": 60, "error": "", "finished": "2019-04-29T10:01:00Z", "id": 1, + "builder": "builder01", + "commands": [ + { + "id": 1, + "command": "python setup.py install", + "description": "Install", + "output": "Successfully installed", + "exit_code": 0, + "start_time": "2019-04-29T10:00:00Z", + "end_time": "2019-04-29T10:00:05Z", + "run_time": 5 + } + ], "_links": { "_self": "https://readthedocs.org/api/v3/projects/project/builds/1/", "project": "https://readthedocs.org/api/v3/projects/project/", diff --git a/readthedocs/api/v3/tests/responses/projects-builds-list.json b/readthedocs/api/v3/tests/responses/projects-builds-list.json index df60e9912fe..ae730787ebb 100644 --- a/readthedocs/api/v3/tests/responses/projects-builds-list.json +++ b/readthedocs/api/v3/tests/responses/projects-builds-list.json @@ -5,11 +5,26 @@ "results": [ { "commit": "a1b2c3", + "commit_url": "", "created": "2019-04-29T10:00:00Z", + "docs_url": "http://project.readthedocs.io/en/v1.0/", "duration": 60, "error": "", "finished": "2019-04-29T10:01:00Z", "id": 1, + "builder": "builder01", + "commands": [ + { + "id": 1, + "command": "python setup.py install", + "description": "Install", + "output": "Successfully installed", + "exit_code": 0, + "start_time": "2019-04-29T10:00:00Z", + "end_time": "2019-04-29T10:00:05Z", + "run_time": 5 + } + ], "_links": { "_self": "https://readthedocs.org/api/v3/projects/project/builds/1/", "project": "https://readthedocs.org/api/v3/projects/project/", diff --git a/readthedocs/api/v3/tests/test_builds.py b/readthedocs/api/v3/tests/test_builds.py index 37d451133fb..1b875d6a7d3 100644 --- a/readthedocs/api/v3/tests/test_builds.py +++ b/readthedocs/api/v3/tests/test_builds.py @@ -675,3 +675,79 @@ def test_projects_builds_notifitications_detail_post(self): response = self.client.patch(url, data) self.assertEqual(response.status_code, 204) self.assertEqual(self.build.notifications.first().state, "read") + + def test_projects_builds_detail_has_new_fields(self): + """Test that build detail includes commands, docs_url, commit_url, and builder fields.""" + url = reverse( + "projects-builds-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "build_pk": self.build.pk, + }, + ) + + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.json() + + # Check new fields exist + self.assertIn("commands", data) + self.assertIn("docs_url", data) + self.assertIn("commit_url", data) + self.assertIn("builder", data) + + # Validate field values + self.assertEqual(data["builder"], "builder01") + self.assertEqual(data["docs_url"], "http://project.readthedocs.io/en/v1.0/") + self.assertEqual(data["commit_url"], "") + self.assertEqual(len(data["commands"]), 1) + self.assertEqual(data["commands"][0]["command"], "python setup.py install") + + def test_projects_builds_detail_expand_config(self): + """Test that build detail can expand config.""" + url = reverse( + "projects-builds-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "build_pk": self.build.pk, + }, + ) + url += "?expand=config" + + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.json() + + # Check config is expanded + self.assertIn("config", data) + self.assertEqual(data["config"]["property"], "test value") + + def test_projects_builds_detail_expand_notifications(self): + """Test that build detail can expand notifications.""" + url = reverse( + "projects-builds-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "build_pk": self.build.pk, + }, + ) + url += "?expand=notifications" + + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.json() + + # Check notifications are expanded + self.assertIn("notifications", data) + self.assertIsInstance(data["notifications"], list) + # We should have the notification created in the test setup + self.assertEqual(len(data["notifications"]), 1) diff --git a/readthedocs/api/v3/views.py b/readthedocs/api/v3/views.py index 93efbce5716..0a38192c1de 100644 --- a/readthedocs/api/v3/views.py +++ b/readthedocs/api/v3/views.py @@ -399,6 +399,7 @@ class BuildsViewSet( permission_classes = [ReadOnlyPermission | (IsAuthenticated & IsProjectAdmin)] permit_list_expands = [ "config", + "notifications", ]