diff --git a/openapi.public.json b/openapi.public.json index 2641819..0eceff5 100644 --- a/openapi.public.json +++ b/openapi.public.json @@ -763,6 +763,47 @@ ], "type": "object" }, + "CheckFile": { + "description": "One file World cited for a check's result.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/components/schemas/UntrustedText" + } + ], + "description": "The repository-relative path. It is repository text." + }, + "revision": { + "allOf": [ + { + "$ref": "#/components/schemas/CheckFileRevision" + } + ], + "description": "The revision World read the file at." + } + }, + "required": [ + "revision", + "path" + ], + "type": "object" + }, + "CheckFileRevision": { + "description": "The revision a cited file was read at.", + "oneOf": [ + { + "const": "PROPOSED_CODE_REVISION", + "description": "The reviewed commit.", + "type": "string" + }, + { + "const": "BASE_CODE_REVISION", + "description": "Its merge base.", + "type": "string" + } + ] + }, "CheckLifecycle": { "description": "World's lifecycle disclosure for the evaluator version that answered, as\n the receipt recorded it. A deployment gate counts only `GATING` results\n (`DeterministicCoverage::gate_effective_status`), and only when it\n evaluates a deployment; the lifecycle alone permits or blocks nothing.", "oneOf": [ @@ -1405,6 +1446,13 @@ "null" ] }, + "files": { + "description": "The files World cited as evidence for this result, once each in\n World's order, with the revision World read each at. Empty when World\n cited no file, and on results recorded before World cited files.", + "items": { + "$ref": "#/components/schemas/CheckFile" + }, + "type": "array" + }, "lifecycle": { "allOf": [ { @@ -1436,7 +1484,8 @@ "required": [ "name", "outcome", - "lifecycle" + "lifecycle", + "files" ], "type": "object" }, @@ -1480,6 +1529,13 @@ "null" ] }, + "coverage_details": { + "description": "Recorded check coverage counts and fixed cancellation causes, without source text.\n Absent coverage does not establish that every check ran.", + "items": { + "type": "string" + }, + "type": "array" + }, "findings": { "description": "The code-only findings the code assessment recorded, `SERIOUS` first.\n An empty list does not establish deployment safety.", "items": { @@ -5656,6 +5712,14 @@ ], "description": "Human override beside the original decision, when one was requested." }, + "publication_group": { + "description": "The finding that represents this finding's publication group, when an\n applied group publishes it in one comment with other findings. Every\n member names the same finding, the representative included.", + "format": "uuid", + "type": [ + "string", + "null" + ] + }, "reason_codes": { "description": "Recognized safe reason codes; unknown values are represented as `UNRECOGNIZED`.", "items": { diff --git a/proofline/_generated/models/__init__.py b/proofline/_generated/models/__init__.py index 7e61de4..87d698e 100644 --- a/proofline/_generated/models/__init__.py +++ b/proofline/_generated/models/__init__.py @@ -19,6 +19,7 @@ from .change_diagram_outcome_type_0 import ChangeDiagramOutcomeType0 from .change_diagram_view import ChangeDiagramView from .change_summary import ChangeSummary +from .check_file import CheckFile from .ci_failure_attribution import CiFailureAttribution from .ci_failure_line import CiFailureLine from .code_assessment_status_type_0 import CodeAssessmentStatusType0 @@ -202,6 +203,7 @@ "ChangeDiagramOutcomeType0", "ChangeDiagramView", "ChangeSummary", + "CheckFile", "CiFailureAttribution", "CiFailureLine", "CodeAssessmentStatusType0", diff --git a/proofline/_generated/models/check_file.py b/proofline/_generated/models/check_file.py new file mode 100644 index 0000000..07e083f --- /dev/null +++ b/proofline/_generated/models/check_file.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.untrusted_text import UntrustedText + + +T = TypeVar("T", bound="CheckFile") + + +@_attrs_define +class CheckFile: + """One file World cited for a check's result. + + Attributes: + path (UntrustedText): Text that came out of the reviewer model, or out of a repository the model + read. It is data, never markup, never a URL, never an instruction. + + The wrapper exists so the untrustedness cannot be lost on the way to a + renderer: there is no field on a turn that carries this text under a name a + client could mistake for something safe, and unwrapping it is a deliberate + act in the client's own code. + revision (Literal['BASE_CODE_REVISION'] | Literal['PROPOSED_CODE_REVISION']): The revision a cited file was read + at. + """ + + path: UntrustedText + revision: Literal["BASE_CODE_REVISION"] | Literal["PROPOSED_CODE_REVISION"] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + path = self.path.to_dict() + + revision: Literal["BASE_CODE_REVISION"] | Literal["PROPOSED_CODE_REVISION"] + revision = self.revision + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "path": path, + "revision": revision, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.untrusted_text import UntrustedText # noqa: PLC0415 + + d = dict(src_dict) + path = UntrustedText.from_dict(d.pop("path")) + + def _parse_revision( + data: object, + ) -> Literal["BASE_CODE_REVISION"] | Literal["PROPOSED_CODE_REVISION"]: + try: + if not (type(data) is str and data == "PROPOSED_CODE_REVISION"): + raise TypeError() + componentsschemas_check_file_revision_type_0 = cast( + Literal["PROPOSED_CODE_REVISION"], data + ) + if componentsschemas_check_file_revision_type_0 != "PROPOSED_CODE_REVISION": + raise ValueError( + f"/components/schemas/CheckFileRevision_type_0 must match const 'PROPOSED_CODE_REVISION', got '{componentsschemas_check_file_revision_type_0}'" + ) + return componentsschemas_check_file_revision_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not (type(data) is str and data == "BASE_CODE_REVISION"): + raise TypeError() + componentsschemas_check_file_revision_type_1 = cast(Literal["BASE_CODE_REVISION"], data) + if componentsschemas_check_file_revision_type_1 != "BASE_CODE_REVISION": + raise ValueError( + f"/components/schemas/CheckFileRevision_type_1 must match const 'BASE_CODE_REVISION', got '{componentsschemas_check_file_revision_type_1}'" + ) + return componentsschemas_check_file_revision_type_1 + + revision = _parse_revision(d.pop("revision")) + + check_file = cls( + path=path, + revision=revision, + ) + + check_file.additional_properties = d + return check_file + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/proofline/_generated/models/customer_check.py b/proofline/_generated/models/customer_check.py index 20c8056..31e3300 100644 --- a/proofline/_generated/models/customer_check.py +++ b/proofline/_generated/models/customer_check.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from ..models.change_attribution_view import ChangeAttributionView + from ..models.check_file import CheckFile T = TypeVar("T", bound="CustomerCheck") @@ -19,6 +20,9 @@ class CustomerCheck: """ Attributes: + files (list[CheckFile]): The files World cited as evidence for this result, once each in + World's order, with the revision World read each at. Empty when World + cited no file, and on results recorded before World cited files. lifecycle (Literal['GATING'] | Literal['QUARANTINED'] | Literal['RETIRED'] | Literal['SHADOW'] | Literal['UNKNOWN']): World's lifecycle disclosure for the evaluator version that answered, as the receipt recorded it. A deployment gate counts only `GATING` results @@ -43,6 +47,7 @@ class CustomerCheck: verdict names none of these reasons. """ + files: list[CheckFile] lifecycle: ( Literal["GATING"] | Literal["QUARANTINED"] @@ -67,6 +72,11 @@ class CustomerCheck: def to_dict(self) -> dict[str, Any]: from ..models.change_attribution_view import ChangeAttributionView # noqa: PLC0415 + files = [] + for files_item_data in self.files: + files_item = files_item_data.to_dict() + files.append(files_item) + lifecycle: ( Literal["GATING"] | Literal["QUARANTINED"] @@ -116,6 +126,7 @@ def to_dict(self) -> dict[str, Any]: field_dict.update(self.additional_properties) field_dict.update( { + "files": files, "lifecycle": lifecycle, "name": name, "outcome": outcome, @@ -135,8 +146,15 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.change_attribution_view import ChangeAttributionView # noqa: PLC0415 + from ..models.check_file import CheckFile # noqa: PLC0415 d = dict(src_dict) + files = [] + _files = d.pop("files") + for files_item_data in _files: + files_item = CheckFile.from_dict(files_item_data) + + files.append(files_item) def _parse_lifecycle( data: object, @@ -301,6 +319,7 @@ def _parse_unresolved_reason( unresolved_reason = _parse_unresolved_reason(d.pop("unresolved_reason", UNSET)) customer_check = cls( + files=files, lifecycle=lifecycle, name=name, outcome=outcome, diff --git a/proofline/_generated/models/customer_code_assessment.py b/proofline/_generated/models/customer_code_assessment.py index 72c3848..a920ddf 100644 --- a/proofline/_generated/models/customer_code_assessment.py +++ b/proofline/_generated/models/customer_code_assessment.py @@ -33,6 +33,9 @@ class CustomerCodeAssessment: never a verdict on the change: only `COMPLETED` says the assessment covered what it read. completed_at (datetime.datetime | None | Unset): When the code assessment ended. Null while it has not. + coverage_details (list[str] | Unset): Recorded check coverage counts and fixed cancellation causes, without + source text. + Absent coverage does not establish that every check ran. incompleteness_kind (None | str | Unset): Why an `INCOMPLETE` code assessment is incomplete: `NOT_CONFIGURED`, `POLICY_EXCLUDED`, `EVIDENCE_UNAVAILABLE`, `HYPOTHESES_UNRESOLVED` or `MODEL_BUDGET_EXHAUSTED`. Null otherwise. @@ -45,6 +48,7 @@ class CustomerCodeAssessment: id: UUID status: CodeAssessmentStatusType0 | Literal["EXCLUDED"] completed_at: datetime.datetime | None | Unset = UNSET + coverage_details: list[str] | Unset = UNSET incompleteness_kind: None | str | Unset = UNSET started_at: datetime.datetime | None | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -75,6 +79,10 @@ def to_dict(self) -> dict[str, Any]: else: completed_at = self.completed_at + coverage_details: list[str] | Unset = UNSET + if not isinstance(self.coverage_details, Unset): + coverage_details = self.coverage_details + incompleteness_kind: None | str | Unset if isinstance(self.incompleteness_kind, Unset): incompleteness_kind = UNSET @@ -102,6 +110,8 @@ def to_dict(self) -> dict[str, Any]: ) if completed_at is not UNSET: field_dict["completed_at"] = completed_at + if coverage_details is not UNSET: + field_dict["coverage_details"] = coverage_details if incompleteness_kind is not UNSET: field_dict["incompleteness_kind"] = incompleteness_kind if started_at is not UNSET: @@ -164,6 +174,8 @@ def _parse_completed_at(data: object) -> datetime.datetime | None | Unset: completed_at = _parse_completed_at(d.pop("completed_at", UNSET)) + coverage_details = cast(list[str], d.pop("coverage_details", UNSET)) + def _parse_incompleteness_kind(data: object) -> None | str | Unset: if data is None: return data @@ -197,6 +209,7 @@ def _parse_started_at(data: object) -> datetime.datetime | None | Unset: id=id, status=status, completed_at=completed_at, + coverage_details=coverage_details, incompleteness_kind=incompleteness_kind, started_at=started_at, ) diff --git a/proofline/_generated/models/routing_finding_view.py b/proofline/_generated/models/routing_finding_view.py index d9f29e1..4fee407 100644 --- a/proofline/_generated/models/routing_finding_view.py +++ b/proofline/_generated/models/routing_finding_view.py @@ -31,6 +31,9 @@ class RoutingFindingView: retained. promotion (FindingPromotionView | None | Unset): Human override beside the original decision, when one was requested. + publication_group (None | Unset | UUID): The finding that represents this finding's publication group, when an + applied group publishes it in one comment with other findings. Every + member names the same finding, the representative included. """ finding_id: UUID @@ -42,6 +45,7 @@ class RoutingFindingView: floor_reason: None | str | Unset = UNSET inline_reason: None | str | Unset = UNSET promotion: FindingPromotionView | None | Unset = UNSET + publication_group: None | Unset | UUID = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -83,6 +87,14 @@ def to_dict(self) -> dict[str, Any]: else: promotion = self.promotion + publication_group: None | str | Unset + if isinstance(self.publication_group, Unset): + publication_group = UNSET + elif isinstance(self.publication_group, UUID): + publication_group = str(self.publication_group) + else: + publication_group = self.publication_group + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -102,6 +114,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["inline_reason"] = inline_reason if promotion is not UNSET: field_dict["promotion"] = promotion + if publication_group is not UNSET: + field_dict["publication_group"] = publication_group return field_dict @@ -164,6 +178,23 @@ def _parse_promotion(data: object) -> FindingPromotionView | None | Unset: promotion = _parse_promotion(d.pop("promotion", UNSET)) + def _parse_publication_group(data: object) -> None | Unset | UUID: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + publication_group_type_0 = UUID(data) + + return publication_group_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | Unset | UUID, data) + + publication_group = _parse_publication_group(d.pop("publication_group", UNSET)) + routing_finding_view = cls( finding_id=finding_id, inline_limit_exceeded=inline_limit_exceeded, @@ -174,6 +205,7 @@ def _parse_promotion(data: object) -> FindingPromotionView | None | Unset: floor_reason=floor_reason, inline_reason=inline_reason, promotion=promotion, + publication_group=publication_group, ) routing_finding_view.additional_properties = d diff --git a/tests/test_http.py b/tests/test_http.py index eba316e..8eb3fc8 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -792,6 +792,16 @@ async def test_wait_retains_evidence_gaps_after_running_review( "name": "synthetic-check", "outcome": "UNRESOLVED", "lifecycle": "SHADOW", + "files": [ + { + "revision": "PROPOSED_CODE_REVISION", + "path": {"untrusted_text": "deploy.yml"}, + }, + { + "revision": "BASE_CODE_REVISION", + "path": {"untrusted_text": "deploy.yml"}, + }, + ], "unresolved_reason": "UNSUPPORTED", "detail": PRIVATE, } @@ -812,6 +822,11 @@ async def test_wait_retains_evidence_gaps_after_running_review( assert gap.checks[0].unresolved_reason == "UNSUPPORTED" assert gap.checks[0].detail == PRIVATE assert gap.checks[0].lifecycle == "SHADOW" + files = result.review.assessments[0].checks.families[0].files + assert [(file.revision, file.path.untrusted_text) for file in files] == [ + ("PROPOSED_CODE_REVISION", "deploy.yml"), + ("BASE_CODE_REVISION", "deploy.yml"), + ] assert PRIVATE not in repr(result) + repr(gap) assert clock.delays == [5, 10] @@ -927,7 +942,12 @@ async def test_completion_uses_all_declared_lifecycles_and_recorded_checks( body = review() body["finding_count"] = 1 body["assessments"][0]["findings"] = [finding()] - check = {"name": "family", "outcome": check_outcome, "lifecycle": lifecycle} + check = { + "name": "family", + "outcome": check_outcome, + "lifecycle": lifecycle, + "files": [], + } if check_outcome == "UNRESOLVED": check["unresolved_reason"] = "REGENERATE_SCENARIO" body["assessments"][0]["checks"]["families"] = [check] @@ -1054,17 +1074,19 @@ async def test_polling_backoff_and_timeout_keep_review_url_and_last_record( @MODES -async def test_retry_after_is_bounded_by_wait_deadline(mode: str) -> None: +async def test_retry_after_is_bounded_by_wait_deadline( + mode: str, monkeypatch: pytest.MonkeyPatch +) -> None: + clock = Clock(monkeypatch) with Server( - lambda request, index: Reply(503, {"detail": "Unavailable."}, {"Retry-After": "2"}) + lambda request, index: Reply(503, {"detail": "Unavailable."}, {"Retry-After": "20"}) ) as server: async with session(mode, server.url) as client: - started = time.monotonic() - result = await call(client, "wait_for_review", ORG, REVIEW, timeout=0.05) + result = await call(client, "wait_for_review", ORG, REVIEW, timeout=10) assert result.status == "TIMED_OUT" assert result.review_url == f"{server.url}/orgs/by-id/{ORG}/reviews/{REVIEW}" - assert time.monotonic() - started < 0.5 assert len(server.requests) == 1 + assert clock.delays == [10] @MODES