imbi-api 2.9.1__tar.gz → 2.9.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.9.1 → imbi_api-2.9.2}/.pre-commit-config.yaml +2 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/PKG-INFO +2 -2
- {imbi_api-2.9.1 → imbi_api-2.9.2}/pyproject.toml +2 -2
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/app.py +1 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/permissions.py +9 -2
- imbi_api-2.9.2/src/imbi_api/commit_sync/__init__.py +28 -0
- imbi_api-2.9.2/src/imbi_api/commit_sync/queue.py +269 -0
- imbi_api-2.9.2/src/imbi_api/commit_sync/service.py +311 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/events.py +7 -1
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/organizations.py +5 -0
- imbi_api-2.9.2/src/imbi_api/endpoints/project_commit_sync.py +83 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/lifespans.py +34 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/test_permissions.py +52 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_events.py +12 -0
- imbi_api-2.9.2/tests/endpoints/test_project_commit_sync.py +131 -0
- imbi_api-2.9.2/tests/test_commit_sync_queue.py +122 -0
- imbi_api-2.9.2/tests/test_commit_sync_service.py +168 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/uv.lock +5 -5
- {imbi_api-2.9.1 → imbi_api-2.9.2}/.github/workflows/deploy.yaml +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/.github/workflows/docs.yaml +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/.github/workflows/testing.yaml +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/.gitignore +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/.python-version +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/CLAUDE.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/CODE_REVIEW_PUNCHLIST.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/LICENSE +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/README.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/coderabbit.yaml +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/compose.yaml +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0001-record-architecture-decisions.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0002-authentication-and-authorization-architecture.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0003-email-sending-architecture.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0004-phase-5-authentication-enhancements.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0005-file-upload-storage-architecture.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0006-project-identity-and-multi-type.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0007-relationship-blueprints.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0008-plugin-system-architecture.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0009-database-driven-oauth-providers.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0010-identity-plugin-architecture.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0011-graph-based-project-scoring.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0012-plugin-manifest-service-template.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0013-deployment-plugin-type.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0014-generic-plugin-entity-abstraction.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr/0015-cyclonedx-1.7-sbom-standard.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/adr.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/configuration.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/index.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/docs/restore-backup.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/justfile +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/mkdocs.yml +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/mypy.ini +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/authorization_codes.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/local_auth.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/login_providers.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/membership.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/models.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/oauth.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/oauth_clients.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/password.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/seed.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/sessions.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/tokens.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/auth/totp.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/backfill_embeddings.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/blueprint_attributes.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/domain/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/domain/models.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/domain/scoring.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/email/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/email/client.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/email/dependencies.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/email/models.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/email/templates/base.html +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/email/templates/base.txt +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/email/templates/password_reset.html +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/email/templates/password_reset.txt +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/email/templates/welcome.html +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/email/templates/welcome.txt +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/email/templates.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/_credentials.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/_helpers.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/_json_fields.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/_pagination.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/_request_urls.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/admin.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/admin_plugins.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/api_keys.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/auth.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/auth_providers.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/blueprints.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/client_credentials.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/comments.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/dashboard.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/document_templates.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/documents.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/environments.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/graph_query.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/identity_plugins.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/link_definitions.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/local_auth.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/mcp_servers.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/mfa.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/oauth_metadata.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/operations_log.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/plugin_edges.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/plugin_entities.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/plugins.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/project_analysis.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/project_configuration.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/project_deployments.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/project_logs.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/project_plugins.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/project_type_plugins.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/project_types.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/projects.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/pull_requests.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/releases.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/roles.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/sa_api_keys.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/scoring.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/scoring_policies.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/search.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/service_accounts.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/service_plugins.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/status.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/tags.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/teams.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/third_party_services.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/uploads.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/user_activity.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/users.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/endpoints/webhooks.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/entrypoint.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/graph_sql.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/identity/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/identity/endpoints.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/identity/errors.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/identity/flows.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/identity/host_integration.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/identity/models.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/identity/repository.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/identity/resolution.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/identity/state.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/identity/sweeper.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/llm/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/llm/dependencies.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/mcp_test.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/middleware/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/middleware/rate_limit.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/models.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/openapi.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/patch.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/plugins/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/plugins/assignment_writer.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/plugins/assignments.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/plugins/credentials.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/plugins/installer.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/plugins/lifecycle.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/plugins/lifecycle_dispatch.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/plugins/reload.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/plugins/resolution.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/plugins/schemas.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/prompts/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/prompts/release_notes_system.md +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/py.typed +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/relationships.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/sbom.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/scoring/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/scoring/queue.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/settings.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/storage/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/storage/client.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/storage/dependencies.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/storage/thumbnails.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/src/imbi_api/storage/validation.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/test_api_key_auth.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/test_authentication.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/test_authorization.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/test_encryption.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/test_login_providers.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/test_membership.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/test_oauth.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/test_oauth_server.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/test_seed.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/test_sessions.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/auth/test_totp.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/clickhouse/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/clickhouse/test_client.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/clickhouse/test_init.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/clickhouse/test_privacy.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/email/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/email/test_client.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/email/test_init.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/email/test_integration.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/email/test_models.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/email/test_templates.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_admin.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_admin_plugins.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_api_keys.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_auth.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_auth_providers.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_blueprints.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_client_credentials.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_comments.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_credentials.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_dashboard.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_document_templates.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_documents.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_environments.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_graph_query.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_init.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_json_fields.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_link_definitions.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_local_auth.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_mcp_servers.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_mfa.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_operations_log.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_organizations.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_pagination.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_plugin_label_validation.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_plugins.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_project_analysis.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_project_configuration.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_project_deployments.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_project_logs.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_project_plugins.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_project_response_surfacing.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_project_type_plugins.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_project_types.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_projects.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_projects_helpers.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_pull_requests.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_releases.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_request_urls.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_roles.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_sa_api_keys.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_scoring.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_scoring_policies.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_search.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_service_accounts.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_service_plugins.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_service_writeback.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_status.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_tags.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_teams.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_third_party_services.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_uploads.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_user_activity.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_users.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/endpoints/test_webhooks.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/fixtures/sbom/npm-realistic.json +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/fixtures/sbom/pypi-realistic.json +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/fixtures/sbom/tiny.json +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/identity/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/identity/test_endpoints.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/identity/test_errors.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/identity/test_flows.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/identity/test_host_integration.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/identity/test_repository.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/identity/test_resolution.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/identity/test_state.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/identity/test_sweeper.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/middleware/test_rate_limit.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/storage/__init__.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/storage/test_client.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/storage/test_init.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/storage/test_thumbnails.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/storage/test_validation.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/support.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_app.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_assignment_writer.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_backfill_embeddings.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_blueprint_attributes.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_blueprints.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_entrypoint.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_graph_sql.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_init.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_installer.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_lifecycle_dispatch.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_lifespans.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_models.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_openapi.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_patch.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_plugin_schemas.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_plugins.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_sbom.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_scoring_queue.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_scoring_triggers.py +0 -0
- {imbi_api-2.9.1 → imbi_api-2.9.2}/tests/test_settings.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: imbi-api
|
|
3
|
-
Version: 2.9.
|
|
3
|
+
Version: 2.9.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: 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.9.
|
|
23
|
+
Requires-Dist: imbi-common[databases,llm,sentry,server]==2.9.2
|
|
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.9.
|
|
3
|
+
version = "2.9.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.9.
|
|
31
|
+
"imbi-common[databases,llm,sentry,server]==2.9.2",
|
|
32
32
|
"jinja2",
|
|
33
33
|
"jsonpatch>=1.33",
|
|
34
34
|
"mcp>=1.26.0",
|
|
@@ -13,7 +13,7 @@ import fastapi
|
|
|
13
13
|
import jwt
|
|
14
14
|
import pydantic
|
|
15
15
|
from fastapi import security
|
|
16
|
-
from imbi_common import graph
|
|
16
|
+
from imbi_common import access_log, graph
|
|
17
17
|
from imbi_common.auth import core
|
|
18
18
|
|
|
19
19
|
from imbi_api import models, settings
|
|
@@ -658,7 +658,14 @@ async def _authenticate_token(
|
|
|
658
658
|
treats the token as a JWT.
|
|
659
659
|
"""
|
|
660
660
|
if token.startswith('ik_'):
|
|
661
|
-
|
|
661
|
+
ctx = await authenticate_api_key(db, token, auth_settings)
|
|
662
|
+
# Cache the key's owner so imbi-common's access log renders the
|
|
663
|
+
# person (email / service-account slug) instead of the opaque
|
|
664
|
+
# ``ik_<id>`` it parses from the Authorization header.
|
|
665
|
+
access_log.remember_api_key_principal(
|
|
666
|
+
ctx.session_id or '', ctx.principal_name
|
|
667
|
+
)
|
|
668
|
+
return ctx
|
|
662
669
|
return await authenticate_jwt(db, token, auth_settings)
|
|
663
670
|
|
|
664
671
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""On-demand commit/tag history sync for imbi-api.
|
|
2
|
+
|
|
3
|
+
A project-scoped "Sync Commits & Tags" action (Project Doctor) enqueues a
|
|
4
|
+
Valkey-stream job; a background worker resolves the project's
|
|
5
|
+
``github-commit-sync`` webhook plugin, runs a full default-branch +
|
|
6
|
+
all-tags backfill via the plugin's service credential, and records the
|
|
7
|
+
last-sync status on the ``Project`` node for the UI to poll.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from imbi_api.commit_sync.queue import (
|
|
11
|
+
consume_commit_sync,
|
|
12
|
+
enqueue_commit_sync,
|
|
13
|
+
)
|
|
14
|
+
from imbi_api.commit_sync.service import (
|
|
15
|
+
CommitSyncStatus,
|
|
16
|
+
CommitSyncUnavailable,
|
|
17
|
+
read_status,
|
|
18
|
+
run_sync,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
'CommitSyncStatus',
|
|
23
|
+
'CommitSyncUnavailable',
|
|
24
|
+
'consume_commit_sync',
|
|
25
|
+
'enqueue_commit_sync',
|
|
26
|
+
'read_status',
|
|
27
|
+
'run_sync',
|
|
28
|
+
]
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Commit/tag-sync queue (Valkey Streams).
|
|
2
|
+
|
|
3
|
+
Mirrors :mod:`imbi_api.scoring.queue`: a single consumer group drains an
|
|
4
|
+
``imbi:commit-sync`` stream, with a per-project debounce, stale-entry
|
|
5
|
+
reclaim, and a dead-letter queue after repeated failures. Each job runs
|
|
6
|
+
a full commit/tag backfill via :func:`commit_sync.service.run_sync` and
|
|
7
|
+
records the outcome on the ``Project`` node.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import socket
|
|
16
|
+
import typing
|
|
17
|
+
from collections import abc
|
|
18
|
+
|
|
19
|
+
from imbi_common import graph
|
|
20
|
+
from valkey import asyncio as valkey
|
|
21
|
+
|
|
22
|
+
from imbi_api.commit_sync.service import (
|
|
23
|
+
CommitSyncUnavailable,
|
|
24
|
+
run_sync,
|
|
25
|
+
set_status,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
STREAM = 'imbi:commit-sync'
|
|
29
|
+
GROUP = 'commit-sync-workers'
|
|
30
|
+
CONSUMER_PREFIX = 'worker'
|
|
31
|
+
DLQ = 'imbi:commit-sync:dlq'
|
|
32
|
+
DEBOUNCE_PREFIX = 'imbi:commit-sync:debounce'
|
|
33
|
+
DEBOUNCE_SECONDS = 10
|
|
34
|
+
MAX_DELIVERIES = 3
|
|
35
|
+
CLAIM_IDLE_MS = 120_000
|
|
36
|
+
|
|
37
|
+
LOGGER = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def enqueue_commit_sync(
|
|
41
|
+
client: valkey.Valkey | None,
|
|
42
|
+
org_slug: str,
|
|
43
|
+
project_id: str,
|
|
44
|
+
requested_by: str | None = None,
|
|
45
|
+
) -> bool:
|
|
46
|
+
"""Debounce-then-XADD a commit-sync job. Returns True if enqueued.
|
|
47
|
+
|
|
48
|
+
Tolerates *client* being ``None`` (returns False) so the endpoint can
|
|
49
|
+
surface "queueing unavailable" rather than 500 when Valkey is down.
|
|
50
|
+
"""
|
|
51
|
+
if client is None:
|
|
52
|
+
return False
|
|
53
|
+
key = f'{DEBOUNCE_PREFIX}:{project_id}'
|
|
54
|
+
try:
|
|
55
|
+
acquired = await client.set(key, b'1', ex=DEBOUNCE_SECONDS, nx=True)
|
|
56
|
+
if not acquired:
|
|
57
|
+
return False
|
|
58
|
+
await client.xadd(
|
|
59
|
+
STREAM,
|
|
60
|
+
{
|
|
61
|
+
'org_slug': org_slug,
|
|
62
|
+
'project_id': project_id,
|
|
63
|
+
'requested_by': requested_by or 'system',
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
except Exception:
|
|
67
|
+
# Release the debounce key so a transient Valkey write failure
|
|
68
|
+
# doesn't block the user's immediate retry for DEBOUNCE_SECONDS.
|
|
69
|
+
try:
|
|
70
|
+
await client.delete(key)
|
|
71
|
+
except Exception:
|
|
72
|
+
LOGGER.exception('failed to clear debounce key for %s', project_id)
|
|
73
|
+
LOGGER.exception('enqueue_commit_sync failed for %s', project_id)
|
|
74
|
+
return False
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def ensure_group(client: valkey.Valkey) -> None:
|
|
79
|
+
try:
|
|
80
|
+
await client.xgroup_create(STREAM, GROUP, id='$', mkstream=True)
|
|
81
|
+
except Exception as err:
|
|
82
|
+
if 'BUSYGROUP' not in str(err):
|
|
83
|
+
LOGGER.exception('xgroup_create failed')
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _process_message(db: graph.Graph, fields: dict[str, str]) -> None:
|
|
88
|
+
project_id = fields.get('project_id')
|
|
89
|
+
org_slug = fields.get('org_slug') or ''
|
|
90
|
+
requested_by = fields.get('requested_by') or 'system'
|
|
91
|
+
if not project_id:
|
|
92
|
+
return
|
|
93
|
+
await set_status(
|
|
94
|
+
db, project_id, status='running', requested_by=requested_by
|
|
95
|
+
)
|
|
96
|
+
try:
|
|
97
|
+
commits, tags = await run_sync(db, org_slug, project_id)
|
|
98
|
+
except CommitSyncUnavailable as exc:
|
|
99
|
+
# Misconfiguration, not transient: record and don't retry.
|
|
100
|
+
LOGGER.warning('commit-sync unavailable for %s: %s', project_id, exc)
|
|
101
|
+
await set_status(
|
|
102
|
+
db,
|
|
103
|
+
project_id,
|
|
104
|
+
status='failed',
|
|
105
|
+
requested_by=requested_by,
|
|
106
|
+
error=str(exc),
|
|
107
|
+
)
|
|
108
|
+
return
|
|
109
|
+
except Exception:
|
|
110
|
+
# Persist a generic, user-safe message; the polling endpoint
|
|
111
|
+
# exposes this status, so keep raw exception detail in logs only.
|
|
112
|
+
LOGGER.exception('commit-sync run failed for %s', project_id)
|
|
113
|
+
await set_status(
|
|
114
|
+
db,
|
|
115
|
+
project_id,
|
|
116
|
+
status='failed',
|
|
117
|
+
requested_by=requested_by,
|
|
118
|
+
error='Commit sync failed. See server logs for details.',
|
|
119
|
+
)
|
|
120
|
+
raise
|
|
121
|
+
LOGGER.info(
|
|
122
|
+
'commit-sync for %s recorded %d commits, %d tags',
|
|
123
|
+
project_id,
|
|
124
|
+
commits,
|
|
125
|
+
tags,
|
|
126
|
+
)
|
|
127
|
+
await set_status(
|
|
128
|
+
db,
|
|
129
|
+
project_id,
|
|
130
|
+
status='success',
|
|
131
|
+
requested_by=requested_by,
|
|
132
|
+
commits=commits,
|
|
133
|
+
tags=tags,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _decode_fields(
|
|
138
|
+
raw: abc.Mapping[bytes | str, bytes | str],
|
|
139
|
+
) -> dict[str, str]:
|
|
140
|
+
return {
|
|
141
|
+
(k.decode() if isinstance(k, bytes) else k): (
|
|
142
|
+
v.decode() if isinstance(v, bytes) else v
|
|
143
|
+
)
|
|
144
|
+
for k, v in raw.items()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def _claim_stale(
|
|
149
|
+
client: valkey.Valkey,
|
|
150
|
+
consumer: str,
|
|
151
|
+
) -> list[tuple[bytes, abc.Mapping[bytes | str, bytes | str]]]:
|
|
152
|
+
try:
|
|
153
|
+
result = await client.xautoclaim(
|
|
154
|
+
STREAM,
|
|
155
|
+
GROUP,
|
|
156
|
+
consumer,
|
|
157
|
+
min_idle_time=CLAIM_IDLE_MS,
|
|
158
|
+
start_id='0-0',
|
|
159
|
+
count=16,
|
|
160
|
+
)
|
|
161
|
+
except Exception as err: # noqa: BLE001
|
|
162
|
+
LOGGER.debug('xautoclaim failed: %s', err)
|
|
163
|
+
return []
|
|
164
|
+
if isinstance(result, (list, tuple)) and len(result) >= 2: # type: ignore[arg-type]
|
|
165
|
+
msgs: object = result[1] # type: ignore[index]
|
|
166
|
+
if isinstance(msgs, list):
|
|
167
|
+
return msgs # type: ignore[return-value]
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _maybe_dead_letter(
|
|
172
|
+
client: valkey.Valkey,
|
|
173
|
+
msg_id: bytes,
|
|
174
|
+
fields: dict[str, str],
|
|
175
|
+
) -> bool:
|
|
176
|
+
try:
|
|
177
|
+
info = await client.xpending_range(
|
|
178
|
+
STREAM, GROUP, min=msg_id, max=msg_id, count=1
|
|
179
|
+
)
|
|
180
|
+
except Exception: # noqa: BLE001
|
|
181
|
+
return False
|
|
182
|
+
if not info:
|
|
183
|
+
return False
|
|
184
|
+
entry: object = info[0] # type: ignore[index]
|
|
185
|
+
delivered: int | None = None
|
|
186
|
+
if isinstance(entry, dict):
|
|
187
|
+
raw_delivered = entry.get('times_delivered') # type: ignore[union-attr]
|
|
188
|
+
if raw_delivered is not None:
|
|
189
|
+
delivered = int(raw_delivered) # type: ignore[arg-type]
|
|
190
|
+
elif isinstance(entry, (list, tuple)) and len(entry) >= 4: # type: ignore[arg-type]
|
|
191
|
+
raw_delivered = entry[3] # type: ignore[index]
|
|
192
|
+
if raw_delivered is not None:
|
|
193
|
+
delivered = int(raw_delivered) # type: ignore[arg-type]
|
|
194
|
+
if delivered is not None and delivered >= MAX_DELIVERIES:
|
|
195
|
+
await client.xadd(DLQ, fields)
|
|
196
|
+
await client.xack(STREAM, GROUP, msg_id)
|
|
197
|
+
LOGGER.warning(
|
|
198
|
+
'dead-lettered commit-sync msg %s after %s deliveries',
|
|
199
|
+
msg_id,
|
|
200
|
+
delivered,
|
|
201
|
+
)
|
|
202
|
+
return True
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def _handle_entries(
|
|
207
|
+
client: valkey.Valkey,
|
|
208
|
+
entries: list[tuple[bytes, abc.Mapping[bytes | str, bytes | str]]],
|
|
209
|
+
db: graph.Graph,
|
|
210
|
+
check_dlq: bool = False,
|
|
211
|
+
) -> None:
|
|
212
|
+
for msg_id, raw_fields in entries:
|
|
213
|
+
fields = _decode_fields(raw_fields)
|
|
214
|
+
if check_dlq and await _maybe_dead_letter(client, msg_id, fields):
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
await _process_message(db, fields)
|
|
218
|
+
except Exception:
|
|
219
|
+
LOGGER.exception('commit-sync failed for %s', fields)
|
|
220
|
+
continue
|
|
221
|
+
await client.xack(STREAM, GROUP, msg_id)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def consume_commit_sync(
|
|
225
|
+
client: valkey.Valkey,
|
|
226
|
+
db: graph.Graph,
|
|
227
|
+
consumer: str | None = None,
|
|
228
|
+
stop: asyncio.Event | None = None,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Run the commit-sync consumer loop until *stop* is set."""
|
|
231
|
+
# Derive a per-process consumer name so concurrent workers don't share
|
|
232
|
+
# a Pending Entries List and stale-claim each other's in-flight jobs.
|
|
233
|
+
consumer = (
|
|
234
|
+
consumer or f'{CONSUMER_PREFIX}-{socket.gethostname()}-{os.getpid()}'
|
|
235
|
+
)
|
|
236
|
+
await ensure_group(client)
|
|
237
|
+
LOGGER.info('Commit-sync consumer loop running (consumer=%s)', consumer)
|
|
238
|
+
while stop is None or not stop.is_set():
|
|
239
|
+
stale = await _claim_stale(client, consumer)
|
|
240
|
+
if stale:
|
|
241
|
+
try:
|
|
242
|
+
await _handle_entries(client, stale, db, check_dlq=True)
|
|
243
|
+
except Exception:
|
|
244
|
+
LOGGER.exception('commit-sync stale-entry handling failed')
|
|
245
|
+
await asyncio.sleep(1)
|
|
246
|
+
continue
|
|
247
|
+
try:
|
|
248
|
+
response = await client.xreadgroup(
|
|
249
|
+
GROUP,
|
|
250
|
+
consumer,
|
|
251
|
+
{STREAM: '>'},
|
|
252
|
+
count=16,
|
|
253
|
+
block=2000,
|
|
254
|
+
)
|
|
255
|
+
except Exception:
|
|
256
|
+
LOGGER.exception('xreadgroup failed')
|
|
257
|
+
await asyncio.sleep(1)
|
|
258
|
+
continue
|
|
259
|
+
if not response:
|
|
260
|
+
continue
|
|
261
|
+
for _stream, entries in typing.cast(
|
|
262
|
+
'list[tuple[object, list[typing.Any]]]', response
|
|
263
|
+
):
|
|
264
|
+
try:
|
|
265
|
+
await _handle_entries(client, entries, db)
|
|
266
|
+
except Exception:
|
|
267
|
+
LOGGER.exception('commit-sync entry handling failed')
|
|
268
|
+
await asyncio.sleep(1)
|
|
269
|
+
break
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Resolve + invoke the commit-sync plugin and track its status.
|
|
2
|
+
|
|
3
|
+
The on-demand sync acts with the ``github-commit-sync`` plugin's
|
|
4
|
+
*service* credential (PAT or GitHub App), so there is no acting user: the
|
|
5
|
+
worker resolves the plugin attached to a ``ThirdPartyService`` the project
|
|
6
|
+
``EXISTS_IN``, builds the :class:`PluginContext` it needs (project links +
|
|
7
|
+
the connected ``service_plugins`` for host resolution), decrypts the
|
|
8
|
+
credential, and awaits the plugin's ``sync_all_history`` method.
|
|
9
|
+
|
|
10
|
+
Last-sync state is persisted as a handful of properties on the ``Project``
|
|
11
|
+
node so the UI can poll it without a dedicated status store.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime
|
|
17
|
+
import logging
|
|
18
|
+
import typing
|
|
19
|
+
from collections import abc
|
|
20
|
+
|
|
21
|
+
import pydantic
|
|
22
|
+
from imbi_common import graph
|
|
23
|
+
from imbi_common.plugins.base import PluginContext, ServicePlugin
|
|
24
|
+
from imbi_common.plugins.errors import PluginNotFoundError
|
|
25
|
+
from imbi_common.plugins.registry import RegistryEntry, get_plugin
|
|
26
|
+
|
|
27
|
+
from imbi_api.plugins import parse_options
|
|
28
|
+
from imbi_api.plugins.credentials import get_plugin_credentials
|
|
29
|
+
|
|
30
|
+
LOGGER = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_COMMIT_SYNC_SLUG = 'github-commit-sync'
|
|
33
|
+
# Persisted error strings are truncated so a noisy upstream message can't
|
|
34
|
+
# bloat the Project node.
|
|
35
|
+
_MAX_ERROR_LEN = 500
|
|
36
|
+
|
|
37
|
+
SyncState = typing.Literal['idle', 'queued', 'running', 'success', 'failed']
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CommitSyncUnavailable(Exception):
|
|
41
|
+
"""No ``github-commit-sync`` plugin is reachable for the project."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CommitSyncStatus(pydantic.BaseModel):
|
|
45
|
+
"""Last-sync state for a project's commit/tag history."""
|
|
46
|
+
|
|
47
|
+
status: SyncState = 'idle'
|
|
48
|
+
last_synced_at: datetime.datetime | None = None
|
|
49
|
+
commits_synced: int | None = None
|
|
50
|
+
tags_synced: int | None = None
|
|
51
|
+
error: str | None = None
|
|
52
|
+
requested_by: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _ResolvedCommitSync(typing.NamedTuple):
|
|
56
|
+
plugin_id: str
|
|
57
|
+
entry: RegistryEntry
|
|
58
|
+
tps_slug: str
|
|
59
|
+
service_endpoint: str | None
|
|
60
|
+
service_plugins: list[ServicePlugin]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _resolve_plugin(
|
|
64
|
+
db: graph.Graph, project_id: str
|
|
65
|
+
) -> _ResolvedCommitSync:
|
|
66
|
+
"""Find the ``github-commit-sync`` plugin attached to a service the
|
|
67
|
+
project ``EXISTS_IN`` and gather the sibling plugins on that service.
|
|
68
|
+
|
|
69
|
+
Raises :class:`CommitSyncUnavailable` when no such plugin is
|
|
70
|
+
configured (or its registry entry is missing).
|
|
71
|
+
"""
|
|
72
|
+
query: typing.LiteralString = """
|
|
73
|
+
MATCH (proj:Project {{id: {project_id}}})
|
|
74
|
+
-[:EXISTS_IN]->(tps:ThirdPartyService)
|
|
75
|
+
-[:HAS_PLUGIN]->(csp:Plugin {{plugin_slug: {slug}}})
|
|
76
|
+
OPTIONAL MATCH (tps)-[:HAS_PLUGIN]->(sib:Plugin)
|
|
77
|
+
WITH tps, csp,
|
|
78
|
+
collect(DISTINCT {{slug: sib.plugin_slug, options: sib.options}})
|
|
79
|
+
AS siblings
|
|
80
|
+
RETURN csp.id AS plugin_id,
|
|
81
|
+
tps.slug AS tps_slug,
|
|
82
|
+
tps.api_endpoint AS api_endpoint,
|
|
83
|
+
siblings AS siblings
|
|
84
|
+
LIMIT 1
|
|
85
|
+
"""
|
|
86
|
+
records = await db.execute(
|
|
87
|
+
query,
|
|
88
|
+
{'project_id': project_id, 'slug': _COMMIT_SYNC_SLUG},
|
|
89
|
+
['plugin_id', 'tps_slug', 'api_endpoint', 'siblings'],
|
|
90
|
+
)
|
|
91
|
+
if not records:
|
|
92
|
+
raise CommitSyncUnavailable(
|
|
93
|
+
'No github-commit-sync plugin is connected to a service this '
|
|
94
|
+
'project belongs to; configure it on the GitHub service.'
|
|
95
|
+
)
|
|
96
|
+
record = records[0]
|
|
97
|
+
plugin_id = graph.parse_agtype(record.get('plugin_id'))
|
|
98
|
+
if not plugin_id:
|
|
99
|
+
raise CommitSyncUnavailable(
|
|
100
|
+
'github-commit-sync plugin row is missing an id'
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
entry = get_plugin(_COMMIT_SYNC_SLUG)
|
|
104
|
+
except PluginNotFoundError as exc:
|
|
105
|
+
raise CommitSyncUnavailable(
|
|
106
|
+
'github-commit-sync plugin is not loaded in the registry'
|
|
107
|
+
) from exc
|
|
108
|
+
tps_slug = graph.parse_agtype(record.get('tps_slug'))
|
|
109
|
+
api_endpoint = graph.parse_agtype(record.get('api_endpoint'))
|
|
110
|
+
siblings = typing.cast(
|
|
111
|
+
'list[dict[str, typing.Any]]',
|
|
112
|
+
graph.parse_agtype(record.get('siblings')) or [],
|
|
113
|
+
)
|
|
114
|
+
service_plugins: list[ServicePlugin] = []
|
|
115
|
+
for sib in siblings:
|
|
116
|
+
slug = sib.get('slug')
|
|
117
|
+
if not slug:
|
|
118
|
+
continue
|
|
119
|
+
service_plugins.append(
|
|
120
|
+
ServicePlugin(
|
|
121
|
+
slug=str(slug), options=parse_options(sib.get('options'))
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return _ResolvedCommitSync(
|
|
125
|
+
plugin_id=str(plugin_id),
|
|
126
|
+
entry=entry,
|
|
127
|
+
tps_slug=str(tps_slug) if tps_slug else '',
|
|
128
|
+
service_endpoint=str(api_endpoint) if api_endpoint else None,
|
|
129
|
+
service_plugins=service_plugins,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _build_context(
|
|
134
|
+
db: graph.Graph,
|
|
135
|
+
org_slug: str,
|
|
136
|
+
project_id: str,
|
|
137
|
+
resolved: _ResolvedCommitSync,
|
|
138
|
+
) -> PluginContext:
|
|
139
|
+
"""Assemble the :class:`PluginContext` the plugin needs (no actor)."""
|
|
140
|
+
# Imported here (not at module load) so the worker/service module
|
|
141
|
+
# never pulls the endpoints package at import time.
|
|
142
|
+
from imbi_api.endpoints import _helpers
|
|
143
|
+
|
|
144
|
+
project_slug, team_slug = await _helpers.lookup_project_slugs(
|
|
145
|
+
db, project_id
|
|
146
|
+
)
|
|
147
|
+
project_links = await _helpers.lookup_project_links(db, project_id)
|
|
148
|
+
project_type_slugs = await _helpers.lookup_project_type_slugs(
|
|
149
|
+
db, project_id
|
|
150
|
+
)
|
|
151
|
+
service_connections = await _helpers.lookup_project_exists_in(
|
|
152
|
+
db, project_id
|
|
153
|
+
)
|
|
154
|
+
assignment_options: dict[str, typing.Any] = {
|
|
155
|
+
'service_slug': resolved.tps_slug,
|
|
156
|
+
}
|
|
157
|
+
if resolved.service_endpoint:
|
|
158
|
+
assignment_options['service_endpoint'] = resolved.service_endpoint
|
|
159
|
+
return PluginContext(
|
|
160
|
+
project_id=project_id,
|
|
161
|
+
project_slug=project_slug,
|
|
162
|
+
org_slug=org_slug,
|
|
163
|
+
team_slug=team_slug,
|
|
164
|
+
assignment_options=assignment_options,
|
|
165
|
+
service_plugins=resolved.service_plugins,
|
|
166
|
+
project_links=project_links,
|
|
167
|
+
project_type_slugs=project_type_slugs,
|
|
168
|
+
third_party_service_slug=resolved.tps_slug or None,
|
|
169
|
+
service_connections=service_connections,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def check_available(db: graph.Graph, project_id: str) -> None:
|
|
174
|
+
"""Raise :class:`CommitSyncUnavailable` if the project can't be synced.
|
|
175
|
+
|
|
176
|
+
Used by the enqueue endpoint to fail fast (400) rather than queueing a
|
|
177
|
+
job that the worker can only mark failed.
|
|
178
|
+
"""
|
|
179
|
+
await _resolve_plugin(db, project_id)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def run_sync(
|
|
183
|
+
db: graph.Graph, org_slug: str, project_id: str
|
|
184
|
+
) -> tuple[int, int]:
|
|
185
|
+
"""Resolve the commit-sync plugin and run a full history backfill.
|
|
186
|
+
|
|
187
|
+
Returns ``(commits_recorded, tags_recorded)``. Raises
|
|
188
|
+
:class:`CommitSyncUnavailable` when no plugin/credential is
|
|
189
|
+
configured; other failures propagate so the caller can record them.
|
|
190
|
+
"""
|
|
191
|
+
resolved = await _resolve_plugin(db, project_id)
|
|
192
|
+
ctx = await _build_context(db, org_slug, project_id, resolved)
|
|
193
|
+
credentials = await get_plugin_credentials(
|
|
194
|
+
db, resolved.plugin_id, resolved.entry
|
|
195
|
+
)
|
|
196
|
+
handler = resolved.entry.handler_cls()
|
|
197
|
+
sync = getattr(handler, 'sync_all_history', None)
|
|
198
|
+
if sync is None:
|
|
199
|
+
raise CommitSyncUnavailable(
|
|
200
|
+
'github-commit-sync plugin does not implement sync_all_history; '
|
|
201
|
+
'upgrade imbi-plugin-github'
|
|
202
|
+
)
|
|
203
|
+
sync_fn = typing.cast(
|
|
204
|
+
'abc.Callable[..., abc.Awaitable[tuple[int, int]]]', sync
|
|
205
|
+
)
|
|
206
|
+
return await sync_fn(ctx=ctx, credentials=credentials)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _now_iso() -> str:
|
|
210
|
+
return datetime.datetime.now(datetime.UTC).isoformat()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def set_status(
|
|
214
|
+
db: graph.Graph,
|
|
215
|
+
project_id: str,
|
|
216
|
+
*,
|
|
217
|
+
status: SyncState,
|
|
218
|
+
requested_by: str = '',
|
|
219
|
+
commits: int = 0,
|
|
220
|
+
tags: int = 0,
|
|
221
|
+
error: str = '',
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Persist last-sync state on the ``Project`` node (best-effort)."""
|
|
224
|
+
query: typing.LiteralString = """
|
|
225
|
+
MATCH (p:Project {{id: {project_id}}})
|
|
226
|
+
SET p.commit_sync_status = {status},
|
|
227
|
+
p.commit_sync_at = {at},
|
|
228
|
+
p.commit_sync_by = {by},
|
|
229
|
+
p.commit_sync_commits = {commits},
|
|
230
|
+
p.commit_sync_tags = {tags},
|
|
231
|
+
p.commit_sync_error = {error}
|
|
232
|
+
RETURN p.id AS id
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
await db.execute(
|
|
236
|
+
query,
|
|
237
|
+
{
|
|
238
|
+
'project_id': project_id,
|
|
239
|
+
'status': status,
|
|
240
|
+
'at': _now_iso(),
|
|
241
|
+
'by': requested_by,
|
|
242
|
+
'commits': commits,
|
|
243
|
+
'tags': tags,
|
|
244
|
+
'error': error[:_MAX_ERROR_LEN],
|
|
245
|
+
},
|
|
246
|
+
['id'],
|
|
247
|
+
)
|
|
248
|
+
except Exception: # noqa: BLE001
|
|
249
|
+
LOGGER.warning(
|
|
250
|
+
'Failed to persist commit-sync status for project %s',
|
|
251
|
+
project_id,
|
|
252
|
+
exc_info=True,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _opt_str(value: object) -> str | None:
|
|
257
|
+
text = str(value) if value is not None else ''
|
|
258
|
+
return text or None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _opt_int(value: object) -> int | None:
|
|
262
|
+
if isinstance(value, bool):
|
|
263
|
+
return int(value)
|
|
264
|
+
if isinstance(value, int):
|
|
265
|
+
return value
|
|
266
|
+
if isinstance(value, str):
|
|
267
|
+
try:
|
|
268
|
+
return int(value)
|
|
269
|
+
except ValueError:
|
|
270
|
+
return None
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def read_status(db: graph.Graph, project_id: str) -> CommitSyncStatus:
|
|
275
|
+
"""Read last-sync state from the ``Project`` node (``idle`` default)."""
|
|
276
|
+
query: typing.LiteralString = """
|
|
277
|
+
MATCH (p:Project {{id: {project_id}}})
|
|
278
|
+
RETURN p.commit_sync_status AS status,
|
|
279
|
+
p.commit_sync_at AS at,
|
|
280
|
+
p.commit_sync_by AS by,
|
|
281
|
+
p.commit_sync_commits AS commits,
|
|
282
|
+
p.commit_sync_tags AS tags,
|
|
283
|
+
p.commit_sync_error AS error
|
|
284
|
+
"""
|
|
285
|
+
records = await db.execute(
|
|
286
|
+
query,
|
|
287
|
+
{'project_id': project_id},
|
|
288
|
+
['status', 'at', 'by', 'commits', 'tags', 'error'],
|
|
289
|
+
)
|
|
290
|
+
if not records:
|
|
291
|
+
return CommitSyncStatus()
|
|
292
|
+
row = records[0]
|
|
293
|
+
status_raw = graph.parse_agtype(row.get('status'))
|
|
294
|
+
status: SyncState = 'idle'
|
|
295
|
+
if status_raw in ('queued', 'running', 'success', 'failed', 'idle'):
|
|
296
|
+
status = status_raw
|
|
297
|
+
at_raw = _opt_str(graph.parse_agtype(row.get('at')))
|
|
298
|
+
last_synced_at: datetime.datetime | None = None
|
|
299
|
+
if at_raw:
|
|
300
|
+
try:
|
|
301
|
+
last_synced_at = datetime.datetime.fromisoformat(at_raw)
|
|
302
|
+
except ValueError:
|
|
303
|
+
last_synced_at = None
|
|
304
|
+
return CommitSyncStatus(
|
|
305
|
+
status=status,
|
|
306
|
+
last_synced_at=last_synced_at,
|
|
307
|
+
commits_synced=_opt_int(graph.parse_agtype(row.get('commits'))),
|
|
308
|
+
tags_synced=_opt_int(graph.parse_agtype(row.get('tags'))),
|
|
309
|
+
error=_opt_str(graph.parse_agtype(row.get('error'))),
|
|
310
|
+
requested_by=_opt_str(graph.parse_agtype(row.get('by'))),
|
|
311
|
+
)
|