imbi-api 2.32.2__tar.gz → 2.32.3__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 (337) hide show
  1. {imbi_api-2.32.2 → imbi_api-2.32.3}/PKG-INFO +2 -2
  2. {imbi_api-2.32.2 → imbi_api-2.32.3}/pyproject.toml +2 -2
  3. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/project_deployments.py +98 -0
  4. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/releases.py +198 -44
  5. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_project_deployments.py +140 -0
  6. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_releases.py +235 -8
  7. {imbi_api-2.32.2 → imbi_api-2.32.3}/.gitignore +0 -0
  8. {imbi_api-2.32.2 → imbi_api-2.32.3}/README.md +0 -0
  9. {imbi_api-2.32.2 → imbi_api-2.32.3}/moon.yml +0 -0
  10. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/README.md +0 -0
  11. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/__init__.py +0 -0
  12. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/app.py +0 -0
  13. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/__init__.py +0 -0
  14. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/authorization_codes.py +0 -0
  15. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/autonomous.py +0 -0
  16. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/internal_services.py +0 -0
  17. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/local_auth.py +0 -0
  18. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/login_providers.py +0 -0
  19. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/membership.py +0 -0
  20. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/models.py +0 -0
  21. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/oauth.py +0 -0
  22. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/oauth_clients.py +0 -0
  23. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/password.py +0 -0
  24. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/permissions.py +0 -0
  25. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/principals.py +0 -0
  26. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/seed.py +0 -0
  27. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/sessions.py +0 -0
  28. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/tokens.py +0 -0
  29. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/auth/totp.py +0 -0
  30. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/backfill_embeddings.py +0 -0
  31. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/blueprint_attributes.py +0 -0
  32. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/blueprint_compliance.py +0 -0
  33. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/commit_sync/__init__.py +0 -0
  34. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/commit_sync/queue.py +0 -0
  35. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/commit_sync/service.py +0 -0
  36. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/component_facts.py +0 -0
  37. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/deployment_status_repair.py +0 -0
  38. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/deployment_sweeper.py +0 -0
  39. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/deployment_sync/__init__.py +0 -0
  40. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/deployment_sync/queue.py +0 -0
  41. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/deployment_sync/service.py +0 -0
  42. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/documents/__init__.py +0 -0
  43. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/documents/read_sweeper.py +0 -0
  44. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/domain/__init__.py +0 -0
  45. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/domain/models.py +0 -0
  46. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/domain/scoring.py +0 -0
  47. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/drift.py +0 -0
  48. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/email/__init__.py +0 -0
  49. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/email/client.py +0 -0
  50. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/email/dependencies.py +0 -0
  51. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/email/models.py +0 -0
  52. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/email/templates/base.html +0 -0
  53. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/email/templates/base.txt +0 -0
  54. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/email/templates/password_reset.html +0 -0
  55. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/email/templates/password_reset.txt +0 -0
  56. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/email/templates/welcome.html +0 -0
  57. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/email/templates/welcome.txt +0 -0
  58. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/email/templates.py +0 -0
  59. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/__init__.py +0 -0
  60. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/_credentials.py +0 -0
  61. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/_document_events.py +0 -0
  62. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/_document_history.py +0 -0
  63. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/_document_reads.py +0 -0
  64. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/_document_scope.py +0 -0
  65. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/_helpers.py +0 -0
  66. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/_json_fields.py +0 -0
  67. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/_pagination.py +0 -0
  68. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/_request_urls.py +0 -0
  69. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/_search_index.py +0 -0
  70. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/admin.py +0 -0
  71. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/admin_plugins.py +0 -0
  72. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/ai_models.py +0 -0
  73. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/ai_providers.py +0 -0
  74. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/api_keys.py +0 -0
  75. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/auth.py +0 -0
  76. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/auth_providers.py +0 -0
  77. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/blueprints.py +0 -0
  78. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/client_credentials.py +0 -0
  79. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/comments.py +0 -0
  80. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/components.py +0 -0
  81. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/dashboard.py +0 -0
  82. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/document_analytics.py +0 -0
  83. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/document_likes.py +0 -0
  84. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/document_presence.py +0 -0
  85. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/document_templates.py +0 -0
  86. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/document_versions.py +0 -0
  87. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/documents.py +0 -0
  88. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/environments.py +0 -0
  89. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/events.py +0 -0
  90. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/graph_query.py +0 -0
  91. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/iggy_connectors.py +0 -0
  92. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/integrations.py +0 -0
  93. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/link_definitions.py +0 -0
  94. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/local_auth.py +0 -0
  95. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/maintenance.py +0 -0
  96. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/mcp_servers.py +0 -0
  97. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/mfa.py +0 -0
  98. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/oauth_metadata.py +0 -0
  99. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/operations_log.py +0 -0
  100. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/organizations.py +0 -0
  101. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/plugin_edges.py +0 -0
  102. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/plugin_entities.py +0 -0
  103. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/plugins.py +0 -0
  104. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/project_analysis.py +0 -0
  105. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/project_commit_sync.py +0 -0
  106. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/project_configuration.py +0 -0
  107. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/project_incidents.py +0 -0
  108. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/project_integrations.py +0 -0
  109. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/project_lifecycle.py +0 -0
  110. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/project_logs.py +0 -0
  111. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/project_plugins.py +0 -0
  112. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/project_pr_sync.py +0 -0
  113. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/project_types.py +0 -0
  114. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/projects.py +0 -0
  115. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/pull_requests.py +0 -0
  116. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/roles.py +0 -0
  117. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/sa_api_keys.py +0 -0
  118. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/scoring.py +0 -0
  119. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/scoring_policies.py +0 -0
  120. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/search.py +0 -0
  121. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/service_accounts.py +0 -0
  122. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/status.py +0 -0
  123. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/tags.py +0 -0
  124. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/teams.py +0 -0
  125. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/uploads.py +0 -0
  126. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/user_activity.py +0 -0
  127. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/users.py +0 -0
  128. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/endpoints/webhooks.py +0 -0
  129. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/entrypoint.py +0 -0
  130. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/graph_sql.py +0 -0
  131. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/identity/__init__.py +0 -0
  132. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/identity/attribution.py +0 -0
  133. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/identity/endpoints.py +0 -0
  134. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/identity/errors.py +0 -0
  135. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/identity/flows.py +0 -0
  136. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/identity/host_integration.py +0 -0
  137. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/identity/models.py +0 -0
  138. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/identity/repository.py +0 -0
  139. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/identity/resolution.py +0 -0
  140. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/identity/state.py +0 -0
  141. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/identity/sweeper.py +0 -0
  142. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/lifespans.py +0 -0
  143. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/llm/__init__.py +0 -0
  144. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/llm/dependencies.py +0 -0
  145. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/maintenance/__init__.py +0 -0
  146. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/maintenance/log.py +0 -0
  147. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/maintenance/operations.py +0 -0
  148. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/maintenance/registry.py +0 -0
  149. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/maintenance/state.py +0 -0
  150. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/maintenance/worker.py +0 -0
  151. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/mcp_test.py +0 -0
  152. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/middleware/__init__.py +0 -0
  153. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/middleware/rate_limit.py +0 -0
  154. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/models.py +0 -0
  155. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/openapi.py +0 -0
  156. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/orphan_releases.py +0 -0
  157. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/plugins/__init__.py +0 -0
  158. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/plugins/assignment_writer.py +0 -0
  159. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/plugins/assignments.py +0 -0
  160. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/plugins/credentials.py +0 -0
  161. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/plugins/installer.py +0 -0
  162. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/plugins/lifecycle.py +0 -0
  163. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/plugins/lifecycle_dispatch.py +0 -0
  164. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/plugins/reload.py +0 -0
  165. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/plugins/resolution.py +0 -0
  166. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/plugins/schemas.py +0 -0
  167. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/pr_sync/__init__.py +0 -0
  168. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/pr_sync/queue.py +0 -0
  169. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/pr_sync/service.py +0 -0
  170. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/prompts/__init__.py +0 -0
  171. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/prompts/release_notes_system.md +0 -0
  172. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/py.typed +0 -0
  173. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/relationships.py +0 -0
  174. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/release_promote/__init__.py +0 -0
  175. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/release_promote/queue.py +0 -0
  176. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/release_promote/service.py +0 -0
  177. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/sbom.py +0 -0
  178. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/scoring/__init__.py +0 -0
  179. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/scoring/queue.py +0 -0
  180. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/settings.py +0 -0
  181. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/storage/__init__.py +0 -0
  182. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/storage/client.py +0 -0
  183. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/storage/dependencies.py +0 -0
  184. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/storage/thumbnails.py +0 -0
  185. {imbi_api-2.32.2 → imbi_api-2.32.3}/src/imbi/api/storage/validation.py +0 -0
  186. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/__init__.py +0 -0
  187. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/__init__.py +0 -0
  188. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/test_authentication.py +0 -0
  189. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/test_authorization.py +0 -0
  190. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/test_encryption.py +0 -0
  191. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/test_internal_services.py +0 -0
  192. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/test_login_providers.py +0 -0
  193. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/test_membership.py +0 -0
  194. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/test_oauth.py +0 -0
  195. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/test_oauth_server.py +0 -0
  196. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/test_permissions.py +0 -0
  197. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/test_seed.py +0 -0
  198. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/test_sessions.py +0 -0
  199. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/auth/test_totp.py +0 -0
  200. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/clickhouse/__init__.py +0 -0
  201. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/clickhouse/test_client.py +0 -0
  202. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/clickhouse/test_init.py +0 -0
  203. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/clickhouse/test_privacy.py +0 -0
  204. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/email/__init__.py +0 -0
  205. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/email/test_client.py +0 -0
  206. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/email/test_init.py +0 -0
  207. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/email/test_integration.py +0 -0
  208. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/email/test_models.py +0 -0
  209. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/email/test_templates.py +0 -0
  210. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/__init__.py +0 -0
  211. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_admin.py +0 -0
  212. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_admin_plugins.py +0 -0
  213. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_ai_models.py +0 -0
  214. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_ai_providers.py +0 -0
  215. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_api_keys.py +0 -0
  216. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_auth.py +0 -0
  217. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_auth_providers.py +0 -0
  218. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_blueprints.py +0 -0
  219. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_client_credentials.py +0 -0
  220. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_comments.py +0 -0
  221. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_components.py +0 -0
  222. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_credentials.py +0 -0
  223. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_dashboard.py +0 -0
  224. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_document_analytics.py +0 -0
  225. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_document_likes.py +0 -0
  226. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_document_presence.py +0 -0
  227. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_document_reads.py +0 -0
  228. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_document_reads_sql.py +0 -0
  229. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_document_templates.py +0 -0
  230. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_document_versions.py +0 -0
  231. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_documents.py +0 -0
  232. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_environments.py +0 -0
  233. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_events.py +0 -0
  234. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_graph_query.py +0 -0
  235. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_iggy_connectors.py +0 -0
  236. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_init.py +0 -0
  237. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_integrations.py +0 -0
  238. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_json_fields.py +0 -0
  239. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_link_definitions.py +0 -0
  240. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_local_auth.py +0 -0
  241. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_maintenance.py +0 -0
  242. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_mcp_servers.py +0 -0
  243. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_mfa.py +0 -0
  244. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_operations_log.py +0 -0
  245. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_organizations.py +0 -0
  246. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_pagination.py +0 -0
  247. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_plugin_label_validation.py +0 -0
  248. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_plugins.py +0 -0
  249. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_project_analysis.py +0 -0
  250. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_project_commit_sync.py +0 -0
  251. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_project_configuration.py +0 -0
  252. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_project_deployments_autonomous.py +0 -0
  253. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_project_incidents.py +0 -0
  254. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_project_integrations.py +0 -0
  255. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_project_lifecycle.py +0 -0
  256. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_project_logs.py +0 -0
  257. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_project_plugins.py +0 -0
  258. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_project_response_surfacing.py +0 -0
  259. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_project_types.py +0 -0
  260. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_projects.py +0 -0
  261. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_projects_helpers.py +0 -0
  262. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_pull_requests.py +0 -0
  263. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_request_urls.py +0 -0
  264. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_roles.py +0 -0
  265. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_sa_api_keys.py +0 -0
  266. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_scoring.py +0 -0
  267. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_scoring_policies.py +0 -0
  268. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_search.py +0 -0
  269. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_search_index.py +0 -0
  270. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_service_accounts.py +0 -0
  271. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_service_writeback.py +0 -0
  272. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_status.py +0 -0
  273. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_tags.py +0 -0
  274. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_teams.py +0 -0
  275. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_uploads.py +0 -0
  276. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_user_activity.py +0 -0
  277. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_users.py +0 -0
  278. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/endpoints/test_webhooks.py +0 -0
  279. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/fixtures/sbom/npm-realistic.json +0 -0
  280. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/fixtures/sbom/pypi-realistic.json +0 -0
  281. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/fixtures/sbom/tiny.json +0 -0
  282. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/identity/__init__.py +0 -0
  283. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/identity/test_endpoints.py +0 -0
  284. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/identity/test_errors.py +0 -0
  285. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/identity/test_flows.py +0 -0
  286. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/identity/test_host_integration.py +0 -0
  287. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/identity/test_repository.py +0 -0
  288. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/identity/test_resolution.py +0 -0
  289. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/identity/test_state.py +0 -0
  290. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/identity/test_sweeper.py +0 -0
  291. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/middleware/test_rate_limit.py +0 -0
  292. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/storage/__init__.py +0 -0
  293. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/storage/test_client.py +0 -0
  294. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/storage/test_init.py +0 -0
  295. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/storage/test_thumbnails.py +0 -0
  296. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/storage/test_validation.py +0 -0
  297. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/support.py +0 -0
  298. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_app.py +0 -0
  299. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_assignment_writer.py +0 -0
  300. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_backfill_embeddings.py +0 -0
  301. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_blueprint_attributes.py +0 -0
  302. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_blueprint_compliance.py +0 -0
  303. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_blueprints.py +0 -0
  304. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_commit_sync_queue.py +0 -0
  305. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_commit_sync_service.py +0 -0
  306. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_component_facts.py +0 -0
  307. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_deployment_status_repair.py +0 -0
  308. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_deployment_sweeper.py +0 -0
  309. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_deployment_sync_queue.py +0 -0
  310. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_deployment_sync_service.py +0 -0
  311. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_drift.py +0 -0
  312. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_entrypoint.py +0 -0
  313. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_graph_sql.py +0 -0
  314. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_identity_attribution.py +0 -0
  315. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_init.py +0 -0
  316. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_installer.py +0 -0
  317. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_lifecycle_dispatch.py +0 -0
  318. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_lifespans.py +0 -0
  319. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_maintenance_log.py +0 -0
  320. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_maintenance_operations.py +0 -0
  321. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_maintenance_state.py +0 -0
  322. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_maintenance_worker.py +0 -0
  323. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_models.py +0 -0
  324. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_openapi.py +0 -0
  325. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_orphan_releases.py +0 -0
  326. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_plugin_schemas.py +0 -0
  327. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_plugins.py +0 -0
  328. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_plugins_assignments.py +0 -0
  329. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_plugins_credentials.py +0 -0
  330. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_pr_sync_queue.py +0 -0
  331. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_pr_sync_service.py +0 -0
  332. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_projects_drift_ranges.py +0 -0
  333. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_release_promote_service.py +0 -0
  334. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_sbom.py +0 -0
  335. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_scoring_queue.py +0 -0
  336. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_scoring_triggers.py +0 -0
  337. {imbi_api-2.32.2 → imbi_api-2.32.3}/tests/test_settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: imbi-api
3
- Version: 2.32.2
3
+ Version: 2.32.3
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.32.2
22
+ Requires-Dist: imbi-common[databases,llm,sentry,server]==2.32.3
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.32.2"
3
+ version = "2.32.3"
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.32.2",
31
+ "imbi-common[databases,llm,sentry,server]==2.32.3",
32
32
  "jinja2",
33
33
  "jsonpatch>=1.33",
34
34
  "mcp>=1.26.0",
@@ -1545,6 +1545,104 @@ async def fetch_release_notes_for_tag(
1545
1545
  )
1546
1546
 
1547
1547
 
1548
+ async def resolve_committish_for_tag(
1549
+ db: graph.Graph,
1550
+ *,
1551
+ org_slug: str,
1552
+ project_id: str,
1553
+ tag: str,
1554
+ auth: permissions.AuthContext,
1555
+ ) -> str | None:
1556
+ """Resolve ``tag`` to the short committish it points at.
1557
+
1558
+ The release-create path uses this when a caller supplies a tag but no
1559
+ commit -- a source host's ``release`` webhook names the tag and the
1560
+ branch it was cut from, never the SHA. The lookup goes through the
1561
+ deployment capability rather than the ClickHouse ``tags`` table
1562
+ because the two race: the ``release`` delivery lands before commit
1563
+ sync records the tag (32s apart on the delivery that motivated this),
1564
+ so the table is reliably empty at exactly the moment this is called.
1565
+ The remote, by contrast, has the tag by definition -- it is what it
1566
+ just announced.
1567
+
1568
+ ``None`` means the answer is *no commit*, and the caller may reject
1569
+ the release on it: the remote positively reported the ref does not
1570
+ exist (HTTP 404/422), no deployment capability is bound to the
1571
+ project at all, or the plugin does not implement
1572
+ ``resolve_committish``. Every other failure raises instead -- the
1573
+ same distinction ``resolve_remote_tags`` draws between ``'absent'``
1574
+ and ``'error'``, and for the same reason: a caller that refuses a
1575
+ release on ``None`` must never read unreachability as absence. A
1576
+ timed-out, rate-limited, unauthorized or uncredentialed lookup
1577
+ would otherwise turn a "retry shortly" into a terminal "your tag is
1578
+ invalid". Both the capability resolution and the call itself can
1579
+ fail either way, so both are sorted, not just the call.
1580
+ """
1581
+ try:
1582
+ resolved, ctx, credentials = await _resolve_and_context(
1583
+ db, org_slug, project_id, auth, best_effort_identity=True
1584
+ )
1585
+ except fastapi.HTTPException as exc:
1586
+ # Only 404 -- "no integration provides the deployment
1587
+ # capability" -- means this project has no way to name a
1588
+ # commit. The other answers here are failures to *ask*: a
1589
+ # missing service credential is a deliberate, retryable 503
1590
+ # (see ``_resolve_and_context``), a 403 is an authorization
1591
+ # problem, and a 400 means the capability is bound more than
1592
+ # once and needs ``?source=``. Reporting any of those as an
1593
+ # absent tag is the same mistake as swallowing the timeout.
1594
+ if exc.status_code == 404:
1595
+ return None
1596
+ raise
1597
+ handler = _handler(resolved)
1598
+ try:
1599
+ commit = await call_with_timeout(
1600
+ handler.resolve_committish(
1601
+ ctx, _resolve_credentials(ctx, credentials), tag
1602
+ )
1603
+ )
1604
+ except NotImplementedError:
1605
+ return None
1606
+ except plugin_errors.PluginRateLimited:
1607
+ raise
1608
+ except fastapi.HTTPException:
1609
+ # ``call_with_timeout`` raises 503 + ``Retry-After`` on timeout.
1610
+ raise
1611
+ except httpx.HTTPStatusError as exc:
1612
+ # 404 is the remote saying "no such ref"; GitHub answers 422 for
1613
+ # a ref it cannot even parse. Everything else (403, 5xx) is the
1614
+ # remote failing to answer, not answering "no".
1615
+ if exc.response.status_code in (404, 422):
1616
+ return None
1617
+ LOGGER.warning(
1618
+ 'committish lookup for project=%s tag=%s failed with %s',
1619
+ project_id,
1620
+ tag,
1621
+ exc.response.status_code,
1622
+ )
1623
+ raise fastapi.HTTPException(
1624
+ status_code=503,
1625
+ detail=f'Could not reach the source host to resolve {tag!r}',
1626
+ headers={'Retry-After': '5'},
1627
+ ) from exc
1628
+ except httpx.HTTPError as exc:
1629
+ # Transport-level: connect, read, protocol. Transient, so it
1630
+ # must not read as "the tag does not exist" either.
1631
+ LOGGER.warning(
1632
+ 'committish lookup for project=%s tag=%s failed: %r',
1633
+ project_id,
1634
+ tag,
1635
+ exc,
1636
+ )
1637
+ raise fastapi.HTTPException(
1638
+ status_code=503,
1639
+ detail=f'Could not reach the source host to resolve {tag!r}',
1640
+ headers={'Retry-After': '5'},
1641
+ ) from exc
1642
+ await persist_link_writeback(db, ctx)
1643
+ return versioning.short_committish(commit.sha)
1644
+
1645
+
1548
1646
  async def backfill_release_notes(
1549
1647
  db: graph.Graph,
1550
1648
  *,
@@ -74,26 +74,53 @@ _RELEASE_HYDRATION_SEMAPHORE = asyncio.Semaphore(
74
74
 
75
75
 
76
76
  class ReleaseCreate(pydantic.BaseModel):
77
- """Request body for creating a release."""
77
+ """Request body for creating a release.
78
+
79
+ At least one of ``committish`` / ``tag`` is required. A source
80
+ host's *release* notification carries no commit -- GitHub's
81
+ ``release`` payload offers only ``target_commitish``, which is the
82
+ branch the release was cut from (``main`` on 99.7% of deliveries)
83
+ rather than a SHA -- so a caller that has only the tag omits the
84
+ committish and the endpoint resolves it through the project's
85
+ deployment capability.
86
+ """
78
87
 
79
88
  model_config = pydantic.ConfigDict(extra='forbid')
80
89
 
81
90
  tag: str | None = None
82
91
  committish: typing.Annotated[
83
- str,
92
+ str | None,
84
93
  pydantic.Field(
85
94
  pattern=r'^[0-9a-f]{7}$',
86
95
  description=(
87
96
  'Short commit SHA (7 lowercase hexadecimal chars) '
88
- 'identifying the source revision for this release.'
97
+ 'identifying the source revision for this release. '
98
+ 'Optional when a tag is supplied: the tag is then '
99
+ 'resolved to its commit through the deployment '
100
+ 'capability.'
89
101
  ),
90
102
  ),
91
- ]
103
+ ] = None
92
104
  title: str
93
105
  description: str | None = None
94
106
  links: list[models.ReleaseLink] = []
95
107
  created_by: str | None = None
96
108
 
109
+ @pydantic.model_validator(mode='after')
110
+ def validate_identity(self) -> typing.Self:
111
+ """Reject a body that identifies no revision at all.
112
+
113
+ An empty ``tag`` is normalized to ``None`` first, so it cannot
114
+ reach the graph: the untagged-release lookup treats ``''`` and
115
+ NULL alike, and a node stored with ``''`` would be matched as
116
+ untagged but could not then be named.
117
+ """
118
+ if self.tag == '':
119
+ self.tag = None
120
+ if not self.committish and not self.tag:
121
+ raise ValueError('one of committish or tag is required')
122
+ return self
123
+
97
124
 
98
125
  class ReleaseUpdate(pydantic.BaseModel):
99
126
  """JSON Patch-compatible release shape.
@@ -308,6 +335,107 @@ async def _fetch_release(
308
335
  )
309
336
 
310
337
 
338
+ # Per-project release identity, in priority order.
339
+ #
340
+ # A tag is the identity whenever one exists: it names one shippable
341
+ # artifact, while the commit it points at moves (the release workflow
342
+ # bumps the version, then tags the bump commit). Keying on the
343
+ # committish is what let the post-bump SHA miss the node Imbi had
344
+ # already created.
345
+ #
346
+ # The second clause is the cross-webhook case. A ``deployment_status``
347
+ # delivery creates the release from a commit alone, then the ``release``
348
+ # delivery names the same artifact by tag; matching on the tag alone
349
+ # misses that node and creates a sibling for one release. A tagged
350
+ # create therefore also claims an *untagged* node carrying its commit.
351
+ # The reverse is deliberately not true -- an untagged create never
352
+ # claims a tagged node, because a tag is a narrower identity than the
353
+ # commit under it and two tags can share one commit.
354
+ #
355
+ # AGE has no NULL equality, so nullable comparisons go through COALESCE
356
+ # to a sentinel.
357
+ _RELEASE_BY_TAG: typing.Final[typing.LiteralString] = """
358
+ MATCH (p:Project {{id: {project_id}}})-[:HAS_RELEASE]->(r:Release)
359
+ WHERE r.tag = {tag}
360
+ RETURN r.id AS id
361
+ """
362
+
363
+ _RELEASE_BY_COMMITTISH: typing.Final[typing.LiteralString] = """
364
+ MATCH (p:Project {{id: {project_id}}})-[:HAS_RELEASE]->(r:Release)
365
+ WHERE r.committish = {committish} AND COALESCE(r.tag, '') = ''
366
+ RETURN r.id AS id
367
+ """
368
+
369
+ # The predicate has to be the same one ``_RELEASE_BY_COMMITTISH``
370
+ # matched on. ``COALESCE(r.tag, {tag})`` replaces only NULL, so a node
371
+ # stored with an empty-string tag would be matched as untagged, adopted,
372
+ # and left untagged -- dropping the incoming tag and answering 200 with
373
+ # a release that still has none. ``ReleaseCreate`` now normalizes ``''``
374
+ # to NULL so this endpoint cannot create such a node, but rows predating
375
+ # that (or written by another client) still have to be nameable.
376
+ _RELEASE_ADOPT_TAG: typing.Final[typing.LiteralString] = """
377
+ MATCH (r:Release {{id: {release_id}}})
378
+ SET r.tag = CASE WHEN COALESCE(r.tag, '') = '' THEN {tag} ELSE r.tag END,
379
+ r.updated_at = {now}
380
+ RETURN r.id AS id
381
+ """
382
+
383
+
384
+ class _ExistingRelease(typing.NamedTuple):
385
+ """A release a create would duplicate, and how it was matched."""
386
+
387
+ id: str
388
+ #: False when the match came from the committish, meaning the node is
389
+ #: untagged and a tagged create should adopt it.
390
+ by_tag: bool
391
+
392
+
393
+ async def _find_existing_release(
394
+ db: graph.Graph,
395
+ *,
396
+ project_id: str,
397
+ tag: str | None,
398
+ committish: str,
399
+ ) -> _ExistingRelease | None:
400
+ """Return the release this create would duplicate, if any."""
401
+ if tag:
402
+ rows = await db.execute(
403
+ _RELEASE_BY_TAG,
404
+ {'project_id': project_id, 'tag': tag},
405
+ ['id'],
406
+ )
407
+ if rows:
408
+ return _ExistingRelease(
409
+ str(graph.parse_agtype(rows[0]['id'])), True
410
+ )
411
+ rows = await db.execute(
412
+ _RELEASE_BY_COMMITTISH,
413
+ {'project_id': project_id, 'committish': committish},
414
+ ['id'],
415
+ )
416
+ if rows:
417
+ return _ExistingRelease(str(graph.parse_agtype(rows[0]['id'])), False)
418
+ return None
419
+
420
+
421
+ async def _adopt_release_tag(
422
+ db: graph.Graph,
423
+ *,
424
+ release_id: str,
425
+ tag: str,
426
+ ) -> None:
427
+ """Name an untagged release, leaving an already-tagged one alone."""
428
+ await db.execute(
429
+ _RELEASE_ADOPT_TAG,
430
+ {
431
+ 'release_id': release_id,
432
+ 'tag': tag,
433
+ 'now': datetime.datetime.now(datetime.UTC).isoformat(),
434
+ },
435
+ ['id'],
436
+ )
437
+
438
+
311
439
  #: Alias kept for readers of this module; the map is shared with the
312
440
  #: sweeper, which folds the same plugin answers into the same statuses.
313
441
  _RUN_STATUS_TO_EVENT_STATUS = deployment_nodes.RUN_STATUS_TO_STATUS
@@ -588,12 +716,26 @@ async def _hydrate_release_train(
588
716
  # -- Endpoints ----------------------------------------------------------
589
717
 
590
718
 
591
- @releases_router.post('/', status_code=201, response_model=ReleaseResponse)
719
+ @releases_router.post(
720
+ '/',
721
+ status_code=201,
722
+ response_model=ReleaseResponse,
723
+ responses={
724
+ 200: {
725
+ 'model': ReleaseResponse,
726
+ 'description': (
727
+ 'The release already existed; it is returned unchanged '
728
+ 'rather than conflicting.'
729
+ ),
730
+ },
731
+ },
732
+ )
592
733
  async def create_release(
593
734
  org_slug: str,
594
735
  project_id: str,
595
736
  data: ReleaseCreate,
596
737
  db: graph.Pool,
738
+ response: fastapi.Response,
597
739
  background_tasks: fastapi.BackgroundTasks,
598
740
  auth: typing.Annotated[
599
741
  permissions.AuthContext,
@@ -602,46 +744,64 @@ async def create_release(
602
744
  ),
603
745
  ],
604
746
  ) -> ReleaseResponse:
605
- """Create a new release for a project."""
747
+ """Create a release for a project, or return the one that exists.
748
+
749
+ The call is idempotent: a release matching the body is returned with
750
+ ``200`` rather than conflicting, because more than one webhook can
751
+ announce the same release. A ``deployment_status`` delivery and a
752
+ ``release`` delivery describe one artifact from two angles, and the
753
+ first to arrive must not make the second an error -- nor a second
754
+ node. Only a first create answers ``201``.
755
+ """
606
756
  if not await _project_exists(db, org_slug, project_id):
607
757
  raise fastapi.HTTPException(
608
758
  status_code=404,
609
759
  detail=f'Project {project_id!r} not found',
610
760
  )
611
761
 
612
- # Per-project uniqueness pre-check. A tag is the release's
613
- # identity: it names one shippable artifact, and the commit it
614
- # points at moves (the release workflow bumps the version, then
615
- # tags the bump commit). Matching on the committish instead let the
616
- # post-bump SHA miss the node Imbi had already created and create a
617
- # duplicate. Only an untagged release falls back to the committish.
618
- # AGE has no NULL equality, so nullable comparisons go through
619
- # COALESCE to a sentinel.
620
- existing_query: typing.LiteralString = """
621
- MATCH (p:Project {{id: {project_id}}})-[:HAS_RELEASE]->(r:Release)
622
- WHERE (COALESCE({tag}, '') <> '' AND r.tag = {tag})
623
- OR (COALESCE({tag}, '') = ''
624
- AND r.committish = {committish}
625
- AND COALESCE(r.tag, '') = '')
626
- RETURN r.id AS id
627
- """
628
- existing = await db.execute(
629
- existing_query,
630
- {
631
- 'project_id': project_id,
632
- 'committish': data.committish,
633
- 'tag': data.tag,
634
- },
635
- ['id'],
762
+ # Local import avoids a circular import: project_deployments imports
763
+ # append_deployment_event from this module.
764
+ from imbi.api.endpoints.project_deployments import (
765
+ fetch_release_notes_for_tag,
766
+ resolve_committish_for_tag,
636
767
  )
637
- if existing:
638
- raise fastapi.HTTPException(
639
- status_code=409,
640
- detail=(
641
- f'Release tag={data.tag!r} committish={data.committish!r}'
642
- f' already exists for project {project_id!r}'
643
- ),
768
+
769
+ committish = data.committish
770
+ if committish is None:
771
+ # Guaranteed by ReleaseCreate.validate_identity.
772
+ assert data.tag is not None # noqa: S101
773
+ committish = await resolve_committish_for_tag(
774
+ db,
775
+ org_slug=org_slug,
776
+ project_id=project_id,
777
+ tag=data.tag,
778
+ auth=auth,
644
779
  )
780
+ if committish is None:
781
+ raise fastapi.HTTPException(
782
+ status_code=422,
783
+ detail=(
784
+ f'Could not resolve tag {data.tag!r} to a commit for'
785
+ f' project {project_id!r}; supply committish explicitly'
786
+ ),
787
+ )
788
+
789
+ match = await _find_existing_release(
790
+ db, project_id=project_id, tag=data.tag, committish=committish
791
+ )
792
+ if match is not None:
793
+ # A tagged create that matched an untagged node adopts it: the
794
+ # deployment webhook records the commit before the release
795
+ # webhook names it, and leaving them apart is what produces the
796
+ # duplicate this endpoint exists to avoid.
797
+ if data.tag and not match.by_tag:
798
+ await _adopt_release_tag(db, release_id=match.id, tag=data.tag)
799
+ existing = await _fetch_release(db, org_slug, project_id, match.id)
800
+ if existing is not None:
801
+ response.status_code = 200
802
+ return _release_to_response(existing, project_id)
803
+ # Raced with a delete between the two reads; fall through and
804
+ # create the node rather than 404 a create.
645
805
 
646
806
  # Spec calls for ``auth.user.username``; the User model uses
647
807
  # ``email`` as the principal identity (no ``username`` field).
@@ -656,12 +816,6 @@ async def create_release(
656
816
  # and the release is still created (identified by committish + tag).
657
817
  description = data.description
658
818
  if data.tag and not description:
659
- # Local import avoids a circular import: project_deployments
660
- # imports append_deployment_event from this module.
661
- from imbi.api.endpoints.project_deployments import (
662
- fetch_release_notes_for_tag,
663
- )
664
-
665
819
  description = await fetch_release_notes_for_tag(
666
820
  db,
667
821
  org_slug=org_slug,
@@ -673,7 +827,7 @@ async def create_release(
673
827
  props: dict[str, typing.Any] = {
674
828
  'id': nanoid.generate(),
675
829
  'tag': data.tag,
676
- 'committish': data.committish,
830
+ 'committish': committish,
677
831
  'title': data.title,
678
832
  'description': description,
679
833
  'links': _serialize_links(data.links),
@@ -6399,6 +6399,146 @@ class ResolveRemoteTagsTestCase(unittest.IsolatedAsyncioTestCase):
6399
6399
  await self._resolve(['1.0.0'])
6400
6400
 
6401
6401
 
6402
+ class ResolveCommittishForTagTestCase(unittest.IsolatedAsyncioTestCase):
6403
+ """The tag lookup behind a tag-only release create.
6404
+
6405
+ ``create_release`` rejects the release with a 422 when this answers
6406
+ ``None``, so the same absence-vs-failure distinction
6407
+ ``resolve_remote_tags`` draws has to hold here: only a positive "no
6408
+ such ref" may answer ``None``. A failure to reach the remote must
6409
+ raise, or a timeout would read as an invalid tag.
6410
+ """
6411
+
6412
+ def setUp(self) -> None:
6413
+ self.resolve_committish = mock.AsyncMock(
6414
+ return_value=mock.Mock(sha='9b2356a5da56631e3faf75bc5710bad1')
6415
+ )
6416
+ for target, replacement in (
6417
+ ('_resolve_and_context', mock.AsyncMock()),
6418
+ (
6419
+ '_handler',
6420
+ mock.Mock(
6421
+ return_value=mock.Mock(
6422
+ resolve_committish=self.resolve_committish
6423
+ )
6424
+ ),
6425
+ ),
6426
+ ('_resolve_credentials', mock.Mock(return_value={})),
6427
+ ('persist_link_writeback', mock.AsyncMock()),
6428
+ ):
6429
+ patcher = mock.patch(f'{_MODULE}.{target}', replacement)
6430
+ self.addCleanup(patcher.stop)
6431
+ patcher.start()
6432
+ project_deployments._resolve_and_context.return_value = (
6433
+ mock.Mock(plugin_slug='github'),
6434
+ mock.Mock(environment_config=None),
6435
+ {},
6436
+ )
6437
+
6438
+ async def _resolve(self, tag: str = '5.0.2') -> str | None:
6439
+ return await project_deployments.resolve_committish_for_tag(
6440
+ mock.AsyncMock(),
6441
+ org_slug='octo',
6442
+ project_id='p1',
6443
+ tag=tag,
6444
+ auth=mock.Mock(),
6445
+ )
6446
+
6447
+ async def test_resolves_the_tag_to_a_short_committish(self) -> None:
6448
+ self.assertEqual('9b2356a', await self._resolve())
6449
+
6450
+ async def test_a_remote_404_answers_none(self) -> None:
6451
+ self.resolve_committish.side_effect = _http_status_error(404)
6452
+ self.assertIsNone(await self._resolve())
6453
+
6454
+ async def test_a_remote_422_answers_none(self) -> None:
6455
+ self.resolve_committish.side_effect = _http_status_error(422)
6456
+ self.assertIsNone(await self._resolve())
6457
+
6458
+ async def test_no_capability_answers_none(self) -> None:
6459
+ project_deployments._resolve_and_context.side_effect = (
6460
+ fastapi.HTTPException(status_code=404, detail='none')
6461
+ )
6462
+ self.assertIsNone(await self._resolve())
6463
+
6464
+ async def test_a_missing_credential_propagates_its_503(self) -> None:
6465
+ # ``_resolve_and_context`` raises this deliberately, and its own
6466
+ # comment calls a retry reasonable. Reading it as an absent tag
6467
+ # is the same mistake as swallowing the timeout, one call up.
6468
+ project_deployments._resolve_and_context.side_effect = (
6469
+ fastapi.HTTPException(
6470
+ status_code=503,
6471
+ detail={'error': 'no_service_credential'},
6472
+ )
6473
+ )
6474
+ with self.assertRaises(fastapi.HTTPException) as ctx:
6475
+ await self._resolve()
6476
+ self.assertEqual(503, ctx.exception.status_code)
6477
+
6478
+ async def test_an_authorization_failure_propagates_its_403(self) -> None:
6479
+ # The gateway posts as a service principal, so act-as-service
6480
+ # checks are reachable here. Not an answer about the tag.
6481
+ project_deployments._resolve_and_context.side_effect = (
6482
+ fastapi.HTTPException(status_code=403, detail='nope')
6483
+ )
6484
+ with self.assertRaises(fastapi.HTTPException) as ctx:
6485
+ await self._resolve()
6486
+ self.assertEqual(403, ctx.exception.status_code)
6487
+
6488
+ async def test_an_ambiguous_capability_propagates_its_400(self) -> None:
6489
+ # "Multiple integrations provide this capability" is a config
6490
+ # error to surface, not a tag that does not exist.
6491
+ project_deployments._resolve_and_context.side_effect = (
6492
+ fastapi.HTTPException(status_code=400, detail='ambiguous')
6493
+ )
6494
+ with self.assertRaises(fastapi.HTTPException) as ctx:
6495
+ await self._resolve()
6496
+ self.assertEqual(400, ctx.exception.status_code)
6497
+
6498
+ async def test_an_unimplemented_plugin_answers_none(self) -> None:
6499
+ self.resolve_committish.side_effect = NotImplementedError
6500
+ self.assertIsNone(await self._resolve())
6501
+
6502
+ async def test_a_remote_500_raises_503_rather_than_absence(self) -> None:
6503
+ self.resolve_committish.side_effect = _http_status_error(500)
6504
+ with self.assertRaises(fastapi.HTTPException) as ctx:
6505
+ await self._resolve()
6506
+ self.assertEqual(503, ctx.exception.status_code)
6507
+
6508
+ async def test_a_remote_403_raises_503_rather_than_absence(self) -> None:
6509
+ # A revoked credential is not the tag failing to exist.
6510
+ self.resolve_committish.side_effect = _http_status_error(403)
6511
+ with self.assertRaises(fastapi.HTTPException) as ctx:
6512
+ await self._resolve()
6513
+ self.assertEqual(503, ctx.exception.status_code)
6514
+
6515
+ async def test_a_transport_failure_raises_503(self) -> None:
6516
+ self.resolve_committish.side_effect = httpx.ConnectError('down')
6517
+ with self.assertRaises(fastapi.HTTPException) as ctx:
6518
+ await self._resolve()
6519
+ self.assertEqual(503, ctx.exception.status_code)
6520
+
6521
+ async def test_a_timeout_keeps_its_503_and_retry_after(self) -> None:
6522
+ # ``call_with_timeout`` raises this; swallowing it turned a
6523
+ # "retry in 5s" into a terminal "your tag is invalid".
6524
+ self.resolve_committish.side_effect = fastapi.HTTPException(
6525
+ status_code=503,
6526
+ detail='Plugin timed out',
6527
+ headers={'Retry-After': '5'},
6528
+ )
6529
+ with self.assertRaises(fastapi.HTTPException) as ctx:
6530
+ await self._resolve()
6531
+ self.assertEqual(503, ctx.exception.status_code)
6532
+ self.assertEqual({'Retry-After': '5'}, ctx.exception.headers)
6533
+
6534
+ async def test_rate_limiting_propagates(self) -> None:
6535
+ self.resolve_committish.side_effect = plugin_errors.PluginRateLimited(
6536
+ retry_at=1.0
6537
+ )
6538
+ with self.assertRaises(plugin_errors.PluginRateLimited):
6539
+ await self._resolve()
6540
+
6541
+
6402
6542
  class IngestDriftNotesTests(ProjectDeploymentsTestCase):
6403
6543
  URL = '/organizations/myorg/projects/proj1/deployments/drift-notes'
6404
6544
  BODY: typing.ClassVar[dict[str, str]] = {