imbi-api 2.26.0__tar.gz → 2.26.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {imbi_api-2.26.0 → imbi_api-2.26.2}/PKG-INFO +2 -2
- {imbi_api-2.26.0 → imbi_api-2.26.2}/pyproject.toml +2 -2
- imbi_api-2.26.2/src/imbi/api/component_facts.py +348 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/drift.py +232 -21
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/_helpers.py +39 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/components.py +162 -226
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/project_deployments.py +1 -1
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/project_lifecycle.py +8 -4
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/projects.py +249 -35
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/releases.py +1 -1
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/search.py +14 -10
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/maintenance/operations.py +50 -7
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/plugins/lifecycle_dispatch.py +75 -6
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/sbom.py +46 -65
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_components.py +380 -105
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_releases.py +89 -33
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_search.py +30 -6
- imbi_api-2.26.2/tests/test_component_facts.py +161 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_drift.py +247 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_lifecycle_dispatch.py +182 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_maintenance_operations.py +169 -12
- imbi_api-2.26.2/tests/test_projects_drift_ranges.py +172 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/.gitignore +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/README.md +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/moon.yml +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/README.md +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/app.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/authorization_codes.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/internal_services.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/local_auth.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/login_providers.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/membership.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/models.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/oauth.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/oauth_clients.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/password.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/permissions.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/principals.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/seed.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/sessions.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/tokens.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/auth/totp.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/backfill_embeddings.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/blueprint_attributes.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/blueprint_compliance.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/commit_sync/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/commit_sync/queue.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/commit_sync/service.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/deployment_migration.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/deployment_sweeper.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/deployment_sync/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/deployment_sync/queue.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/deployment_sync/service.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/documents/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/documents/read_sweeper.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/domain/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/domain/models.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/domain/scoring.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/email/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/email/client.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/email/dependencies.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/email/models.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/email/templates/base.html +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/email/templates/base.txt +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/email/templates/password_reset.html +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/email/templates/password_reset.txt +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/email/templates/welcome.html +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/email/templates/welcome.txt +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/email/templates.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/_credentials.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/_document_events.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/_document_history.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/_document_reads.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/_document_scope.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/_json_fields.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/_pagination.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/_request_urls.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/_search_index.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/admin.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/admin_plugins.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/api_keys.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/auth.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/auth_providers.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/blueprints.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/client_credentials.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/comments.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/dashboard.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/document_analytics.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/document_likes.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/document_presence.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/document_templates.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/document_versions.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/documents.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/environments.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/events.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/graph_query.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/integrations.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/link_definitions.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/local_auth.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/maintenance.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/mcp_servers.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/mfa.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/oauth_metadata.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/operations_log.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/organizations.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/plugin_edges.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/plugin_entities.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/plugins.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/project_analysis.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/project_commit_sync.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/project_configuration.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/project_incidents.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/project_integrations.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/project_logs.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/project_plugins.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/project_pr_sync.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/project_types.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/pull_requests.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/roles.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/sa_api_keys.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/scoring.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/scoring_policies.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/service_accounts.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/status.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/tags.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/teams.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/uploads.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/user_activity.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/users.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/endpoints/webhooks.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/entrypoint.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/graph_sql.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/identity/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/identity/attribution.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/identity/endpoints.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/identity/errors.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/identity/flows.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/identity/host_integration.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/identity/models.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/identity/repository.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/identity/resolution.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/identity/state.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/identity/sweeper.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/lifespans.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/llm/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/llm/dependencies.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/maintenance/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/maintenance/log.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/maintenance/registry.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/maintenance/state.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/maintenance/worker.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/mcp_test.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/middleware/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/middleware/rate_limit.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/models.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/openapi.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/plugins/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/plugins/assignment_writer.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/plugins/assignments.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/plugins/credentials.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/plugins/installer.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/plugins/lifecycle.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/plugins/reload.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/plugins/resolution.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/plugins/schemas.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/pr_sync/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/pr_sync/queue.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/pr_sync/service.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/prompts/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/prompts/release_notes_system.md +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/py.typed +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/relationships.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/release_promote/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/release_promote/queue.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/release_promote/service.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/sbom_backfill.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/scoring/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/scoring/queue.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/settings.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/storage/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/storage/client.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/storage/dependencies.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/storage/thumbnails.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/src/imbi/api/storage/validation.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/test_authentication.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/test_authorization.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/test_encryption.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/test_internal_services.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/test_login_providers.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/test_membership.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/test_oauth.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/test_oauth_server.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/test_permissions.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/test_seed.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/test_sessions.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/auth/test_totp.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/clickhouse/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/clickhouse/test_client.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/clickhouse/test_init.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/clickhouse/test_privacy.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/email/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/email/test_client.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/email/test_init.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/email/test_integration.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/email/test_models.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/email/test_templates.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_admin.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_admin_plugins.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_api_keys.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_auth.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_auth_providers.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_blueprints.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_client_credentials.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_comments.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_credentials.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_dashboard.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_document_analytics.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_document_likes.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_document_presence.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_document_reads.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_document_reads_sql.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_document_templates.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_document_versions.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_documents.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_environments.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_events.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_graph_query.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_init.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_integrations.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_json_fields.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_link_definitions.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_local_auth.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_maintenance.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_mcp_servers.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_mfa.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_operations_log.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_organizations.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_pagination.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_plugin_label_validation.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_plugins.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_project_analysis.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_project_commit_sync.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_project_configuration.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_project_deployments.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_project_incidents.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_project_integrations.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_project_lifecycle.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_project_logs.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_project_plugins.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_project_response_surfacing.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_project_types.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_projects.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_projects_helpers.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_pull_requests.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_request_urls.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_roles.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_sa_api_keys.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_scoring.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_scoring_policies.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_search_index.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_service_accounts.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_service_writeback.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_status.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_tags.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_teams.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_uploads.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_user_activity.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_users.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/endpoints/test_webhooks.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/fixtures/sbom/npm-realistic.json +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/fixtures/sbom/pypi-realistic.json +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/fixtures/sbom/tiny.json +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/identity/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/identity/test_endpoints.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/identity/test_errors.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/identity/test_flows.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/identity/test_host_integration.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/identity/test_repository.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/identity/test_resolution.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/identity/test_state.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/identity/test_sweeper.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/middleware/test_rate_limit.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/storage/__init__.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/storage/test_client.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/storage/test_init.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/storage/test_thumbnails.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/storage/test_validation.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/support.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_app.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_assignment_writer.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_backfill_embeddings.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_blueprint_attributes.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_blueprint_compliance.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_blueprints.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_commit_sync_queue.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_commit_sync_service.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_deployment_migration.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_deployment_sweeper.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_deployment_sync_queue.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_deployment_sync_service.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_entrypoint.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_graph_sql.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_identity_attribution.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_init.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_installer.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_lifespans.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_maintenance_log.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_maintenance_state.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_maintenance_worker.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_models.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_openapi.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_plugin_schemas.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_plugins.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_plugins_assignments.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_plugins_credentials.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_pr_sync_queue.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_pr_sync_service.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_release_promote_service.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_sbom.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_sbom_backfill.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_scoring_queue.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_scoring_triggers.py +0 -0
- {imbi_api-2.26.0 → imbi_api-2.26.2}/tests/test_settings.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: imbi-api
|
|
3
|
-
Version: 2.26.
|
|
3
|
+
Version: 2.26.2
|
|
4
4
|
Summary: Imbi is a DevOps Service Management Platform designed to provide an efficient way to manage a large environment that contains many services and applications.
|
|
5
5
|
Author-email: Alex Campbell <alexc@aweber.com>, Dave Shawley <daves@aweber.com>, "Gavin M. Roy" <gavinr@aweber.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -19,7 +19,7 @@ Requires-Dist: cyclonedx-python-lib<12,>=11.7
|
|
|
19
19
|
Requires-Dist: fastapi
|
|
20
20
|
Requires-Dist: filetype
|
|
21
21
|
Requires-Dist: httpx<1,>=0.28.1
|
|
22
|
-
Requires-Dist: imbi-common[databases,llm,sentry,server]==2.26.
|
|
22
|
+
Requires-Dist: imbi-common[databases,llm,sentry,server]==2.26.2
|
|
23
23
|
Requires-Dist: jinja2
|
|
24
24
|
Requires-Dist: jsonpatch>=1.33
|
|
25
25
|
Requires-Dist: mcp>=1.26.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "imbi-api"
|
|
3
|
-
version = "2.26.
|
|
3
|
+
version = "2.26.2"
|
|
4
4
|
description = "Imbi is a DevOps Service Management Platform designed to provide an efficient way to manage a large environment that contains many services and applications."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -28,7 +28,7 @@ dependencies = [
|
|
|
28
28
|
"fastapi",
|
|
29
29
|
"filetype",
|
|
30
30
|
"httpx>=0.28.1,<1",
|
|
31
|
-
"imbi-common[databases,llm,sentry,server]==2.26.
|
|
31
|
+
"imbi-common[databases,llm,sentry,server]==2.26.2",
|
|
32
32
|
"jinja2",
|
|
33
33
|
"jsonpatch>=1.33",
|
|
34
34
|
"mcp>=1.26.0",
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Read SBoM component usage facts from ClickHouse.
|
|
2
|
+
|
|
3
|
+
Phase three of the SBoM component migration. Every read that used to
|
|
4
|
+
answer "which components does this release use" by traversing
|
|
5
|
+
``USES_COMPONENT_RELEASE`` asks this module instead. The edge's fan-in
|
|
6
|
+
is what made those reads unusable -- counting the label outright fails
|
|
7
|
+
with ``could not resize shared memory segment`` -- and no amount of
|
|
8
|
+
Cypher rewriting fixes an unbounded incoming traversal.
|
|
9
|
+
|
|
10
|
+
What stays in the graph is identity and governance: the ``Component``
|
|
11
|
+
and ``ComponentRelease`` nodes, their statuses, advisories, notes, and
|
|
12
|
+
the project/team/environment topology. What moves here is the usage
|
|
13
|
+
fact, which is a per-release immutable snapshot and was never a good
|
|
14
|
+
fit for a graph edge.
|
|
15
|
+
|
|
16
|
+
Two graph primitives feed these reads and they are not interchangeable:
|
|
17
|
+
|
|
18
|
+
- The **org project set** -- non-archived project ids of an
|
|
19
|
+
organization. Hundreds of ids, no fan-in. It scopes authorization,
|
|
20
|
+
search, and the catalog totals, and it deliberately spans every
|
|
21
|
+
release a project has ever had.
|
|
22
|
+
- The **deployment pointer set** -- the releases that
|
|
23
|
+
``(:Project)-[d:DEPLOYED_IN]->(:Environment)`` currently points at
|
|
24
|
+
via ``d.current_release``. Around a thousand ids. It scopes the
|
|
25
|
+
reports, which describe what is running now.
|
|
26
|
+
|
|
27
|
+
Substituting one for the other silently changes what a screen means, so
|
|
28
|
+
every function here takes the ids it needs rather than deriving them.
|
|
29
|
+
|
|
30
|
+
Every read resolves each release to exactly one batch before reading
|
|
31
|
+
its rows, using ``argMax(batch_id, (source = 'ingest', recorded_at))``.
|
|
32
|
+
``source`` leads the tuple deliberately: an ingest batch outranks a
|
|
33
|
+
backfill one whatever order the two land in, so a backfill running
|
|
34
|
+
beside live traffic can never displace a real SBoM. Reading
|
|
35
|
+
``release_components`` without that join reads every snapshot a release
|
|
36
|
+
has ever had, superseded ones included.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import typing
|
|
42
|
+
|
|
43
|
+
from imbi.common import clickhouse, graph
|
|
44
|
+
|
|
45
|
+
#: Non-archived project ids of an organization -- the org project set.
|
|
46
|
+
#: Followed outward from the organization, so it is bounded by the
|
|
47
|
+
#: number of projects rather than by the component catalog.
|
|
48
|
+
ORG_PROJECT_IDS: typing.LiteralString = """
|
|
49
|
+
MATCH (:Organization {{slug: {org_slug}}})<-[:BELONGS_TO]-(:Team)
|
|
50
|
+
<-[:OWNED_BY]-(p:Project)
|
|
51
|
+
WHERE coalesce(p.archived, false) = false
|
|
52
|
+
RETURN p.id AS project_id
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
#: The deployment pointer set, with the topology each report row needs.
|
|
56
|
+
#: ``d.current_release`` is a property lookup, not an event replay, so
|
|
57
|
+
#: this stays cheap however long a project's deployment history is.
|
|
58
|
+
ORG_DEPLOYED_RELEASES: typing.LiteralString = """
|
|
59
|
+
MATCH (p:Project)-[d:DEPLOYED_IN]->(e:Environment)
|
|
60
|
+
MATCH (p)-[:OWNED_BY]->(t:Team)-[:BELONGS_TO]->
|
|
61
|
+
(:Organization {{slug: {org_slug}}})
|
|
62
|
+
WHERE coalesce(p.archived, false) = false
|
|
63
|
+
OPTIONAL MATCH (p)-[:TYPE]->(pt:ProjectType)
|
|
64
|
+
RETURN d.current_release AS release_id,
|
|
65
|
+
p.id AS project_id,
|
|
66
|
+
p.name AS project_name,
|
|
67
|
+
p.slug AS project_slug,
|
|
68
|
+
t.name AS team_name,
|
|
69
|
+
t.slug AS team_slug,
|
|
70
|
+
e.name AS environment_name,
|
|
71
|
+
e.slug AS environment_slug,
|
|
72
|
+
e.label_color AS environment_color,
|
|
73
|
+
collect(DISTINCT pt.name) AS project_types
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
#: Resolve each release of a set of projects to its winning batch.
|
|
77
|
+
_BATCHES_BY_PROJECT = (
|
|
78
|
+
'SELECT release_id,'
|
|
79
|
+
" argMax(batch_id, (source = 'ingest', recorded_at)) AS batch_id"
|
|
80
|
+
' FROM imbi.release_component_batches'
|
|
81
|
+
' WHERE project_id IN {project_ids:Array(String)}'
|
|
82
|
+
' GROUP BY release_id'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
#: The same resolution anchored on releases. Used by the reports, whose
|
|
86
|
+
#: scope is the deployment pointer set rather than whole projects.
|
|
87
|
+
_BATCHES_BY_RELEASE = (
|
|
88
|
+
'SELECT release_id,'
|
|
89
|
+
" argMax(batch_id, (source = 'ingest', recorded_at)) AS batch_id"
|
|
90
|
+
' FROM imbi.release_component_batches'
|
|
91
|
+
' WHERE release_id IN {release_ids:Array(String)}'
|
|
92
|
+
' GROUP BY release_id'
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
_JOIN = (
|
|
96
|
+
' INNER JOIN ({batches}) AS b'
|
|
97
|
+
' ON c.release_id = b.release_id AND c.batch_id = b.batch_id'
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _by_project(select: str, where: str = '') -> str:
|
|
102
|
+
"""Compose a project-scoped read against the winning batches."""
|
|
103
|
+
return (
|
|
104
|
+
f'{select} FROM imbi.release_components AS c'
|
|
105
|
+
+ _JOIN.format(batches=_BATCHES_BY_PROJECT)
|
|
106
|
+
+ ' WHERE c.project_id IN {project_ids:Array(String)}'
|
|
107
|
+
+ where
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _by_release(select: str, where: str = '') -> str:
|
|
112
|
+
"""Compose a release-scoped read against the winning batches."""
|
|
113
|
+
return (
|
|
114
|
+
f'{select} FROM imbi.release_components AS c'
|
|
115
|
+
+ _JOIN.format(batches=_BATCHES_BY_RELEASE)
|
|
116
|
+
+ ' WHERE c.release_id IN {release_ids:Array(String)}'
|
|
117
|
+
+ where
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def org_project_ids(db: graph.Graph, org_slug: str) -> list[str]:
|
|
122
|
+
"""Return the org project set, or an empty list for an unknown org."""
|
|
123
|
+
rows = await db.execute(
|
|
124
|
+
ORG_PROJECT_IDS, {'org_slug': org_slug}, ['project_id']
|
|
125
|
+
)
|
|
126
|
+
# AGE hands back JSON-encoded scalars; ``str()`` on one of those
|
|
127
|
+
# yields a quoted id that matches nothing in ClickHouse.
|
|
128
|
+
decoded = (graph.parse_agtype(row.get('project_id')) for row in rows)
|
|
129
|
+
return [str(value) for value in decoded if value]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def component_ids_in_org(
|
|
133
|
+
project_ids: list[str],
|
|
134
|
+
component_ids: list[str] | None = None,
|
|
135
|
+
) -> set[str]:
|
|
136
|
+
"""Which component ids the org's projects currently depend on.
|
|
137
|
+
|
|
138
|
+
Feeds the global search org scope, which needs the whole set, and
|
|
139
|
+
package search, which needs only the subset its name match already
|
|
140
|
+
produced. Passing *component_ids* narrows the aggregate to those,
|
|
141
|
+
so a keystroke intersects against its own candidates rather than
|
|
142
|
+
materialising the org's entire catalog to discard most of it.
|
|
143
|
+
|
|
144
|
+
Unlike the traversal this replaces, the cost either way is one
|
|
145
|
+
aggregate over the org's own rows rather than a fan-in from every
|
|
146
|
+
``ComponentRelease`` in the graph.
|
|
147
|
+
"""
|
|
148
|
+
if not project_ids or component_ids == []:
|
|
149
|
+
return set()
|
|
150
|
+
where = ''
|
|
151
|
+
params: dict[str, typing.Any] = {'project_ids': project_ids}
|
|
152
|
+
if component_ids is not None:
|
|
153
|
+
where = ' AND c.component_id IN {component_ids:Array(String)}'
|
|
154
|
+
params['component_ids'] = component_ids
|
|
155
|
+
rows = await clickhouse.query(
|
|
156
|
+
_by_project('SELECT DISTINCT c.component_id AS component_id', where),
|
|
157
|
+
params,
|
|
158
|
+
)
|
|
159
|
+
return {str(row['component_id']) for row in rows}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def component_in_org(project_ids: list[str], component_id: str) -> bool:
|
|
163
|
+
"""Whether any of ``project_ids`` depends on ``component_id``.
|
|
164
|
+
|
|
165
|
+
``component_id`` leads ``release_components``' sort key, so this is
|
|
166
|
+
a prefix seek rather than a scan however large the table gets.
|
|
167
|
+
"""
|
|
168
|
+
if not project_ids:
|
|
169
|
+
return False
|
|
170
|
+
rows = await clickhouse.query(
|
|
171
|
+
_by_project(
|
|
172
|
+
'SELECT 1 AS hit',
|
|
173
|
+
' AND c.component_id = {component_id:String} LIMIT 1',
|
|
174
|
+
),
|
|
175
|
+
{'project_ids': project_ids, 'component_id': component_id},
|
|
176
|
+
)
|
|
177
|
+
return bool(rows)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def component_release_in_org(
|
|
181
|
+
project_ids: list[str],
|
|
182
|
+
component_id: str,
|
|
183
|
+
component_release_id: str,
|
|
184
|
+
) -> bool:
|
|
185
|
+
"""Whether any of ``project_ids`` depends on that specific version.
|
|
186
|
+
|
|
187
|
+
Takes the owning ``component_id`` as well as the version's own id
|
|
188
|
+
purely to keep the sort-key prefix seek: ``component_release_id``
|
|
189
|
+
is the fourth key column, so filtering on it alone scans. The
|
|
190
|
+
caller already has the parent from the graph, where one
|
|
191
|
+
``HAS_RELEASE`` hop resolves it.
|
|
192
|
+
"""
|
|
193
|
+
if not project_ids:
|
|
194
|
+
return False
|
|
195
|
+
rows = await clickhouse.query(
|
|
196
|
+
_by_project(
|
|
197
|
+
'SELECT 1 AS hit',
|
|
198
|
+
' AND c.component_id = {component_id:String}'
|
|
199
|
+
' AND c.component_release_id = {component_release_id:String}'
|
|
200
|
+
' LIMIT 1',
|
|
201
|
+
),
|
|
202
|
+
{
|
|
203
|
+
'project_ids': project_ids,
|
|
204
|
+
'component_id': component_id,
|
|
205
|
+
'component_release_id': component_release_id,
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
return bool(rows)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def search_counts(
|
|
212
|
+
project_ids: list[str], component_ids: list[str]
|
|
213
|
+
) -> dict[str, tuple[int, int]]:
|
|
214
|
+
"""Return ``(version_count, project_count)`` per component id.
|
|
215
|
+
|
|
216
|
+
``uniqExact`` rather than ``uniq``: these are small cardinalities
|
|
217
|
+
shown as literal numbers beside a package name, so an approximate
|
|
218
|
+
count would be visibly wrong for no useful saving.
|
|
219
|
+
"""
|
|
220
|
+
if not project_ids or not component_ids:
|
|
221
|
+
return {}
|
|
222
|
+
rows = await clickhouse.query(
|
|
223
|
+
_by_project(
|
|
224
|
+
'SELECT c.component_id AS component_id,'
|
|
225
|
+
' uniqExact(c.component_release_id) AS version_count,'
|
|
226
|
+
' uniqExact(c.project_id) AS project_count',
|
|
227
|
+
' AND c.component_id IN {component_ids:Array(String)}'
|
|
228
|
+
' GROUP BY c.component_id',
|
|
229
|
+
),
|
|
230
|
+
{'project_ids': project_ids, 'component_ids': component_ids},
|
|
231
|
+
)
|
|
232
|
+
return {
|
|
233
|
+
str(row['component_id']): (
|
|
234
|
+
int(row['version_count']),
|
|
235
|
+
int(row['project_count']),
|
|
236
|
+
)
|
|
237
|
+
for row in rows
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
async def ecosystem_totals(project_ids: list[str]) -> dict[str, int]:
|
|
242
|
+
"""Component counts per ecosystem, scoped to the organization.
|
|
243
|
+
|
|
244
|
+
Restores the scoping traded away for speed when this was a graph
|
|
245
|
+
traversal: it counted every component in the catalog because
|
|
246
|
+
traversing to the org cost 36.5s against 1.07s. The aggregate here
|
|
247
|
+
is org-scoped and still cheap, so the chip labels describe what the
|
|
248
|
+
reader's organization actually depends on again.
|
|
249
|
+
"""
|
|
250
|
+
if not project_ids:
|
|
251
|
+
return {}
|
|
252
|
+
rows = await clickhouse.query(
|
|
253
|
+
_by_project(
|
|
254
|
+
'SELECT c.ecosystem AS ecosystem,'
|
|
255
|
+
' uniqExact(c.component_id) AS total',
|
|
256
|
+
" AND c.ecosystem != '' GROUP BY c.ecosystem",
|
|
257
|
+
),
|
|
258
|
+
{'project_ids': project_ids},
|
|
259
|
+
)
|
|
260
|
+
return {str(row['ecosystem']): int(row['total']) for row in rows}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def component_usage(
|
|
264
|
+
release_ids: list[str], component_id: str
|
|
265
|
+
) -> list[tuple[str, str, str]]:
|
|
266
|
+
"""``(release_id, component_release_id, version)`` for one package.
|
|
267
|
+
|
|
268
|
+
Scoped to the deployment pointer set, so the result describes what
|
|
269
|
+
is running now rather than every release that ever named the
|
|
270
|
+
package. The caller joins each ``release_id`` back to the project,
|
|
271
|
+
team, and environment it came from -- that topology stayed in the
|
|
272
|
+
graph, and it is what produced the ids passed in here.
|
|
273
|
+
"""
|
|
274
|
+
if not release_ids:
|
|
275
|
+
return []
|
|
276
|
+
rows = await clickhouse.query(
|
|
277
|
+
_by_release(
|
|
278
|
+
'SELECT DISTINCT c.release_id AS release_id,'
|
|
279
|
+
' c.component_release_id AS component_release_id,'
|
|
280
|
+
' c.version AS version',
|
|
281
|
+
' AND c.component_id = {component_id:String}',
|
|
282
|
+
),
|
|
283
|
+
{'release_ids': release_ids, 'component_id': component_id},
|
|
284
|
+
)
|
|
285
|
+
return [
|
|
286
|
+
(
|
|
287
|
+
str(row['release_id']),
|
|
288
|
+
str(row['component_release_id']),
|
|
289
|
+
str(row['version']),
|
|
290
|
+
)
|
|
291
|
+
for row in rows
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def governed_usage(
|
|
296
|
+
release_ids: list[str], component_release_ids: list[str]
|
|
297
|
+
) -> list[dict[str, str]]:
|
|
298
|
+
"""Deployed usages of a governed set of component versions.
|
|
299
|
+
|
|
300
|
+
Both bounds come from the graph and both are small: the deployment
|
|
301
|
+
pointer set on one side, and on the other the versions carrying a
|
|
302
|
+
status or an advisory -- the anchor that kept Problem Packages
|
|
303
|
+
starting from the governed set rather than from the catalog.
|
|
304
|
+
"""
|
|
305
|
+
if not release_ids or not component_release_ids:
|
|
306
|
+
return []
|
|
307
|
+
rows = await clickhouse.query(
|
|
308
|
+
_by_release(
|
|
309
|
+
'SELECT DISTINCT c.release_id AS release_id,'
|
|
310
|
+
' c.component_id AS component_id,'
|
|
311
|
+
' c.component_release_id AS component_release_id,'
|
|
312
|
+
' c.version AS version',
|
|
313
|
+
' AND c.component_release_id IN'
|
|
314
|
+
' {component_release_ids:Array(String)}',
|
|
315
|
+
),
|
|
316
|
+
{
|
|
317
|
+
'release_ids': release_ids,
|
|
318
|
+
'component_release_ids': component_release_ids,
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
return [{key: str(value) for key, value in row.items()} for row in rows]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def release_components(
|
|
325
|
+
release_id: str,
|
|
326
|
+
) -> list[dict[str, typing.Any]]:
|
|
327
|
+
"""One release's currently-published component set.
|
|
328
|
+
|
|
329
|
+
Returns the denormalized columns plus the ids the caller hydrates
|
|
330
|
+
the rest from. ``license``, ``supplier``, ``hashes``, and the
|
|
331
|
+
component identifiers are governance attributes of the
|
|
332
|
+
``ComponentRelease`` node and were never copied here, so the
|
|
333
|
+
listing endpoint fetches them from the graph by id -- a bounded
|
|
334
|
+
``IN`` over one release's versions, not a traversal.
|
|
335
|
+
"""
|
|
336
|
+
rows = await clickhouse.query(
|
|
337
|
+
_by_release(
|
|
338
|
+
'SELECT c.component_id AS component_id,'
|
|
339
|
+
' c.component_release_id AS component_release_id,'
|
|
340
|
+
' c.purl_name AS purl_name,'
|
|
341
|
+
' c.ecosystem AS ecosystem,'
|
|
342
|
+
' c.version AS version,'
|
|
343
|
+
' c.scope AS scope,'
|
|
344
|
+
' c.groups AS groups',
|
|
345
|
+
),
|
|
346
|
+
{'release_ids': [release_id]},
|
|
347
|
+
)
|
|
348
|
+
return list(rows)
|
|
@@ -35,6 +35,7 @@ import fastapi
|
|
|
35
35
|
|
|
36
36
|
from imbi.api.auth import principals
|
|
37
37
|
from imbi.common import graph
|
|
38
|
+
from imbi.common.clickhouse import client as ch_client
|
|
38
39
|
from imbi.common.plugins import errors as plugin_errors
|
|
39
40
|
|
|
40
41
|
LOGGER = logging.getLogger(__name__)
|
|
@@ -42,6 +43,19 @@ LOGGER = logging.getLogger(__name__)
|
|
|
42
43
|
#: Notes namespace CI writes drift verdicts to (``refs/notes/<this>``).
|
|
43
44
|
NAMESPACE = 'imbi-drift'
|
|
44
45
|
|
|
46
|
+
#: Per-commit verdict table. The Release properties answer "did the
|
|
47
|
+
#: commit this tag points at drift"; this answers the same for *every*
|
|
48
|
+
#: commit, which is what OR-ing a range needs.
|
|
49
|
+
VERDICT_TABLE = 'commit_drift'
|
|
50
|
+
|
|
51
|
+
_VERDICT_COLUMNS = [
|
|
52
|
+
'project_id',
|
|
53
|
+
'sha',
|
|
54
|
+
'drift_detected',
|
|
55
|
+
'paths',
|
|
56
|
+
'recorded_at',
|
|
57
|
+
]
|
|
58
|
+
|
|
45
59
|
REQUESTED_BY = principals.DRIFT_SYNC
|
|
46
60
|
|
|
47
61
|
#: How many unanswered Releases one sweep asks the remote about. Every
|
|
@@ -83,32 +97,112 @@ LIMIT {limit}
|
|
|
83
97
|
"""
|
|
84
98
|
|
|
85
99
|
|
|
86
|
-
|
|
87
|
-
"""
|
|
100
|
+
class NoteVerdict(typing.NamedTuple):
|
|
101
|
+
"""One note's verdict and the paths that caused it.
|
|
102
|
+
|
|
103
|
+
``drift_detected`` is ``None`` for a note that gave no usable
|
|
104
|
+
boolean. ``paths`` explains a verdict but never implies one: it is
|
|
105
|
+
empty when nothing drifted, and empty for every note CI writes today
|
|
106
|
+
(``{"drift_detected": <bool>}`` and nothing else), even though
|
|
107
|
+
``drift`` itself reports ``drift_paths``.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
drift_detected: bool | None
|
|
111
|
+
paths: list[str]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_note_verdict(body: str | None) -> NoteVerdict:
|
|
115
|
+
"""Extract the verdict and the drifting paths from one note body.
|
|
88
116
|
|
|
89
|
-
Strict JSON, reading only
|
|
90
|
-
unknown keys. Anything else -- invalid JSON, a non-object,
|
|
91
|
-
non-boolean
|
|
92
|
-
note never fails the webhook or the sweep.
|
|
117
|
+
Strict JSON, reading only ``drift_detected`` and ``drift_paths`` and
|
|
118
|
+
ignoring unknown keys. Anything else -- invalid JSON, a non-object,
|
|
119
|
+
a non-boolean verdict -- logs a warning and answers a ``None``
|
|
120
|
+
verdict so a bad note never fails the webhook or the sweep.
|
|
93
121
|
"""
|
|
94
122
|
if body is None:
|
|
95
|
-
return None
|
|
123
|
+
return NoteVerdict(None, [])
|
|
96
124
|
try:
|
|
97
125
|
parsed: object = json.loads(body)
|
|
98
126
|
except ValueError:
|
|
99
127
|
LOGGER.warning('Ignoring invalid drift note: %.100r', body)
|
|
100
|
-
return None
|
|
128
|
+
return NoteVerdict(None, [])
|
|
101
129
|
if not isinstance(parsed, dict):
|
|
102
130
|
LOGGER.warning('Ignoring non-object drift note: %.100r', body)
|
|
131
|
+
return NoteVerdict(None, [])
|
|
132
|
+
note = typing.cast('dict[str, object]', parsed)
|
|
133
|
+
value: object = note.get('drift_detected')
|
|
134
|
+
if not isinstance(value, bool):
|
|
135
|
+
if value is not None:
|
|
136
|
+
LOGGER.warning('Ignoring non-boolean drift_detected: %r', value)
|
|
137
|
+
return NoteVerdict(None, [])
|
|
138
|
+
# Paths only ever accompany a ``true``: a range with nothing worth
|
|
139
|
+
# acting on has no drifting paths by construction, so a ``false``
|
|
140
|
+
# note carrying some is malformed and its paths mean nothing.
|
|
141
|
+
if not value:
|
|
142
|
+
return NoteVerdict(False, [])
|
|
143
|
+
return NoteVerdict(True, _parse_paths(note.get('drift_paths')))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _parse_paths(value: object) -> list[str]:
|
|
147
|
+
"""The note's ``drift_paths``, or ``[]`` when it has none usable.
|
|
148
|
+
|
|
149
|
+
A malformed path list must not discard an otherwise good verdict --
|
|
150
|
+
the paths only ever explain one.
|
|
151
|
+
"""
|
|
152
|
+
if not isinstance(value, list):
|
|
153
|
+
return []
|
|
154
|
+
return [
|
|
155
|
+
item
|
|
156
|
+
for item in typing.cast('list[object]', value)
|
|
157
|
+
if isinstance(item, str)
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parse_note(body: str | None) -> bool | None:
|
|
162
|
+
"""The verdict alone, for callers that do not need the paths."""
|
|
163
|
+
return parse_note_verdict(body).drift_detected
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def record_verdicts(
|
|
167
|
+
project_id: str, verdicts: dict[str, NoteVerdict]
|
|
168
|
+
) -> int | None:
|
|
169
|
+
"""Persist per-commit verdicts, one insert for the whole batch.
|
|
170
|
+
|
|
171
|
+
Keyed by the annotated commit's full SHA to join ``imbi.commits``,
|
|
172
|
+
so only callers holding full SHAs write here -- the notes tree is
|
|
173
|
+
keyed that way, while a ``Release.committish`` is abbreviated and
|
|
174
|
+
would collide.
|
|
175
|
+
|
|
176
|
+
Notes with no usable verdict are skipped rather than written: a
|
|
177
|
+
missing row *is* the "no verdict" state.
|
|
178
|
+
|
|
179
|
+
Returns how many rows were written, or ``None`` when the write
|
|
180
|
+
failed. Zero and ``None`` have to stay distinguishable: a repo with
|
|
181
|
+
no notes legitimately writes nothing, while a ClickHouse outage
|
|
182
|
+
writes nothing and must not let a caller record the work as done.
|
|
183
|
+
Failures are logged rather than raised -- the graph write has already
|
|
184
|
+
succeeded, and the next push or backfill rewrites the rows.
|
|
185
|
+
"""
|
|
186
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
187
|
+
rows = [
|
|
188
|
+
[project_id, sha.lower(), verdict.drift_detected, verdict.paths, now]
|
|
189
|
+
for sha, verdict in verdicts.items()
|
|
190
|
+
if verdict.drift_detected is not None
|
|
191
|
+
]
|
|
192
|
+
if not rows:
|
|
193
|
+
return 0
|
|
194
|
+
try:
|
|
195
|
+
await ch_client.Clickhouse.get_instance().insert(
|
|
196
|
+
VERDICT_TABLE, rows, _VERDICT_COLUMNS
|
|
197
|
+
)
|
|
198
|
+
except Exception:
|
|
199
|
+
LOGGER.exception(
|
|
200
|
+
'could not record %d drift verdicts for project %s',
|
|
201
|
+
len(rows),
|
|
202
|
+
project_id,
|
|
203
|
+
)
|
|
103
204
|
return None
|
|
104
|
-
|
|
105
|
-
'drift_detected'
|
|
106
|
-
)
|
|
107
|
-
if isinstance(value, bool):
|
|
108
|
-
return value
|
|
109
|
-
if value is not None:
|
|
110
|
-
LOGGER.warning('Ignoring non-boolean drift_detected: %r', value)
|
|
111
|
-
return None
|
|
205
|
+
return len(rows)
|
|
112
206
|
|
|
113
207
|
|
|
114
208
|
async def _set_drift(
|
|
@@ -204,11 +298,17 @@ async def apply_notes_diff(
|
|
|
204
298
|
"""Ingest one push to the notes ref.
|
|
205
299
|
|
|
206
300
|
Diffs the notes tree between ``before`` and ``after`` through the
|
|
207
|
-
project's deployment plugin,
|
|
208
|
-
Releases on its
|
|
209
|
-
:class:`NotImplementedError` when the
|
|
210
|
-
support, and lets plugin resolution's
|
|
211
|
-
the endpoint turns both into an
|
|
301
|
+
project's deployment plugin, records every verdict against its
|
|
302
|
+
commit, then applies each changed note to the Releases on its
|
|
303
|
+
annotated commit. Raises :class:`NotImplementedError` when the
|
|
304
|
+
plugin has no git-notes support, and lets plugin resolution's
|
|
305
|
+
``HTTPException`` propagate -- the endpoint turns both into an
|
|
306
|
+
honest status.
|
|
307
|
+
|
|
308
|
+
A note the push *removed* arrives as a ``None`` body and leaves the
|
|
309
|
+
recorded verdict in place. Deliberate: CI removes a drift note only
|
|
310
|
+
by rewriting the ref, and the alternative -- deleting the row -- would
|
|
311
|
+
silently turn a range clean on a force push.
|
|
212
312
|
"""
|
|
213
313
|
from imbi.api.endpoints import project_deployments
|
|
214
314
|
from imbi.api.plugins import call_with_timeout
|
|
@@ -229,6 +329,10 @@ async def apply_notes_diff(
|
|
|
229
329
|
after=after,
|
|
230
330
|
)
|
|
231
331
|
)
|
|
332
|
+
await record_verdicts(
|
|
333
|
+
project_id,
|
|
334
|
+
{sha: parse_note_verdict(body) for sha, body in changed.items()},
|
|
335
|
+
)
|
|
232
336
|
updated = 0
|
|
233
337
|
for full_sha, body in changed.items():
|
|
234
338
|
try:
|
|
@@ -251,6 +355,113 @@ async def apply_notes_diff(
|
|
|
251
355
|
return updated
|
|
252
356
|
|
|
253
357
|
|
|
358
|
+
_BACKFILLED_AT: typing.Final[typing.LiteralString] = """
|
|
359
|
+
MATCH (p:Project {{id: {project_id}}})
|
|
360
|
+
RETURN p.drift_verdicts_at AS at
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
_MARK_BACKFILLED: typing.Final[typing.LiteralString] = """
|
|
364
|
+
MATCH (p:Project {{id: {project_id}}})
|
|
365
|
+
SET p.drift_verdicts_at = {now}
|
|
366
|
+
RETURN p.id AS id
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def backfill_verdicts(
|
|
371
|
+
db: graph.Graph,
|
|
372
|
+
*,
|
|
373
|
+
org_slug: str,
|
|
374
|
+
project_id: str,
|
|
375
|
+
) -> int | None:
|
|
376
|
+
"""Record every note in the namespace, once per project.
|
|
377
|
+
|
|
378
|
+
The cover for notes written before Imbi read them. Reading the whole
|
|
379
|
+
notes tree costs one Git Data call per note body, so this runs once
|
|
380
|
+
and then stops, guarded by ``Project.drift_verdicts_at``.
|
|
381
|
+
|
|
382
|
+
That marker, rather than "does the table hold a row for this
|
|
383
|
+
project": the webhook writes a row on the first push after deploy, so
|
|
384
|
+
a row-count guard would declare the backfill done for exactly the
|
|
385
|
+
active projects whose history most needs it, and their older notes
|
|
386
|
+
would never be read.
|
|
387
|
+
|
|
388
|
+
The marker is set only after a *complete* enumeration. A listing
|
|
389
|
+
that could not read every note leaves it unset so the next sweep
|
|
390
|
+
tries again -- recording a partial pass as finished would leave those
|
|
391
|
+
commits permanently unanswered, which the rule reads as "nothing to
|
|
392
|
+
do".
|
|
393
|
+
|
|
394
|
+
A project whose notes ref does not exist yet is a complete, empty
|
|
395
|
+
listing, so it is marked and not asked again -- the alternative is a
|
|
396
|
+
Git Data call per note-less project on every sweep, and the webhook
|
|
397
|
+
covers a ref created later from its first push onward.
|
|
398
|
+
|
|
399
|
+
Returns how many verdicts were recorded, or ``None`` when nothing
|
|
400
|
+
could answer -- no capability bound, or one without git notes.
|
|
401
|
+
Raises when the verdicts could not be stored: the maintenance
|
|
402
|
+
operation turns that into a failed item, where returning zero would
|
|
403
|
+
have reported a ClickHouse outage as "nothing to do".
|
|
404
|
+
"""
|
|
405
|
+
from imbi.api.endpoints import project_deployments
|
|
406
|
+
from imbi.api.plugins import call_with_timeout
|
|
407
|
+
|
|
408
|
+
marked = await db.execute(
|
|
409
|
+
_BACKFILLED_AT, {'project_id': project_id}, ['at']
|
|
410
|
+
)
|
|
411
|
+
if marked and graph.parse_agtype(marked[0]['at']) is not None:
|
|
412
|
+
return 0
|
|
413
|
+
try:
|
|
414
|
+
(
|
|
415
|
+
handler,
|
|
416
|
+
ctx,
|
|
417
|
+
credentials,
|
|
418
|
+
) = await project_deployments.resolve_deployment_capability(
|
|
419
|
+
db, org_slug=org_slug, project_id=project_id
|
|
420
|
+
)
|
|
421
|
+
except fastapi.HTTPException as exc:
|
|
422
|
+
if exc.status_code in (400, 404):
|
|
423
|
+
return None
|
|
424
|
+
raise
|
|
425
|
+
try:
|
|
426
|
+
listing = await call_with_timeout(
|
|
427
|
+
handler.list_commit_notes(ctx, credentials, namespace=NAMESPACE)
|
|
428
|
+
)
|
|
429
|
+
except NotImplementedError:
|
|
430
|
+
return None
|
|
431
|
+
recorded = await record_verdicts(
|
|
432
|
+
project_id,
|
|
433
|
+
{sha: parse_note_verdict(body) for sha, body in listing.notes.items()},
|
|
434
|
+
)
|
|
435
|
+
if recorded is None:
|
|
436
|
+
# Raised rather than returned as zero: the caller cannot tell a
|
|
437
|
+
# lost write from an empty ref, and reporting a ClickHouse
|
|
438
|
+
# outage as "nothing to do" hides the one pass this project
|
|
439
|
+
# gets. The marker stays unset either way, so a later sweep
|
|
440
|
+
# retries.
|
|
441
|
+
raise RuntimeError(
|
|
442
|
+
f'could not store drift verdicts for project {project_id}'
|
|
443
|
+
)
|
|
444
|
+
# Not ``recorded > 0``: a ref with no notes writes nothing and is
|
|
445
|
+
# still finished, and gating on the count would re-read its tree on
|
|
446
|
+
# every sweep forever.
|
|
447
|
+
if listing.complete:
|
|
448
|
+
await db.execute(
|
|
449
|
+
_MARK_BACKFILLED,
|
|
450
|
+
{
|
|
451
|
+
'project_id': project_id,
|
|
452
|
+
'now': datetime.datetime.now(datetime.UTC).isoformat(),
|
|
453
|
+
},
|
|
454
|
+
['id'],
|
|
455
|
+
)
|
|
456
|
+
else:
|
|
457
|
+
LOGGER.warning(
|
|
458
|
+
'drift notes for project %s were incomplete; leaving the '
|
|
459
|
+
'backfill unmarked so a later sweep retries',
|
|
460
|
+
project_id,
|
|
461
|
+
)
|
|
462
|
+
return recorded
|
|
463
|
+
|
|
464
|
+
|
|
254
465
|
async def sweep_project(
|
|
255
466
|
db: graph.Graph,
|
|
256
467
|
*,
|