openapi: 3.1.0
info:
  title: Bugnet REST API
  version: "1.0.0"
  summary: Manage projects and bug reports with a user session.
  description: |
    The authenticated API behind the Bugnet dashboard, for scripts and
    integrations that act as a team member: listing and triaging reports,
    commenting, labelling, managing webhooks and changelogs.

    **Authentication.** Exchange an email and password for a session token
    with `POST /api/auth/login`, then send it as `Authorization: Bearer
    <token>`. Tokens last 30 days and are revoked by `POST /api/auth/logout`.
    Keep them server-side — a token acts as that user on every project in
    their account. Game clients should use the project API key and the
    **Public API** instead.

    **Addressing.** Projects are addressed by `slug`; bug reports by their
    per-project report `number` (the `#12` in the dashboard), not their UUID.

    **Permissions.** Every project route checks that the caller is a member
    of the project, and write routes check the relevant account permission
    (e.g. `bugs.assign`, `webhooks.manage`). Non-members get `404`; members
    without the permission get `403`.

    Every response uses the envelope `{"ok": true, "data": …}` or
    `{"ok": false, "error": "message"}`. Rate limit: 300 requests/minute per
    IP (login and signup: 10/minute). Every path is also served under
    `/api/v1/…`.

    This spec covers the core resources. The full route list, including
    analytics, integrations, team and billing, is on the
    [API Reference](/docs/api-reference) page.
  contact:
    name: Bugnet
    url: https://bugnet.io/contact
servers:
  - url: https://api.bugnet.io
    description: Production API
  - url: https://bugnet.io
    description: Same API, proxied through the website origin
security:
  - BearerAuth: []
tags:
  - name: Auth
    description: Sessions and the signed-in user.
  - name: Projects
    description: Games you track reports for.
  - name: Bug reports
    description: Listing, reading, creating, triaging and deleting reports.
  - name: Comments & attachments
    description: Discussion and files on a report.
  - name: Labels
    description: Project labels and applying them to reports.
  - name: Webhooks
    description: Outgoing HTTP notifications for report events.
  - name: Integrations
    description: |
      Connections from a project to other tools. Chat providers (`slack`,
      `teams`) post messages; alerting providers (`pagerduty`, `opsgenie`)
      open an incident per new bug and resolve it with the bug; tracker
      providers (`linear`, `clickup`, `jira`, `asana`, `trello`, `airtable`,
      `monday`, `notion`) file bugs as issues, records, items or pages and
      keep each one's status in step; `sentry` links crash reports to the Sentry
      issue for their error type and resolves them together; store providers
      (`googleplay`, `appstore`) import reviews hourly, can file bug-like
      one- and two-star reviews as reports, and post replies.
      Every provider uses the same routes; `{provider}` is its key. Listing needs project membership; creating,
      changing, testing and removing need the `project.integrations`
      permission.
  - name: Changelog
    description: Managing entries for the public changelog.

paths:
  /api/auth/login:
    post:
      tags: [Auth]
      operationId: login
      summary: Sign in
      description: Returns a session token. Accepts email and password, or a Firebase ID token from Google/GitHub sign-in. Rate limit 10/minute.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password }
                firebase_token: { type: string, description: Use instead of email/password for social sign-in. }
            example:
              email: dev@example.com
              password: your_password
      responses:
        '200':
          description: Signed in.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Session' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: Wrong email or password, or an invalid Firebase token.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/auth/signup:
    post:
      tags: [Auth]
      operationId: signup
      summary: Create an account
      description: Creates a user (and their account) and signs them in. A verification email is sent. Rate limit 10/minute.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                name: { type: string, maxLength: 100, description: Defaults to the part of the email before `@`. }
                email: { type: string, format: email }
                password: { type: string, format: password, minLength: 8 }
      responses:
        '201':
          description: Account created and signed in.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Session' }
        '400':
          description: Missing fields, invalid email, or a password under 8 characters.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '409':
          description: An account with this email already exists.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/auth/logout:
    post:
      tags: [Auth]
      operationId: logout
      summary: Revoke the current session token
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/auth/me:
    get:
      tags: [Auth]
      operationId: getMe
      summary: Get the signed-in user
      responses:
        '200':
          description: The user's profile.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/User' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/auth/profile:
    patch:
      tags: [Auth]
      operationId: updateProfile
      summary: Update the signed-in user's profile
      description: Send only the fields to change. Unrecognized values for `theme` and `role` are ignored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name: { type: string }
                avatar_url: { type: string }
                theme: { type: string, enum: [light, dark] }
                role: { type: string, enum: [developer, designer, qa, producer, community_manager, player] }
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '400':
          description: No valid fields to update.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/auth/change-password:
    post:
      tags: [Auth]
      operationId: changePassword
      summary: Change password
      description: Only for email/password accounts; social-login accounts get `400`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, new_password]
              properties:
                current_password: { type: string, format: password }
                new_password: { type: string, format: password, minLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: Missing session, or the current password is wrong.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  /api/projects:
    get:
      tags: [Projects]
      operationId: listProjects
      summary: List your projects
      responses:
        '200':
          description: Every project you are a member of.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/ProjectSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Projects]
      operationId: createProject
      summary: Create a project
      description: |
        Creates a project in the account you work in. The stored slug is
        prefixed with a short code derived from your user ID (e.g. `my-game`
        becomes `a1b2c3-my-game`), so read the `slug` from the response.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 150 }
                slug: { type: string, description: 'Defaults to the name, lowercased with spaces as hyphens.' }
                description: { type: string }
                website_url: { type: string }
            example:
              name: My Cool Game
              slug: my-cool-game
              description: An awesome platformer
              website_url: https://mycoolga.me
      responses:
        '201':
          description: Project created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string, format: uuid }
                          slug: { type: string }
                          api_key: { type: string, description: The project's SDK key. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The account has reached its plan's project limit.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  /api/projects/{slug}:
    parameters:
      - $ref: '#/components/parameters/Slug'
    get:
      tags: [Projects]
      operationId: getProject
      summary: Get a project
      responses:
        '200':
          description: Project details, settings and stats.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Project' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Projects]
      operationId: updateProject
      summary: Update a project
      description: Send only the fields to change.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProjectUpdate' }
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Projects]
      operationId: deleteProject
      summary: Delete a project
      description: Permanently deletes the project and its reports. Only the account owner, or the teammate who created the project, may do this. Deleting a project does not give back bug report or upload quota.
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/projects/{slug}/rotate-key:
    parameters:
      - $ref: '#/components/parameters/Slug'
    post:
      tags: [Projects]
      operationId: rotateProjectKey
      summary: Rotate the project API key
      description: Issues a new `sk_live_…` key. The old key stops working immediately, so shipped builds using it can no longer report.
      responses:
        '200':
          description: The new key.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          api_key: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/bugs:
    parameters:
      - $ref: '#/components/parameters/Slug'
    get:
      tags: [Bug reports]
      operationId: listBugs
      summary: List bug reports
      description: 25 reports per page. Deleted reports are excluded.
      parameters:
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/Status' }
        - name: priority
          in: query
          schema: { $ref: '#/components/schemas/Priority' }
        - name: category
          in: query
          schema: { $ref: '#/components/schemas/Category' }
        - name: q
          in: query
          description: Full-text search over title and description (MySQL boolean mode).
          schema: { type: string }
        - name: assigned_to
          in: query
          description: A user ID, or `unassigned`.
          schema: { type: string }
        - name: label
          in: query
          description: A label ID.
          schema: { type: string }
        - name: source
          in: query
          description: '`user` for player/team reports, `system` for reports Bugnet filed itself.'
          schema: { type: string, enum: [user, system] }
        - name: error_type
          in: query
          description: An exception/error class, as returned in `error_type` (e.g. `NullReferenceException`). See `GET /api/projects/{slug}/error-types` for the types a project has.
          schema: { type: string }
        - name: sort
          in: query
          description: Defaults to newest first.
          schema: { type: string, enum: [priority, upvotes, updated] }
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: One page of reports.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          bugs:
                            type: array
                            items: { $ref: '#/components/schemas/BugSummary' }
                          total: { type: integer }
                          page: { type: integer }
                          limit: { type: integer, const: 25 }
                          total_pages: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Bug reports]
      operationId: createBug
      summary: Create a bug report as a team member
      description: Files a report attributed to you. To file from a game, use `POST /api/bugs/submit` with the API key instead.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BugCreate' }
            example:
              title: Player falls through floor on level 3
              description: Near the bridge the collision mesh has a gap.
              category: gameplay
              priority: high
              steps_to_reproduce: "1. Start level 3\n2. Walk to the bridge\n3. Jump near the left railing"
              expected_behavior: Player lands on the bridge
              actual_behavior: Player falls through the floor
              platform: Windows
              game_version: 1.2.0
      responses:
        '201':
          description: Report created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string, format: uuid }
                          report_number: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The account has reached its plan's bug report limit.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/bugs/bulk:
    parameters:
      - $ref: '#/components/parameters/Slug'
    patch:
      tags: [Bug reports]
      operationId: bulkUpdateBugs
      summary: Update up to 100 reports at once
      description: Identify reports by `bug_ids` (UUIDs) or `bug_numbers`. Set any of the change fields.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/BulkSelector'
                - type: object
                  properties:
                    status: { $ref: '#/components/schemas/Status' }
                    priority: { $ref: '#/components/schemas/Priority' }
                    category: { $ref: '#/components/schemas/Category' }
                    assigned_to: { type: string, description: A user ID. }
                    is_private: { type: boolean }
            example:
              bug_numbers: [12, 15, 19]
              status: resolved
      responses:
        '200':
          description: Number of reports changed.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          updated: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Bug reports]
      operationId: bulkDeleteBugs
      summary: Delete up to 100 reports at once
      description: Soft-deletes the reports. Deleted reports still count toward the plan's bug report quota.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BulkSelector' }
      responses:
        '200':
          description: Number of reports deleted.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          deleted: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/bugs/similar:
    parameters:
      - $ref: '#/components/parameters/Slug'
    get:
      tags: [Bug reports]
      operationId: findSimilarBugs
      summary: Find reports similar to a title
      parameters:
        - name: title
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Matching reports (empty when none, or when the title is too short).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            report_number: { type: integer }
                            title: { type: string }
                            status: { $ref: '#/components/schemas/Status' }
                            priority: { $ref: '#/components/schemas/Priority' }
                            created_at: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/projects/{slug}/bugs/export:
    parameters:
      - $ref: '#/components/parameters/Slug'
    get:
      tags: [Bug reports]
      operationId: exportBugsCsv
      summary: Export reports as CSV
      parameters:
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/Status' }
        - name: priority
          in: query
          schema: { $ref: '#/components/schemas/Priority' }
      responses:
        '200':
          description: A CSV file.
          content:
            text/csv:
              schema: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/error-types:
    parameters:
      - $ref: '#/components/parameters/Slug'
    get:
      tags: [Bug reports]
      operationId: listErrorTypes
      summary: List a project's error types
      description: Each exception/error class found in the project's reports, with how many reports carry it, most common first (at most 200). Deleted reports and reports that name no error class are left out.
      responses:
        '200':
          description: Error types with report counts.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            error_type: { $ref: '#/components/schemas/ErrorType' }
                            count: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /api/projects/{slug}/bugs/{number}:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Number'
    get:
      tags: [Bug reports]
      operationId: getBug
      summary: Get a report with its comments and labels
      responses:
        '200':
          description: The report.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          bug: { $ref: '#/components/schemas/Bug' }
                          comments:
                            type: array
                            items: { $ref: '#/components/schemas/Comment' }
                          labels:
                            type: array
                            items:
                              type: object
                              properties:
                                name: { type: string }
                                color: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Bug reports]
      operationId: updateBug
      summary: Update a report
      description: Send only the fields to change. Changing `assigned_to` needs the `bugs.assign` permission. Fires the `bug_updated` webhook.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                description: { type: string }
                status: { $ref: '#/components/schemas/Status' }
                priority: { $ref: '#/components/schemas/Priority' }
                category: { $ref: '#/components/schemas/Category' }
                assigned_to: { type: string, description: A user ID; empty string unassigns. }
                is_private: { type: boolean, description: Private reports never appear on the public tracker or roadmap. }
                platform: { type: string }
            example:
              status: in_progress
              priority: high
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Bug reports]
      operationId: deleteBug
      summary: Delete a report
      description: Soft-deletes the report. It still counts toward the plan's bug report quota.
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/bugs/{number}/activity:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Number'
    get:
      tags: [Bug reports]
      operationId: getBugActivity
      summary: Get a report's activity log
      responses:
        '200':
          description: Activity entries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Activity' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/bugs/{number}/occurrences:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Number'
    get:
      tags: [Bug reports]
      operationId: listBugOccurrences
      summary: List stacked occurrences of a report
      description: The individual submissions grouped onto this report, 50 per page.
      parameters:
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: One page of occurrences.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          occurrences:
                            type: array
                            items: { $ref: '#/components/schemas/Occurrence' }
                          total: { type: integer }
                          page: { type: integer }
                          per_page: { type: integer, const: 50 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/bugs/{number}/comments:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Number'
    post:
      tags: [Comments & attachments]
      operationId: addComment
      summary: Comment on a report
      description: Internal comments are visible to the team only and never appear on the public tracker. Fires the `comment_added` webhook.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
                is_internal: { type: boolean, default: false }
            example:
              body: Reproduced on 1.2.0 — looking into the collision mesh.
              is_internal: true
      responses:
        '201':
          description: Comment added.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string, format: uuid }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/bugs/{number}/attachments:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Number'
    get:
      tags: [Comments & attachments]
      operationId: listAttachments
      summary: List a report's attachments
      responses:
        '200':
          description: Attachments.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string }
                            file_name: { type: string }
                            file_url: { type: string }
                            file_size: { type: integer }
                            mime_type: { type: [string, 'null'] }
                            created_at: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Comments & attachments]
      operationId: uploadAttachment
      summary: Upload an attachment
      description: One file per request, up to 10 MB. Only text, image, video and JSON files are accepted. Counts toward the plan's upload limit.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        '201':
          description: Attachment stored.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
                          file_url: { type: string }
                          file_name: { type: string }
                          file_size: { type: integer }
        '400':
          description: Missing file, file over 10 MB, or a disallowed type.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Not a member, or the upload limit is reached.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  /api/projects/{slug}/labels:
    parameters:
      - $ref: '#/components/parameters/Slug'
    get:
      tags: [Labels]
      operationId: listLabels
      summary: List labels
      responses:
        '200':
          description: The project's labels.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Label' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Labels]
      operationId: createLabel
      summary: Create a label
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                color: { type: string, default: '#2E5BFF', description: Hex color. }
      responses:
        '201':
          description: Label created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
                          name: { type: string }
                          color: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: A label with this name already exists.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  /api/projects/{slug}/labels/{labelId}:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/LabelId'
    patch:
      tags: [Labels]
      operationId: updateLabel
      summary: Rename or recolor a label
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                color: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    delete:
      tags: [Labels]
      operationId: deleteLabel
      summary: Delete a label
      description: Also removes it from every report.
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/projects/{slug}/bugs/{number}/labels:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Number'
    post:
      tags: [Labels]
      operationId: addLabelToBug
      summary: Apply a label to a report
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label_id]
              properties:
                label_id: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/bugs/{number}/labels/{labelId}:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Number'
      - $ref: '#/components/parameters/LabelId'
    delete:
      tags: [Labels]
      operationId: removeLabelFromBug
      summary: Remove a label from a report
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/webhooks/generic:
    parameters:
      - $ref: '#/components/parameters/Slug'
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhooks
      responses:
        '200':
          description: The project's webhooks, including each one's stored `secret`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Webhook' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Create a webhook
      description: |
        Bugnet POSTs `{"event", "timestamp", "data"}` JSON to `url` for each
        subscribed event, with `X-Bugnet-Event` and `X-Bugnet-Delivery`
        headers. When `secret` is set, each delivery also carries
        `X-Bugnet-Signature: sha256=<hex>`, the HMAC-SHA256 of the raw body
        keyed with the secret. A delivery that gets no response below 400
        within 10 seconds is retried after 1, 5, 25 and 125 minutes (5
        attempts in total). Needs the `webhooks.manage` permission.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                name: { type: string, default: Webhook }
                event_types:
                  type: string
                  default: bug_created,bug_updated,comment_added
                  description: Comma-separated list of `bug_created`, `bug_updated`, `comment_added`.
                secret: { type: string, description: 'Signing secret. When set, deliveries carry `X-Bugnet-Signature: sha256=<HMAC-SHA256 of the raw body>`.' }
            example:
              url: https://example.com/hooks/bugnet
              name: Triage bot
              event_types: bug_created,comment_added
              secret: a-long-random-string
      responses:
        '201':
          description: Webhook created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/webhooks/generic/{webhookId}:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/WebhookId'
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete a webhook
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/webhooks/generic/{webhookId}/test:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/WebhookId'
    post:
      tags: [Webhooks]
      operationId: testWebhook
      summary: Send a test payload
      description: Sends a `test` event, signed like a real delivery when the webhook has a secret. It is not recorded as a delivery or retried.
      responses:
        '200':
          description: The receiver answered; `status_code` is its HTTP status.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          status_code: { type: integer }
                          message: { type: string }
                          signed: { type: boolean, description: Whether the test payload was signed. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '502':
          description: The webhook URL could not be reached.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  /api/projects/{slug}/webhooks/generic/{webhookId}/deliveries:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/WebhookId'
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: List recent deliveries
      description: The webhook's 50 most recent deliveries, newest first.
      responses:
        '200':
          description: Recorded deliveries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/WebhookDelivery' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/integrations/providers:
    get:
      tags: [Integrations]
      operationId: listIntegrationProviders
      summary: List integration providers
      description: The tools a project can connect to and the events each accepts.
      responses:
        '200':
          description: Registered providers.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            key: { type: string, example: slack }
                            name: { type: string, example: Slack }
                            events: { type: array, items: { $ref: '#/components/schemas/IntegrationEvent' } }
                            default_events: { type: array, items: { $ref: '#/components/schemas/IntegrationEvent' } }
                            plan: { type: string, description: Lowest plan that carries the provider; omitted when every plan does. }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/projects/{slug}/integrations:
    parameters:
      - $ref: '#/components/parameters/Slug'
    get:
      tags: [Integrations]
      operationId: listIntegrations
      summary: List a project's integrations
      description: Every connection on the project. The stored credential is never returned; `secret_hint` identifies it.
      parameters:
        - name: provider
          in: query
          required: false
          schema: { type: string }
          description: Only this provider's connections.
      responses:
        '200':
          description: The project's connections.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Integration' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/integrations/{provider}:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Provider'
    post:
      tags: [Integrations]
      operationId: createIntegration
      summary: Connect a channel or tool
      description: |
        `secret` is the provider's credential. For `slack` it is an incoming
        webhook URL (`https://hooks.slack.com/services/…`); for `teams` it is
        a Workflows webhook URL (`https://….logic.azure.com/…` or
        `….powerplatform.com`) — URLs on any other host are rejected. For
        `linear` it is a personal API key (`lin_api_…`) and `config` needs
        `team_id`; for `clickup` it is an API token (`pk_…`) and `config`
        needs `list_id` (list both with `POST …/{provider}/targets`). For
        `pagerduty` it is an Events API v2 integration key and for
        `opsgenie` an API integration key; both take `config.region`
        (`us`/`eu`). For `sentry` it is an auth token with Project: Read and
        Issue & Event: Read & Write, `config.project` is `org-slug:project-id`
        from `targets` and `config.region` is `us` or `de`. For `jira` (Studio
        Plus and up) it is an Atlassian API token with `config.site`
        (`*.atlassian.net`), `config.email` and `config.project_id`; for
        `asana` a personal access token with `config.project_gid`; for
        `trello` a user token with `config.api_key` and `config.list_id`.
        For `airtable` a personal access token with `config.table`
        (`appID/tblID`), for `monday` an API token with `config.board_id`,
        for `notion` an internal integration secret with
        `config.database_id`; these fill the columns/properties whose names
        match Bugnet's fields. For `googleplay` it is a service-account JSON
        key with `config.package_name`; for `appstore` the `.p8` key with
        `config.issuer_id`, `config.key_id` and `config.app_id` (from
        `targets`). Store connections take the events `reviews` and
        `review_bugs`. `targets` accepts the same `config` for
        providers that need it (Jira's site and email, Trello's API key).

        Chat providers post each subscribed event whose bug is at or above
        `min_priority`; `digest` sends a daily summary from 09:00. For
        trackers, `bug_created` files every new bug at or above
        `min_priority` as an issue and `bug_updated` moves a linked issue
        when its bug's status changes. Failed deliveries are retried after
        1, 5, 25 and 125 minutes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/IntegrationInput'
                - required: [secret]
            example:
              secret: https://hooks.slack.com/services/T000/B000/XXXXXXXX
              name: '#qa'
              event_types: [bug_created, bug_updated, digest]
              min_priority: low
      responses:
        '201':
          description: Connected.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/integrations/{provider}/targets:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Provider'
    post:
      tags: [Integrations]
      operationId: listIntegrationTargets
      summary: List where a tracker can file issues
      description: Linear teams or ClickUp lists the credential can see. Send a pasted `secret`, or the `id` of a saved connection to use its stored one.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                secret: { type: string }
                id: { type: string }
            example: { secret: lin_api_xxxxxxxx }
      responses:
        '200':
          description: Targets, with `group` naming the workspace/space/folder where there is one.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string }
                            name: { type: string, example: ENG — Engineering }
                            group: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '502':
          description: The tool refused the credential or could not be reached.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  /api/projects/{slug}/bugs/{number}/issues:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Number'
    get:
      tags: [Integrations]
      operationId: listBugTrackerIssues
      summary: Issues filed for a report
      description: Issues tracker connections created for this report, and the active tracker connections that could file it.
      responses:
        '200':
          description: Linked issues and available trackers.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          links: { type: array, items: { $ref: '#/components/schemas/TrackerIssueLink' } }
                          trackers:
                            type: array
                            items:
                              type: object
                              properties:
                                id: { type: string, description: Integration ID. }
                                provider: { type: string }
                                provider_name: { type: string }
                                name: { type: string }
                                target: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Integrations]
      operationId: fileBugInTracker
      summary: File a report in a tracker
      description: Creates the issue now. Filing the same report through the same connection again returns the existing link. Needs `bugs.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [integration_id]
              properties:
                integration_id: { type: string }
      responses:
        '201':
          description: The linked issue.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/TrackerIssueLink' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '502':
          description: The tracker refused the issue; `error` includes its answer.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  /api/projects/{slug}/integrations/{provider}/{integrationId}:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Provider'
      - $ref: '#/components/parameters/IntegrationId'
    patch:
      tags: [Integrations]
      operationId: updateIntegration
      summary: Change a connection
      description: Any of the fields may be sent; `is_active` false pauses it (queued retries wait until it is resumed).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IntegrationInput' }
            example: { min_priority: critical, is_active: true }
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Integrations]
      operationId: deleteIntegration
      summary: Disconnect
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/integrations/{provider}/{integrationId}/sync:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Provider'
      - $ref: '#/components/parameters/IntegrationId'
    post:
      tags: [Integrations]
      operationId: syncStoreReviews
      summary: Import store reviews now
      description: Runs the hourly review import for a Google Play or App Store connection immediately.
      responses:
        '200':
          description: What the import did.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          fetched: { type: integer }
                          new: { type: integer }
                          filed: { type: integer, description: New reviews filed as bug reports. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '502':
          description: The store refused the credentials; `error` says why.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  /api/projects/{slug}/store-reviews:
    parameters:
      - $ref: '#/components/parameters/Slug'
    get:
      tags: [Integrations]
      operationId: listStoreReviews
      summary: List imported store reviews
      parameters:
        - { name: provider, in: query, required: false, schema: { type: string, enum: [googleplay, appstore] } }
        - { name: max_rating, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 5 } }
        - { name: page, in: query, required: false, schema: { type: integer, minimum: 1, default: 1 }, description: 50 per page. }
      responses:
        '200':
          description: Reviews, newest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/StoreReview' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/store-reviews/{reviewId}/reply:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - { name: reviewId, in: path, required: true, schema: { type: string } }
    post:
      tags: [Integrations]
      operationId: replyToStoreReview
      summary: Reply to a store review
      description: Posts (or replaces) the public developer reply. Google Play allows 350 characters. Needs `project.integrations`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '502':
          description: The store rejected the reply.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  /api/projects/{slug}/integrations/{provider}/{integrationId}/test:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Provider'
      - $ref: '#/components/parameters/IntegrationId'
    post:
      tags: [Integrations]
      operationId: testIntegration
      summary: Send a test message
      description: Posts a test message right away. It is not recorded as a delivery or retried.
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '502':
          description: The tool rejected the message; `error` includes its answer.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  /api/projects/{slug}/integrations/{provider}/{integrationId}/deliveries:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - $ref: '#/components/parameters/Provider'
      - $ref: '#/components/parameters/IntegrationId'
    get:
      tags: [Integrations]
      operationId: listIntegrationDeliveries
      summary: List recent deliveries
      description: The connection's 50 most recent messages, newest first.
      responses:
        '200':
          description: Recorded deliveries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/IntegrationDelivery' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/changelog/all:
    parameters:
      - $ref: '#/components/parameters/Slug'
    get:
      tags: [Changelog]
      operationId: listAllChangelog
      summary: List all changelog entries, drafts included
      description: The published list is public — see `GET /api/projects/{slug}/changelog` in the Public API.
      responses:
        '200':
          description: Entries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { type: object }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/projects/{slug}/changelog:
    parameters:
      - $ref: '#/components/parameters/Slug'
    post:
      tags: [Changelog]
      operationId: createChangelogEntry
      summary: Create a changelog entry
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, body]
              properties:
                title: { type: string }
                body: { type: string }
                version: { type: string }
                publish: { type: boolean, default: false, description: Publish immediately instead of saving a draft. }
      responses:
        '201':
          description: Entry created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/projects/{slug}/changelog/{entryId}:
    parameters:
      - $ref: '#/components/parameters/Slug'
      - name: entryId
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: [Changelog]
      operationId: updateChangelogEntry
      summary: Update or publish a changelog entry
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                body: { type: string }
                version: { type: string }
                publish: { type: boolean }
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    delete:
      tags: [Changelog]
      operationId: deleteChangelogEntry
      summary: Delete a changelog entry
      responses:
        '200': { $ref: '#/components/responses/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/projects/{slug}/changelog/auto-generate:
    parameters:
      - $ref: '#/components/parameters/Slug'
    post:
      tags: [Changelog]
      operationId: autoGenerateChangelog
      summary: Draft an entry from recently resolved reports
      responses:
        '201':
          description: Draft created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
                          bugs_included: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: Session token from `POST /api/auth/login`.

  parameters:
    Slug:
      name: slug
      in: path
      required: true
      description: The project slug.
      schema: { type: string }
    Number:
      name: number
      in: path
      required: true
      description: The report number within the project, not the UUID.
      schema: { type: integer, minimum: 1 }
    Page:
      name: page
      in: query
      description: 1-based page number.
      schema: { type: integer, minimum: 1, default: 1 }
    LabelId:
      name: labelId
      in: path
      required: true
      schema: { type: string }
    WebhookId:
      name: webhookId
      in: path
      required: true
      schema: { type: string }
    Provider:
      name: provider
      in: path
      required: true
      description: Integration provider key.
      schema: { type: string, enum: [slack, teams, pagerduty, opsgenie, linear, clickup, jira, asana, trello, airtable, monday, notion, sentry, googleplay, appstore] }
    IntegrationId:
      name: integrationId
      in: path
      required: true
      schema: { type: string }

  responses:
    Message:
      description: Success, with a short confirmation message.
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/OkEnvelope'
              - type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: { type: string }
    BadRequest:
      description: The request was malformed or failed validation.
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    Unauthorized:
      description: Missing, invalid or expired session token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example: { ok: false, error: invalid or expired session }
    Forbidden:
      description: You are a member but lack the permission this action needs.
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    NotFound:
      description: The project or record does not exist, or you are not a member of the project.
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    RateLimited:
      description: Too many requests from this IP. Retry after `Retry-After` seconds.
      headers:
        Retry-After:
          schema: { type: integer }
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  schemas:
    OkEnvelope:
      type: object
      required: [ok]
      properties:
        ok: { type: boolean, const: true }
        data: {}
    ErrorEnvelope:
      type: object
      required: [ok, error]
      properties:
        ok: { type: boolean, const: false }
        error: { type: string }

    Priority:
      type: string
      enum: [critical, high, medium, low]
    Category:
      type: string
      enum: [crash, visual, gameplay, performance, audio, ui, network, other]
    Status:
      type: string
      enum: [open, in_progress, resolved, closed, wont_fix]

    Session:
      type: object
      properties:
        token: { type: string, description: 'Send as `Authorization: Bearer <token>`.' }
        user_id: { type: string, format: uuid }
        email: { type: string }
        name: { type: string }
        onboarding_done: { type: boolean }
        email_verified: { type: boolean }

    User:
      type: object
      properties:
        id: { type: string, format: uuid }
        email: { type: string }
        display_name: { type: string }
        avatar_url: { type: [string, 'null'] }
        role: { type: string }
        email_verified: { type: boolean }
        onboarding_done: { type: boolean }
        game_platform: { type: [string, 'null'] }
        theme: { type: string }
        created_at: { type: string, format: date-time }
        has_password: { type: boolean }
        support_share_code: { type: [string, 'null'] }
        support_share_code_expires_at: { type: [string, 'null'] }

    ProjectSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        description: { type: [string, 'null'] }
        logo_url: { type: [string, 'null'] }
        is_public: { type: boolean }
        created_at: { type: string, format: date-time }
        bug_count: { type: integer }
        open_bugs: { type: integer }

    Project:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        description: { type: [string, 'null'] }
        website_url: { type: [string, 'null'] }
        logo_url: { type: [string, 'null'] }
        api_key: { type: string }
        is_public: { type: boolean, description: 'When off, the API key is rejected by every SDK endpoint.' }
        screenshot_capture: { type: boolean }
        session_capture: { type: boolean }
        auto_file_errors: { type: boolean }
        capture_editor_errors: { type: boolean }
        capture_warnings: { type: boolean }
        bug_grouping: { type: boolean }
        created_at: { type: string, format: date-time }
        bug_count: { type: integer }
        open_bugs: { type: integer }
        in_progress_bugs: { type: integer }
        critical_bugs: { type: integer }
        member_count: { type: integer }
        avg_resolution_hours: { type: number }
        member_role: { type: string, description: Your role on the project. }
        plan: { type: string }

    ProjectUpdate:
      type: object
      properties:
        name: { type: string }
        description: { type: string }
        website_url: { type: string }
        logo_url: { type: string }
        is_public: { type: boolean }
        screenshot_capture: { type: boolean }
        session_capture: { type: boolean }
        auto_file_errors: { type: boolean }
        capture_editor_errors: { type: boolean }
        capture_warnings: { type: boolean }
        bug_grouping: { type: boolean, description: Stack reports with identical titles onto one report. }

    BugSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        report_number: { type: integer }
        title: { type: string }
        status: { $ref: '#/components/schemas/Status' }
        priority: { $ref: '#/components/schemas/Priority' }
        category: { $ref: '#/components/schemas/Category' }
        platform: { type: [string, 'null'] }
        game_version: { type: [string, 'null'] }
        is_private: { type: boolean }
        upvote_count: { type: integer }
        occurrence_count: { type: integer }
        last_occurred_at: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        reporter_name: { type: [string, 'null'] }
        screenshot_url: { type: [string, 'null'] }
        source: { type: string, enum: [user, system] }
        error_type: { $ref: '#/components/schemas/ErrorType' }

    ErrorType:
      type: string
      description: The exception or error class the report names (e.g. `NullReferenceException`, `TypeError`, `ZeroDivisionError`, `SIGSEGV`), read from its title and stack trace. Empty when the report names none.
      example: NullReferenceException

    Bug:
      type: object
      properties:
        id: { type: string, format: uuid }
        report_number: { type: integer }
        title: { type: string }
        description: { type: string }
        status: { $ref: '#/components/schemas/Status' }
        priority: { $ref: '#/components/schemas/Priority' }
        category: { $ref: '#/components/schemas/Category' }
        platform: { type: [string, 'null'] }
        game_version: { type: [string, 'null'] }
        os_info: { type: [string, 'null'] }
        device_info: { type: [string, 'null'] }
        steps_to_reproduce: { type: [string, 'null'] }
        expected_behavior: { type: [string, 'null'] }
        actual_behavior: { type: [string, 'null'] }
        screenshot_url: { type: [string, 'null'] }
        is_private: { type: boolean }
        upvote_count: { type: integer }
        occurrence_count: { type: integer }
        last_occurred_at: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        resolved_at: { type: [string, 'null'] }
        due_date: { type: [string, 'null'] }
        due_date_auto: { type: boolean, description: 'True when `due_date` was set by the project''s SLA rule rather than by hand (returned by the single-bug endpoint).' }
        reporter_name: { type: [string, 'null'] }
        reporter_email: { type: [string, 'null'] }
        assignee_name: { type: [string, 'null'] }
        metadata: { description: 'The JSON metadata submitted with the report, or null.' }
        error_type: { $ref: '#/components/schemas/ErrorType' }

    BugCreate:
      type: object
      required: [title, description]
      properties:
        title: { type: string }
        description: { type: string }
        priority: { allOf: [{ $ref: '#/components/schemas/Priority' }], default: medium }
        category: { allOf: [{ $ref: '#/components/schemas/Category' }], default: other }
        platform: { type: string }
        game_version: { type: string }
        os_info: { type: string }
        device_info: { type: string }
        steps_to_reproduce: { type: string }
        expected_behavior: { type: string }
        actual_behavior: { type: string }

    BulkSelector:
      type: object
      description: Provide `bug_ids` or `bug_numbers` (at most 100).
      properties:
        bug_ids:
          type: array
          maxItems: 100
          items: { type: string, format: uuid }
        bug_numbers:
          type: array
          maxItems: 100
          items: { type: integer }

    Comment:
      type: object
      properties:
        id: { type: string }
        body: { type: string }
        is_internal: { type: boolean }
        created_at: { type: string, format: date-time }
        author: { type: [string, 'null'] }
        avatar_url: { type: [string, 'null'] }

    Activity:
      type: object
      properties:
        id: { type: string }
        action: { type: string }
        field_name: { type: [string, 'null'] }
        old_value: { type: [string, 'null'] }
        new_value: { type: [string, 'null'] }
        actor_name: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }

    Occurrence:
      type: object
      properties:
        id: { type: string }
        platform: { type: [string, 'null'] }
        game_version: { type: [string, 'null'] }
        os_info: { type: [string, 'null'] }
        device_info: { type: [string, 'null'] }
        description: { type: [string, 'null'] }
        screenshot_url: { type: [string, 'null'] }
        reporter_name: { type: [string, 'null'] }
        steam_id: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }

    Label:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        color: { type: string }
        created_at: { type: string, format: date-time }

    Webhook:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        url: { type: string }
        secret: { type: string, description: Omitted when empty. }
        event_types: { type: string, description: Comma-separated event names. }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }

    IntegrationEvent:
      type: string
      enum: [bug_created, bug_updated, comment_added, digest, reviews, review_bugs]
      description: |
        `bug_updated` fires on status, priority, assignee, category and title
        changes. `comment_added` never includes internal notes. `digest` is a
        daily summary sent from 09:00.

    IntegrationInput:
      type: object
      properties:
        secret: { type: string, description: 'The provider credential, e.g. a Slack incoming webhook URL. Write-only.' }
        name: { type: string, maxLength: 150, description: 'Label shown in the dashboard, e.g. `#qa`.' }
        event_types:
          type: array
          items: { $ref: '#/components/schemas/IntegrationEvent' }
          description: Defaults to `[bug_created, bug_updated]`.
        min_priority: { type: string, enum: [low, medium, high, critical], default: low, description: Only bugs at or above this priority are sent. }
        is_active: { type: boolean }
        config: { type: object, description: Provider-specific settings. }

    Integration:
      type: object
      properties:
        id: { type: string }
        provider: { type: string, example: slack }
        name: { type: string }
        secret_hint: { type: string, example: 'hooks.slack.com/…abcd', description: Host and last characters of the credential. }
        event_types: { type: array, items: { $ref: '#/components/schemas/IntegrationEvent' } }
        min_priority: { type: string, enum: [low, medium, high, critical] }
        is_active: { type: boolean }
        config: { type: object }
        last_delivery_at: { type: [string, 'null'], format: date-time }
        last_error: { type: [string, 'null'], description: What the tool answered the last time a message failed. }
        created_at: { type: string, format: date-time }

    IntegrationDelivery:
      type: object
      properties:
        id: { type: string }
        event_type: { $ref: '#/components/schemas/IntegrationEvent' }
        status: { type: string, enum: [pending, success, failed] }
        attempts: { type: integer }
        last_error: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }

    TrackerIssueLink:
      type: object
      properties:
        integration_id: { type: string }
        provider: { type: string, example: linear }
        provider_name: { type: string, example: Linear }
        key: { type: [string, 'null'], example: ENG-123 }
        url: { type: string }
        title: { type: [string, 'null'] }
        state: { type: [string, 'null'], description: The issue's state in the tracker as last synced. }
        created_at: { type: string, format: date-time }

    StoreReview:
      type: object
      properties:
        id: { type: string }
        provider: { type: string }
        provider_name: { type: string }
        author: { type: [string, 'null'] }
        rating: { type: integer, minimum: 1, maximum: 5 }
        title: { type: [string, 'null'] }
        body: { type: [string, 'null'] }
        language: { type: [string, 'null'] }
        territory: { type: [string, 'null'] }
        app_version: { type: [string, 'null'] }
        device: { type: [string, 'null'] }
        os_version: { type: [string, 'null'] }
        reviewed_at: { type: [string, 'null'], format: date-time }
        reply: { type: [string, 'null'] }
        replied_at: { type: [string, 'null'], format: date-time }
        bug_number: { type: [integer, 'null'], description: The bug report this review was filed as. }
        can_reply: { type: boolean }
        reply_limit: { type: integer }

    WebhookDelivery:
      type: object
      properties:
        id: { type: string, description: 'Also sent as the `X-Bugnet-Delivery` header.' }
        event_type: { type: string }
        status: { type: string, enum: [pending, success, failed] }
        attempts: { type: integer }
        last_error: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
