imbi-api 2.9.3__tar.gz → 2.10.0__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.9.3 → imbi_api-2.10.0}/PKG-INFO +2 -2
- {imbi_api-2.9.3 → imbi_api-2.10.0}/pyproject.toml +2 -2
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/commit_sync/service.py +60 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/domain/models.py +13 -1
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/domain/scoring.py +8 -1
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/dashboard.py +18 -7
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/projects.py +177 -41
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/releases.py +43 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/scoring_policies.py +2 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/users.py +28 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/models.py +2 -1
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/scoring/queue.py +12 -6
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_projects.py +12 -4
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_projects_helpers.py +5 -5
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_releases.py +29 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_scoring_policies.py +46 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_users.py +33 -12
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_commit_sync_service.py +107 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_scoring_triggers.py +23 -0
- imbi_api-2.10.0/uv.lock +3209 -0
- imbi_api-2.9.3/uv.lock +0 -3209
- {imbi_api-2.9.3 → imbi_api-2.10.0}/.github/workflows/deploy.yaml +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/.github/workflows/docs.yaml +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/.github/workflows/testing.yaml +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/.gitignore +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/.pre-commit-config.yaml +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/.python-version +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/CLAUDE.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/CODE_REVIEW_PUNCHLIST.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/LICENSE +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/README.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/coderabbit.yaml +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/compose.yaml +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0001-record-architecture-decisions.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0002-authentication-and-authorization-architecture.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0003-email-sending-architecture.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0004-phase-5-authentication-enhancements.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0005-file-upload-storage-architecture.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0006-project-identity-and-multi-type.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0007-relationship-blueprints.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0008-plugin-system-architecture.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0009-database-driven-oauth-providers.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0010-identity-plugin-architecture.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0011-graph-based-project-scoring.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0012-plugin-manifest-service-template.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0013-deployment-plugin-type.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0014-generic-plugin-entity-abstraction.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr/0015-cyclonedx-1.7-sbom-standard.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/adr.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/configuration.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/index.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/docs/restore-backup.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/justfile +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/mkdocs.yml +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/mypy.ini +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/app.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/authorization_codes.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/local_auth.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/login_providers.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/membership.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/models.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/oauth.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/oauth_clients.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/password.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/permissions.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/seed.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/sessions.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/tokens.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/auth/totp.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/backfill_embeddings.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/blueprint_attributes.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/commit_sync/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/commit_sync/queue.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/domain/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/email/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/email/client.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/email/dependencies.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/email/models.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/email/templates/base.html +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/email/templates/base.txt +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/email/templates/password_reset.html +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/email/templates/password_reset.txt +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/email/templates/welcome.html +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/email/templates/welcome.txt +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/email/templates.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/_credentials.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/_helpers.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/_json_fields.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/_pagination.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/_request_urls.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/admin.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/admin_plugins.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/api_keys.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/auth.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/auth_providers.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/blueprints.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/client_credentials.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/comments.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/document_templates.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/documents.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/environments.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/events.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/graph_query.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/identity_plugins.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/link_definitions.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/local_auth.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/mcp_servers.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/mfa.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/oauth_metadata.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/operations_log.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/organizations.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/plugin_edges.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/plugin_entities.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/plugins.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/project_analysis.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/project_commit_sync.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/project_configuration.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/project_deployments.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/project_logs.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/project_plugins.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/project_type_plugins.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/project_types.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/pull_requests.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/roles.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/sa_api_keys.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/scoring.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/search.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/service_accounts.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/service_plugins.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/status.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/tags.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/teams.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/third_party_services.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/uploads.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/user_activity.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/endpoints/webhooks.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/entrypoint.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/graph_sql.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/identity/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/identity/endpoints.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/identity/errors.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/identity/flows.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/identity/host_integration.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/identity/models.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/identity/repository.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/identity/resolution.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/identity/state.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/identity/sweeper.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/lifespans.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/llm/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/llm/dependencies.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/mcp_test.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/middleware/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/middleware/rate_limit.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/openapi.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/patch.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/plugins/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/plugins/assignment_writer.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/plugins/assignments.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/plugins/credentials.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/plugins/installer.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/plugins/lifecycle.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/plugins/lifecycle_dispatch.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/plugins/reload.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/plugins/resolution.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/plugins/schemas.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/prompts/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/prompts/release_notes_system.md +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/py.typed +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/relationships.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/sbom.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/scoring/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/settings.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/storage/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/storage/client.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/storage/dependencies.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/storage/thumbnails.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/src/imbi_api/storage/validation.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/test_api_key_auth.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/test_authentication.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/test_authorization.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/test_encryption.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/test_login_providers.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/test_membership.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/test_oauth.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/test_oauth_server.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/test_permissions.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/test_seed.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/test_sessions.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/auth/test_totp.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/clickhouse/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/clickhouse/test_client.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/clickhouse/test_init.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/clickhouse/test_privacy.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/email/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/email/test_client.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/email/test_init.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/email/test_integration.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/email/test_models.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/email/test_templates.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_admin.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_admin_plugins.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_api_keys.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_auth.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_auth_providers.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_blueprints.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_client_credentials.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_comments.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_credentials.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_dashboard.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_document_templates.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_documents.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_environments.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_events.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_graph_query.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_init.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_json_fields.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_link_definitions.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_local_auth.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_mcp_servers.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_mfa.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_operations_log.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_organizations.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_pagination.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_plugin_label_validation.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_plugins.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_project_analysis.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_project_commit_sync.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_project_configuration.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_project_deployments.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_project_logs.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_project_plugins.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_project_response_surfacing.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_project_type_plugins.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_project_types.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_pull_requests.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_request_urls.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_roles.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_sa_api_keys.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_scoring.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_search.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_service_accounts.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_service_plugins.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_service_writeback.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_status.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_tags.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_teams.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_third_party_services.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_uploads.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_user_activity.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/endpoints/test_webhooks.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/fixtures/sbom/npm-realistic.json +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/fixtures/sbom/pypi-realistic.json +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/fixtures/sbom/tiny.json +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/identity/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/identity/test_endpoints.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/identity/test_errors.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/identity/test_flows.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/identity/test_host_integration.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/identity/test_repository.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/identity/test_resolution.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/identity/test_state.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/identity/test_sweeper.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/middleware/test_rate_limit.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/storage/__init__.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/storage/test_client.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/storage/test_init.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/storage/test_thumbnails.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/storage/test_validation.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/support.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_app.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_assignment_writer.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_backfill_embeddings.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_blueprint_attributes.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_blueprints.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_commit_sync_queue.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_entrypoint.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_graph_sql.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_init.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_installer.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_lifecycle_dispatch.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_lifespans.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_models.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_openapi.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_patch.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_plugin_schemas.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_plugins.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_sbom.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_scoring_queue.py +0 -0
- {imbi_api-2.9.3 → imbi_api-2.10.0}/tests/test_settings.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: imbi-api
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.10.0
|
|
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: BSD-3-Clause
|
|
@@ -20,7 +20,7 @@ Requires-Dist: cyclonedx-python-lib<12,>=11.7
|
|
|
20
20
|
Requires-Dist: fastapi
|
|
21
21
|
Requires-Dist: filetype
|
|
22
22
|
Requires-Dist: httpx<1,>=0.28.1
|
|
23
|
-
Requires-Dist: imbi-common[databases,llm,sentry,server]==2.
|
|
23
|
+
Requires-Dist: imbi-common[databases,llm,sentry,server]==2.10.0
|
|
24
24
|
Requires-Dist: jinja2
|
|
25
25
|
Requires-Dist: jsonpatch>=1.33
|
|
26
26
|
Requires-Dist: mcp>=1.26.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "imbi-api"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.10.0"
|
|
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.
|
|
31
|
+
"imbi-common[databases,llm,sentry,server]==2.10.0",
|
|
32
32
|
"jinja2",
|
|
33
33
|
"jsonpatch>=1.33",
|
|
34
34
|
"mcp>=1.26.0",
|
|
@@ -25,6 +25,8 @@ from imbi_common.plugins.base import PluginContext, ServicePlugin
|
|
|
25
25
|
from imbi_common.plugins.errors import PluginNotFoundError
|
|
26
26
|
from imbi_common.plugins.registry import RegistryEntry, get_plugin
|
|
27
27
|
|
|
28
|
+
from imbi_api import models
|
|
29
|
+
from imbi_api.identity import repository as identity_repository
|
|
28
30
|
from imbi_api.plugins import parse_options
|
|
29
31
|
from imbi_api.plugins.credentials import get_plugin_credentials
|
|
30
32
|
|
|
@@ -137,6 +139,61 @@ async def _resolve_plugin(
|
|
|
137
139
|
)
|
|
138
140
|
|
|
139
141
|
|
|
142
|
+
def _identity_plugin_slugs(service_plugins: list[ServicePlugin]) -> list[str]:
|
|
143
|
+
"""Return connected plugin slugs registered as identity plugins."""
|
|
144
|
+
slugs: list[str] = []
|
|
145
|
+
for plugin in service_plugins:
|
|
146
|
+
try:
|
|
147
|
+
entry = get_plugin(plugin.slug)
|
|
148
|
+
except PluginNotFoundError:
|
|
149
|
+
continue
|
|
150
|
+
if entry.manifest.plugin_type == 'identity':
|
|
151
|
+
slugs.append(plugin.slug)
|
|
152
|
+
return slugs
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _make_user_resolver(
|
|
156
|
+
db: graph.Graph, service_plugins: list[ServicePlugin]
|
|
157
|
+
) -> abc.Callable[[str], abc.Awaitable[str | None]] | None:
|
|
158
|
+
"""Build the ``PluginContext.resolve_user_by_identity`` resolver.
|
|
159
|
+
|
|
160
|
+
Maps an external identity *subject* (a GitHub numeric user id) to the
|
|
161
|
+
matching Imbi user's email by querying the graph directly -- no HTTP,
|
|
162
|
+
since this runs inside imbi-api on a background backfill. Connected
|
|
163
|
+
plugins are filtered to identity plugins; ``None`` is returned when
|
|
164
|
+
none qualify so the action skips the lookups entirely. A subject
|
|
165
|
+
resolving to two different users across plugins is treated as
|
|
166
|
+
unresolved (logged), never mis-attributed.
|
|
167
|
+
"""
|
|
168
|
+
identity_slugs = _identity_plugin_slugs(service_plugins)
|
|
169
|
+
if not identity_slugs:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
async def _resolve(subject: str) -> str | None:
|
|
173
|
+
emails: set[str] = set()
|
|
174
|
+
for slug in identity_slugs:
|
|
175
|
+
user_id = await identity_repository.find_user_by_subject(
|
|
176
|
+
db, slug, subject
|
|
177
|
+
)
|
|
178
|
+
if user_id is None:
|
|
179
|
+
continue
|
|
180
|
+
users = await db.match(models.User, {'id': user_id})
|
|
181
|
+
if users:
|
|
182
|
+
emails.add(users[0].email)
|
|
183
|
+
if len(emails) > 1:
|
|
184
|
+
LOGGER.error(
|
|
185
|
+
'Identity subject %r resolved to multiple Imbi users via '
|
|
186
|
+
'plugins %r: %r — leaving unattributed',
|
|
187
|
+
subject,
|
|
188
|
+
identity_slugs,
|
|
189
|
+
sorted(emails),
|
|
190
|
+
)
|
|
191
|
+
return None
|
|
192
|
+
return next(iter(emails), None)
|
|
193
|
+
|
|
194
|
+
return _resolve
|
|
195
|
+
|
|
196
|
+
|
|
140
197
|
async def _build_context(
|
|
141
198
|
db: graph.Graph,
|
|
142
199
|
org_slug: str,
|
|
@@ -174,6 +231,9 @@ async def _build_context(
|
|
|
174
231
|
project_type_slugs=project_type_slugs,
|
|
175
232
|
third_party_service_slug=resolved.tps_slug or None,
|
|
176
233
|
service_connections=service_connections,
|
|
234
|
+
resolve_user_by_identity=_make_user_resolver(
|
|
235
|
+
db, resolved.service_plugins
|
|
236
|
+
),
|
|
177
237
|
)
|
|
178
238
|
|
|
179
239
|
|
|
@@ -55,6 +55,7 @@ __all__ = [
|
|
|
55
55
|
'ServiceApplicationSecrets',
|
|
56
56
|
'Session',
|
|
57
57
|
'TOTPSecret',
|
|
58
|
+
'TeamMembership',
|
|
58
59
|
'ThirdPartyService',
|
|
59
60
|
'ThirdPartyServiceCreate',
|
|
60
61
|
'ThirdPartyServiceResponse',
|
|
@@ -224,6 +225,15 @@ class OrgMembership(pydantic.BaseModel):
|
|
|
224
225
|
role: str
|
|
225
226
|
|
|
226
227
|
|
|
228
|
+
class TeamMembership(pydantic.BaseModel):
|
|
229
|
+
"""Team membership for API responses."""
|
|
230
|
+
|
|
231
|
+
model_config = pydantic.ConfigDict(extra='ignore')
|
|
232
|
+
team_name: str
|
|
233
|
+
team_slug: str
|
|
234
|
+
organization_slug: str
|
|
235
|
+
|
|
236
|
+
|
|
227
237
|
class Role(models.Node): # type: ignore[misc]
|
|
228
238
|
"""Role for grouping permissions."""
|
|
229
239
|
|
|
@@ -445,12 +455,14 @@ class CurrentUserResponse(UserResponse):
|
|
|
445
455
|
"""Response model for the authenticated user's own profile.
|
|
446
456
|
|
|
447
457
|
Extends ``UserResponse`` with the caller's effective permission set
|
|
448
|
-
so the UI can gate features
|
|
458
|
+
and team memberships so the UI can gate features and offer
|
|
459
|
+
membership-scoped filters in a single request. Only exposed via
|
|
449
460
|
``GET /users/me`` — the per-email endpoint deliberately omits
|
|
450
461
|
permissions to avoid disclosing one user's grants to another.
|
|
451
462
|
"""
|
|
452
463
|
|
|
453
464
|
permissions: list[str] = []
|
|
465
|
+
teams: list[TeamMembership] = []
|
|
454
466
|
|
|
455
467
|
|
|
456
468
|
class PasswordChangeRequest(pydantic.BaseModel):
|
|
@@ -58,7 +58,12 @@ class PolicyCreate(pydantic.BaseModel):
|
|
|
58
58
|
slug: str
|
|
59
59
|
description: str | None = None
|
|
60
60
|
category: typing.Literal[
|
|
61
|
-
'attribute',
|
|
61
|
+
'attribute',
|
|
62
|
+
'presence',
|
|
63
|
+
'link_presence',
|
|
64
|
+
'age',
|
|
65
|
+
'analysis_result',
|
|
66
|
+
'deployment_status',
|
|
62
67
|
] = 'attribute'
|
|
63
68
|
weight: int = pydantic.Field(ge=0, le=100)
|
|
64
69
|
enabled: bool = True
|
|
@@ -67,6 +72,7 @@ class PolicyCreate(pydantic.BaseModel):
|
|
|
67
72
|
attribute_name: str | None = None
|
|
68
73
|
link_slug: str | None = None
|
|
69
74
|
result_slug: str | None = None
|
|
75
|
+
environment_slug: str | None = None
|
|
70
76
|
present_score: int | None = pydantic.Field(default=None, ge=0, le=100)
|
|
71
77
|
missing_score: int | None = pydantic.Field(default=None, ge=0, le=100)
|
|
72
78
|
value_score_map: dict[str, int] | None = None
|
|
@@ -85,6 +91,7 @@ class PolicyUpdate(pydantic.BaseModel):
|
|
|
85
91
|
attribute_name: str | None = None
|
|
86
92
|
link_slug: str | None = None
|
|
87
93
|
result_slug: str | None = None
|
|
94
|
+
environment_slug: str | None = None
|
|
88
95
|
weight: int | None = pydantic.Field(default=None, ge=0, le=100)
|
|
89
96
|
enabled: bool | None = None
|
|
90
97
|
priority: int | None = None
|
|
@@ -37,8 +37,11 @@ class DatastoreStatus(pydantic.BaseModel):
|
|
|
37
37
|
status: typing.Literal['ok', 'error']
|
|
38
38
|
latency_ms: float | None = None
|
|
39
39
|
# Bytes on disk (Postgres/ClickHouse) or resident memory (Valkey,
|
|
40
|
-
# which is in-memory). ``None`` when the size probe failed.
|
|
40
|
+
# which is in-memory). ``None`` when the size probe failed. For
|
|
41
|
+
# ClickHouse this is the Imbi application database; ``total_bytes``
|
|
42
|
+
# carries the whole-server footprint (incl. ``system.*``).
|
|
41
43
|
size_bytes: int | None = None
|
|
44
|
+
total_bytes: int | None = None
|
|
42
45
|
detail: str | None = None
|
|
43
46
|
|
|
44
47
|
|
|
@@ -134,13 +137,19 @@ async def _check_postgres(db: graph.Graph) -> DatastoreStatus:
|
|
|
134
137
|
|
|
135
138
|
|
|
136
139
|
async def _check_clickhouse() -> DatastoreStatus:
|
|
137
|
-
"""Probe ClickHouse
|
|
140
|
+
"""Probe ClickHouse for the app-database and whole-server disk size.
|
|
141
|
+
|
|
142
|
+
``size`` is the active-part footprint of the connected (Imbi)
|
|
143
|
+
database; ``total`` spans every database, including ``system.*``.
|
|
144
|
+
"""
|
|
138
145
|
start = time.perf_counter()
|
|
139
146
|
try:
|
|
140
147
|
rows = await asyncio.wait_for(
|
|
141
148
|
clickhouse.query(
|
|
142
|
-
'SELECT
|
|
143
|
-
|
|
149
|
+
'SELECT'
|
|
150
|
+
' sumIf(bytes_on_disk, database = currentDatabase()) AS size,'
|
|
151
|
+
' sum(bytes_on_disk) AS total'
|
|
152
|
+
' FROM system.parts WHERE active'
|
|
144
153
|
),
|
|
145
154
|
_CHECK_TIMEOUT,
|
|
146
155
|
)
|
|
@@ -152,14 +161,16 @@ async def _check_clickhouse() -> DatastoreStatus:
|
|
|
152
161
|
status='error',
|
|
153
162
|
detail=str(err),
|
|
154
163
|
)
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
row = rows[0] if rows else {}
|
|
165
|
+
size_raw = row.get('size')
|
|
166
|
+
total_raw = row.get('total')
|
|
157
167
|
return DatastoreStatus(
|
|
158
168
|
name='ClickHouse',
|
|
159
169
|
role='Timeseries data',
|
|
160
170
|
status='ok',
|
|
161
171
|
latency_ms=_ms(start),
|
|
162
|
-
size_bytes=
|
|
172
|
+
size_bytes=int(size_raw) if size_raw is not None else None,
|
|
173
|
+
total_bytes=int(total_raw) if total_raw is not None else None,
|
|
163
174
|
)
|
|
164
175
|
|
|
165
176
|
|
|
@@ -603,7 +603,7 @@ def _latest_deployment_event(
|
|
|
603
603
|
return latest_ts, latest_by
|
|
604
604
|
|
|
605
605
|
|
|
606
|
-
async def
|
|
606
|
+
async def lookup_ops_log_performed_by(
|
|
607
607
|
targets: list[tuple[str, str, str]],
|
|
608
608
|
) -> dict[tuple[str, str, str], str]:
|
|
609
609
|
"""Map ``(project_id, environment_slug, version)`` → ``performed_by``.
|
|
@@ -737,7 +737,7 @@ async def _fetch_current_releases(
|
|
|
737
737
|
) in latest.items()
|
|
738
738
|
if performed_by is None and (tag or committish)
|
|
739
739
|
]
|
|
740
|
-
performed_by_by_key = await
|
|
740
|
+
performed_by_by_key = await lookup_ops_log_performed_by(enrich_targets)
|
|
741
741
|
if performed_by_by_key:
|
|
742
742
|
for key, (tag, committish, ts, performed_by) in list(latest.items()):
|
|
743
743
|
if performed_by is not None:
|
|
@@ -1499,6 +1499,14 @@ class BlueprintSectionProperty(pydantic.BaseModel):
|
|
|
1499
1499
|
alias='x-ui',
|
|
1500
1500
|
serialization_alias='x-ui',
|
|
1501
1501
|
)
|
|
1502
|
+
#: Optional value-display transforms (e.g. ``{'format': 'humanize'}``)
|
|
1503
|
+
#: applied to the rendered value, independent of ``x-ui`` color/icon
|
|
1504
|
+
#: resolution (which keys off the raw value).
|
|
1505
|
+
x_display: dict[str, typing.Any] = pydantic.Field(
|
|
1506
|
+
default_factory=dict,
|
|
1507
|
+
alias='x-display',
|
|
1508
|
+
serialization_alias='x-display',
|
|
1509
|
+
)
|
|
1502
1510
|
|
|
1503
1511
|
|
|
1504
1512
|
class BlueprintSection(pydantic.BaseModel):
|
|
@@ -1507,6 +1515,11 @@ class BlueprintSection(pydantic.BaseModel):
|
|
|
1507
1515
|
name: str
|
|
1508
1516
|
slug: str
|
|
1509
1517
|
description: str | None = None
|
|
1518
|
+
#: ``project`` for node blueprints (project-level attributes);
|
|
1519
|
+
#: ``environment`` for relationship blueprints on the
|
|
1520
|
+
#: ``Project -[:DEPLOYED_IN]-> Environment`` edge (per-environment
|
|
1521
|
+
#: edge attributes rendered in the Environments card).
|
|
1522
|
+
scope: typing.Literal['project', 'environment'] = 'project'
|
|
1510
1523
|
properties: dict[str, BlueprintSectionProperty]
|
|
1511
1524
|
|
|
1512
1525
|
|
|
@@ -1568,40 +1581,34 @@ async def get_project_schema(
|
|
|
1568
1581
|
graph.parse_agtype(records[0]['env_slugs']) or []
|
|
1569
1582
|
)
|
|
1570
1583
|
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
)
|
|
1584
|
+
def _build_section(
|
|
1585
|
+
bp: models.Blueprint,
|
|
1586
|
+
scope: typing.Literal['project', 'environment'],
|
|
1587
|
+
) -> BlueprintSection | None:
|
|
1588
|
+
"""Build a schema section from a blueprint, or ``None`` to skip.
|
|
1577
1589
|
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1590
|
+
A blueprint is skipped when its filter doesn't intersect the
|
|
1591
|
+
project's own types/envs, or when it declares no properties. A
|
|
1592
|
+
blueprint with no filter matches everything; a ``project_type``
|
|
1593
|
+
filter matches if any of the project's types appear in it (same
|
|
1594
|
+
for ``environment``).
|
|
1595
|
+
"""
|
|
1584
1596
|
f = bp.filter
|
|
1585
1597
|
if f is not None:
|
|
1586
1598
|
if f.project_type and not type_slugs.intersection(f.project_type):
|
|
1587
|
-
|
|
1599
|
+
return None
|
|
1588
1600
|
if f.environment and not env_slugs.intersection(f.environment):
|
|
1589
|
-
|
|
1590
|
-
|
|
1601
|
+
return None
|
|
1591
1602
|
schema = bp.json_schema
|
|
1592
1603
|
if not schema.properties:
|
|
1593
|
-
|
|
1594
|
-
|
|
1604
|
+
return None
|
|
1595
1605
|
props: dict[str, BlueprintSectionProperty] = {}
|
|
1596
1606
|
for prop_name, prop_schema in schema.properties.items():
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
if prop_schema.model_extra
|
|
1600
|
-
else None
|
|
1601
|
-
)
|
|
1602
|
-
x_ui = dict(raw_x_ui or {})
|
|
1607
|
+
extra = prop_schema.model_extra or {}
|
|
1608
|
+
x_ui = dict(extra.get('x-ui') or {})
|
|
1603
1609
|
if x_ui.get('editable') is None:
|
|
1604
1610
|
x_ui['editable'] = True
|
|
1611
|
+
raw_x_display = extra.get('x-display')
|
|
1605
1612
|
props[prop_name] = BlueprintSectionProperty(
|
|
1606
1613
|
type=getattr(prop_schema, 'type', None),
|
|
1607
1614
|
format=getattr(prop_schema, 'format', None),
|
|
@@ -1611,21 +1618,146 @@ async def get_project_schema(
|
|
|
1611
1618
|
default=getattr(prop_schema, 'default', None),
|
|
1612
1619
|
minimum=getattr(prop_schema, 'minimum', None),
|
|
1613
1620
|
maximum=getattr(prop_schema, 'maximum', None),
|
|
1614
|
-
**{'x-ui': x_ui},
|
|
1615
|
-
)
|
|
1616
|
-
|
|
1617
|
-
sections.append(
|
|
1618
|
-
BlueprintSection(
|
|
1619
|
-
name=bp.name,
|
|
1620
|
-
slug=bp.slug or '',
|
|
1621
|
-
description=bp.description,
|
|
1622
|
-
properties=props,
|
|
1621
|
+
**{'x-ui': x_ui, 'x-display': dict(raw_x_display or {})},
|
|
1623
1622
|
)
|
|
1623
|
+
return BlueprintSection(
|
|
1624
|
+
name=bp.name,
|
|
1625
|
+
slug=bp.slug or '',
|
|
1626
|
+
description=bp.description,
|
|
1627
|
+
scope=scope,
|
|
1628
|
+
properties=props,
|
|
1624
1629
|
)
|
|
1625
1630
|
|
|
1631
|
+
# Node blueprints (project-level attributes) ...
|
|
1632
|
+
node_blueprints = await db.match(
|
|
1633
|
+
models.Blueprint,
|
|
1634
|
+
{'type': 'Project', 'enabled': True},
|
|
1635
|
+
order_by='priority',
|
|
1636
|
+
)
|
|
1637
|
+
# ... and relationship blueprints on the
|
|
1638
|
+
# ``Project -[:DEPLOYED_IN]-> Environment`` edge (per-environment edge
|
|
1639
|
+
# attributes), which carry the same JSON-Schema + ``x-ui`` metadata so
|
|
1640
|
+
# the UI renders them with the shared attribute-display logic.
|
|
1641
|
+
rel_blueprints = await db.match(
|
|
1642
|
+
models.Blueprint,
|
|
1643
|
+
{'kind': 'relationship', 'enabled': True},
|
|
1644
|
+
order_by='priority',
|
|
1645
|
+
)
|
|
1646
|
+
|
|
1647
|
+
sections: list[BlueprintSection] = []
|
|
1648
|
+
for bp in node_blueprints:
|
|
1649
|
+
section = _build_section(bp, 'project')
|
|
1650
|
+
if section is not None:
|
|
1651
|
+
sections.append(section)
|
|
1652
|
+
for bp in rel_blueprints:
|
|
1653
|
+
if (
|
|
1654
|
+
bp.source != 'Project'
|
|
1655
|
+
or bp.target != 'Environment'
|
|
1656
|
+
or bp.edge != 'DEPLOYED_IN'
|
|
1657
|
+
):
|
|
1658
|
+
continue
|
|
1659
|
+
section = _build_section(bp, 'environment')
|
|
1660
|
+
if section is not None:
|
|
1661
|
+
sections.append(section)
|
|
1662
|
+
|
|
1626
1663
|
return ProjectSchemaResponse(sections=sections)
|
|
1627
1664
|
|
|
1628
1665
|
|
|
1666
|
+
class EnvironmentEdgeUpdate(pydantic.BaseModel):
|
|
1667
|
+
"""Edge-property updates for a project's ``DEPLOYED_IN`` edge.
|
|
1668
|
+
|
|
1669
|
+
Keys are blueprint-defined edge attributes (accepted via
|
|
1670
|
+
``extra='allow'``). A non-null value sets the property; a ``null``
|
|
1671
|
+
value removes it.
|
|
1672
|
+
"""
|
|
1673
|
+
|
|
1674
|
+
model_config = pydantic.ConfigDict(extra='allow')
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
@projects_router.patch('/{project_id}/environments/{env_slug}')
|
|
1678
|
+
async def patch_project_environment(
|
|
1679
|
+
org_slug: str,
|
|
1680
|
+
project_id: str,
|
|
1681
|
+
env_slug: str,
|
|
1682
|
+
updates: EnvironmentEdgeUpdate,
|
|
1683
|
+
db: graph.Pool,
|
|
1684
|
+
auth: typing.Annotated[
|
|
1685
|
+
permissions.AuthContext,
|
|
1686
|
+
fastapi.Depends(permissions.require_permission('project:write')),
|
|
1687
|
+
],
|
|
1688
|
+
) -> dict[str, typing.Any]:
|
|
1689
|
+
"""Update ``DEPLOYED_IN`` edge properties for a single environment.
|
|
1690
|
+
|
|
1691
|
+
Performs targeted per-key ``SET`` / ``REMOVE`` on the one
|
|
1692
|
+
``(project)-[:DEPLOYED_IN]->(environment)`` edge, so it neither
|
|
1693
|
+
rewrites the other environments' edges nor relies on edge deletion
|
|
1694
|
+
(both of which behave unreliably on some Apache AGE builds). Protected
|
|
1695
|
+
structural keys are rejected. Returns the updated edge properties.
|
|
1696
|
+
"""
|
|
1697
|
+
extra = updates.model_extra or {}
|
|
1698
|
+
protected_keys = sorted(k for k in extra if k in _PROTECTED_ENV_KEYS)
|
|
1699
|
+
if protected_keys:
|
|
1700
|
+
raise fastapi.HTTPException(
|
|
1701
|
+
status_code=400,
|
|
1702
|
+
detail=(
|
|
1703
|
+
'Protected environment fields are not editable via this '
|
|
1704
|
+
f'endpoint: {protected_keys!r}'
|
|
1705
|
+
),
|
|
1706
|
+
)
|
|
1707
|
+
props = dict(extra)
|
|
1708
|
+
if not props:
|
|
1709
|
+
raise fastapi.HTTPException(
|
|
1710
|
+
status_code=400,
|
|
1711
|
+
detail='No editable edge properties provided',
|
|
1712
|
+
)
|
|
1713
|
+
|
|
1714
|
+
params: dict[str, typing.Any] = {
|
|
1715
|
+
'project_id': project_id,
|
|
1716
|
+
'org_slug': org_slug,
|
|
1717
|
+
'env_slug': env_slug,
|
|
1718
|
+
}
|
|
1719
|
+
set_parts: list[str] = []
|
|
1720
|
+
remove_parts: list[str] = []
|
|
1721
|
+
for i, (key, value) in enumerate(props.items()):
|
|
1722
|
+
if value is None:
|
|
1723
|
+
remove_parts.append(f'r.{escape_prop(key)}')
|
|
1724
|
+
else:
|
|
1725
|
+
param = f'edge_{i}'
|
|
1726
|
+
set_parts.append(f'r.{escape_prop(key)} = {{{param}}}')
|
|
1727
|
+
params[param] = value
|
|
1728
|
+
|
|
1729
|
+
mutations = ''
|
|
1730
|
+
if set_parts:
|
|
1731
|
+
mutations += ' SET ' + ', '.join(set_parts)
|
|
1732
|
+
if remove_parts:
|
|
1733
|
+
mutations += ' REMOVE ' + ', '.join(remove_parts)
|
|
1734
|
+
|
|
1735
|
+
query: str = (
|
|
1736
|
+
'MATCH (p:Project {{id: {project_id}}})'
|
|
1737
|
+
'-[:OWNED_BY]->(:Team)'
|
|
1738
|
+
'-[:BELONGS_TO]->(o:Organization {{slug: {org_slug}}})'
|
|
1739
|
+
' MATCH (p)-[r:DEPLOYED_IN]->'
|
|
1740
|
+
'(e:Environment {{slug: {env_slug}}})-[:BELONGS_TO]->(o)'
|
|
1741
|
+
+ mutations
|
|
1742
|
+
+ ' RETURN properties(r) AS props'
|
|
1743
|
+
)
|
|
1744
|
+
records = await db.execute(query, params, ['props'])
|
|
1745
|
+
if not records:
|
|
1746
|
+
raise fastapi.HTTPException(
|
|
1747
|
+
status_code=404,
|
|
1748
|
+
detail=(
|
|
1749
|
+
f'Project {project_id!r} is not deployed in '
|
|
1750
|
+
f'environment {env_slug!r}'
|
|
1751
|
+
),
|
|
1752
|
+
)
|
|
1753
|
+
edge_props: dict[str, typing.Any] = (
|
|
1754
|
+
graph.parse_agtype(records[0]['props']) or {}
|
|
1755
|
+
)
|
|
1756
|
+
return {
|
|
1757
|
+
k: v for k, v in edge_props.items() if k not in _PROTECTED_ENV_KEYS
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
|
|
1629
1761
|
@projects_router.get('/{project_id}', name='get_project')
|
|
1630
1762
|
async def get_project(
|
|
1631
1763
|
org_slug: str,
|
|
@@ -2035,9 +2167,16 @@ def _build_update_clauses(
|
|
|
2035
2167
|
new_edge_props_tpl = _edge_props_map(new_env_entries)
|
|
2036
2168
|
|
|
2037
2169
|
if data.environments is not None:
|
|
2038
|
-
#
|
|
2039
|
-
#
|
|
2040
|
-
#
|
|
2170
|
+
# Replace the project's DEPLOYED_IN edges wholesale: delete the
|
|
2171
|
+
# existing ones, then re-create them with inline edge properties
|
|
2172
|
+
# (mirroring ``create_project``). We deliberately avoid
|
|
2173
|
+
# ``MERGE (p)-[r]->(e) SET r = {map}``: some Apache AGE builds
|
|
2174
|
+
# silently no-op a full-property ``SET r = {map}`` on a
|
|
2175
|
+
# relationship, dropping every edge attribute, whereas inline
|
|
2176
|
+
# ``CREATE ... {props}`` persists reliably. The DELETE and CREATE
|
|
2177
|
+
# run in the same transaction as one ``execute`` call, so a
|
|
2178
|
+
# retried attempt (see _execute_project_update) rolls back wholly
|
|
2179
|
+
# and cannot accumulate duplicate edges.
|
|
2041
2180
|
rel_clauses += (
|
|
2042
2181
|
' WITH DISTINCT p, o'
|
|
2043
2182
|
' OPTIONAL MATCH'
|
|
@@ -2045,15 +2184,12 @@ def _build_update_clauses(
|
|
|
2045
2184
|
' DELETE old_env'
|
|
2046
2185
|
)
|
|
2047
2186
|
if new_env_entries:
|
|
2048
|
-
merge_set = (
|
|
2049
|
-
' SET r =' + new_edge_props_tpl if new_edge_props_tpl else ''
|
|
2050
|
-
)
|
|
2051
2187
|
rel_clauses += (
|
|
2052
2188
|
' WITH DISTINCT p, o'
|
|
2053
2189
|
f' UNWIND {new_env_tpl} AS entry'
|
|
2054
2190
|
' MATCH (e:Environment'
|
|
2055
2191
|
' {{slug: entry.slug}})-[:BELONGS_TO]->(o)'
|
|
2056
|
-
'
|
|
2192
|
+
' CREATE (p)-[:DEPLOYED_IN' + new_edge_props_tpl + ']->(e)'
|
|
2057
2193
|
)
|
|
2058
2194
|
|
|
2059
2195
|
return rel_clauses, new_env_params
|
|
@@ -27,7 +27,10 @@ from imbi_api import sbom
|
|
|
27
27
|
from imbi_api.auth import permissions
|
|
28
28
|
from imbi_api.endpoints._helpers import fetch_or_404
|
|
29
29
|
from imbi_api.endpoints.operations_log import complete_opslog_entry
|
|
30
|
+
from imbi_api.endpoints.projects import lookup_ops_log_performed_by
|
|
30
31
|
from imbi_api.plugins import call_with_timeout
|
|
32
|
+
from imbi_api.scoring import OptionalValkeyClient
|
|
33
|
+
from imbi_api.scoring import queue as score_queue
|
|
31
34
|
|
|
32
35
|
LOGGER = logging.getLogger(__name__)
|
|
33
36
|
|
|
@@ -163,6 +166,10 @@ class CurrentReleaseEnvironment(pydantic.BaseModel):
|
|
|
163
166
|
last_event_at: datetime.datetime | None = None
|
|
164
167
|
external_run_url: str | None = None
|
|
165
168
|
ci_status: CheckStatus | None = None
|
|
169
|
+
#: Deployer of the latest event when observed from a remote (e.g. the
|
|
170
|
+
#: GitHub ``deployment.creator.login``). ``None`` for in-product deploys,
|
|
171
|
+
#: where the operator is captured in the operations log instead.
|
|
172
|
+
performed_by: str | None = None
|
|
166
173
|
|
|
167
174
|
|
|
168
175
|
# -- Helpers ------------------------------------------------------------
|
|
@@ -711,6 +718,22 @@ async def list_current_releases(
|
|
|
711
718
|
db, org_slug, project_id, auth, by_env
|
|
712
719
|
)
|
|
713
720
|
|
|
721
|
+
# Backfill performed_by from operations_log for events whose AGE edge
|
|
722
|
+
# left it null (in-product deploy/promote path), mirroring the
|
|
723
|
+
# project-detail current-releases view so both agree.
|
|
724
|
+
enrich_targets: list[tuple[str, str, str]] = []
|
|
725
|
+
for env, release_raw, event in by_env.values():
|
|
726
|
+
if event is None or event.performed_by is not None:
|
|
727
|
+
continue
|
|
728
|
+
if release_raw is None:
|
|
729
|
+
continue
|
|
730
|
+
version = str(
|
|
731
|
+
release_raw.get('tag') or release_raw.get('committish') or ''
|
|
732
|
+
)
|
|
733
|
+
if version:
|
|
734
|
+
enrich_targets.append((project_id, env['slug'], version))
|
|
735
|
+
performed_by_by_key = await lookup_ops_log_performed_by(enrich_targets)
|
|
736
|
+
|
|
714
737
|
sortable: list[tuple[int, str, CurrentReleaseEnvironment]] = []
|
|
715
738
|
for env, release_raw, event in by_env.values():
|
|
716
739
|
release_resp = (
|
|
@@ -718,6 +741,18 @@ async def list_current_releases(
|
|
|
718
741
|
if release_raw is not None
|
|
719
742
|
else None
|
|
720
743
|
)
|
|
744
|
+
performed_by = event.performed_by if event else None
|
|
745
|
+
if (
|
|
746
|
+
performed_by is None
|
|
747
|
+
and event is not None
|
|
748
|
+
and release_raw is not None
|
|
749
|
+
):
|
|
750
|
+
version = str(
|
|
751
|
+
release_raw.get('tag') or release_raw.get('committish') or ''
|
|
752
|
+
)
|
|
753
|
+
performed_by = performed_by_by_key.get(
|
|
754
|
+
(project_id, env['slug'], version)
|
|
755
|
+
)
|
|
721
756
|
item = CurrentReleaseEnvironment(
|
|
722
757
|
environment=ReleaseEnvironmentRef(
|
|
723
758
|
slug=env['slug'], name=env['name']
|
|
@@ -727,6 +762,7 @@ async def list_current_releases(
|
|
|
727
762
|
last_event_at=event.timestamp if event else None,
|
|
728
763
|
external_run_url=event.external_run_url if event else None,
|
|
729
764
|
ci_status=ci_status_by_slug.get(env['slug']),
|
|
765
|
+
performed_by=performed_by,
|
|
730
766
|
)
|
|
731
767
|
sortable.append((env.get('sort_order') or 0, env['name'], item))
|
|
732
768
|
|
|
@@ -1170,6 +1206,7 @@ async def record_deployment(
|
|
|
1170
1206
|
env_slug: str,
|
|
1171
1207
|
data: DeploymentEventInput,
|
|
1172
1208
|
db: graph.Pool,
|
|
1209
|
+
valkey_client: OptionalValkeyClient,
|
|
1173
1210
|
_auth: typing.Annotated[
|
|
1174
1211
|
permissions.AuthContext,
|
|
1175
1212
|
fastapi.Depends(
|
|
@@ -1258,6 +1295,12 @@ async def record_deployment(
|
|
|
1258
1295
|
datetime.datetime.now(datetime.UTC),
|
|
1259
1296
|
)
|
|
1260
1297
|
|
|
1298
|
+
# A DeploymentStatusPolicy may score this project on its current
|
|
1299
|
+
# deployment status, so a new event can change the score.
|
|
1300
|
+
await score_queue.enqueue_recompute(
|
|
1301
|
+
valkey_client, project_id, 'deployment_status_change'
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1261
1304
|
return _edge_to_response(env, deployments)
|
|
1262
1305
|
|
|
1263
1306
|
|
|
@@ -31,6 +31,7 @@ PolicyType = (
|
|
|
31
31
|
| scoring_common.LinkPresencePolicy
|
|
32
32
|
| scoring_common.AgePolicy
|
|
33
33
|
| scoring_common.AnalysisResultPolicy
|
|
34
|
+
| scoring_common.DeploymentStatusPolicy
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
|
|
@@ -58,6 +59,7 @@ _NODE_PROPERTY_KEYS: frozenset[str] = frozenset(
|
|
|
58
59
|
'attribute_name',
|
|
59
60
|
'link_slug',
|
|
60
61
|
'result_slug',
|
|
62
|
+
'environment_slug',
|
|
61
63
|
'present_score',
|
|
62
64
|
'missing_score',
|
|
63
65
|
'value_score_map',
|