From 7efe9b7ef7290cafd0f0d8c846e0882a6ebd6d76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:41:02 +0000 Subject: [PATCH 1/5] Initial plan From e0b476174b8f2757a9e7584053af9dc2da3ea1cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:52:25 +0000 Subject: [PATCH 2/5] Add BuildCommand support and missing fields to API v3 Build endpoint - Add BuildCommandSerializer for build command results - Add commands field to BuildSerializer - Add docs_url field to BuildSerializer - Add commit_url field to BuildSerializer - Add builder field to BuildSerializer - Enable ?expand=notifications for Build endpoint - Add comprehensive tests for new fields and expand functionality - Update test fixtures to include new fields Co-authored-by: ericholscher <25510+ericholscher@users.noreply.github.com> --- readthedocs/api/v3/serializers.py | 49 +++++++++++- readthedocs/api/v3/tests/mixins.py | 14 +++- .../responses/projects-builds-detail.json | 15 ++++ .../tests/responses/projects-builds-list.json | 15 ++++ readthedocs/api/v3/tests/test_builds.py | 76 +++++++++++++++++++ readthedocs/api/v3/views.py | 1 + 6 files changed, 168 insertions(+), 2 deletions(-) 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", ] From e8f474e70c6da4ab59ec3d4e11240e329291ec73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:55:24 +0000 Subject: [PATCH 3/5] Update API v3 design documentation with build endpoint enhancements Co-authored-by: ericholscher <25510+ericholscher@users.noreply.github.com> --- docs/dev/design/apiv3.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/dev/design/apiv3.rst b/docs/dev/design/apiv3.rst index ce24f26774e..a91a3101635 100644 --- a/docs/dev/design/apiv3.rst +++ b/docs/dev/design/apiv3.rst @@ -125,6 +125,14 @@ This iteration will include: * Trigger Build for default version * Allow CRUD for Redirect, Environment Variables and Notifications (``WebHook`` and ``EmailHook``) * Create/Delete a Project as subproject of another Project +* Build endpoint enhancements: + + * Build commands (``commands`` field) + * Documentation URL (``docs_url`` field) + * Commit URL (``commit_url`` field) + * Builder information (``builder`` field) + * Expandable notifications (``?expand=notifications``) + * Documentation From 3021e5adfee45cdf90772f36be746afb27b9e6cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:05:14 +0000 Subject: [PATCH 4/5] Update public REST API docs instead of design docs - Revert changes to docs/dev/design/apiv3.rst (design docs) - Add new Build endpoint fields to docs/user/api/v3.rst (public API docs) - Document commands, commit_url, docs_url, builder fields - Update expand parameter to include notifications option Co-authored-by: ericholscher <25510+ericholscher@users.noreply.github.com> --- docs/dev/design/apiv3.rst | 8 -------- docs/user/api/v3.rst | 30 +++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/dev/design/apiv3.rst b/docs/dev/design/apiv3.rst index a91a3101635..ce24f26774e 100644 --- a/docs/dev/design/apiv3.rst +++ b/docs/dev/design/apiv3.rst @@ -125,14 +125,6 @@ This iteration will include: * Trigger Build for default version * Allow CRUD for Redirect, Environment Variables and Notifications (``WebHook`` and ``EmailHook``) * Create/Delete a Project as subproject of another Project -* Build endpoint enhancements: - - * Build commands (``commands`` field) - * Documentation URL (``docs_url`` field) - * Commit URL (``commit_url`` field) - * Builder information (``builder`` field) - * Expandable notifications (``?expand=notifications``) - * Documentation 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 From 7e20afea0950862371b3bf069e47dda9396f8a50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Nov 2025 17:17:45 +0000 Subject: [PATCH 5/5] Refactor cold storage logic into common utility function - Created get_commands_from_cold_storage() utility in api/v2/utils.py - Refactored API v2 BuildViewSet.retrieve() to use the common utility - Added retrieve() method to API v3 BuildsViewSet using the same utility - This ensures both APIs handle cold storage builds consistently Co-authored-by: ericholscher <25510+ericholscher@users.noreply.github.com> --- readthedocs/api/v2/utils.py | 41 +++++++++++++++++++++++++ readthedocs/api/v2/views/model_views.py | 30 +++++------------- readthedocs/api/v3/views.py | 23 ++++++++++++++ 3 files changed, 71 insertions(+), 23 deletions(-) diff --git a/readthedocs/api/v2/utils.py b/readthedocs/api/v2/utils.py index c5a0bc8bfee..a4dccda113f 100644 --- a/readthedocs/api/v2/utils.py +++ b/readthedocs/api/v2/utils.py @@ -1,6 +1,7 @@ """Utility functions that are used by both views and celery tasks.""" import itertools +import json import re import structlog @@ -17,6 +18,7 @@ from readthedocs.builds.constants import TAG from readthedocs.builds.models import RegexAutomationRule from readthedocs.builds.models import Version +from readthedocs.storage import build_commands_storage log = structlog.get_logger(__name__) @@ -287,3 +289,42 @@ class RemoteProjectPagination(PageNumberPagination): class ProjectPagination(PageNumberPagination): page_size = 100 max_page_size = 1000 + + +def get_commands_from_cold_storage(build_instance): + """ + Retrieve build commands from cold storage if available. + + :param build_instance: Build instance to retrieve commands for + :returns: List of command dictionaries with normalized commands, or None if not available + """ + if not build_instance.cold_storage: + return None + + storage_path = "{date}/{id}.json".format( + date=str(build_instance.date.date()), + id=build_instance.id, + ) + + if not build_commands_storage.exists(storage_path): + return None + + try: + json_resp = build_commands_storage.open(storage_path).read() + commands = json.loads(json_resp) + + # Normalize commands in the same way as when returning them using the serializer + for buildcommand in commands: + buildcommand["command"] = normalize_build_command( + buildcommand["command"], + build_instance.project.slug, + build_instance.get_version_slug(), + ) + + return commands + except Exception: + log.exception( + "Failed to read build data from storage.", + path=storage_path, + ) + return None diff --git a/readthedocs/api/v2/views/model_views.py b/readthedocs/api/v2/views/model_views.py index b0ee7dff1cf..1846c5ec6c4 100644 --- a/readthedocs/api/v2/views/model_views.py +++ b/readthedocs/api/v2/views/model_views.py @@ -58,6 +58,7 @@ from ..serializers import SocialAccountSerializer from ..serializers import VersionAdminSerializer from ..serializers import VersionSerializer +from ..utils import get_commands_from_cold_storage from ..utils import ProjectPagination from ..utils import RemoteOrganizationPagination from ..utils import RemoteProjectPagination @@ -342,29 +343,12 @@ def retrieve(self, *args, **kwargs): instance = self.get_object() serializer = self.get_serializer(instance) data = serializer.data - if instance.cold_storage: - storage_path = "{date}/{id}.json".format( - date=str(instance.date.date()), - id=instance.id, - ) - if build_commands_storage.exists(storage_path): - try: - json_resp = build_commands_storage.open(storage_path).read() - data["commands"] = json.loads(json_resp) - - # Normalize commands in the same way than when returning - # them using the serializer - for buildcommand in data["commands"]: - buildcommand["command"] = normalize_build_command( - buildcommand["command"], - instance.project.slug, - instance.get_version_slug(), - ) - except Exception: - log.exception( - "Failed to read build data from storage.", - path=storage_path, - ) + + # Load commands from cold storage if available + commands_from_storage = get_commands_from_cold_storage(instance) + if commands_from_storage is not None: + data["commands"] = commands_from_storage + return Response(data) @decorators.action( diff --git a/readthedocs/api/v3/views.py b/readthedocs/api/v3/views.py index 0a38192c1de..f067944ab24 100644 --- a/readthedocs/api/v3/views.py +++ b/readthedocs/api/v3/views.py @@ -1,4 +1,5 @@ import django_filters.rest_framework as filters +from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Exists @@ -28,6 +29,7 @@ from rest_framework_extensions.mixins import NestedViewSetMixin from readthedocs.api.v2.permissions import ReadOnlyPermission +from readthedocs.api.v2.utils import get_commands_from_cold_storage from readthedocs.builds.constants import EXTERNAL from readthedocs.builds.models import Build from readthedocs.builds.models import Version @@ -402,6 +404,27 @@ class BuildsViewSet( "notifications", ] + def retrieve(self, request, *args, **kwargs): + """ + Retrieve build details with commands from cold storage if available. + + This uses files from storage to get the JSON for cold storage builds, + and replaces the ``commands`` part of the response data. + """ + if not settings.RTD_SAVE_BUILD_COMMANDS_TO_STORAGE: + return super().retrieve(request, *args, **kwargs) + + instance = self.get_object() + serializer = self.get_serializer(instance) + data = serializer.data + + # Load commands from cold storage if available + commands_from_storage = get_commands_from_cold_storage(instance) + if commands_from_storage is not None: + data["commands"] = commands_from_storage + + return Response(data) + class BuildsCreateViewSet(BuildsViewSet, CreateModelMixin): def get_serializer_class(self):