diff --git a/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py b/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py index 7c4cc35e91a17a..307a6d250dcec1 100644 --- a/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py +++ b/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py @@ -78,6 +78,7 @@ def validate_preprod_artifact_schema(request_body: bytes) -> tuple[dict[str, Any # Optional metadata "build_configuration": {"type": "string"}, "release_notes": {"type": "string"}, + "date_built": {"type": "string"}, # VCS parameters - allow empty strings to support clearing auto-filled values "head_sha": {"type": "string", "pattern": "^(|[0-9a-f]{40})$"}, "base_sha": {"type": "string", "pattern": "^(|[0-9a-f]{40})$"}, @@ -97,6 +98,7 @@ def validate_preprod_artifact_schema(request_body: bytes) -> tuple[dict[str, Any "chunks": "The chunks field is required and must be provided as an array of 40-character hexadecimal strings.", "build_configuration": "The build_configuration field must be a string.", "release_notes": "The release_notes field msut be a string.", + "date_built": "The date_built field must be an ISO 8601 formatted date-time string.", "head_sha": "The head_sha field must be a 40-character hexadecimal SHA1 string (no uppercase letters).", "base_sha": "The base_sha field must be a 40-character hexadecimal SHA1 string (no uppercase letters).", "provider": "The provider field must be a string with maximum length of 255 characters containing the domain of the VCS provider (ex. github.com)", @@ -204,6 +206,7 @@ def post(self, request: Request, project: Project) -> Response: checksum=checksum, build_configuration_name=data.get("build_configuration"), release_notes=data.get("release_notes"), + date_built=data.get("date_built"), head_sha=data.get("head_sha"), base_sha=data.get("base_sha"), provider=data.get("provider"), diff --git a/src/sentry/preprod/tasks.py b/src/sentry/preprod/tasks.py index aae6c8e2c456dd..f5f002b9310864 100644 --- a/src/sentry/preprod/tasks.py +++ b/src/sentry/preprod/tasks.py @@ -178,6 +178,7 @@ def create_preprod_artifact( checksum: str, build_configuration_name: str | None = None, release_notes: str | None = None, + date_built: str | None = None, head_sha: str | None = None, base_sha: str | None = None, provider: str | None = None, @@ -236,7 +237,18 @@ def create_preprod_artifact( if release_notes: extras = {"release_notes": release_notes} - preprod_artifact, _ = PreprodArtifact.objects.get_or_create( + # Parse date_built if provided + parsed_date_built = None + if date_built: + try: + parsed_date_built = datetime.datetime.fromisoformat(date_built) + except (ValueError, TypeError) as e: + logger.warning( + "Failed to parse date_built", + extra={"date_built": date_built, "error": str(e)}, + ) + + preprod_artifact, created = PreprodArtifact.objects.get_or_create( project=project, build_configuration=build_config, state=PreprodArtifact.ArtifactState.UPLOADING, @@ -244,6 +256,11 @@ def create_preprod_artifact( extras=extras, ) + # Set date_built if provided and artifact was just created + if created and parsed_date_built: + preprod_artifact.date_built = parsed_date_built + preprod_artifact.save(update_fields=["date_built"]) + # TODO(preprod): add gating to only create if has quota PreprodArtifactSizeMetrics.objects.get_or_create( preprod_artifact=preprod_artifact, diff --git a/tests/sentry/preprod/api/endpoints/test_organization_preprod_artifact_assemble.py b/tests/sentry/preprod/api/endpoints/test_organization_preprod_artifact_assemble.py index 491dd847e3a466..dd95eecd65b380 100644 --- a/tests/sentry/preprod/api/endpoints/test_organization_preprod_artifact_assemble.py +++ b/tests/sentry/preprod/api/endpoints/test_organization_preprod_artifact_assemble.py @@ -41,6 +41,7 @@ def test_valid_full_schema(self) -> None: "checksum": "a" * 40, "chunks": ["b" * 40, "c" * 40], "build_configuration": "release", + "date_built": "2025-11-26T10:30:00", "head_sha": "e" * 40, "base_sha": "f" * 40, "provider": "github", @@ -164,6 +165,21 @@ def test_pr_number_invalid(self) -> None: assert error is not None assert result == {} + def test_date_built_valid_string(self) -> None: + """Test valid date_built string is accepted.""" + data = {"checksum": "a" * 40, "chunks": [], "date_built": "2025-11-26T10:30:00"} + body = orjson.dumps(data) + result, error = validate_preprod_artifact_schema(body) + assert error is None + assert result == data + + def test_date_built_wrong_type(self) -> None: + """Test non-string date_built returns error.""" + body = orjson.dumps({"checksum": "a" * 40, "chunks": [], "date_built": 123}) + result, error = validate_preprod_artifact_schema(body) + assert error is not None + assert result == {} + def test_additional_properties_rejected(self) -> None: """Test additional properties are rejected.""" body = orjson.dumps({"checksum": "a" * 40, "chunks": [], "extra_field": "value"}) diff --git a/tests/sentry/preprod/test_tasks.py b/tests/sentry/preprod/test_tasks.py index 39ace59a3db484..64f6338dcbaddc 100644 --- a/tests/sentry/preprod/test_tasks.py +++ b/tests/sentry/preprod/test_tasks.py @@ -114,6 +114,29 @@ def test_create_preprod_artifact_with_release_notes(self) -> None: # Clean up delete_assemble_status(AssembleTask.PREPROD_ARTIFACT, self.project.id, total_checksum) + def test_create_preprod_artifact_with_date_built(self) -> None: + """Test that create_preprod_artifact stores date_built field""" + content = b"test preprod artifact with date_built" + total_checksum = sha1(content).hexdigest() + date_built_str = "2025-11-26T10:30:00" + + # Create preprod artifact with date_built + artifact = create_preprod_artifact( + org_id=self.organization.id, + project_id=self.project.id, + checksum=total_checksum, + build_configuration_name="release", + date_built=date_built_str, + ) + assert artifact is not None + + # Verify the artifact was created with date_built + assert artifact.date_built is not None + assert artifact.date_built.isoformat() == date_built_str + + # Clean up + delete_assemble_status(AssembleTask.PREPROD_ARTIFACT, self.project.id, total_checksum) + def test_assemble_preprod_artifact_with_commit_comparison(self) -> None: content = b"test preprod artifact with commit comparison" fileobj = ContentFile(content)