imbi-api 2.9.3__tar.gz → 2.10.0__tar.gz

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