openapi: 3.1.0
info:
  title: BuildPulse Platform API
  version: "1.0.0"
  summary: Public REST API for BuildPulse CI analytics.
  description: |
    The BuildPulse Platform API exposes read-only test analytics for any
    repository onboarded to BuildPulse: paginated test submissions,
    disruptive/flaky test inventory, per-test disruption history, and SVG
    badges for flakiness and coverage.

    This API is the drop-in replacement for the legacy Rails API at
    `https://buildpulse.io/api`. Endpoint paths, query parameters, and
    response JSON shapes are intentionally identical so existing
    integrations migrate by changing only the base URL.

    ## Base URLs

    - Production: `https://platform.buildpulse.io`
    - Development: `https://platform.dev.buildpulse.io`

    ## Authentication

    All endpoints except `/health` require a 40-character hexadecimal API
    token in the `Authorization` header. Both `token <hex>` and
    `Bearer <hex>` prefixes are accepted.

    Tokens are issued from the BuildPulse web app under Organization
    Settings → API Tokens. They are SHA256-hashed at rest; the plaintext
    token is shown only once at creation time.

    ## Response shapes

    JSON timestamps are ISO 8601 with second precision in UTC
    (e.g. `2026-05-17T12:34:56Z`). List endpoints always return an array
    for the data field, never `null`. Empty results return `[]`.

    ## Errors

    | Status | Body |
    |--------|------|
    | 401    | empty |
    | 404    | `{"message":"Not Found"}` |
    | 400    | `{"errors":{"after":["<msg>"]}}` |
    | 500    | empty |

    The empty-body 401/500 contract matches the legacy Rails behavior
    (`render body: nil`) and is preserved deliberately.

    ## Pagination

    Two cursor styles are in use, both base64-encoded so consumers should
    treat them as opaque:

    - **Keyset (submissions only)** — cursor is `base64("id:<24-hex>")`.
      Stable under inserts.
    - **Offset (tests, flaky tests)** — cursor is `base64("offset:<n>")`.

    The next-page cursor is returned as `metadata.after`. When there is
    no next page, `metadata.after` is `null`.

    ## Reporting window

    Endpoints that return time-bounded data (submissions, tests, flaky
    tests, badges, results) use a fixed **14-day** rolling window. This
    is hardcoded; a per-organization override is on the roadmap.

  license:
    name: Proprietary
    url: https://buildpulse.io/terms
  contact:
    name: BuildPulse Support
    email: support@buildpulse.io
    url: https://buildpulse.io
servers:
  - url: https://platform.buildpulse.io
    description: Production
  - url: https://platform.dev.buildpulse.io
    description: Development

tags:
  - name: Health
    description: Service health checks (unauthenticated).
  - name: Auth
    description: Token validation.
  - name: Submissions
    description: Test result sets (a.k.a. submissions) — one per CI run.
  - name: Tests
    description: Disruptive / flaky tests per repository.
  - name: Flaky
    description: Cross-repo flaky test inventory with optional field expansion.
  - name: Badges
    description: SVG status badges (flakiness, coverage).

security:
  - TokenAuth: []
  - BearerAuth: []

paths:
  /health:
    get:
      operationId: health
      tags: [Health]
      summary: Liveness probe.
      description: |
        Returns `{"status":"ok"}`. Used by the ALB / load balancer.
        Does not require authentication.
      security: []
      responses:
        "200":
          description: Service is healthy.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
              example:
                status: ok
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -s https://platform.buildpulse.io/health
        - lang: javascript
          label: Node
          source: |
            const res = await fetch("https://platform.buildpulse.io/health");
            console.log(await res.json());
        - lang: python
          label: Python
          source: |
            import requests
            r = requests.get("https://platform.buildpulse.io/health")
            print(r.json())

  /api:
    get:
      operationId: validateToken
      tags: [Auth]
      summary: Validate an API token.
      description: |
        Returns `204 No Content` when the supplied token is valid and the
        organization has an active plan. Use this as a cheap sanity check
        before issuing other calls.
      responses:
        "204":
          description: Token is valid; organization has an active plan.
        "401":
          $ref: "#/components/responses/Unauthorized"
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -i -H "Authorization: token $BUILDPULSE_TOKEN" \
              https://platform.buildpulse.io/api
        - lang: javascript
          label: Node
          source: |
            const res = await fetch("https://platform.buildpulse.io/api", {
              headers: { Authorization: `token ${process.env.BUILDPULSE_TOKEN}` }
            });
            console.log(res.status); // 204
        - lang: python
          label: Python
          source: |
            import os, requests
            r = requests.get(
              "https://platform.buildpulse.io/api",
              headers={"Authorization": f"token {os.environ['BUILDPULSE_TOKEN']}"}
            )
            print(r.status_code)  # 204

  /api/me/organizations:
    get:
      operationId: listMyOrganizations
      tags: [Auth]
      summary: List organizations accessible to the current token.
      description: |
        Returns the set of organizations the calling token may scope
        requests to.

        For long-lived **apiToken** callers (single-org by
        construction), the list always has one element — the org the
        token was generated under.

        For ephemeral **mcpSession** callers (OAuth-minted from the
        hosted MCP), the list contains every org the authenticated
        Cognito user has a membership in. The agent should call this
        endpoint to enumerate the user's orgs, then pass the chosen
        `id` back as `?organization_id=<uuid>` on subsequent
        org-scoped requests.
      responses:
        "200":
          description: Caller's accessible organizations.
          content:
            application/json:
              schema:
                type: object
                required: [organizations]
                properties:
                  organizations:
                    type: array
                    items:
                      type: object
                      required: [id, name]
                      properties:
                        id:
                          type: string
                          description: |
                            Organization UUID (the value of the
                            `id` field on the organization document
                            — NOT the internal `_id` ObjectID).
                            Pass this as `?organization_id=…` on
                            subsequent requests.
                        name:
                          type: string
                        slug:
                          type: string
              example:
                organizations:
                  - id: "544f50de-5a15-4506-bb79-f00fb29e7317"
                    name: "Acme Inc."
                    slug: "acme"
                  - id: "8e3d2c1b-9a07-4f24-b1e3-3a8c2cdda8e1"
                    name: "Acme Spinoff"
                    slug: "acme-spinoff"
        "401":
          $ref: "#/components/responses/Unauthorized"
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -H "Authorization: Bearer $BUILDPULSE_TOKEN" \
              https://platform.buildpulse.io/api/me/organizations

  /api/repos/{owner}/{name}/submissions:
    get:
      operationId: listSubmissions
      tags: [Submissions]
      summary: List recent test submissions for a repository.
      description: |
        Returns the last 14 days of test result sets ("submissions") for
        the named repository, newest first. Pagination is keyset-based on
        the document ID — `metadata.after` is an opaque cursor that
        should be passed back as the `after` query parameter to fetch the
        next page.

        Repository lookup is case-insensitive; `OWNER/REPO` matches
        `owner/repo`.
      parameters:
        - $ref: "#/components/parameters/Owner"
        - $ref: "#/components/parameters/Name"
        - $ref: "#/components/parameters/Limit10"
        - $ref: "#/components/parameters/AfterCursor"
      responses:
        "200":
          description: Paginated submissions.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubmissionsResponse"
              example:
                count: 2
                submissions:
                  - key: "1234/5678/test-results.xml.gz"
                    build_url: "https://github.com/acme/widgets/actions/runs/1234"
                    commit_oid: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
                    recorded_at: "2026-05-17T12:34:56Z"
                    status: "processed"
                    test_result_count: 412
                  - key: "1234/5679/test-results.xml.gz"
                    build_url: "https://github.com/acme/widgets/actions/runs/1235"
                    commit_oid: "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0a1"
                    recorded_at: "2026-05-17T11:00:00Z"
                    status: "processed"
                    test_result_count: 412
                metadata:
                  after: "aWQ6NjVkNGYxYThiM2MyZDFlMGY5YThiN2M2"
                  limit: 10
        "400":
          $ref: "#/components/responses/BadCursor"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -H "Authorization: token $BUILDPULSE_TOKEN" \
              "https://platform.buildpulse.io/api/repos/acme/widgets/submissions?limit=5"
        - lang: javascript
          label: Node
          source: |
            const res = await fetch(
              "https://platform.buildpulse.io/api/repos/acme/widgets/submissions?limit=5",
              { headers: { Authorization: `token ${process.env.BUILDPULSE_TOKEN}` } }
            );
            const { submissions, metadata } = await res.json();
        - lang: python
          label: Python
          source: |
            import os, requests
            r = requests.get(
              "https://platform.buildpulse.io/api/repos/acme/widgets/submissions",
              headers={"Authorization": f"token {os.environ['BUILDPULSE_TOKEN']}"},
              params={"limit": 5},
            )
            r.raise_for_status()
            data = r.json()

  /api/repos/{owner}/{name}/tests:
    get:
      operationId: listTests
      tags: [Tests]
      summary: List disruptive (flaky) tests for a repository.
      description: |
        Returns tests that have been disruptive in the last 14 days,
        sorted by disruptiveness ratio (default) or by recency.

        The `q` parameter accepts a structured query string. Terms are
        space-separated and AND'd together. Supported terms:

        - `first-seen:[op]YYYY-MM-DD` — operators `<`, `<=`, `>`, `>=`
          (no operator = exact day match).
        - `last-seen:[op]YYYY-MM-DD` — same operator set.
        - `tags:foo,bar` — matches any listed tag.
        - bare text — partial, case-insensitive match against
          `suite`, `name`, `file`, and `classname`.

        Example: `q=last-seen:>=2026-05-01 tags:auth login`.
      parameters:
        - $ref: "#/components/parameters/Owner"
        - $ref: "#/components/parameters/Name"
        - $ref: "#/components/parameters/Limit10"
        - $ref: "#/components/parameters/AfterCursor"
        - $ref: "#/components/parameters/Sort"
        - $ref: "#/components/parameters/Q"
      responses:
        "200":
          description: Paginated disruptive tests.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TestsResponse"
              example:
                count: 1
                tests:
                  - id: "65d4f1a8b3c2d1e0f9a8b7c6"
                    name: "test_login_success"
                    suite: "auth"
                    class: "AuthTest"
                    file: "test/auth_test.rb"
                    disruptiveness: 0.375
                    nondeterministic_negative_result_count: 3
                    nondeterminism_first_recorded_at: "2026-05-10T08:00:00Z"
                    latest_nondeterministic_commit_oid: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
                    latest_nondeterministic_negative_result:
                      recorded_at: "2026-05-17T10:00:00Z"
                      build_url: "https://github.com/acme/widgets/actions/runs/9001"
                      message: "AssertionError: expected 200, got 500"
                    latest_nondeterministic_positive_result:
                      recorded_at: "2026-05-16T14:30:00Z"
                      build_url: "https://github.com/acme/widgets/actions/runs/8997"
                      message: null
                metadata:
                  after: null
                  limit: 10
        "400":
          $ref: "#/components/responses/BadCursor"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -H "Authorization: token $BUILDPULSE_TOKEN" \
              "https://platform.buildpulse.io/api/repos/acme/widgets/tests?q=last-seen:%3E%3D2026-05-01&sort=recency"
        - lang: javascript
          label: Node
          source: |
            const url = new URL("https://platform.buildpulse.io/api/repos/acme/widgets/tests");
            url.searchParams.set("q", "last-seen:>=2026-05-01");
            url.searchParams.set("sort", "recency");
            const res = await fetch(url, {
              headers: { Authorization: `token ${process.env.BUILDPULSE_TOKEN}` },
            });
        - lang: python
          label: Python
          source: |
            import os, requests
            r = requests.get(
              "https://platform.buildpulse.io/api/repos/acme/widgets/tests",
              headers={"Authorization": f"token {os.environ['BUILDPULSE_TOKEN']}"},
              params={"q": "last-seen:>=2026-05-01", "sort": "recency"},
            )
            data = r.json()

  /api/tests/{id}/results:
    get:
      operationId: listTestResults
      tags: [Tests]
      summary: List recent disruption events for a specific test.
      description: |
        Returns up to 10 most-recent disruption events for the given
        disruptor (test) ID, within the 14-day reporting window. The test
        must belong to a repository in the caller's organization.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            pattern: "^[0-9a-fA-F]{24}$"
          description: |
            The disruptor (test) ID as a 24-character MongoDB ObjectID
            hex string. This is the same `id` returned by
            `GET /api/repos/{owner}/{name}/tests`.
      responses:
        "200":
          description: Recent disruption events.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TestResultsResponse"
              example:
                results:
                  - id: "65d4f1a8b3c2d1e0f9a8b7c7"
                    build_url: "https://github.com/acme/widgets/actions/runs/9001"
                    commit_oid: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
                    conclusion: "failure"
                    recorded_at: "2026-05-17T10:00:00Z"
                    message: "timeout: expected task to complete in 5s"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -H "Authorization: token $BUILDPULSE_TOKEN" \
              https://platform.buildpulse.io/api/tests/65d4f1a8b3c2d1e0f9a8b7c6/results
        - lang: javascript
          label: Node
          source: |
            const res = await fetch(
              "https://platform.buildpulse.io/api/tests/65d4f1a8b3c2d1e0f9a8b7c6/results",
              { headers: { Authorization: `token ${process.env.BUILDPULSE_TOKEN}` } }
            );
            const { results } = await res.json();
        - lang: python
          label: Python
          source: |
            import os, requests
            test_id = "65d4f1a8b3c2d1e0f9a8b7c6"
            r = requests.get(
              f"https://platform.buildpulse.io/api/tests/{test_id}/results",
              headers={"Authorization": f"token {os.environ['BUILDPULSE_TOKEN']}"},
            )
            results = r.json()["results"]

  /api/v1/flaky/tests:
    get:
      operationId: listFlakyTests
      tags: [Flaky]
      summary: List flaky tests with optional field expansion.
      description: |
        Returns flaky tests for the named repository. Identifies the
        repository by either `repository` (name) or `repository_id`
        (legacy numeric ID from the Rails API era).

        ### Optional fields

        By default the response is a minimal shape: `id`, `name`,
        `suite`, `class`, `file`, `disruptor_type`. Pass
        `include=field1,field2,…` to expand any of:

        - `disruptiveness_ratio`
        - `nondeterministic_negative_result_count`
        - `nondeterminism_first_recorded_at`
        - `recent_nondeterministic_commit_oid`
        - `recent_nondeterministic_negative_result`
        - `recent_nondeterministic_positive_result`
        - `tags`
        - `quarantine_date`
        - `quarantined_by`
        - `quarantine_type`
        - `time_consumed`

        Unknown fields are silently dropped.

        ### Quarantine

        By default, tests with `quarantineType` set (and not `"none"`)
        are excluded. Pass `quarantine=true` to include them.
      parameters:
        - in: query
          name: repository
          schema:
            type: string
          description: |
            Repository name (case-insensitive). Required unless
            `repository_id` is supplied.
          example: widgets
        - in: query
          name: repository_id
          schema:
            type: string
          description: |
            Legacy numeric repository ID (from the Rails era). Used when
            migrating older integrations that didn't have repo names
            available at call time.
        - $ref: "#/components/parameters/Limit25"
        - $ref: "#/components/parameters/AfterCursor"
        - in: query
          name: include
          schema:
            type: string
          description: |
            Comma-separated list of optional fields to include. See
            description above for the allowed set.
          example: disruptiveness_ratio,tags,recent_nondeterministic_negative_result
        - in: query
          name: quarantine
          schema:
            type: string
            enum: ["true", "false"]
          description: |
            Set to `true` to include quarantined tests. Defaults to
            `false`.
          example: "false"
        - $ref: "#/components/parameters/Sort"
        - $ref: "#/components/parameters/Q"
      responses:
        "200":
          description: Paginated flaky tests.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FlakyTestsResponse"
              example:
                count: 1
                tests:
                  - id: "65d4f1a8b3c2d1e0f9a8b7c6"
                    name: "test_login"
                    suite: "auth"
                    class: "AuthTest"
                    file: "test/auth.rb"
                    disruptor_type: "flaky"
                    disruptiveness_ratio: 0.375
                    tags: ["network", "auth"]
                metadata:
                  after: null
                  limit: 25
        "400":
          $ref: "#/components/responses/BadCursor"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -H "Authorization: token $BUILDPULSE_TOKEN" \
              "https://platform.buildpulse.io/api/v1/flaky/tests?repository=widgets&include=disruptiveness_ratio,tags"
        - lang: javascript
          label: Node
          source: |
            const url = new URL("https://platform.buildpulse.io/api/v1/flaky/tests");
            url.searchParams.set("repository", "widgets");
            url.searchParams.set("include", "disruptiveness_ratio,tags");
            const res = await fetch(url, {
              headers: { Authorization: `token ${process.env.BUILDPULSE_TOKEN}` },
            });
        - lang: python
          label: Python
          source: |
            import os, requests
            r = requests.get(
              "https://platform.buildpulse.io/api/v1/flaky/tests",
              headers={"Authorization": f"token {os.environ['BUILDPULSE_TOKEN']}"},
              params={
                "repository": "widgets",
                "include": "disruptiveness_ratio,tags",
              },
            )
            data = r.json()

  /api/v1/flaky/badges:
    get:
      operationId: flakyBadge
      tags: [Badges]
      summary: Render a flakiness SVG badge for a repository.
      description: |
        Returns an SVG status badge showing the flakiness percentage for
        the named repository over the last 14 days.

        Color thresholds: 0% = green, 1–20% = yellow, >20% = red.

        Embed in a README:

        ```markdown
        ![flakiness](https://platform.buildpulse.io/api/v1/flaky/badges?repository=widgets)
        ```

        Note: badge endpoints require the same token authentication as
        the rest of the API. The legacy badge endpoints were
        unauthenticated; that was an oversight and is fixed here.
      parameters:
        - in: query
          name: repository
          required: true
          schema:
            type: string
          description: Repository name (case-insensitive).
          example: widgets
      responses:
        "200":
          description: SVG badge image.
          content:
            image/svg+xml:
              schema:
                type: string
                format: binary
              example: |
                <svg xmlns="http://www.w3.org/2000/svg" width="210" height="20">…</svg>
        "400":
          description: Missing `repository` parameter.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -H "Authorization: token $BUILDPULSE_TOKEN" \
              "https://platform.buildpulse.io/api/v1/flaky/badges?repository=widgets" \
              -o flakiness.svg

  /api/v1/coverage/badges:
    get:
      operationId: coverageBadge
      tags: [Badges]
      summary: Render a coverage SVG badge for a repository.
      description: |
        Returns an SVG status badge showing the latest coverage
        percentage for the named repository. The most-recently-uploaded
        coverage report is used, regardless of branch.

        Color thresholds: 100% = green, 90–99% = light green, 70–89% =
        yellow, <70% = red.

        Embed in a README:

        ```markdown
        ![coverage](https://platform.buildpulse.io/api/v1/coverage/badges?repository=widgets)
        ```
      parameters:
        - in: query
          name: repository
          required: true
          schema:
            type: string
          description: Repository name (case-insensitive).
          example: widgets
        - in: query
          name: branch_or_pr
          schema:
            type: string
          description: |
            Reserved for future branch-/PR-specific coverage selection.
            Currently ignored; the most recent report is always used.
      responses:
        "200":
          description: SVG badge image.
          content:
            image/svg+xml:
              schema:
                type: string
                format: binary
        "400":
          description: Missing `repository` parameter.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -H "Authorization: token $BUILDPULSE_TOKEN" \
              "https://platform.buildpulse.io/api/v1/coverage/badges?repository=widgets" \
              -o coverage.svg

components:
  securitySchemes:
    TokenAuth:
      type: apiKey
      in: header
      name: Authorization
      description: |
        Send `Authorization: token <40-hex-token>`. This is the legacy
        Rails token format; existing integrations should not need to
        change.
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: hex
      description: |
        Send `Authorization: Bearer <40-hex-token>`. Functionally
        identical to `TokenAuth`; both are accepted.

  parameters:
    Owner:
      in: path
      name: owner
      required: true
      schema: { type: string }
      description: Repository owner (case-insensitive).
      example: acme
    Name:
      in: path
      name: name
      required: true
      schema: { type: string }
      description: Repository name (case-insensitive).
      example: widgets
    Limit10:
      in: query
      name: limit
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 10
      description: Page size. Clamped to `[1, 100]`.
    Limit25:
      in: query
      name: limit
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
      description: Page size. Clamped to `[1, 100]`.
    AfterCursor:
      in: query
      name: after
      schema: { type: string }
      description: |
        Opaque pagination cursor from a previous response's
        `metadata.after`. Pass back verbatim.
    Sort:
      in: query
      name: sort
      schema:
        type: string
        enum: [disruptivenessRatio, recency]
        default: disruptivenessRatio
      description: |
        Sort order. `recency` sorts by `latestDisruptionAt` descending.
    Q:
      in: query
      name: q
      schema: { type: string }
      description: |
        Structured query string. See `/api/repos/{owner}/{name}/tests`
        description for the full grammar.
      example: "last-seen:>=2026-05-01 tags:auth login"

  responses:
    Unauthorized:
      description: |
        Missing, malformed, or invalid token; or organization has no
        active plan. Response body is empty (matches legacy Rails).
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/NotFoundError"
          example:
            message: Not Found
    BadCursor:
      description: Cursor failed to decode.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/CursorError"
          example:
            errors:
              after: ["invalid cursor format"]

  schemas:
    HealthResponse:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok]

    Submission:
      type: object
      required:
        - key
        - build_url
        - commit_oid
        - recorded_at
        - status
        - test_result_count
      properties:
        key:
          type: string
          description: |
            S3 object key for the raw test result archive. Stable and
            unique per submission.
          example: "1234/5678/test-results.xml.gz"
        build_url:
          type: string
          format: uri
          description: CI run URL that produced this submission.
        commit_oid:
          type: string
          description: Git commit SHA-1 (40 hex chars).
        recorded_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp (UTC, second precision).
        status:
          type: string
          description: |
            Processing status. Currently `processed`, `processing`, or
            other internal states.
          example: processed
        test_result_count:
          type: integer
          description: Number of individual test results in this submission.

    SubmissionsResponse:
      type: object
      required: [count, submissions, metadata]
      properties:
        count:
          type: integer
          format: int64
          description: Total submissions matching the filter (not just this page).
        submissions:
          type: array
          items: { $ref: "#/components/schemas/Submission" }
        metadata:
          $ref: "#/components/schemas/Metadata"

    Result:
      type: object
      required: [recorded_at, build_url, message]
      properties:
        recorded_at:
          type: string
          format: date-time
        build_url:
          type: string
          format: uri
        message:
          type: [string, "null"]
          description: |
            Failure message (negative result) or always `null`
            (positive result, to match legacy behavior).

    Test:
      type: object
      required:
        - id
        - name
        - suite
        - class
        - file
        - disruptiveness
        - nondeterministic_negative_result_count
        - nondeterminism_first_recorded_at
        - latest_nondeterministic_commit_oid
        - latest_nondeterministic_negative_result
        - latest_nondeterministic_positive_result
      properties:
        id:
          type: string
          description: 24-character MongoDB ObjectID hex.
        name:
          type: string
        suite:
          type: string
        class:
          type: string
        file:
          type: string
        disruptiveness:
          type: number
          format: float
          description: |
            Disruptiveness ratio, truncated to 3 decimal places.
            Ranges from 0.0 (deterministic) to 1.0 (always flakes).
        nondeterministic_negative_result_count:
          type: integer
        nondeterminism_first_recorded_at:
          type: string
          format: date-time
        latest_nondeterministic_commit_oid:
          type: string
        latest_nondeterministic_negative_result:
          $ref: "#/components/schemas/Result"
        latest_nondeterministic_positive_result:
          $ref: "#/components/schemas/Result"

    TestsResponse:
      type: object
      required: [count, tests, metadata]
      properties:
        count:
          type: integer
          format: int64
        tests:
          type: array
          items: { $ref: "#/components/schemas/Test" }
        metadata:
          $ref: "#/components/schemas/Metadata"

    TestResult:
      type: object
      required:
        - id
        - build_url
        - commit_oid
        - conclusion
        - recorded_at
        - message
      properties:
        id:
          type: string
        build_url:
          type: string
          format: uri
        commit_oid:
          type: string
        conclusion:
          type: string
          description: Disruption conclusion (e.g. `failure`).
        recorded_at:
          type: string
          format: date-time
        message:
          type: [string, "null"]

    TestResultsResponse:
      type: object
      required: [results]
      properties:
        results:
          type: array
          items: { $ref: "#/components/schemas/TestResult" }

    FlakyTest:
      type: object
      description: |
        Base shape (no `include` param). Add fields by listing them in
        the `include` query parameter — see endpoint description for the
        whitelist.
      required: [id, name, suite, class, file, disruptor_type]
      properties:
        id:
          type: string
        name:
          type: string
        suite:
          type: string
        class:
          type: string
        file:
          type: string
        disruptor_type:
          type: string
          example: flaky
        disruptiveness_ratio:
          type: number
          format: float
          description: Only present when `include` lists `disruptiveness_ratio`.
        disruption_count:
          type: integer
          description: Only present when `include` lists `nondeterministic_negative_result_count`.
        first_disruption_at:
          type: string
          format: date-time
          description: Only present when `include` lists `nondeterminism_first_recorded_at`.
        recent_disruption_commit_oid:
          type: string
          description: Only present when `include` lists `recent_nondeterministic_commit_oid`.
        recent_nondeterministic_negative_result:
          $ref: "#/components/schemas/Result"
        recent_nondeterministic_positive_result:
          $ref: "#/components/schemas/Result"
        tags:
          type: array
          items: { type: string }
        quarantine_date:
          type: [string, "null"]
          format: date-time
        quarantined_by:
          type: [string, "null"]
        quarantine_type:
          type: [string, "null"]
        time_consumed:
          type: integer
          format: int64

    FlakyTestsResponse:
      type: object
      required: [count, tests, metadata]
      properties:
        count:
          type: integer
          format: int64
        tests:
          type: array
          items: { $ref: "#/components/schemas/FlakyTest" }
        metadata:
          $ref: "#/components/schemas/Metadata"

    Metadata:
      type: object
      required: [after, limit]
      properties:
        after:
          type: [string, "null"]
          description: Opaque cursor for the next page; `null` if there is no next page.
        limit:
          type: integer

    NotFoundError:
      type: object
      required: [message]
      properties:
        message:
          type: string
          enum: ["Not Found"]

    CursorError:
      type: object
      required: [errors]
      properties:
        errors:
          type: object
          required: [after]
          properties:
            after:
              type: array
              items: { type: string }
