attocode-code-intel 0.1.0__py3-none-any.whl

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 (345) hide show
  1. attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
  2. attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
  3. attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
  4. attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
  5. attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. attocode_intel/GUIDELINES.md +432 -0
  7. attocode_intel/__init__.py +52 -0
  8. attocode_intel/_internal/__init__.py +1 -0
  9. attocode_intel/_internal/errors/__init__.py +126 -0
  10. attocode_intel/_internal/integrations/__init__.py +1 -0
  11. attocode_intel/_internal/integrations/context/__init__.py +1 -0
  12. attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
  13. attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
  14. attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
  15. attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
  16. attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
  17. attocode_intel/_internal/integrations/context/cas.py +466 -0
  18. attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
  19. attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
  20. attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
  21. attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
  22. attocode_intel/_internal/integrations/context/cross_references.py +616 -0
  23. attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
  24. attocode_intel/_internal/integrations/context/embeddings.py +282 -0
  25. attocode_intel/_internal/integrations/context/frecency.py +427 -0
  26. attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
  27. attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
  28. attocode_intel/_internal/integrations/context/hydration.py +90 -0
  29. attocode_intel/_internal/integrations/context/index_repair.py +87 -0
  30. attocode_intel/_internal/integrations/context/index_store.py +586 -0
  31. attocode_intel/_internal/integrations/context/memory_store.py +602 -0
  32. attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
  33. attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
  34. attocode_intel/_internal/integrations/context/query_history.py +417 -0
  35. attocode_intel/_internal/integrations/context/reranker.py +351 -0
  36. attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
  37. attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
  38. attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
  39. attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
  40. attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
  41. attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
  42. attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
  43. attocode_intel/_internal/integrations/context/vector_store.py +795 -0
  44. attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
  45. attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
  46. attocode_intel/_internal/integrations/lsp/client.py +1140 -0
  47. attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
  48. attocode_intel/_internal/integrations/mcp/client.py +253 -0
  49. attocode_intel/_internal/integrations/security/__init__.py +1 -0
  50. attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
  51. attocode_intel/_internal/integrations/security/dataflow.py +522 -0
  52. attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
  53. attocode_intel/_internal/integrations/security/matcher.py +44 -0
  54. attocode_intel/_internal/integrations/security/patterns.py +724 -0
  55. attocode_intel/_internal/integrations/security/scanner.py +351 -0
  56. attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
  57. attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
  58. attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
  59. attocode_intel/_internal/tricks/__init__.py +1 -0
  60. attocode_intel/_internal/tricks/recursive_context.py +357 -0
  61. attocode_intel/_internal/types/__init__.py +1 -0
  62. attocode_intel/_internal/types/events.py +289 -0
  63. attocode_intel/_internal/types/severity.py +15 -0
  64. attocode_intel/_shared.py +341 -0
  65. attocode_intel/analysis_coverage.py +56 -0
  66. attocode_intel/api/__init__.py +1 -0
  67. attocode_intel/api/access.py +32 -0
  68. attocode_intel/api/app.py +247 -0
  69. attocode_intel/api/auth/__init__.py +128 -0
  70. attocode_intel/api/auth/api_keys.py +61 -0
  71. attocode_intel/api/auth/context.py +34 -0
  72. attocode_intel/api/auth/jwt.py +66 -0
  73. attocode_intel/api/auth/oauth.py +146 -0
  74. attocode_intel/api/auth/passwords.py +15 -0
  75. attocode_intel/api/deps.py +351 -0
  76. attocode_intel/api/knowledge_bridge.py +13 -0
  77. attocode_intel/api/mcp_transport.py +51 -0
  78. attocode_intel/api/middleware.py +411 -0
  79. attocode_intel/api/models.py +698 -0
  80. attocode_intel/api/providers/__init__.py +1 -0
  81. attocode_intel/api/providers/db_provider.py +1106 -0
  82. attocode_intel/api/providers/local_provider.py +476 -0
  83. attocode_intel/api/providers/remote_provider.py +740 -0
  84. attocode_intel/api/repository_source.py +21 -0
  85. attocode_intel/api/routes/__init__.py +1 -0
  86. attocode_intel/api/routes/_pin_helper.py +158 -0
  87. attocode_intel/api/routes/activity.py +148 -0
  88. attocode_intel/api/routes/adr.py +102 -0
  89. attocode_intel/api/routes/analysis.py +515 -0
  90. attocode_intel/api/routes/api_keys.py +170 -0
  91. attocode_intel/api/routes/auth.py +428 -0
  92. attocode_intel/api/routes/branches.py +262 -0
  93. attocode_intel/api/routes/cross_repo_search.py +198 -0
  94. attocode_intel/api/routes/embeddings.py +386 -0
  95. attocode_intel/api/routes/files.py +132 -0
  96. attocode_intel/api/routes/files_v2.py +388 -0
  97. attocode_intel/api/routes/gc.py +322 -0
  98. attocode_intel/api/routes/git_v2.py +433 -0
  99. attocode_intel/api/routes/graph.py +174 -0
  100. attocode_intel/api/routes/graph_viz.py +160 -0
  101. attocode_intel/api/routes/health.py +62 -0
  102. attocode_intel/api/routes/history.py +175 -0
  103. attocode_intel/api/routes/jobs.py +84 -0
  104. attocode_intel/api/routes/learning.py +205 -0
  105. attocode_intel/api/routes/lsp.py +272 -0
  106. attocode_intel/api/routes/notify.py +623 -0
  107. attocode_intel/api/routes/operations.py +27 -0
  108. attocode_intel/api/routes/orgs.py +924 -0
  109. attocode_intel/api/routes/preferences.py +100 -0
  110. attocode_intel/api/routes/presence.py +42 -0
  111. attocode_intel/api/routes/projects.py +116 -0
  112. attocode_intel/api/routes/repos.py +47 -0
  113. attocode_intel/api/routes/search.py +180 -0
  114. attocode_intel/api/routes/snapshots.py +614 -0
  115. attocode_intel/api/routes/webhooks.py +313 -0
  116. attocode_intel/api/routes/websocket.py +116 -0
  117. attocode_intel/api/static/graph.html +239 -0
  118. attocode_intel/api/utils.py +96 -0
  119. attocode_intel/artifacts/__init__.py +45 -0
  120. attocode_intel/artifacts/hashing.py +230 -0
  121. attocode_intel/artifacts/provenance.py +190 -0
  122. attocode_intel/artifacts/retrieval_pin.py +159 -0
  123. attocode_intel/audit.py +59 -0
  124. attocode_intel/bug_finder.py +206 -0
  125. attocode_intel/bundle.py +80 -0
  126. attocode_intel/catalog.py +242 -0
  127. attocode_intel/change_review.py +75 -0
  128. attocode_intel/cli.py +3062 -0
  129. attocode_intel/community.py +63 -0
  130. attocode_intel/confidence/__init__.py +144 -0
  131. attocode_intel/confidence/jev.py +234 -0
  132. attocode_intel/confidence/llm.py +330 -0
  133. attocode_intel/confidence/redact.py +44 -0
  134. attocode_intel/confidence/settings.py +74 -0
  135. attocode_intel/config.py +256 -0
  136. attocode_intel/crypto.py +31 -0
  137. attocode_intel/db/__init__.py +7 -0
  138. attocode_intel/db/base.py +33 -0
  139. attocode_intel/db/engine.py +61 -0
  140. attocode_intel/db/models.py +529 -0
  141. attocode_intel/entrypoint.py +185 -0
  142. attocode_intel/focused_evidence.py +130 -0
  143. attocode_intel/freshness.py +154 -0
  144. attocode_intel/gateway.py +734 -0
  145. attocode_intel/git/__init__.py +7 -0
  146. attocode_intel/git/credentials.py +75 -0
  147. attocode_intel/git/manager.py +493 -0
  148. attocode_intel/git/models.py +96 -0
  149. attocode_intel/git/storage.py +98 -0
  150. attocode_intel/graph_query_parser.py +761 -0
  151. attocode_intel/helpers.py +712 -0
  152. attocode_intel/indexing/__init__.py +29 -0
  153. attocode_intel/indexing/debouncer.py +136 -0
  154. attocode_intel/indexing/delta_indexer.py +151 -0
  155. attocode_intel/indexing/full_indexer.py +323 -0
  156. attocode_intel/indexing/incremental.py +240 -0
  157. attocode_intel/indexing/parser.py +403 -0
  158. attocode_intel/indexing/summarizer.py +264 -0
  159. attocode_intel/installer.py +1896 -0
  160. attocode_intel/knowledge.py +269 -0
  161. attocode_intel/local_knowledge.py +77 -0
  162. attocode_intel/migrations/alembic.ini +36 -0
  163. attocode_intel/migrations/env.py +71 -0
  164. attocode_intel/migrations/script.py.mako +27 -0
  165. attocode_intel/migrations/versions/001_initial_schema.py +219 -0
  166. attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
  167. attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
  168. attocode_intel/migrations/versions/004_audit_events.py +38 -0
  169. attocode_intel/migrations/versions/005_user_preferences.py +30 -0
  170. attocode_intel/migrations/versions/006_google_oauth.py +27 -0
  171. attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
  172. attocode_intel/migrations/versions/008_symbol_references.py +37 -0
  173. attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
  174. attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
  175. attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
  176. attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
  177. attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
  178. attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
  179. attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
  180. attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
  181. attocode_intel/migrations/versions/017_provenance_table.py +102 -0
  182. attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
  183. attocode_intel/migrations/versions/019_symref_caller.py +42 -0
  184. attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
  185. attocode_intel/onboarding.py +258 -0
  186. attocode_intel/output.py +178 -0
  187. attocode_intel/precision.py +133 -0
  188. attocode_intel/presence.py +160 -0
  189. attocode_intel/probe.py +110 -0
  190. attocode_intel/project_dir.py +97 -0
  191. attocode_intel/pubsub.py +113 -0
  192. attocode_intel/query_ranking.py +242 -0
  193. attocode_intel/readiness.py +972 -0
  194. attocode_intel/redis.py +51 -0
  195. attocode_intel/reference_pages.py +60 -0
  196. attocode_intel/remote.py +131 -0
  197. attocode_intel/repo_ranker.py +246 -0
  198. attocode_intel/request_context.py +82 -0
  199. attocode_intel/rules/__init__.py +28 -0
  200. attocode_intel/rules/ci.py +320 -0
  201. attocode_intel/rules/combinators.py +293 -0
  202. attocode_intel/rules/enricher.py +127 -0
  203. attocode_intel/rules/evolution.py +452 -0
  204. attocode_intel/rules/executor.py +531 -0
  205. attocode_intel/rules/filters/__init__.py +5 -0
  206. attocode_intel/rules/filters/pipeline.py +151 -0
  207. attocode_intel/rules/formatter.py +198 -0
  208. attocode_intel/rules/hygiene.py +242 -0
  209. attocode_intel/rules/importers/__init__.py +1 -0
  210. attocode_intel/rules/importers/semgrep.py +382 -0
  211. attocode_intel/rules/loader.py +292 -0
  212. attocode_intel/rules/marketplace.py +375 -0
  213. attocode_intel/rules/metavar.py +181 -0
  214. attocode_intel/rules/model.py +317 -0
  215. attocode_intel/rules/packs/__init__.py +1 -0
  216. attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
  217. attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
  218. attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
  219. attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
  220. attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
  221. attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
  222. attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
  223. attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
  224. attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
  225. attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
  226. attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
  227. attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
  228. attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
  229. attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
  230. attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
  231. attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
  232. attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
  233. attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
  234. attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
  235. attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
  236. attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
  237. attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
  238. attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
  239. attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
  240. attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
  241. attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
  242. attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
  243. attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
  244. attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
  245. attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
  246. attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
  247. attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
  248. attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
  249. attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
  250. attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
  251. attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
  252. attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
  253. attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
  254. attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
  255. attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
  256. attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
  257. attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
  258. attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
  259. attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
  260. attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
  261. attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
  262. attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
  263. attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
  264. attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
  265. attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
  266. attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
  267. attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
  268. attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
  269. attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
  270. attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
  271. attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
  272. attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
  273. attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
  274. attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
  275. attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
  276. attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
  277. attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
  278. attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
  279. attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
  280. attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
  281. attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
  282. attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
  283. attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
  284. attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
  285. attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
  286. attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
  287. attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
  288. attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
  289. attocode_intel/rules/packs/pack_loader.py +244 -0
  290. attocode_intel/rules/plugins/__init__.py +1 -0
  291. attocode_intel/rules/plugins/plugin_loader.py +132 -0
  292. attocode_intel/rules/profiling.py +332 -0
  293. attocode_intel/rules/registry.py +213 -0
  294. attocode_intel/rules/sarif.py +187 -0
  295. attocode_intel/rules/synthesis.py +420 -0
  296. attocode_intel/rules/testing.py +326 -0
  297. attocode_intel/server.py +734 -0
  298. attocode_intel/service.py +3416 -0
  299. attocode_intel/sharing.py +57 -0
  300. attocode_intel/storage/__init__.py +6 -0
  301. attocode_intel/storage/branch_overlay.py +352 -0
  302. attocode_intel/storage/content_store.py +220 -0
  303. attocode_intel/storage/dependency_store.py +143 -0
  304. attocode_intel/storage/diff_engine.py +187 -0
  305. attocode_intel/storage/embedding_store.py +594 -0
  306. attocode_intel/storage/provenance_store.py +137 -0
  307. attocode_intel/storage/security_scanner_db.py +164 -0
  308. attocode_intel/storage/symbol_store.py +160 -0
  309. attocode_intel/structural_evidence.py +144 -0
  310. attocode_intel/symbol_inspection.py +141 -0
  311. attocode_intel/symbol_links.py +167 -0
  312. attocode_intel/telemetry.py +42 -0
  313. attocode_intel/test_ranking.py +73 -0
  314. attocode_intel/testing/__init__.py +43 -0
  315. attocode_intel/testing/fixtures.py +122 -0
  316. attocode_intel/testing/helpers.py +75 -0
  317. attocode_intel/testing/mocks.py +99 -0
  318. attocode_intel/tools/__init__.py +2 -0
  319. attocode_intel/tools/adr_tools.py +628 -0
  320. attocode_intel/tools/analysis_tools.py +382 -0
  321. attocode_intel/tools/composite_tools.py +695 -0
  322. attocode_intel/tools/cross_mode_tools.py +173 -0
  323. attocode_intel/tools/dead_code_tools.py +582 -0
  324. attocode_intel/tools/distill_tools.py +353 -0
  325. attocode_intel/tools/frecency_tools.py +220 -0
  326. attocode_intel/tools/fuzzy_tools.py +255 -0
  327. attocode_intel/tools/history_tools.py +486 -0
  328. attocode_intel/tools/learning_tools.py +222 -0
  329. attocode_intel/tools/lsp_tools.py +218 -0
  330. attocode_intel/tools/maintenance_tools.py +1662 -0
  331. attocode_intel/tools/navigation_tools.py +220 -0
  332. attocode_intel/tools/overlay_tools.py +540 -0
  333. attocode_intel/tools/pin_store.py +432 -0
  334. attocode_intel/tools/pin_tools.py +266 -0
  335. attocode_intel/tools/query_constraints_tools.py +262 -0
  336. attocode_intel/tools/query_history_tools.py +158 -0
  337. attocode_intel/tools/readiness_tools.py +44 -0
  338. attocode_intel/tools/rule_tools.py +992 -0
  339. attocode_intel/tools/search_tools.py +770 -0
  340. attocode_intel/tools/snapshot_tools.py +624 -0
  341. attocode_intel/workers/__init__.py +1 -0
  342. attocode_intel/workers/job_utils.py +111 -0
  343. attocode_intel/workers/jobs.py +573 -0
  344. attocode_intel/workers/run.py +30 -0
  345. attocode_intel/workers/settings.py +61 -0
@@ -0,0 +1,924 @@
1
+ """Organization management endpoints (service mode only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import uuid
8
+ from datetime import UTC, datetime
9
+
10
+ from fastapi import APIRouter, Depends, HTTPException, Query
11
+ from pydantic import BaseModel
12
+ from sqlalchemy import func, select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from attocode_intel.api.auth import resolve_auth
16
+ from attocode_intel.api.auth.context import AuthContext
17
+ from attocode_intel.api.deps import get_db_session
18
+ from attocode_intel.db.models import Organization, OrgMembership, Repository, User
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ router = APIRouter(prefix="/api/v1/orgs", tags=["organizations"])
23
+
24
+
25
+ # --- Request/Response models ---
26
+
27
+
28
+ class CreateOrgRequest(BaseModel):
29
+ name: str
30
+ slug: str = ""
31
+
32
+
33
+ class UpdateOrgRequest(BaseModel):
34
+ name: str | None = None
35
+ plan: str | None = None
36
+ settings: dict | None = None
37
+
38
+
39
+ class OrgResponse(BaseModel):
40
+ id: str
41
+ name: str
42
+ slug: str
43
+ plan: str
44
+ created_at: str
45
+ member_count: int = 0
46
+
47
+
48
+ class OrgListResponse(BaseModel):
49
+ organizations: list[OrgResponse]
50
+ total: int = 0
51
+ limit: int = 20
52
+ offset: int = 0
53
+ has_more: bool = False
54
+
55
+
56
+ class InviteMemberRequest(BaseModel):
57
+ email: str
58
+ role: str = "member"
59
+
60
+
61
+ class MemberResponse(BaseModel):
62
+ user_id: str
63
+ email: str
64
+ name: str
65
+ role: str
66
+ accepted: bool
67
+
68
+
69
+ class MemberListResponse(BaseModel):
70
+ members: list[MemberResponse]
71
+ total: int = 0
72
+ limit: int = 20
73
+ offset: int = 0
74
+ has_more: bool = False
75
+
76
+
77
+ class UpdateMemberRequest(BaseModel):
78
+ role: str
79
+
80
+
81
+ class CreateRepoRequest(BaseModel):
82
+ name: str
83
+ clone_url: str | None = None
84
+ local_path: str | None = None
85
+ default_branch: str = "main"
86
+ language: str | None = None
87
+
88
+
89
+ class UpdateRepoRequest(BaseModel):
90
+ """Fields that can be updated after a repository is created.
91
+
92
+ ``None`` means "leave unchanged". Empty string explicitly clears a
93
+ nullable column. Immutable fields (``id``, ``created_at``) are not
94
+ present.
95
+
96
+ ``target_org_id`` lets admins move a repo between orgs they have
97
+ admin rights in. Both source and target orgs are checked for
98
+ membership, and the target org must not already contain a repo with
99
+ the same name.
100
+ """
101
+ name: str | None = None
102
+ clone_url: str | None = None
103
+ default_branch: str | None = None
104
+ language: str | None = None
105
+ local_path: str | None = None
106
+ target_org_id: uuid.UUID | None = None
107
+
108
+
109
+ class RepoResponse(BaseModel):
110
+ id: str
111
+ name: str
112
+ clone_url: str | None = None
113
+ local_path: str | None = None
114
+ default_branch: str
115
+ language: str | None = None
116
+ index_status: str
117
+ last_indexed_at: str | None = None
118
+ created_at: str
119
+
120
+
121
+ class RepoListResponse(BaseModel):
122
+ repositories: list[RepoResponse]
123
+ total: int = 0
124
+ limit: int = 20
125
+ offset: int = 0
126
+ has_more: bool = False
127
+
128
+
129
+ def _slugify(name: str) -> str:
130
+ """Generate a URL-safe slug from a name."""
131
+ slug = name.lower().strip()
132
+ slug = re.sub(r"[^\w\s-]", "", slug)
133
+ slug = re.sub(r"[\s_]+", "-", slug)
134
+ return slug[:64]
135
+
136
+
137
+ async def _require_membership(
138
+ org_id: uuid.UUID, auth: AuthContext, session: AsyncSession, min_role: str = "member"
139
+ ) -> OrgMembership:
140
+ """Verify the user is a member of the org with sufficient role."""
141
+ if not auth.user_id:
142
+ raise HTTPException(status_code=403, detail="Authentication required")
143
+
144
+ role_hierarchy = {"owner": 3, "admin": 2, "member": 1}
145
+ min_level = role_hierarchy.get(min_role, 1)
146
+
147
+ result = await session.execute(
148
+ select(OrgMembership).where(
149
+ OrgMembership.org_id == org_id,
150
+ OrgMembership.user_id == auth.user_id,
151
+ )
152
+ )
153
+ membership = result.scalar_one_or_none()
154
+ if membership is None:
155
+ raise HTTPException(status_code=403, detail="Not a member of this organization")
156
+
157
+ user_level = role_hierarchy.get(membership.role, 0)
158
+ if user_level < min_level:
159
+ raise HTTPException(status_code=403, detail=f"Requires {min_role} role or higher")
160
+
161
+ return membership
162
+
163
+
164
+ # --- Organization CRUD ---
165
+
166
+
167
+ @router.post("", response_model=OrgResponse)
168
+ async def create_org(
169
+ req: CreateOrgRequest,
170
+ auth: AuthContext = Depends(resolve_auth),
171
+ session: AsyncSession = Depends(get_db_session),
172
+ ) -> OrgResponse:
173
+ """Create a new organization. Creator becomes owner."""
174
+ if not auth.user_id:
175
+ raise HTTPException(status_code=401, detail="Authentication required")
176
+
177
+ slug = req.slug or _slugify(req.name)
178
+ existing = await session.execute(select(Organization).where(Organization.slug == slug))
179
+ if existing.scalar_one_or_none():
180
+ raise HTTPException(status_code=409, detail=f"Slug '{slug}' already taken")
181
+
182
+ org = Organization(name=req.name, slug=slug)
183
+ session.add(org)
184
+ await session.flush()
185
+
186
+ # Add creator as owner
187
+ membership = OrgMembership(
188
+ org_id=org.id,
189
+ user_id=auth.user_id,
190
+ role="owner",
191
+ accepted_at=datetime.now(UTC),
192
+ )
193
+ session.add(membership)
194
+ await session.commit()
195
+ await session.refresh(org)
196
+
197
+ from attocode_intel.audit import log_event
198
+
199
+ await log_event(session, org.id, "org.created", user_id=auth.user_id, detail={"name": org.name, "slug": org.slug})
200
+
201
+ return OrgResponse(
202
+ id=str(org.id),
203
+ name=org.name,
204
+ slug=org.slug,
205
+ plan=org.plan,
206
+ created_at=org.created_at.isoformat(),
207
+ member_count=1,
208
+ )
209
+
210
+
211
+ @router.get("", response_model=OrgListResponse)
212
+ async def list_orgs(
213
+ limit: int = Query(20, ge=1, le=100),
214
+ offset: int = Query(0, ge=0),
215
+ auth: AuthContext = Depends(resolve_auth),
216
+ session: AsyncSession = Depends(get_db_session),
217
+ ) -> OrgListResponse:
218
+ """List organizations the current user belongs to."""
219
+ if not auth.user_id:
220
+ raise HTTPException(status_code=401, detail="Authentication required")
221
+
222
+ base_query = (
223
+ select(Organization)
224
+ .join(OrgMembership, Organization.id == OrgMembership.org_id)
225
+ .where(OrgMembership.user_id == auth.user_id)
226
+ )
227
+
228
+ count_result = await session.execute(
229
+ select(func.count()).select_from(base_query.subquery())
230
+ )
231
+ total = count_result.scalar() or 0
232
+
233
+ result = await session.execute(base_query.offset(offset).limit(limit))
234
+ orgs = []
235
+ for org in result.scalars():
236
+ orgs.append(OrgResponse(
237
+ id=str(org.id),
238
+ name=org.name,
239
+ slug=org.slug,
240
+ plan=org.plan,
241
+ created_at=org.created_at.isoformat(),
242
+ ))
243
+ return OrgListResponse(
244
+ organizations=orgs, total=total, limit=limit, offset=offset,
245
+ has_more=(offset + limit < total),
246
+ )
247
+
248
+
249
+ @router.get("/{org_id}", response_model=OrgResponse)
250
+ async def get_org(
251
+ org_id: uuid.UUID,
252
+ auth: AuthContext = Depends(resolve_auth),
253
+ session: AsyncSession = Depends(get_db_session),
254
+ ) -> OrgResponse:
255
+ """Get organization details."""
256
+ await _require_membership(org_id, auth, session)
257
+
258
+ result = await session.execute(select(Organization).where(Organization.id == org_id))
259
+ org = result.scalar_one_or_none()
260
+ if org is None:
261
+ raise HTTPException(status_code=404, detail="Organization not found")
262
+
263
+ return OrgResponse(
264
+ id=str(org.id),
265
+ name=org.name,
266
+ slug=org.slug,
267
+ plan=org.plan,
268
+ created_at=org.created_at.isoformat(),
269
+ )
270
+
271
+
272
+ @router.patch("/{org_id}", response_model=OrgResponse)
273
+ async def update_org(
274
+ org_id: uuid.UUID,
275
+ req: UpdateOrgRequest,
276
+ auth: AuthContext = Depends(resolve_auth),
277
+ session: AsyncSession = Depends(get_db_session),
278
+ ) -> OrgResponse:
279
+ """Update organization. Requires admin+ role."""
280
+ await _require_membership(org_id, auth, session, min_role="admin")
281
+
282
+ result = await session.execute(select(Organization).where(Organization.id == org_id))
283
+ org = result.scalar_one_or_none()
284
+ if org is None:
285
+ raise HTTPException(status_code=404, detail="Organization not found")
286
+
287
+ if req.name is not None:
288
+ org.name = req.name
289
+ if req.plan is not None:
290
+ org.plan = req.plan
291
+ if req.settings is not None:
292
+ org.settings = req.settings
293
+
294
+ await session.commit()
295
+ await session.refresh(org)
296
+
297
+ return OrgResponse(
298
+ id=str(org.id),
299
+ name=org.name,
300
+ slug=org.slug,
301
+ plan=org.plan,
302
+ created_at=org.created_at.isoformat(),
303
+ )
304
+
305
+
306
+ # --- Members ---
307
+
308
+
309
+ @router.post("/{org_id}/members", response_model=MemberResponse)
310
+ async def invite_member(
311
+ org_id: uuid.UUID,
312
+ req: InviteMemberRequest,
313
+ auth: AuthContext = Depends(resolve_auth),
314
+ session: AsyncSession = Depends(get_db_session),
315
+ ) -> MemberResponse:
316
+ """Invite a user to the organization. Requires admin+ role."""
317
+ await _require_membership(org_id, auth, session, min_role="admin")
318
+
319
+ if req.role not in ("member", "admin"):
320
+ raise HTTPException(status_code=400, detail="Role must be 'member' or 'admin'")
321
+
322
+ result = await session.execute(select(User).where(User.email == req.email))
323
+ user = result.scalar_one_or_none()
324
+ if user is None:
325
+ raise HTTPException(status_code=404, detail=f"User with email '{req.email}' not found")
326
+
327
+ # Check if already a member
328
+ existing = await session.execute(
329
+ select(OrgMembership).where(
330
+ OrgMembership.org_id == org_id,
331
+ OrgMembership.user_id == user.id,
332
+ )
333
+ )
334
+ if existing.scalar_one_or_none():
335
+ raise HTTPException(status_code=409, detail="User is already a member")
336
+
337
+ membership = OrgMembership(org_id=org_id, user_id=user.id, role=req.role)
338
+ session.add(membership)
339
+ await session.commit()
340
+
341
+ from attocode_intel.audit import log_event
342
+
343
+ await log_event(session, org_id, "member.invited", user_id=auth.user_id, detail={"email": req.email, "role": req.role})
344
+
345
+ return MemberResponse(
346
+ user_id=str(user.id),
347
+ email=user.email,
348
+ name=user.name,
349
+ role=req.role,
350
+ accepted=False,
351
+ )
352
+
353
+
354
+ @router.get("/{org_id}/members", response_model=MemberListResponse)
355
+ async def list_members(
356
+ org_id: uuid.UUID,
357
+ limit: int = Query(20, ge=1, le=100),
358
+ offset: int = Query(0, ge=0),
359
+ auth: AuthContext = Depends(resolve_auth),
360
+ session: AsyncSession = Depends(get_db_session),
361
+ ) -> MemberListResponse:
362
+ """List organization members."""
363
+ await _require_membership(org_id, auth, session)
364
+
365
+ count_result = await session.execute(
366
+ select(func.count()).select_from(
367
+ select(OrgMembership).where(OrgMembership.org_id == org_id).subquery()
368
+ )
369
+ )
370
+ total = count_result.scalar() or 0
371
+
372
+ result = await session.execute(
373
+ select(OrgMembership, User)
374
+ .join(User, OrgMembership.user_id == User.id)
375
+ .where(OrgMembership.org_id == org_id)
376
+ .offset(offset).limit(limit)
377
+ )
378
+ members = []
379
+ for membership, user in result:
380
+ members.append(MemberResponse(
381
+ user_id=str(user.id),
382
+ email=user.email,
383
+ name=user.name,
384
+ role=membership.role,
385
+ accepted=membership.accepted_at is not None,
386
+ ))
387
+ return MemberListResponse(
388
+ members=members, total=total, limit=limit, offset=offset,
389
+ has_more=(offset + limit < total),
390
+ )
391
+
392
+
393
+ @router.patch("/{org_id}/members/{user_id}", response_model=MemberResponse)
394
+ async def update_member_role(
395
+ org_id: uuid.UUID,
396
+ user_id: uuid.UUID,
397
+ req: UpdateMemberRequest,
398
+ auth: AuthContext = Depends(resolve_auth),
399
+ session: AsyncSession = Depends(get_db_session),
400
+ ) -> MemberResponse:
401
+ """Change a member's role. Requires owner role."""
402
+ await _require_membership(org_id, auth, session, min_role="owner")
403
+
404
+ if req.role not in ("member", "admin", "owner"):
405
+ raise HTTPException(status_code=400, detail="Invalid role")
406
+
407
+ result = await session.execute(
408
+ select(OrgMembership).where(
409
+ OrgMembership.org_id == org_id,
410
+ OrgMembership.user_id == user_id,
411
+ )
412
+ )
413
+ membership = result.scalar_one_or_none()
414
+ if membership is None:
415
+ raise HTTPException(status_code=404, detail="Member not found")
416
+
417
+ membership.role = req.role
418
+ await session.commit()
419
+
420
+ user_result = await session.execute(select(User).where(User.id == user_id))
421
+ user = user_result.scalar_one()
422
+
423
+ from attocode_intel.audit import log_event
424
+
425
+ await log_event(session, org_id, "member.role_changed", user_id=auth.user_id, detail={"target_user_id": str(user_id), "new_role": req.role})
426
+
427
+ return MemberResponse(
428
+ user_id=str(user.id),
429
+ email=user.email,
430
+ name=user.name,
431
+ role=membership.role,
432
+ accepted=membership.accepted_at is not None,
433
+ )
434
+
435
+
436
+ @router.delete("/{org_id}/members/{user_id}")
437
+ async def remove_member(
438
+ org_id: uuid.UUID,
439
+ user_id: uuid.UUID,
440
+ auth: AuthContext = Depends(resolve_auth),
441
+ session: AsyncSession = Depends(get_db_session),
442
+ ) -> dict:
443
+ """Remove a member from the organization. Requires admin+ role."""
444
+ await _require_membership(org_id, auth, session, min_role="admin")
445
+
446
+ result = await session.execute(
447
+ select(OrgMembership).where(
448
+ OrgMembership.org_id == org_id,
449
+ OrgMembership.user_id == user_id,
450
+ )
451
+ )
452
+ membership = result.scalar_one_or_none()
453
+ if membership is None:
454
+ raise HTTPException(status_code=404, detail="Member not found")
455
+
456
+ if membership.role == "owner":
457
+ raise HTTPException(status_code=400, detail="Cannot remove the org owner")
458
+
459
+ await session.delete(membership)
460
+
461
+ from attocode_intel.audit import log_event
462
+
463
+ await log_event(session, org_id, "member.removed", user_id=auth.user_id, detail={"removed_user_id": str(user_id)})
464
+
465
+ await session.commit()
466
+ return {"detail": "Member removed"}
467
+
468
+
469
+ # --- Org-scoped Repositories ---
470
+
471
+
472
+ @router.post("/{org_id}/repos", response_model=RepoResponse)
473
+ async def create_repo(
474
+ org_id: uuid.UUID,
475
+ req: CreateRepoRequest,
476
+ auth: AuthContext = Depends(resolve_auth),
477
+ session: AsyncSession = Depends(get_db_session),
478
+ ) -> RepoResponse:
479
+ """Create a repository in the organization."""
480
+ await _require_membership(org_id, auth, session)
481
+ from attocode_intel.api.repository_source import validate_remote_source
482
+ validate_remote_source(req.clone_url, req.local_path)
483
+
484
+ # Check uniqueness
485
+ existing = await session.execute(
486
+ select(Repository).where(
487
+ Repository.org_id == org_id,
488
+ Repository.name == req.name,
489
+ )
490
+ )
491
+ if existing.scalar_one_or_none():
492
+ raise HTTPException(status_code=409, detail=f"Repository '{req.name}' already exists in this org")
493
+
494
+ repo = Repository(
495
+ org_id=org_id,
496
+ name=req.name,
497
+ clone_url=req.clone_url,
498
+ local_path=req.local_path,
499
+ default_branch=req.default_branch,
500
+ language=req.language,
501
+ )
502
+ session.add(repo)
503
+ await session.commit()
504
+ await session.refresh(repo)
505
+
506
+ # Only enqueue full index if we have a clone_url (server can clone it).
507
+ # local_path-only repos use incremental updates via notify/watch.
508
+ if repo.clone_url:
509
+ try:
510
+ from arq import create_pool
511
+
512
+ from attocode_intel.workers.settings import get_redis_settings
513
+
514
+ pool = await create_pool(get_redis_settings())
515
+ await pool.enqueue_job("index_repository", str(repo.id))
516
+ await pool.aclose()
517
+ repo.index_status = "indexing"
518
+ await session.commit()
519
+ await session.refresh(repo)
520
+ except Exception:
521
+ logger.warning("Failed to enqueue indexing job for repo %s", repo.id, exc_info=True)
522
+
523
+ return RepoResponse(
524
+ id=str(repo.id),
525
+ name=repo.name,
526
+ clone_url=repo.clone_url,
527
+ local_path=repo.local_path,
528
+ default_branch=repo.default_branch,
529
+ language=repo.language,
530
+ index_status=repo.index_status,
531
+ last_indexed_at=repo.last_indexed_at.isoformat() if repo.last_indexed_at else None,
532
+ created_at=repo.created_at.isoformat(),
533
+ )
534
+
535
+
536
+ @router.get("/{org_id}/repos", response_model=RepoListResponse)
537
+ async def list_repos(
538
+ org_id: uuid.UUID,
539
+ limit: int = Query(20, ge=1, le=100),
540
+ offset: int = Query(0, ge=0),
541
+ auth: AuthContext = Depends(resolve_auth),
542
+ session: AsyncSession = Depends(get_db_session),
543
+ ) -> RepoListResponse:
544
+ """List repositories in the organization."""
545
+ await _require_membership(org_id, auth, session)
546
+
547
+ count_result = await session.execute(
548
+ select(func.count()).select_from(
549
+ select(Repository).where(Repository.org_id == org_id).subquery()
550
+ )
551
+ )
552
+ total = count_result.scalar() or 0
553
+
554
+ result = await session.execute(
555
+ select(Repository).where(Repository.org_id == org_id)
556
+ .offset(offset).limit(limit)
557
+ )
558
+ repos = []
559
+ for repo in result.scalars():
560
+ repos.append(RepoResponse(
561
+ id=str(repo.id),
562
+ name=repo.name,
563
+ clone_url=repo.clone_url,
564
+ local_path=repo.local_path,
565
+ default_branch=repo.default_branch,
566
+ language=repo.language,
567
+ index_status=repo.index_status,
568
+ last_indexed_at=repo.last_indexed_at.isoformat() if repo.last_indexed_at else None,
569
+ created_at=repo.created_at.isoformat(),
570
+ ))
571
+ return RepoListResponse(
572
+ repositories=repos, total=total, limit=limit, offset=offset,
573
+ has_more=(offset + limit < total),
574
+ )
575
+
576
+
577
+ @router.get("/{org_id}/repos/{repo_id}", response_model=RepoResponse)
578
+ async def get_repo(
579
+ org_id: uuid.UUID,
580
+ repo_id: uuid.UUID,
581
+ auth: AuthContext = Depends(resolve_auth),
582
+ session: AsyncSession = Depends(get_db_session),
583
+ ) -> RepoResponse:
584
+ """Get repository details."""
585
+ await _require_membership(org_id, auth, session)
586
+
587
+ result = await session.execute(
588
+ select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
589
+ )
590
+ repo = result.scalar_one_or_none()
591
+ if repo is None:
592
+ raise HTTPException(status_code=404, detail="Repository not found")
593
+
594
+ return RepoResponse(
595
+ id=str(repo.id),
596
+ name=repo.name,
597
+ clone_url=repo.clone_url,
598
+ local_path=repo.local_path,
599
+ default_branch=repo.default_branch,
600
+ language=repo.language,
601
+ index_status=repo.index_status,
602
+ last_indexed_at=repo.last_indexed_at.isoformat() if repo.last_indexed_at else None,
603
+ created_at=repo.created_at.isoformat(),
604
+ )
605
+
606
+
607
+ @router.delete("/{org_id}/repos/{repo_id}")
608
+ async def delete_repo(
609
+ org_id: uuid.UUID,
610
+ repo_id: uuid.UUID,
611
+ auth: AuthContext = Depends(resolve_auth),
612
+ session: AsyncSession = Depends(get_db_session),
613
+ ) -> dict:
614
+ """Delete a repository. Requires admin+ role."""
615
+ await _require_membership(org_id, auth, session, min_role="admin")
616
+
617
+ result = await session.execute(
618
+ select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
619
+ )
620
+ repo = result.scalar_one_or_none()
621
+ if repo is None:
622
+ raise HTTPException(status_code=404, detail="Repository not found")
623
+
624
+ await session.delete(repo)
625
+ await session.commit()
626
+ return {"detail": "Repository deleted"}
627
+
628
+
629
+ @router.patch("/{org_id}/repos/{repo_id}", response_model=RepoResponse)
630
+ async def update_repo(
631
+ org_id: uuid.UUID,
632
+ repo_id: uuid.UUID,
633
+ req: UpdateRepoRequest,
634
+ auth: AuthContext = Depends(resolve_auth),
635
+ session: AsyncSession = Depends(get_db_session),
636
+ ) -> RepoResponse:
637
+ """Update mutable repository fields (admin+ required).
638
+
639
+ Supports renaming the repo, retargeting its ``clone_url``, changing
640
+ the default branch, or updating the declared language. Phase 3a
641
+ does NOT support moving a repo between orgs (that would require
642
+ re-checking quotas and audit logging across two orgs); use create +
643
+ delete for that case.
644
+
645
+ Returns the updated :class:`RepoResponse`.
646
+ """
647
+ await _require_membership(org_id, auth, session, min_role="admin")
648
+
649
+ result = await session.execute(
650
+ select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
651
+ )
652
+ repo = result.scalar_one_or_none()
653
+ if repo is None:
654
+ raise HTTPException(status_code=404, detail="Repository not found")
655
+
656
+ changes: dict[str, dict[str, str | None]] = {}
657
+
658
+ # Rename: enforce uniqueness within the org.
659
+ if req.name is not None and req.name != repo.name:
660
+ if not req.name:
661
+ raise HTTPException(status_code=422, detail="name cannot be empty")
662
+ existing = await session.execute(
663
+ select(Repository).where(
664
+ Repository.org_id == org_id,
665
+ Repository.name == req.name,
666
+ Repository.id != repo_id,
667
+ )
668
+ )
669
+ if existing.scalar_one_or_none():
670
+ raise HTTPException(
671
+ status_code=409,
672
+ detail=f"Repository '{req.name}' already exists in this org",
673
+ )
674
+ changes["name"] = {"from": repo.name, "to": req.name}
675
+ repo.name = req.name
676
+
677
+ from attocode_intel.api.repository_source import validate_remote_source
678
+ validate_remote_source(req.clone_url, req.local_path)
679
+ if req.clone_url is not None and req.clone_url != repo.clone_url:
680
+ changes["clone_url"] = {"from": repo.clone_url, "to": req.clone_url or None}
681
+ repo.clone_url = req.clone_url or None
682
+
683
+ if req.default_branch is not None and req.default_branch != repo.default_branch:
684
+ if not req.default_branch:
685
+ raise HTTPException(status_code=422, detail="default_branch cannot be empty")
686
+ changes["default_branch"] = {
687
+ "from": repo.default_branch,
688
+ "to": req.default_branch,
689
+ }
690
+ repo.default_branch = req.default_branch
691
+
692
+ if req.language is not None and req.language != repo.language:
693
+ changes["language"] = {"from": repo.language, "to": req.language or None}
694
+ repo.language = req.language or None
695
+
696
+ if req.local_path is not None and req.local_path != repo.local_path:
697
+ changes["local_path"] = {"from": repo.local_path, "to": req.local_path or None}
698
+ repo.local_path = req.local_path or None
699
+
700
+ # Optional cross-org move. Rejected if the caller doesn't hold admin
701
+ # in the target org, or if the target already has a repo with the
702
+ # same (post-rename) name.
703
+ moved_from_org: uuid.UUID | None = None
704
+ if req.target_org_id is not None and req.target_org_id != org_id:
705
+ # Verify target org membership (admin+).
706
+ await _require_membership(
707
+ req.target_org_id, auth, session, min_role="admin",
708
+ )
709
+ # Check name collision in the target org under the new name.
710
+ target_name = repo.name # already reflects any rename above
711
+ existing = await session.execute(
712
+ select(Repository).where(
713
+ Repository.org_id == req.target_org_id,
714
+ Repository.name == target_name,
715
+ Repository.id != repo_id,
716
+ )
717
+ )
718
+ if existing.scalar_one_or_none():
719
+ raise HTTPException(
720
+ status_code=409,
721
+ detail=(
722
+ f"Target org already has a repository named "
723
+ f"{target_name!r}; rename before moving."
724
+ ),
725
+ )
726
+ moved_from_org = repo.org_id
727
+ changes["org_id"] = {
728
+ "from": str(moved_from_org),
729
+ "to": str(req.target_org_id),
730
+ }
731
+ repo.org_id = req.target_org_id
732
+
733
+ if changes:
734
+ from attocode_intel.audit import log_event
735
+
736
+ await log_event(
737
+ session,
738
+ org_id,
739
+ "repo.updated",
740
+ repo_id=repo_id,
741
+ user_id=auth.user_id,
742
+ detail={"changes": changes},
743
+ )
744
+ if moved_from_org is not None:
745
+ # Emit a distinct ``repo.moved`` event in the TARGET org so
746
+ # target-org admins see the move in their audit feed.
747
+ await log_event(
748
+ session,
749
+ req.target_org_id, # type: ignore[arg-type]
750
+ "repo.moved",
751
+ repo_id=repo_id,
752
+ user_id=auth.user_id,
753
+ detail={
754
+ "from_org_id": str(moved_from_org),
755
+ "to_org_id": str(req.target_org_id),
756
+ "name": repo.name,
757
+ },
758
+ )
759
+ await session.commit()
760
+ await session.refresh(repo)
761
+
762
+ return RepoResponse(
763
+ id=str(repo.id),
764
+ name=repo.name,
765
+ clone_url=repo.clone_url,
766
+ local_path=repo.local_path,
767
+ default_branch=repo.default_branch,
768
+ language=repo.language,
769
+ index_status=repo.index_status,
770
+ last_indexed_at=repo.last_indexed_at.isoformat() if repo.last_indexed_at else None,
771
+ created_at=repo.created_at.isoformat(),
772
+ )
773
+
774
+
775
+ @router.post("/{org_id}/repos/{repo_id}/reindex")
776
+ async def reindex_repo(
777
+ org_id: uuid.UUID,
778
+ repo_id: uuid.UUID,
779
+ auth: AuthContext = Depends(resolve_auth),
780
+ session: AsyncSession = Depends(get_db_session),
781
+ ) -> dict:
782
+ """Trigger a reindex of the repository."""
783
+ await _require_membership(org_id, auth, session)
784
+
785
+ result = await session.execute(
786
+ select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
787
+ )
788
+ repo = result.scalar_one_or_none()
789
+ if repo is None:
790
+ raise HTTPException(status_code=404, detail="Repository not found")
791
+
792
+ try:
793
+ from arq import create_pool
794
+
795
+ from attocode_intel.workers.settings import get_redis_settings
796
+
797
+ pool = await create_pool(get_redis_settings())
798
+ await pool.enqueue_job("index_repository", str(repo_id))
799
+ await pool.aclose()
800
+ except Exception:
801
+ logger.warning("Failed to enqueue reindex job for repo %s", repo_id, exc_info=True)
802
+ raise HTTPException(status_code=503, detail="Failed to enqueue reindex job")
803
+
804
+ repo.index_status = "indexing"
805
+ await session.commit()
806
+
807
+ return {"detail": "Reindex triggered", "repo_id": str(repo_id)}
808
+
809
+
810
+ # --- Repo Credential Management ---
811
+
812
+ _VALID_CRED_TYPES = {"pat", "deploy_token", "ssh_key"}
813
+
814
+
815
+ class SetCredentialRequest(BaseModel):
816
+ cred_type: str # pat|deploy_token|ssh_key
817
+ value: str
818
+
819
+
820
+ @router.post("/{org_id}/repos/{repo_id}/credentials")
821
+ async def set_repo_credential(
822
+ org_id: uuid.UUID,
823
+ repo_id: uuid.UUID,
824
+ body: SetCredentialRequest,
825
+ auth: AuthContext = Depends(resolve_auth),
826
+ session: AsyncSession = Depends(get_db_session),
827
+ ) -> dict:
828
+ """Set or replace credential for a private repository (admin+)."""
829
+ await _require_membership(org_id, auth, session, min_role="admin")
830
+
831
+ if body.cred_type not in _VALID_CRED_TYPES:
832
+ raise HTTPException(
833
+ status_code=422,
834
+ detail=f"Invalid cred_type. Must be one of: {', '.join(sorted(_VALID_CRED_TYPES))}",
835
+ )
836
+
837
+ result = await session.execute(
838
+ select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
839
+ )
840
+ repo = result.scalar_one_or_none()
841
+ if repo is None:
842
+ raise HTTPException(status_code=404, detail="Repository not found")
843
+
844
+ from attocode_intel.crypto import encrypt_credential
845
+ from attocode_intel.db.models import RepoCredential
846
+
847
+ # Remove any existing credential for this repo
848
+ existing = await session.execute(
849
+ select(RepoCredential).where(RepoCredential.repo_id == repo_id)
850
+ )
851
+ for old_cred in existing.scalars().all():
852
+ await session.delete(old_cred)
853
+
854
+ cred = RepoCredential(
855
+ repo_id=repo_id,
856
+ cred_type=body.cred_type,
857
+ encrypted_value=encrypt_credential(body.value),
858
+ )
859
+ session.add(cred)
860
+ await session.commit()
861
+
862
+ return {"detail": "Credential set", "cred_type": body.cred_type}
863
+
864
+
865
+ @router.get("/{org_id}/repos/{repo_id}/credentials")
866
+ async def get_repo_credential_status(
867
+ org_id: uuid.UUID,
868
+ repo_id: uuid.UUID,
869
+ auth: AuthContext = Depends(resolve_auth),
870
+ session: AsyncSession = Depends(get_db_session),
871
+ ) -> dict:
872
+ """Check whether a credential is configured for a repo (admin+). Never returns the value."""
873
+ await _require_membership(org_id, auth, session, min_role="admin")
874
+
875
+ result = await session.execute(
876
+ select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
877
+ )
878
+ if result.scalar_one_or_none() is None:
879
+ raise HTTPException(status_code=404, detail="Repository not found")
880
+
881
+ from attocode_intel.db.models import RepoCredential
882
+
883
+ result = await session.execute(
884
+ select(RepoCredential).where(RepoCredential.repo_id == repo_id).limit(1)
885
+ )
886
+ cred = result.scalar_one_or_none()
887
+ if cred is None:
888
+ return {"configured": False}
889
+
890
+ return {
891
+ "configured": True,
892
+ "cred_type": cred.cred_type,
893
+ "created_at": cred.created_at.isoformat(),
894
+ }
895
+
896
+
897
+ @router.delete("/{org_id}/repos/{repo_id}/credentials")
898
+ async def delete_repo_credential(
899
+ org_id: uuid.UUID,
900
+ repo_id: uuid.UUID,
901
+ auth: AuthContext = Depends(resolve_auth),
902
+ session: AsyncSession = Depends(get_db_session),
903
+ ) -> dict:
904
+ """Remove credential for a repository (admin+)."""
905
+ await _require_membership(org_id, auth, session, min_role="admin")
906
+
907
+ result = await session.execute(
908
+ select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
909
+ )
910
+ if result.scalar_one_or_none() is None:
911
+ raise HTTPException(status_code=404, detail="Repository not found")
912
+
913
+ from attocode_intel.db.models import RepoCredential
914
+
915
+ existing = await session.execute(
916
+ select(RepoCredential).where(RepoCredential.repo_id == repo_id)
917
+ )
918
+ deleted = 0
919
+ for cred in existing.scalars().all():
920
+ await session.delete(cred)
921
+ deleted += 1
922
+
923
+ await session.commit()
924
+ return {"detail": "Credential removed" if deleted else "No credential configured"}