imbi-api 2.11.2__tar.gz → 2.11.4__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 (308) hide show
  1. {imbi_api-2.11.2 → imbi_api-2.11.4}/PKG-INFO +2 -2
  2. {imbi_api-2.11.2 → imbi_api-2.11.4}/pyproject.toml +2 -2
  3. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/app.py +1 -0
  4. imbi_api-2.11.4/src/imbi_api/blueprint_compliance.py +393 -0
  5. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/organizations.py +5 -0
  6. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/project_analysis.py +69 -10
  7. imbi_api-2.11.4/src/imbi_api/endpoints/project_pr_sync.py +85 -0
  8. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/lifespans.py +34 -0
  9. imbi_api-2.11.4/src/imbi_api/pr_sync/__init__.py +28 -0
  10. imbi_api-2.11.4/src/imbi_api/pr_sync/queue.py +308 -0
  11. imbi_api-2.11.4/src/imbi_api/pr_sync/service.py +321 -0
  12. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_events.py +2 -2
  13. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_project_analysis.py +53 -0
  14. imbi_api-2.11.4/tests/test_pr_sync_queue.py +204 -0
  15. imbi_api-2.11.4/tests/test_pr_sync_service.py +192 -0
  16. {imbi_api-2.11.2 → imbi_api-2.11.4}/uv.lock +70 -63
  17. {imbi_api-2.11.2 → imbi_api-2.11.4}/.github/workflows/deploy.yaml +0 -0
  18. {imbi_api-2.11.2 → imbi_api-2.11.4}/.github/workflows/docs.yaml +0 -0
  19. {imbi_api-2.11.2 → imbi_api-2.11.4}/.github/workflows/testing.yaml +0 -0
  20. {imbi_api-2.11.2 → imbi_api-2.11.4}/.gitignore +0 -0
  21. {imbi_api-2.11.2 → imbi_api-2.11.4}/.pre-commit-config.yaml +0 -0
  22. {imbi_api-2.11.2 → imbi_api-2.11.4}/.python-version +0 -0
  23. {imbi_api-2.11.2 → imbi_api-2.11.4}/CLAUDE.md +0 -0
  24. {imbi_api-2.11.2 → imbi_api-2.11.4}/CODE_REVIEW_PUNCHLIST.md +0 -0
  25. {imbi_api-2.11.2 → imbi_api-2.11.4}/LICENSE +0 -0
  26. {imbi_api-2.11.2 → imbi_api-2.11.4}/README.md +0 -0
  27. {imbi_api-2.11.2 → imbi_api-2.11.4}/coderabbit.yaml +0 -0
  28. {imbi_api-2.11.2 → imbi_api-2.11.4}/compose.yaml +0 -0
  29. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0001-record-architecture-decisions.md +0 -0
  30. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0002-authentication-and-authorization-architecture.md +0 -0
  31. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0003-email-sending-architecture.md +0 -0
  32. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0004-phase-5-authentication-enhancements.md +0 -0
  33. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0005-file-upload-storage-architecture.md +0 -0
  34. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0006-project-identity-and-multi-type.md +0 -0
  35. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0007-relationship-blueprints.md +0 -0
  36. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0008-plugin-system-architecture.md +0 -0
  37. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0009-database-driven-oauth-providers.md +0 -0
  38. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0010-identity-plugin-architecture.md +0 -0
  39. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0011-graph-based-project-scoring.md +0 -0
  40. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0012-plugin-manifest-service-template.md +0 -0
  41. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0013-deployment-plugin-type.md +0 -0
  42. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0014-generic-plugin-entity-abstraction.md +0 -0
  43. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr/0015-cyclonedx-1.7-sbom-standard.md +0 -0
  44. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/adr.md +0 -0
  45. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/configuration.md +0 -0
  46. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/index.md +0 -0
  47. {imbi_api-2.11.2 → imbi_api-2.11.4}/docs/restore-backup.md +0 -0
  48. {imbi_api-2.11.2 → imbi_api-2.11.4}/justfile +0 -0
  49. {imbi_api-2.11.2 → imbi_api-2.11.4}/mkdocs.yml +0 -0
  50. {imbi_api-2.11.2 → imbi_api-2.11.4}/mypy.ini +0 -0
  51. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/__init__.py +0 -0
  52. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/__init__.py +0 -0
  53. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/authorization_codes.py +0 -0
  54. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/local_auth.py +0 -0
  55. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/login_providers.py +0 -0
  56. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/membership.py +0 -0
  57. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/models.py +0 -0
  58. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/oauth.py +0 -0
  59. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/oauth_clients.py +0 -0
  60. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/password.py +0 -0
  61. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/permissions.py +0 -0
  62. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/seed.py +0 -0
  63. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/sessions.py +0 -0
  64. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/tokens.py +0 -0
  65. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/auth/totp.py +0 -0
  66. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/backfill_embeddings.py +0 -0
  67. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/blueprint_attributes.py +0 -0
  68. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/commit_sync/__init__.py +0 -0
  69. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/commit_sync/queue.py +0 -0
  70. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/commit_sync/service.py +0 -0
  71. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/domain/__init__.py +0 -0
  72. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/domain/models.py +0 -0
  73. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/domain/scoring.py +0 -0
  74. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/email/__init__.py +0 -0
  75. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/email/client.py +0 -0
  76. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/email/dependencies.py +0 -0
  77. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/email/models.py +0 -0
  78. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/email/templates/base.html +0 -0
  79. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/email/templates/base.txt +0 -0
  80. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/email/templates/password_reset.html +0 -0
  81. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/email/templates/password_reset.txt +0 -0
  82. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/email/templates/welcome.html +0 -0
  83. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/email/templates/welcome.txt +0 -0
  84. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/email/templates.py +0 -0
  85. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/__init__.py +0 -0
  86. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/_credentials.py +0 -0
  87. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/_helpers.py +0 -0
  88. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/_json_fields.py +0 -0
  89. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/_pagination.py +0 -0
  90. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/_request_urls.py +0 -0
  91. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/admin.py +0 -0
  92. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/admin_plugins.py +0 -0
  93. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/api_keys.py +0 -0
  94. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/auth.py +0 -0
  95. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/auth_providers.py +0 -0
  96. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/blueprints.py +0 -0
  97. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/client_credentials.py +0 -0
  98. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/comments.py +0 -0
  99. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/dashboard.py +0 -0
  100. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/document_templates.py +0 -0
  101. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/documents.py +0 -0
  102. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/environments.py +0 -0
  103. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/events.py +0 -0
  104. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/graph_query.py +0 -0
  105. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/identity_plugins.py +0 -0
  106. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/link_definitions.py +0 -0
  107. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/local_auth.py +0 -0
  108. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/mcp_servers.py +0 -0
  109. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/mfa.py +0 -0
  110. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/oauth_metadata.py +0 -0
  111. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/operations_log.py +0 -0
  112. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/plugin_edges.py +0 -0
  113. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/plugin_entities.py +0 -0
  114. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/plugins.py +0 -0
  115. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/project_commit_sync.py +0 -0
  116. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/project_configuration.py +0 -0
  117. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/project_deployments.py +0 -0
  118. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/project_incidents.py +0 -0
  119. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/project_lifecycle.py +0 -0
  120. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/project_logs.py +0 -0
  121. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/project_plugins.py +0 -0
  122. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/project_type_plugins.py +0 -0
  123. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/project_types.py +0 -0
  124. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/projects.py +0 -0
  125. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/pull_requests.py +0 -0
  126. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/releases.py +0 -0
  127. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/roles.py +0 -0
  128. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/sa_api_keys.py +0 -0
  129. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/scoring.py +0 -0
  130. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/scoring_policies.py +0 -0
  131. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/search.py +0 -0
  132. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/service_accounts.py +0 -0
  133. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/service_plugins.py +0 -0
  134. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/status.py +0 -0
  135. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/tags.py +0 -0
  136. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/teams.py +0 -0
  137. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/third_party_services.py +0 -0
  138. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/uploads.py +0 -0
  139. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/user_activity.py +0 -0
  140. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/users.py +0 -0
  141. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/endpoints/webhooks.py +0 -0
  142. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/entrypoint.py +0 -0
  143. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/graph_sql.py +0 -0
  144. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/identity/__init__.py +0 -0
  145. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/identity/attribution.py +0 -0
  146. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/identity/endpoints.py +0 -0
  147. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/identity/errors.py +0 -0
  148. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/identity/flows.py +0 -0
  149. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/identity/host_integration.py +0 -0
  150. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/identity/models.py +0 -0
  151. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/identity/repository.py +0 -0
  152. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/identity/resolution.py +0 -0
  153. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/identity/state.py +0 -0
  154. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/identity/sweeper.py +0 -0
  155. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/llm/__init__.py +0 -0
  156. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/llm/dependencies.py +0 -0
  157. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/mcp_test.py +0 -0
  158. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/middleware/__init__.py +0 -0
  159. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/middleware/rate_limit.py +0 -0
  160. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/models.py +0 -0
  161. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/openapi.py +0 -0
  162. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/patch.py +0 -0
  163. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/plugins/__init__.py +0 -0
  164. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/plugins/assignment_writer.py +0 -0
  165. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/plugins/assignments.py +0 -0
  166. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/plugins/credentials.py +0 -0
  167. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/plugins/installer.py +0 -0
  168. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/plugins/lifecycle.py +0 -0
  169. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/plugins/lifecycle_dispatch.py +0 -0
  170. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/plugins/reload.py +0 -0
  171. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/plugins/resolution.py +0 -0
  172. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/plugins/schemas.py +0 -0
  173. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/prompts/__init__.py +0 -0
  174. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/prompts/release_notes_system.md +0 -0
  175. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/py.typed +0 -0
  176. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/relationships.py +0 -0
  177. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/sbom.py +0 -0
  178. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/scoring/__init__.py +0 -0
  179. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/scoring/queue.py +0 -0
  180. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/settings.py +0 -0
  181. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/storage/__init__.py +0 -0
  182. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/storage/client.py +0 -0
  183. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/storage/dependencies.py +0 -0
  184. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/storage/thumbnails.py +0 -0
  185. {imbi_api-2.11.2 → imbi_api-2.11.4}/src/imbi_api/storage/validation.py +0 -0
  186. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/__init__.py +0 -0
  187. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/__init__.py +0 -0
  188. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/test_api_key_auth.py +0 -0
  189. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/test_authentication.py +0 -0
  190. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/test_authorization.py +0 -0
  191. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/test_encryption.py +0 -0
  192. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/test_login_providers.py +0 -0
  193. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/test_membership.py +0 -0
  194. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/test_oauth.py +0 -0
  195. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/test_oauth_server.py +0 -0
  196. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/test_permissions.py +0 -0
  197. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/test_seed.py +0 -0
  198. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/test_sessions.py +0 -0
  199. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/auth/test_totp.py +0 -0
  200. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/clickhouse/__init__.py +0 -0
  201. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/clickhouse/test_client.py +0 -0
  202. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/clickhouse/test_init.py +0 -0
  203. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/clickhouse/test_privacy.py +0 -0
  204. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/email/__init__.py +0 -0
  205. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/email/test_client.py +0 -0
  206. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/email/test_init.py +0 -0
  207. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/email/test_integration.py +0 -0
  208. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/email/test_models.py +0 -0
  209. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/email/test_templates.py +0 -0
  210. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/__init__.py +0 -0
  211. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_admin.py +0 -0
  212. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_admin_plugins.py +0 -0
  213. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_api_keys.py +0 -0
  214. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_auth.py +0 -0
  215. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_auth_providers.py +0 -0
  216. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_blueprints.py +0 -0
  217. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_client_credentials.py +0 -0
  218. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_comments.py +0 -0
  219. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_credentials.py +0 -0
  220. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_dashboard.py +0 -0
  221. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_document_templates.py +0 -0
  222. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_documents.py +0 -0
  223. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_environments.py +0 -0
  224. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_graph_query.py +0 -0
  225. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_init.py +0 -0
  226. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_json_fields.py +0 -0
  227. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_link_definitions.py +0 -0
  228. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_local_auth.py +0 -0
  229. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_mcp_servers.py +0 -0
  230. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_mfa.py +0 -0
  231. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_operations_log.py +0 -0
  232. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_organizations.py +0 -0
  233. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_pagination.py +0 -0
  234. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_plugin_label_validation.py +0 -0
  235. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_plugins.py +0 -0
  236. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_project_commit_sync.py +0 -0
  237. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_project_configuration.py +0 -0
  238. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_project_deployments.py +0 -0
  239. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_project_incidents.py +0 -0
  240. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_project_lifecycle.py +0 -0
  241. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_project_logs.py +0 -0
  242. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_project_plugins.py +0 -0
  243. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_project_response_surfacing.py +0 -0
  244. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_project_type_plugins.py +0 -0
  245. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_project_types.py +0 -0
  246. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_projects.py +0 -0
  247. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_projects_helpers.py +0 -0
  248. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_pull_requests.py +0 -0
  249. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_releases.py +0 -0
  250. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_request_urls.py +0 -0
  251. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_roles.py +0 -0
  252. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_sa_api_keys.py +0 -0
  253. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_scoring.py +0 -0
  254. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_scoring_policies.py +0 -0
  255. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_search.py +0 -0
  256. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_service_accounts.py +0 -0
  257. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_service_plugins.py +0 -0
  258. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_service_writeback.py +0 -0
  259. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_status.py +0 -0
  260. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_tags.py +0 -0
  261. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_teams.py +0 -0
  262. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_third_party_services.py +0 -0
  263. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_uploads.py +0 -0
  264. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_user_activity.py +0 -0
  265. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_users.py +0 -0
  266. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/endpoints/test_webhooks.py +0 -0
  267. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/fixtures/sbom/npm-realistic.json +0 -0
  268. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/fixtures/sbom/pypi-realistic.json +0 -0
  269. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/fixtures/sbom/tiny.json +0 -0
  270. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/identity/__init__.py +0 -0
  271. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/identity/test_endpoints.py +0 -0
  272. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/identity/test_errors.py +0 -0
  273. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/identity/test_flows.py +0 -0
  274. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/identity/test_host_integration.py +0 -0
  275. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/identity/test_repository.py +0 -0
  276. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/identity/test_resolution.py +0 -0
  277. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/identity/test_state.py +0 -0
  278. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/identity/test_sweeper.py +0 -0
  279. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/middleware/test_rate_limit.py +0 -0
  280. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/storage/__init__.py +0 -0
  281. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/storage/test_client.py +0 -0
  282. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/storage/test_init.py +0 -0
  283. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/storage/test_thumbnails.py +0 -0
  284. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/storage/test_validation.py +0 -0
  285. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/support.py +0 -0
  286. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_app.py +0 -0
  287. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_assignment_writer.py +0 -0
  288. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_backfill_embeddings.py +0 -0
  289. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_blueprint_attributes.py +0 -0
  290. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_blueprints.py +0 -0
  291. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_commit_sync_queue.py +0 -0
  292. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_commit_sync_service.py +0 -0
  293. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_entrypoint.py +0 -0
  294. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_graph_sql.py +0 -0
  295. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_identity_attribution.py +0 -0
  296. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_init.py +0 -0
  297. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_installer.py +0 -0
  298. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_lifecycle_dispatch.py +0 -0
  299. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_lifespans.py +0 -0
  300. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_models.py +0 -0
  301. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_openapi.py +0 -0
  302. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_patch.py +0 -0
  303. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_plugin_schemas.py +0 -0
  304. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_plugins.py +0 -0
  305. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_sbom.py +0 -0
  306. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_scoring_queue.py +0 -0
  307. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_scoring_triggers.py +0 -0
  308. {imbi_api-2.11.2 → imbi_api-2.11.4}/tests/test_settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: imbi-api
3
- Version: 2.11.2
3
+ Version: 2.11.4
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.11.2
23
+ Requires-Dist: imbi-common[databases,llm,sentry,server]==2.11.4
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.11.2"
3
+ version = "2.11.4"
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.11.2",
31
+ "imbi-common[databases,llm,sentry,server]==2.11.4",
32
32
  "jinja2",
33
33
  "jsonpatch>=1.33",
34
34
  "mcp>=1.26.0",
@@ -26,6 +26,7 @@ def create_app() -> fastapi.FastAPI:
26
26
  valkey.valkey_lifespan,
27
27
  lifespans.score_worker_hook,
28
28
  lifespans.commit_sync_worker_hook,
29
+ lifespans.pr_sync_worker_hook,
29
30
  lifespans.identity_refresh_hook,
30
31
  ),
31
32
  version=version,
@@ -0,0 +1,393 @@
1
+ """Blueprint property compliance check for Project Doctor.
2
+
3
+ Compares a project's current AGE node properties against every
4
+ applicable Project blueprint's JSON Schema and emits an
5
+ :class:`~imbi_common.plugins.base.AnalysisResultItem` for each
6
+ non-conformant property.
7
+
8
+ This is a *built-in* check — no external plugin or credentials are
9
+ required; it queries the graph directly. The caller is responsible
10
+ for wrapping the returned items into :class:`AnalysisResult` objects
11
+ (adding ``plugin_slug`` and ``plugin_id``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ import typing
19
+
20
+ from imbi_common import graph, models
21
+ from imbi_common.plugins.base import AnalysisResultItem
22
+
23
+ from imbi_api.blueprint_attributes import project_blueprints
24
+
25
+ LOGGER = logging.getLogger(__name__)
26
+
27
+ BLUEPRINT_PLUGIN_SLUG = 'blueprint-compliance'
28
+ BLUEPRINT_PLUGIN_ID = 'built-in'
29
+
30
+ _SENTINEL = object() # distinguishes "not present" from explicit None
31
+
32
+ # Blueprint property names must be safe to embed in Cypher SET clauses.
33
+ # Only allow names that look like identifiers.
34
+ _SAFE_PROP_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
35
+
36
+ _FETCH_PROPS_QUERY = """
37
+ MATCH (p:Project {{id: {project_id}}})
38
+ RETURN p{{.*}} AS props
39
+ """
40
+
41
+
42
+ async def _fetch_project_props(
43
+ db: graph.Graph, project_id: str
44
+ ) -> dict[str, typing.Any]:
45
+ rows = await db.execute(
46
+ _FETCH_PROPS_QUERY, {'project_id': project_id}, ['props']
47
+ )
48
+ if not rows:
49
+ return {}
50
+ raw = graph.parse_agtype(rows[0]['props'])
51
+ if not isinstance(raw, dict):
52
+ return {}
53
+ return typing.cast('dict[str, typing.Any]', raw)
54
+
55
+
56
+ def _is_missing(value: typing.Any) -> bool:
57
+ return value is _SENTINEL or value is None or value == ''
58
+
59
+
60
+ def _check_property(
61
+ section_slug: str,
62
+ prop_name: str,
63
+ prop_schema: models.Schema,
64
+ required: bool,
65
+ current_value: typing.Any,
66
+ ) -> AnalysisResultItem | None:
67
+ """Return a finding for a non-conformant property, or ``None``."""
68
+ display = getattr(prop_schema, 'title', None) or prop_name
69
+ missing = _is_missing(current_value)
70
+
71
+ if required and missing:
72
+ return AnalysisResultItem(
73
+ slug=f'{BLUEPRINT_PLUGIN_SLUG}:{section_slug}:{prop_name}:missing',
74
+ title=f'Required property not set: {display}',
75
+ description=(
76
+ f'`{prop_name}` is required by the **{section_slug}** '
77
+ f'blueprint but has no value. Edit the project to set it.'
78
+ ),
79
+ status='fail',
80
+ )
81
+
82
+ if not missing:
83
+ enum = getattr(prop_schema, 'enum', None)
84
+ if enum is not None and current_value not in enum:
85
+ choices = ', '.join(f'`{v}`' for v in enum)
86
+ return AnalysisResultItem(
87
+ slug=f'{BLUEPRINT_PLUGIN_SLUG}:{section_slug}:{prop_name}:invalid-enum',
88
+ title=f'Property value not in allowed set: {display}',
89
+ description=(
90
+ f'`{prop_name}` is `{current_value!r}` but the '
91
+ f'allowed values are: {choices}. '
92
+ f'Edit the project to correct it.'
93
+ ),
94
+ status='fail',
95
+ )
96
+
97
+ minimum = getattr(prop_schema, 'minimum', None)
98
+ maximum = getattr(prop_schema, 'maximum', None)
99
+ if isinstance(current_value, (int, float)):
100
+ if minimum is not None and current_value < minimum:
101
+ return AnalysisResultItem(
102
+ slug=f'{BLUEPRINT_PLUGIN_SLUG}:{section_slug}:{prop_name}:below-minimum',
103
+ title=f'Property below minimum: {display}',
104
+ description=(
105
+ f'`{prop_name}` is `{current_value}` but '
106
+ f'the minimum is `{minimum}`.'
107
+ ),
108
+ status='warn',
109
+ )
110
+ if maximum is not None and current_value > maximum:
111
+ return AnalysisResultItem(
112
+ slug=f'{BLUEPRINT_PLUGIN_SLUG}:{section_slug}:{prop_name}:above-maximum',
113
+ title=f'Property above maximum: {display}',
114
+ description=(
115
+ f'`{prop_name}` is `{current_value}` but '
116
+ f'the maximum is `{maximum}`.'
117
+ ),
118
+ status='warn',
119
+ )
120
+
121
+ # Missing but not required — warn when a default is available
122
+ if missing:
123
+ default = getattr(prop_schema, 'default', None)
124
+ if default is not None:
125
+ return AnalysisResultItem(
126
+ slug=f'{BLUEPRINT_PLUGIN_SLUG}:{section_slug}:{prop_name}:use-default',
127
+ title=f'Property not set — default available: {display}',
128
+ description=(
129
+ f'`{prop_name}` has no value. '
130
+ f'The blueprint default is `{default!r}`. '
131
+ f'Use **Apply Blueprint Fixes** to set it.'
132
+ ),
133
+ status='warn',
134
+ )
135
+
136
+ return None
137
+
138
+
139
+ def _stale_blueprint_properties(
140
+ all_blueprints: list[models.Blueprint],
141
+ applicable: list[models.Blueprint],
142
+ current_props: dict[str, typing.Any],
143
+ ) -> list[str]:
144
+ """Return property names set on the project with no applicable blueprint.
145
+
146
+ A property is "stale" when it was set by a blueprint that no longer
147
+ applies to this project's type(s). Only considers properties defined in
148
+ *some* blueprint (so core model fields like ``id`` or ``name`` are never
149
+ flagged).
150
+ """
151
+ all_bp_props: set[str] = set()
152
+ for bp in all_blueprints:
153
+ if bp.kind != 'node':
154
+ continue
155
+ for prop_name in bp.json_schema.properties or {}:
156
+ all_bp_props.add(prop_name)
157
+
158
+ applicable_props: set[str] = set()
159
+ for bp in applicable:
160
+ for prop_name in bp.json_schema.properties or {}:
161
+ applicable_props.add(prop_name)
162
+
163
+ stale = all_bp_props - applicable_props
164
+ return [
165
+ p for p in stale if not _is_missing(current_props.get(p, _SENTINEL))
166
+ ]
167
+
168
+
169
+ def _applicable_blueprints(
170
+ blueprints: list[models.Blueprint],
171
+ type_slug_set: set[str],
172
+ ) -> list[models.Blueprint]:
173
+ out: list[models.Blueprint] = []
174
+ for bp in blueprints:
175
+ if bp.kind != 'node':
176
+ continue
177
+ f = bp.filter
178
+ if (
179
+ f is not None
180
+ and f.project_type
181
+ and not type_slug_set.intersection(f.project_type)
182
+ ):
183
+ continue
184
+ if bp.json_schema.properties:
185
+ out.append(bp)
186
+ return out
187
+
188
+
189
+ async def check_blueprint_compliance(
190
+ db: graph.Graph,
191
+ project_id: str,
192
+ type_slugs: list[str],
193
+ ) -> list[AnalysisResultItem]:
194
+ """Return blueprint compliance findings for a project.
195
+
196
+ Loads every enabled Project blueprint, filters to those that apply
197
+ to the project's types, and checks each property against the
198
+ project's current AGE node properties. Returns a single ``pass``
199
+ item when everything is compliant so the Doctor card always shows
200
+ something for this check.
201
+ """
202
+ all_blueprints = await project_blueprints(db)
203
+ if not all_blueprints:
204
+ return [
205
+ AnalysisResultItem(
206
+ slug=f'{BLUEPRINT_PLUGIN_SLUG}:no-blueprints',
207
+ title='No Project blueprints configured',
208
+ description=(
209
+ 'No enabled Project blueprints are defined. '
210
+ 'Add blueprints to track property compliance.'
211
+ ),
212
+ status='pass',
213
+ )
214
+ ]
215
+
216
+ type_slug_set = set(type_slugs)
217
+ applicable = _applicable_blueprints(all_blueprints, type_slug_set)
218
+
219
+ props = await _fetch_project_props(db, project_id)
220
+ findings: list[AnalysisResultItem] = []
221
+
222
+ for bp in applicable:
223
+ schema = bp.json_schema
224
+ required_names: set[str] = set(schema.required or [])
225
+ section_slug = bp.slug or ''
226
+ for prop_name, prop_schema in (schema.properties or {}).items():
227
+ extra = prop_schema.model_extra or {}
228
+ x_ui = dict(extra.get('x-ui') or {})
229
+ required = (
230
+ prop_name in required_names or x_ui.get('required') is True
231
+ )
232
+ current_value = props.get(prop_name, _SENTINEL)
233
+ finding = _check_property(
234
+ section_slug, prop_name, prop_schema, required, current_value
235
+ )
236
+ if finding is not None:
237
+ findings.append(finding)
238
+
239
+ # Detect blueprint-managed properties no longer in any applicable
240
+ # blueprint.
241
+ stale = _stale_blueprint_properties(all_blueprints, applicable, props)
242
+ for prop_name in sorted(stale):
243
+ findings.append(
244
+ AnalysisResultItem(
245
+ slug=f'{BLUEPRINT_PLUGIN_SLUG}:stale:{prop_name}',
246
+ title=f'Property not in any applicable blueprint: {prop_name}',
247
+ description=(
248
+ f'`{prop_name}` is set on this project but is not defined '
249
+ f'in any currently applicable blueprint. '
250
+ f'Use **Apply Blueprint Fixes** to remove it.'
251
+ ),
252
+ status='warn',
253
+ )
254
+ )
255
+
256
+ if not findings:
257
+ if not applicable:
258
+ return [
259
+ AnalysisResultItem(
260
+ slug=f'{BLUEPRINT_PLUGIN_SLUG}:no-applicable',
261
+ title='No blueprints apply to this project type',
262
+ description=(
263
+ "No enabled blueprints match this project's type(s)."
264
+ ),
265
+ status='pass',
266
+ )
267
+ ]
268
+ return [
269
+ AnalysisResultItem(
270
+ slug=f'{BLUEPRINT_PLUGIN_SLUG}:all-pass',
271
+ title='All blueprint properties are correctly set',
272
+ description=(
273
+ 'Every required and recommended blueprint property '
274
+ 'has a valid value.'
275
+ ),
276
+ status='pass',
277
+ )
278
+ ]
279
+ return findings
280
+
281
+
282
+ async def apply_blueprint_defaults(
283
+ db: graph.Graph,
284
+ project_id: str,
285
+ type_slugs: list[str],
286
+ ) -> int:
287
+ """Set blueprint default values on project properties that are unset.
288
+
289
+ Only touches properties that are currently ``None`` / absent AND
290
+ have a ``default`` in the blueprint schema. Does not overwrite
291
+ existing values.
292
+
293
+ Returns the number of properties updated.
294
+ """
295
+ all_blueprints = await project_blueprints(db)
296
+ if not all_blueprints:
297
+ return 0
298
+
299
+ type_slug_set = set(type_slugs)
300
+ applicable = _applicable_blueprints(all_blueprints, type_slug_set)
301
+ if not applicable:
302
+ return 0
303
+
304
+ props = await _fetch_project_props(db, project_id)
305
+
306
+ # Collect prop_name → default for every unset property with a default.
307
+ # Use an ordered dict so later (higher-priority) blueprints win.
308
+ defaults: dict[str, typing.Any] = {}
309
+ for bp in applicable:
310
+ for prop_name, prop_schema in (
311
+ bp.json_schema.properties or {}
312
+ ).items():
313
+ if not _SAFE_PROP_RE.match(prop_name):
314
+ LOGGER.warning(
315
+ 'Skipping unsafe blueprint property name %r', prop_name
316
+ )
317
+ continue
318
+ if not _is_missing(props.get(prop_name)):
319
+ continue
320
+ default = getattr(prop_schema, 'default', None)
321
+ if default is not None:
322
+ defaults[prop_name] = default
323
+
324
+ if not defaults:
325
+ return 0
326
+
327
+ # Build a single SET clause; parameter names are indexed to avoid
328
+ # collisions with the property names themselves.
329
+ set_parts = [f'p.{k} = {{v{i}}}' for i, k in enumerate(defaults)]
330
+ cypher = (
331
+ 'MATCH (p:Project {{id: {project_id}}}) SET '
332
+ + ', '.join(set_parts)
333
+ + ' RETURN p.id AS id'
334
+ )
335
+ params: dict[str, typing.Any] = {'project_id': project_id}
336
+ for i, (_, v) in enumerate(defaults.items()):
337
+ params[f'v{i}'] = v
338
+
339
+ await db.execute(cypher, params, ['id'])
340
+ LOGGER.info(
341
+ 'Applied %d blueprint default(s) to project %s: %s',
342
+ len(defaults),
343
+ project_id,
344
+ list(defaults.keys()),
345
+ )
346
+ return len(defaults)
347
+
348
+
349
+ async def remove_stale_blueprint_properties(
350
+ db: graph.Graph,
351
+ project_id: str,
352
+ type_slugs: list[str],
353
+ ) -> int:
354
+ """Remove blueprint-managed properties no longer in any applicable
355
+ blueprint.
356
+
357
+ Only removes properties defined in *some* blueprint, so core model
358
+ fields (``id``, ``name``, etc.) are never touched. Stale properties are
359
+ removed by setting them to ``null`` in a single Cypher ``SET`` clause.
360
+
361
+ Returns the number of properties removed.
362
+ """
363
+ all_blueprints = await project_blueprints(db)
364
+ if not all_blueprints:
365
+ return 0
366
+
367
+ type_slug_set = set(type_slugs)
368
+ applicable = _applicable_blueprints(all_blueprints, type_slug_set)
369
+ props = await _fetch_project_props(db, project_id)
370
+
371
+ stale = [
372
+ p
373
+ for p in _stale_blueprint_properties(all_blueprints, applicable, props)
374
+ if _SAFE_PROP_RE.match(p)
375
+ ]
376
+ if not stale:
377
+ return 0
378
+
379
+ # Setting a property to null removes it from the AGE node.
380
+ set_parts = [f'p.{k} = null' for k in stale]
381
+ cypher = (
382
+ 'MATCH (p:Project {{id: {project_id}}}) SET '
383
+ + ', '.join(set_parts)
384
+ + ' RETURN p.id AS id'
385
+ )
386
+ await db.execute(cypher, {'project_id': project_id}, ['id'])
387
+ LOGGER.info(
388
+ 'Removed %d stale blueprint property/ies from project %s: %s',
389
+ len(stale),
390
+ project_id,
391
+ stale,
392
+ )
393
+ return len(stale)
@@ -34,6 +34,7 @@ from .project_incidents import project_incidents_router
34
34
  from .project_lifecycle import project_lifecycle_router
35
35
  from .project_logs import project_logs_router
36
36
  from .project_plugins import project_plugins_router
37
+ from .project_pr_sync import project_pr_sync_router
37
38
  from .project_type_plugins import project_type_plugins_router
38
39
  from .project_types import project_types_router
39
40
  from .projects import projects_router
@@ -176,6 +177,10 @@ organizations_router.include_router(
176
177
  project_commit_sync_router,
177
178
  prefix='/{org_slug}/projects/{project_id}/commits',
178
179
  )
180
+ organizations_router.include_router(
181
+ project_pr_sync_router,
182
+ prefix='/{org_slug}/projects/{project_id}/pull-requests',
183
+ )
179
184
  organizations_router.include_router(
180
185
  pull_requests_project_router,
181
186
  prefix='/{org_slug}/projects/{project_id}/pull-requests',
@@ -29,6 +29,13 @@ from imbi_common.plugins.base import (
29
29
  from imbi_common.plugins.errors import PluginCredentialsMissing
30
30
 
31
31
  from imbi_api.auth import permissions
32
+ from imbi_api.blueprint_compliance import (
33
+ BLUEPRINT_PLUGIN_ID,
34
+ BLUEPRINT_PLUGIN_SLUG,
35
+ apply_blueprint_defaults,
36
+ check_blueprint_compliance,
37
+ remove_stale_blueprint_properties,
38
+ )
32
39
  from imbi_api.endpoints._helpers import (
33
40
  lookup_project_exists_in,
34
41
  lookup_project_links,
@@ -381,6 +388,13 @@ async def get_project_analysis(
381
388
  return report
382
389
 
383
390
 
391
+ class ApplyDefaultsResponse(pydantic.BaseModel):
392
+ """Result of syncing blueprint properties on a project."""
393
+
394
+ properties_updated: int
395
+ properties_removed: int = 0
396
+
397
+
384
398
  @project_analysis_router.post('/run', response_model=AnalysisReport)
385
399
  async def run_project_analysis(
386
400
  org_slug: str,
@@ -391,20 +405,27 @@ async def run_project_analysis(
391
405
  fastapi.Depends(permissions.require_permission('project:write')),
392
406
  ],
393
407
  ) -> AnalysisReport:
394
- plugins = await resolve_analysis_plugins(db, project_id)
395
- if not plugins:
396
- report = await _persist_report(
397
- db,
398
- project_id=project_id,
399
- overall_status='pass',
400
- triggered_by_user_id=(auth.user.id if auth.user else None),
401
- results=[],
408
+ type_slugs = await lookup_project_type_slugs(db, project_id)
409
+ compliance_items = await check_blueprint_compliance(
410
+ db, project_id, type_slugs
411
+ )
412
+ compliance_results = [
413
+ AnalysisResult(
414
+ slug=item.slug,
415
+ title=item.title,
416
+ description=item.description,
417
+ status=item.status,
418
+ plugin_slug=BLUEPRINT_PLUGIN_SLUG,
419
+ plugin_id=BLUEPRINT_PLUGIN_ID,
402
420
  )
403
- return report
421
+ for item in compliance_items
422
+ ]
423
+
424
+ plugins = await resolve_analysis_plugins(db, project_id)
404
425
  per_plugin = await asyncio.gather(
405
426
  *(_run_one(db, org_slug, project_id, rp) for rp in plugins)
406
427
  )
407
- results: list[AnalysisResult] = []
428
+ results: list[AnalysisResult] = list(compliance_results)
408
429
  for batch in per_plugin:
409
430
  results.extend(batch)
410
431
  results.sort(key=lambda r: (-_STATUS_RANK.get(r.status, 0), r.title))
@@ -417,3 +438,41 @@ async def run_project_analysis(
417
438
  triggered_by_user_id=triggered,
418
439
  results=results,
419
440
  )
441
+
442
+
443
+ @project_analysis_router.post(
444
+ '/apply-blueprint-defaults',
445
+ response_model=ApplyDefaultsResponse,
446
+ )
447
+ async def apply_project_blueprint_defaults(
448
+ org_slug: str,
449
+ project_id: str,
450
+ db: graph.Pool,
451
+ auth: typing.Annotated[
452
+ permissions.AuthContext,
453
+ fastapi.Depends(permissions.require_permission('project:write')),
454
+ ],
455
+ ) -> ApplyDefaultsResponse:
456
+ """Apply blueprint default values to any unset project properties.
457
+
458
+ Only sets properties that are currently absent or ``null`` and that
459
+ have a ``default`` defined in the applicable blueprint schema.
460
+ Existing values are never overwritten.
461
+ """
462
+ del org_slug
463
+ type_slugs = await lookup_project_type_slugs(db, project_id)
464
+ count = await apply_blueprint_defaults(db, project_id, type_slugs)
465
+ removed = await remove_stale_blueprint_properties(
466
+ db, project_id, type_slugs
467
+ )
468
+ if auth.user:
469
+ LOGGER.info(
470
+ 'User %s applied %d default(s), removed %d stale on project %s',
471
+ auth.user.id,
472
+ count,
473
+ removed,
474
+ project_id,
475
+ )
476
+ return ApplyDefaultsResponse(
477
+ properties_updated=count, properties_removed=removed
478
+ )
@@ -0,0 +1,85 @@
1
+ """On-demand PR history sync endpoints (Project Doctor).
2
+
3
+ ``POST /pull-requests/sync`` enqueues a background backfill of the
4
+ project's full pull-request history; ``GET /pull-requests/sync-status``
5
+ returns the last-run state for the UI to poll. The work runs as a
6
+ Valkey-stream job using the ``github-pr-sync`` plugin's service
7
+ credential, so the endpoint only validates eligibility and enqueues.
8
+ """
9
+
10
+ import typing
11
+
12
+ import fastapi
13
+ import pydantic
14
+ from imbi_common import graph
15
+
16
+ from imbi_api.auth import permissions
17
+ from imbi_api.plugins.resolution import resolve_plugin
18
+ from imbi_api.pr_sync import service
19
+ from imbi_api.pr_sync.queue import enqueue_pr_sync
20
+ from imbi_api.scoring import OptionalValkeyClient
21
+
22
+ project_pr_sync_router = fastapi.APIRouter(tags=['Project: Pull Requests'])
23
+
24
+
25
+ class PRSyncEnqueueResponse(pydantic.BaseModel):
26
+ """Result of enqueueing a PR history sync."""
27
+
28
+ enqueued: bool
29
+
30
+
31
+ @project_pr_sync_router.post('/sync', status_code=202)
32
+ async def sync_pull_requests(
33
+ org_slug: str,
34
+ project_id: str,
35
+ db: graph.Pool,
36
+ valkey_client: OptionalValkeyClient,
37
+ auth: typing.Annotated[
38
+ permissions.AuthContext,
39
+ fastapi.Depends(
40
+ permissions.require_permission('project:deployment:write'),
41
+ ),
42
+ ],
43
+ ) -> PRSyncEnqueueResponse:
44
+ """Enqueue a full pull-request history backfill for the project.
45
+
46
+ Requires the project to have a GitHub deployment plugin (the same
47
+ gate as deployment resync) and a connected ``github-pr-sync`` plugin
48
+ to provide the service credential. Returns ``enqueued=False`` when
49
+ the job was debounced or Valkey is unavailable.
50
+ """
51
+ await resolve_plugin(db, project_id, 'deployment', None)
52
+ try:
53
+ await service.check_available(db, project_id)
54
+ except service.PRSyncUnavailable as exc:
55
+ raise fastapi.HTTPException(status_code=400, detail=str(exc)) from exc
56
+ requested_by = auth.principal_name
57
+ enqueued = await enqueue_pr_sync(
58
+ valkey_client, org_slug, project_id, requested_by
59
+ )
60
+ if enqueued:
61
+ await service.set_status(
62
+ db,
63
+ project_id,
64
+ status='queued',
65
+ requested_by=requested_by,
66
+ retry=False,
67
+ )
68
+ return PRSyncEnqueueResponse(enqueued=enqueued)
69
+
70
+
71
+ @project_pr_sync_router.get('/sync-status')
72
+ async def get_pr_sync_status(
73
+ org_slug: str,
74
+ project_id: str,
75
+ db: graph.Pool,
76
+ _auth: typing.Annotated[
77
+ permissions.AuthContext,
78
+ fastapi.Depends(
79
+ permissions.require_permission('project:deployment:read'),
80
+ ),
81
+ ],
82
+ ) -> service.PRSyncStatus:
83
+ """Return the project's last PR sync state."""
84
+ del org_slug
85
+ return await service.read_status(db, project_id)
@@ -19,6 +19,7 @@ from imbi_api.email.client import EmailClient
19
19
  from imbi_api.email.templates import TemplateManager
20
20
  from imbi_api.identity import sweeper as identity_sweeper
21
21
  from imbi_api.plugins import lifecycle as plugin_lifecycle
22
+ from imbi_api.pr_sync import queue as pr_sync_queue
22
23
  from imbi_api.scoring import queue as score_queue
23
24
  from imbi_api.storage.client import StorageClient
24
25
 
@@ -165,6 +166,39 @@ async def commit_sync_worker_hook() -> abc.AsyncGenerator[None]:
165
166
  )
166
167
 
167
168
 
169
+ @contextlib.asynccontextmanager
170
+ async def pr_sync_worker_hook() -> abc.AsyncGenerator[None]:
171
+ """Run the on-demand PR-sync consumer loop."""
172
+ try:
173
+ client = valkey.get_client()
174
+ except RuntimeError:
175
+ LOGGER.warning('Valkey unavailable; pr-sync worker not started')
176
+ yield None
177
+ return
178
+ if _graph is None:
179
+ LOGGER.warning('Graph not ready; pr-sync worker not started')
180
+ yield None
181
+ return
182
+ stop = asyncio.Event()
183
+ LOGGER.info('PR-sync worker starting')
184
+ consumer_task = asyncio.create_task(
185
+ pr_sync_queue.consume_pr_sync(client, _graph, stop=stop)
186
+ )
187
+ try:
188
+ yield None
189
+ finally:
190
+ stop.set()
191
+ consumer_task.cancel()
192
+ try:
193
+ await consumer_task
194
+ except asyncio.CancelledError:
195
+ pass
196
+ except Exception: # noqa: BLE001
197
+ LOGGER.warning(
198
+ 'PR-sync worker task exited with error', exc_info=True
199
+ )
200
+
201
+
168
202
  @contextlib.asynccontextmanager
169
203
  async def score_worker_hook() -> abc.AsyncGenerator[None]:
170
204
  """Run the score-recompute consumer and the daily tick."""