openapi: 3.1.0
info:
  title: Demystify QA API
  version: "1.0.0"
  description: >-
    Bugs and test cycles for every Demystify application under test, callable by
    machines: other products, CI jobs, GitHub Actions and Vercel crons, external
    dashboards, and orchestrated agents. Miatz is the system of record; a GitHub
    issue is an optional per-bug mirror.

    Full guide: docs/qa/QA-API.md. MCP server: POST /api/qa/mcp.
servers:
  - url: https://miatz.com/api/qa
    description: Production
  - url: https://dev.miatz.com/api/qa
    description: Integration (verify here first)

security:
  - bearerAuth: []

tags:
  - name: apps
  - name: bugs
  - name: comments
  - name: cycles
  - name: github
  - name: mcp
  - name: tokens

paths:
  /apps:
    get:
      tags: [apps]
      summary: Applications under test this credential can reach
      description: >-
        An empty list means nothing is in scope for this credential, never that
        no apps exist.
      parameters:
        - { name: query, in: query, schema: { type: string }, description: Filter on name or slug }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      ok: { const: true }
                      apps: { type: array, items: { $ref: "#/components/schemas/App" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /bugs:
    get:
      tags: [bugs]
      summary: List bugs, newest first
      description: >-
        Use `updated_since` to poll — it returns only what moved. The default
        excludes closed and won't-fix bugs.

        Ordered on (created_at desc, id desc) and walked with `cursor`: while
        `has_more` is true there are older matching bugs this page did not
        show. A poller that stops at the first page silently loses the rest of
        its backlog.
      parameters:
        - { name: app, in: query, schema: { type: string }, description: App slug or uuid }
        - name: workflow_state
          in: query
          description: Repeatable, or comma-separated
          schema: { type: array, items: { $ref: "#/components/schemas/WorkflowState" } }
        - name: severity
          in: query
          schema: { type: array, items: { $ref: "#/components/schemas/Severity" } }
        - name: priority
          in: query
          schema: { type: array, items: { $ref: "#/components/schemas/Priority" } }
        - { name: assignee_id, in: query, schema: { type: string, format: uuid } }
        - name: mine
          in: query
          schema: { type: boolean }
          description: Only bugs the calling person reported. User sessions only.
        - { name: updated_since, in: query, schema: { type: string, format: date-time } }
        - { name: include_closed, in: query, schema: { type: boolean } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      ok: { const: true }
                      bugs: { type: array, items: { $ref: "#/components/schemas/Bug" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [bugs]
      summary: File a bug
      description: >-
        steps, expected and actual are required. A report without them cannot be
        verified, so it is refused rather than accepted.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewBug" }
      responses:
        "200":
          description: Filed
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  bug: { $ref: "#/components/schemas/Bug" }
        "400": { $ref: "#/components/responses/Invalid" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /bugs/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [bugs]
      summary: One bug with its whole comment thread
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  bug: { $ref: "#/components/schemas/Bug" }
                  comments: { type: array, items: { $ref: "#/components/schemas/Comment" } }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [bugs]
      summary: Move a bug through its lifecycle, or re-grade it
      description: >-
        A state change with no `comment` still writes one automatically. A
        non-staff reporter may send only `comment`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                workflow_state: { $ref: "#/components/schemas/WorkflowState" }
                severity: { $ref: "#/components/schemas/Severity" }
                priority: { $ref: "#/components/schemas/Priority" }
                assignee_id: { type: [string, "null"], format: uuid }
                labels: { type: array, maxItems: 12, items: { type: string } }
                comment: { type: string, maxLength: 8000 }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  bug: { $ref: "#/components/schemas/Bug" }
        "400": { $ref: "#/components/responses/Invalid" }
        "404": { $ref: "#/components/responses/NotFound" }

  /bugs/{id}/comments:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [comments]
      summary: The thread, oldest first
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  count: { type: integer }
                  comments: { type: array, items: { $ref: "#/components/schemas/Comment" } }
    post:
      tags: [comments]
      summary: Append to the thread
      description: Machine comments are attributed to the credential, never to a person.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, minLength: 1, maxLength: 8000 }
      responses:
        "200":
          description: Posted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  comment: { $ref: "#/components/schemas/Comment" }

  /bugs/{id}/github:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [bugs]
      summary: Mirror the bug as a GitHub issue, or attach an existing one
      description: >-
        Needs a GitHub repo_url on the app. The credential is resolved per bug,
        tenant first: (1) the GitHub App installation of the org the bug belongs
        to, which ALWAYS wins so one customer's connection can never reach
        another's repos; (2) GITHUB_QA_TOKEN / GITHUB_TOKEN, the platform's own
        credential, and only for repos owned by GITHUB_QA_PLATFORM_OWNERS —
        a tenant admin may set repo_url freely, so without that fence they could
        borrow our access; (3) neither, which is the 503, naming which of the
        two is missing and for whom. An existing_ref is verified against the
        GitHub API, not trusted.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                existing_ref:
                  type: string
                  pattern: '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#\d+$'
                  examples: ["miatz-as/miatz#412"]
      responses:
        "200":
          description: Linked
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  bug: { $ref: "#/components/schemas/Bug" }
        "400": { $ref: "#/components/responses/Invalid" }
        "503":
          description: The GitHub bridge is not configured on this deployment
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /cycles:
    get:
      tags: [cycles]
      summary: Recent test cycles, newest first
      description: >-
        Ordered on (started_at desc, id desc) and walked with `cursor` while
        `has_more` is true.
      parameters:
        - { name: app, in: query, schema: { type: string } }
        - name: status
          in: query
          schema: { type: array, items: { $ref: "#/components/schemas/CycleStatus" } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      ok: { const: true }
                      cycles: { type: array, items: { $ref: "#/components/schemas/Cycle" } }
    post:
      tags: [cycles]
      summary: Open a test cycle
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [app, name]
              properties:
                app: { type: string }
                name: { type: string, maxLength: 200 }
                kind: { $ref: "#/components/schemas/CycleKind" }
                ref: { type: string, maxLength: 300, description: Commit SHA, PR, tag or deployment URL }
                environment: { type: string, maxLength: 300 }
      responses:
        "200":
          description: Opened
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  cycle: { $ref: "#/components/schemas/Cycle" }

  /cycles/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [cycles]
      summary: One cycle with every case outcome
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  cycle:
                    allOf:
                      - $ref: "#/components/schemas/Cycle"
                      - type: object
                        properties:
                          results:
                            type: array
                            items:
                              type: object
                              properties:
                                caseKey: { type: string }
                                title: { type: [string, "null"] }
                                result: { $ref: "#/components/schemas/CaseResult" }
                                bugId: { type: [string, "null"], format: uuid }

  /cycles/{id}/results:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [cycles]
      summary: Post case outcomes, optionally closing the cycle
      description: >-
        Totals and the final status are recomputed server-side from the stored
        rows, never taken from the caller. Any fail or blocked case ⇒ failed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [results]
              properties:
                complete: { type: boolean }
                results:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: object
                    required: [case_key, result]
                    properties:
                      case_key: { type: string, maxLength: 300 }
                      title: { type: string, maxLength: 300 }
                      result: { $ref: "#/components/schemas/CaseResult" }
                      duration_ms: { type: integer, minimum: 0 }
                      details: { type: string, maxLength: 4000 }
                      bug_id: { type: string, format: uuid }
      responses:
        "200":
          description: Recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  cycle: { $ref: "#/components/schemas/Cycle" }

  /github/connect:
    get:
      tags: [github]
      summary: Start a tenant's GitHub App installation
      description: >-
        A browser redirect, not a machine call: it sends an owner or admin of
        `orgId` to GitHub's install page for the miatz app, where THEY choose
        which of their own repositories to grant. The org travels onward inside
        a signed, expiring `state`, never re-read from the callback's query
        string.
      security:
        - orgSession: []
      parameters:
        - { name: orgId, in: query, required: true, schema: { type: string, format: uuid } }
      responses:
        "302":
          description: Redirect to GitHub's installation page
          headers:
            Location: { schema: { type: string, format: uri } }
        "400": { $ref: "#/components/responses/Invalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /github/callback:
    get:
      tags: [github]
      summary: Where GitHub returns after an install
      description: >-
        Called by GitHub, never by an integrator. The signed `state` must verify
        AND the caller must STILL hold owner/admin on that org; the
        installation's account is read from GitHub, not from the query string.
        Every outcome is a redirect to /org/integrations — a JSON body would be
        shown as raw text in the browser the person landed in.
      security:
        - orgSession: []
      parameters:
        - { name: state, in: query, schema: { type: string }, description: The signed, expiring state issued by /github/connect }
        - { name: installation_id, in: query, schema: { type: integer } }
        - name: setup_action
          in: query
          schema: { type: string, enum: [install, update] }
          description: "`update` with no installation_id is a Configure visit, not a new install"
      responses:
        "302":
          description: >-
            Redirect to /org/integrations — `?github=connected&account=…` when
            the installation was recorded, `?error=…` otherwise.
          headers:
            Location: { schema: { type: string, format: uri } }

  /github/installations/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: The connection row's id }
    delete:
      tags: [github]
      summary: Forget a GitHub connection
      description: >-
        Removes OUR record so the bridge stops using it. It deliberately does
        NOT uninstall the app on GitHub: the grant belongs to the customer's
        GitHub organization, and revoking it from our side would misrepresent
        who controls it. The response says where to finish the removal.
      security:
        - orgSession: []
      parameters:
        - { name: orgId, in: query, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Forgotten
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  forgotten: { type: string, format: uuid }
                  note: { type: string }
        "400": { $ref: "#/components/responses/Invalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /mcp:
    post:
      tags: [mcp]
      summary: The QA remote MCP server (JSON-RPC 2.0, Streamable HTTP)
      description: >-
        Stateless single-response mode. `tools/list` advertises only the tools
        the calling credential can run, and the dispatcher re-checks the scope
        before every call — a tool list is a convenience, never the boundary.
        A TOOL failure comes back 200 with `isError` so the agent can read the
        reason and adapt; only parse and auth failures use a transport status.

        Add it to a repo's agent with `claude mcp add --transport http miatz-qa
        https://miatz.com/api/qa/mcp --header "Authorization: Bearer qa_…"`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RpcRequest" }
      responses:
        "200":
          description: A JSON-RPC result, including a tool call that failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RpcResponse" }
        "202": { description: A notification was accepted; there is no body }
        "400":
          description: Unparseable JSON-RPC, or an unknown method
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RpcResponse" }
        "401":
          description: No valid credential (JSON-RPC error -32001)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RpcResponse" }
        "429":
          description: Over 120 requests/minute (JSON-RPC error -32002)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RpcResponse" }

  /tokens:
    get:
      tags: [tokens]
      summary: Every QA credential, without its hash
      description: Super admin only.
      responses:
        "200": { description: OK }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [tokens]
      summary: Mint a credential
      description: >-
        Super admin only. The plaintext is returned EXACTLY ONCE and cannot be
        recovered. created_by is who machine-filed bugs are attributed to.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 2, maxLength: 80 }
                org_id: { type: [string, "null"], format: uuid, description: null = platform-wide }
                app_ids: { type: array, maxItems: 50, items: { type: string, format: uuid } }
                scopes: { type: array, items: { $ref: "#/components/schemas/Scope" } }
                expires_in_days: { type: integer, minimum: 1, maximum: 365 }
      responses:
        "200":
          description: Minted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  token: { type: object }
                  secret: { type: string, description: Shown once. Store it now. }

  /tokens/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      tags: [tokens]
      summary: Revoke a credential (soft — attribution survives)
      responses:
        "200": { description: Revoked }
        "403": { $ref: "#/components/responses/Forbidden" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        Either a `qa_` machine token or a Supabase access token. A browser
        session cookie also works. 120 requests/minute per credential.
    orgSession:
      type: apiKey
      in: cookie
      name: sb-<project-ref>-auth-token
      description: >-
        The Supabase browser session, which must belong to an owner or admin of
        the org. The GitHub connection endpoints take no other credential: a
        grant is repo write access for the whole tenant, so a machine token
        cannot start or undo one.

  parameters:
    Cursor:
      name: cursor
      in: query
      description: >-
        The `next_cursor` from the previous page. Opaque — decode nothing from
        it and construct none of your own; it encodes a keyset position and the
        encoding is ours to change. Keep every other filter identical across a
        walk.
      schema: { type: string }

  responses:
    Unauthorized:
      description: No valid credential
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: The credential is missing a required scope
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Not found, or outside this credential's scope (the two are indistinguishable by design)
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Invalid:
      description: The request body failed validation
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: Over 120 requests/minute
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  schemas:
    Page:
      type: object
      description: >-
        Every list response carries these. `has_more` true means the page you
        are holding is NOT the whole answer: call again with
        `cursor=<next_cursor>` until it is false. `count` is the size of THIS
        page, never the size of the result set.
      properties:
        count: { type: integer }
        has_more: { type: boolean }
        next_cursor: { type: [string, "null"] }

    Error:
      type: object
      properties:
        error: { type: string }

    RpcRequest:
      type: object
      required: [jsonrpc, method]
      properties:
        jsonrpc: { const: "2.0" }
        id: { type: [string, integer, "null"], description: Absent for a notification }
        method: { type: string, examples: ["tools/list", "tools/call"] }
        params: { type: object }

    RpcResponse:
      type: object
      required: [jsonrpc]
      properties:
        jsonrpc: { const: "2.0" }
        id: { type: [string, integer, "null"] }
        result: { type: object }
        error:
          type: object
          properties:
            code: { type: integer }
            message: { type: string }

    Scope:
      type: string
      enum: [apps:read, bugs:read, bugs:write, comments:write, tests:write, github:write]

    Severity: { type: string, enum: [low, medium, high, critical] }
    Priority: { type: string, enum: [p0, p1, p2, p3] }
    WorkflowState:
      type: string
      enum: [open, triaged, in_progress, fixed, verified, closed, wont_fix, duplicate, cannot_reproduce]
    TriageStatus:
      type: string
      enum: [pending, valid, invalid, duplicate]
      description: The reviewer's verdict, distinct from workflow_state. Drives the /admin/bugs board and the Slack digest.
    CycleKind: { type: string, enum: [manual, ci, agent, regression, smoke, e2e] }
    CycleStatus: { type: string, enum: [running, passed, failed, cancelled] }
    CaseResult: { type: string, enum: [pass, fail, skip, blocked] }

    App:
      type: object
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        name: { type: string }
        orgId: { type: [string, "null"], format: uuid }
        repoUrl: { type: [string, "null"] }
        status: { type: string }
        category: { type: [string, "null"] }
        openBugs: { type: integer }

    NewBug:
      type: object
      required: [app, title, steps, expected, actual]
      properties:
        app: { type: string, description: App slug or uuid }
        title: { type: string, minLength: 3, maxLength: 200 }
        steps: { type: string, minLength: 1 }
        expected: { type: string, minLength: 1 }
        actual: { type: string, minLength: 1 }
        severity: { $ref: "#/components/schemas/Severity" }
        priority: { $ref: "#/components/schemas/Priority" }
        environment: { type: string, maxLength: 500 }
        labels: { type: array, maxItems: 12, items: { type: string, maxLength: 40 } }
        cycle_id: { type: string, format: uuid }

    Bug:
      type: object
      properties:
        id: { type: string, format: uuid }
        appId: { type: [string, "null"], format: uuid }
        appSlug: { type: [string, "null"] }
        orgId: { type: [string, "null"], format: uuid }
        title: { type: string }
        steps: { type: string }
        expected: { type: string }
        actual: { type: string }
        severity: { $ref: "#/components/schemas/Severity" }
        priority: { $ref: "#/components/schemas/Priority" }
        status: { $ref: "#/components/schemas/TriageStatus" }
        workflowState: { $ref: "#/components/schemas/WorkflowState" }
        environment: { type: [string, "null"] }
        labels: { type: array, items: { type: string } }
        source: { type: string, enum: [app, api, mcp, ci, agent, tools, import] }
        reporterId: { type: [string, "null"], format: uuid }
        assigneeId: { type: [string, "null"], format: uuid }
        externalProvider: { type: [string, "null"], enum: [github, null] }
        externalRef: { type: [string, "null"], examples: ["miatz-as/miatz#412"] }
        externalUrl: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: [string, "null"], format: date-time }

    Comment:
      type: object
      properties:
        id: { type: string, format: uuid }
        bugId: { type: string, format: uuid }
        body: { type: string }
        authorKind: { type: string, enum: [human, agent, system, external] }
        authorId: { type: [string, "null"], format: uuid }
        authorLabel: { type: [string, "null"], description: The credential's name for machine comments }
        createdAt: { type: string, format: date-time }

    Cycle:
      type: object
      properties:
        id: { type: string, format: uuid }
        appId: { type: [string, "null"], format: uuid }
        orgId: { type: [string, "null"], format: uuid }
        name: { type: string }
        kind: { $ref: "#/components/schemas/CycleKind" }
        status: { $ref: "#/components/schemas/CycleStatus" }
        ref: { type: [string, "null"] }
        environment: { type: [string, "null"] }
        startedAt: { type: string, format: date-time }
        completedAt: { type: [string, "null"], format: date-time }
        totals:
          type: object
          properties:
            passed: { type: integer }
            failed: { type: integer }
            skipped: { type: integer }
            blocked: { type: integer }
