mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Every first-party Python caller and test now names the mount the API actually answers on. Mechanical: URL literals only, and only where the /api/ was ours -- a preceding word character means it was a module path like insights/api/ or a third-party host like slack.com/api/, and those are untouched. Left saying /api/, each for a reason rather than for safety: - products/warehouse_sources/.../sources/** is a catalogue of vendor endpoints - services/llm-gateway deploys separately and is under a code freeze, so moving its two calls would break it during the skew - the mcp_store and Slack/Teams redirect_uris are registered with third parties and must byte-match what was registered - the Wizard and Raycast client-metadata paths are OAuth client_ids - /api/surveys and /api/feature_flag/local_evaluation are SDK aliases the ingress routes to the Rust fleets, held by released SDK versions Test URLconfs moved with the requests that call them, and the Datastore user-selection fixtures now carry the leading slash that request.path has.
1108 lines
49 KiB
Python
1108 lines
49 KiB
Python
import json
|
||
import logging
|
||
from collections.abc import Mapping
|
||
from typing import cast
|
||
|
||
from django.db.models import Q
|
||
|
||
from asgiref.sync import async_to_sync
|
||
from drf_spectacular.types import OpenApiTypes
|
||
from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema_field
|
||
from opentelemetry import trace
|
||
from opentelemetry.trace import Status, StatusCode
|
||
from rest_framework import serializers
|
||
|
||
from insights.models import User
|
||
from insights.temporal.common.client import sync_connect
|
||
|
||
from products.signals.backend import contracts
|
||
from products.signals.backend.billing import REFUND_INELIGIBILITY_REASONS, refund_ineligibility_reason
|
||
from products.signals.backend.enums import SignalSourceProduct, SignalSourceType
|
||
|
||
from .artefact_schemas import NON_WRITABLE_ARTEFACT_TYPES
|
||
from .models import (
|
||
AutonomyPriority,
|
||
SignalReport,
|
||
SignalReportArtefact,
|
||
SignalReportRefund,
|
||
SignalSourceConfig,
|
||
SignalTeamConfig,
|
||
SignalUserAutonomyConfig,
|
||
)
|
||
from .report_charts import CHART_SIZES, MAX_CHART_CAPTION_LENGTH, MAX_CHART_ID_LENGTH, MAX_CHART_TITLE_LENGTH
|
||
from .report_generation.resolve_reviewers import enrich_reviewer_dicts_with_org_members
|
||
|
||
logger = logging.getLogger(__name__)
|
||
tracer = trace.get_tracer(__name__)
|
||
|
||
DEFAULT_SESSION_ANALYSIS_SAMPLE_RATE = 0.1
|
||
|
||
|
||
# Maps (source_product, source_type) → (ExternalDataSourceType value, schema name)
|
||
_DATA_IMPORT_SOURCE_MAP: dict[tuple[str, str], tuple[str, str]] = {
|
||
(SignalSourceConfig.SourceProduct.GITHUB, SignalSourceConfig.SourceType.ISSUE): ("Github", "issues"),
|
||
(SignalSourceConfig.SourceProduct.LINEAR, SignalSourceConfig.SourceType.ISSUE): ("Linear", "issues"),
|
||
(SignalSourceConfig.SourceProduct.ZENDESK, SignalSourceConfig.SourceType.TICKET): ("Zendesk", "tickets"),
|
||
(SignalSourceConfig.SourceProduct.PGANALYZE, SignalSourceConfig.SourceType.ISSUE): ("PgAnalyze", "issues"),
|
||
}
|
||
|
||
|
||
class SignalSourceConfigSerializer(serializers.ModelSerializer):
|
||
status = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = SignalSourceConfig
|
||
fields = [
|
||
"id",
|
||
"source_product",
|
||
"source_type",
|
||
"enabled",
|
||
"config",
|
||
"created_at",
|
||
"updated_at",
|
||
"status",
|
||
]
|
||
read_only_fields = ["id", "created_at", "updated_at", "status"]
|
||
|
||
def get_status(self, obj: SignalSourceConfig) -> str | None:
|
||
if obj.source_type == SignalSourceConfig.SourceType.SESSION_ANALYSIS_CLUSTER:
|
||
return self._get_session_analysis_status(obj.team_id)
|
||
|
||
mapping = _DATA_IMPORT_SOURCE_MAP.get((obj.source_product, obj.source_type))
|
||
if mapping is None:
|
||
return None
|
||
ext_source_type, schema_name = mapping
|
||
return self._get_data_import_status(obj.team_id, ext_source_type, schema_name)
|
||
|
||
# Per-row Temporal RPC: serializing N source configs issues N of these on inbox load.
|
||
# The span surfaces that cost so the N+1 is visible per request in APM.
|
||
@tracer.start_as_current_span("signals.source_config.session_analysis_status")
|
||
def _get_session_analysis_status(self, team_id: int) -> str | None:
|
||
""" "running" iff any `summarize-session` workflow for this team is currently executing."""
|
||
query = f'InsightsTeamId = {team_id} AND WorkflowType = "summarize-session" AND ExecutionStatus = "Running"'
|
||
|
||
try:
|
||
temporal = sync_connect()
|
||
|
||
async def has_running() -> bool:
|
||
async for _ in temporal.list_workflows(query=query, page_size=1):
|
||
return True
|
||
return False
|
||
|
||
if async_to_sync(has_running)():
|
||
return "running"
|
||
except Exception as e:
|
||
# The except swallows the error, so OTel won't auto-record it on the span — mark it
|
||
# failed explicitly, else an unreachable Temporal looks like a successful no-op in APM.
|
||
span = trace.get_current_span()
|
||
span.record_exception(e)
|
||
span.set_status(Status(StatusCode.ERROR))
|
||
logger.warning("Failed to list session summarization workflows: %s", e)
|
||
return None
|
||
|
||
def _get_data_import_status(self, team_id: int, ext_source_type: str, schema_name: str) -> str | None:
|
||
from products.warehouse_sources.backend.facade.models import ExternalDataSchema
|
||
|
||
statuses = set(
|
||
ExternalDataSchema.objects.filter(
|
||
Q(name=schema_name) | Q(name__endswith=f".{schema_name}"),
|
||
team_id=team_id,
|
||
source__source_type=ext_source_type,
|
||
)
|
||
.exclude(source__deleted=True)
|
||
.values_list("status", flat=True)
|
||
)
|
||
if ExternalDataSchema.Status.RUNNING in statuses:
|
||
return "running"
|
||
# One failing repo outranks its siblings' success, so a broken repo is never hidden.
|
||
if statuses & {
|
||
ExternalDataSchema.Status.FAILED,
|
||
ExternalDataSchema.Status.BILLING_LIMIT_REACHED,
|
||
ExternalDataSchema.Status.BILLING_LIMIT_TOO_LOW,
|
||
}:
|
||
return "failed"
|
||
if ExternalDataSchema.Status.COMPLETED in statuses:
|
||
return "completed"
|
||
return None
|
||
|
||
def validate(self, attrs: dict) -> dict:
|
||
source_product = attrs.get("source_product", getattr(self.instance, "source_product", None))
|
||
source_type = attrs.get("source_type", getattr(self.instance, "source_type", None))
|
||
enabled = attrs.get("enabled", getattr(self.instance, "enabled", False))
|
||
config = attrs.get("config", {})
|
||
if source_product == SignalSourceConfig.SourceProduct.SESSION_REPLAY and config:
|
||
recording_filters = config.get("recording_filters")
|
||
if recording_filters is not None and not isinstance(recording_filters, dict):
|
||
raise serializers.ValidationError({"config": "recording_filters must be a JSON object"})
|
||
sample_rate = config.get("sample_rate")
|
||
if sample_rate is not None:
|
||
# `isinstance(True, int)` is True in Python — reject bools explicitly.
|
||
if isinstance(sample_rate, bool) or not isinstance(sample_rate, int | float):
|
||
raise serializers.ValidationError({"config": "sample_rate must be a number between 0 and 1"})
|
||
if not (0 <= sample_rate <= 1):
|
||
raise serializers.ValidationError({"config": "sample_rate must be between 0 and 1"})
|
||
if enabled and source_type == SignalSourceConfig.SourceType.SESSION_ANALYSIS_CLUSTER:
|
||
get_team = self.context.get("get_team")
|
||
team = get_team() if get_team else None
|
||
if team is not None and not team.organization.is_ai_data_processing_approved:
|
||
raise serializers.ValidationError(
|
||
{
|
||
"enabled": "AI data processing must be approved at the organization level to enable session analysis."
|
||
}
|
||
)
|
||
return attrs
|
||
|
||
def create(self, validated_data: dict) -> SignalSourceConfig:
|
||
if (
|
||
validated_data.get("source_product") == SignalSourceConfig.SourceProduct.SESSION_REPLAY
|
||
and validated_data.get("source_type") == SignalSourceConfig.SourceType.SESSION_ANALYSIS_CLUSTER
|
||
):
|
||
config = dict(validated_data.get("config") or {})
|
||
config.setdefault("sample_rate", DEFAULT_SESSION_ANALYSIS_SAMPLE_RATE)
|
||
validated_data["config"] = config
|
||
return super().create(validated_data)
|
||
|
||
|
||
class SignalTeamConfigSerializer(serializers.ModelSerializer):
|
||
autostart_base_branches = serializers.DictField(
|
||
child=serializers.CharField(max_length=255, allow_blank=True),
|
||
required=False,
|
||
help_text=(
|
||
"Per-repository base branch overrides for auto-started inbox PRs, keyed by "
|
||
"'organization/repository'. The branch is what the auto-PR targets; omit a repo "
|
||
"(or send {}) to keep targeting the repo default branch."
|
||
),
|
||
)
|
||
|
||
class Meta:
|
||
model = SignalTeamConfig
|
||
fields = [
|
||
"id",
|
||
"autostart_enabled",
|
||
"default_autostart_priority",
|
||
"default_slack_notification_channel",
|
||
"autostart_base_branches",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = ["id", "created_at", "updated_at"]
|
||
extra_kwargs = {
|
||
"autostart_enabled": {
|
||
"help_text": (
|
||
"Master switch for autonomous inbox PRs. Null (never set) leaves autostart on; set "
|
||
"false to opt out, so actionable reports still generate and notify but the team "
|
||
"never auto-starts an implementation task or opens a PR — reviewers open PRs manually."
|
||
)
|
||
},
|
||
"default_slack_notification_channel": {
|
||
"help_text": (
|
||
"Default Slack channel for this team's signal inbox notifications, in the same "
|
||
"`channel_id|#channel-name` shape Insights uses elsewhere (only the channel id is required). "
|
||
"Null means no team-level default; per-user channels still apply."
|
||
)
|
||
},
|
||
}
|
||
|
||
def validate_autostart_base_branches(self, value: dict) -> dict:
|
||
cleaned: dict[str, str] = {}
|
||
for repo, branch in value.items():
|
||
repo_key = (repo or "").strip()
|
||
if repo_key.count("/") != 1 or any(not part for part in repo_key.split("/")):
|
||
raise serializers.ValidationError(
|
||
f"Repository keys must be in 'organization/repository' form, got '{repo}'."
|
||
)
|
||
branch_value = (branch or "").strip()
|
||
if branch_value:
|
||
cleaned[repo_key.lower()] = branch_value
|
||
return cleaned
|
||
|
||
|
||
class _UserSerializer(serializers.ModelSerializer):
|
||
class Meta:
|
||
model = User
|
||
fields = ["id", "uuid", "first_name", "last_name", "email"]
|
||
read_only_fields = fields
|
||
|
||
|
||
class SignalUserAutonomyConfigSerializer(serializers.ModelSerializer):
|
||
user = _UserSerializer(read_only=True)
|
||
slack_notification_integration_id = serializers.IntegerField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
help_text="ID of the Slack Integration to deliver inbox-item notifications through, or null when notifications are disabled.",
|
||
)
|
||
|
||
class Meta:
|
||
model = SignalUserAutonomyConfig
|
||
fields = [
|
||
"id",
|
||
"user",
|
||
"autostart_priority",
|
||
"slack_notification_integration_id",
|
||
"slack_notification_channel",
|
||
"slack_notification_min_priority",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = ["id", "user", "created_at", "updated_at"]
|
||
extra_kwargs = {
|
||
"slack_notification_channel": {
|
||
"help_text": (
|
||
"Slack channel target in the same `channel_id|#channel-name` shape Insights uses elsewhere "
|
||
"(only the channel id is required). Null disables Slack notifications."
|
||
)
|
||
},
|
||
"slack_notification_min_priority": {
|
||
"help_text": (
|
||
"Minimum report priority that triggers a Slack notification. P0 is highest. "
|
||
"Null means notify on every priority (and reports without a priority judgment)."
|
||
)
|
||
},
|
||
}
|
||
|
||
|
||
class SignalUserAutonomyConfigCreateSerializer(serializers.Serializer):
|
||
autostart_priority = serializers.ChoiceField(choices=AutonomyPriority.choices, required=False, allow_null=True)
|
||
slack_notification_integration_id = serializers.IntegerField(
|
||
required=False,
|
||
allow_null=True,
|
||
help_text=(
|
||
"Primary key of a Slack `Integration` row in one of the caller's teams. Pair with "
|
||
"`slack_notification_channel` to enable notifications; pass null on either to disable them."
|
||
),
|
||
)
|
||
slack_notification_channel = serializers.CharField(
|
||
required=False,
|
||
allow_null=True,
|
||
allow_blank=True,
|
||
max_length=255,
|
||
help_text="`channel_id|#channel-name` target — same convention used by Insight Alerts.",
|
||
)
|
||
slack_notification_min_priority = serializers.ChoiceField(
|
||
choices=AutonomyPriority.choices,
|
||
required=False,
|
||
allow_null=True,
|
||
help_text="P0 is highest. Null = notify for every priority.",
|
||
)
|
||
|
||
|
||
class SignalReportRefundSerializer(serializers.ModelSerializer):
|
||
billing_synced = serializers.SerializerMethodField(
|
||
help_text=(
|
||
"Whether the billing service has acknowledged this refund. Always relevant for the "
|
||
"credited path (the Stripe credit is issued asynchronously); excluded-path refunds "
|
||
"need no billing sync and report false."
|
||
),
|
||
)
|
||
|
||
class Meta:
|
||
model = SignalReportRefund
|
||
fields = [
|
||
"id",
|
||
"reason",
|
||
"note",
|
||
"billing_path",
|
||
"credits",
|
||
"pr_url",
|
||
"pr_run_created_at",
|
||
"credit_amount_usd",
|
||
"billing_synced",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
extra_kwargs = {
|
||
"reason": {"help_text": "Why the user refunded this PR (feeds the refund review)."},
|
||
"note": {"help_text": "Optional free-form note captured with the refund."},
|
||
"billing_path": {
|
||
"help_text": (
|
||
"How the refund was executed, frozen at refund time: 'excluded' (same UTC day as "
|
||
"the billable PR run — the report never reaches billing) or 'credited' (billing "
|
||
"issues a Stripe customer-balance credit)."
|
||
)
|
||
},
|
||
"credits": {"help_text": "Signals credits refunded (flat per-PR charge snapshot; 1 credit = $0.01)."},
|
||
"pr_url": {"help_text": "The refunded implementation PR's GitHub URL, snapshotted at refund time."},
|
||
"pr_run_created_at": {
|
||
"help_text": "When the first billable PR run was created — the charge this reverses."
|
||
},
|
||
"credit_amount_usd": {
|
||
"help_text": (
|
||
"USD amount the billing service credited (credited path only). Null until the sync "
|
||
"completes; '0.00' is a legitimate outcome (e.g. the PR was inside the free tier)."
|
||
)
|
||
},
|
||
"created_at": {"help_text": "When the refund was created."},
|
||
}
|
||
|
||
def get_billing_synced(self, obj: SignalReportRefund) -> bool:
|
||
return obj.billing_synced_at is not None
|
||
|
||
|
||
# The chart `query` is free-form JSON by design, and the generated schema has to keep it that way.
|
||
#
|
||
# This is load-bearing, not a style call. The MCP executor dispatches Zod's *parsed* output
|
||
# (`services/mcp/src/tools/exec.ts` — "Dispatch the parsed output so coerced values and defaults
|
||
# apply"), and a generated `zod.object({...})` strips keys it doesn't name. Declaring the node's
|
||
# shape — even just `kind` — would therefore drop `source` / `display` / `shortId` on the way through
|
||
# the tool and hand the backend a bare `{"kind": ...}`: valid per `ReportChart`, and a chart that
|
||
# renders nothing. `additionalProperties` doesn't save it either; it reaches the TypeScript type but
|
||
# not the Zod schema.
|
||
#
|
||
# So the field stays untyped in the schema (the `spec: zod.unknown()` precedent), and the contract
|
||
# lives in `help_text` where the scout reads it, enforced by `ReportChart` server-side.
|
||
@extend_schema_field(OpenApiTypes.ANY)
|
||
class ChartQueryField(serializers.JSONField):
|
||
"""The query node on a report chart. Typed for the schema pipeline so the generated MCP tool and
|
||
frontend types describe a query node instead of an opaque `unknown`, while still carrying the
|
||
node's per-kind fields through untouched."""
|
||
|
||
|
||
class ReportChartSerializer(serializers.Serializer):
|
||
"""One chart attached to a report — rendered in the inbox and referenceable from the summary."""
|
||
|
||
chart_id = serializers.CharField(
|
||
max_length=MAX_CHART_ID_LENGTH,
|
||
help_text=(
|
||
"Stable slug for this chart within the report (lowercase letters, numbers, underscores, "
|
||
"hyphens; must start with a letter or number). Reference it from `summary` as a markdown "
|
||
"link with a `chart:` target — `[Daily signups](chart:signups-drop)` — to place the chart "
|
||
"at that point in the body. A chart you don't reference still renders, below the summary."
|
||
),
|
||
)
|
||
title = serializers.CharField(
|
||
max_length=MAX_CHART_TITLE_LENGTH,
|
||
help_text="Short heading shown above the chart.",
|
||
)
|
||
query = ChartQueryField(
|
||
help_text=(
|
||
"The query node to render. `kind` must be `InsightVizNode` (an ad-hoc product analytics "
|
||
"chart), `DataVisualizationNode` (a SQL series — a `InsightsQLQuery` source plus a `display`), "
|
||
"or `SavedInsightNode` (an existing insight by `shortId`). Pin the window to absolute "
|
||
"dates where the node supports it, so the reader sees the data you wrote about rather "
|
||
"than whatever a relative range resolves to when they open the report."
|
||
),
|
||
)
|
||
caption = serializers.CharField(
|
||
required=False,
|
||
allow_null=True,
|
||
max_length=MAX_CHART_CAPTION_LENGTH,
|
||
help_text="Optional one-line note on what to look at in the chart.",
|
||
)
|
||
size = serializers.ChoiceField(
|
||
choices=CHART_SIZES,
|
||
required=False,
|
||
allow_null=True,
|
||
help_text=(
|
||
"How much height the chart gets: `small` for a single number or a short series, `medium` "
|
||
"for an ordinary graph, `large` when there are rows or a grid to read (retention, paths, "
|
||
"a wide breakdown). Leave it out unless the default looks wrong — the inbox sizes a chart "
|
||
"from its query, and two charts referenced from the same paragraph sit side by side."
|
||
),
|
||
)
|
||
|
||
|
||
class SignalReportSerializer(serializers.ModelSerializer):
|
||
artefact_count = serializers.IntegerField(read_only=True)
|
||
charts = ReportChartSerializer(
|
||
many=True,
|
||
read_only=True,
|
||
help_text=(
|
||
"Charts the report shows, in the order they were written. The summary places one with a "
|
||
"`[label](chart:<chart_id>)` link; the rest render below it."
|
||
),
|
||
)
|
||
refund_ineligibility_reason = serializers.SerializerMethodField(
|
||
help_text=(
|
||
"Why refunding this report's PR would be rejected right now, or null when a refund "
|
||
"would be accepted (see the field's schema for the reason values)."
|
||
),
|
||
)
|
||
priority = serializers.SerializerMethodField(
|
||
help_text="P0–P4 from the latest priority judgment artefact (when present).",
|
||
)
|
||
actionability = serializers.SerializerMethodField(
|
||
help_text="Actionability choice from the latest actionability judgment artefact (when present).",
|
||
)
|
||
already_addressed = serializers.SerializerMethodField(
|
||
help_text=(
|
||
"Whether the issue is already being handled — fixed in recent changes, or with a fix in "
|
||
"flight (an open PR, a recently active branch, an assigned / in-progress issue or agent "
|
||
"task) — from the actionability judgment artefact."
|
||
),
|
||
)
|
||
dismissal_reason = serializers.SerializerMethodField(
|
||
help_text="Reason code from the latest dismissal artefact, set when the report was suppressed (when present).",
|
||
)
|
||
dismissal_note = serializers.SerializerMethodField(
|
||
help_text="Free-form note captured alongside the dismissal reason (when present).",
|
||
)
|
||
is_suggested_reviewer = serializers.BooleanField(read_only=True, default=False)
|
||
source_products = serializers.SerializerMethodField(
|
||
help_text="Distinct source products contributing signals to this report (from Datastore).",
|
||
)
|
||
scout_name = serializers.SerializerMethodField(
|
||
help_text="skill_name slug of the scout that authored this report, when scout-authored (from Datastore); null otherwise.",
|
||
)
|
||
implementation_pr_url = serializers.SerializerMethodField(
|
||
help_text="PR URL from the latest implementation task run, if available.",
|
||
)
|
||
implementation_pr_merged = serializers.SerializerMethodField(
|
||
help_text=(
|
||
"Whether that implementation PR is merged, per the GitHub webhook. False when there is no "
|
||
"PR or it hasn't merged. Report status doesn't imply this: a resolved report may have been "
|
||
"resolved directly, without a merged PR."
|
||
),
|
||
)
|
||
refund = serializers.SerializerMethodField(
|
||
help_text="The report's PR refund, when one exists. One refund per report, ever.",
|
||
)
|
||
|
||
class Meta:
|
||
model = SignalReport
|
||
fields = [
|
||
"id",
|
||
"title",
|
||
"summary",
|
||
"status",
|
||
"total_weight", # Used for priority scoring
|
||
"signal_count", # Used for occurrence count
|
||
"signals_at_run", # Snooze threshold: re-promote when signal_count >= this value
|
||
"created_at",
|
||
"updated_at",
|
||
"artefact_count",
|
||
"charts",
|
||
"priority",
|
||
"actionability",
|
||
"already_addressed",
|
||
"dismissal_reason",
|
||
"dismissal_note",
|
||
"is_suggested_reviewer",
|
||
"source_products",
|
||
"scout_name",
|
||
"implementation_pr_url",
|
||
"implementation_pr_merged",
|
||
"refund",
|
||
"refund_ineligibility_reason",
|
||
"billing_exempt_reason",
|
||
]
|
||
read_only_fields = fields
|
||
extra_kwargs = {
|
||
"billing_exempt_reason": {
|
||
"help_text": (
|
||
"Non-null when this report is system-marked never-billable (Insights-system origin, "
|
||
"e.g. a health-check scout finding) — its implementation PRs are free and cannot be "
|
||
"refunded because nothing was charged."
|
||
)
|
||
},
|
||
}
|
||
|
||
def _get_actionability_artefact_data(self, obj: SignalReport) -> dict | None:
|
||
prefetched = getattr(obj, "prefetched_actionability_artefacts", None)
|
||
if prefetched is not None:
|
||
art = prefetched[0] if prefetched else None
|
||
else:
|
||
art = (
|
||
obj.artefacts.filter(type=SignalReportArtefact.ArtefactType.ACTIONABILITY_JUDGMENT)
|
||
.order_by("-created_at")
|
||
.first()
|
||
)
|
||
if art is None:
|
||
return None
|
||
try:
|
||
data = json.loads(art.content)
|
||
except (json.JSONDecodeError, TypeError, ValueError):
|
||
return None
|
||
return data if isinstance(data, dict) else None
|
||
|
||
def get_priority(self, obj: SignalReport) -> str | None:
|
||
prefetched = getattr(obj, "prefetched_priority_artefacts", None)
|
||
if prefetched is not None:
|
||
art = prefetched[0] if prefetched else None
|
||
else:
|
||
art = (
|
||
obj.artefacts.filter(type=SignalReportArtefact.ArtefactType.PRIORITY_JUDGMENT)
|
||
.order_by("-created_at")
|
||
.first()
|
||
)
|
||
if art is None:
|
||
return None
|
||
try:
|
||
data = json.loads(art.content)
|
||
except (json.JSONDecodeError, TypeError, ValueError):
|
||
return None
|
||
if not isinstance(data, dict):
|
||
return None
|
||
p = data.get("priority")
|
||
return p if isinstance(p, str) else None
|
||
|
||
def get_actionability(self, obj: SignalReport) -> str | None:
|
||
data = self._get_actionability_artefact_data(obj)
|
||
if data is None:
|
||
return None
|
||
value = data.get("actionability")
|
||
return value if isinstance(value, str) else None
|
||
|
||
def get_already_addressed(self, obj: SignalReport) -> bool | None:
|
||
data = self._get_actionability_artefact_data(obj)
|
||
if data is None:
|
||
return None
|
||
value = data.get("already_addressed")
|
||
return value if isinstance(value, bool) else None
|
||
|
||
def _get_dismissal_artefact_data(self, obj: SignalReport) -> dict | None:
|
||
prefetched = getattr(obj, "prefetched_dismissal_artefacts", None)
|
||
if prefetched is not None:
|
||
art = prefetched[0] if prefetched else None
|
||
else:
|
||
art = obj.artefacts.filter(type=SignalReportArtefact.ArtefactType.DISMISSAL).order_by("-created_at").first()
|
||
if art is None:
|
||
return None
|
||
try:
|
||
data = json.loads(art.content)
|
||
except (json.JSONDecodeError, TypeError, ValueError):
|
||
return None
|
||
return data if isinstance(data, dict) else None
|
||
|
||
def get_dismissal_reason(self, obj: SignalReport) -> str | None:
|
||
data = self._get_dismissal_artefact_data(obj)
|
||
if data is None:
|
||
return None
|
||
# Reason codes are owned by the client; pass through whatever was stored.
|
||
value = data.get("reason")
|
||
return value if isinstance(value, str) and value else None
|
||
|
||
def get_dismissal_note(self, obj: SignalReport) -> str | None:
|
||
data = self._get_dismissal_artefact_data(obj)
|
||
if data is None:
|
||
return None
|
||
value = data.get("note")
|
||
return value if isinstance(value, str) and value else None
|
||
|
||
def get_source_products(self, obj: SignalReport) -> list[str]:
|
||
source_products_map: dict[str, list[str]] | None = self.context.get("source_products_map")
|
||
if source_products_map is not None:
|
||
return source_products_map.get(str(obj.id), [])
|
||
return []
|
||
|
||
def get_scout_name(self, obj: SignalReport) -> str | None:
|
||
scout_names_map: dict[str, str] | None = self.context.get("scout_names_map")
|
||
if scout_names_map is not None:
|
||
return scout_names_map.get(str(obj.id))
|
||
return None
|
||
|
||
def get_implementation_pr_url(self, obj: SignalReport) -> str | None:
|
||
implementation_pr_url_map: dict[str, str] | None = self.context.get("implementation_pr_url_map")
|
||
if implementation_pr_url_map is not None:
|
||
return implementation_pr_url_map.get(str(obj.id))
|
||
value = getattr(obj, "implementation_pr_url", None)
|
||
return value if isinstance(value, str) else None
|
||
|
||
def get_implementation_pr_merged(self, obj: SignalReport) -> bool:
|
||
merged_report_ids: set[str] | None = self.context.get("implementation_pr_merged_ids")
|
||
if merged_report_ids is not None:
|
||
return str(obj.id) in merged_report_ids
|
||
# Annotated path: the JSON flag arrives as text, and NULL means no PR-bearing run at all.
|
||
value = getattr(obj, "implementation_pr_merged", None)
|
||
return value in (True, "true", "True")
|
||
|
||
@extend_schema_field(SignalReportRefundSerializer(allow_null=True))
|
||
def get_refund(self, obj: SignalReport) -> dict | None:
|
||
# Reverse OneToOne: RelatedObjectDoesNotExist subclasses AttributeError, so getattr
|
||
# degrades to None for unrefunded reports. The viewset select_related()s the relation.
|
||
refund = getattr(obj, "refund", None)
|
||
if refund is None:
|
||
return None
|
||
return SignalReportRefundSerializer(refund).data
|
||
|
||
@extend_schema_field(
|
||
serializers.ChoiceField(
|
||
choices=list(REFUND_INELIGIBILITY_REASONS),
|
||
allow_null=True,
|
||
help_text=(
|
||
"Why refunding this report's PR would be rejected right now, or null when a refund "
|
||
"would be accepted. Shares the refund endpoint's eligibility decision, so the UI can "
|
||
"disable the Refund action instead of offering a request that would 400. One of: "
|
||
"already_refunded, billing_exempt, no_billable_pr, out_of_period."
|
||
),
|
||
)
|
||
)
|
||
def get_refund_ineligibility_reason(self, obj: SignalReport) -> str | None:
|
||
period = self.context.get("billing_period_bounds")
|
||
# Degrades to null (eligible) outside the reports viewset, where neither the period
|
||
# context nor the billable-moment annotation exists — the refund endpoint re-enforces.
|
||
if period is None:
|
||
return None
|
||
return refund_ineligibility_reason(
|
||
has_refund=getattr(obj, "refund", None) is not None,
|
||
billing_exempt=bool(obj.billing_exempt_reason),
|
||
billable_run_at=getattr(obj, "first_billable_pr_run_at", None),
|
||
period=period,
|
||
)
|
||
|
||
|
||
# ── Report `signals` action ─────────────────────────────────────────────────────
|
||
#
|
||
# A signal's `extra` blob is one of the Pydantic `*SignalExtra` shapes from `contracts.py`. Those
|
||
# models are passed straight to `PolymorphicProxySerializer` — drf-spectacular's built-in
|
||
# `PydanticExtension` turns each into a named OpenAPI component (nested models included), so the
|
||
# frontend types flow through the standard OpenAPI/Orval pipeline without re-declaring the shapes.
|
||
|
||
# All `extra` payload shapes. They're discriminated at runtime by the (source_product, source_type)
|
||
# pair on the signal row, not by a field inside `extra`, so the OpenAPI union carries no discriminator.
|
||
SIGNAL_EXTRA_MODELS = list(contracts.SignalExtraBase.__subclasses__())
|
||
|
||
|
||
@extend_schema_field(
|
||
PolymorphicProxySerializer(
|
||
component_name="SignalExtra",
|
||
# drf-spectacular's built-in PydanticExtension resolves the Pydantic models at schema-build
|
||
# time; the stubs only know about DRF serializers, hence the cast.
|
||
serializers=cast(list, SIGNAL_EXTRA_MODELS),
|
||
resource_type_field_name=None,
|
||
)
|
||
)
|
||
class SignalExtraField(serializers.JSONField):
|
||
"""Product-specific `extra` payload — one of the *SignalExtra shapes."""
|
||
|
||
|
||
# Mirrors of the clustering dataclasses in `temporal/types.py` (SpecificityMetadata,
|
||
# MatchedMetadata, NoMatchMetadata). Those are plain dataclasses, which spectacular's
|
||
# PydanticExtension can't consume directly, so the shape is declared here as DRF serializers.
|
||
|
||
|
||
class SpecificityMetadataSerializer(serializers.Serializer):
|
||
pr_title = serializers.CharField(help_text="Title of the PR the specificity gate evaluated.")
|
||
specific_enough = serializers.BooleanField(help_text="Whether the report passed the PR-specificity gate.")
|
||
reason = serializers.CharField(help_text="The gate's reasoning.")
|
||
|
||
|
||
class MatchedMetadataSerializer(serializers.Serializer):
|
||
parent_signal_id = serializers.CharField(help_text="Signal already in the report that this one matched.")
|
||
match_query = serializers.CharField(help_text="Query used to find the parent signal.")
|
||
reason = serializers.CharField(help_text="Why the signals were judged to describe the same issue.")
|
||
specificity = SpecificityMetadataSerializer(
|
||
required=False, allow_null=True, help_text="PR-specificity gate result, when the gate ran."
|
||
)
|
||
|
||
|
||
class NoMatchMetadataSerializer(serializers.Serializer):
|
||
reason = serializers.CharField(help_text="Why no existing report matched.")
|
||
rejected_signal_ids = serializers.ListField(
|
||
child=serializers.CharField(), help_text="Candidate signals that were considered and rejected."
|
||
)
|
||
specificity_rejection = SpecificityMetadataSerializer(
|
||
required=False, allow_null=True, help_text="PR-specificity gate result that caused a rejection, when present."
|
||
)
|
||
|
||
|
||
@extend_schema_field(
|
||
PolymorphicProxySerializer(
|
||
component_name="SignalMatchMetadata",
|
||
serializers=[MatchedMetadataSerializer, NoMatchMetadataSerializer],
|
||
resource_type_field_name=None,
|
||
)
|
||
)
|
||
class SignalMatchMetadataField(serializers.JSONField):
|
||
"""Why the signal matched (or didn't) into its report cluster."""
|
||
|
||
|
||
class SignalNodeSerializer(serializers.Serializer):
|
||
signal_id = serializers.CharField(help_text="Datastore document id of the signal.")
|
||
content = serializers.CharField(help_text="The signal's human-readable description.")
|
||
source_product = serializers.ChoiceField(
|
||
choices=[(p.value, p.value) for p in SignalSourceProduct],
|
||
help_text="Product that emitted the signal.",
|
||
)
|
||
source_type = serializers.ChoiceField(
|
||
choices=[(t.value, t.value) for t in SignalSourceType],
|
||
help_text="Signal type within the source product.",
|
||
)
|
||
source_id = serializers.CharField(help_text="Emitter-scoped id of the underlying object (issue, ticket, ...).")
|
||
weight = serializers.FloatField(help_text="Signal weight in [0, 1]; drives report ranking.")
|
||
timestamp = serializers.DateTimeField(help_text="Emission timestamp.")
|
||
extra = SignalExtraField(help_text="Product-specific payload; shape depends on (source_product, source_type).")
|
||
match_metadata = SignalMatchMetadataField(
|
||
required=False,
|
||
allow_null=True,
|
||
help_text="Clustering match/no-match metadata, when present.",
|
||
)
|
||
|
||
|
||
class ReportSignalsResponseSerializer(serializers.Serializer):
|
||
"""Response body for GET /v1/projects/:id/signals/reports/:id/signals/."""
|
||
|
||
report = SignalReportSerializer(help_text="The report these signals were clustered into.")
|
||
signals = SignalNodeSerializer(many=True, help_text="All signals contributing to the report.")
|
||
|
||
|
||
class SignalReportArtefactSerializer(serializers.ModelSerializer):
|
||
content = serializers.SerializerMethodField()
|
||
created_by = _UserSerializer(
|
||
read_only=True,
|
||
allow_null=True,
|
||
help_text="User the artefact is attributed to, when a user produced it. Null for task/system writes.",
|
||
)
|
||
task_id = serializers.UUIDField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
help_text="Task the artefact is attributed to, when an agent produced it. Null for user/system writes.",
|
||
)
|
||
|
||
class Meta:
|
||
model = SignalReportArtefact
|
||
fields = ["id", "type", "content", "created_at", "updated_at", "created_by", "task_id"]
|
||
read_only_fields = fields
|
||
|
||
def get_content(self, obj: SignalReportArtefact) -> dict | list:
|
||
try:
|
||
parsed = json.loads(obj.content)
|
||
except (json.JSONDecodeError, ValueError):
|
||
return {}
|
||
|
||
# Enrich suggested_reviewers with fresh Insights user info at read time
|
||
if obj.type == SignalReportArtefact.ArtefactType.SUGGESTED_REVIEWERS and isinstance(parsed, list):
|
||
reviewer_login_map = cast(
|
||
Mapping[str, User] | None,
|
||
self.context.get("signals_github_login_to_user_map"),
|
||
)
|
||
return enrich_reviewer_dicts_with_org_members(
|
||
obj.team_id,
|
||
parsed,
|
||
login_to_user=reviewer_login_map,
|
||
)
|
||
|
||
return parsed
|
||
|
||
|
||
class SuggestedReviewerEntryWriteSerializer(serializers.Serializer):
|
||
"""Single entry in a PUT body for a `suggested_reviewers` artefact.
|
||
|
||
Each entry must identify a reviewer by at least one of `github_login` or `user_uuid`.
|
||
The server canonicalizes to a lowercase `github_login` — if `user_uuid` is supplied,
|
||
it must map to an org member on this team with a linked GitHub login.
|
||
"""
|
||
|
||
github_login = serializers.CharField(
|
||
required=False,
|
||
allow_blank=False,
|
||
max_length=200,
|
||
help_text="GitHub login (case-insensitive). Stored lowercased.",
|
||
)
|
||
user_uuid = serializers.UUIDField(
|
||
required=False,
|
||
help_text=(
|
||
"Insights user UUID. Must be an org member on this team with a linked GitHub identity. "
|
||
"If supplied together with `github_login`, the server-resolved login from the user wins."
|
||
),
|
||
)
|
||
github_name = serializers.CharField(
|
||
required=False,
|
||
allow_blank=True,
|
||
max_length=200,
|
||
help_text="Optional human-readable display name. Not backfilled from GitHub by the server.",
|
||
)
|
||
reason = serializers.CharField(
|
||
required=False,
|
||
allow_blank=True,
|
||
allow_null=True,
|
||
max_length=500,
|
||
help_text=(
|
||
"Optional short evidence for why this reviewer was chosen. Omitted entries keep the "
|
||
"prior reason for reviewers already on the report."
|
||
),
|
||
)
|
||
|
||
def validate(self, attrs: dict) -> dict:
|
||
if not attrs.get("github_login") and not attrs.get("user_uuid"):
|
||
raise serializers.ValidationError("Each entry must include `github_login` or `user_uuid` (or both).")
|
||
return attrs
|
||
|
||
|
||
class SignalReportArtefactWriteSerializer(serializers.Serializer):
|
||
"""PUT body for replacing a `suggested_reviewers` artefact's content.
|
||
|
||
Only `suggested_reviewers` artefacts may be modified via this endpoint;
|
||
the viewset enforces the type check before validation runs.
|
||
"""
|
||
|
||
MAX_ENTRIES = 10
|
||
|
||
content = SuggestedReviewerEntryWriteSerializer(
|
||
many=True,
|
||
allow_empty=True,
|
||
help_text=(
|
||
f"Full replacement list of reviewers. Empty list clears the artefact. At most {MAX_ENTRIES} entries."
|
||
),
|
||
)
|
||
|
||
def validate_content(self, value: list[dict]) -> list[dict]:
|
||
if len(value) > self.MAX_ENTRIES:
|
||
raise serializers.ValidationError(f"At most {self.MAX_ENTRIES} reviewers may be supplied.")
|
||
return value
|
||
|
||
|
||
# Writable types only — `video_segment` (and any other NON_WRITABLE type) is read-only and rejected
|
||
# by the write API, so it must not be advertised as an option here.
|
||
_WRITABLE_ARTEFACT_TYPES = sorted(set(SignalReportArtefact.ArtefactType.values) - NON_WRITABLE_ARTEFACT_TYPES)
|
||
|
||
_ARTEFACT_TYPES_HELP = (
|
||
"The artefact type. One of: "
|
||
+ ", ".join(_WRITABLE_ARTEFACT_TYPES)
|
||
+ ". Log types accumulate; status types (safety_judgment, actionability_judgment, "
|
||
"priority_judgment, repo_selection, suggested_reviewers) are latest-wins — appending a new "
|
||
"version supersedes the previous one as the report's canonical status."
|
||
)
|
||
|
||
|
||
def _validate_artefact_content_is_container(value: object) -> dict | list:
|
||
if not isinstance(value, dict | list):
|
||
raise serializers.ValidationError("content must be a JSON object or array.")
|
||
return value
|
||
|
||
|
||
class SignalReportArtefactLogCreateSerializer(serializers.Serializer):
|
||
"""Body for appending an artefact to a report.
|
||
|
||
Everything is append-only: log artefacts accumulate, status artefacts supersede the previous
|
||
version (latest-wins). The `content` shape depends on `artefact_type` and is validated
|
||
against the type's schema (see `products/signals/backend/artefact_schemas.py`).
|
||
"""
|
||
|
||
# Plain CharField (not ChoiceField) on purpose: the value is validated against
|
||
# `ArtefactType.values` in the view, and avoiding a `choices=` enum keeps this off the
|
||
# collision-prone enum-name path in the generated OpenAPI types.
|
||
artefact_type = serializers.CharField(help_text=_ARTEFACT_TYPES_HELP)
|
||
content = serializers.JSONField(
|
||
help_text="The artefact payload as a JSON object or array; shape depends on artefact_type "
|
||
"and is validated against its schema.",
|
||
)
|
||
|
||
def validate_content(self, value: object) -> dict | list:
|
||
# Shape-only here: the view is the schema boundary — it parses the payload into the
|
||
# type's content model (after normalizing task_run defaults) and 400s on a mismatch.
|
||
return _validate_artefact_content_is_container(value)
|
||
|
||
|
||
class SignalReportArtefactLogUpdateSerializer(serializers.Serializer):
|
||
"""Body for replacing the content of an existing artefact (addressed by id).
|
||
|
||
Per-type schema validation happens in the view, which knows the artefact's type.
|
||
"""
|
||
|
||
content = serializers.JSONField(
|
||
help_text="The new artefact payload as a JSON object or array, matching the artefact type's schema."
|
||
)
|
||
|
||
def validate_content(self, value: object) -> dict | list:
|
||
return _validate_artefact_content_is_container(value)
|
||
|
||
|
||
class SignalReportArtefactWriteResponseSerializer(serializers.Serializer):
|
||
"""Response shape for the log-artefact create/update endpoints — echoes the stored row."""
|
||
|
||
id = serializers.UUIDField(read_only=True, help_text="The artefact's unique id.")
|
||
report_id = serializers.UUIDField(read_only=True, help_text="The id of the report this artefact belongs to.")
|
||
# Plain CharField (no `choices=`) to keep the model's full ArtefactType enum out of the
|
||
# generated OpenAPI schema; the value is simply echoed back.
|
||
type = serializers.CharField(read_only=True, help_text="The artefact type.")
|
||
content = serializers.JSONField(read_only=True, help_text="The artefact payload, parsed from storage.")
|
||
created_at = serializers.DateTimeField(read_only=True, help_text="When the artefact was created.")
|
||
updated_at = serializers.DateTimeField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
help_text="When the artefact was last written — set on creation and refreshed on each edit. "
|
||
"Null only for rows created before this field existed.",
|
||
)
|
||
task_id = serializers.UUIDField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
help_text="Task the artefact is attributed to, when an agent produced it. Null for user writes.",
|
||
)
|
||
|
||
|
||
class CommitDiffResponseSerializer(serializers.Serializer):
|
||
"""Response for the `commit` artefact diff endpoint — the commit's branch rendered against the
|
||
repository default branch."""
|
||
|
||
diff = serializers.CharField(
|
||
read_only=True,
|
||
help_text="Unified diff (patch) text of the branch against the repository default branch, "
|
||
"from the GitHub compare API.",
|
||
)
|
||
truncated = serializers.BooleanField(
|
||
read_only=True,
|
||
help_text="True when the diff was too large to return in full and has been truncated.",
|
||
)
|
||
|
||
|
||
class PullRequestCheckSerializer(serializers.Serializer):
|
||
"""One CI check on a pull request's head commit — a GitHub Actions check run or a legacy commit
|
||
status, normalized to a common shape."""
|
||
|
||
name = serializers.CharField(read_only=True, help_text="Check run name or status context.")
|
||
status = serializers.CharField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
help_text="Lifecycle state: 'queued', 'in_progress', or 'completed'.",
|
||
)
|
||
conclusion = serializers.CharField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
help_text="Outcome once completed: 'success', 'failure', 'neutral', 'cancelled', 'skipped', "
|
||
"'timed_out', or 'action_required'. Null while still running.",
|
||
)
|
||
url = serializers.CharField(
|
||
read_only=True, allow_null=True, help_text="Link to the check run / status detail on GitHub."
|
||
)
|
||
|
||
|
||
class PullRequestChecksResponseSerializer(serializers.Serializer):
|
||
"""Response for the PR checks endpoint — the CI status of a report's implementation PR."""
|
||
|
||
checks = PullRequestCheckSerializer(many=True, read_only=True)
|
||
|
||
|
||
class PullRequestCommentReactionSerializer(serializers.Serializer):
|
||
"""One emoji reaction on a review comment, with the reactor so the viewer's own can be toggled."""
|
||
|
||
id = serializers.CharField(read_only=True, help_text="GitHub reaction id (needed to remove it).")
|
||
content = serializers.CharField(
|
||
read_only=True,
|
||
help_text="Reaction key: '+1', '-1', 'laugh', 'hooray', 'confused', 'heart', 'rocket', or 'eyes'.",
|
||
)
|
||
user_login = serializers.CharField(
|
||
read_only=True, allow_null=True, help_text="GitHub login of the user who added the reaction."
|
||
)
|
||
|
||
|
||
class PullRequestCommentSerializer(serializers.Serializer):
|
||
"""One comment on a pull request — a conversation comment or an inline review comment."""
|
||
|
||
id = serializers.CharField(read_only=True, help_text="GitHub comment id.")
|
||
author = serializers.CharField(read_only=True, allow_null=True, help_text="Comment author's GitHub login.")
|
||
author_avatar_url = serializers.CharField(read_only=True, allow_null=True, help_text="Author's GitHub avatar URL.")
|
||
body = serializers.CharField(read_only=True, allow_blank=True, help_text="Comment body (GitHub-flavored markdown).")
|
||
created_at = serializers.CharField(read_only=True, allow_null=True, help_text="ISO 8601 creation timestamp.")
|
||
url = serializers.CharField(read_only=True, allow_null=True, help_text="Link to the comment on GitHub.")
|
||
comment_type = serializers.ChoiceField(
|
||
read_only=True,
|
||
choices=["conversation", "review"],
|
||
help_text="'conversation' for a PR discussion comment, 'review' for an inline code-review comment.",
|
||
)
|
||
path = serializers.CharField(
|
||
read_only=True, allow_null=True, help_text="File path the review comment is anchored to (review comments only)."
|
||
)
|
||
line = serializers.IntegerField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
help_text="Line in the diff the review comment is anchored to — the end line for multi-line comments "
|
||
"(review comments only; null when the comment is outdated relative to the PR head).",
|
||
)
|
||
start_line = serializers.IntegerField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
help_text="First line of a multi-line review comment's range (review comments only).",
|
||
)
|
||
side = serializers.ChoiceField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
choices=["LEFT", "RIGHT"],
|
||
help_text="Diff side the review comment is anchored to: 'LEFT' = deletions, 'RIGHT' = additions "
|
||
"(review comments only).",
|
||
)
|
||
diff_hunk = serializers.CharField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
help_text="Diff hunk excerpt the review comment applies to (review comments only).",
|
||
)
|
||
in_reply_to_id = serializers.CharField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
help_text="Id of the thread root comment this one replies to; null for thread roots and conversation comments.",
|
||
)
|
||
commit_id = serializers.CharField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
help_text="SHA of the commit the review comment was made against (review comments only).",
|
||
)
|
||
reactions = PullRequestCommentReactionSerializer(
|
||
many=True,
|
||
read_only=True,
|
||
help_text="Emoji reactions on this review comment, one entry per reactor.",
|
||
)
|
||
|
||
|
||
class PullRequestCommentsResponseSerializer(serializers.Serializer):
|
||
"""Response for the PR comments endpoint — conversation and review comments merged chronologically."""
|
||
|
||
comments = PullRequestCommentSerializer(many=True, read_only=True)
|
||
|
||
|
||
class PullRequestReviewCommentCreateSerializer(serializers.Serializer):
|
||
"""Request body for posting an inline PR review comment as the requesting user.
|
||
|
||
Two shapes: a reply to an existing thread (only `body` + `in_reply_to`), or a new
|
||
thread on a diff line (`body` + `path` + `line`, optionally `side`)."""
|
||
|
||
body = serializers.CharField(help_text="Comment body (GitHub-flavored markdown).", max_length=65536)
|
||
# Numeric-only: this id is interpolated into the GitHub reply URL, so an unconstrained string could
|
||
# smuggle path segments (e.g. `../../issues/1/comments`) and retarget the request.
|
||
in_reply_to = serializers.RegexField(
|
||
r"^[0-9]+$",
|
||
required=False,
|
||
allow_null=True,
|
||
help_text="Numeric id of the thread root comment to reply to. When set, path/line/side are ignored.",
|
||
)
|
||
path = serializers.CharField(
|
||
required=False,
|
||
allow_null=True,
|
||
help_text="File path to anchor a new comment thread to (required when starting a new thread).",
|
||
)
|
||
line = serializers.IntegerField(
|
||
required=False,
|
||
allow_null=True,
|
||
min_value=1,
|
||
help_text="Diff line to anchor a new comment thread to (required when starting a new thread).",
|
||
)
|
||
side = serializers.ChoiceField(
|
||
required=False,
|
||
allow_null=True,
|
||
choices=["LEFT", "RIGHT"],
|
||
help_text="Diff side of the anchor line: 'LEFT' = deletions, 'RIGHT' = additions. Defaults to 'RIGHT'.",
|
||
)
|
||
|
||
def validate(self, attrs: dict) -> dict:
|
||
if not attrs.get("in_reply_to") and not (attrs.get("path") and attrs.get("line")):
|
||
raise serializers.ValidationError("Provide either in_reply_to (reply) or path + line (new thread).")
|
||
return attrs
|
||
|
||
|
||
class PullRequestReviewCommentCreateResponseSerializer(serializers.Serializer):
|
||
"""Response after posting a review comment — the created comment in the normalized PR-comment shape."""
|
||
|
||
comment = PullRequestCommentSerializer(read_only=True)
|
||
|
||
|
||
class PullRequestReviewCommentUpdateSerializer(serializers.Serializer):
|
||
"""Request body for editing a review comment's markdown body."""
|
||
|
||
body = serializers.CharField(help_text="New comment body (GitHub-flavored markdown).", max_length=65536)
|
||
|
||
|
||
_REACTION_CONTENTS = ["+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes"]
|
||
|
||
|
||
class PullRequestReviewCommentReactionCreateSerializer(serializers.Serializer):
|
||
"""Request body for adding an emoji reaction to a review comment."""
|
||
|
||
content = serializers.ChoiceField(
|
||
choices=_REACTION_CONTENTS,
|
||
help_text="Reaction to add: one of '+1', '-1', 'laugh', 'hooray', 'confused', 'heart', 'rocket', 'eyes'.",
|
||
)
|
||
|
||
|
||
class PullRequestReviewCommentReactionCreateResponseSerializer(serializers.Serializer):
|
||
"""Response after adding a reaction — the created reaction, so the frontend can track its id."""
|
||
|
||
reaction = PullRequestCommentReactionSerializer(read_only=True)
|