From 9fee984e848074f727b878b07c82a54c039aab6d Mon Sep 17 00:00:00 2001 From: Just One More Night Date: Fri, 11 Sep 2026 20:35:52 +0700 Subject: [PATCH] fix(api): serialize response datetimes as UTC --- .../src/repowise/server/schemas/__init__.py | 2 + .../src/repowise/server/schemas/_datetime.py | 26 ++++++++ .../src/repowise/server/schemas/chat.py | 8 +-- .../repowise/server/schemas/code_quality.py | 8 +-- .../src/repowise/server/schemas/decisions.py | 12 ++-- .../server/src/repowise/server/schemas/git.py | 14 ++-- .../src/repowise/server/schemas/ownership.py | 12 ++-- .../src/repowise/server/schemas/pages.py | 17 ++--- .../src/repowise/server/schemas/repository.py | 10 +-- .../server/test_datetime_serialization.py | 65 +++++++++++++++++++ 10 files changed, 135 insertions(+), 39 deletions(-) create mode 100644 packages/server/src/repowise/server/schemas/_datetime.py create mode 100644 tests/unit/server/test_datetime_serialization.py diff --git a/packages/server/src/repowise/server/schemas/__init__.py b/packages/server/src/repowise/server/schemas/__init__.py index 9bd002d8a..70bf4409a 100644 --- a/packages/server/src/repowise/server/schemas/__init__.py +++ b/packages/server/src/repowise/server/schemas/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations +from ._datetime import UTCDateTime from .architecture import ( ArchEdgeResponse, ArchitectureViewResponse, @@ -471,6 +472,7 @@ "SymbolNodeSummary", "SymbolResponse", "TransitiveEntry", + "UTCDateTime", "UnclusteredFiles", "UpdateMcpToolsRequest", "VersionResponse", diff --git a/packages/server/src/repowise/server/schemas/_datetime.py b/packages/server/src/repowise/server/schemas/_datetime.py new file mode 100644 index 000000000..96a511c87 --- /dev/null +++ b/packages/server/src/repowise/server/schemas/_datetime.py @@ -0,0 +1,26 @@ +"""Shared datetime types for the REST API.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Annotated + +from pydantic import PlainSerializer + + +def _serialize_utc(value: datetime) -> str: + """Serialize an instant as an explicit UTC ISO-8601 value. + + SQLite drops ``tzinfo`` when round-tripping SQLAlchemy datetime columns, + while PostgreSQL preserves it. The persistence layer stores these values as + UTC, so a naive value read from SQLite is UTC as well. Stamping it here + keeps every REST client from interpreting it as local browser time. + """ + value = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + return value.isoformat().replace("+00:00", "Z") + + +UTCDateTime = Annotated[ + datetime, + PlainSerializer(_serialize_utc, return_type=str, when_used="json"), +] diff --git a/packages/server/src/repowise/server/schemas/chat.py b/packages/server/src/repowise/server/schemas/chat.py index 85eb4fa40..8572dad94 100644 --- a/packages/server/src/repowise/server/schemas/chat.py +++ b/packages/server/src/repowise/server/schemas/chat.py @@ -3,12 +3,12 @@ from __future__ import annotations import json -from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, Field from repowise.server.chat_artifacts import normalize_message_artifacts +from repowise.server.schemas._datetime import UTCDateTime class ChatPageContext(BaseModel): @@ -68,8 +68,8 @@ class ConversationResponse(BaseModel): title: str message_count: int = 0 pinned: bool = False - created_at: datetime - updated_at: datetime + created_at: UTCDateTime + updated_at: UTCDateTime @classmethod def from_orm(cls, obj: object, message_count: int = 0) -> ConversationResponse: @@ -103,7 +103,7 @@ class ChatMessageResponse(BaseModel): conversation_id: str role: str content: dict - created_at: datetime + created_at: UTCDateTime @classmethod def from_orm(cls, obj: object) -> ChatMessageResponse: diff --git a/packages/server/src/repowise/server/schemas/code_quality.py b/packages/server/src/repowise/server/schemas/code_quality.py index 5cfb74d93..bb810fbcd 100644 --- a/packages/server/src/repowise/server/schemas/code_quality.py +++ b/packages/server/src/repowise/server/schemas/code_quality.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -from datetime import datetime from pydantic import BaseModel @@ -11,6 +10,7 @@ effective_safe_to_delete, path_risk_factors, ) +from repowise.server.schemas._datetime import UTCDateTime class DeadCodeFindingResponse(BaseModel): @@ -40,7 +40,7 @@ class DeadCodeFindingResponse(BaseModel): # confidence ladder. Deliberately not ``age_days``: that is measured from # the *first* commit, so it answers "how old is this file", not "how long # has this been dead", and the two disagree on 75% of findings. - last_commit_at: datetime | None + last_commit_at: UTCDateTime | None # Commits to the file in the last 90 days. Top rung of the confidence # ladder (0 commits is what earns the high tiers), so surfacing it is what # makes a low confidence score legible: the file is still being worked on. @@ -98,7 +98,7 @@ class SecurityFindingResponse(BaseModel): kind: str severity: str snippet: str | None - detected_at: datetime + detected_at: UTCDateTime # Where in the file. Checked against the live tree before serving, so a # line that drifted is either corrected or withdrawn — see # ``services/security_lines.py``. ``None`` means the snippet is gone from @@ -110,7 +110,7 @@ class SecurityFindingResponse(BaseModel): # Present when the finding was sourced from git history (full-history # scan). ``None`` for working-tree findings produced during indexing. commit_sha: str | None - commit_at: datetime | None + commit_at: UTCDateTime | None found_in_history: bool diff --git a/packages/server/src/repowise/server/schemas/decisions.py b/packages/server/src/repowise/server/schemas/decisions.py index 2b5871640..9b46495d1 100644 --- a/packages/server/src/repowise/server/schemas/decisions.py +++ b/packages/server/src/repowise/server/schemas/decisions.py @@ -3,12 +3,12 @@ from __future__ import annotations import json -from datetime import datetime from pydantic import BaseModel, ConfigDict, Field from repowise.core.analysis.decisions.policy import DISCOVERY_BOUNDS from repowise.core.analysis.decisions.scope import derive_decision_scope +from repowise.server.schemas._datetime import UTCDateTime class EvidencePreview(BaseModel): @@ -46,9 +46,9 @@ class DecisionRecordResponse(BaseModel): # the linkage fields, so old records get it too. scope: str | None = None superseded_by: str | None - last_code_change: datetime | None - created_at: datetime - updated_at: datetime + last_code_change: UTCDateTime | None + created_at: UTCDateTime + updated_at: UTCDateTime # List-row evidence preview: the top-ranked evidence row's verbatim quote # plus how many evidence rows back the record. Populated by the list # endpoint only (None on detail/graph responses, which have the full @@ -280,7 +280,9 @@ class DecisionLineageResponse(BaseModel): #: Sourced from the policy registry so the wire bounds cannot drift from the #: ones the resolver enforces. _DISCOVERY_DEFAULTS = {key: bounds[2] for key, bounds in DISCOVERY_BOUNDS.items()} -_DISCOVERY_RANGE = {key: {"ge": bounds[0], "le": bounds[1]} for key, bounds in DISCOVERY_BOUNDS.items()} +_DISCOVERY_RANGE = { + key: {"ge": bounds[0], "le": bounds[1]} for key, bounds in DISCOVERY_BOUNDS.items() +} class DecisionSourceState(BaseModel): diff --git a/packages/server/src/repowise/server/schemas/git.py b/packages/server/src/repowise/server/schemas/git.py index 384951234..2f211566d 100644 --- a/packages/server/src/repowise/server/schemas/git.py +++ b/packages/server/src/repowise/server/schemas/git.py @@ -3,12 +3,12 @@ from __future__ import annotations import json -from datetime import datetime from typing import Any from pydantic import BaseModel from repowise.core.co_change import parse_partners +from repowise.server.schemas._datetime import UTCDateTime from repowise.server.schemas.risk_semantics import RiskAuthority @@ -17,8 +17,8 @@ class GitMetadataResponse(BaseModel): commit_count_total: int commit_count_90d: int commit_count_30d: int - first_commit_at: datetime | None - last_commit_at: datetime | None + first_commit_at: UTCDateTime | None + last_commit_at: UTCDateTime | None primary_owner_name: str | None primary_owner_email: str | None primary_owner_commit_pct: float | None @@ -50,7 +50,7 @@ class GitMetadataResponse(BaseModel): # the same at two weeks and two years. Empty/None on a pre-rollup index. fix_symbol_counts: dict = {} bug_magnet: bool = False - last_fix_at: datetime | None = None + last_fix_at: UTCDateTime | None = None temporal_hotspot_score: float | None = None commit_count_capped: bool = False # Rename lineage: the file's path before its most recent move, if any. @@ -136,7 +136,7 @@ class HotspotResponse(BaseModel): merge_commit_count_90d: int = 0 commit_count_capped: bool = False age_days: int = 0 - last_commit_at: datetime | None = None + last_commit_at: UTCDateTime | None = None # Change-complexity + defect-history signals. change_entropy: float = 0.0 change_entropy_pct: float = 0.0 @@ -145,7 +145,7 @@ class HotspotResponse(BaseModel): # age describes "fixed 4x last month" and "fixed 4x two years ago" # identically. Consumers drop the flag when the timestamp is missing. bug_magnet: bool = False - last_fix_at: datetime | None = None + last_fix_at: UTCDateTime | None = None original_path: str | None = None @@ -179,7 +179,7 @@ class CommitResponse(BaseModel): short_sha: str author_name: str author_email: str - committed_at: datetime | None + committed_at: UTCDateTime | None subject: str lines_added: int lines_deleted: int diff --git a/packages/server/src/repowise/server/schemas/ownership.py b/packages/server/src/repowise/server/schemas/ownership.py index a11ebcce5..dd09fb08e 100644 --- a/packages/server/src/repowise/server/schemas/ownership.py +++ b/packages/server/src/repowise/server/schemas/ownership.py @@ -2,10 +2,10 @@ from __future__ import annotations -from datetime import datetime - from pydantic import BaseModel +from repowise.server.schemas._datetime import UTCDateTime + class KnowledgeMapOwner(BaseModel): email: str @@ -44,7 +44,7 @@ class OwnerListEntry(BaseModel): dead_code_files_owned: int dead_code_lines_owned: int commit_count_90d: int # sum of per-file 90d commits attributed to this person - last_commit_at: datetime | None + last_commit_at: UTCDateTime | None bus_factor_risk_files: int # files they own where bus_factor <= 1 @@ -61,7 +61,7 @@ class OwnerFileEntry(BaseModel): churn_percentile: float # 0-100 bus_factor: int is_hotspot: bool - last_commit_at: datetime | None + last_commit_at: UTCDateTime | None primary_owner_commit_pct: float | None @@ -100,8 +100,8 @@ class OwnerProfileResponse(BaseModel): dead_code_files_owned: int dead_code_lines_owned: int commit_count_90d: int - last_commit_at: datetime | None - first_commit_at: datetime | None + last_commit_at: UTCDateTime | None + first_commit_at: UTCDateTime | None bus_factor_risk_files: int # 90d activity proxies (approximated from file-level totals weighted by diff --git a/packages/server/src/repowise/server/schemas/pages.py b/packages/server/src/repowise/server/schemas/pages.py index e3545e36c..de705d2bd 100644 --- a/packages/server/src/repowise/server/schemas/pages.py +++ b/packages/server/src/repowise/server/schemas/pages.py @@ -3,10 +3,11 @@ from __future__ import annotations import json -from datetime import datetime from pydantic import BaseModel +from repowise.server.schemas._datetime import UTCDateTime + def _layer_stamp(obj: object, metadata: dict | None) -> tuple[str | None, str | None]: """Which layer this page belongs to, read off its metadata blob. @@ -148,8 +149,8 @@ class PageSummaryResponse(BaseModel): display_order: int = 0 section_number: str | None = None structural_key: str | None = None - created_at: datetime - updated_at: datetime + created_at: UTCDateTime + updated_at: UTCDateTime @classmethod def from_orm(cls, obj: object) -> PageSummaryResponse: @@ -185,7 +186,7 @@ class PageVersionResponse(BaseModel): input_tokens: int output_tokens: int confidence: float - archived_at: datetime + archived_at: UTCDateTime @classmethod def from_orm(cls, obj: object) -> PageVersionResponse: @@ -218,10 +219,10 @@ class JobResponse(BaseModel): current_level: int error_message: str | None config: dict - created_at: datetime - updated_at: datetime - started_at: datetime | None - finished_at: datetime | None + created_at: UTCDateTime + updated_at: UTCDateTime + started_at: UTCDateTime | None + finished_at: UTCDateTime | None # Short-lived token for the SSE progress stream (an EventSource can't send # the bearer header). Only minted while the job is live; ``None`` once it # reaches a terminal state, since there's nothing left to stream. Any client diff --git a/packages/server/src/repowise/server/schemas/repository.py b/packages/server/src/repowise/server/schemas/repository.py index 846416d78..f0c249992 100644 --- a/packages/server/src/repowise/server/schemas/repository.py +++ b/packages/server/src/repowise/server/schemas/repository.py @@ -3,12 +3,12 @@ from __future__ import annotations import json -from datetime import datetime from pathlib import Path from pydantic import BaseModel, field_validator from repowise.core.docs_mode import DocsMode +from repowise.server.schemas._datetime import UTCDateTime class RepoCreate(BaseModel): @@ -50,8 +50,8 @@ class RepoResponse(BaseModel): default_branch: str head_commit: str | None settings: dict - created_at: datetime - updated_at: datetime + created_at: UTCDateTime + updated_at: UTCDateTime # Workspace context — populated when the server is running in # workspace mode. ``status`` indicates whether the repo has been # indexed yet; the web UI uses it to render "needs index" CTA cards @@ -121,7 +121,7 @@ class RepoSummaryRow(BaseModel): id: str name: str local_path: str - updated_at: datetime | None = None + updated_at: UTCDateTime | None = None #: "indexed" | "needs_index" | "missing_dir" — same vocabulary as #: ``RepoResponse.workspace_status``, which the sidebar already renders. status: str = "indexed" @@ -147,7 +147,7 @@ class RepoSummaryRow(BaseModel): #: distinct from a score of 0, which would mean "analysed, and terrible". average_health: float | None = None hotspot_health: float | None = None - health_taken_at: datetime | None = None + health_taken_at: UTCDateTime | None = None #: Index-vs-checkout freshness. ``index_behind`` is ``None`` when the #: comparison could not run (no git checkout on disk, unreadable HEAD) diff --git a/tests/unit/server/test_datetime_serialization.py b/tests/unit/server/test_datetime_serialization.py new file mode 100644 index 000000000..c6d8257bf --- /dev/null +++ b/tests/unit/server/test_datetime_serialization.py @@ -0,0 +1,65 @@ +"""REST datetime fields must identify UTC explicitly.""" + +from datetime import UTC, datetime, timedelta, timezone + +from repowise.server.schemas import ( + ChatMessageResponse, + JobResponse, + RepoResponse, +) + + +def test_naive_sqlite_datetime_is_serialized_as_utc() -> None: + response = RepoResponse( + id="repo", + name="repo", + url="", + local_path="/tmp/repo", + default_branch="main", + head_commit=None, + settings={}, + created_at=datetime(2026, 9, 8, 20, 2, 58, 949469), + updated_at=datetime(2026, 9, 8, 20, 2, 58, 949469), + ) + + payload = response.model_dump(mode="json") + + assert payload["created_at"] == "2026-09-08T20:02:58.949469Z" + assert payload["updated_at"] == "2026-09-08T20:02:58.949469Z" + + +def test_aware_datetime_is_normalized_to_utc() -> None: + response = ChatMessageResponse( + id="message", + conversation_id="conversation", + role="user", + content={"text": "hello"}, + created_at=datetime(2026, 9, 8, 22, 2, 58, tzinfo=timezone(timedelta(hours=2))), + ) + + assert response.model_dump(mode="json")["created_at"] == "2026-09-08T20:02:58Z" + + +def test_optional_job_datetimes_keep_null_and_emit_utc() -> None: + response = JobResponse( + id="job", + repository_id="repo", + status="running", + provider_name="mock", + model_name="mock", + total_pages=1, + completed_pages=0, + failed_pages=0, + current_level=0, + error_message=None, + config={}, + created_at=datetime(2026, 9, 8, tzinfo=UTC), + updated_at=datetime(2026, 9, 8, tzinfo=UTC), + started_at=datetime(2026, 9, 8), + finished_at=None, + ) + + payload = response.model_dump(mode="json") + + assert payload["started_at"] == "2026-09-08T00:00:00Z" + assert payload["finished_at"] is None