imbi-api 2.9.1__tar.gz → 2.9.2__tar.gz

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