diff --git a/.github/workflows/update-openapi-specs.yml b/.github/workflows/update-openapi-specs.yml index 097bbeb62e..32a24e1c9a 100644 --- a/.github/workflows/update-openapi-specs.yml +++ b/.github/workflows/update-openapi-specs.yml @@ -22,7 +22,7 @@ jobs: update-specs: name: Fetch specs and open PR if changed runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: # Runs as the semgrep-docs-release App rather than GITHUB_TOKEN for two # reasons: App-authored PRs trigger Docs CI (GITHUB_TOKEN-authored ones do @@ -39,6 +39,9 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: token: ${{ steps.generate-token.outputs.token }} + # Full history: the changelog generator diffs the specs across git + # revisions, which oasdiff reads with `git show` under the hood. + fetch-depth: 0 - name: Fetch latest specs from semgrep.dev run: | @@ -67,13 +70,76 @@ jobs: # Upstream emits no `servers:` block, and Mintlify has no docs.json # override for OpenAPI-generated pages, so without this the playground - # renders https://api.example.com and cannot send requests. Runs last: - # the two steps above parse `paths:` and `tags:`, and neither needs to - # see the block this adds. Safe to drop once semgrep-app emits `servers` - # natively -- this step no-ops rather than overwriting it. + # renders https://api.example.com and cannot send requests. Runs after + # the two steps above, which parse `paths:` and `tags:` and neither of + # which needs to see the block this adds. Safe to drop once semgrep-app + # emits `servers` natively -- this step no-ops rather than overwriting it. - name: Add the API base URL Mintlify's playground needs run: uv run scripts/add_openapi_servers.py docs/public_v1.openapi.yaml docs/public_v2.openapi.yaml + # Checksummed binary download rather than a third-party action, per the + # supply-chain pinning policy. Keep the version pinned: bumping oasdiff + # regenerates every changelog entry, which belongs in its own PR. + - name: Install oasdiff (pinned) + run: | + curl --fail --silent --show-error --location --retry 3 \ + --output /tmp/oasdiff.tar.gz \ + https://github.com/oasdiff/oasdiff/releases/download/v1.28.0/oasdiff_1.28.0_linux_amd64.tar.gz + echo "e0ef076f2cf953d922addc04be9c3851cf3ec18f7678d2b94d44cea23dca51b5 /tmp/oasdiff.tar.gz" | sha256sum --check + tar -xzf /tmp/oasdiff.tar.gz -C /tmp oasdiff + sudo install /tmp/oasdiff /usr/local/bin/oasdiff + + # Must run after the mirror, sort, and servers steps so the working-tree + # specs being diffed are in their final committed form. Runs before + # validate so the generated MDX is validated too. + - name: Regenerate API changelogs + run: | + uv run scripts/generate_api_changelog.py docs/public_v1.openapi.yaml \ + docs/api-reference/v1/Changelog.mdx \ + --api-name "Semgrep API v1" --api-href /api-reference/v1/Introduction \ + --link-base /api-reference/v1 \ + --rss-url https://docs.semgrep.dev/api-reference/v1/Changelog/rss.xml + uv run scripts/generate_api_changelog.py docs/public_v2.openapi.yaml \ + docs/api-reference/v2/Changelog.mdx \ + --api-name "Semgrep API v2" --api-href /api-reference/v2/Introduction \ + --link-base /api-reference/v2 \ + --rss-url https://docs.semgrep.dev/api-reference/v2/Changelog/rss.xml + + # The table each service landing page shows. Hand-maintaining it would go + # stale every time an endpoint is published or regrouped, and a hardcoded + # "no endpoints yet" placeholder is wrong the moment the first one lands. + - name: Regenerate the service endpoint tables + run: | + uv run scripts/generate_service_endpoints.py docs/public_v2.openapi.yaml \ + docs/snippets/api/v2 --link-base /api-reference/v2 \ + --changelog /api-reference/v2/Changelog \ + --services-dir docs/api-reference/v2/services + + # One nav group per service, each opening its landing page rather than + # whichever endpoint sorts first. Mintlify's autogenerated groups cannot + # carry a `root`, so the endpoint list has to be enumerated -- which is + # why it is generated rather than hand-maintained. Runs after the sort + # step, whose order it inherits. + # v2 only. v1 has no service landing pages, and regrouping it would + # move every v1 endpoint's URL for no benefit -- it is legacy and its + # flat "Endpoints" group is fine. + - name: Rebuild the API navigation + run: | + uv run scripts/generate_api_nav.py docs/public_v2.openapi.yaml \ + docs/docs.json --dropdown v2 --landing-dir api-reference/v2/services \ + --directory api-reference/v2 + + # Mintlify builds an endpoint's page URL from its first tag, so an + # endpoint regrouped into a new service moves and its old URL 404s. + # Must run before the changelog step commits, and while the previous + # spec is still what HEAD has -- this diffs the two. + - name: Redirect endpoints whose page URL moved + run: | + uv run scripts/generate_openapi_redirects.py docs/public_v1.openapi.yaml \ + docs/docs.json --link-base /api-reference/v1 + uv run scripts/generate_openapi_redirects.py docs/public_v2.openapi.yaml \ + docs/docs.json --link-base /api-reference/v2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 @@ -92,6 +158,10 @@ jobs: add-paths: | docs/public_v1.openapi.yaml docs/public_v2.openapi.yaml + docs/docs.json + docs/api-reference/v1/Changelog.mdx + docs/api-reference/v2/Changelog.mdx + docs/snippets/api branch: chore/update-openapi-specs delete-branch: true # Re-issued on every run, not just when the PR is first opened. That @@ -113,3 +183,7 @@ jobs: - `docs/public_v1.openapi.yaml` ← https://semgrep.dev/api/v1/public_v1.openapi.yaml - `docs/public_v2.openapi.yaml` ← https://semgrep.dev/api/v2/openapi.yaml + + The API changelog pages under `docs/api-reference/*/Changelog.mdx` + are regenerated from the spec's git history by + `scripts/generate_api_changelog.py`. diff --git a/docs/api-reference/v1/Changelog.mdx b/docs/api-reference/v1/Changelog.mdx new file mode 100644 index 0000000000..8397c32543 --- /dev/null +++ b/docs/api-reference/v1/Changelog.mdx @@ -0,0 +1,140 @@ +--- +title: "Changelog" +description: "Changes to the Semgrep API v1, generated from its OpenAPI specification." +rss: true +--- + +{/* Auto-generated by scripts/generate_api_changelog.py -- do not edit by hand. */} + +Changes to the [Semgrep API v1](/api-reference/v1/Introduction), detected by +comparing successive versions of its OpenAPI specification. Breaking changes +are labeled; documentation-only edits are not listed. + + + **Subscribe to this changelog:** add [`https://docs.semgrep.dev/api-reference/v1/Changelog/rss.xml`](https://docs.semgrep.dev/api-reference/v1/Changelog/rss.xml) to any + feed reader, or run `/feed subscribe https://docs.semgrep.dev/api-reference/v1/Changelog/rss.xml` in Slack to post new + entries to a channel. Most readers also accept this page's own URL and + find the feed automatically. + + + +**Breaking changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Removed | removed the success response with the status `200` | POST /api/v1/deployments/{deploymentId}/sbom/export | + +
+ +**Changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the success response with the status `202` | POST /api/v1/deployments/{deploymentId}/sbom/export | +| Added | added the new optional request property `is_partial_scan` | POST /api/v1/deployments/{deploymentId}/scans/search | +| Added | added the optional property `scans[].is_partial_scan` to the response with the `200` status | POST /api/v1/deployments/{deploymentId}/scans/search | + +
+
+ + +**Potentially breaking changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Deprecated | endpoint deprecated | GET /api/v1/deployments/{deploymentId}/policies | +| Deprecated | endpoint deprecated | GET /api/v1/deployments/{deploymentId}/policies/{policyId} | +| Deprecated | endpoint deprecated | PUT /api/v1/deployments/{deploymentId}/policies/{policyId} | + +
+ +**Changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the new optional request property `dependencyFilter.includeArchived` | POST /api/v1/deployments/{deploymentId}/dependencies
POST /api/v1/deployments/{deploymentId}/dependencies/repositories
POST /api/v1/deployments/{deploymentId}/dependencies/repositories/{repositoryId}/lockfiles | + +
+
+ + +**Breaking changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Changed | the `deployments[].id` response's property `format` changed from `uint32` to `int64` for status `200` | GET /api/v1/deployments | +| Changed | the `dependencyFilter.repositoryId[]` request property `format` changed from `uint32` to `int64` | POST /api/v1/deployments/{deploymentId}/dependencies | +| Changed | the `format` of the request properties `cursor`, `deploymentId` changed from `uint64` to `int64` | POST /api/v1/deployments/{deploymentId}/dependencies | +| Changed | the `cursor` response's property `format` changed from `uint64` to `int64` for status `200` | POST /api/v1/deployments/{deploymentId}/dependencies | +| Changed | the `deploymentId` request property `format` changed from `uint64` to `int64` | POST /api/v1/deployments/{deploymentId}/dependencies/repositories
POST /api/v1/deployments/{deploymentId}/scans/search | +| Changed | the `format` of the request properties `cursor`, `dependencyFilter.repositoryId[]`, `pageSize` changed from `uint32` to `int64` | POST /api/v1/deployments/{deploymentId}/dependencies/repositories | +| Changed | the `format` of the response properties `cursor`, `repositorySummaries[].id`, `repositorySummaries[].numDependencies` changed from `uint32` to `int64` for status `200` | POST /api/v1/deployments/{deploymentId}/dependencies/repositories | +| Changed | for the `path` request parameters `deploymentId`, `repositoryId`, the `format` was changed from `uint64` to `int64` | POST /api/v1/deployments/{deploymentId}/dependencies/repositories/{repositoryId}/lockfiles | +| Changed | the `format` of the request properties `dependencyFilter.repositoryId[]`, `pageSize` changed from `uint32` to `int64` | POST /api/v1/deployments/{deploymentId}/dependencies/repositories/{repositoryId}/lockfiles | +| Changed | the `format` of the request properties `deploymentId`, `repositoryId` changed from `uint64` to `int64` | POST /api/v1/deployments/{deploymentId}/dependencies/repositories/{repositoryId}/lockfiles
POST /api/v1/deployments/{deploymentId}/sbom/export | +| Changed | the `lockfileSummaries[].numDependencies` response's property `format` changed from `uint32` to `int64` for status `200` | POST /api/v1/deployments/{deploymentId}/dependencies/repositories/{repositoryId}/lockfiles | +| Changed | for the `path` request parameter `deploymentId`, the `format` was changed from `uint64` to `int64` | GET /api/v1/deployments/{deploymentId}/policies
GET /api/v1/deployments/{deploymentId}/secrets
POST /api/v1/deployments/{deploymentId}/dependencies
and 3 more | +| Changed | the `policies[].id` response's property `format` changed from `uint64` to `int64` for status `200` | GET /api/v1/deployments/{deploymentId}/policies | +| Changed | for the `path` request parameters `deploymentId`, `policyId`, the `format` was changed from `uint64` to `int64` | GET /api/v1/deployments/{deploymentId}/policies/{policyId}
PUT /api/v1/deployments/{deploymentId}/policies/{policyId} | +| Changed | for the `query` request parameter `limit`, the `format` was changed from `uint32` to `int64` | GET /api/v1/deployments/{deploymentId}/policies/{policyId}
GET /api/v1/deployments/{deploymentId}/secrets | +| Changed | the `format` of the response properties `policy.id`, `rules[].id` changed from `uint64` to `int64` for status `200` | GET /api/v1/deployments/{deploymentId}/policies/{policyId} | +| Changed | the `format` of the request properties `deploymentId`, `policyId` changed from `uint64` to `int64` | PUT /api/v1/deployments/{deploymentId}/policies/{policyId} | +| Changed | the `format` of the response properties `policyId`, `updatedRule.id` changed from `uint64` to `int64` for status `200` | PUT /api/v1/deployments/{deploymentId}/policies/{policyId} | +| Changed | for the `path` request parameters `deploymentId`, `scanId`, the `format` was changed from `uint64` to `int64` | GET /api/v1/deployments/{deploymentId}/scan/{scanId} | +| Changed | the `format` of the response properties `deployment_id`, `exit_code`, `id`, `repository_id` changed from `uint32` to `int64` for status `200` | GET /api/v1/deployments/{deploymentId}/scan/{scanId} | +| Changed | the `format` of the response properties `scans[].deployment_id`, `scans[].findings_counts.code`, `scans[].findings_counts.secrets`, `scans[].findings_counts.supply_chain`, `scans[].findings_counts.total`, `scans[].id`, `scans[].repository_id` changed from `uint64` to `int64` for status `200` | POST /api/v1/deployments/{deploymentId}/scans/search | +| Changed | for the `path` request parameter `externalTicketId`, the `format` was changed from `uint32` to `int64` | DELETE /api/v1/deployments/{deploymentId}/ticketing/v2/tickets/{externalTicketId} | +| Changed | for the `query` request parameter `repository_ids`, the `format` of property `items` was narrowed from `uint32` to `int64` | GET /api/v1/deployments/{deploymentSlug}/findings | +| Changed | for the `query` request parameters `page`, `page_size`, the `format` was changed from `uint32` to `int64` | GET /api/v1/deployments/{deploymentSlug}/findings
GET /api/v1/deployments/{deploymentSlug}/projects | +| Changed | the `projects[].id` response's property `format` changed from `uint32` to `int64` for status `200` | GET /api/v1/deployments/{deploymentSlug}/projects | +| Changed | the `project.id` response's property `format` changed from `uint32` to `int64` for status `200` | DELETE /api/v1/deployments/{deploymentSlug}/projects/{projectName}/tags
GET /api/v1/deployments/{deploymentSlug}/projects/{projectName}
PATCH /api/v1/deployments/{deploymentSlug}/projects/{projectName}
and 2 more | +| Changed | the `limit` request property `format` changed from `uint32` to `int64` | POST /api/v1/deployments/{deploymentSlug}/tickets | +| Changed | the `format` of the response properties `failed[].issue_ids[]`, `skipped[].issue_ids[]`, `succeeded[].issue_ids[]`, `succeeded[].ticket_id` changed from `uint32` to `int64` for status `200` | POST /api/v1/deployments/{deploymentSlug}/tickets | +| Changed | the `format` of the request properties `issue_ids[]`, `limit` changed from `uint32` to `int64` | POST /api/v1/deployments/{deploymentSlug}/triage | +| Changed | the `format` of the response properties `num_triaged`, `triaged_issues[]` changed from `uint32` to `int64` for status `200` | POST /api/v1/deployments/{deploymentSlug}/triage | + +
+ +**Potentially breaking changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Removed | removed the request property `formatVersion.version` | POST /api/v1/deployments/{deploymentId}/sbom/export | +| Removed | removed the optional property `sastFindings` from the response with the `200` status | GET /api/v1/deployments/{deploymentSlug}/findings | +| Removed | removed the optional property `scaFindings` from the response with the `200` status | GET /api/v1/deployments/{deploymentSlug}/findings | +| Removed | removed the request property `deploymentSlug` | POST /api/v1/deployments/{deploymentSlug}/triage | + +
+ +**Changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the new enum values to the `cocoapods` request and response property | POST /api/v1/deployments/{deploymentId}/dependencies
POST /api/v1/deployments/{deploymentId}/dependencies/repositories
POST /api/v1/deployments/{deploymentId}/dependencies/repositories/{repositoryId}/lockfiles | +| Added | added the new optional request property `formatVersion.cyclonedxVersion` | POST /api/v1/deployments/{deploymentId}/sbom/export | +| Added | added the new `SCAN_STATUS_CANCELLED`, `SCAN_STATUS_PENDING` enum values to the `statuses` request property | POST /api/v1/deployments/{deploymentId}/scans/search | +| Added | added the new `SCAN_STATUS_CANCELLED`, `SCAN_STATUS_PENDING` enum values to the `scans[].status` response property for the response status `200` | POST /api/v1/deployments/{deploymentId}/scans/search | +| Added | added the new `SCM_TYPE_HARNESS` enum value to the `findings[].repository.scmType` response property for the response status `200` | GET /api/v1/deployments/{deploymentId}/secrets | +| Added | endpoint added | POST /api/v1/deployments/{deploymentId}/tickets/link | +| Added | endpoint added | POST /api/v1/deployments/{deploymentId}/tickets/unlink | +| Added | added the new enum values to the `duplicate` request property | GET /api/v1/deployments/{deploymentSlug}/findings
POST /api/v1/deployments/{deploymentSlug}/tickets
POST /api/v1/deployments/{deploymentSlug}/triage | +| Added | added the new enum values to the `provisionally_ignored` request property | GET /api/v1/deployments/{deploymentSlug}/findings
POST /api/v1/deployments/{deploymentSlug}/tickets
POST /api/v1/deployments/{deploymentSlug}/triage | +| Added | added the new enum value `ai_sast` to the `query` request parameter `issue_type` | GET /api/v1/deployments/{deploymentSlug}/findings | +| Added | added the optional property `findings` to the response with the `200` status | GET /api/v1/deployments/{deploymentSlug}/findings | + +
+
diff --git a/docs/api-reference/v1/Deprecation-Policy.mdx b/docs/api-reference/v1/Deprecation-Policy.mdx new file mode 100644 index 0000000000..965b076020 --- /dev/null +++ b/docs/api-reference/v1/Deprecation-Policy.mdx @@ -0,0 +1,10 @@ +--- +title: "Deprecation Policy" +description: "Learn how Semgrep communicates API deprecations, including notice periods and endpoint removal timelines." +--- + +{/* Shared with the other API version -- edit /snippets/api-deprecation-policy.mdx */} + +import ApiDeprecationPolicy from "/snippets/api-deprecation-policy.mdx" + + diff --git a/docs/api-reference/v2/Changelog.mdx b/docs/api-reference/v2/Changelog.mdx new file mode 100644 index 0000000000..3474752aa9 --- /dev/null +++ b/docs/api-reference/v2/Changelog.mdx @@ -0,0 +1,214 @@ +--- +title: "Changelog" +description: "Changes to the Semgrep API v2, generated from its OpenAPI specification." +rss: true +--- + +{/* Auto-generated by scripts/generate_api_changelog.py -- do not edit by hand. */} + +Changes to the [Semgrep API v2](/api-reference/v2/Introduction), detected by +comparing successive versions of its OpenAPI specification. Breaking changes +are labeled; documentation-only edits are not listed. + + + **Subscribe to this changelog:** add [`https://docs.semgrep.dev/api-reference/v2/Changelog/rss.xml`](https://docs.semgrep.dev/api-reference/v2/Changelog/rss.xml) to any + feed reader, or run `/feed subscribe https://docs.semgrep.dev/api-reference/v2/Changelog/rss.xml` in Slack to post new + entries to a channel. Most readers also accept this page's own URL and + find the feed automatically. + + + +**Breaking changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the new required `header` request parameter `if-match` | PUT /api/policies/v2/deployments/{deploymentId}/remediation-policies | + +
+
+ + +**Changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the optional property `gitSha` to the response with the `200` status | GET /api/health-check | +| Added | endpoint added | GET /api/workflows/v1/deployments/{deploymentId}/jobs/id/{jobId}/findings | +| Added | endpoint added | GET /api/workflows/v1/deployments/{deploymentId}/jobs/id/{jobId}/overview | +| Added | endpoint added | POST /api/workflows/v1/deployments/{deploymentId}/jobs/list | +| Added | endpoint added | GET /api/workflows/v1/deployments/{deploymentId}/registry | +| Added | endpoint added | POST /api/workflows/v1/deployments/{deploymentId}/trigger | + +
+
+ + +**Changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the optional property `byProject.additionalProperties.projectId` to the response with the `200` status | POST /api/reporting/{deploymentId}/reports/by-project/by-product
POST /api/reporting/{deploymentId}/reports/by-project/by-severity | + +
+
+ + +**Changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the new optional request property `filters.latestScanStatus` | PATCH /api/agent/deployments/{deploymentId}/repos/filtered
POST /api/agent/deployments/{deploymentId}/repos/_provision
POST /api/agent/deployments/{deploymentId}/repos/_secret
and 1 more | + +
+
+ + +**Potentially breaking changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Removed | removed the optional property `products.sast.autofix_pr_enabled` from the response with the `200` status | GET /api/agent/deployments/{deploymentId}/products
PUT /api/agent/deployments/{deploymentId}/products | +| Removed | removed the request property `sast.autofix_pr_enabled` | PUT /api/agent/deployments/{deploymentId}/products | + +
+ +**Changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the new `TRIGGER_SOURCE_PATCH_READY` enum value to the `triggerSource` request and response property | GET /api/notifications/deployments/{deploymentId}/automations
GET /api/v2/deployments/{deploymentId}/automations
POST /api/notifications/deployments/{deploymentId}/automations
and 1 more | +| Added | added the new enum value `TOKEN_ROLE_READ_ONLY` to the `query` request parameter `roleFilter` | DELETE /api/tokens/v1/deployments/{deploymentId}/tokens | + +
+
+ + +**Changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the new optional request property `filter.hasInFlightAutofixPr` | PATCH /api/agent/deployments/{deploymentId}/findings/v2
POST /api/agent/deployments/{deploymentId}/issue_counts
POST /api/agent/deployments/{deploymentId}/issue_filters
and 5 more | +| Added | added the optional property `packageManagerConfigsWithCredentials[].config.registryType` to the response with the `200` status | GET /api/agent/deployments/{deploymentId}/packagemanagerauthconfigs | +| Added | added the new optional request property `packageManagerConfigForInsert.config.registryType` | PATCH /api/agent/deployments/{deploymentId}/packagemanagerauthconfigs/{packageManagerConfigId}
POST /api/agent/deployments/{deploymentId}/packagemanagerauthconfigs | +| Added | added the optional property `packageManagerConfig.registryType` to the response with the `200` status | PATCH /api/agent/deployments/{deploymentId}/packagemanagerauthconfigs/{packageManagerConfigId}
POST /api/agent/deployments/{deploymentId}/packagemanagerauthconfigs | +| Added | endpoint added | POST /api/issues/{deploymentId}/counts | +| Added | endpoint added | GET /api/issues/{deploymentId}/issue/{issueId} | +| Added | endpoint added | POST /api/issues/{deploymentId}/issue/{issueId} | +| Added | endpoint added | GET /api/issues/{deploymentId}/issue/{issueId}/activity | +| Added | endpoint added | POST /api/issues/{deploymentId}/search | +| Added | added the new optional request property `issueFilters.hasInFlightAutofixPr` | POST /api/notifications/deployments/{deploymentId}/ticketing/v2/{ticketingInstanceId}/create_tickets | +| Added | added the optional property `projects[].scmType` to the response with the `200` status | POST /api/v2/deployments/{deploymentId}/projects/list | +| Added | added the optional property `project.scmType` to the response with the `200` status | DELETE /api/v2/deployments/{deploymentId}/projects/{projectId}
GET /api/v2/deployments/{deploymentId}/projects/{projectId} | + +
+
+ + +**Potentially breaking changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Removed | removed the optional property `instances[].transitionStates` from the response with the `200` status | GET /api/notifications/deployments/{deploymentId}/ticketing/v2 | + +
+ +**Changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the optional property `errorDetail` to the response with the `200` status | POST /api/agent/deployments/{deploymentId}/actions/{notificationRuleId}/test
POST /api/notification_webhooks/deployments/{deploymentId}/webhooks/{webhookId}/test | +| Added | added the new `FIX_JOB_ERROR_TYPE_AI_PROVIDER_NOT_ALLOWED`, `FIX_JOB_ERROR_TYPE_ARCHIVED_REPOSITORY`, `FIX_JOB_ERROR_TYPE_AUTOFIX_DISABLED`, `FIX_JOB_ERROR_TYPE_GROUPING_DISABLED`, `FIX_JOB_ERROR_TYPE_INVALID_GROUP`, `FIX_JOB_ERROR_TYPE_NON_PRIMARY_BRANCH_DISABLED`, `FIX_JOB_ERROR_TYPE_UNSUPPORTED_ISSUE_TYPE` enum values to the `errors[].errorType` response property for the response status `200` | POST /api/agent/deployments/{deploymentId}/issues/{issueId}/fix_jobs | +| Added | added the optional property `products.ai.mdr_enabled` to the response with the `200` status | GET /api/agent/deployments/{deploymentId}/products
PUT /api/agent/deployments/{deploymentId}/products | +| Added | added the optional property `automations[].actions[].id` to the response with the `200` status | GET /api/notifications/deployments/{deploymentId}/automations
GET /api/v2/deployments/{deploymentId}/automations | +| Added | added the new optional request property `automation.actions[].id` | POST /api/notifications/deployments/{deploymentId}/automations
PUT /api/notifications/deployments/{deploymentId}/automations/{automationId} | +| Added | added the optional property `automation.actions[].id` to the response with the `200` status | POST /api/notifications/deployments/{deploymentId}/automations
PUT /api/notifications/deployments/{deploymentId}/automations/{automationId} | +| Added | added the optional property `instances[].fixedTransitionState` to the response with the `200` status | GET /api/notifications/deployments/{deploymentId}/ticketing/v2 | + +
+
+ + +**Changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the new `TOUR_ID_WORKFLOWS_ACTIVATION` enum value to the `id` request and response property | GET /api/auth/users/current/settings
PATCH /api/auth/users/current/settings | +| Added | added the new `WorkflowsActivationBanner` enum value to the request property `eventSource` | PATCH /api/auth/users/current/settings | + +
+
+ + +**Changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the new `CONDITION_TYPE_BREAKING_CHANGE`, `CONDITION_TYPE_FULL_SCAN` enum values to the `type` request and response property | GET /api/notifications/deployments/{deploymentId}/automations
GET /api/v2/deployments/{deploymentId}/automations
POST /api/notifications/deployments/{deploymentId}/automations
and 1 more | +| Added | added the new `TRIGGER_SOURCE_UPGRADE_GUIDANCE` enum value to the `triggerSource` request and response property | GET /api/notifications/deployments/{deploymentId}/automations
GET /api/v2/deployments/{deploymentId}/automations
POST /api/notifications/deployments/{deploymentId}/automations
and 1 more | + +
+
+ + +**Potentially breaking changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Deprecated | endpoint deprecated | GET /api/policies/v1/deployments/{deploymentId} | +| Deprecated | endpoint deprecated | GET /api/policies/v1/deployments/{deploymentId}/{policyId}/rules | +| Deprecated | endpoint deprecated | PUT /api/policies/v1/deployments/{deploymentId}/{policyId}/rules/{rulePath} | + +
+ +**Changes** + +
+ +| Change | Description | Endpoint | +|---|---|---| +| Added | added the new optional request property `includeFirstDetectedAt` | POST /api/agent/deployments/{deploymentId}/issues
POST /api/agent/deployments/{deploymentId}/issues/export | +| Added | added the optional property `issues[].issue.exceptionRequest.requesterName` to the response with the `200` status | POST /api/agent/deployments/{deploymentId}/issues | +| Added | added the optional property `issues[].issue.exceptionRequest.reviewerName` to the response with the `200` status | POST /api/agent/deployments/{deploymentId}/issues | +| Added | added the optional property `issues[].issue.firstDetectedAt` to the response with the `200` status | POST /api/agent/deployments/{deploymentId}/issues | +| Added | added the optional property `exceptionRequest.requesterName` to the response with the `200` status | GET /api/agent/deployments/{deploymentId}/issues/v2/{issueId} | +| Added | added the optional property `exceptionRequest.reviewerName` to the response with the `200` status | GET /api/agent/deployments/{deploymentId}/issues/v2/{issueId} | +| Added | added the optional property `firstDetectedAt` to the response with the `200` status | GET /api/agent/deployments/{deploymentId}/issues/v2/{issueId} | +| Added | added the optional property `users[].scimManaged` to the response with the `200` status | GET /api/agent/deployments/{deploymentId}/users
POST /api/agent/deployments/{deploymentId}/users/list | +| Added | added the new `ACTION_TYPE_OPEN_AUTOFIX_PR`, `ACTION_TYPE_TRIGGER_WORKFLOW` enum values to the `type` request and response property | GET /api/notifications/deployments/{deploymentId}/automations
GET /api/v2/deployments/{deploymentId}/automations
POST /api/notifications/deployments/{deploymentId}/automations
and 1 more | +| Added | added the new `CONDITION_TYPE_CWE`, `CONDITION_TYPE_OWASP` enum values to the `type` request and response property | GET /api/notifications/deployments/{deploymentId}/automations
GET /api/v2/deployments/{deploymentId}/automations
POST /api/notifications/deployments/{deploymentId}/automations
and 1 more | +| Added | added the new optional request property `issueTypes` | POST /api/notifications/deployments/{deploymentId}/ticketing/v2/{ticketingInstanceId}/create_tickets | +| Added | added the optional property `findings[].exceptionRequest.requesterName` to the response with the `200` status | POST /api/reporting/{deploymentId}/findings | +| Added | added the optional property `findings[].exceptionRequest.reviewerName` to the response with the `200` status | POST /api/reporting/{deploymentId}/findings | +| Added | added the optional property `findings[].firstDetectedAt` to the response with the `200` status | POST /api/reporting/{deploymentId}/findings | +| Added | added the new optional request property `filters.timeBucketSize` | POST /api/reporting/{deploymentId}/reports/malware-firewall/blocked-mal-deps/by-advisory
POST /api/reporting/{deploymentId}/reports/malware-firewall/blocked-mal-deps/by-dep-version-and-ecosystem
POST /api/reporting/{deploymentId}/reports/malware-firewall/blocked-mal-deps/by-ecosystem | +| Added | endpoint added | POST /api/reporting/{deploymentId}/reports/malware-firewall/scan-activity | +| Added | endpoint added | POST /api/reporting/{deploymentId}/reports/malware-firewall/summary | +| Added | added the new optional `query` request parameter `dependencyFilter.includeArchived` | GET /api/sca/deployments/{deploymentId}/dependencies | +| Added | added the new optional request property `dependencyFilter.includeArchived` | POST /api/sca/deployments/{deploymentId}/dependencies
POST /api/sca/deployments/{deploymentId}/repositories
POST /api/sca/deployments/{deploymentId}/{repositoryId}/lockfiles | + +
+
diff --git a/docs/api-reference/v2/Deprecation-Policy.mdx b/docs/api-reference/v2/Deprecation-Policy.mdx new file mode 100644 index 0000000000..965b076020 --- /dev/null +++ b/docs/api-reference/v2/Deprecation-Policy.mdx @@ -0,0 +1,10 @@ +--- +title: "Deprecation Policy" +description: "Learn how Semgrep communicates API deprecations, including notice periods and endpoint removal timelines." +--- + +{/* Shared with the other API version -- edit /snippets/api-deprecation-policy.mdx */} + +import ApiDeprecationPolicy from "/snippets/api-deprecation-policy.mdx" + + diff --git a/docs/api-reference/v2/Introduction.mdx b/docs/api-reference/v2/Introduction.mdx index ed03f334bf..9c15fe3790 100644 --- a/docs/api-reference/v2/Introduction.mdx +++ b/docs/api-reference/v2/Introduction.mdx @@ -3,7 +3,7 @@ title: "Semgrep API v2" description: "Welcome to the portal for Semgrep AppSec Platform's web API v2." --- -The v2 API is under active development as Semgrep continues to expand platform capabilities. Semgrep will continue to support existing integrations and will not deprecate v1 until all v1 functionality is available in v2. +Semgrep will continue to support existing integrations and will not deprecate v1 until all v1 functionality is available in v2. ## API maturity levels @@ -11,7 +11,7 @@ Each endpoint in the v2 API is marked with a maturity badge to help you understa - 🚧 **Experimental** - Use at your own risk. This endpoint is not designed for third-party use or is under active development. Expect significant breaking changes. - ⚠️ **Beta** - This endpoint is being refined. Semgrep will communicate breaking changes to beta partners as we tweak the implementation. -- ✅ **Stable** - No breaking changes will be made to this API. You can confidently build production integrations against these endpoints. +- ✅ **Stable** - Build production integrations against these endpoints with confidence. Breaking changes are rare, and when one is unavoidable you get six months' notice — see the [deprecation policy](/api-reference/v2/Deprecation-Policy) for the notice you are owed at each maturity level. Use Stable endpoints for production applications and treat **experimental/beta** endpoints as previews of upcoming functionality. diff --git a/docs/api-reference/v2/services/analytics.mdx b/docs/api-reference/v2/services/analytics.mdx new file mode 100644 index 0000000000..9c0653ffb6 --- /dev/null +++ b/docs/api-reference/v2/services/analytics.mdx @@ -0,0 +1,16 @@ +--- +title: "Analytics" +description: "Aggregate program reporting." +--- + +import AnalyticsEndpoints from "/snippets/api/v2/analytics-endpoints.mdx" + +Analytics answers questions about your program as a whole rather than about one project or issue — coverage, trends over time, and how remediation is progressing. + +## Endpoints + + + +Each change to this service is recorded in the +[changelog](/api-reference/v2/Changelog); anything superseded is deprecated +under the [deprecation policy](/api-reference/v2/Deprecation-Policy). diff --git a/docs/api-reference/v2/services/deployments.mdx b/docs/api-reference/v2/services/deployments.mdx new file mode 100644 index 0000000000..b2031597bb --- /dev/null +++ b/docs/api-reference/v2/services/deployments.mdx @@ -0,0 +1,16 @@ +--- +title: "Deployments" +description: "The deployment, its users, product configuration, RBAC teams, and token identity." +--- + +import DeploymentsEndpoints from "/snippets/api/v2/deployments-endpoints.mdx" + +A deployment is your organization in Semgrep, and the root of almost every other resource: projects belong to a deployment, as do policies, scans, and integrations. These endpoints manage the deployment itself, the people in it, and what each of them may do. + +## Endpoints + + + +Each change to this service is recorded in the +[changelog](/api-reference/v2/Changelog); anything superseded is deprecated +under the [deprecation policy](/api-reference/v2/Deprecation-Policy). diff --git a/docs/api-reference/v2/services/integrations.mdx b/docs/api-reference/v2/services/integrations.mdx new file mode 100644 index 0000000000..affe48f479 --- /dev/null +++ b/docs/api-reference/v2/services/integrations.mdx @@ -0,0 +1,16 @@ +--- +title: "Integrations" +description: "Source code manager connections, package manager credentials, and ticketing." +--- + +import IntegrationsEndpoints from "/snippets/api/v2/integrations-endpoints.mdx" + +Integrations are the connections between Semgrep and the systems around it. These endpoints manage source code manager connections and apps, the credentials Semgrep uses to reach private package registries, and the ticketing systems it files issues into. + +## Endpoints + + + +Each change to this service is recorded in the +[changelog](/api-reference/v2/Changelog); anything superseded is deprecated +under the [deprecation policy](/api-reference/v2/Deprecation-Policy). diff --git a/docs/api-reference/v2/services/issues.mdx b/docs/api-reference/v2/services/issues.mdx new file mode 100644 index 0000000000..4679a204d7 --- /dev/null +++ b/docs/api-reference/v2/services/issues.mdx @@ -0,0 +1,16 @@ +--- +title: "Issues" +description: "Issues, counts, triage, exports, and AI fix jobs." +--- + +import IssuesEndpoints from "/snippets/api/v2/issues-endpoints.mdx" + +An issue is the persistent, triageable record of something Semgrep found — as distinct from a finding, which is one immutable detection from one scan. These endpoints list and filter issues, move them through triage, export them, and manage the AI fix jobs raised against them, across SAST, AI-powered detection, Supply Chain, and Secrets. + +## Endpoints + + + +Each change to this service is recorded in the +[changelog](/api-reference/v2/Changelog); anything superseded is deprecated +under the [deprecation policy](/api-reference/v2/Deprecation-Policy). diff --git a/docs/api-reference/v2/services/jobs.mdx b/docs/api-reference/v2/services/jobs.mdx new file mode 100644 index 0000000000..e9f32c1a7e --- /dev/null +++ b/docs/api-reference/v2/services/jobs.mdx @@ -0,0 +1,16 @@ +--- +title: "Jobs" +description: "Scan runs, workflow job runs, and retries." +--- + +import JobsEndpoints from "/snippets/api/v2/jobs-endpoints.mdx" + +A job is one run of Semgrep work against a project: a scan, or an Agentic Workflows job. These endpoints report on those runs, retry the ones that failed, and cover how workflow jobs are triggered and scheduled. The issues a job produces live in [Workflow issues](/api-reference/v2/services/workflow-issues). + +## Endpoints + + + +Each change to this service is recorded in the +[changelog](/api-reference/v2/Changelog); anything superseded is deprecated +under the [deprecation policy](/api-reference/v2/Deprecation-Policy). diff --git a/docs/api-reference/v2/services/multimodal.mdx b/docs/api-reference/v2/services/multimodal.mdx new file mode 100644 index 0000000000..bad193dc7a --- /dev/null +++ b/docs/api-reference/v2/services/multimodal.mdx @@ -0,0 +1,16 @@ +--- +title: "Multimodal" +description: "Assistant and memories." +--- + +import MultimodalEndpoints from "/snippets/api/v2/multimodal-endpoints.mdx" + +These endpoints cover Semgrep Assistant and the memories that shape its behavior for your deployment. + +## Endpoints + + + +Each change to this service is recorded in the +[changelog](/api-reference/v2/Changelog); anything superseded is deprecated +under the [deprecation policy](/api-reference/v2/Deprecation-Policy). diff --git a/docs/api-reference/v2/services/policies.mdx b/docs/api-reference/v2/services/policies.mdx new file mode 100644 index 0000000000..6536933de6 --- /dev/null +++ b/docs/api-reference/v2/services/policies.mdx @@ -0,0 +1,16 @@ +--- +title: "Policies" +description: "Detection and remediation policies, rules, dry runs, automations, and notification rules." +--- + +import PoliciesEndpoints from "/snippets/api/v2/policies-endpoints.mdx" + +A policy decides which rules run and what happens when they match — whether a finding blocks a pull request, gets ignored, or raises a ticket. These endpoints manage detection and remediation policies and the rules inside them, let you dry-run a change before committing to it, and configure the automations and notifications that fire off the result. + +## Endpoints + + + +Each change to this service is recorded in the +[changelog](/api-reference/v2/Changelog); anything superseded is deprecated +under the [deprecation policy](/api-reference/v2/Deprecation-Policy). diff --git a/docs/api-reference/v2/services/projects.mdx b/docs/api-reference/v2/services/projects.mdx new file mode 100644 index 0000000000..cb302e5433 --- /dev/null +++ b/docs/api-reference/v2/services/projects.mdx @@ -0,0 +1,16 @@ +--- +title: "Projects" +description: "Projects, branches, project tags, syncs, and per-project scan settings." +--- + +import ProjectsEndpoints from "/snippets/api/v2/projects-endpoints.mdx" + +A project is what a scan sees: one codebase Semgrep is configured to analyze. These endpoints manage projects and their branches, the tags used to organize them, the syncs that keep them aligned with your source code manager, and the per-project scan and resolution settings. + +## Endpoints + + + +Each change to this service is recorded in the +[changelog](/api-reference/v2/Changelog); anything superseded is deprecated +under the [deprecation policy](/api-reference/v2/Deprecation-Policy). diff --git a/docs/api-reference/v2/services/supply-chain.mdx b/docs/api-reference/v2/services/supply-chain.mdx new file mode 100644 index 0000000000..35ec910b0c --- /dev/null +++ b/docs/api-reference/v2/services/supply-chain.mdx @@ -0,0 +1,16 @@ +--- +title: "Supply Chain" +description: "Dependencies, advisories, and SBOMs." +--- + +import SupplyChainEndpoints from "/snippets/api/v2/supply-chain-endpoints.mdx" + +Supply Chain covers what your code depends on. These endpoints list the dependencies Semgrep found across your projects, the advisories that affect them, and generate SBOM exports. + +## Endpoints + + + +Each change to this service is recorded in the +[changelog](/api-reference/v2/Changelog); anything superseded is deprecated +under the [deprecation policy](/api-reference/v2/Deprecation-Policy). diff --git a/docs/api-reference/v2/services/tasks.mdx b/docs/api-reference/v2/services/tasks.mdx new file mode 100644 index 0000000000..0fd2c88e38 --- /dev/null +++ b/docs/api-reference/v2/services/tasks.mdx @@ -0,0 +1,16 @@ +--- +title: "Tasks" +description: "Async result polling." +--- + +import TasksEndpoints from "/snippets/api/v2/tasks-endpoints.mdx" + +Some operations are too slow to answer inline — exports, SBOM generation, and syncs all return a task handle instead of a result. These endpoints redeem that handle once the work finishes. Tasks is deliberately its own service rather than living under any one of the services that produce them, so the same polling call works whatever started the work. + +## Endpoints + + + +Each change to this service is recorded in the +[changelog](/api-reference/v2/Changelog); anything superseded is deprecated +under the [deprecation policy](/api-reference/v2/Deprecation-Policy). diff --git a/docs/api-reference/v2/services/workflow-issues.mdx b/docs/api-reference/v2/services/workflow-issues.mdx new file mode 100644 index 0000000000..64afb1e2b8 --- /dev/null +++ b/docs/api-reference/v2/services/workflow-issues.mdx @@ -0,0 +1,16 @@ +--- +title: "Workflow issues" +description: "Issues, counts, and triage for Agentic Workflows." +--- + +import WorkflowIssuesEndpoints from "/snippets/api/v2/workflow-issues-endpoints.mdx" + +Workflow issues are the issues produced by Agentic Workflows, with their own listing, counting, and triage endpoints. This service is intended to eventually replace [Issues](/api-reference/v2/services/issues) for every issue type; for now it serves Agentic Workflows only. + +## Endpoints + + + +Each change to this service is recorded in the +[changelog](/api-reference/v2/Changelog); anything superseded is deprecated +under the [deprecation policy](/api-reference/v2/Deprecation-Policy). diff --git a/docs/docs.json b/docs/docs.json index 3b6e1e8f9d..588518fd44 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -279,7 +279,7 @@ { "group": "View findings", "root": "semgrep-supply-chain/findings", - "pages": [ + "pages": [ "semgrep-supply-chain/finding-details" ] }, @@ -292,8 +292,8 @@ "group": "Triage and fix vulnerabilities", "root": "semgrep-supply-chain/triage-and-fix/overview", "pages": [ - "semgrep-supply-chain/upgrade-guidance", - "semgrep-supply-chain/autofix" + "semgrep-supply-chain/upgrade-guidance", + "semgrep-supply-chain/autofix" ] }, { @@ -618,7 +618,9 @@ "pages": [ "api-reference/v1/Introduction", "api-reference/v1/Authentication", - "api-reference/v1/Terms-of-Use" + "api-reference/v1/Terms-of-Use", + "api-reference/v1/Changelog", + "api-reference/v1/Deprecation-Policy" ] }, { @@ -631,7 +633,7 @@ ] }, { - "dropdown": "v2 (Experimental)", + "dropdown": "v2", "icon": "flask", "groups": [ { @@ -639,15 +641,679 @@ "pages": [ "api-reference/v2/Introduction", "api-reference/v2/Authentication", - "api-reference/v2/Terms-of-Use" + "api-reference/v2/Terms-of-Use", + "api-reference/v2/Changelog", + "api-reference/v2/Deprecation-Policy" ] }, { - "group": "Endpoints", - "openapi": { - "source": "public_v2.openapi.yaml", - "directory": "api-reference/v2" - } + "group": "Services", + "pages": [ + { + "group": "AI Fix Jobs", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/agent/deployments/{deploymentId}/issues/{issueId}/fix_jobs" + ] + }, + { + "group": "AI tasks", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/ai/{deploymentId}/combined_task", + "POST /api/ai/pattern_fix", + "GET /api/ai/{deploymentId}/info" + ] + }, + { + "group": "Autofix", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/v1/deployments/{deploymentId}/issues/{issueId}/autofix" + ] + }, + { + "group": "Automations", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/notifications/deployments/{deploymentId}/automations", + "GET /api/notifications/deployments/{deploymentId}/automations", + "DELETE /api/notifications/deployments/{deploymentId}/automations/{automationId}", + "PUT /api/notifications/deployments/{deploymentId}/automations/{automationId}", + "GET /api/v2/deployments/{deploymentId}/automations" + ] + }, + { + "group": "Autotriage Feedback", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/ai/deployments/{deploymentId}/autotriage_feedback" + ] + }, + { + "group": "Deployment", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/agent/deployments/{deploymentId}/users", + "GET /api/agent/deployments/{deploymentId}/users", + "POST /api/agent/deployments", + "GET /api/agent/deployments/{deploymentId}", + "PATCH /api/agent/deployments/{deploymentId}", + "GET /api/agent/deployments/{deploymentId}/authorized_actions", + "GET /api/agent/deployments/{deploymentId}/default_user_role", + "GET /api/agent/deployment", + "GET /api/agent/deployments/{deploymentId}/github_app_status", + "POST /api/agent/deployments/{deploymentId}/users/list", + "DELETE /api/agent/deployments/{deploymentId}/users/{userId}", + "POST /api/agent/deployments/{deploymentId}/products/feedback", + "POST /api/agent/deployments/{deploymentId}/has_deepsemgrep", + "POST /api/agent/deployments/{deploymentId}/has_dependency_query", + "POST /api/agent/deployments/{deploymentId}/has_triage_via_comment", + "PATCH /api/agent/deployments/{deploymentId}/users/{userId}/roles" + ] + }, + { + "group": "Deployment Products", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "PUT /api/agent/deployments/{deploymentId}/products", + "GET /api/agent/deployments/{deploymentId}/products", + "GET /api/agent/deployments/{deploymentId}/products/admin" + ] + }, + { + "group": "Deployment SSO Providers", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/auth/sso/deployments/{deploymentId}/providers" + ] + }, + { + "group": "Deployment Tags", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "PUT /api/agent/deployments/{deploymentId}/tags/{repositoryTag}", + "DELETE /api/agent/deployments/{deploymentId}/tags/{repositoryTag}", + "GET /api/agent/deployments/{deploymentId}/tags/{repositoryTag}", + "POST /api/agent/deployments/{deploymentId}/tags/find", + "GET /api/agent/deployments/{deploymentId}/tags" + ] + }, + { + "group": "Deployments", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "root": "api-reference/v2/services/deployments", + "pages": [ + "GET /api/v2/deployments" + ] + }, + { + "group": "Editor", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/run" + ] + }, + { + "group": "External Ticketing", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/notifications/deployments/{deploymentId}/ticketing/v2/{ticketingInstanceId}/create_tickets", + "DELETE /api/notifications/deployments/{deploymentId}/ticketing/v2/{ticketingInstanceId}/tickets/{nangoExternalTicketId}", + "GET /api/notifications/deployments/{deploymentId}/ticketing/v2", + "POST /api/notifications/deployments/{deploymentId}/ticketing/v2/{ticketingInstanceId}/tickets/link", + "POST /api/notifications/deployments/{deploymentId}/ticketing/v2/{ticketingInstanceId}/tickets/unlink" + ] + }, + { + "group": "Feature Rollouts", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/agent/features" + ] + }, + { + "group": "Global Ignores", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/agent/deployments/{deploymentId}/ignores", + "DELETE /api/agent/deployments/{deploymentId}/ignores", + "GET /api/agent/deployments/{deploymentId}/ignores", + "PATCH /api/agent/deployments/{deploymentId}/ignores" + ] + }, + { + "group": "Infrastructure Configurations", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/infra-config/bootstrap-sms-vpc" + ] + }, + { + "group": "Issues", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "root": "api-reference/v2/services/issues", + "pages": [ + "PATCH /api/agent/deployments/{deploymentId}/findings/v2", + "POST /api/agent/deployments/{deploymentId}/issues/export", + "GET /api/agent/deployments/{deploymentId}/issues/v2/{issueId}", + "GET /api/agent/deployments/{deploymentId}/issues/v2/{issueId}/code-snippets", + "POST /api/agent/deployments/{deploymentId}/issue_counts", + "POST /api/agent/deployments/{deploymentId}/issue_filters", + "POST /api/agent/deployments/{deploymentId}/issues/search", + "POST /api/agent/deployments/{deploymentId}/issues" + ] + }, + { + "group": "Issues (Semgrep Agentic Workflows)", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/issues/{deploymentId}/counts", + "GET /api/issues/{deploymentId}/issue/{issueId}/activity", + "GET /api/issues/{deploymentId}/issue/{issueId}", + "POST /api/issues/{deploymentId}/issue/{issueId}", + "POST /api/issues/{deploymentId}/search" + ] + }, + { + "group": "Managed Scan Settings", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/agent/deployments/{deploymentId}/managed_scan_settings", + "GET /api/agent/deployments/{deploymentId}/managed_scan_settings", + "DELETE /api/agent/deployments/{deploymentId}/managed_scan_settings/{managedScanSettingsId}", + "PUT /api/agent/deployments/{deploymentId}/managed_scan_settings/{managedScanSettingsId}" + ] + }, + { + "group": "Memories", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/ai/deployments/{deploymentId}/memories", + "POST /api/ai/deployments/{deploymentId}/memories/triage", + "DELETE /api/ai/deployments/{deploymentId}/memories/{memoryId}", + "PUT /api/ai/deployments/{deploymentId}/memories/{memoryId}", + "POST /api/ai/deployments/{deploymentId}/relevant_issues", + "POST /api/ai/deployments/{deploymentId}/memories/ids", + "GET /api/ai/deployments/{deploymentId}/memories/stats", + "POST /api/ai/deployments/{deploymentId}/memories/list", + "GET /api/ai/deployments/{deploymentId}/memories/suggested" + ] + }, + { + "group": "Notification Rules", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/agent/deployments/{deploymentId}/actions", + "DELETE /api/agent/deployments/{deploymentId}/actions/{notificationRuleId}", + "PUT /api/agent/deployments/{deploymentId}/actions/{notificationRuleId}", + "POST /api/agent/deployments/{deploymentId}/actions/list", + "POST /api/agent/deployments/{deploymentId}/actions/{notificationRuleId}/test" + ] + }, + { + "group": "Notification Webhooks", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/notification_webhooks/deployments/{deploymentId}/webhooks", + "GET /api/notification_webhooks/deployments/{deploymentId}/webhooks", + "DELETE /api/notification_webhooks/deployments/{deploymentId}/webhooks/{webhookId}", + "GET /api/notification_webhooks/deployments/{deploymentId}/webhooks/{webhookId}", + "PUT /api/notification_webhooks/deployments/{deploymentId}/webhooks/{webhookId}", + "POST /api/notification_webhooks/deployments/{deploymentId}/webhooks/{webhookId}/test" + ] + }, + { + "group": "Notifications", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/notifications/in-app/deployments/{deploymentId}", + "PUT /api/notifications/in-app/deployments/{deploymentId}/mark_as_seen" + ] + }, + { + "group": "Onboarding Checklist", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/onboarding/deployments/{deploymentId}/checklist", + "PATCH /api/onboarding/deployments/{deploymentId}/checklist", + "POST /api/onboarding/deployments/{deploymentId}/invite_members" + ] + }, + { + "group": "Policies", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "root": "api-reference/v2/services/policies", + "pages": [ + "GET /api/policies/v1/deployments/{deploymentId}", + "GET /api/policies/v1/deployments/{deploymentId}/{policyId}/rules", + "PUT /api/policies/v1/deployments/{deploymentId}/{policyId}/rules/{rulePath}" + ] + }, + { + "group": "Policies V2", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "PUT /api/policies/v2/deployments/{deploymentId}/detection-policy/{product}", + "GET /api/policies/v2/deployments/{deploymentId}/detection-policy/{product}", + "PUT /api/policies/v2/deployments/{deploymentId}/remediation-policies", + "GET /api/policies/v2/deployments/{deploymentId}/remediation-policies", + "GET /api/policies/v2/deployments/{deploymentId}/detection-policy", + "GET /api/policies/v2/deployments/{deploymentId}/vocab", + "POST /api/policies/v2/deployments/{deploymentId}/detection-policy/{product}:dryRun", + "POST /api/policies/v2/deployments/{deploymentId}/remediation-policies:dryRun" + ] + }, + { + "group": "Projects", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "root": "api-reference/v2/services/projects", + "pages": [ + "POST /api/agent/deployments/{deploymentId}/repos/_secret", + "PATCH /api/agent/deployments/{deploymentId}/repos/filtered", + "PATCH /api/agent/deployments/{deploymentId}/repos", + "DELETE /api/v2/deployments/{deploymentId}/projects/{projectId}", + "GET /api/v2/deployments/{deploymentId}/projects/{projectId}", + "GET /api/v2/deployments/{deploymentId}/projects/{projectId}/branches/{branchId}", + "POST /api/agent/deployments/{deploymentId}/repos/count", + "GET /api/agent/deployments/{deploymentId}/repos/by-tag", + "POST /api/v2/deployments/{deploymentId}/projects/list", + "POST /api/agent/deployments/{deploymentId}/repos/_provision", + "POST /api/agent/deployments/{deploymentId}/repos/_refresh_async", + "POST /api/agent/deployments/{deploymentId}/repos/{repoId}/_sync", + "PATCH /api/agent/deployments/{deploymentId}/repos/{idOrName}" + ] + }, + { + "group": "Projects - Managed Scan Settings", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/sms/v2/deployments/{deploymentId}/project_settings", + "GET /api/sms/v2/deployments/{deploymentId}/projects/{projectId}/settings" + ] + }, + { + "group": "Reporting", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/reporting/{deploymentId}/reports/backlog/by-product", + "POST /api/reporting/{deploymentId}/reports/backlog/by-rule", + "POST /api/reporting/{deploymentId}/reports/backlog/by-severity", + "POST /api/reporting/{deploymentId}/reports/activity", + "POST /api/reporting/{deploymentId}/reports/by-project/by-product", + "POST /api/reporting/{deploymentId}/reports/by-project/by-severity", + "POST /api/reporting/{deploymentId}/reports/funnel", + "POST /api/reporting/{deploymentId}/reports/guardrails-activity", + "POST /api/reporting/{deploymentId}/reports/guardrails-adoption", + "POST /api/reporting/{deploymentId}/reports/malware-firewall/blocked-mal-deps/by-advisory", + "POST /api/reporting/{deploymentId}/reports/malware-firewall/blocked-mal-deps/by-dep-version-and-ecosystem", + "POST /api/reporting/{deploymentId}/reports/malware-firewall/blocked-mal-deps/by-ecosystem", + "POST /api/reporting/{deploymentId}/reports/malware-firewall/scan-activity", + "POST /api/reporting/{deploymentId}/reports/malware-firewall/summary", + "POST /api/reporting/{deploymentId}/reports/median-open-age/by-product", + "POST /api/reporting/{deploymentId}/reports/median-open-age/by-severity", + "POST /api/reporting/{deploymentId}/findings" + ] + }, + { + "group": "Review Comment Product Content", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/deployments/{deploymentId}/scm_comment_product_content", + "GET /api/deployments/{deploymentId}/scm_comment_product_content", + "DELETE /api/deployments/{deploymentId}/scm_comment_product_content/{reviewCommentProductContentId}", + "PUT /api/deployments/{deploymentId}/scm_comment_product_content/{reviewCommentProductContentId}" + ] + }, + { + "group": "Ruleboards", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/agent/deployments/{deploymentId}/ruleboards", + "GET /api/agent/deployments/{deploymentId}/ruleboards", + "DELETE /api/agent/deployments/{deploymentId}/ruleboards/{ruleboardSlug}", + "GET /api/agent/deployments/{deploymentId}/ruleboards/{ruleboardSlug}", + "PUT /api/agent/deployments/{deploymentId}/ruleboards/{ruleboardSlug}", + "GET /api/agent/deployments/{deploymentId}/ruleboards/{ruleboardSlug}/finding_counts", + "GET /api/agent/deployments/{deploymentId}/ruleboards/{ruleboardSlug}/overview", + "PUT /api/agent/deployments/{deploymentId}/ruleboards/{ruleboardSlug}/overview" + ] + }, + { + "group": "Scans", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/v2/deployments/{deploymentId}/scans/{scanId}", + "POST /api/v2/deployments/{deploymentId}/projects/{projectId}/scans/list", + "POST /api/v2/deployments/{deploymentId}/scans/retry" + ] + }, + { + "group": "Semgrep Agentic Workflows", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/workflows/v1/deployments/{deploymentId}/jobs/id/{jobId}/findings", + "GET /api/workflows/v1/deployments/{deploymentId}/jobs/id/{jobId}/overview", + "GET /api/workflows/v1/deployments/{deploymentId}/registry", + "POST /api/workflows/v1/deployments/{deploymentId}/jobs/list", + "POST /api/workflows/v1/deployments/{deploymentId}/trigger" + ] + }, + { + "group": "Slack", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "DELETE /api/agent/deployments/{deploymentId}/slack/installations", + "GET /api/agent/deployments/{deploymentId}/slack/installations", + "GET /api/agent/deployments/{deploymentId}/slack/install", + "GET /api/agent/deployments/{deploymentId}/slack/channel_mappings", + "GET /api/agent/deployments/{deploymentId}/slack/channels", + "POST /api/agent/deployments/{deploymentId}/slack/oauth/callback" + ] + }, + { + "group": "Source Code Management (SCM) apps", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/scm/deployments/{deploymentId}/scm_apps/{scmAppId}/add_ado_project", + "PUT /api/scm/ado_app_install", + "PUT /api/scm/app_request_complete", + "POST /api/scm/ado_app", + "POST /api/scm/app_request", + "DELETE /api/scm/deployments/{deploymentId}/scm_apps/{scmAppId}", + "GET /api/scm/deployments/{deploymentId}/scm_apps/public_gha", + "GET /api/scm/deployments/{deploymentId}/scm_apps/{requestId}", + "GET /api/scm/deployments/{deploymentId}/scm_apps", + "POST /api/scm/deployments/{deploymentId}/scm_apps/{scmAppId}/rotate_webhook_secret" + ] + }, + { + "group": "Source Code Management (SCM) configurations", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/scm/deployments/{deploymentId}/configs/{configId}/check", + "POST /api/scm/deployments/{deploymentId}/configs", + "GET /api/scm/deployments/{deploymentId}/configs", + "DELETE /api/scm/deployments/{deploymentId}/configs/{configId}", + "PATCH /api/scm/deployments/{deploymentId}/configs/{configId}", + "POST /api/scm/deployments/{deploymentId}/configs/search", + "POST /api/scm/deployments/{deploymentId}/configs/{configId}/_sync" + ] + }, + { + "group": "Source Code Management (SCM) webhooks", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/scm/deployments/{deploymentId}/subscriptions/{configId}", + "PUT /api/scm/deployments/{deploymentId}/subscriptions/{configId}", + "DELETE /api/scm/deployments/{deploymentId}/subscriptions/{configId}", + "POST /api/scm/deployments/{deploymentId}/subscriptions/{configId}/rotate_secret" + ] + }, + { + "group": "Supply Chain", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "root": "api-reference/v2/services/supply-chain", + "pages": [ + "POST /api/sca/deployments/{deploymentId}/sbom_async", + "GET /api/sca/deployments/{deploymentId}/dependencies", + "POST /api/sca/deployments/{deploymentId}/dependencies", + "POST /api/sca/deployments/{deploymentId}/advisories/{advisoryId}/findings", + "POST /api/sca/deployments/{deploymentId}/{repositoryId}/lockfiles", + "POST /api/sca/deployments/{deploymentId}/repositories" + ] + }, + { + "group": "Supply Chain - Managed Scans dependency resolution configurations", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/agent/deployments/{deploymentId}/projects/{projectId}/resolution_configs" + ] + }, + { + "group": "Supply Chain - Managed Scans package manager configurations", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/agent/deployments/{deploymentId}/packagemanagerauthconfigs", + "GET /api/agent/deployments/{deploymentId}/packagemanagerauthconfigs", + "DELETE /api/agent/deployments/{deploymentId}/packagemanagerauthconfigs/{packageManagerConfigId}", + "PATCH /api/agent/deployments/{deploymentId}/packagemanagerauthconfigs/{packageManagerConfigId}" + ] + }, + { + "group": "Support", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/support/cases", + "GET /api/support/cases/{orgid}" + ] + }, + { + "group": "Surveys", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/survey/name/{surveyName}", + "POST /api/survey/submit" + ] + }, + { + "group": "Tasks", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "root": "api-reference/v2/services/tasks", + "pages": [ + "GET /api/tasks/v2/{taskTokenJwt}" + ] + }, + { + "group": "Teams", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/permissions/v2/deployments/{deploymentId}/status", + "POST /api/permissions/v2/deployments/{deploymentId}/teams", + "DELETE /api/permissions/v2/deployments/{deploymentId}/teams/{teamId}", + "GET /api/permissions/v2/deployments/{deploymentId}/teams/{teamId}", + "PATCH /api/permissions/v2/deployments/{deploymentId}/teams/{teamId}", + "GET /api/permissions/v2/deployments/{deploymentId}/teams/{teamId}/teams", + "GET /api/permissions/v2/deployments/{deploymentId}/teams/{teamId}/repos", + "GET /api/permissions/v2/deployments/{deploymentId}/teams/{teamId}/members", + "POST /api/permissions/v2/deployments/{deploymentId}/teams/list" + ] + }, + { + "group": "Tokens", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "DELETE /api/tokens/v1/deployments/{deploymentId}/tokens" + ] + }, + { + "group": "Users", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/auth/users/current", + "GET /api/auth/users/current/settings", + "PATCH /api/auth/users/current/settings", + "GET /api/auth/authorized_resources", + "GET /api/auth/users/current/deployments/joinable", + "GET /api/auth/users/current/deployments" + ] + }, + { + "group": "Versions", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/agent/versions/{deploymentId}/project-info/{product}" + ] + }, + { + "group": "Wiz Credentials", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "POST /api/notifications/deployments/{deploymentId}/wiz", + "GET /api/notifications/deployments/{deploymentId}/wiz", + "DELETE /api/notifications/deployments/{deploymentId}/wiz/{id}", + "GET /api/notifications/deployments/{deploymentId}/wiz/{id}", + "PUT /api/notifications/deployments/{deploymentId}/wiz/{id}", + "POST /api/notifications/deployments/{deploymentId}/wiz/validate" + ] + }, + { + "group": "Other", + "openapi": { + "source": "public_v2.openapi.yaml", + "directory": "api-reference/v2" + }, + "pages": [ + "GET /api/health-check", + "GET /api/readiness-check", + "GET /api/agent/identity", + "GET /api/agent/ip", + "GET /api/agent/tenant", + "GET /api/agent/ping" + ] + } + ] } ] } diff --git a/docs/snippets/api-deprecation-policy.mdx b/docs/snippets/api-deprecation-policy.mdx new file mode 100644 index 0000000000..576828888f --- /dev/null +++ b/docs/snippets/api-deprecation-policy.mdx @@ -0,0 +1,78 @@ +## What this policy promises + +APIs change over time. This policy explains how much notice you'll receive before a breaking change affects your integration. + + + In summary, **stable endpoints receive at least 180 days' notice before a breaking change or a removal.** After the deprecation period ends, the endpoint returns `410 Gone` and points you at its replacement. It never returns incorrect or partial data. + + +## What this policy covers + +This policy applies to every endpoint documented on [docs.semgrep.dev](https://docs.semgrep.dev/), according to its maturity level. +Each endpoint's maturity is shown as a badge in the API reference and as a badge extension in the OpenAPI spec. + +| Maturity | What to expect | Notice before a breaking change or removal | +| --- | --- | --- | +| **Stable** | Supported and documented. Expected to evolve and improve without breaking changes. | 180 days | +| **Beta** | Supported and documented. Semgrep maintains backward compatibility during the beta period, but the endpoint may still be renamed or removed. | 60 days | +| **Experimental** | Published to help you preview functionality. Experimental endpoints may change, be renamed, or be removed at any time without notice. | None | + +**Undocumented endpoints are not covered**. If an endpoint does not appear in the published API reference, it is not part of the supported API, regardless of what you see in your browser's network tab. This includes internal endpoints used by Semgrep AppSec Platform and the Semgrep CLI. These endpoints may change or be removed without notice and are not supported for customer integrations. + +### What counts as a breaking change + +On a stable endpoint, Semgrep does not do any of the following without the full notice period: + +- Remove or rename the endpoint, or change its URL path or HTTP method. +- Remove or rename a field in a response, whether or not that field is optional. +- Change the type of an existing field, or narrow the set of values it accepts. +- Add a new required request parameter, or make an existing optional one required. +- Change the meaning of an existing field without changing its name. + +Semgrep may make the following changes at any time, so build your integration to tolerate them: + +- Add a new endpoint, or a new optional request parameter. +- Add a new field to a response. Parse responses so that unrecognized fields are ignored rather than treated as an error. +- Add a new value to an existing enum, unless that enum is documented as closed. +- Change the order of items in a response where no order is documented. +- Change human-readable text, such as an `error` message, that is not documented as stable. + +### Exception: unavoidable urgent changes + +If continuing to support an endpoint, field, or other aspect of API behavior would expose customer data, create a security risk, or violate an obligation, Semgrep may make changes with less notice than described above, or in rare cases without notice. + +Semgrep will not invoke this exception lightly. When invoked, Semgrep will communicate what changed and why as soon as possible. + +## Identify deprecated endpoints + +- Subscribe to the API changelog: Every deprecation is announced in the changelog on the day it ships, marked **Deprecated**, and filed under **Potentially breaking** so that it still appears when you filter the changelog for changes that can affect your integration. Each API version has its own RSS feed. Subscribe to the [v1](/api-reference/v1/Changelog) or [v2](/api-reference/v2/Changelog) feed to receive deprecation notices automatically. +- Check the API reference: The API reference marks deprecated endpoints and includes their sunset dates. +- Monitor deprecation headers: Deprecated endpoints include `Deprecation` and `Sunset` response headers. Monitoring these headers in your application logs helps identify any deprecated endpoints that your integration still depends on. +- Contact Semgrep if you're unsure: If you're unsure whether an endpoint or feature is covered by this policy, contact [support@semgrep.com](mailto:support@semgrep.com) and Semgrep will confirm in writing. + +## What happens at sunset + +Once an endpoint's sunset date arrives, it stops serving data and returns `410 Gone`. The removal is recorded in the changelog on the day it happens, so the end of an endpoint's life is announced the same way its deprecation was. Semgrep does not silently leave a sunset endpoint running, and it never responds with partial or stale data in place of the data you asked for. + +A sunset response looks like this: + +```http +HTTP/1.1 410 Gone +Content-Type: application/json +Deprecation: Tue, 01 Sep 2026 00:00:00 GMT +Sunset: Mon, 01 Mar 2027 00:00:00 GMT +Link: ; rel="successor-version" + +{ + "error": "This endpoint was removed on 2027-03-01. Use GET /api/v2/issues instead." +} +``` + +| Field | Meaning | +| --- | --- | +| `Deprecation` | The date the deprecation became public, as an HTTP date. This is when the notice period started. | +| `Sunset` | The date the endpoint stopped serving data, as an HTTP date, per [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594). | +| `Link` | The replacement endpoint, with the relation `successor-version`. Parse this rather than the `error` string when you need to route to the replacement automatically. | +| `error` | A human-readable explanation. Its wording is not stable, so don't match on it. | + +When an endpoint is removed with no replacement, the `Link` header is omitted and the `error` string says that no replacement exists. The `Deprecation` and `Sunset` headers are also sent on successful responses throughout the notice period, not only at sunset. diff --git a/docs/snippets/api/v2/analytics-endpoints.mdx b/docs/snippets/api/v2/analytics-endpoints.mdx new file mode 100644 index 0000000000..141fc3c6bb --- /dev/null +++ b/docs/snippets/api/v2/analytics-endpoints.mdx @@ -0,0 +1,5 @@ +{/* Auto-generated by scripts/generate_service_endpoints.py -- do not edit by hand. */} + +No endpoints are published in this service yet. They appear here as each one +is published, and every change is recorded in the +[changelog](/api-reference/v2/Changelog). diff --git a/docs/snippets/api/v2/deployments-endpoints.mdx b/docs/snippets/api/v2/deployments-endpoints.mdx new file mode 100644 index 0000000000..ce5516a168 --- /dev/null +++ b/docs/snippets/api/v2/deployments-endpoints.mdx @@ -0,0 +1,5 @@ +{/* Auto-generated by scripts/generate_service_endpoints.py -- do not edit by hand. */} + +| Endpoint | Description | +| --- | --- | +| GET [List deployments](/api-reference/v2/deploymentsservice/list-deployments) | Request the deployments your auth can access. | diff --git a/docs/snippets/api/v2/integrations-endpoints.mdx b/docs/snippets/api/v2/integrations-endpoints.mdx new file mode 100644 index 0000000000..141fc3c6bb --- /dev/null +++ b/docs/snippets/api/v2/integrations-endpoints.mdx @@ -0,0 +1,5 @@ +{/* Auto-generated by scripts/generate_service_endpoints.py -- do not edit by hand. */} + +No endpoints are published in this service yet. They appear here as each one +is published, and every change is recorded in the +[changelog](/api-reference/v2/Changelog). diff --git a/docs/snippets/api/v2/issues-endpoints.mdx b/docs/snippets/api/v2/issues-endpoints.mdx new file mode 100644 index 0000000000..8583beab3d --- /dev/null +++ b/docs/snippets/api/v2/issues-endpoints.mdx @@ -0,0 +1,12 @@ +{/* Auto-generated by scripts/generate_service_endpoints.py -- do not edit by hand. */} + +| Endpoint | Description | +| --- | --- | +| PATCH [Bulk update issues](/api-reference/v2/issuesservice/bulk-update-issues) | Bulk triage issues that match specified filters | +| POST [Export issues](/api-reference/v2/issuesservice/export-issues) | Asynchronously export issues matching filters to a file | +| GET [Get issue](/api-reference/v2/issuesservice/get-issue) | Get a single issue by ID with details | +| GET [Get issue code snippets](/api-reference/v2/issuesservice/get-issue-code-snippets) | Get code snippets for an issue. | +| POST [Get issue counts](/api-reference/v2/issuesservice/get-issue-counts) | Get counts of issues by status with filtering | +| POST [Get issue filters](/api-reference/v2/issuesservice/get-issue-filters) | Get available filter options for issues | +| POST [List issue groups](/api-reference/v2/issuesservice/list-issue-groups) | Search and group issues by rule with filtering and pagination | +| POST [List issues](/api-reference/v2/issuesservice/list-issues) | List an organization's recent issues with filtering, sorting, and pagination | diff --git a/docs/snippets/api/v2/jobs-endpoints.mdx b/docs/snippets/api/v2/jobs-endpoints.mdx new file mode 100644 index 0000000000..141fc3c6bb --- /dev/null +++ b/docs/snippets/api/v2/jobs-endpoints.mdx @@ -0,0 +1,5 @@ +{/* Auto-generated by scripts/generate_service_endpoints.py -- do not edit by hand. */} + +No endpoints are published in this service yet. They appear here as each one +is published, and every change is recorded in the +[changelog](/api-reference/v2/Changelog). diff --git a/docs/snippets/api/v2/multimodal-endpoints.mdx b/docs/snippets/api/v2/multimodal-endpoints.mdx new file mode 100644 index 0000000000..141fc3c6bb --- /dev/null +++ b/docs/snippets/api/v2/multimodal-endpoints.mdx @@ -0,0 +1,5 @@ +{/* Auto-generated by scripts/generate_service_endpoints.py -- do not edit by hand. */} + +No endpoints are published in this service yet. They appear here as each one +is published, and every change is recorded in the +[changelog](/api-reference/v2/Changelog). diff --git a/docs/snippets/api/v2/policies-endpoints.mdx b/docs/snippets/api/v2/policies-endpoints.mdx new file mode 100644 index 0000000000..a8e6f45651 --- /dev/null +++ b/docs/snippets/api/v2/policies-endpoints.mdx @@ -0,0 +1,7 @@ +{/* Auto-generated by scripts/generate_service_endpoints.py -- do not edit by hand. */} + +| Endpoint | Description | +| --- | --- | +| GET [List policies](/api-reference/v2/policiesservice/list-policies) | List all policies for a given deployment. | +| GET [List policy rules](/api-reference/v2/policiesservice/list-policy-rules) | List the rules for a given policy. | +| PUT [Update policy rule](/api-reference/v2/policiesservice/update-policy-rule) | Update a specific rule within a policy. | diff --git a/docs/snippets/api/v2/projects-endpoints.mdx b/docs/snippets/api/v2/projects-endpoints.mdx new file mode 100644 index 0000000000..871b2210c0 --- /dev/null +++ b/docs/snippets/api/v2/projects-endpoints.mdx @@ -0,0 +1,17 @@ +{/* Auto-generated by scripts/generate_service_endpoints.py -- do not edit by hand. */} + +| Endpoint | Description | +| --- | --- | +| POST [Add Semgrep CI secrets to GitHub repositories](/api-reference/v2/projectsservice/add-semgrep-ci-secrets-to-github-repositories) | Generates and adds a secret for the "semgrep-ci" GitHub Action to the GitHub Repositories that are associated with any Semgrep Projects that match the given filters. | +| PATCH [Bulk edit projects by filter](/api-reference/v2/projectsservice/bulk-edit-projects-by-filter) | Applies a change to many projects that match the given filters. | +| PATCH [Bulk edit selected projects](/api-reference/v2/projectsservice/bulk-edit-selected-projects) | Applies changes to multiple specified projects at once. | +| DELETE [Delete project](/api-reference/v2/projectsservice/delete-project) | Delete a project by its ID. | +| GET [Get project](/api-reference/v2/projectsservice/get-project) | Get a project by its ID. | +| GET [Get branch](/api-reference/v2/projectsservice/get-branch) | Get a branch by its ID. | +| POST [Get project count](/api-reference/v2/projectsservice/get-project-count) | Get the number of projects in a deployment. | +| GET [List project IDs by tag](/api-reference/v2/projectsservice/list-project-ids-by-tag) | Gets a mapping from tag ID to related project IDs. | +| POST [List projects](/api-reference/v2/projectsservice/list-projects) | List all projects based on provided filters. | +| POST [Provision Semgrep CI GitHub Actions](/api-reference/v2/projectsservice/provision-semgrep-ci-github-actions) | Adds a "semgrep-ci" GitHub Action to the GitHub Repositories that are associated with any Semgrep Projects that match the given filters. | +| POST [Sync all projects to SCM](/api-reference/v2/projectsservice/sync-all-projects-to-scm) | Schedules a job to sync all projects in a deployment to their SCM. | +| POST [Sync project to SCM](/api-reference/v2/projectsservice/sync-project-to-scm) | Schedules a job to sync a project with its SCM. | +| PATCH [Update project](/api-reference/v2/projectsservice/update-project) | Use a "Merge Patch" to update a project's name or SMS settings. | diff --git a/docs/snippets/api/v2/supply-chain-endpoints.mdx b/docs/snippets/api/v2/supply-chain-endpoints.mdx new file mode 100644 index 0000000000..1f16013aad --- /dev/null +++ b/docs/snippets/api/v2/supply-chain-endpoints.mdx @@ -0,0 +1,10 @@ +{/* Auto-generated by scripts/generate_service_endpoints.py -- do not edit by hand. */} + +| Endpoint | Description | +| --- | --- | +| POST [Generate a project's SBOM](/api-reference/v2/supplychain2service/generate-a-projects-sbom) | Starts a job to asynchronously generate a Software Bill of Materials (SBOM) for a given project. | +| GET [List dependencies](/api-reference/v2/supplychain2service/list-dependencies) | Returns all (or filtered) dependencies for a deployment using pagination | +| POST [List dependencies](/api-reference/v2/supplychain2service/list-dependencies) | Returns all (or filtered) dependencies for a deployment using pagination | +| POST [List findings for advisory](/api-reference/v2/supplychain2service/list-findings-for-advisory) | Returns a summary of findings grouped by project and branch for a specific advisory using pagination | +| POST [List lockfiles with dependencies in a given repository](/api-reference/v2/supplychain2service/list-lockfiles-with-dependencies-in-a-given-repository) | Returns all (or filtered) lockfiles that contain dependencies within the specified repository | +| POST [List repositories with dependencies](/api-reference/v2/supplychain2service/list-repositories-with-dependencies) | Returns any repositories which contain dependencies that match the given filter for a deployment | diff --git a/docs/snippets/api/v2/tasks-endpoints.mdx b/docs/snippets/api/v2/tasks-endpoints.mdx new file mode 100644 index 0000000000..d1e7f9a23a --- /dev/null +++ b/docs/snippets/api/v2/tasks-endpoints.mdx @@ -0,0 +1,5 @@ +{/* Auto-generated by scripts/generate_service_endpoints.py -- do not edit by hand. */} + +| Endpoint | Description | +| --- | --- | +| GET [Get task result](/api-reference/v2/tasksservice/get-task-result) | Returns the status of a task or task group, and if completed, the result. | diff --git a/docs/snippets/api/v2/workflow-issues-endpoints.mdx b/docs/snippets/api/v2/workflow-issues-endpoints.mdx new file mode 100644 index 0000000000..141fc3c6bb --- /dev/null +++ b/docs/snippets/api/v2/workflow-issues-endpoints.mdx @@ -0,0 +1,5 @@ +{/* Auto-generated by scripts/generate_service_endpoints.py -- do not edit by hand. */} + +No endpoints are published in this service yet. They appear here as each one +is published, and every change is recorded in the +[changelog](/api-reference/v2/Changelog). diff --git a/docs/styles.css b/docs/styles.css index c468805cc4..570ef91572 100644 --- a/docs/styles.css +++ b/docs/styles.css @@ -13,3 +13,66 @@ h1 { .dark [data-component-part="accordion-button"]:hover { background-color: #1f2d44 !important; } + +/* API changelog tables (generated by scripts/generate_api_changelog.py, + which emits the wrapper class): hug the Change column to its chip. + Mintlify's table styling puts min-width: 150px on every td, which is + what holds the column open -- hence the !important override. */ +.api-changelog-table th:first-child, +.api-changelog-table td:first-child { + min-width: 0 !important; + width: 1%; + white-space: nowrap; +} + +/* API reference sidebar density. + 38 of 43 entries under a service carry a maturity pill, and in the narrow + nav rail it wraps onto its own line -- roughly doubling each entry's height + and making a service look far larger than it is. Maturity is stated on the + endpoint page itself and defined in the deprecation policy, so the rail does + not need to repeat it. The `deprecated` badge is deliberately NOT hidden: + there are only a few, and that one is worth interrupting for. + + Hide the pill's WRAPPER, not just the pill: the wrapper carries h-[1lh] and + sits in a flex-wrap row, so hiding only the pill leaves a one-line-tall + empty box that wraps below any title long enough to take two lines -- + showing up as a stray gap under exactly those entries. */ +#sidebar div:has(> .nav-tag-pill) { + display: none !important; +} + +/* Mintlify sets `hyphens: auto` on nav titles, which breaks words mid-syllable + in the narrow rail -- "Preview a detection poli-cy apply". */ +#sidebar .hyphens-auto, +#sidebar .sidebar-title { + hyphens: none !important; +} + +/* Changelog endpoint column: a change to a shared type lists several endpoint + pills in one cell, stacked with
. Without a little leading they read as + one solid block. Scoped to the generated table's wrapper class so it cannot + affect badges anywhere else. */ +.api-changelog-table td:last-child a, +.api-changelog-table td:last-child > span { + display: inline-block; + margin-bottom: 0.3rem; +} + +.api-changelog-table td:last-child a:last-child { + margin-bottom: 0; +} + +/* The changelog's "and N more" hovercard lists full endpoint paths, and a path + is one unbreakable token -- `/api/notifications/deployments/{deploymentId}/ + automations/{automationId}` has nowhere to wrap. Mintlify caps the popup at + 16rem, so the text ran ~190px out past the rounded background rather than + wrapping inside it. Allow a break mid-path, and widen the cap a little so a + path takes two lines instead of five. Still bounded by the viewport. */ +[data-component-part="tooltip-content"] { + max-width: min(28rem, calc(100vw - 2rem)); + overflow-wrap: anywhere; + /* The hovercard lists one endpoint per line. `pre-line` honours those + newlines while still collapsing runs of spaces and wrapping long + paths, which `pre` would not. */ + white-space: pre-line; +} diff --git a/scripts/generate_api_changelog.py b/scripts/generate_api_changelog.py new file mode 100644 index 0000000000..82f0ce4b4b --- /dev/null +++ b/scripts/generate_api_changelog.py @@ -0,0 +1,1130 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.9" +# dependencies = ["pyyaml"] +# +# [tool.uv] +# exclude-newer = "1 week" +# /// +# +# Generate a Mintlify changelog page (docs/api-reference/*/Changelog.mdx) from +# an OpenAPI spec's git history. +# +# Why this exists: the API reference is rendered straight from the checked-in +# specs, so the spec's git history *is* the API's change history — but nothing +# surfaced it to readers. This script walks that history with oasdiff +# (https://github.com/oasdiff/oasdiff), which diffs the specs semantically: +# line reorders from sort_openapi_nav.py and description edits from +# mirror_openapi.py produce no entries, only consumer-affecting changes do. +# +# How dates work: commits touching the spec are collapsed to one snapshot per +# committer-date (the day's final state — intra-day flip-flops intentionally +# vanish), and consecutive snapshots are diffed to make one block per +# day. If the working tree differs from HEAD (the update-openapi-specs.yml +# cron fetches fresh specs before committing), a snapshot dated "today" is +# added so the changelog entry lands in the same PR as the spec change. Should +# that PR merge on a later day, the next full regeneration re-dates the entry +# to the merge commit's date — a small, self-correcting relabel diff. +# +# The page is fully regenerated on every run (idempotent, and retroactively +# consistent if tooling changes). Output is a pure function of git history and +# the oasdiff version, so keep oasdiff pinned in CI — bumping it may reword +# every entry at once, which is expected and fine in a deliberate bump PR. +# +# Requires the oasdiff binary (brew install oasdiff, or see the pinned install +# in .github/workflows/update-openapi-specs.yml) and full git history (not a +# shallow clone). + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, NamedTuple, Optional + +import yaml + +# Spec-hygiene checks aimed at API maintainers, not consumers. The `info` +# section only ever changes when the spec's own version/title metadata does +# (e.g. "major version did not increase"), which is meaningless noise on a +# reader-facing changelog. +EXCLUDED_SECTIONS = frozenset(("info",)) + +# Checks dropped outright, by id. +# +# A tag is documentation grouping: it decides which nav group an endpoint +# renders under, not what the endpoint accepts or returns. Regrouping the +# public API into its new services re-tags roughly 200 endpoints, and oasdiff +# emits an `added` and a `removed` for each, so the whole reorganisation would +# land as ~400 rows on one date -- a new , which is the one thing that +# still publishes to RSS subscribers. That would announce an internal filing +# change as the largest entry in the feed's history while burying the real +# changes shipped that day. +# +# Not entirely free: some SDK generators namespace by tag, so a consumer +# generating a client from the published spec could see a method move class. +# It is still not a change to the HTTP contract -- same path, same request, +# same response -- and oasdiff itself rates both checks INFO. +EXCLUDED_CHECKS = frozenset(("api-tag-added", "api-tag-removed")) + +# oasdiff levels we deliberately disagree with, keyed by check id. +# +# oasdiff rates a new *response* enum value WARN (potentially breaking) on the +# theory that a consumer exhaustively switching on the enum, or validating +# responses against a closed set, breaks when an unseen value arrives. That is +# reasonable for a hand-written spec, but ours are generated from protobuf, +# where enums are open by construction and gaining values is routine +# evolution -- so every sync filed a pile of `SCAN_STATUS_*`-style additions +# under a "Potentially breaking changes" heading and buried the actual +# removals. Request-side enum additions are already INFO upstream (the server +# accepting strictly more input cannot break a caller), so only the response +# side needs correcting. +LEVEL_OVERRIDES = { + "response-property-enum-value-added": 1, + # oasdiff files a deprecation announcement as INFO, which lands it under + # "Changes" with the filter tag "Non-breaking". That is the wrong shelf: + # the announcement is the only advance warning a caller gets, and the one + # reader who most needs it -- someone filtering the changelog for what will + # break them -- is exactly the reader a "Non-breaking" tag hides it from. + # It is not breaking on the day it ships, so BREAKING would cry wolf; + # POTENTIALLY_BREAKING says what is true, that this breaks you unless you + # act before the sunset date. + "endpoint-deprecated": 2, + "endpoint-deprecated-with-sunset": 2, +} + +# oasdiff names properties with JSON Schema's internal vocabulary, which leaks +# spec plumbing into reader-facing text. Three rewrites turn a path into the +# field reference a consumer would actually write: +# +# 1. `allOf[#/components/schemas/protos.projects.v1.RepoFilters]` segments are +# dropped. These specs are protobuf-generated and wrap every described +# $ref in a single-element allOf (the OpenAPI 3.0 workaround for $ref +# siblings being ignored), so the segment is pure composition bookkeeping +# -- the wire payload has no such level, and dropping it makes the path +# *more* accurate as well as shorter. It also happens to be the only place +# an internal proto package name appears. +# 2. An `items` segment means "element of the preceding array", so it folds +# into the parent as `[]`. A trailing one (an array of scalars, e.g. +# `repositoryId/items/`) would otherwise dangle. +# 3. `/` becomes `.`, matching how the field is addressed in JSON. +# +# Field-name *case* is deliberately never touched: the specs inconsistently +# emit both `deployment_id` and `deploymentId` depending on the service, and +# the changelog has to name the key the endpoint actually accepts. +# Stripped whole, before splitting on "/": the JSON pointer inside the brackets +# contains slashes of its own, so a naive split would shred it. +ALLOF_SEGMENT = re.compile(r"/allOf\[[^\]]*\]") + + +def humanize_property_path(path: str) -> str: + """`a/items/b/allOf[#/...]/c` -> `a[].b.c`. Non-paths pass through.""" + out: list[str] = [] + for segment in ALLOF_SEGMENT.sub("", path).split("/"): + if not segment: + continue + if segment == "items" and out: + out[-1] += "[]" + continue + out.append(segment) + return ".".join(out) + + +def humanize_change_text(change: dict) -> dict: + """Rewrite property paths inside the change's code spans.""" + text = change.get("text") or "" + if "`" not in text: + return change + parts = text.split("`") + if len(parts) % 2 == 0: # unbalanced backticks: leave well alone + return change + for i in range(1, len(parts), 2): # odd indices are code spans + span = parts[i] + # a leading "/" marks a URL path, not a property path + if "/" in span and not span.startswith("/"): + parts[i] = humanize_property_path(span) + rewritten = "`".join(parts) + return change if rewritten == text else {**change, "text": rewritten} + + +MONTHS = ( + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +) + +BREAKING = 3 +POTENTIALLY_BREAKING = 2 + +SECTION_TITLES = { + BREAKING: "Breaking changes", + POTENTIALLY_BREAKING: "Potentially breaking changes", + 1: "Changes", +} + +# The side-panel filter tag, naming the day's highest severity. Every +# needs one: Mintlify hides an update only when it carries tags and none of +# them is selected, so an with no tags= at all stays visible whatever +# is selected. Tagging just the breaking days therefore rendered a "Breaking" +# filter that, when clicked, hid nothing -- the other days had no tags to fail +# to match. Levels outside this map fall in with the level-1 changes, matching +# how the severity sections are grouped. +UPDATE_TAGS = { + BREAKING: "Breaking", + POTENTIALLY_BREAKING: "Potentially breaking", + 1: "Non-breaking", +} + +# One upstream sync often lands the same kind of change many times over — six +# enum values added to one property, a dozen properties switching `uint32` to +# `int64`. Rendering each as its own bullet buries the day's real news, so +# changes whose oasdiff text differs only in one identifier are folded into a +# single bullet. Keyed by oasdiff check id; the regex names the varying token +# `value` and any other groups become part of the merge key (changes merge only +# when everything but `value` matches). Texts that don't match the expected +# wording (e.g. after an oasdiff upgrade) are left verbatim. +COALESCIBLE = { + "request-property-enum-value-added": ( + re.compile(r"^added the new `(?P[^`]+)` enum value to the request property `(?P[^`]+)`$"), + "added the new {values} enum values to the request property `{prop}`", + ), + "response-property-enum-value-added": ( + re.compile( + r"^added the new `(?P[^`]+)` enum value to the `(?P[^`]+)` response property" + r" for the response status `(?P[^`]+)`$" + ), + "added the new {values} enum values to the `{prop}` response property" + " for the response status `{status}`", + ), + "request-parameter-enum-value-added": ( + re.compile( + r"^added the new enum value `(?P[^`]+)` to the `(?P[^`]+)`" + r" request parameter `(?P[^`]+)`$" + ), + "added the new enum values {values} to the `{loc}` request parameter `{param}`", + ), + "request-property-type-changed": ( + re.compile( + r"^the `(?P[^`]+)` request property `(?P[^`]+)` changed" + r" from `(?P[^`]+)` to `(?P[^`]+)`$" + ), + "the `{attr}` of the request properties {values} changed from `{old}` to `{new}`", + ), + "response-property-type-changed": ( + re.compile( + r"^the `(?P[^`]+)` response's property `(?P[^`]+)` changed" + r" from `(?P[^`]+)` to `(?P[^`]+)` for status `(?P[^`]+)`$" + ), + "the `{attr}` of the response properties {values} changed" + " from `{old}` to `{new}` for status `{status}`", + ), + "request-parameter-type-changed": ( + re.compile( + r"^for the `(?P[^`]+)` request parameter `(?P[^`]+)`, the `(?P[^`]+)`" + r" was changed from `(?P[^`]+)` to `(?P[^`]+)`$" + ), + "for the `{loc}` request parameters {values}, the `{attr}`" + " was changed from `{old}` to `{new}`", + ), +} + + +class Snapshot(NamedTuple): + ref: Optional[str] # commit sha, or None for the working tree + date: str # ISO YYYY-MM-DD + + +class Entry(NamedTuple): + date: str # ISO YYYY-MM-DD + changes: list + + +def repo_root() -> Path: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, check=True, + ) + return Path(out.stdout.strip()) + + +def list_snapshots(spec_rel: str, cwd: Path) -> list[Snapshot]: + """One snapshot per committer-date the spec changed on, newest first. + + --first-parent keeps dates monotonic along the main branch; without it, + commits authored days earlier on a side branch would interleave. + """ + out = subprocess.run( + ["git", "log", "--first-parent", "--format=%H %cs", "--", spec_rel], + capture_output=True, text=True, check=True, cwd=cwd, + ) + snapshots: list[Snapshot] = [] + seen_dates = set() + for line in out.stdout.splitlines(): + sha, _, date = line.strip().partition(" ") + if not sha or date in seen_dates: + continue # git log is newest-first: first hash = day's final state + seen_dates.add(date) + snapshots.append(Snapshot(sha, date)) + return snapshots + + +def working_tree_differs(spec_rel: str, cwd: Path) -> bool: + return ( + subprocess.run( + ["git", "diff", "--quiet", "HEAD", "--", spec_rel], cwd=cwd + ).returncode + != 0 + ) + + +def run_oasdiff(base: str, rev: str, oasdiff_bin: str, cwd: Path) -> Optional[list]: + """Changes between two spec revisions, or None if oasdiff failed. + + Exit code 0 covers both "changes found" and "no changes" (we don't pass + --fail-on), so a non-zero exit always means a real error such as an + unparseable revision. + """ + result = subprocess.run( + [oasdiff_bin, "changelog", "--format", "json", "--flatten-allof", base, rev], + capture_output=True, text=True, cwd=cwd, + ) + if result.returncode != 0: + print( + f"warning: oasdiff failed for {base} -> {rev}: {result.stderr.strip()}", + file=sys.stderr, + ) + return None + return json.loads(result.stdout) + + +def _revision_arg(snapshot: Snapshot, spec_rel: str) -> str: + return f"{snapshot.ref}:{spec_rel}" if snapshot.ref else spec_rel + + +# Our own check id for a removal oasdiff stays silent about. Prefixed +# "endpoint-" so it reads as endpoint-scoped and chips as "Removed", like the +# oasdiff removals it sits beside. +SUNSET_REMOVAL_ID = "endpoint-removed-after-sunset" + + +def load_spec(snapshot: Snapshot, spec_rel: str, cwd: Path) -> Optional[dict]: + """A snapshot's parsed spec, or None if it cannot be read.""" + try: + if snapshot.ref is None: + text = (cwd / spec_rel).read_text() + else: + text = subprocess.run( + ["git", "show", f"{snapshot.ref}:{spec_rel}"], + capture_output=True, text=True, check=True, cwd=cwd, + ).stdout + return yaml.safe_load(text) + except (subprocess.CalledProcessError, OSError, yaml.YAMLError) as exc: + print(f"warning: could not read {spec_rel} at {snapshot.ref}: {exc}", file=sys.stderr) + return None + + +def spec_operations(spec: dict) -> dict: + """(METHOD, path) -> the operation's `x-sunset` date, "" when it has none.""" + ops = {} + for path, item in ((spec or {}).get("paths") or {}).items(): + if not isinstance(item, dict): + continue + for method, op in item.items(): + if method not in HTTP_METHODS or not isinstance(op, dict): + continue + ops[(method.upper(), path)] = str(op.get("x-sunset") or "") + return ops + + +def sunset_removals( + base: Snapshot, + rev: Snapshot, + spec_rel: str, + load: Optional[Callable[[Snapshot], Optional[dict]]], + reported: list, +) -> list: + """Removals that honoured their sunset date, which oasdiff omits entirely. + + oasdiff reports a removal only when something went wrong with it -- gone + with no deprecation, or gone before the sunset date it published. Retire an + endpoint exactly as the policy prescribes and `oasdiff changelog` says "No + changes to report", so the day the endpoint actually stopped answering is + the one day missing from the changelog. That inverts the incentive: the + changelog would record only the removals we botched. + + Detected by diffing the two snapshots' operation sets rather than by asking + oasdiff differently, because there is no flag for this -- the check simply + does not exist upstream. + """ + if load is None: + return [] + base_spec, rev_spec = load(base), load(rev) + if base_spec is None or rev_spec is None: + return [] + + surviving = spec_operations(rev_spec) + already = { + (c.get("operation"), c.get("path")) + for c in reported + if "removed" in str(c.get("id", "")).split("-") + } + + removals = [] + for (method, path), sunset in sorted(spec_operations(base_spec).items()): + # No sunset date, or oasdiff already had something to say: either way + # not ours to report. + if not sunset or (method, path) in surviving or (method, path) in already: + continue + removals.append( + { + "id": SUNSET_REMOVAL_ID, + "text": f"endpoint removed after its sunset date `{sunset}`", + "level": BREAKING, + "operation": method, + "path": path, + "section": "paths", + } + ) + return removals + + +def build_entries( + snapshots: list[Snapshot], + spec_rel: str, + diff: Callable[[str, str], Optional[list]], + load: Optional[Callable[[Snapshot], Optional[dict]]] = None, +) -> list[Entry]: + """Diff consecutive snapshots into per-date entries, newest first. + + The oldest snapshot is the baseline and never gets an entry — diffing it + against nothing would report every endpoint as "added". + """ + ordered = list(reversed(snapshots)) # oldest -> newest + if not ordered: + return [] + + entries: list[Entry] = [] + last_good = ordered[0] + for snapshot in ordered[1:]: + rev_arg = _revision_arg(snapshot, spec_rel) + changes = diff(_revision_arg(last_good, spec_rel), rev_arg) + if changes is None: + # Decide which side is unparseable: if the newer side self-diffs + # cleanly, the base was bad — drop it and let its successor's diff + # pick up any changes; otherwise skip the newer snapshot. + if diff(rev_arg, rev_arg) is not None: + last_good = snapshot + continue + changes = [ + c + for c in changes + if c.get("section") not in EXCLUDED_SECTIONS + and c.get("id") not in EXCLUDED_CHECKS + ] + changes += sunset_removals(last_good, snapshot, spec_rel, load, changes) + changes = [humanize_change_text(apply_level_override(c)) for c in changes] + if changes: + entries.append(Entry(snapshot.date, changes)) + last_good = snapshot + return list(reversed(entries)) + + +def apply_level_override(change: dict) -> dict: + """Re-level a change per LEVEL_OVERRIDES, leaving others untouched.""" + override = LEVEL_OVERRIDES.get(change.get("id")) + if override is None or change.get("level") == override: + return change + return {**change, "level": override} + + +def format_date(iso: str) -> str: + """"2026-08-07" -> "August 7, 2026" (no strftime: %-d is platform-bound).""" + year, month, day = iso.split("-") + return f"{MONTHS[int(month) - 1]} {int(day)}, {int(year)}" + + +def _escape_segment(text: str) -> str: + text = text.replace("&", "&") + text = text.replace("<", "<").replace(">", ">") + text = text.replace("{", "{").replace("}", "}") + for char in "*_[]": + text = text.replace(char, "\\" + char) + return text + + +def escape_mdx(text: str) -> str: + """Escape MDX-hazardous characters, preserving `code spans`. + + oasdiff wraps identifiers in backticks; inside a code span MDX treats + braces and angle brackets literally, so only text outside spans needs + entity-escaping. Unbalanced backticks can't delimit spans — escape them. + """ + parts = text.split("`") + if len(parts) % 2 == 0: # odd backtick count: not span-delimited + return _escape_segment(text).replace("`", "\\`") + for i in range(0, len(parts), 2): # even indices sit outside code spans + parts[i] = _escape_segment(parts[i]) + return "`".join(parts) + + +def coalesce_changes(changes: list) -> list: + """Fold same-kind changes that differ only in one identifier into one. + + See COALESCIBLE. Order follows first appearance of each merge bucket, and + a bucket with a single change keeps its original dict untouched. + """ + buckets: dict = {} + order: list = [] + for change in changes: + pattern, template = COALESCIBLE.get(change.get("id"), (None, None)) + match = pattern.match(change["text"]) if pattern else None + if match: + fixed = {k: v for k, v in match.groupdict().items() if k != "value"} + key = ( + change["id"], change.get("operation"), change.get("path"), + tuple(sorted(fixed.items())), + ) + bucket = buckets.setdefault(key, {"changes": [], "values": [], "fixed": fixed, "template": template}) + bucket["changes"].append(change) + bucket["values"].append(match.group("value")) + else: + key = ("verbatim", len(order)) + buckets[key] = {"changes": [change]} + if key not in order: + order.append(key) + + merged = [] + for key in order: + bucket = buckets[key] + if len(bucket["changes"]) == 1: + merged.append(bucket["changes"][0]) + continue + values = ", ".join(f"`{v}`" for v in sorted(bucket["values"])) + merged.append( + {**bucket["changes"][0], "text": bucket["template"].format(values=values, **bucket["fixed"])} + ) + return merged + + +# Enum-addition checks, which get a second merge pass beyond the identical-text +# one. An enum is a shared type: `automation.triggerSource` and +# `automations[].triggerSource` are the same enum field reached through a +# different wrapper, and a value added to it applies to requests and responses +# alike. So for these -- and only these -- the wrapper path and the direction +# are normalised away, taking one enum value from three rows to one. +# +# Not done for the other checks. Collapsing `a.id` and `b.id` to `id` would +# conflate genuinely different fields, and a type change on one of them is not +# a type change on the other. Enum values are safe because the value itself is +# part of the key: two fields do not gain the same new constant by coincidence. +ENUM_ADDITION_SUFFIX = "-enum-value-added" +ENUM_VALUE_RE = re.compile(r"`([A-Z][A-Z0-9_]{2,})`") +ENUM_FIELD_RE = re.compile(r"`([A-Za-z][A-Za-z0-9_.\[\]]*)`") + + +def merge_enum_additions(changes: list) -> list: + """Fold an enum addition into one row regardless of wrapper or direction. + + Runs after merge_across_endpoints, so the endpoint lists it finds are + already deduplicated and it only has to union them. + """ + buckets: dict = {} + order: list = [] + for change in changes: + if not str(change.get("id", "")).endswith(ENUM_ADDITION_SUFFIX): + key = ("verbatim", len(order)) + buckets[key] = [change] + else: + text = change["text"] + values = tuple(sorted(set(ENUM_VALUE_RE.findall(text)))) + fields = [f for f in ENUM_FIELD_RE.findall(text) if not f.isupper()] + leaf = fields[0].split(".")[-1].removesuffix("[]") if fields else "" + key = ("enum", values, leaf) + buckets.setdefault(key, []).append(change) + if key not in order: + order.append(key) + + merged = [] + for key in order: + group = buckets[key] + if key[0] != "enum" or len(group) == 1: + merged.append(group[0]) + continue + values, leaf = key[1], key[2] + listed = ", ".join(f"`{v}`" for v in values) + plural = "values" if len(values) != 1 else "value" + directions = sorted( + {"request" if "request" in c["text"] else "response" for c in group} + ) + endpoints: set = set() + for c in group: + endpoints.update( + c.get("endpoints") or [(c.get("operation"), c.get("path"))] + ) + merged.append( + { + **group[0], + "text": ( + f"added the new {listed} enum {plural} to the `{leaf}` " + f"{' and '.join(directions)} property" + ), + "endpoints": sorted(e for e in endpoints if e[1]), + } + ) + return merged + + +# How many endpoint pills a merged row shows before it truncates. +MAX_ENDPOINT_PILLS = 3 + + +# Checks whose subject *is* the endpoint, rather than something shared that +# happens to surface on one. These must never merge across endpoints: two +# endpoints being added on the same day both read "endpoint added", and folding +# them into one row would hide the endpoints behind "and N more" -- which is +# the only information those rows carry. +ENDPOINT_SCOPED_PREFIXES = ("endpoint-", "api-") + + +def _is_endpoint_scoped(change: dict) -> bool: + return str(change.get("id", "")).startswith(ENDPOINT_SCOPED_PREFIXES) + + +def merge_across_endpoints(changes: list) -> list: + """Fold one change surfaced on several endpoints into a single row. + + A change to a *shared type* is not an endpoint-level change. One protobuf + enum gaining one value, or one field going from `uint32` to `int64`, + surfaces once per endpoint that touches it -- `TRIGGER_SOURCE_PATCH_READY` + produced six rows on one day, and the `uint32` sweep produced dozens. The + reader wants "this changed, here is where it shows up", not the same + sentence restated per endpoint. + + Merges only rows whose wording is *identical*, so nothing is lost: the + property path, direction and status code all still have to match. That is + the inverse of what COALESCIBLE does, where the identifier varies and the + endpoint is fixed. + + A merged change carries an `endpoints` list, which the table renderer turns + into several pills in one cell. Order follows first appearance. + """ + buckets: dict = {} + order: list = [] + for change in changes: + if _is_endpoint_scoped(change): + key = ("verbatim", len(order)) + buckets[key] = [change] + else: + key = (change.get("id"), change["text"]) + buckets.setdefault(key, []).append(change) + if key not in order: + order.append(key) + + merged = [] + for key in order: + group = buckets[key] + if len(group) == 1: + merged.append(group[0]) + continue + endpoints = sorted( + {(c.get("operation"), c.get("path")) for c in group if c.get("path")} + ) + merged.append({**group[0], "endpoints": endpoints}) + return merged + + +HTTP_METHODS = frozenset( + ("get", "post", "put", "patch", "delete", "options", "head", "trace") +) + +# Mintlify Badge colors per HTTP method, matching the API playground's visual +# language (green reads, blue creates, red deletes). +METHOD_BADGE_COLORS = { + "GET": "green", + "POST": "blue", + "PUT": "orange", + "PATCH": "yellow", + "DELETE": "red", +} + +# Verb chip for the table style's Change column, keyed on tokens of the +# oasdiff check id. Order matters: "api-path-removed-without-deprecation" +# contains both removal and deprecation words, and it is a removal. +CHANGE_VERBS = ( + (frozenset(("removed", "deleted")), ("Removed", "red")), + (frozenset(("added", "new")), ("Added", "green")), + (frozenset(("deprecated",)), ("Deprecated", "yellow")), +) +CHANGE_VERB_FALLBACKS = ( + frozenset( + ("changed", "specialized", "narrowed", "generalized", + "increased", "decreased", "restricted") + ), + ("Changed", "orange"), +) + + +def mintlify_slug(text: str) -> str: + """Slugify the way Mintlify names its generated endpoint pages. + + Reverse-engineered and verified against the rendered site (every + operation in both specs resolved with HTTP 200): lowercase; apostrophes + stripped outright; underscores and square brackets preserved; every other + run of non-alphanumerics becomes a single hyphen. + """ + text = text.lower().replace("'", "") + return re.sub(r"[^a-z0-9_\[\]]+", "-", text).strip("-") + + +def _default_page_title(method: str, path: str) -> str: + """Mintlify's page title for operations without a summary. + + Path parameter segments become word breaks; adjacent literal segments + run together: GET /api/agent/deployments/{id}/ignores -> + "Get apiagentdeployments ignores". + """ + text = "".join( + " " if seg.startswith("{") else seg for seg in path.strip("/").split("/") + ) + return f"{method.capitalize()} {' '.join(text.split())}" + + +def endpoint_urls(spec: dict, link_base: str) -> dict: + """(METHOD, path) -> docs URL for every operation in the spec. + + Built from the *current* spec on purpose: pages only exist for endpoints + that are still in it, so removed endpoints simply drop out of the map and + render unlinked instead of pointing at a 404. + """ + urls = {} + for path, item in (spec.get("paths") or {}).items(): + if not isinstance(item, dict): + continue + for method, op in item.items(): + if method not in HTTP_METHODS or not isinstance(op, dict): + continue + tags = op.get("tags") or [] + if not tags: + continue + title = op.get("summary") or _default_page_title(method, path) + # brackets are legal in Mintlify slugs but break markdown link + # destinations, so percent-encode them + slug = mintlify_slug(title).replace("[", "%5B").replace("]", "%5D") + urls[(method.upper(), path)] = f"{link_base}/{mintlify_slug(tags[0])}/{slug}" + return urls + + +def _endpoint_key(change: dict) -> tuple: + path = change.get("path") or "" + if not path: + return (1, "", "") # General bucket sorts after all endpoints + return (0, path, change.get("operation") or "") + + +def _count_parts(changes: list) -> list[str]: + """Non-zero severity counts, least-qualified last: ["2 breaking", "3 other"]. + + Shared by the on-page summary and the RSS description so the two cannot + disagree. The trailing noun is left to the caller, which appends it to the + final part -- "2 breaking · 3 other changes" reads as one phrase, so a + breaking-only day must still come out as "1 breaking change" rather than + the bare "1 breaking". "other" is dropped when it stands alone, since + there is then nothing for it to be other than. + """ + breaking = sum(1 for c in changes if c["level"] == BREAKING) + potential = sum(1 for c in changes if c["level"] == POTENTIALLY_BREAKING) + other = len(changes) - breaking - potential + counts = ((breaking, "breaking"), (potential, "potentially breaking"), (other, "other")) + present = [(n, word) for n, word in counts if n] + if len(present) == 1 and present[0][1] == "other": + return [str(present[0][0])] + return [f"{n} {word}" for n, word in present] + + +def _with_noun(parts: list[str], count: int) -> list[str]: + """Append "change"/"changes" to the final part, agreeing with its count.""" + return parts[:-1] + [f"{parts[-1]} change{'s' if count != 1 else ''}"] + + +def _summary(changes: list) -> str: + """Raw change counts, e.g. "2 breaking · 1 potentially breaking · 3 other changes".""" + parts = _count_parts(changes) + if not parts: + return "no changes" + last = int(parts[-1].split()[0]) + return " · ".join(_with_noun(parts, last)) + + +def _update_tag(changes: list) -> str: + """The day's highest severity, as its side-panel filter tag.""" + for level in (BREAKING, POTENTIALLY_BREAKING): + if any(c["level"] == level for c in changes): + return UPDATE_TAGS[level] + return UPDATE_TAGS[1] + + +def _jsx_string(text: str) -> str: + """Escape text for a double-quoted JSX string literal.""" + return text.replace("\\", "\\\\").replace('"', '\\"') + + +def _rss_description(changes: list) -> str: + """Plain-prose summary for RSS subscribers. + + Mintlify strips components, code and HTML from feed entries, and our + entries are almost entirely a JSX table of Badges and links -- so without + an explicit `rss` description a subscriber would receive an empty item. + Prose rather than the interpunct-separated on-page summary, since this is + read in a feed reader with no surrounding context. + """ + parts = _count_parts(changes) + if not parts: + return "No changes." + last = int(parts[-1].split()[0]) + parts = _with_noun(parts, last) + if len(parts) == 1: + listed = parts[0] + elif len(parts) == 2: + listed = f"{parts[0]} and {parts[1]}" # no serial comma for a pair + else: + listed = ", ".join(parts[:-1]) + f", and {parts[-1]}" + endpoints = {(c.get("operation"), c.get("path")) for c in changes if c.get("path")} + if not endpoints: + return f"{listed}." + scope = "endpoint" if len(endpoints) == 1 else "endpoints" + return f"{listed} across {len(endpoints)} {scope}." + + +def _render_section(changes: list, links: dict) -> list: + """Bullets for one severity section, grouped by endpoint.""" + groups: dict[tuple, list] = {} + for change in changes: + groups.setdefault(_endpoint_key(change), []).append(change) + + lines = [] + for key in sorted(groups): + texts = [ + escape_mdx(c["text"]) + for c in sorted(groups[key], key=lambda c: (c["id"], c["text"])) + ] + if key[0] == 1: # General bucket: schema/security changes, no endpoint + lines.extend(f"- {text}" for text in texts) + continue + lead = _endpoint_cell(key, links) + if len(texts) == 1: + lines.append(f"- {lead}: {texts[0]}") + else: + lines.append(f"- {lead}:") + lines.extend(f" - {text}" for text in texts) + return lines + + +def _change_verb(change: dict) -> tuple: + """(label, badge color) for the table's Change column, from the check id.""" + tokens = set(change.get("id", "").split("-")) + for words, verb in CHANGE_VERBS: + if tokens & words: + return verb + if tokens & CHANGE_VERB_FALLBACKS[0]: + return CHANGE_VERB_FALLBACKS[1] + return ("Changed", "gray") + + +def _endpoint_cell(key: tuple, links: dict) -> str: + if key[0] == 1: # General bucket: schema/security changes, no endpoint + return "—" + operation, path = key[2], key[1] + color = METHOD_BADGE_COLORS.get(operation, "gray") + url = links.get((operation, path)) + target = f"[`{path}`]({url})" if url else f"`{path}`" + return f'{operation} {target}' + + +def _escape_jsx_text(text: str) -> str: + """Escape text placed as JSX element children (Badge content). + + Braces open JSX expressions and angle brackets open tags; HTML entities + are decoded by the JSX runtime, so they render as the literal characters. + """ + text = text.replace("&", "&") + text = text.replace("<", "<").replace(">", ">") + return text.replace("{", "{").replace("}", "}") + + +def _endpoint_pill(key: tuple, links: dict) -> str: + """Table style's endpoint cell: one clickable pill with method + path. + + A raw rather than a markdown link because Mintlify underlines links + with a bottom border, which the inline style suppresses. after + each path segment lets the pill wrap instead of overflowing the table + (paths are single unbreakable tokens otherwise) without inserting + characters that would survive copy-paste. + """ + if key[0] == 1: # General bucket: schema/security changes, no endpoint + return "—" + operation, path = key[2], key[1] + color = METHOD_BADGE_COLORS.get(operation, "gray") + escaped = _escape_jsx_text(path) + wrappable = escaped[0] + escaped[1:].replace("/", "/") + badge = f'{operation} {wrappable}' + url = links.get((operation, path)) + if not url: + return badge + return ( + f'' + f"{badge}" + ) + + +def _endpoints_cell(change: dict, links: dict) -> str: + """The Endpoint cell: one pill, or several for a change that spans them. + + Truncated at MAX_ENDPOINT_PILLS. A shared-type change can touch a dozen + endpoints, and a cell that tall pushes everything else off the screen -- + the reader needs to know it is broad, not to read every path. + """ + endpoints = change.get("endpoints") + if not endpoints: + return _endpoint_pill(_endpoint_key(change), links) + + shown = endpoints[:MAX_ENDPOINT_PILLS] + pills = [_endpoint_pill((0, path, operation), links) for operation, path in shown] + hidden = endpoints[len(shown):] + if hidden: + # Name the hidden ones on hover rather than making them unreachable. + # The cell is truncated to keep the row a readable height, not to + # withhold the list. + # + # One bullet per line, not a comma-separated run: paths are long + # enough to wrap, so a run gives no way to tell where one ends and + # the next begins. Passed as a JSX expression rather than an + # attribute string so the newlines survive -- json.dumps handles the + # escaping, and braces inside a JS string literal need no entity + # encoding. `white-space: pre-line` in styles.css renders the breaks. + listed = "\n".join(f"\u2022 {operation} {path}" for operation, path in hidden) + pills.append( + f"and {len(hidden)} more" + ) + return "
".join(pills) + + +def _render_section_table(changes: list, links: dict) -> list: + """One table for a severity section: Change | Description | Endpoint.""" + # Sort by the first endpoint so rows still cluster by area, then by the + # change itself. A merged row sorts by the first endpoint it lists. + def sort_key(change: dict) -> tuple: + endpoints = change.get("endpoints") + key = (0,) + endpoints[0][::-1] if endpoints else _endpoint_key(change) + return (key, change["id"], change["text"]) + + # The div carries a hook for docs/styles.css, which hugs the Change + # column to its chip; plain markdown tables offer no width control. + lines = [ + '
', + "", + "| Change | Description | Endpoint |", + "|---|---|---|", + ] + for change in sorted(changes, key=sort_key): + verb, color = _change_verb(change) + description = escape_mdx(change["text"]).replace("|", "\\|") + lines.append( + f'| {verb}' + f" | {description} | {_endpoints_cell(change, links)} |" + ) + lines.extend(["", "
"]) + return lines + + +def _render_update( + entry: Entry, links: dict, style: str = "table", api_name: Optional[str] = None +) -> str: + label = format_date(entry.date) + attrs = [ + f'label="{label}"', + f'description="{_summary(entry.changes)}"', + 'tags={["%s"]}' % _update_tag(entry.changes), + ] + # Pins the feed to one item per . Without it Mintlify emits one + # entry per Markdown heading inside the block, so a day with three + # severity sections becomes three items titled "Breaking changes", + # "Potentially breaking changes" and "Changes" -- undated, and repeated + # across every day. It also decouples the feed from the heading structure: + # a re-level that adds or drops a section heading would otherwise count as + # "modifying headings inside an existing Update" and republish the entry. + rss_title = f"{api_name} — {label}" if api_name else label + attrs.append( + 'rss={{ title: "%s", description: "%s" }}' + % (_jsx_string(rss_title), _jsx_string(_rss_description(entry.changes))) + ) + + render_section = _render_section_table if style == "table" else _render_section + changes = merge_enum_additions(merge_across_endpoints(coalesce_changes(entry.changes))) + lines = [f""] + first = True + for level in (BREAKING, POTENTIALLY_BREAKING, 1): + section = [c for c in changes if (c["level"] if c["level"] in SECTION_TITLES else 1) == level] + if not section: + continue + if not first: + lines.append("") + first = False + # Bold rather than a Markdown heading, deliberately. Mintlify + # republishes a feed entry when a heading inside an existing + # is modified, and which severity sections a day has depends on the + # level mapping -- so a re-level (see LEVEL_OVERRIDES) or an oasdiff + # bump would re-notify every subscriber about months-old entries. + # With no headings in the block, the only publish trigger left is + # adding a new . The rss= prop supplies the feed title, so + # nothing depends on these being headings. + lines.append(f"**{SECTION_TITLES[level]}**") + lines.append("") + lines.extend(render_section(section, links)) + lines.append("") + return "\n".join(lines) + + +def _rss_callout(rss_url: str) -> str: + """Subscribe instructions. + + Mintlify emits a `` tag + for `rss: true` pages, which is autodiscovery only -- it renders no + visible affordance -- so the page has to say this itself. + """ + return ( + "\n" + f" **Subscribe to this changelog:** add [`{rss_url}`]({rss_url}) to any\n" + f" feed reader, or run `/feed subscribe {rss_url}` in Slack to post new\n" + " entries to a channel. Most readers also accept this page's own URL and\n" + " find the feed automatically.\n" + "" + ) + + +def render_page( + api_name: str, + api_href: Optional[str], + note: Optional[str], + entries: list[Entry], + links: Optional[dict] = None, + style: str = "table", + rss_url: Optional[str] = None, +) -> str: + name = f"[{api_name}]({api_href})" if api_href else api_name + intro = ( + f"Changes to the {name}, detected by\n" + "comparing successive versions of its OpenAPI specification. Breaking changes\n" + "are labeled; documentation-only edits are not listed." + ) + if note: + intro += f" {note}" + + blocks = [ + "---", + 'title: "Changelog"', + f'description: "Changes to the {api_name}, generated from its OpenAPI specification."', + "rss: true", + "---", + "", + "{/* Auto-generated by scripts/generate_api_changelog.py -- do not edit by hand. */}", + "", + intro, + "", + ] + if rss_url: + blocks.extend([_rss_callout(rss_url), ""]) + if entries: + blocks.append( + "\n\n".join( + _render_update(entry, links or {}, style, api_name) for entry in entries + ) + ) + else: + blocks.append("No API changes recorded yet.") + return "\n".join(blocks) + "\n" + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Generate a Mintlify changelog page from an OpenAPI spec's git history." + ) + parser.add_argument("spec", help="path to the OpenAPI spec (tracked in git)") + parser.add_argument("output", help="path of the Changelog.mdx to write") + parser.add_argument("--api-name", required=True, help='e.g. "Semgrep API v1"') + parser.add_argument("--api-href", help="docs link for the API name in the intro") + parser.add_argument("--note", help="extra sentence appended to the intro") + parser.add_argument( + "--rss-url", + help="absolute URL of the page's Mintlify RSS feed (the page path with" + " /rss.xml appended); when set, the page carries subscribe instructions", + ) + parser.add_argument( + "--link-base", + help="URL prefix of the generated endpoint pages (e.g. /api-reference/v1);" + " when set, endpoint mentions link to their reference page", + ) + parser.add_argument( + "--style", + choices=("table", "list"), + default="table", + help="table: Stripe-style Change/Description/Endpoint tables (default);" + " list: endpoint-grouped bullets", + ) + parser.add_argument( + "--no-working-tree", action="store_true", + help="ignore uncommitted spec changes (only diff committed history)", + ) + parser.add_argument("--today", help="date label for working-tree changes (YYYY-MM-DD)") + parser.add_argument("--oasdiff-bin", default="oasdiff") + parser.add_argument( + "--max-entries", type=int, default=0, + help="cap the page at the newest N entries (0 = unlimited)", + ) + args = parser.parse_args(argv) + + cwd = repo_root() + spec_rel = Path(args.spec).resolve().relative_to(cwd).as_posix() + + snapshots = list_snapshots(spec_rel, cwd) + if not snapshots: + print(f"error: no git history for {spec_rel} (shallow clone?)", file=sys.stderr) + return 1 + + if not args.no_working_tree and working_tree_differs(spec_rel, cwd): + today = args.today or datetime.now(timezone.utc).strftime("%Y-%m-%d") + worktree = Snapshot(None, today) + if snapshots[0].date == today: + snapshots[0] = worktree # merge into one Update for the day + else: + snapshots.insert(0, worktree) + + entries = build_entries( + snapshots, + spec_rel, + lambda base, rev: run_oasdiff(base, rev, oasdiff_bin=args.oasdiff_bin, cwd=cwd), + lambda snapshot: load_spec(snapshot, spec_rel, cwd), + ) + if args.max_entries: + entries = entries[: args.max_entries] + + links = {} + if args.link_base: + links = endpoint_urls(yaml.safe_load(Path(args.spec).read_text()), args.link_base) + + content = render_page( + args.api_name, args.api_href, args.note, entries, links, args.style, args.rss_url + ) + output = Path(args.output) + if output.exists() and output.read_text() == content: + print(f"{output}: unchanged") + return 0 + output.write_text(content) + print(f"{output}: regenerated with {len(entries)} entries") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_api_nav.py b/scripts/generate_api_nav.py new file mode 100644 index 0000000000..3faddda800 --- /dev/null +++ b/scripts/generate_api_nav.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.9" +# dependencies = ["pyyaml"] +# +# [tool.uv] +# exclude-newer = "1 week" +# /// +# +# Rebuild the API tab's endpoint navigation in docs.json from an OpenAPI spec. +# +# Why this exists: Mintlify can autogenerate a nav group per tag from a spec, +# and that is what this replaces. Autogenerated groups cannot carry a `root` +# page, so clicking a service in the sidebar opens whichever endpoint happens +# to sort first rather than a page describing the service. `root` is only +# available on an explicitly declared group -- which means the endpoint list +# has to be enumerated, which means it has to be generated. +# +# One group per tag, in the spec's own order (sort_openapi_nav.py has already +# put that in display-name order): +# +# { +# "group": "Issues", <- x-displayName +# "root": "api-reference/v2/services/issues", <- landing page +# "openapi": "public_v2.openapi.yaml", +# "pages": ["GET /api/issues", ...] <- METHOD /path +# } +# +# `root` is emitted only when a landing page for that service actually exists +# on disk, so a tag with no page still gets a working group. +# +# Runs in update-openapi-specs.yml after the spec is fetched, so a newly +# published endpoint appears in the nav in the same PR that adds it. + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import sys +from typing import Optional + +import yaml + +HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head", "trace") + + +def service_groups(spec: dict, docs_root: pathlib.Path, spec_name: str, + landing_dir: str, directory: Optional[str] = None) -> list[dict]: + """One nav group per tag, each with its endpoints and its landing page.""" + display = {} + for tag in spec.get("tags") or []: + display[tag["name"]] = tag.get("x-displayName") or tag["name"] + + # tag -> ["GET /path", ...], in the order the spec lists paths + endpoints: dict[str, list[str]] = {} + for path, item in (spec.get("paths") or {}).items(): + if not isinstance(item, dict): + continue + for method, op in item.items(): + if method not in HTTP_METHODS or not isinstance(op, dict): + continue + tags = op.get("tags") or [] + if not tags: + continue + endpoints.setdefault(tags[0], []).append(f"{method.upper()} {path}") + + groups = [] + for tag, pages in endpoints.items(): + label = display.get(tag, tag) + # The object form carries `directory`, which is what keeps the + # generated endpoint pages under /api-reference//. Without it + # Mintlify serves them from /api-reference// instead, silently + # moving every endpoint URL and colliding v1 with v2. + source: object = ( + {"source": spec_name, "directory": directory} if directory else spec_name + ) + group: dict = {"group": label, "openapi": source} + # slug the display name the way the landing pages are named + slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-") + landing = f"{landing_dir}/{slug}" + if (docs_root / f"{landing}.mdx").exists(): + group["root"] = landing + group["pages"] = pages + groups.append(group) + return groups + + +def replace_groups(docs: dict, dropdown: str, groups: list[dict], + keep: tuple[str, ...], parent: str) -> dict: + """Swap the endpoint groups of one dropdown, keeping the named ones. + + The service groups are nested one level inside `parent` rather than sitting + at the dropdown's top level. A top-level group renders as a flat section + heading; only a nested one collapses, which is what the autogenerated + `Endpoints` group used to provide. 27 other groups in this docs.json nest + the same way. + """ + api = next(t for t in docs["navigation"]["tabs"] if t.get("tab") == "API") + dd = next(d for d in api["dropdowns"] if d["dropdown"] == dropdown) + dd["groups"] = [g for g in dd["groups"] if g.get("group") in keep] + [ + {"group": parent, "pages": groups} + ] + return docs + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Rebuild the API tab endpoint navigation from an OpenAPI spec." + ) + parser.add_argument("spec", help="path to the OpenAPI spec") + parser.add_argument("docs_json", help="path to docs.json") + parser.add_argument("--dropdown", required=True, help='e.g. "v2"') + parser.add_argument( + "--landing-dir", required=True, + help="docs-relative dir holding the service pages, e.g. api-reference/v2/services", + ) + parser.add_argument( + "--directory", + help="dir the generated endpoint pages live under, e.g. api-reference/v2; " + "omit and Mintlify serves them from /api-reference// instead", + ) + parser.add_argument( + "--parent", default="Services", + help="name of the collapsible group the service groups nest inside", + ) + parser.add_argument( + "--keep", default="Overview", + help="comma-separated groups to preserve ahead of the generated ones", + ) + args = parser.parse_args(argv) + + docs_path = pathlib.Path(args.docs_json) + docs_root = docs_path.parent + spec = yaml.safe_load(pathlib.Path(args.spec).read_text()) + + groups = service_groups( + spec, docs_root, pathlib.Path(args.spec).name, args.landing_dir, + args.directory, + ) + if not groups: + print(f"{args.spec}: no tagged operations, nav left alone") + return 0 + + docs = json.loads(docs_path.read_text()) + docs = replace_groups(docs, args.dropdown, groups, tuple( + g.strip() for g in args.keep.split(",") if g.strip() + ), args.parent) + docs_path.write_text(json.dumps(docs, indent=2, ensure_ascii=False) + "\n") + + rooted = sum(1 for g in groups if "root" in g) + total = sum(len(g["pages"]) for g in groups) + print(f"{docs_path}: {len(groups)} service groups, {rooted} with a landing page, " + f"{total} endpoints") + for g in groups: + mark = "" if "root" in g else " (no landing page)" + print(f" {len(g['pages']):4} {g['group']}{mark}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_openapi_redirects.py b/scripts/generate_openapi_redirects.py new file mode 100644 index 0000000000..72b239ccc5 --- /dev/null +++ b/scripts/generate_openapi_redirects.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.9" +# dependencies = ["pyyaml"] +# +# [tool.uv] +# exclude-newer = "1 week" +# /// +# +# Keep docs.json redirects in step with endpoints that change OpenAPI tag. +# +# Why this exists: Mintlify builds an endpoint's page URL from its first tag, +# so `/api-reference/v2//`. Regrouping the public API +# into its new services changes tags without touching a path, a request or a +# response -- but every affected page moves, and the old URL 404s for anyone +# who bookmarked it, linked it, or found it in search. +# +# The move is derivable rather than something anyone should hand-maintain: +# diff the committed spec against the working tree, compute both URLs with the +# same slug rules the changelog uses, and emit a redirect for each difference. +# Runs in update-openapi-specs.yml alongside the changelog regeneration, so a +# tag change and its redirect land in the same PR. +# +# Chains are collapsed rather than accumulated. If an endpoint moves A -> B and +# later B -> C, the existing A -> B entry is repointed to C instead of leaving +# a redirect to a URL that no longer resolves. +# +# Requires the spec to be tracked in git; the first commit that adds a spec has +# nothing to diff against and is a no-op. + +from __future__ import annotations + +import argparse +import json +import pathlib +import subprocess +import sys +from typing import Optional + +import yaml + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from generate_api_changelog import endpoint_urls # noqa: E402 + + +def committed_spec(spec_rel: str, cwd: pathlib.Path) -> Optional[dict]: + """The spec as of HEAD, or None if it is not tracked there yet.""" + out = subprocess.run( + ["git", "show", f"HEAD:{spec_rel}"], capture_output=True, text=True, cwd=cwd + ) + if out.returncode != 0: + return None + return yaml.safe_load(out.stdout) + + +def moved_endpoints(before: dict, after: dict, link_base: str) -> list[tuple[str, str]]: + """(old_url, new_url) for every operation whose page URL changed. + + Keyed by (method, path), which is the endpoint's identity: a tag change + moves the page but not the endpoint, so the key is stable across the move. + Endpoints added or removed outright are not redirects and are skipped. + """ + old_urls = endpoint_urls(before, link_base) + new_urls = endpoint_urls(after, link_base) + moves = [] + for key, old in sorted(old_urls.items()): + new = new_urls.get(key) + if new is not None and new != old: + moves.append((old, new)) + return moves + + +def apply_moves(docs_json: str, moves: list[tuple[str, str]]) -> tuple[str, int, int]: + """Patch the redirects array textually. Returns (text, added, repointed). + + Textual rather than a json round-trip: re-serialising docs.json reflows the + whole file and buries a two-line change in a whole-file diff. + """ + if not moves: + return docs_json, 0, 0 + + existing = {r["source"]: r["destination"] for r in json.loads(docs_json).get("redirects", [])} + added = repointed = 0 + new_entries = [] + + for old, new in moves: + # An earlier move already points somewhere; send it to the new home. + for source, destination in list(existing.items()): + if destination == old: + docs_json = docs_json.replace( + f'"source": "{source}",\n "destination": "{destination}"', + f'"source": "{source}",\n "destination": "{new}"', + ) + existing[source] = new + repointed += 1 + if old in existing: + continue # already redirected; the loop above kept it current + new_entries.append((old, new)) + existing[old] = new + added += 1 + + if new_entries: + block = ",\n".join( + f' {{\n "source": "{old}",\n "destination": "{new}"\n }}' + for old, new in new_entries + ) + marker = ' "redirects": [\n' + assert marker in docs_json, "redirects array not found in docs.json" + cut = docs_json.index(marker) + len(marker) + rest = docs_json[cut:] + # An empty array closes immediately, so the block must not end in a + # comma; otherwise it is separated from the entries that follow. + separator = "\n" if rest.lstrip().startswith("]") else ",\n" + docs_json = docs_json[:cut] + block + separator + rest + + return docs_json, added, repointed + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Add docs.json redirects for endpoints whose OpenAPI tag changed." + ) + parser.add_argument("spec", help="path to the OpenAPI spec (tracked in git)") + parser.add_argument("docs_json", help="path to docs.json") + parser.add_argument( + "--link-base", required=True, help="URL prefix of the generated endpoint pages" + ) + parser.add_argument( + "--dry-run", action="store_true", help="report the moves without writing" + ) + args = parser.parse_args(argv) + + root = pathlib.Path( + subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, check=True, + ).stdout.strip() + ) + spec_rel = pathlib.Path(args.spec).resolve().relative_to(root).as_posix() + + before = committed_spec(spec_rel, root) + if before is None: + print(f"{spec_rel}: not tracked at HEAD, nothing to diff") + return 0 + after = yaml.safe_load(pathlib.Path(args.spec).read_text()) + + moves = moved_endpoints(before, after, args.link_base) + if not moves: + print(f"{spec_rel}: no endpoint changed page URL") + return 0 + + for old, new in moves: + print(f" {old} -> {new}") + if args.dry_run: + print(f"{spec_rel}: {len(moves)} moved (dry run, nothing written)") + return 0 + + path = pathlib.Path(args.docs_json) + patched, added, repointed = apply_moves(path.read_text(), moves) + json.loads(patched) # fail loudly rather than write invalid JSON + path.write_text(patched) + print(f"{path}: {added} redirect(s) added, {repointed} repointed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_service_endpoints.py b/scripts/generate_service_endpoints.py new file mode 100644 index 0000000000..1dc0830901 --- /dev/null +++ b/scripts/generate_service_endpoints.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.9" +# dependencies = ["pyyaml"] +# +# [tool.uv] +# exclude-newer = "1 week" +# /// +# +# Generate the endpoint table each service landing page shows. +# +# Why this exists: a service page needs to list the endpoints in that service, +# linked to their reference pages, and that list changes every time an endpoint +# is published, renamed or regrouped. Hand-maintaining it guarantees it goes +# stale, and a hardcoded "no endpoints yet" placeholder is wrong the moment the +# first one lands. +# +# One snippet per service, imported by the landing page, so the prose stays +# hand-written and the table stays correct. Rendered from the same spec the +# reference pages are generated from, in the sync workflow, so the two cannot +# disagree. +# +# Endpoint URLs come from generate_api_changelog rather than a second copy of +# the slug rules -- those were reverse-engineered against the rendered site and +# two copies would drift into broken links. + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys +from typing import Optional + +import yaml + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from generate_api_changelog import ( # noqa: E402 + METHOD_BADGE_COLORS, + endpoint_urls, + escape_mdx, + mintlify_slug, +) + +HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head", "trace") + +HEADER = """{{/* Auto-generated by scripts/generate_service_endpoints.py -- do not edit by hand. */}} + +{body} +""" + +EMPTY = ( + "No endpoints are published in this service yet. They appear here as each one\n" + "is published, and every change is recorded in the\n" + "[changelog]({changelog})." +) + + +def first_sentence(text: str) -> str: + """The lead sentence of a description, for a one-line table cell. + + Descriptions run to several paragraphs and carry Markdown of their own; a + landing page wants the gist, with the endpoint page one click away. + """ + text = " ".join((text or "").split()) + if not text: + return "" + # ". " is not a sentence end inside e.g. "v1. 2" or an abbreviation we care + # about here, so accept the first period followed by a space and a capital. + m = re.search(r"\.(?=\s+[A-Z(])", text) + out = text[: m.start() + 1] if m else text + return out if out.endswith(".") or not m else out + + +def service_tables(spec: dict, link_base: str) -> dict[str, str]: + """display name -> the MDX table of that service's endpoints.""" + display = { + t["name"]: (t.get("x-displayName") or t["name"]) for t in (spec.get("tags") or []) + } + urls = endpoint_urls(spec, link_base) + + rows: dict[str, list[str]] = {} + for path, item in (spec.get("paths") or {}).items(): + if not isinstance(item, dict): + continue + for method, op in item.items(): + if method not in HTTP_METHODS or not isinstance(op, dict): + continue + tags = op.get("tags") or [] + if not tags: + continue + label = display.get(tags[0], tags[0]) + verb = method.upper() + colour = METHOD_BADGE_COLORS.get(verb, "gray") + title = op.get("summary") or f"{verb} {path}" + url = urls.get((verb, path)) + linked = f"[{escape_mdx(title)}]({url})" if url else escape_mdx(title) + desc = escape_mdx(first_sentence(op.get("description") or "")) + badge = f'{verb}' + rows.setdefault(label, []).append(f"| {badge} {linked} | {desc} |") + + out = {} + for label, body in rows.items(): + out[label] = "\n".join( + ["| Endpoint | Description |", "| --- | --- |", *body] + ) + return out + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Generate the endpoint table for each service landing page." + ) + parser.add_argument("spec", help="path to the OpenAPI spec") + parser.add_argument("out_dir", help="directory to write the snippets into") + parser.add_argument("--link-base", required=True, help="e.g. /api-reference/v2") + parser.add_argument( + "--changelog", required=True, help="changelog page path, for the empty state" + ) + parser.add_argument( + "--services-dir", required=True, + help="landing-page dir; one snippet is written per page found there", + ) + args = parser.parse_args(argv) + + spec = yaml.safe_load(pathlib.Path(args.spec).read_text()) + tables = service_tables(spec, args.link_base) + + # One snippet per landing page, not per spec tag. A tag with no landing + # page needs no snippet; a landing page whose service has no endpoints yet + # gets the empty state, which is generated rather than written into the + # page -- that is the thing that would otherwise go stale. + by_slug = {mintlify_slug(label): label for label in tables} + wanted = { + page.stem: by_slug.get(page.stem, page.stem) + for page in sorted(pathlib.Path(args.services_dir).glob("*.mdx")) + } + + out_dir = pathlib.Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + written = empty = 0 + for slug, label in sorted(wanted.items()): + body = tables.get(label) or EMPTY.format(changelog=args.changelog) + if label not in tables: + empty += 1 + path = out_dir / f"{slug}-endpoints.mdx" + content = HEADER.format(body=body) + if path.exists() and path.read_text() == content: + continue + path.write_text(content) + written += 1 + print(f"{out_dir}: {len(wanted)} snippets ({empty} with no endpoints), " + f"{written} written") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/fixtures/changelog_empty.json b/scripts/tests/fixtures/changelog_empty.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/scripts/tests/fixtures/changelog_empty.json @@ -0,0 +1 @@ +[] diff --git a/scripts/tests/fixtures/changelog_info_only.json b/scripts/tests/fixtures/changelog_info_only.json new file mode 100644 index 0000000000..4c54b5f051 --- /dev/null +++ b/scripts/tests/fixtures/changelog_info_only.json @@ -0,0 +1,12 @@ +[ + { + "id": "endpoint-added", + "text": "endpoint added", + "level": 1, + "operation": "POST", + "operationId": "createPolicy", + "path": "/api/v1/policies", + "section": "paths", + "fingerprint": "285df1289de3" + } +] diff --git a/scripts/tests/fixtures/changelog_mixed_levels.json b/scripts/tests/fixtures/changelog_mixed_levels.json new file mode 100644 index 0000000000..17c6295970 --- /dev/null +++ b/scripts/tests/fixtures/changelog_mixed_levels.json @@ -0,0 +1,66 @@ +[ + { + "id": "api-path-removed-without-deprecation", + "text": "api path removed without deprecation", + "level": 3, + "operation": "GET", + "operationId": "listAgents", + "path": "/api/v1/agents", + "section": "paths", + "fingerprint": "437410792ed8" + }, + { + "id": "response-required-property-removed", + "text": "removed the required property `tags` from the response with the `200` status", + "level": 3, + "operation": "GET", + "operationId": "listProjects", + "path": "/api/v1/deployments/{deployment_id}/projects", + "section": "paths", + "fingerprint": "5011e5fc6ce1" + }, + { + "id": "request-parameter-removed", + "text": "deleted the `query` request parameter `page_token`", + "level": 2, + "operation": "GET", + "operationId": "listProjects", + "path": "/api/v1/deployments/{deployment_id}/projects", + "section": "paths", + "fingerprint": "9f2b3c4d5e6a" + }, + { + "id": "api-major-version-not-bumped", + "text": "a breaking change was detected but the major version did not increase, from `1.0.0` to `1.1.0`", + "level": 1, + "section": "info", + "fingerprint": "12dd67d7a24c" + }, + { + "id": "new-optional-request-parameter", + "text": "added the new optional `query` request parameter `page_size`", + "level": 1, + "operation": "GET", + "operationId": "listProjects", + "path": "/api/v1/deployments/{deployment_id}/projects", + "section": "paths", + "fingerprint": "0b0c82b8f358" + }, + { + "id": "endpoint-added", + "text": "endpoint added", + "level": 1, + "operation": "POST", + "operationId": "createPolicy", + "path": "/api/v1/policies", + "section": "paths", + "fingerprint": "285df1289de3" + }, + { + "id": "api-schema-removed", + "text": "removed the schema `ProjectFilter`", + "level": 1, + "section": "components", + "fingerprint": "aa11bb22cc33" + } +] diff --git a/scripts/tests/test_generate_api_changelog.py b/scripts/tests/test_generate_api_changelog.py new file mode 100644 index 0000000000..12461270f9 --- /dev/null +++ b/scripts/tests/test_generate_api_changelog.py @@ -0,0 +1,1312 @@ +import json +import re +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +import generate_api_changelog as gac + +FIXTURES = Path(__file__).parent / "fixtures" + + +def load_fixture(name): + return json.loads((FIXTURES / name).read_text()) + + +# --- snapshot listing ------------------------------------------------------- + + +def test_list_snapshots_groups_by_date_keeping_newest_commit(monkeypatch): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + stdout = ( + "aaa111 2026-08-13\n" + "bbb222 2026-08-13\n" + "ccc333 2026-08-07\n" + "ddd444 2026-07-14\n" + ) + return subprocess.CompletedProcess(cmd, 0, stdout=stdout, stderr="") + + monkeypatch.setattr(gac.subprocess, "run", fake_run) + snapshots = gac.list_snapshots("docs/spec.yaml", Path("/repo")) + + # git log is newest-first; the first hash seen for a date is that day's + # final state. + assert snapshots == [ + gac.Snapshot("aaa111", "2026-08-13"), + gac.Snapshot("ccc333", "2026-08-07"), + gac.Snapshot("ddd444", "2026-07-14"), + ] + assert "--first-parent" in calls[0] + + +# --- date formatting -------------------------------------------------------- + + +def test_format_date_is_locale_independent(): + assert gac.format_date("2026-08-07") == "August 7, 2026" + assert gac.format_date("2026-12-25") == "December 25, 2026" + + +# --- MDX escaping ----------------------------------------------------------- + + +def test_escape_mdx_preserves_code_spans_and_escapes_outside(): + text = "removed the required property `tags` from the response with the `200` status" + assert gac.escape_mdx(text) == text + + assert ( + gac.escape_mdx("added {x} and around `{'keep'}`") + == "added {x} and <T> around `{'keep'}`" + ) + + +def test_escape_mdx_ampersand_first(): + assert gac.escape_mdx("a & b < c") == "a & b < c" + + +def test_escape_mdx_markdown_chars_outside_code_spans(): + assert gac.escape_mdx("a *b* [c] _d_") == r"a \*b\* \[c\] \_d\_" + + +def test_escape_mdx_unbalanced_backticks_escaped_literally(): + assert gac.escape_mdx("odd ` tick {x}") == "odd \\` tick {x}" + + +# --- endpoint page URLs ------------------------------------------------------ +# +# Slug rules verified empirically against Mintlify-generated pages (all 243 +# operations of both specs resolved with HTTP 200 on 2026-08-13). + + +def test_mintlify_slug_rules(): + assert gac.mintlify_slug("Create Fix Job") == "create-fix-job" + assert gac.mintlify_slug("AiFixJobsService") == "aifixjobsservice" + # apostrophes are stripped, not hyphenated + assert gac.mintlify_slug("Get a Project's Managed Scan Settings") == "get-a-projects-managed-scan-settings" + # commas collapse into the space hyphen; existing hyphens survive + assert ( + gac.mintlify_slug("List Code, Supply Chain, or AI-powered Scan findings") + == "list-code-supply-chain-or-ai-powered-scan-findings" + ) + # underscores and square brackets are preserved + assert gac.mintlify_slug("Get x managed_scan_settings") == "get-x-managed_scan_settings" + assert ( + gac.mintlify_slug("[Beta] Get SMS VPC Bootstrap CloudFormation Template") + == "[beta]-get-sms-vpc-bootstrap-cloudformation-template" + ) + + +LINKS_SPEC = { + "paths": { + "/api/v1/policies": { + "post": {"tags": ["PoliciesService"], "summary": "Create Policy"}, + "parameters": [{"name": "x", "in": "query"}], # non-method key ignored + }, + "/api/agent/deployments/{deploymentId}/ignores": { + # no summary: page title is derived from method + path, with + # parameter segments becoming word breaks + "get": {"tags": ["IgnoresService"]}, + }, + "/api/v1/untagged": {"get": {"summary": "No Tag"}}, + } +} + + +def test_endpoint_urls_from_spec(): + urls = gac.endpoint_urls(LINKS_SPEC, "/api-reference/v2") + assert urls[("POST", "/api/v1/policies")] == "/api-reference/v2/policiesservice/create-policy" + assert urls[("GET", "/api/agent/deployments/{deploymentId}/ignores")] == ( + "/api-reference/v2/ignoresservice/get-apiagentdeployments-ignores" + ) + assert ("GET", "/api/v1/untagged") not in urls # no tag -> no page URL + + +def test_endpoint_urls_percent_encode_brackets(): + spec = {"paths": {"/api/v1/bootstrap-sms-vpc": {"get": { + "tags": ["MiscService"], + "summary": "[Beta] Get SMS VPC Bootstrap CloudFormation Template", + }}}} + urls = gac.endpoint_urls(spec, "/api-reference/v1") + assert urls[("GET", "/api/v1/bootstrap-sms-vpc")] == ( + "/api-reference/v1/miscservice/%5Bbeta%5D-get-sms-vpc-bootstrap-cloudformation-template" + ) + + +# --- coalescing repetitive changes ------------------------------------------ + + +def enum_change(value, prop="errors/items/errorType", status="200", **overrides): + change = { + "id": "response-property-enum-value-added", + "text": ( + f"added the new `{value}` enum value to the `{prop}` response " + f"property for the response status `{status}`" + ), + "level": 2, + "operation": "POST", + "path": "/api/v1/jobs", + "section": "paths", + } + change.update(overrides) + return change + + +def test_coalesce_merges_enum_values_for_same_property(): + merged = gac.coalesce_changes( + [enum_change("TYPE_B"), enum_change("TYPE_A"), enum_change("TYPE_C")] + ) + assert len(merged) == 1 + assert merged[0]["text"] == ( + "added the new `TYPE_A`, `TYPE_B`, `TYPE_C` enum values to the " + "`errors/items/errorType` response property for the response status `200`" + ) + assert merged[0]["level"] == 2 + + +def test_coalesce_keeps_distinct_properties_apart(): + merged = gac.coalesce_changes( + [enum_change("A"), enum_change("A", prop="other/prop")] + ) + assert len(merged) == 2 + + +def test_coalesce_keeps_distinct_endpoints_apart(): + merged = gac.coalesce_changes( + [enum_change("A"), enum_change("A", path="/api/v1/other")] + ) + assert len(merged) == 2 + + +def test_coalesce_singletons_and_unknown_ids_stay_verbatim(): + single = enum_change("ONLY_ONE") + unknown = { + "id": "endpoint-added", "text": "endpoint added", "level": 1, + "operation": "POST", "path": "/api/v1/policies", "section": "paths", + } + merged = gac.coalesce_changes([single, unknown]) + assert merged == [single, unknown] + + +def test_coalesce_format_changes_by_old_new_pair(): + def fmt(prop, old="uint64"): + return { + "id": "request-property-type-changed", + "text": f"the `{prop}` request property `format` changed from `{old}` to `int64`", + "level": 3, + "operation": "POST", + "path": "/api/v1/deps", + "section": "paths", + } + + merged = gac.coalesce_changes([fmt("cursor"), fmt("deploymentId"), fmt("pageSize", old="uint32")]) + texts = sorted(c["text"] for c in merged) + assert texts == [ + "the `format` of the request properties `cursor`, `deploymentId` changed from `uint64` to `int64`", + "the `pageSize` request property `format` changed from `uint32` to `int64`", + ] + + +def test_coalesce_unmatched_text_stays_verbatim(): + odd = enum_change("X") + odd["text"] = "some future oasdiff wording we do not recognize" + assert gac.coalesce_changes([odd, enum_change("Y")]) == [odd, enum_change("Y")] + + +# --- entry building --------------------------------------------------------- + + +def snapshots_for(*pairs): + """pairs of (ref, date), newest first.""" + return [gac.Snapshot(ref, date) for ref, date in pairs] + + +def test_build_entries_diffs_consecutive_snapshots_newest_first(): + snaps = snapshots_for( + ("ccc", "2026-08-07"), ("bbb", "2026-07-23"), ("aaa", "2026-07-14") + ) + diffs = { + ("aaa:spec.yaml", "bbb:spec.yaml"): load_fixture("changelog_info_only.json"), + ("bbb:spec.yaml", "ccc:spec.yaml"): load_fixture("changelog_mixed_levels.json"), + } + entries = gac.build_entries(snaps, "spec.yaml", lambda b, r: diffs[(b, r)]) + + assert [e.date for e in entries] == ["2026-08-07", "2026-07-23"] + # oldest snapshot is the baseline: no entry for 2026-07-14 + + +def test_build_entries_skips_empty_diffs_without_breaking_chain(): + snaps = snapshots_for( + ("ccc", "2026-08-07"), ("bbb", "2026-07-23"), ("aaa", "2026-07-14") + ) + seen = [] + + def diff(base, rev): + seen.append((base, rev)) + if rev == "bbb:spec.yaml": + return [] # e.g. the nav-sort line permutation + return load_fixture("changelog_info_only.json") + + entries = gac.build_entries(snaps, "spec.yaml", diff) + + assert [e.date for e in entries] == ["2026-08-07"] + # bbb produced no entry but still becomes the next comparison base + assert seen == [("aaa:spec.yaml", "bbb:spec.yaml"), ("bbb:spec.yaml", "ccc:spec.yaml")] + + +def test_build_entries_filters_excluded_sections(): + snaps = snapshots_for(("bbb", "2026-08-07"), ("aaa", "2026-07-14")) + version_noise = [c for c in load_fixture("changelog_mixed_levels.json") if c["section"] == "info"] + assert version_noise, "fixture must contain an info-section change" + + entries = gac.build_entries(snaps, "spec.yaml", lambda b, r: version_noise) + + assert entries == [] # a day with only excluded changes gets no entry + + +def test_apply_level_override_demotes_response_enum_additions(): + change = {"id": "response-property-enum-value-added", "level": 2, "text": "added"} + + assert gac.apply_level_override(change)["level"] == 1 + assert change["level"] == 2 # override does not mutate its input + + +def test_apply_level_override_leaves_other_checks_alone(): + for check_id, level in ( + ("api-path-removed-without-deprecation", 3), + ("request-parameter-removed", 2), + ("endpoint-added", 1), + ): + change = {"id": check_id, "level": level} + assert gac.apply_level_override(change) is change + + +def test_build_entries_demotes_response_enum_additions_out_of_breaking_sections(): + snaps = snapshots_for(("bbb", "2026-08-07"), ("aaa", "2026-07-14")) + enum_addition = [ + { + "id": "response-property-enum-value-added", + "level": 2, + "section": "paths", + "operation": "GET", + "path": "/api/v1/deployments", + "text": "added the new `X` enum value to the `status` response property", + } + ] + + entries = gac.build_entries(snaps, "spec.yaml", lambda b, r: enum_addition) + + assert [c["level"] for c in entries[0].changes] == [1] + + +@pytest.mark.parametrize( + "raw,expected", + [ + # the reported case: a described $ref wrapped in a single-element allOf + ( + "filters/allOf[#/components/schemas/protos.projects.v1.RepoFilters]/latestScanStatus", + "filters.latestScanStatus", + ), + # array elements fold into the parent, allOf drops out mid-path + ( + "automations/items/filters/allOf[#/components/schemas/protos.automations.v1.Filters]" + "/conditions/items/type", + "automations[].filters.conditions[].type", + ), + # an array of scalars leaves a trailing `items` that would dangle + ("dependencyFilter/repositoryId/items/", "dependencyFilter.repositoryId[]"), + ("errors/items/errorType", "errors[].errorType"), + # snake_case fields keep their spelling -- the endpoint accepts that key + ("scans/items/is_partial_scan", "scans[].is_partial_scan"), + # already clean, and non-paths + ("filters/latestScanStatus", "filters.latestScanStatus"), + ("latestScanStatus", "latestScanStatus"), + ], +) +def test_humanize_property_path(raw, expected): + assert gac.humanize_property_path(raw) == expected + + +def test_humanize_change_text_rewrites_only_code_spans(): + change = { + "text": "added the new optional request property " + "`filters/allOf[#/components/schemas/protos.projects.v1.RepoFilters]/latestScanStatus`", + } + + assert gac.humanize_change_text(change)["text"] == ( + "added the new optional request property `filters.latestScanStatus`" + ) + assert "allOf" in change["text"] # input not mutated + + +def test_humanize_change_text_leaves_url_paths_and_plain_spans_alone(): + for text in ( + "removed the endpoint `/api/v1/deployments`", # leading slash: a URL + "the response status `200` was added", + "added the new `SCAN_STATUS_CANCELLED` enum value", + "unbalanced ` backtick with a/b path", + ): + assert gac.humanize_change_text({"text": text})["text"] == text + + +def test_humanize_runs_before_coalescing_so_paths_merge(): + changes = [ + { + "id": "response-property-enum-value-added", + "level": 1, + "operation": "GET", + "path": "/x", + "text": "added the new `A` enum value to the " + "`f/allOf[#/components/schemas/protos.a.v1.T]/g` response property" + " for the response status `200`", + }, + { + "id": "response-property-enum-value-added", + "level": 1, + "operation": "GET", + "path": "/x", + "text": "added the new `B` enum value to the `f/g` response property" + " for the response status `200`", + }, + ] + + merged = gac.coalesce_changes([gac.humanize_change_text(c) for c in changes]) + + assert len(merged) == 1 # identical paths after humanizing, so they fold + assert "`A`, `B`" in merged[0]["text"] + assert "allOf" not in merged[0]["text"] + + +def test_build_entries_drops_tag_only_changes(): + """Regrouping endpoints into new services must not reach subscribers. + + oasdiff emits api-tag-added + api-tag-removed per re-tagged operation, so + the reorganisation would otherwise land as hundreds of rows on one date -- + a new , which publishes to RSS. + """ + snaps = snapshots_for(("bbb", "2026-08-07"), ("aaa", "2026-07-14")) + retag = [ + {"id": "api-tag-removed", "level": 1, "section": "paths", + "operation": "GET", "path": "/api/agent/identity", + "text": "api tag `MiscService` removed"}, + {"id": "api-tag-added", "level": 1, "section": "paths", + "operation": "GET", "path": "/api/agent/identity", + "text": "api tag `Deployments` added"}, + ] + + entries = gac.build_entries(snaps, "spec.yaml", lambda b, r: retag) + + assert entries == [] # a day of nothing but re-tagging gets no entry at all + + +def test_build_entries_keeps_real_changes_shipped_alongside_a_retag(): + snaps = snapshots_for(("bbb", "2026-08-07"), ("aaa", "2026-07-14")) + mixed = [ + {"id": "api-tag-added", "level": 1, "section": "paths", + "operation": "GET", "path": "/api/agent/identity", + "text": "api tag `Deployments` added"}, + {"id": "endpoint-added", "level": 1, "section": "paths", + "operation": "POST", "path": "/api/v2/things", + "text": "endpoint added"}, + ] + + entries = gac.build_entries(snaps, "spec.yaml", lambda b, r: mixed) + + assert [c["id"] for c in entries[0].changes] == ["endpoint-added"] + + +def _shared_type_change(op, path): + """The same change surfaced on a different endpoint: identical wording.""" + return { + "id": "response-property-enum-value-added", "level": 1, "section": "paths", + "operation": op, "path": path, + "text": "added the new `X` enum value to the `s` response property", + } + + +def test_merge_across_endpoints_folds_one_change_surfaced_on_many(): + merged = gac.merge_across_endpoints( + [_shared_type_change("GET", "/a"), _shared_type_change("POST", "/b")] + ) + + assert len(merged) == 1 + assert merged[0]["endpoints"] == [("GET", "/a"), ("POST", "/b")] + + +def test_merge_across_endpoints_keeps_differently_worded_changes_apart(): + a = _shared_type_change("GET", "/a") + b = {**_shared_type_change("GET", "/b"), "text": "a different sentence"} + + assert len(gac.merge_across_endpoints([a, b])) == 2 + + +def test_merge_across_endpoints_never_folds_endpoint_scoped_changes(): + """Two endpoints added on one day both read "endpoint added"; folding them + would hide the endpoints behind "and N more", which is all those rows say.""" + added = [ + {"id": "endpoint-added", "level": 1, "section": "paths", + "operation": m, "path": p, "text": "endpoint added"} + for m, p in (("GET", "/a"), ("POST", "/b")) + ] + + merged = gac.merge_across_endpoints(added) + + assert len(merged) == 2 + assert all("endpoints" not in c for c in merged) + + +def test_truncated_endpoint_cell_names_the_hidden_ones_in_a_tooltip(): + change = { + **_shared_type_change("GET", "/a"), + "endpoints": [("GET", f"/e{i}") for i in range(6)], + } + + cell = gac._endpoints_cell(change, links={}) + + assert cell.count("