diff --git a/openapi.public.json b/openapi.public.json index 01ccfc5..7ec47b6 100644 --- a/openapi.public.json +++ b/openapi.public.json @@ -5991,6 +5991,68 @@ ], "type": "object" }, + "ScanRequestBody": { + "additionalProperties": false, + "description": "One request to scan a ref or an exact commit of the repository.", + "properties": { + "budget_credits": { + "description": "The credits the whole scan may spend, at most the organization's\n on-demand ceiling.", + "format": "int64", + "type": "integer" + }, + "commit_sha": { + "description": "An exact commit. Name it or `ref`.", + "type": [ + "string", + "null" + ] + }, + "ref": { + "description": "A ref, resolved once when the request arrives. Name it or `commit_sha`.", + "type": [ + "string", + "null" + ] + }, + "request_id": { + "description": "The caller's identity for this request. Sending it again returns the\n first resolution instead of scanning again.", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "request_id", + "budget_credits" + ], + "type": "object" + }, + "ScanResponse": { + "description": "What a scan request resolved to.", + "properties": { + "outcome": { + "description": "`CREATED`, `EXISTING` when this request id was already resolved, or\n `REPOSITORY_UNAVAILABLE` when GitHub could not resolve it.", + "type": "string" + }, + "resolution_id": { + "description": "The request's one resolution receipt.", + "format": "uuid", + "type": "string" + }, + "review_id": { + "description": "The scan review, readable through the review routes. Absent when\n GitHub could not resolve the request.", + "format": "uuid", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "resolution_id", + "outcome" + ], + "type": "object" + }, "SpecialistAssessmentExecution": { "properties": { "assessment_id": { @@ -7596,6 +7658,84 @@ "x-proofline-token-scope": "guidance:read" } }, + "/api/v1/organizations/{org_id}/repositories/{repo_id}/scans": { + "post": { + "description": "Scans the whole repository at one commit, resolved once from `ref` or named by `commit_sha`, and records that resolution. Requires live GitHub write access, and `budget_credits` at most the organization's on-demand scan limit; a limit of 0 turns on-demand scans off. Sending the same `request_id` again returns the first resolution. Results remain in Proofline and are never posted to GitHub.", + "operationId": "request_scan", + "parameters": [ + { + "in": "path", + "name": "org_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + }, + "style": "simple" + }, + { + "in": "path", + "name": "repo_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanRequestBody" + } + } + }, + "description": "One request to scan a ref or an exact commit of the repository.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanResponse" + } + } + }, + "description": "What a scan request resolved to." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunNowRefused" + } + } + }, + "description": "A run-now request that created nothing, as the 400 it is answered with.\n\n The refusal is a configuration fact, not a failure, and it is still sent\n as an RFC 9457 problem: `title`, `status` and `detail` are what a client\n that knows only problems shows, and `repository_name` and `refusal` are\n the extension members a client that knows refusals links from. During a\n rollout a page loaded from the previous bundle keeps working against\n this server, because a refusal was a 400 with a `detail` before too." + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "An RFC 9457 problem; the HTTP status remains authoritative." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Scan a repository revision", + "x-proofline-token-scope": "scans:request" + } + }, "/api/v1/organizations/{org_id}/repository-names": { "get": { "description": "Returns the ID and full name of each connected repository inside the token's repository restriction, including repositories with no review, so a client can resolve `owner/name` to an ID.", diff --git a/proofline/__init__.py b/proofline/__init__.py index f9dbdb9..5e4bfcc 100644 --- a/proofline/__init__.py +++ b/proofline/__init__.py @@ -25,6 +25,8 @@ RunNowMode, RunNowRequest, RunNowResponse, + ScanRequestBody, + ScanResponse, StartSession, StopResponse, UploadedReport, @@ -67,6 +69,8 @@ "RunNowMode", "RunNowRequest", "RunNowResponse", + "ScanRequestBody", + "ScanResponse", "SecretStr", "StartSession", "StopResponse", diff --git a/proofline/_generated/api/default/request_scan.py b/proofline/_generated/api/default/request_scan.py new file mode 100644 index 0000000..81e3062 --- /dev/null +++ b/proofline/_generated/api/default/request_scan.py @@ -0,0 +1,220 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote +from uuid import UUID + +import httpx + +from ...client import AuthenticatedClient, Client +from ...models.problem import Problem +from ...models.run_now_refused import RunNowRefused +from ...models.scan_request_body import ScanRequestBody +from ...models.scan_response import ScanResponse +from ...types import Response + + +def _get_kwargs( + org_id: UUID, + repo_id: UUID, + *, + body: ScanRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v1/organizations/{org_id}/repositories/{repo_id}/scans".format( + org_id=quote(str(org_id), safe=""), + repo_id=quote(str(repo_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Problem | RunNowRefused | ScanResponse: + if response.status_code == 200: + response_200 = ScanResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = RunNowRefused.from_dict(response.json()) + + return response_400 + + response_default = Problem.from_dict(response.json()) + + return response_default + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Problem | RunNowRefused | ScanResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + org_id: UUID, + repo_id: UUID, + *, + client: AuthenticatedClient, + body: ScanRequestBody, +) -> Response[Problem | RunNowRefused | ScanResponse]: + """Scan a repository revision + + Scans the whole repository at one commit, resolved once from `ref` or named by `commit_sha`, and + records that resolution. Requires live GitHub write access, and `budget_credits` at most the + organization's on-demand scan limit; a limit of 0 turns on-demand scans off. Sending the same + `request_id` again returns the first resolution. Results remain in Proofline and are never posted to + GitHub. + + Args: + org_id (UUID): + repo_id (UUID): + body (ScanRequestBody): One request to scan a ref or an exact commit of the repository. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Problem | RunNowRefused | ScanResponse] + """ + + kwargs = _get_kwargs( + org_id=org_id, + repo_id=repo_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + org_id: UUID, + repo_id: UUID, + *, + client: AuthenticatedClient, + body: ScanRequestBody, +) -> Problem | RunNowRefused | ScanResponse | None: + """Scan a repository revision + + Scans the whole repository at one commit, resolved once from `ref` or named by `commit_sha`, and + records that resolution. Requires live GitHub write access, and `budget_credits` at most the + organization's on-demand scan limit; a limit of 0 turns on-demand scans off. Sending the same + `request_id` again returns the first resolution. Results remain in Proofline and are never posted to + GitHub. + + Args: + org_id (UUID): + repo_id (UUID): + body (ScanRequestBody): One request to scan a ref or an exact commit of the repository. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Problem | RunNowRefused | ScanResponse + """ + + return sync_detailed( + org_id=org_id, + repo_id=repo_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + org_id: UUID, + repo_id: UUID, + *, + client: AuthenticatedClient, + body: ScanRequestBody, +) -> Response[Problem | RunNowRefused | ScanResponse]: + """Scan a repository revision + + Scans the whole repository at one commit, resolved once from `ref` or named by `commit_sha`, and + records that resolution. Requires live GitHub write access, and `budget_credits` at most the + organization's on-demand scan limit; a limit of 0 turns on-demand scans off. Sending the same + `request_id` again returns the first resolution. Results remain in Proofline and are never posted to + GitHub. + + Args: + org_id (UUID): + repo_id (UUID): + body (ScanRequestBody): One request to scan a ref or an exact commit of the repository. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Problem | RunNowRefused | ScanResponse] + """ + + kwargs = _get_kwargs( + org_id=org_id, + repo_id=repo_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + org_id: UUID, + repo_id: UUID, + *, + client: AuthenticatedClient, + body: ScanRequestBody, +) -> Problem | RunNowRefused | ScanResponse | None: + """Scan a repository revision + + Scans the whole repository at one commit, resolved once from `ref` or named by `commit_sha`, and + records that resolution. Requires live GitHub write access, and `budget_credits` at most the + organization's on-demand scan limit; a limit of 0 turns on-demand scans off. Sending the same + `request_id` again returns the first resolution. Results remain in Proofline and are never posted to + GitHub. + + Args: + org_id (UUID): + repo_id (UUID): + body (ScanRequestBody): One request to scan a ref or an exact commit of the repository. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Problem | RunNowRefused | ScanResponse + """ + + return ( + await asyncio_detailed( + org_id=org_id, + repo_id=repo_id, + client=client, + body=body, + ) + ).parsed diff --git a/proofline/_generated/models/__init__.py b/proofline/_generated/models/__init__.py index 7e61de4..b54f22a 100644 --- a/proofline/_generated/models/__init__.py +++ b/proofline/_generated/models/__init__.py @@ -159,6 +159,8 @@ from .run_now_refused import RunNowRefused from .run_now_request import RunNowRequest from .run_now_response import RunNowResponse +from .scan_request_body import ScanRequestBody +from .scan_response import ScanResponse from .specialist_assessment_execution import SpecialistAssessmentExecution from .specialist_execution_view import SpecialistExecutionView from .specialist_output_projection import SpecialistOutputProjection @@ -342,6 +344,8 @@ "RunNowRefused", "RunNowRequest", "RunNowResponse", + "ScanRequestBody", + "ScanResponse", "SpecialistAssessmentExecution", "SpecialistExecutionView", "SpecialistOutputProjection", diff --git a/proofline/_generated/models/scan_request_body.py b/proofline/_generated/models/scan_request_body.py new file mode 100644 index 0000000..db3b956 --- /dev/null +++ b/proofline/_generated/models/scan_request_body.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast +from uuid import UUID + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ScanRequestBody") + + +@_attrs_define +class ScanRequestBody: + """One request to scan a ref or an exact commit of the repository. + + Attributes: + budget_credits (int): The credits the whole scan may spend, at most the organization's + on-demand ceiling. + request_id (UUID): The caller's identity for this request. Sending it again returns the + first resolution instead of scanning again. + commit_sha (None | str | Unset): An exact commit. Name it or `ref`. + ref (None | str | Unset): A ref, resolved once when the request arrives. Name it or `commit_sha`. + """ + + budget_credits: int + request_id: UUID + commit_sha: None | str | Unset = UNSET + ref: None | str | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + budget_credits = self.budget_credits + + request_id = str(self.request_id) + + commit_sha: None | str | Unset + if isinstance(self.commit_sha, Unset): + commit_sha = UNSET + else: + commit_sha = self.commit_sha + + ref: None | str | Unset + if isinstance(self.ref, Unset): + ref = UNSET + else: + ref = self.ref + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "budget_credits": budget_credits, + "request_id": request_id, + } + ) + if commit_sha is not UNSET: + field_dict["commit_sha"] = commit_sha + if ref is not UNSET: + field_dict["ref"] = ref + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + budget_credits = d.pop("budget_credits") + + request_id = UUID(d.pop("request_id")) + + def _parse_commit_sha(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + commit_sha = _parse_commit_sha(d.pop("commit_sha", UNSET)) + + def _parse_ref(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + ref = _parse_ref(d.pop("ref", UNSET)) + + scan_request_body = cls( + budget_credits=budget_credits, + request_id=request_id, + commit_sha=commit_sha, + ref=ref, + ) + + return scan_request_body diff --git a/proofline/_generated/models/scan_response.py b/proofline/_generated/models/scan_response.py new file mode 100644 index 0000000..4447d48 --- /dev/null +++ b/proofline/_generated/models/scan_response.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ScanResponse") + + +@_attrs_define +class ScanResponse: + """What a scan request resolved to. + + Attributes: + outcome (str): `CREATED`, `EXISTING` when this request id was already resolved, or + `REPOSITORY_UNAVAILABLE` when GitHub could not resolve it. + resolution_id (UUID): The request's one resolution receipt. + review_id (None | Unset | UUID): The scan review, readable through the review routes. Absent when + GitHub could not resolve the request. + """ + + outcome: str + resolution_id: UUID + review_id: None | Unset | UUID = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + outcome = self.outcome + + resolution_id = str(self.resolution_id) + + review_id: None | str | Unset + if isinstance(self.review_id, Unset): + review_id = UNSET + elif isinstance(self.review_id, UUID): + review_id = str(self.review_id) + else: + review_id = self.review_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "outcome": outcome, + "resolution_id": resolution_id, + } + ) + if review_id is not UNSET: + field_dict["review_id"] = review_id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + outcome = d.pop("outcome") + + resolution_id = UUID(d.pop("resolution_id")) + + def _parse_review_id(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() + review_id_type_0 = UUID(data) + + return review_id_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | Unset | UUID, data) + + review_id = _parse_review_id(d.pop("review_id", UNSET)) + + scan_response = cls( + outcome=outcome, + resolution_id=resolution_id, + review_id=review_id, + ) + + scan_response.additional_properties = d + return scan_response + + @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/client.py b/proofline/client.py index de6b525..0701cee 100644 --- a/proofline/client.py +++ b/proofline/client.py @@ -33,6 +33,7 @@ request_file_review, request_patch_review, request_review, + request_scan, rerun_review, start_agent_session, stop_review, @@ -65,6 +66,8 @@ from ._generated.models.review_summary import ReviewSummary from ._generated.models.run_now_request import RunNowRequest from ._generated.models.run_now_response import RunNowResponse +from ._generated.models.scan_request_body import ScanRequestBody +from ._generated.models.scan_response import ScanResponse from ._generated.models.start_session import StartSession from ._generated.models.stop_response import StopResponse from ._generated.types import UNSET, Response, Unset @@ -442,6 +445,17 @@ def request_file_review( body=body, ) + def request_scan( + self, organization_id: UUID, repository_id: UUID, *, body: ScanRequestBody + ) -> ScanResponse: + return self._call( + request_scan.sync_detailed, + ScanResponse, + organization_id, + repository_id, + body=body, + ) + def stop_review(self, organization_id: UUID, review_id: UUID) -> StopResponse: return self._call(stop_review.sync_detailed, StopResponse, organization_id, review_id) @@ -797,6 +811,17 @@ async def request_file_review( body=body, ) + async def request_scan( + self, organization_id: UUID, repository_id: UUID, *, body: ScanRequestBody + ) -> ScanResponse: + return await self._call( + request_scan.asyncio_detailed, + ScanResponse, + organization_id, + repository_id, + body=body, + ) + async def stop_review(self, organization_id: UUID, review_id: UUID) -> StopResponse: return await self._call( stop_review.asyncio_detailed, StopResponse, organization_id, review_id diff --git a/scripts/contracts.py b/scripts/contracts.py index e681909..917b4ee 100644 --- a/scripts/contracts.py +++ b/scripts/contracts.py @@ -24,9 +24,9 @@ def main() -> None: for path in (ROOT / "proofline/_generated/api/default").glob("*.py") if path.name != "__init__.py" } - if declared != generated or len(declared) != 21: + if declared != generated or len(declared) != 22: raise SystemExit( - "The generated JSON operations must match the twenty-one public JSON token routes." + "The generated JSON operations must match the twenty-two public JSON token routes." ) tree = ast.parse((ROOT / "proofline/client.py").read_text()) helpers = {"close", "aclose", "iter_reviews", "wait_for_review"} diff --git a/tests/test_http.py b/tests/test_http.py index c543b02..a0442e6 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -41,6 +41,8 @@ RunNowMode, RunNowRequest, RunNowResponse, + ScanRequestBody, + ScanResponse, StopResponse, UploadedReport, _sync_http, @@ -394,6 +396,37 @@ def respond(request: Request, index: int) -> Reply: assert len(server.requests) == 1 +@MODES +async def test_revision_scans_post_the_pinned_request_and_decode_unavailable(mode: str) -> None: + body = ScanRequestBody(request_id=REVIEW, commit_sha="a" * 40, budget_credits=50) + + def respond(request: Request, index: int) -> Reply: + assert request.method == "POST" + assert request.path == f"/api/v1/organizations/{ORG}/repositories/{REPOSITORY}/scans" + assert request.headers["Authorization"] == f"Bearer {TOKEN}" + assert json.loads(request.body) == { + "request_id": str(REVIEW), + "commit_sha": "a" * 40, + "budget_credits": 50, + } + return Reply( + body={ + "resolution_id": str(REVIEW), + "review_id": None, + "outcome": "REPOSITORY_UNAVAILABLE", + } + ) + + with Server(respond) as server: + async with session(mode, server.url) as client: + result = await call(client, "request_scan", ORG, REPOSITORY, body=body) + assert isinstance(result, ScanResponse) + assert result.resolution_id == REVIEW + assert result.review_id is None + assert result.outcome == "REPOSITORY_UNAVAILABLE" + assert len(server.requests) == 1 + + @MODES async def test_coverage_upload_preserves_reports_commit_and_server_refusal(mode: str) -> None: content = "SF:src/main.py\nDA:1,1\nDA:2,0\nend_of_record\n"