openapi: 3.1.0
info:
  title: Gaffer API
  version: 1.0.0
  description: |
    Gaffer API for programmatic access to test data, analytics, and coverage metrics.

    ## Authentication

    Gaffer supports two types of API keys. Send either one via the `X-API-Key`
    header, or as an RFC 6750 bearer token in `Authorization: Bearer <token>`.
    Both are accepted everywhere and behave identically. Bearer is preferred
    for OAuth-aware clients and agent tooling; `X-API-Key` remains supported
    for existing integrations.

    ### Project Tokens (project-scoped)

    Format: `gfr_<64 hex chars>`

    Each project token is scoped to a single project. Use these for uploading test reports and accessing all project-scoped read endpoints. The project ID is resolved automatically from the token.

    ```bash
    curl -H "X-API-Key: gfr_your_project_token_here" \
      https://app.gaffer.sh/api/v1/user/projects/{projectId}/health

    # Equivalent, using a bearer token
    curl -H "Authorization: Bearer gfr_your_project_token_here" \
      https://app.gaffer.sh/api/v1/user/projects/{projectId}/health
    ```

    ### User API Keys (user-scoped)

    Format: `gaf_<64 hex chars>`

    User API keys grant access to all projects across your organizations. Use these for cross-project queries and listing projects.

    ```bash
    curl -H "X-API-Key: gaf_your_user_api_key_here" \
      https://app.gaffer.sh/api/v1/user/projects

    # Equivalent, using a bearer token
    curl -H "Authorization: Bearer gaf_your_user_api_key_here" \
      https://app.gaffer.sh/api/v1/user/projects
    ```

    Generate user API keys from your [account settings](https://app.gaffer.sh/account/api-keys).

    ### Missing or invalid credentials

    A 401 response carries a `WWW-Authenticate: Bearer resource_metadata="https://app.gaffer.sh/.well-known/oauth-protected-resource"` header per RFC 9728, pointing OAuth-aware clients at the protected-resource metadata document.

    ### Which key type should I use?

    - **CI/CD pipelines & single-project agents**: use project tokens (`gfr_`), scoped to one project
    - **Cross-project queries**: use user API keys (`gaf_`), access all projects
    - **AI agents / MCP servers**: either works; project tokens are simpler for single-project setups

    ## Plan-Based Limits

    Analytics endpoints accept a `days` parameter to control the analysis window. This value is clamped by your organization's plan tier:

    | Plan | Max Days | Max Items (test history) |
    |------|----------|--------------------------|
    | Free | 30 | 50 |
    | Pro | 90 | 100 |
    | Team | 180 | 100 |

    Max Items is the per-plan clamp on the test-history endpoint. Paginated
    endpoints declare their own `limit` maximum on the parameter itself.

    When clamping occurs, the response includes a `meta` object:

    ```json
    {
      "meta": {
        "appliedDays": 30,
        "requestedDays": 90,
        "clampedByPlan": true
      }
    }
    ```

    The `meta` field is only present when values were clamped. Always use the values in the response (e.g., `summary.period`) rather than assuming your requested value was applied.

    ## Common Workflows

    ### Diagnose CI failures

    1. `GET /api/v1/user/projects`: find the project ID
    2. `GET /api/v1/user/projects/{id}/test-runs?status=failed`: list failed runs
    3. `GET /api/v1/user/projects/{id}/test-runs/{runId}/details?status=failed`: get failure details with stack traces
    4. `GET /api/v1/user/projects/{id}/test-runs/{runId}/failure-clusters`: group failures by root cause
    5. `GET /api/v1/user/projects/{id}/test-history?testName=...`: check if the failure is new or recurring

    ### Check upload status

    1. `GET /api/v1/user/projects/{id}/upload-sessions?commitSha=...`: find upload session for a commit
    2. Check `processingStatus` field: `completed` means results are ready
    3. `GET /api/v1/user/projects/{id}/upload-sessions/{sessionId}`: get linked test runs and coverage reports

    ### Find flaky tests

    1. `GET /api/v1/user/projects/{id}/flaky-tests?days=30`: list tests with high flip rates
    2. `GET /api/v1/user/projects/{id}/test-history?testName=...`: inspect individual test stability

    ### Assess coverage gaps

    1. `GET /api/v1/user/projects/{id}/coverage-summary`: get overall coverage metrics
    2. `GET /api/v1/user/projects/{id}/coverage/risk-areas`: find files with low coverage AND test failures
    3. `GET /api/v1/user/projects/{id}/coverage/files?maxCoverage=50&sortBy=coverage`: list poorly-covered files

    ### Compare test performance

    1. `GET /api/v1/user/projects/{id}/compare-test?testName=...&beforeCommit=abc&afterCommit=def`: compare a test across commits

    ## For AI Agents

    Gaffer provides an [MCP server](https://www.npmjs.com/package/@gaffer-sh/mcp) that wraps this API with tool definitions optimized for AI agent consumption. If you're building an AI integration, consider using the MCP server instead of calling the API directly.

  contact:
    name: Gaffer Support
    email: support@gaffer.sh
    url: https://gaffer.sh
  license:
    name: Proprietary
    url: https://gaffer.sh/terms

servers:
  - url: https://app.gaffer.sh
    description: Production

security:
  - ApiKeyAuth: []
  - BearerAuth: []

paths:
  # ==========================================================================
  # Project-scoped endpoints (project token auth: gfr_)
  # ==========================================================================

  /api/v1/project:
    get:
      summary: Get project
      description: Returns the project associated with the authenticated project token.
      operationId: getProject
      tags:
        - Project
      responses:
        '200':
          description: Project details
          content:
            application/json:
              schema:
                type: object
                properties:
                  project:
                    $ref: '#/components/schemas/Project'
              example:
                project:
                  id: "proj_abc123def456"
                  name: "My Test Suite"
                  description: "E2E tests for the main application"
                  retentionDays: 30
                  createdAt: "2024-01-15T10:30:00Z"
                  updatedAt: "2024-03-20T14:22:00Z"
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/project/test-runs:
    get:
      summary: List test runs (project-scoped)
      description: Returns a paginated list of test runs for the project.
      operationId: listTestRuns
      tags:
        - Project
      parameters:
        - name: limit
          in: query
          description: Number of items per page (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          description: Number of items to skip
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: branch
          in: query
          description: Filter by branch name
          schema:
            type: string
        - name: framework
          in: query
          description: Filter by test framework
          schema:
            type: string
        - name: commitSha
          in: query
          description: Filter by commit SHA (exact or prefix match)
          schema:
            type: string
        - name: status
          in: query
          description: "Filter by status: 'passed' (no failures) or 'failed' (has failures)"
          schema:
            type: string
            enum: [passed, failed]
      responses:
        '200':
          description: Paginated list of test runs
          content:
            application/json:
              schema:
                type: object
                properties:
                  testRuns:
                    type: array
                    items:
                      $ref: '#/components/schemas/TestRunSummary'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/test-runs/{id}:
    get:
      summary: Get test run
      description: Returns detailed information about a specific test run.
      operationId: getTestRun
      tags:
        - Project
      parameters:
        - name: id
          in: path
          required: true
          description: Test run ID
          schema:
            type: string
      responses:
        '200':
          description: Test run details
          content:
            application/json:
              schema:
                type: object
                properties:
                  testRun:
                    $ref: '#/components/schemas/TestRun'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/project/analytics:
    get:
      summary: Get analytics (project-scoped)
      description: |
        Returns analytics summary including health score, pass rate, and flaky test count.
        The analysis period is clamped by your plan tier.
      operationId: getAnalytics
      tags:
        - Project
      parameters:
        - $ref: '#/components/parameters/DaysParam'
      responses:
        '200':
          description: Analytics summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  analytics:
                    $ref: '#/components/schemas/HealthSummary'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/project/flaky-tests:
    get:
      summary: List flaky tests (project-scoped)
      description: |
        Returns tests identified as flaky based on flip rate analysis.
        A test is flaky if it frequently switches between pass and fail states.
      operationId: listFlakyTests
      tags:
        - Project
      parameters:
        - $ref: '#/components/parameters/ThresholdParam'
        - $ref: '#/components/parameters/LimitParam'
        - $ref: '#/components/parameters/DaysParam'
      responses:
        '200':
          description: List of flaky tests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FlakyTestsResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ==========================================================================
  # Project-scoped read endpoints (user API key or project token)
  # ==========================================================================

  /api/v1/user/projects:
    get:
      summary: List projects
      description: |
        Lists all projects the authenticated user has access to across their organizations.
        Results include organization context for each project.
        **Requires a user API key (gaf_).** Project tokens cannot list projects.
      operationId: listUserProjects
      tags:
        - User Projects
      security:
        - UserApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - name: limit
          in: query
          description: Number of items per page (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: offset
          in: query
          description: Number of items to skip
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: organizationId
          in: query
          description: Filter to a specific organization
          schema:
            type: string
      responses:
        '200':
          description: Paginated list of projects with organization info
          content:
            application/json:
              schema:
                type: object
                properties:
                  projects:
                    type: array
                    items:
                      $ref: '#/components/schemas/UserProject'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
              example:
                projects:
                  - id: "proj_abc123"
                    name: "Frontend Tests"
                    description: "Playwright E2E tests"
                    retentionDays: 30
                    organization:
                      id: "org_xyz789"
                      name: "Acme Corp"
                      slug: "acme-corp"
                    createdAt: "2024-01-15T10:30:00Z"
                    updatedAt: "2024-03-20T14:22:00Z"
                pagination:
                  limit: 50
                  offset: 0
                  total: 3
                  hasMore: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v1/user/projects/{projectId}/health:
    get:
      summary: Get project health
      description: |
        Returns analytics overview for a project including health score,
        pass rate, test run count, trend direction, and flaky test count.
        The analysis period is clamped by your plan tier.
      operationId: getProjectHealth
      tags:
        - User Projects
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - $ref: '#/components/parameters/DaysParam'
      responses:
        '200':
          description: Health analytics for the project
          content:
            application/json:
              schema:
                type: object
                properties:
                  analytics:
                    $ref: '#/components/schemas/HealthSummary'
                  meta:
                    $ref: '#/components/schemas/Meta'
              example:
                analytics:
                  projectId: "proj_abc123"
                  projectName: "Frontend Tests"
                  period:
                    days: 30
                    start: "2024-02-19T00:00:00Z"
                    end: "2024-03-20T14:30:00Z"
                  healthScore: 87
                  passRate: 94.5
                  testRunCount: 42
                  totalTests: 6300
                  flakyTestCount: 3
                  trend: "up"
                  computedAt: "2024-03-20T14:30:00Z"
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/test-runs:
    get:
      summary: List test runs
      description: |
        Lists test runs for a specific project with pagination and filtering.
        Returns summary data (excludes full test results for performance).
      operationId: listUserTestRuns
      tags:
        - User Projects
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - name: limit
          in: query
          description: Number of items per page (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          description: Number of items to skip
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: branch
          in: query
          description: Filter by branch name
          schema:
            type: string
        - name: framework
          in: query
          description: Filter by test framework (e.g., playwright, vitest, jest)
          schema:
            type: string
        - name: commitSha
          in: query
          description: Filter by commit SHA (exact or prefix match)
          schema:
            type: string
        - name: status
          in: query
          description: "Filter by status: 'passed' (no failures) or 'failed' (has failures)"
          schema:
            type: string
            enum: [passed, failed]
      responses:
        '200':
          description: Paginated list of test runs
          content:
            application/json:
              schema:
                type: object
                properties:
                  testRuns:
                    type: array
                    items:
                      $ref: '#/components/schemas/TestRunSummary'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/test-runs/{testRunId}/details:
    get:
      summary: Get test run details
      description: |
        Returns parsed test results for a specific test run with pagination and filtering.
        Includes individual test cases with error messages and stack traces.
        Also includes the test run's commit, branch, and framework context.
      operationId: getTestRunDetails
      tags:
        - Test History
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - name: testRunId
          in: path
          required: true
          description: Test run ID
          schema:
            type: string
        - name: limit
          in: query
          description: Number of test cases per page (1-500)
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: offset
          in: query
          description: Number of test cases to skip
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: status
          in: query
          description: Filter by test status
          schema:
            type: string
            enum: [passed, failed, skipped]
      responses:
        '200':
          description: Paginated test cases with run context
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TestRunDetailsResponse'
              example:
                testRunId: "run_xyz789"
                commitSha: "a1b2c3d4e5f6"
                branch: "feature/login"
                framework: "playwright"
                createdAt: "2024-03-20T14:30:00Z"
                summary:
                  passed: 142
                  failed: 3
                  skipped: 5
                  total: 150
                tests:
                  - name: "should handle session timeout"
                    fullName: "Auth > Login > should handle session timeout"
                    status: "failed"
                    durationMs: 5230
                    filePath: "tests/auth/login.spec.ts"
                    error: "Timeout waiting for selector '.dashboard'"
                    errorStack: "Error: Timeout waiting for selector '.dashboard'\n    at tests/auth/login.spec.ts:42:15"
                  - name: "should display user profile"
                    fullName: "Auth > Login > should display user profile"
                    status: "passed"
                    durationMs: 1200
                    filePath: "tests/auth/login.spec.ts"
                    error: null
                    errorStack: null
                pagination:
                  total: 150
                  limit: 100
                  offset: 0
                  hasMore: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/test-history:
    get:
      summary: Get test history
      description: |
        Returns pass/fail history for a specific test by name or file path.
        Useful for understanding test stability over time.
        Either `testName` or `filePath` must be provided.
      operationId: getTestHistory
      tags:
        - Test History
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - name: testName
          in: query
          description: Test name to search for (provide this or filePath)
          schema:
            type: string
        - name: filePath
          in: query
          description: File path to search for (provide this or testName)
          schema:
            type: string
        - name: limit
          in: query
          description: Maximum number of results (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        '200':
          description: Test history with summary statistics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TestHistoryResponse'
              example:
                history:
                  - testRunId: "run_xyz789"
                    createdAt: "2024-03-20T14:30:00Z"
                    branch: "main"
                    commitSha: "a1b2c3d4"
                    test:
                      name: "should handle login"
                      status: "passed"
                      durationMs: 1250
                      filePath: "tests/auth/login.spec.ts"
                      message: null
                  - testRunId: "run_abc456"
                    createdAt: "2024-03-19T14:30:00Z"
                    branch: "main"
                    commitSha: "f6e5d4c3"
                    test:
                      name: "should handle login"
                      status: "failed"
                      durationMs: 5100
                      filePath: "tests/auth/login.spec.ts"
                      message: null
                summary:
                  totalRuns: 2
                  passedRuns: 1
                  failedRuns: 1
                  passRate: 50.0
                  searchedBy: "testName"
                  searchValue: "should handle login"
                message: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/compare-test:
    get:
      summary: Compare test metrics
      description: |
        Compare test metrics between two commits or two test runs.
        Provide either (`beforeCommit` + `afterCommit`) or (`beforeRunId` + `afterRunId`).
      operationId: compareTest
      tags:
        - Test History
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - name: testName
          in: query
          required: true
          description: The test name to compare
          schema:
            type: string
        - name: beforeCommit
          in: query
          description: Commit SHA for the "before" measurement (use with afterCommit)
          schema:
            type: string
        - name: afterCommit
          in: query
          description: Commit SHA for the "after" measurement (use with beforeCommit)
          schema:
            type: string
        - name: beforeRunId
          in: query
          description: Test run ID for the "before" measurement (use with afterRunId)
          schema:
            type: string
        - name: afterRunId
          in: query
          description: Test run ID for the "after" measurement (use with beforeRunId)
          schema:
            type: string
      responses:
        '200':
          description: Test comparison result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TestComparisonResponse'
              example:
                testName: "should process payment"
                before:
                  testRunId: "run_abc123"
                  commit: "a1b2c3d4"
                  branch: "main"
                  status: "passed"
                  durationMs: 2500
                  createdAt: "2024-03-19T14:30:00Z"
                after:
                  testRunId: "run_def456"
                  commit: "e5f6a7b8"
                  branch: "main"
                  status: "passed"
                  durationMs: 4200
                  createdAt: "2024-03-20T14:30:00Z"
                change:
                  durationMs: 1700
                  percentChange: 68.0
                  statusChanged: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/flaky-tests:
    get:
      summary: List flaky tests
      description: |
        Returns tests identified as flaky based on flip rate analysis.
        A test is considered flaky if its flip rate exceeds the threshold.
        The analysis period is clamped by your plan tier.
      operationId: listUserFlakyTests
      tags:
        - Analytics
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - $ref: '#/components/parameters/ThresholdParam'
        - $ref: '#/components/parameters/LimitParam'
        - $ref: '#/components/parameters/DaysParam'
      responses:
        '200':
          description: List of flaky tests with summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FlakyTestsResponse'
              example:
                flakyTests:
                  - name: "tests/auth/login.spec.ts > should handle session timeout"
                    flipRate: 0.35
                    flipCount: 7
                    totalRuns: 20
                    lastSeen: "2024-03-20T10:15:00Z"
                    flakinessScore: 0.54
                summary:
                  threshold: 0.1
                  totalFlaky: 3
                  period: 30
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/search-failures:
    get:
      summary: Search test failures
      description: |
        Search across test failures by error message, stack trace, or test name.
        Returns matching failed test cases with context from their test runs.
        Omit `query` to return every failed test case in the window.
        The analysis period is clamped by your plan tier.
      operationId: searchFailures
      tags:
        - Analytics
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - name: query
          in: query
          description: Case-insensitive substring to search for. Omit to match every failure.
          schema:
            type: string
            minLength: 1
        - name: searchIn
          in: query
          description: Where to search
          schema:
            type: string
            enum: [errors, names, all]
            default: all
        - $ref: '#/components/parameters/DaysParam'
        - name: branch
          in: query
          description: Filter by branch name
          schema:
            type: string
        - name: limit
          in: query
          description: Maximum matches to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        '200':
          description: Matching test failures
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchFailuresResponse'
              example:
                matches:
                  - testName: "tests/api/upload.spec.ts > should handle connection timeout"
                    testRunId: "run_abc123"
                    branch: "main"
                    commitSha: "a1b2c3d"
                    errorMessage: "Error: Connection timeout after 30000ms"
                    errorStack: "Error: Connection timeout after 30000ms\n    at TCPSocket.connect..."
                    createdAt: "2026-02-28T14:30:00.000Z"
                total: 1
                truncated: false
                query: "timeout"
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/slowest-tests:
    get:
      summary: List slowest tests
      description: |
        Returns the slowest tests for a project, sorted by P95 duration.
        Queries duration statistics from the Analytics Engine.
        The analysis period is clamped by your plan tier.
      operationId: listSlowestTests
      tags:
        - Analytics
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - $ref: '#/components/parameters/DaysParam'
        - name: limit
          in: query
          description: Maximum results to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: framework
          in: query
          description: Filter by test framework (e.g., playwright, vitest)
          schema:
            type: string
        - name: branch
          in: query
          description: Filter by branch name
          schema:
            type: string
      responses:
        '200':
          description: Slowest tests with duration statistics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SlowestTestsResponse'
              example:
                slowestTests:
                  - name: "should process payment"
                    fullName: "Checkout > Payment > should process payment"
                    filePath: "tests/checkout/payment.spec.ts"
                    framework: "playwright"
                    avgDurationMs: 12500
                    p95DurationMs: 18200
                    runCount: 45
                  - name: "should load dashboard"
                    fullName: "Dashboard > should load dashboard"
                    filePath: "tests/dashboard.spec.ts"
                    framework: "playwright"
                    avgDurationMs: 8300
                    p95DurationMs: 14100
                    runCount: 50
                summary:
                  projectId: "proj_abc123"
                  projectName: "Frontend Tests"
                  period: 30
                  totalReturned: 2
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/test-runs/{testRunId}/failure-clusters:
    get:
      summary: Get failure clusters
      description: |
        Groups failed tests by error message similarity using Levenshtein distance.
        Helps identify root causes when multiple tests fail. Often 15 failures are 2-3 bugs.
      operationId: getFailureClusters
      tags:
        - Test History
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - name: testRunId
          in: path
          required: true
          description: Test run ID
          schema:
            type: string
      responses:
        '200':
          description: Failure clusters grouped by error similarity
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureClustersResponse'
              example:
                clusters:
                  - representativeError: "Connection refused: localhost:5432"
                    count: 9
                    tests:
                      - name: "should create user"
                        fullName: "Auth > should create user"
                        errorMessage: "Connection refused: localhost:5432"
                        filePath: "tests/auth/create-user.spec.ts"
                      - name: "should update profile"
                        fullName: "Profile > should update profile"
                        errorMessage: "Connection refused: localhost:5432"
                        filePath: "tests/profile/update.spec.ts"
                    similarity: 0.7
                    aiCategory:
                      category: "environment_issue"
                      confidence: "high"
                      reasoning: "Connection refused errors to localhost:5432 indicate the PostgreSQL database service was unavailable during the test run."
                  - representativeError: "Expected 200, received 401"
                    count: 3
                    tests:
                      - name: "should access dashboard"
                        fullName: "Dashboard > should access dashboard"
                        errorMessage: "Expected 200, received 401"
                        filePath: "tests/dashboard.spec.ts"
                    similarity: 0.7
                    aiCategory:
                      category: "product_bug"
                      confidence: "medium"
                      reasoning: "Unexpected 401 responses suggest an authentication regression in the application."
                totalFailures: 12
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/upload-sessions:
    get:
      summary: List upload sessions
      description: |
        Lists upload sessions for a project with optional filtering by commit or branch.
        Use this to check if CI results have been uploaded and their processing status.
      operationId: listUploadSessions
      tags:
        - Upload Sessions
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - name: commitSha
          in: query
          description: Filter by commit SHA
          schema:
            type: string
        - name: branch
          in: query
          description: Filter by branch name
          schema:
            type: string
        - name: limit
          in: query
          description: Number of items per page (1-50)
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
        - name: offset
          in: query
          description: Number of items to skip
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Paginated list of upload sessions
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadSessionsResponse'
              example:
                sessions:
                  - id: "upl_abc123"
                    projectId: "proj_xyz789"
                    uniqueId: "upl_abc123"
                    tags: null
                    commitSha: "a1b2c3d4e5f6"
                    branch: "main"
                    processingStatus: "completed"
                    pendingFileCount: 0
                    failedFileCount: 0
                    createdAt: "2024-03-20T14:30:00Z"
                    updatedAt: "2024-03-20T14:31:00Z"
                pagination:
                  limit: 10
                  offset: 0
                  total: 1
                  hasMore: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/upload-sessions/{sessionId}:
    get:
      summary: Get upload session detail
      description: |
        Returns detailed information about a specific upload session, including
        linked test runs and coverage reports produced from that upload.
      operationId: getUploadSessionDetail
      tags:
        - Upload Sessions
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - name: sessionId
          in: path
          required: true
          description: Upload session ID
          schema:
            type: string
      responses:
        '200':
          description: Upload session with linked results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadSessionDetailResponse'
              example:
                session:
                  id: "upl_abc123"
                  projectId: "proj_xyz789"
                  uniqueId: "upl_abc123"
                  tags: null
                  commitSha: "a1b2c3d4e5f6"
                  branch: "main"
                  processingStatus: "completed"
                  pendingFileCount: 0
                  failedFileCount: 0
                  createdAt: "2024-03-20T14:30:00Z"
                  updatedAt: "2024-03-20T14:31:00Z"
                testRuns:
                  - id: "run_xyz789"
                    framework: "playwright"
                    summary:
                      passed: 142
                      failed: 3
                      skipped: 5
                      total: 150
                    createdAt: "2024-03-20T14:30:30Z"
                coverageReports:
                  - id: "cov_abc456"
                    format: "lcov"
                    createdAt: "2024-03-20T14:30:45Z"
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/coverage-summary:
    get:
      summary: Get coverage summary
      description: |
        Returns coverage summary for a project including current line/branch/function
        coverage percentages, trend direction, and the lowest-covered files.
        The analysis period is clamped by your plan tier.
      operationId: getCoverageSummary
      tags:
        - Coverage
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - $ref: '#/components/parameters/DaysParam'
      responses:
        '200':
          description: Coverage summary with trend data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CoverageSummaryResponse'
              example:
                hasCoverage: true
                current:
                  lines: 78.5
                  branches: 65.2
                  functions: 82.1
                trend:
                  direction: "up"
                  change: 2.3
                totalReports: 15
                latestReportDate: "2024-03-20T14:30:00Z"
                lowestCoverageFiles:
                  - path: "src/utils/parser.ts"
                    coverage: 12
                  - path: "src/services/billing.ts"
                    coverage: 25
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/coverage/files:
    get:
      summary: List coverage files
      description: |
        Returns per-file coverage data for the project's latest coverage report.
        Supports filtering by path, coverage thresholds, sorting, and pagination.
      operationId: listCoverageFiles
      tags:
        - Coverage
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - name: filePath
          in: query
          description: Filter to files matching this path (contains match)
          schema:
            type: string
        - name: minCoverage
          in: query
          description: Minimum coverage percentage (0-100)
          schema:
            type: number
            minimum: 0
            maximum: 100
        - name: maxCoverage
          in: query
          description: Maximum coverage percentage (0-100)
          schema:
            type: number
            minimum: 0
            maximum: 100
        - name: limit
          in: query
          description: Maximum number of results (1-500)
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: offset
          in: query
          description: Pagination offset
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: sortBy
          in: query
          description: Sort field
          schema:
            type: string
            enum: [path, coverage]
            default: coverage
        - name: sortOrder
          in: query
          description: Sort direction
          schema:
            type: string
            enum: [asc, desc]
            default: asc
      responses:
        '200':
          description: Per-file coverage data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CoverageFilesResponse'
              example:
                hasCoverage: true
                files:
                  - path: "src/utils/parser.ts"
                    lines:
                      covered: 12
                      total: 100
                      percentage: 12.0
                    branches:
                      covered: 3
                      total: 20
                      percentage: 15.0
                    functions:
                      covered: 2
                      total: 8
                      percentage: 25.0
                pagination:
                  total: 156
                  limit: 100
                  offset: 0
                  hasMore: true
                overallCoverage: 78.5
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/coverage/risk-areas:
    get:
      summary: Find coverage risk areas
      description: |
        Returns areas of code that have both low coverage AND test failures.
        Cross-references test results with coverage data to identify high-risk
        areas that need attention. Results are sorted by risk score (highest first).
        The analysis period is clamped by your plan tier.
      operationId: getCoverageRiskAreas
      tags:
        - Coverage
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - $ref: '#/components/parameters/DaysParam'
        - name: coverageThreshold
          in: query
          description: Include files below this coverage percentage (0-100)
          schema:
            type: number
            minimum: 0
            maximum: 100
            default: 80
      responses:
        '200':
          description: Risk areas sorted by risk score
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CoverageRiskAreasResponse'
              example:
                hasCoverage: true
                hasTestResults: true
                riskAreas:
                  - filePath: "src/services/billing.ts"
                    coverage: 25
                    failureCount: 8
                    riskScore: 92
                    testNames:
                      - "should process refund"
                      - "should handle expired card"
                message: null
                analysisParams:
                  days: 30
                  coverageThreshold: 80
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/user/projects/{projectId}/reports/{testRunId}/browser-url:
    get:
      summary: Get report browser URL
      description: |
        Returns a browser-navigable URL with a signed token for viewing HTML test reports.
        The token is short-lived (30 minutes) and grants read-only access to the report.
        Useful for AI agents providing clickable links to Playwright, Vitest, or other HTML reports.
      operationId: getReportBrowserUrl
      tags:
        - Reports
      security:
        - UserApiKeyAuth: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectIdParam'
        - name: testRunId
          in: path
          required: true
          description: Test run ID
          schema:
            type: string
        - name: filename
          in: query
          description: Specific filename to link to (default picks index.html or first HTML file)
          schema:
            type: string
      responses:
        '200':
          description: Signed browser URL for report viewing
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BrowserUrlResponse'
              example:
                url: "https://app.gaffer.sh/reports/run_xyz789/index.html?token=eyJ..."
                filename: "index.html"
                testRunId: "run_xyz789"
                expiresAt: "2024-03-20T15:00:00Z"
                expiresInSeconds: 1800
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/ingest:
    post:
      summary: Ingest structured test data
      description: |
        Receives structured test data from the Gaffer CLI, the GitHub Action, or
        the MCP server's `upload_test_results` function.
        The payload is stored in R2 and processed asynchronously via a queue consumer.

        Returns 202 immediately with an `uploadSessionId`. Poll the upload-sessions
        endpoint to check when processing completes.

        **This endpoint is not idempotent.** Each request creates a new upload and,
        once processed, a new test run. `runId` is stored as the report's source key
        and does not deduplicate across requests, so retrying a call that may have
        already succeeded produces a duplicate run. Poll the upload-sessions endpoint
        instead of retrying blind.

        **Rate limited per project** across all callers. Exceeding the limit returns
        429 with a `Retry-After` header. `X-RateLimit-*` headers are set on every
        response.

        Accepts either credential type. A project token (`gfr_`) is already scoped to
        one project and ignores `projectId`; a user API key (`gaf_`) is not, and must
        pass `projectId`.
      operationId: ingestTestData
      tags:
        - Ingest
      security:
        - ApiKeyAuth: []
        - UserApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - name: projectId
          in: query
          required: false
          description: |
            Target project. Required when authenticating with a user API key (`gaf_`);
            omitted for project tokens, which resolve their own project.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IngestPayload'
            example:
              runId: "550e8400-e29b-41d4-a716-446655440000"
              framework: "vitest"
              branch: "main"
              commitSha: "abc123def456"
              startedAt: "2026-02-22T10:00:00Z"
              finishedAt: "2026-02-22T10:01:30Z"
              summary:
                total: 42
                passed: 40
                failed: 1
                skipped: 1
                durationMs: 90000
              tests:
                - name: "src/auth.test.ts > login > should return 200"
                  status: "passed"
                  durationMs: 150
                  filePath: "src/auth.test.ts"
                - name: "src/auth.test.ts > login > should reject bad credentials"
                  status: "failed"
                  durationMs: 200
                  filePath: "src/auth.test.ts"
                  error: "Expected status 401, got 500"
              coverage:
                format: "lcov"
                lines:
                  covered: 450
                  total: 600
                branches:
                  covered: 120
                  total: 200
                functions:
                  covered: 80
                  total: 100
                files:
                  - path: "src/auth.ts"
                    lines: { covered: 30, total: 40 }
                    branches: { covered: 8, total: 12 }
                    functions: { covered: 5, total: 6 }
      responses:
        '202':
          description: Payload accepted for async processing
          content:
            application/json:
              schema:
                type: object
                properties:
                  uploadSessionId:
                    type: string
                    description: ID of the upload session. Use the upload-sessions endpoint to poll for completion
              example:
                uploadSessionId: "upl_v1a2b3c4d5e6f7g8h9i0j"
        '400':
          description: Invalid payload (validation failed), or a user API key was used without `projectId`
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          description: Project upload rate limit exceeded. Retry after the interval in the `Retry-After` header.

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: "Project token (format: gfr_<64 hex chars>). Scoped to a single project."

    UserApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: "User API key (format: gaf_<64 hex chars>). Access all projects in your organizations."

    BearerAuth:
      type: http
      scheme: bearer
      description: >-
        Either credential type (project token `gfr_...` or user API key
        `gaf_...`) sent as `Authorization: Bearer <token>` instead of
        `X-API-Key`. Behaves identically to the corresponding ApiKeyAuth /
        UserApiKeyAuth scheme.

  parameters:
    ProjectIdParam:
      name: projectId
      in: path
      required: true
      description: Project ID. Use the list projects endpoint to find project IDs.
      schema:
        type: string

    DaysParam:
      name: days
      in: query
      description: |
        Analysis period in days. Clamped by plan tier (Free: 30, Pro: 90, Team: 180).
        Check the `meta` field in the response to see if clamping was applied.
      schema:
        type: integer
        minimum: 1
        maximum: 365
        default: 30

    LimitParam:
      name: limit
      in: query
      description: Maximum number of results to return
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 50

    ThresholdParam:
      name: threshold
      in: query
      description: Minimum flip rate to be considered flaky (0-1)
      schema:
        type: number
        minimum: 0
        maximum: 1
        default: 0.1

  schemas:
    IngestPayload:
      type: object
      required: [runId, framework, startedAt, finishedAt, summary, tests]
      properties:
        runId:
          type: string
          format: uuid
          description: Local UUID used for deduplication
        framework:
          type: string
          description: Test framework identifier (e.g. "vitest", "playwright", "jest")
        branch:
          type: string
          description: Git branch name
        commitSha:
          type: string
          description: Git commit SHA
        ciProvider:
          type: string
          description: CI provider (e.g. "github-actions")
        startedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp when the run started
        finishedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp when the run finished
        summary:
          type: object
          required: [total, passed, failed, skipped, durationMs]
          properties:
            total:
              type: integer
            passed:
              type: integer
            failed:
              type: integer
            skipped:
              type: integer
            durationMs:
              type: number
        tests:
          type: array
          minItems: 1
          maxItems: 50000
          items:
            type: object
            required: [name, status, durationMs]
            properties:
              name:
                type: string
              status:
                type: string
                enum: [passed, failed, skipped, todo]
              durationMs:
                type: number
              filePath:
                type: string
              error:
                type: string
              retryCount:
                type: integer
              flaky:
                type: boolean
        coverage:
          type: object
          description: Optional coverage data collected during the test run
          required: [format, lines, branches, functions, files]
          properties:
            format:
              type: string
              description: Coverage format (e.g. "lcov")
            lines:
              $ref: '#/components/schemas/IngestCoverageMetrics'
            branches:
              $ref: '#/components/schemas/IngestCoverageMetrics'
            functions:
              $ref: '#/components/schemas/IngestCoverageMetrics'
            files:
              type: array
              maxItems: 50000
              items:
                type: object
                required: [path, lines, branches, functions]
                properties:
                  path:
                    type: string
                  lines:
                    $ref: '#/components/schemas/IngestCoverageMetrics'
                  branches:
                    $ref: '#/components/schemas/IngestCoverageMetrics'
                  functions:
                    $ref: '#/components/schemas/IngestCoverageMetrics'

    IngestCoverageMetrics:
      type: object
      properties:
        covered:
          type: integer
        total:
          type: integer
      required:
        - covered
        - total

    Project:
      type: object
      properties:
        id:
          type: string
          description: Unique project identifier
        name:
          type: string
          description: Project name
        description:
          type: string
          nullable: true
          description: Optional project description
        retentionDays:
          type: integer
          description: Report retention period in days
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      required:
        - id
        - name
        - retentionDays
        - createdAt
        - updatedAt

    UserProject:
      type: object
      description: A project with organization context, returned by user-scoped endpoints.
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: string
          nullable: true
        retentionDays:
          type: integer
          nullable: true
        organization:
          type: object
          properties:
            id:
              type: string
            name:
              type: string
            slug:
              type: string
          required:
            - id
            - name
            - slug
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      required:
        - id
        - name
        - organization
        - createdAt
        - updatedAt

    TestRunSummary:
      type: object
      description: Summary of a test run (excludes full test results for performance).
      properties:
        id:
          type: string
        uniqueId:
          type: string
          description: "Deduplication key for idempotent uploads (format: sessionId:filename)"
        sourceFile:
          type: string
          nullable: true
          description: Original filename from the uploaded report
        branch:
          type: string
          nullable: true
        commitSha:
          type: string
          nullable: true
        framework:
          type: string
          nullable: true
          description: "Detected test framework (e.g., playwright, vitest, jest, junit, pytest, rspec, ctrf, trx)"
        tags:
          type: object
          nullable: true
          additionalProperties:
            type: string
        summary:
          $ref: '#/components/schemas/TestSummary'
        createdAt:
          type: string
          format: date-time
      required:
        - id
        - uniqueId
        - summary
        - createdAt

    TestRun:
      allOf:
        - $ref: '#/components/schemas/TestRunSummary'
        - type: object
          properties:
            projectId:
              type: string
            updatedAt:
              type: string
              format: date-time
          required:
            - projectId
            - updatedAt

    TestSummary:
      type: object
      properties:
        passed:
          type: integer
        failed:
          type: integer
        skipped:
          type: integer
        total:
          type: integer
        durationMs:
          type: integer
          nullable: true
          description: Total duration in milliseconds
      required:
        - passed
        - failed
        - skipped
        - total

    TestRunDetailsResponse:
      type: object
      description: Detailed test results for a specific test run, including run context.
      properties:
        testRunId:
          type: string
        commitSha:
          type: string
          nullable: true
          description: Git commit SHA
        branch:
          type: string
          nullable: true
          description: Git branch name
        framework:
          type: string
          nullable: true
          description: Test framework (e.g., playwright, vitest)
        createdAt:
          type: string
          format: date-time
          description: When the test run was created
        summary:
          $ref: '#/components/schemas/TestSummary'
        tests:
          type: array
          items:
            $ref: '#/components/schemas/TestCaseDetail'
        pagination:
          $ref: '#/components/schemas/Pagination'
      required:
        - testRunId
        - createdAt
        - summary
        - tests
        - pagination

    TestCaseDetail:
      type: object
      description: Individual test case result with error details.
      properties:
        name:
          type: string
          description: Short test name
        fullName:
          type: string
          description: Full test name including describe blocks
        status:
          type: string
          enum: [passed, failed, skipped]
        durationMs:
          type: number
          nullable: true
          description: Test duration in milliseconds
        filePath:
          type: string
          nullable: true
          description: Test file path
        error:
          type: string
          nullable: true
          description: Error message for failed tests
        errorStack:
          type: string
          nullable: true
          description: Full stack trace for failed tests
      required:
        - name
        - fullName
        - status

    TestHistoryResponse:
      type: object
      properties:
        history:
          type: array
          items:
            $ref: '#/components/schemas/TestHistoryEntry'
        summary:
          type: object
          properties:
            totalRuns:
              type: integer
            passedRuns:
              type: integer
            failedRuns:
              type: integer
            passRate:
              type: number
              nullable: true
              description: Pass rate as percentage (0-100)
            searchedBy:
              type: string
              enum: [testName, filePath]
            searchValue:
              type: string
          required:
            - totalRuns
            - passedRuns
            - failedRuns
            - searchedBy
            - searchValue
        message:
          type: string
          nullable: true
          description: Human-readable message when no results are found
        meta:
          $ref: '#/components/schemas/Meta'
      required:
        - history
        - summary

    TestHistoryEntry:
      type: object
      properties:
        testRunId:
          type: string
        createdAt:
          type: string
          format: date-time
        branch:
          type: string
          nullable: true
        commitSha:
          type: string
          nullable: true
        test:
          type: object
          properties:
            name:
              type: string
            status:
              type: string
              enum: [passed, failed, skipped, pending]
            durationMs:
              type: number
            filePath:
              type: string
              nullable: true
            message:
              type: string
              nullable: true
          required:
            - name
            - status
            - durationMs
      required:
        - testRunId
        - createdAt
        - test

    TestComparisonResponse:
      type: object
      description: Before/after comparison of a specific test across commits or test runs.
      properties:
        testName:
          type: string
        before:
          $ref: '#/components/schemas/TestMetric'
        after:
          $ref: '#/components/schemas/TestMetric'
        change:
          type: object
          properties:
            durationMs:
              type: number
              nullable: true
              description: Duration change in milliseconds (positive = slower)
            percentChange:
              type: number
              nullable: true
              description: Percentage change in duration
            statusChanged:
              type: boolean
          required:
            - statusChanged
      required:
        - testName
        - before
        - after
        - change

    TestMetric:
      type: object
      properties:
        testRunId:
          type: string
        commit:
          type: string
          nullable: true
        branch:
          type: string
          nullable: true
        status:
          type: string
          enum: [passed, failed, skipped]
        durationMs:
          type: number
          nullable: true
        createdAt:
          type: string
          format: date-time
      required:
        - testRunId
        - status
        - createdAt

    HealthSummary:
      type: object
      description: Project health analytics summary.
      properties:
        projectId:
          type: string
        projectName:
          type: string
        period:
          type: object
          properties:
            days:
              type: integer
            start:
              type: string
              format: date-time
            end:
              type: string
              format: date-time
          required:
            - days
            - start
            - end
        healthScore:
          type: integer
          minimum: 0
          maximum: 100
          description: Overall health score (0-100)
        passRate:
          type: number
          nullable: true
          description: Average pass rate as percentage
        testRunCount:
          type: integer
          description: Number of test runs in the period
        totalTests:
          type: integer
          description: Total test executions
        flakyTestCount:
          type: integer
          description: Number of flaky tests detected
        trend:
          type: string
          enum: [up, down, stable]
          description: Pass rate trend direction
        computedAt:
          type: string
          format: date-time
      required:
        - projectId
        - projectName
        - period
        - healthScore
        - testRunCount
        - totalTests
        - flakyTestCount
        - trend
        - computedAt

    FlakyTest:
      type: object
      properties:
        name:
          type: string
          description: Full test name
        flipRate:
          type: number
          description: Rate of pass/fail transitions (0-1)
        flipCount:
          type: integer
          description: Number of state transitions
        totalRuns:
          type: integer
          description: Total test executions
        lastSeen:
          type: string
          format: date-time
          description: When the test was last executed
        flakinessScore:
          type: number
          description: Composite flakiness score (0-1) combining flip proximity, failure rate, and duration variability. Higher values indicate more problematic flaky tests.
      required:
        - name
        - flipRate
        - flipCount
        - totalRuns
        - lastSeen
        - flakinessScore

    FlakyTestsResponse:
      type: object
      properties:
        flakyTests:
          type: array
          items:
            $ref: '#/components/schemas/FlakyTest'
        summary:
          type: object
          properties:
            threshold:
              type: number
              description: Flip rate threshold used
            totalFlaky:
              type: integer
              description: Total number of flaky tests found
            period:
              type: integer
              description: Analysis period in days
          required:
            - threshold
            - totalFlaky
            - period
        meta:
          $ref: '#/components/schemas/Meta'
      required:
        - flakyTests
        - summary

    SearchFailuresResponse:
      type: object
      properties:
        matches:
          type: array
          items:
            $ref: '#/components/schemas/SearchFailureMatch'
        total:
          type: integer
          description: Number of matches returned
        truncated:
          type: boolean
          description: >-
            True when the scan caps (at most 100 test runs, plus `limit`) stopped
            the scan short of the full window, so the list is incomplete
        query:
          type: string
          nullable: true
          description: The search query used, or null when every failure was returned
      required:
        - matches
        - total
        - truncated
        - query

    SearchFailureMatch:
      type: object
      properties:
        testName:
          type: string
          description: Full test name
        testRunId:
          type: string
          description: ID of the test run containing the failure
        branch:
          type: string
          nullable: true
          description: Git branch name
        commitSha:
          type: string
          nullable: true
          description: Git commit SHA
        errorMessage:
          type: string
          nullable: true
          description: Error message from the test failure
        errorStack:
          type: string
          nullable: true
          description: Error stack trace (truncated to 500 chars)
        createdAt:
          type: string
          format: date-time
          description: When the test run was created
      required:
        - testName
        - testRunId
        - branch
        - commitSha
        - errorMessage
        - errorStack
        - createdAt

    SlowestTest:
      type: object
      properties:
        name:
          type: string
          description: Short test name
        fullName:
          type: string
          description: Full test name including describe blocks
        filePath:
          type: string
          nullable: true
        framework:
          type: string
          nullable: true
        avgDurationMs:
          type: integer
          description: Average duration in milliseconds
        p95DurationMs:
          type: integer
          description: 95th percentile duration in milliseconds
        runCount:
          type: integer
          description: Number of times this test has run
      required:
        - name
        - fullName
        - avgDurationMs
        - p95DurationMs
        - runCount

    SlowestTestsResponse:
      type: object
      properties:
        slowestTests:
          type: array
          items:
            $ref: '#/components/schemas/SlowestTest'
        summary:
          type: object
          properties:
            projectId:
              type: string
            projectName:
              type: string
            period:
              type: integer
              description: Analysis period in days
            totalReturned:
              type: integer
          required:
            - projectId
            - projectName
            - period
            - totalReturned
        meta:
          $ref: '#/components/schemas/Meta'
      required:
        - slowestTests
        - summary

    CoverageSummaryResponse:
      type: object
      description: Coverage summary with trend data. When `hasCoverage` is false, only `message` and `totalReports` are present.
      properties:
        hasCoverage:
          type: boolean
          description: Whether the project has any coverage data
        current:
          type: object
          description: Current coverage percentages (present when hasCoverage is true)
          properties:
            lines:
              type: number
            branches:
              type: number
            functions:
              type: number
          required:
            - lines
            - branches
            - functions
        trend:
          type: object
          description: Coverage trend over the analysis period
          properties:
            direction:
              type: string
              enum: [up, down, stable]
            change:
              type: number
              description: Percentage point change
          required:
            - direction
            - change
        totalReports:
          type: integer
        latestReportDate:
          type: string
          format: date-time
          nullable: true
        lowestCoverageFiles:
          type: array
          description: Up to 5 files with lowest coverage
          items:
            type: object
            properties:
              path:
                type: string
              coverage:
                type: integer
                description: Line coverage percentage (0-100)
            required:
              - path
              - coverage
        message:
          type: string
          nullable: true
          description: Human-readable message (present when no coverage data exists)
        meta:
          $ref: '#/components/schemas/Meta'
      required:
        - hasCoverage
        - totalReports

    CoverageFilesResponse:
      type: object
      description: Per-file coverage data. When `hasCoverage` is false, files array is empty.
      properties:
        hasCoverage:
          type: boolean
        files:
          type: array
          items:
            $ref: '#/components/schemas/CoverageFile'
        pagination:
          $ref: '#/components/schemas/Pagination'
        overallCoverage:
          type: number
          nullable: true
          description: Overall project coverage percentage
        message:
          type: string
          nullable: true
      required:
        - hasCoverage
        - files
        - pagination

    CoverageFile:
      type: object
      description: Per-file coverage metrics.
      properties:
        path:
          type: string
        lines:
          $ref: '#/components/schemas/CoverageMetric'
        branches:
          $ref: '#/components/schemas/CoverageMetric'
        functions:
          $ref: '#/components/schemas/CoverageMetric'
      required:
        - path
        - lines
        - branches
        - functions

    CoverageMetric:
      type: object
      properties:
        covered:
          type: integer
        total:
          type: integer
        percentage:
          type: number
      required:
        - covered
        - total
        - percentage

    CoverageRiskAreasResponse:
      type: object
      description: Files with both low coverage and test failures, sorted by risk score.
      properties:
        hasCoverage:
          type: boolean
        hasTestResults:
          type: boolean
        riskAreas:
          type: array
          items:
            $ref: '#/components/schemas/RiskArea'
        message:
          type: string
          nullable: true
        analysisParams:
          type: object
          properties:
            days:
              type: integer
            coverageThreshold:
              type: number
          required:
            - days
            - coverageThreshold
        meta:
          $ref: '#/components/schemas/Meta'
      required:
        - hasCoverage
        - hasTestResults
        - riskAreas
        - analysisParams

    RiskArea:
      type: object
      properties:
        filePath:
          type: string
        coverage:
          type: number
          description: Line coverage percentage (0-100)
        failureCount:
          type: integer
          description: Number of test failures touching this file
        riskScore:
          type: number
          description: Composite risk score (0-100, higher = more risky)
        testNames:
          type: array
          items:
            type: string
          description: Names of failing tests associated with this file
      required:
        - filePath
        - coverage
        - failureCount
        - riskScore
        - testNames

    BrowserUrlResponse:
      type: object
      description: Signed URL for viewing an HTML test report in the browser.
      properties:
        url:
          type: string
          format: uri
          description: Browser-navigable URL with signed token
        filename:
          type: string
          description: The report filename being linked to
        testRunId:
          type: string
        expiresAt:
          type: string
          format: date-time
          description: When the signed URL expires
        expiresInSeconds:
          type: integer
          description: Seconds until the URL expires
      required:
        - url
        - filename
        - testRunId
        - expiresAt
        - expiresInSeconds

    FailedTest:
      type: object
      description: A failed test within a failure cluster.
      properties:
        name:
          type: string
          description: Short test name
        fullName:
          type: string
          description: Full test name including describe blocks
        errorMessage:
          type: string
          description: The error message for this test
        filePath:
          type: string
          nullable: true
          description: Test file path
      required:
        - name
        - fullName
        - errorMessage

    FailureCategory:
      type: object
      nullable: true
      description: AI-generated categorization of a failure cluster's root cause.
      properties:
        category:
          type: string
          enum: [product_bug, automation_bug, environment_issue]
          description: The classified root cause category
        confidence:
          type: string
          enum: [high, medium]
          description: Confidence level of the classification
        reasoning:
          type: string
          description: Brief explanation of why this category was chosen
      required:
        - category
        - confidence
        - reasoning

    FailureCluster:
      type: object
      description: A group of failed tests sharing a similar error message.
      properties:
        representativeError:
          type: string
          description: The error message representing this cluster
        count:
          type: integer
          description: Number of tests in this cluster
        tests:
          type: array
          items:
            $ref: '#/components/schemas/FailedTest'
        similarity:
          type: number
          description: Similarity threshold used for clustering (0-1)
        aiCategory:
          $ref: '#/components/schemas/FailureCategory'
      required:
        - representativeError
        - count
        - tests
        - similarity

    FailureClustersResponse:
      type: object
      description: Failed tests grouped by error message similarity.
      properties:
        clusters:
          type: array
          items:
            $ref: '#/components/schemas/FailureCluster'
        totalFailures:
          type: integer
          description: Total number of failed tests across all clusters
      required:
        - clusters
        - totalFailures

    UploadSessionSummary:
      type: object
      description: Upload session summary with processing status.
      properties:
        id:
          type: string
        projectId:
          type: string
        uniqueId:
          type: string
        tags:
          type: object
          nullable: true
          additionalProperties:
            type: string
        commitSha:
          type: string
          nullable: true
        branch:
          type: string
          nullable: true
        processingStatus:
          type: string
          enum: [pending, processing, completed, error]
          description: "Current processing status"
        pendingFileCount:
          type: integer
          description: Number of files still being processed
        failedFileCount:
          type: integer
          description: Number of files that failed processing
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      required:
        - id
        - projectId
        - uniqueId
        - processingStatus
        - pendingFileCount
        - failedFileCount
        - createdAt
        - updatedAt

    UploadSessionsResponse:
      type: object
      properties:
        sessions:
          type: array
          items:
            $ref: '#/components/schemas/UploadSessionSummary'
        pagination:
          $ref: '#/components/schemas/Pagination'
      required:
        - sessions
        - pagination

    UploadSessionDetailResponse:
      type: object
      description: Upload session with linked test runs and coverage reports.
      properties:
        session:
          $ref: '#/components/schemas/UploadSessionSummary'
        testRuns:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
              framework:
                type: string
                nullable: true
              summary:
                $ref: '#/components/schemas/TestSummary'
              createdAt:
                type: string
                format: date-time
            required:
              - id
              - summary
              - createdAt
        coverageReports:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
              format:
                type: string
              createdAt:
                type: string
                format: date-time
            required:
              - id
              - format
              - createdAt
      required:
        - session
        - testRuns
        - coverageReports

    Meta:
      type: object
      nullable: true
      description: |
        Present when query parameters were clamped by plan tier limits.
        Only included in the response when clamping actually occurred.
      properties:
        appliedDays:
          type: integer
          description: The actual days value used after clamping
        requestedDays:
          type: integer
          description: The days value originally requested
        appliedLimit:
          type: integer
          description: The actual limit value used after clamping
        requestedLimit:
          type: integer
          description: The limit value originally requested
        clampedByPlan:
          type: boolean
          description: Always true when this object is present

    Pagination:
      type: object
      properties:
        limit:
          type: integer
        offset:
          type: integer
        total:
          type: integer
        hasMore:
          type: boolean
      required:
        - limit
        - offset
        - total
        - hasMore

    Error:
      type: object
      description: >
        The error envelope emitted by the h3/Nitro runtime. Note that `error` is a
        boolean flag, not a nested object: the human-readable reason is in
        `statusMessage`, and `message` is usually an empty string.
      properties:
        error:
          type: boolean
          description: Always `true` on an error response.
          example: true
        url:
          type: string
          description: Absolute URL of the request that failed.
          example: https://app.gaffer.sh/api/v1/user/projects
        statusCode:
          type: integer
          description: HTTP status code, repeated in the body.
          example: 401
        statusMessage:
          type: string
          description: The reason to surface to a human or log. Branch on `statusCode`, not on this string.
          example: Invalid API key format. Expected user API key (gaf_...).
        message:
          type: string
          description: Reserved for detail most errors do not carry. Frequently an empty string.
          example: ""
      required:
        - error
        - statusCode

    QuotaExceededError:
      type: object
      description: >
        402 body returned by the report-upload endpoints (the CLI / GitHub
        Action's `/api/upload`, `/api/upload/mpu/create`) when a Free-tier
        organization's storage cap would be exceeded. These endpoints are not
        otherwise part of this document. They're consumed by `gaffer` (the
        CLI) and the GitHub Action, not called directly, but the 402 shape
        is documented here so an agent parsing the error can act on it. This
        is the raw h3/Nitro envelope (`statusCode`/`statusMessage`/`data`),
        not the `Error` schema above.
      properties:
        statusCode:
          type: integer
          example: 402
        statusMessage:
          type: string
          description: Human-readable summary. Verbatim wording is stable; don't branch on it.
          example: "Storage limit exceeded. Current usage: 490MB, Limit: 500MB, Upload size: 20MB. Upgrade your plan for more storage."
        data:
          type: object
          properties:
            reason:
              type: string
              enum: [quota_exceeded]
              description: Stable machine-readable reason code.
            claim_url:
              type: string
              format: uri
              description: >
                Present only when the uploading organization was provisioned
                automatically (via the GitHub Actions OIDC token exchange) and
                has not yet been claimed by a human. Points a repository admin
                at the claim flow that takes ownership of the organization.
              example: https://app.gaffer.sh/claim/org_abc123
            billing_url:
              type: string
              format: uri
              description: Always present. Points at the organization's billing/upgrade page.
              example: https://app.gaffer.sh/settings/billing
          required:
            - reason
            - billing_url
      required:
        - statusCode
        - statusMessage
        - data

  responses:
    BadRequest:
      description: Invalid request parameters
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: true
            url: https://app.gaffer.sh/api/v1/user/projects/abc123/health
            statusCode: 400
            statusMessage: Invalid query parameters
            message: ""

    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: true
            url: https://app.gaffer.sh/api/v1/user/projects
            statusCode: 401
            statusMessage: Invalid API key format. Expected user API key (gaf_...).
            message: ""

    Forbidden:
      description: Authenticated but insufficient permissions
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: true
            url: https://app.gaffer.sh/api/v1/user/projects/abc123/health
            statusCode: 403
            statusMessage: You do not have access to this organization
            message: ""

    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: true
            url: https://app.gaffer.sh/api/v1/user/projects/abc123/health
            statusCode: 404
            statusMessage: Resource not found
            message: ""

    RateLimited:
      description: Rate limit exceeded
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until rate limit resets
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: RATE_LIMIT_EXCEEDED
              message: Rate limit exceeded. Try again later.

tags:
  - name: Project
    description: Project-scoped endpoints (project token auth)
  - name: User Projects
    description: User-scoped project and test run endpoints
  - name: Test History
    description: Test run details, per-test history, and comparisons
  - name: Analytics
    description: Flaky test detection and performance analysis
  - name: Coverage
    description: Code coverage metrics and risk analysis
  - name: Reports
    description: HTML test report viewing
  - name: Upload Sessions
    description: Upload session status and linked results
