imbi-api 2.26.0__tar.gz → 2.26.2__tar.gz

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