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.
- attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
- attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
- attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
- attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
- attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
- attocode_intel/GUIDELINES.md +432 -0
- attocode_intel/__init__.py +52 -0
- attocode_intel/_internal/__init__.py +1 -0
- attocode_intel/_internal/errors/__init__.py +126 -0
- attocode_intel/_internal/integrations/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
- attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
- attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
- attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
- attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
- attocode_intel/_internal/integrations/context/cas.py +466 -0
- attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
- attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
- attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
- attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
- attocode_intel/_internal/integrations/context/cross_references.py +616 -0
- attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
- attocode_intel/_internal/integrations/context/embeddings.py +282 -0
- attocode_intel/_internal/integrations/context/frecency.py +427 -0
- attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
- attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
- attocode_intel/_internal/integrations/context/hydration.py +90 -0
- attocode_intel/_internal/integrations/context/index_repair.py +87 -0
- attocode_intel/_internal/integrations/context/index_store.py +586 -0
- attocode_intel/_internal/integrations/context/memory_store.py +602 -0
- attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
- attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
- attocode_intel/_internal/integrations/context/query_history.py +417 -0
- attocode_intel/_internal/integrations/context/reranker.py +351 -0
- attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
- attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
- attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
- attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
- attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
- attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
- attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
- attocode_intel/_internal/integrations/context/vector_store.py +795 -0
- attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
- attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
- attocode_intel/_internal/integrations/lsp/client.py +1140 -0
- attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
- attocode_intel/_internal/integrations/mcp/client.py +253 -0
- attocode_intel/_internal/integrations/security/__init__.py +1 -0
- attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
- attocode_intel/_internal/integrations/security/dataflow.py +522 -0
- attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
- attocode_intel/_internal/integrations/security/matcher.py +44 -0
- attocode_intel/_internal/integrations/security/patterns.py +724 -0
- attocode_intel/_internal/integrations/security/scanner.py +351 -0
- attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
- attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
- attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
- attocode_intel/_internal/tricks/__init__.py +1 -0
- attocode_intel/_internal/tricks/recursive_context.py +357 -0
- attocode_intel/_internal/types/__init__.py +1 -0
- attocode_intel/_internal/types/events.py +289 -0
- attocode_intel/_internal/types/severity.py +15 -0
- attocode_intel/_shared.py +341 -0
- attocode_intel/analysis_coverage.py +56 -0
- attocode_intel/api/__init__.py +1 -0
- attocode_intel/api/access.py +32 -0
- attocode_intel/api/app.py +247 -0
- attocode_intel/api/auth/__init__.py +128 -0
- attocode_intel/api/auth/api_keys.py +61 -0
- attocode_intel/api/auth/context.py +34 -0
- attocode_intel/api/auth/jwt.py +66 -0
- attocode_intel/api/auth/oauth.py +146 -0
- attocode_intel/api/auth/passwords.py +15 -0
- attocode_intel/api/deps.py +351 -0
- attocode_intel/api/knowledge_bridge.py +13 -0
- attocode_intel/api/mcp_transport.py +51 -0
- attocode_intel/api/middleware.py +411 -0
- attocode_intel/api/models.py +698 -0
- attocode_intel/api/providers/__init__.py +1 -0
- attocode_intel/api/providers/db_provider.py +1106 -0
- attocode_intel/api/providers/local_provider.py +476 -0
- attocode_intel/api/providers/remote_provider.py +740 -0
- attocode_intel/api/repository_source.py +21 -0
- attocode_intel/api/routes/__init__.py +1 -0
- attocode_intel/api/routes/_pin_helper.py +158 -0
- attocode_intel/api/routes/activity.py +148 -0
- attocode_intel/api/routes/adr.py +102 -0
- attocode_intel/api/routes/analysis.py +515 -0
- attocode_intel/api/routes/api_keys.py +170 -0
- attocode_intel/api/routes/auth.py +428 -0
- attocode_intel/api/routes/branches.py +262 -0
- attocode_intel/api/routes/cross_repo_search.py +198 -0
- attocode_intel/api/routes/embeddings.py +386 -0
- attocode_intel/api/routes/files.py +132 -0
- attocode_intel/api/routes/files_v2.py +388 -0
- attocode_intel/api/routes/gc.py +322 -0
- attocode_intel/api/routes/git_v2.py +433 -0
- attocode_intel/api/routes/graph.py +174 -0
- attocode_intel/api/routes/graph_viz.py +160 -0
- attocode_intel/api/routes/health.py +62 -0
- attocode_intel/api/routes/history.py +175 -0
- attocode_intel/api/routes/jobs.py +84 -0
- attocode_intel/api/routes/learning.py +205 -0
- attocode_intel/api/routes/lsp.py +272 -0
- attocode_intel/api/routes/notify.py +623 -0
- attocode_intel/api/routes/operations.py +27 -0
- attocode_intel/api/routes/orgs.py +924 -0
- attocode_intel/api/routes/preferences.py +100 -0
- attocode_intel/api/routes/presence.py +42 -0
- attocode_intel/api/routes/projects.py +116 -0
- attocode_intel/api/routes/repos.py +47 -0
- attocode_intel/api/routes/search.py +180 -0
- attocode_intel/api/routes/snapshots.py +614 -0
- attocode_intel/api/routes/webhooks.py +313 -0
- attocode_intel/api/routes/websocket.py +116 -0
- attocode_intel/api/static/graph.html +239 -0
- attocode_intel/api/utils.py +96 -0
- attocode_intel/artifacts/__init__.py +45 -0
- attocode_intel/artifacts/hashing.py +230 -0
- attocode_intel/artifacts/provenance.py +190 -0
- attocode_intel/artifacts/retrieval_pin.py +159 -0
- attocode_intel/audit.py +59 -0
- attocode_intel/bug_finder.py +206 -0
- attocode_intel/bundle.py +80 -0
- attocode_intel/catalog.py +242 -0
- attocode_intel/change_review.py +75 -0
- attocode_intel/cli.py +3062 -0
- attocode_intel/community.py +63 -0
- attocode_intel/confidence/__init__.py +144 -0
- attocode_intel/confidence/jev.py +234 -0
- attocode_intel/confidence/llm.py +330 -0
- attocode_intel/confidence/redact.py +44 -0
- attocode_intel/confidence/settings.py +74 -0
- attocode_intel/config.py +256 -0
- attocode_intel/crypto.py +31 -0
- attocode_intel/db/__init__.py +7 -0
- attocode_intel/db/base.py +33 -0
- attocode_intel/db/engine.py +61 -0
- attocode_intel/db/models.py +529 -0
- attocode_intel/entrypoint.py +185 -0
- attocode_intel/focused_evidence.py +130 -0
- attocode_intel/freshness.py +154 -0
- attocode_intel/gateway.py +734 -0
- attocode_intel/git/__init__.py +7 -0
- attocode_intel/git/credentials.py +75 -0
- attocode_intel/git/manager.py +493 -0
- attocode_intel/git/models.py +96 -0
- attocode_intel/git/storage.py +98 -0
- attocode_intel/graph_query_parser.py +761 -0
- attocode_intel/helpers.py +712 -0
- attocode_intel/indexing/__init__.py +29 -0
- attocode_intel/indexing/debouncer.py +136 -0
- attocode_intel/indexing/delta_indexer.py +151 -0
- attocode_intel/indexing/full_indexer.py +323 -0
- attocode_intel/indexing/incremental.py +240 -0
- attocode_intel/indexing/parser.py +403 -0
- attocode_intel/indexing/summarizer.py +264 -0
- attocode_intel/installer.py +1896 -0
- attocode_intel/knowledge.py +269 -0
- attocode_intel/local_knowledge.py +77 -0
- attocode_intel/migrations/alembic.ini +36 -0
- attocode_intel/migrations/env.py +71 -0
- attocode_intel/migrations/script.py.mako +27 -0
- attocode_intel/migrations/versions/001_initial_schema.py +219 -0
- attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
- attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
- attocode_intel/migrations/versions/004_audit_events.py +38 -0
- attocode_intel/migrations/versions/005_user_preferences.py +30 -0
- attocode_intel/migrations/versions/006_google_oauth.py +27 -0
- attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
- attocode_intel/migrations/versions/008_symbol_references.py +37 -0
- attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
- attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
- attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
- attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
- attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
- attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
- attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
- attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
- attocode_intel/migrations/versions/017_provenance_table.py +102 -0
- attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
- attocode_intel/migrations/versions/019_symref_caller.py +42 -0
- attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
- attocode_intel/onboarding.py +258 -0
- attocode_intel/output.py +178 -0
- attocode_intel/precision.py +133 -0
- attocode_intel/presence.py +160 -0
- attocode_intel/probe.py +110 -0
- attocode_intel/project_dir.py +97 -0
- attocode_intel/pubsub.py +113 -0
- attocode_intel/query_ranking.py +242 -0
- attocode_intel/readiness.py +972 -0
- attocode_intel/redis.py +51 -0
- attocode_intel/reference_pages.py +60 -0
- attocode_intel/remote.py +131 -0
- attocode_intel/repo_ranker.py +246 -0
- attocode_intel/request_context.py +82 -0
- attocode_intel/rules/__init__.py +28 -0
- attocode_intel/rules/ci.py +320 -0
- attocode_intel/rules/combinators.py +293 -0
- attocode_intel/rules/enricher.py +127 -0
- attocode_intel/rules/evolution.py +452 -0
- attocode_intel/rules/executor.py +531 -0
- attocode_intel/rules/filters/__init__.py +5 -0
- attocode_intel/rules/filters/pipeline.py +151 -0
- attocode_intel/rules/formatter.py +198 -0
- attocode_intel/rules/hygiene.py +242 -0
- attocode_intel/rules/importers/__init__.py +1 -0
- attocode_intel/rules/importers/semgrep.py +382 -0
- attocode_intel/rules/loader.py +292 -0
- attocode_intel/rules/marketplace.py +375 -0
- attocode_intel/rules/metavar.py +181 -0
- attocode_intel/rules/model.py +317 -0
- attocode_intel/rules/packs/__init__.py +1 -0
- attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
- attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
- attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
- attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
- attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
- attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
- attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
- attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
- attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
- attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
- attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
- attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
- attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
- attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
- attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
- attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
- attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
- attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
- attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
- attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
- attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
- attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
- attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
- attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
- attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
- attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
- attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
- attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
- attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
- attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
- attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
- attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
- attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/pack_loader.py +244 -0
- attocode_intel/rules/plugins/__init__.py +1 -0
- attocode_intel/rules/plugins/plugin_loader.py +132 -0
- attocode_intel/rules/profiling.py +332 -0
- attocode_intel/rules/registry.py +213 -0
- attocode_intel/rules/sarif.py +187 -0
- attocode_intel/rules/synthesis.py +420 -0
- attocode_intel/rules/testing.py +326 -0
- attocode_intel/server.py +734 -0
- attocode_intel/service.py +3416 -0
- attocode_intel/sharing.py +57 -0
- attocode_intel/storage/__init__.py +6 -0
- attocode_intel/storage/branch_overlay.py +352 -0
- attocode_intel/storage/content_store.py +220 -0
- attocode_intel/storage/dependency_store.py +143 -0
- attocode_intel/storage/diff_engine.py +187 -0
- attocode_intel/storage/embedding_store.py +594 -0
- attocode_intel/storage/provenance_store.py +137 -0
- attocode_intel/storage/security_scanner_db.py +164 -0
- attocode_intel/storage/symbol_store.py +160 -0
- attocode_intel/structural_evidence.py +144 -0
- attocode_intel/symbol_inspection.py +141 -0
- attocode_intel/symbol_links.py +167 -0
- attocode_intel/telemetry.py +42 -0
- attocode_intel/test_ranking.py +73 -0
- attocode_intel/testing/__init__.py +43 -0
- attocode_intel/testing/fixtures.py +122 -0
- attocode_intel/testing/helpers.py +75 -0
- attocode_intel/testing/mocks.py +99 -0
- attocode_intel/tools/__init__.py +2 -0
- attocode_intel/tools/adr_tools.py +628 -0
- attocode_intel/tools/analysis_tools.py +382 -0
- attocode_intel/tools/composite_tools.py +695 -0
- attocode_intel/tools/cross_mode_tools.py +173 -0
- attocode_intel/tools/dead_code_tools.py +582 -0
- attocode_intel/tools/distill_tools.py +353 -0
- attocode_intel/tools/frecency_tools.py +220 -0
- attocode_intel/tools/fuzzy_tools.py +255 -0
- attocode_intel/tools/history_tools.py +486 -0
- attocode_intel/tools/learning_tools.py +222 -0
- attocode_intel/tools/lsp_tools.py +218 -0
- attocode_intel/tools/maintenance_tools.py +1662 -0
- attocode_intel/tools/navigation_tools.py +220 -0
- attocode_intel/tools/overlay_tools.py +540 -0
- attocode_intel/tools/pin_store.py +432 -0
- attocode_intel/tools/pin_tools.py +266 -0
- attocode_intel/tools/query_constraints_tools.py +262 -0
- attocode_intel/tools/query_history_tools.py +158 -0
- attocode_intel/tools/readiness_tools.py +44 -0
- attocode_intel/tools/rule_tools.py +992 -0
- attocode_intel/tools/search_tools.py +770 -0
- attocode_intel/tools/snapshot_tools.py +624 -0
- attocode_intel/workers/__init__.py +1 -0
- attocode_intel/workers/job_utils.py +111 -0
- attocode_intel/workers/jobs.py +573 -0
- attocode_intel/workers/run.py +30 -0
- attocode_intel/workers/settings.py +61 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"""File change notification endpoint — hook integration for incremental indexing.
|
|
2
|
+
|
|
3
|
+
This endpoint receives file change notifications from Claude Code post-tooluse
|
|
4
|
+
hooks (or any other client) and feeds them into the debounced incremental
|
|
5
|
+
indexing pipeline.
|
|
6
|
+
|
|
7
|
+
Protocol:
|
|
8
|
+
POST /api/v1/notify/file-changed
|
|
9
|
+
Body: {"paths": ["src/a.py"], "project": "...", "branch": "feat/x"}
|
|
10
|
+
Response: 202 Accepted (processing is async)
|
|
11
|
+
|
|
12
|
+
Hook integration example (.claude/hooks.toml):
|
|
13
|
+
[hooks.post_tool_use]
|
|
14
|
+
command = "curl -s -X POST http://localhost:8080/api/v1/notify/file-changed ..."
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import uuid
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
25
|
+
from pydantic import BaseModel, Field
|
|
26
|
+
|
|
27
|
+
from attocode_intel.api.auth import resolve_auth
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from attocode_intel.indexing.debouncer import FileChangeDebouncer
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
router = APIRouter(tags=["notify"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- Request/Response models ---
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FileChangedRequest(BaseModel):
|
|
40
|
+
paths: list[str] = Field(..., description="List of changed file paths (relative to project root)")
|
|
41
|
+
files: dict[str, str] = Field(default_factory=dict, description="Optional: path → base64-encoded file content (for remote servers that can't read client filesystem)")
|
|
42
|
+
project: str = Field("", description="Project ID or path (local mode: ignored, uses default)")
|
|
43
|
+
branch: str = Field("", description="Branch name (optional, auto-detected if empty)")
|
|
44
|
+
idempotency_key: str = Field("", description="Optional idempotency key to prevent duplicate processing")
|
|
45
|
+
if_match: int | None = Field(None, description="Expected branch version (optimistic concurrency)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FileChangedResponse(BaseModel):
|
|
49
|
+
accepted: int = Field(..., description="Number of paths accepted for processing")
|
|
50
|
+
message: str = "Accepted"
|
|
51
|
+
version: int | None = Field(None, description="Current branch version after update")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FlushRequest(BaseModel):
|
|
55
|
+
project: str = Field("", description="Project ID to flush")
|
|
56
|
+
branch: str = Field("", description="Branch to flush")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- Module-level debouncer singleton ---
|
|
60
|
+
|
|
61
|
+
_debouncer: FileChangeDebouncer | None = None
|
|
62
|
+
_pipeline_initialized = False
|
|
63
|
+
|
|
64
|
+
# --- Idempotency cache ---
|
|
65
|
+
|
|
66
|
+
_idempotency_cache: dict[str, float] = {}
|
|
67
|
+
_IDEMPOTENCY_TTL = 300 # 5 minutes
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _get_debouncer() -> FileChangeDebouncer:
|
|
71
|
+
"""Get or create the module-level debouncer singleton."""
|
|
72
|
+
global _debouncer
|
|
73
|
+
if _debouncer is None:
|
|
74
|
+
from attocode_intel.indexing.debouncer import FileChangeDebouncer
|
|
75
|
+
|
|
76
|
+
_debouncer = FileChangeDebouncer(handler=_handle_debounced_batch)
|
|
77
|
+
return _debouncer
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _handle_debounced_batch(project_id: str, branch: str, paths: list[str], files: dict[str, str] | None = None, request_if_match: int | None = None) -> None:
|
|
81
|
+
"""Handle a debounced batch of file changes.
|
|
82
|
+
|
|
83
|
+
This runs after the debounce window closes. It processes files through
|
|
84
|
+
the content-hash-gated incremental pipeline.
|
|
85
|
+
"""
|
|
86
|
+
from attocode_intel.api import deps
|
|
87
|
+
|
|
88
|
+
config = deps.get_config()
|
|
89
|
+
|
|
90
|
+
if config.is_service_mode:
|
|
91
|
+
await _handle_service_mode(project_id, branch, paths, files=files or {}, request_if_match=request_if_match)
|
|
92
|
+
else:
|
|
93
|
+
await _handle_local_mode(project_id, paths)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def _handle_local_mode(project_id: str, paths: list[str]) -> None:
|
|
97
|
+
"""Process file changes in local mode using existing CodeIntelService."""
|
|
98
|
+
from attocode_intel.api import deps
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
svc = deps.get_service(project_id)
|
|
102
|
+
except ValueError:
|
|
103
|
+
logger.warning("Notify: project %s not found", project_id)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
# Use existing notify_file_changed for each path
|
|
107
|
+
for path in paths:
|
|
108
|
+
try:
|
|
109
|
+
await _call_sync(svc.notify_file_changed, path)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.warning("Error notifying file change %s: %s", path, e)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _handle_service_mode(project_id: str, branch: str, paths: list[str], *, files: dict[str, str] | None = None, request_if_match: int | None = None) -> None:
|
|
115
|
+
"""Process file changes in service mode using the incremental pipeline.
|
|
116
|
+
|
|
117
|
+
M2 note: In production, the caller (notify_file_changed route) already requires
|
|
118
|
+
auth via resolve_auth. The project_id should be validated against org scoping
|
|
119
|
+
in a future iteration when full multi-tenant isolation is implemented.
|
|
120
|
+
"""
|
|
121
|
+
import base64
|
|
122
|
+
|
|
123
|
+
from sqlalchemy import select
|
|
124
|
+
|
|
125
|
+
from attocode_intel.db.engine import get_session
|
|
126
|
+
from attocode_intel.db.models import Branch, Repository
|
|
127
|
+
from attocode_intel.indexing.incremental import IncrementalPipeline
|
|
128
|
+
from attocode_intel.pubsub import publish_event
|
|
129
|
+
|
|
130
|
+
# Decode base64 file contents into bytes
|
|
131
|
+
file_contents: dict[str, bytes] | None = None
|
|
132
|
+
if files:
|
|
133
|
+
file_contents = {}
|
|
134
|
+
for path, b64_data in files.items():
|
|
135
|
+
try:
|
|
136
|
+
file_contents[path] = base64.b64decode(b64_data)
|
|
137
|
+
except Exception:
|
|
138
|
+
logger.warning("Failed to decode base64 content for %s", path)
|
|
139
|
+
|
|
140
|
+
async for session in get_session():
|
|
141
|
+
try:
|
|
142
|
+
# Resolve repository — validate it exists
|
|
143
|
+
repo_result = await session.execute(
|
|
144
|
+
select(Repository).where(Repository.id == uuid.UUID(project_id))
|
|
145
|
+
)
|
|
146
|
+
repo = repo_result.scalar_one_or_none()
|
|
147
|
+
if repo is None:
|
|
148
|
+
logger.warning("Notify: repository %s not found", project_id)
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
branch_result = await session.execute(
|
|
152
|
+
select(Branch).where(
|
|
153
|
+
Branch.repo_id == repo.id,
|
|
154
|
+
Branch.name == branch,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
branch_obj = branch_result.scalar_one_or_none()
|
|
158
|
+
if branch_obj is None:
|
|
159
|
+
# Auto-create branch entry, parented to default
|
|
160
|
+
parent_result = await session.execute(
|
|
161
|
+
select(Branch).where(
|
|
162
|
+
Branch.repo_id == repo.id,
|
|
163
|
+
Branch.is_default == True, # noqa: E712
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
parent_branch = parent_result.scalar_one_or_none()
|
|
167
|
+
|
|
168
|
+
# If no default branch exists, this becomes the default
|
|
169
|
+
is_first_branch = parent_branch is None
|
|
170
|
+
|
|
171
|
+
branch_obj = Branch(
|
|
172
|
+
repo_id=repo.id,
|
|
173
|
+
name=branch,
|
|
174
|
+
parent_branch_id=parent_branch.id if parent_branch else None,
|
|
175
|
+
is_default=is_first_branch,
|
|
176
|
+
)
|
|
177
|
+
session.add(branch_obj)
|
|
178
|
+
|
|
179
|
+
# Update repo.default_branch to match the actual data branch
|
|
180
|
+
if is_first_branch:
|
|
181
|
+
repo.default_branch = branch
|
|
182
|
+
|
|
183
|
+
await session.flush()
|
|
184
|
+
|
|
185
|
+
# Advisory lock: prevent concurrent indexing of the same branch
|
|
186
|
+
await IncrementalPipeline.acquire_branch_lock(session, branch_obj.id)
|
|
187
|
+
|
|
188
|
+
# If-Match optimistic concurrency check
|
|
189
|
+
if request_if_match is not None:
|
|
190
|
+
from attocode_intel.storage.branch_overlay import BranchOverlay
|
|
191
|
+
|
|
192
|
+
overlay = BranchOverlay(session)
|
|
193
|
+
try:
|
|
194
|
+
await overlay.check_version(branch_obj.id, request_if_match)
|
|
195
|
+
except ValueError:
|
|
196
|
+
current_version = await overlay.get_version(branch_obj.id)
|
|
197
|
+
raise Exception(f"VERSION_MISMATCH:{current_version}")
|
|
198
|
+
|
|
199
|
+
# Run incremental pipeline
|
|
200
|
+
pipeline = IncrementalPipeline(session)
|
|
201
|
+
base_dir = repo.clone_path or repo.local_path
|
|
202
|
+
stats = await pipeline.process_file_changes(
|
|
203
|
+
branch_id=branch_obj.id,
|
|
204
|
+
paths=paths,
|
|
205
|
+
base_dir=base_dir,
|
|
206
|
+
file_contents=file_contents,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Transition repo to "indexed" on first successful incremental update
|
|
210
|
+
if stats["processed"] > 0:
|
|
211
|
+
repo.index_status = "indexed"
|
|
212
|
+
repo.last_indexed_at = datetime.now(timezone.utc)
|
|
213
|
+
|
|
214
|
+
await session.commit()
|
|
215
|
+
|
|
216
|
+
# Reliably trigger embedding generation after batch completes
|
|
217
|
+
if stats["processed"] > 0:
|
|
218
|
+
from attocode_intel.workers.job_utils import enqueue_embedding_job
|
|
219
|
+
await enqueue_embedding_job(project_id, branch)
|
|
220
|
+
|
|
221
|
+
# Publish event
|
|
222
|
+
await publish_event(
|
|
223
|
+
str(repo.id),
|
|
224
|
+
"index.updated",
|
|
225
|
+
{"paths": paths, "branch": branch, "stats": stats},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Log audit event
|
|
229
|
+
from attocode_intel.audit import log_event
|
|
230
|
+
|
|
231
|
+
await log_event(session, repo.org_id, "commit.notified", repo_id=repo.id, detail={"paths_count": len(paths), "branch": branch})
|
|
232
|
+
|
|
233
|
+
logger.info(
|
|
234
|
+
"Incremental update for %s/%s: %d processed, %d unchanged",
|
|
235
|
+
project_id, branch, stats["processed"], stats["skipped_unchanged"],
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
error_msg = str(exc)
|
|
240
|
+
if error_msg.startswith("VERSION_MISMATCH:"):
|
|
241
|
+
current_version = int(error_msg.split(":")[1])
|
|
242
|
+
logger.info("Version mismatch for %s/%s: current=%d", project_id, branch, current_version)
|
|
243
|
+
await session.rollback()
|
|
244
|
+
return # Caller should check version
|
|
245
|
+
logger.exception("Error in service mode notify handler")
|
|
246
|
+
await session.rollback()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def _call_sync(func, *args):
|
|
250
|
+
"""Call a sync function from async context."""
|
|
251
|
+
import asyncio
|
|
252
|
+
loop = asyncio.get_running_loop()
|
|
253
|
+
return await loop.run_in_executor(None, func, *args)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# --- Routes ---
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@router.post(
|
|
260
|
+
"/api/v1/notify/file-changed",
|
|
261
|
+
response_model=FileChangedResponse,
|
|
262
|
+
status_code=202,
|
|
263
|
+
)
|
|
264
|
+
async def notify_file_changed(
|
|
265
|
+
request: FileChangedRequest,
|
|
266
|
+
auth: dict = Depends(resolve_auth),
|
|
267
|
+
) -> FileChangedResponse:
|
|
268
|
+
"""Notify that files have changed. Returns 202 immediately.
|
|
269
|
+
|
|
270
|
+
The actual processing happens asynchronously after debouncing.
|
|
271
|
+
Multiple rapid notifications are batched into a single update.
|
|
272
|
+
"""
|
|
273
|
+
if not request.paths:
|
|
274
|
+
raise HTTPException(status_code=422, detail="No paths provided")
|
|
275
|
+
|
|
276
|
+
# Idempotency check
|
|
277
|
+
if request.idempotency_key:
|
|
278
|
+
import time
|
|
279
|
+
|
|
280
|
+
now = time.monotonic()
|
|
281
|
+
# Clean expired entries
|
|
282
|
+
expired = [k for k, t in _idempotency_cache.items() if now - t > _IDEMPOTENCY_TTL]
|
|
283
|
+
for k in expired:
|
|
284
|
+
_idempotency_cache.pop(k, None)
|
|
285
|
+
# Check for duplicate
|
|
286
|
+
if request.idempotency_key in _idempotency_cache:
|
|
287
|
+
return FileChangedResponse(accepted=0, message="Duplicate (idempotency key already processed)")
|
|
288
|
+
_idempotency_cache[request.idempotency_key] = now
|
|
289
|
+
|
|
290
|
+
from attocode_intel.api import deps
|
|
291
|
+
|
|
292
|
+
config = deps.get_config()
|
|
293
|
+
|
|
294
|
+
# Resolve project ID
|
|
295
|
+
project_id = request.project or deps.get_default_project_id()
|
|
296
|
+
|
|
297
|
+
# Resolve branch (auto-detect from git if not provided)
|
|
298
|
+
branch = request.branch
|
|
299
|
+
if not branch:
|
|
300
|
+
branch = await _detect_current_branch(config.project_dir) if config.project_dir else "main"
|
|
301
|
+
|
|
302
|
+
debouncer = _get_debouncer()
|
|
303
|
+
await debouncer.notify(project_id, branch, request.paths, files=request.files)
|
|
304
|
+
|
|
305
|
+
return FileChangedResponse(accepted=len(request.paths))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@router.post(
|
|
309
|
+
"/api/v1/notify/flush",
|
|
310
|
+
response_model=FileChangedResponse,
|
|
311
|
+
status_code=200,
|
|
312
|
+
)
|
|
313
|
+
async def flush_pending(
|
|
314
|
+
request: FlushRequest,
|
|
315
|
+
auth: dict = Depends(resolve_auth),
|
|
316
|
+
) -> FileChangedResponse:
|
|
317
|
+
"""Force-flush any pending debounced notifications.
|
|
318
|
+
|
|
319
|
+
Useful for testing or when you need immediate consistency.
|
|
320
|
+
"""
|
|
321
|
+
from attocode_intel.api import deps
|
|
322
|
+
|
|
323
|
+
project_id = request.project or deps.get_default_project_id()
|
|
324
|
+
branch = request.branch
|
|
325
|
+
|
|
326
|
+
debouncer = _get_debouncer()
|
|
327
|
+
await debouncer.flush(project_id, branch)
|
|
328
|
+
|
|
329
|
+
return FileChangedResponse(accepted=0, message="Flushed")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class CommitPushRequest(BaseModel):
|
|
333
|
+
commits: list[dict] = Field(..., description="List of commit dicts: {oid, message, author_name, author_email, timestamp, parent_oids}")
|
|
334
|
+
project: str = Field(..., description="Repository ID")
|
|
335
|
+
branch: str = Field("main", description="Branch name")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class CommitPushResponse(BaseModel):
|
|
339
|
+
stored: int = 0
|
|
340
|
+
skipped: int = 0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@router.post(
|
|
344
|
+
"/api/v1/notify/commits",
|
|
345
|
+
response_model=CommitPushResponse,
|
|
346
|
+
status_code=200,
|
|
347
|
+
)
|
|
348
|
+
async def push_commits(
|
|
349
|
+
request: CommitPushRequest,
|
|
350
|
+
auth: dict = Depends(resolve_auth),
|
|
351
|
+
) -> CommitPushResponse:
|
|
352
|
+
"""Push commit metadata for DB-backed commit history (remote repos)."""
|
|
353
|
+
from sqlalchemy import select
|
|
354
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
355
|
+
|
|
356
|
+
from attocode_intel.db.engine import get_session
|
|
357
|
+
from attocode_intel.db.models import Commit, Repository
|
|
358
|
+
|
|
359
|
+
if not request.commits:
|
|
360
|
+
return CommitPushResponse()
|
|
361
|
+
|
|
362
|
+
stored = 0
|
|
363
|
+
skipped = 0
|
|
364
|
+
|
|
365
|
+
async for session in get_session():
|
|
366
|
+
try:
|
|
367
|
+
repo_result = await session.execute(
|
|
368
|
+
select(Repository).where(Repository.id == uuid.UUID(request.project))
|
|
369
|
+
)
|
|
370
|
+
repo = repo_result.scalar_one_or_none()
|
|
371
|
+
if repo is None:
|
|
372
|
+
logger.warning("push_commits: repository %s not found", request.project)
|
|
373
|
+
return CommitPushResponse()
|
|
374
|
+
|
|
375
|
+
for c in request.commits:
|
|
376
|
+
try:
|
|
377
|
+
values = {
|
|
378
|
+
"repo_id": repo.id,
|
|
379
|
+
"oid": c["oid"],
|
|
380
|
+
"message": c.get("message", ""),
|
|
381
|
+
"author_name": c.get("author_name", ""),
|
|
382
|
+
"author_email": c.get("author_email", ""),
|
|
383
|
+
"timestamp": int(c.get("timestamp", 0)),
|
|
384
|
+
"parent_oids": c.get("parent_oids", []),
|
|
385
|
+
"branch_name": request.branch,
|
|
386
|
+
}
|
|
387
|
+
if c.get("changed_files"):
|
|
388
|
+
values["changed_files"] = c["changed_files"]
|
|
389
|
+
stmt = pg_insert(Commit.__table__).values(
|
|
390
|
+
**values,
|
|
391
|
+
).on_conflict_do_nothing(
|
|
392
|
+
constraint="uq_commits_repo_oid",
|
|
393
|
+
)
|
|
394
|
+
result = await session.execute(stmt)
|
|
395
|
+
if result.rowcount:
|
|
396
|
+
stored += 1
|
|
397
|
+
else:
|
|
398
|
+
skipped += 1
|
|
399
|
+
except Exception as e:
|
|
400
|
+
logger.warning("Error storing commit %s: %s", c.get("oid", "?"), e)
|
|
401
|
+
skipped += 1
|
|
402
|
+
|
|
403
|
+
await session.commit()
|
|
404
|
+
except Exception:
|
|
405
|
+
logger.exception("Error in push_commits handler")
|
|
406
|
+
await session.rollback()
|
|
407
|
+
|
|
408
|
+
return CommitPushResponse(stored=stored, skipped=skipped)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class BulkSyncRequest(BaseModel):
|
|
412
|
+
files: dict[str, str] = Field(..., description="Map of path → base64-encoded content (up to 500 files)")
|
|
413
|
+
project: str = Field(..., description="Repository ID")
|
|
414
|
+
branch: str = Field("main", description="Branch name")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class BulkSyncResponse(BaseModel):
|
|
418
|
+
processed: int = 0
|
|
419
|
+
skipped_unchanged: int = 0
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@router.post(
|
|
423
|
+
"/api/v1/notify/bulk-sync",
|
|
424
|
+
response_model=BulkSyncResponse,
|
|
425
|
+
status_code=200,
|
|
426
|
+
)
|
|
427
|
+
async def bulk_sync(
|
|
428
|
+
request: BulkSyncRequest,
|
|
429
|
+
auth: dict = Depends(resolve_auth),
|
|
430
|
+
) -> BulkSyncResponse:
|
|
431
|
+
"""Bulk sync files — bypasses debouncer for fast initial sync.
|
|
432
|
+
|
|
433
|
+
Accepts up to 500 files per request. Direct pipeline call in single transaction.
|
|
434
|
+
"""
|
|
435
|
+
import base64
|
|
436
|
+
|
|
437
|
+
from sqlalchemy import select
|
|
438
|
+
|
|
439
|
+
from attocode_intel.db.engine import get_session
|
|
440
|
+
from attocode_intel.db.models import Branch, Repository
|
|
441
|
+
from attocode_intel.indexing.incremental import IncrementalPipeline
|
|
442
|
+
|
|
443
|
+
if not request.files:
|
|
444
|
+
return BulkSyncResponse()
|
|
445
|
+
|
|
446
|
+
if len(request.files) > 500:
|
|
447
|
+
raise HTTPException(status_code=422, detail="Maximum 500 files per bulk-sync request")
|
|
448
|
+
|
|
449
|
+
async for session in get_session():
|
|
450
|
+
try:
|
|
451
|
+
repo_result = await session.execute(
|
|
452
|
+
select(Repository).where(Repository.id == uuid.UUID(request.project))
|
|
453
|
+
)
|
|
454
|
+
repo = repo_result.scalar_one_or_none()
|
|
455
|
+
if repo is None:
|
|
456
|
+
raise HTTPException(status_code=404, detail="Repository not found")
|
|
457
|
+
|
|
458
|
+
branch_result = await session.execute(
|
|
459
|
+
select(Branch).where(
|
|
460
|
+
Branch.repo_id == repo.id,
|
|
461
|
+
Branch.name == request.branch,
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
branch_obj = branch_result.scalar_one_or_none()
|
|
465
|
+
if branch_obj is None:
|
|
466
|
+
# Auto-create branch
|
|
467
|
+
parent_result = await session.execute(
|
|
468
|
+
select(Branch).where(
|
|
469
|
+
Branch.repo_id == repo.id,
|
|
470
|
+
Branch.is_default == True, # noqa: E712
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
parent_branch = parent_result.scalar_one_or_none()
|
|
474
|
+
is_first = parent_branch is None
|
|
475
|
+
branch_obj = Branch(
|
|
476
|
+
repo_id=repo.id,
|
|
477
|
+
name=request.branch,
|
|
478
|
+
parent_branch_id=parent_branch.id if parent_branch else None,
|
|
479
|
+
is_default=is_first,
|
|
480
|
+
)
|
|
481
|
+
session.add(branch_obj)
|
|
482
|
+
if is_first:
|
|
483
|
+
repo.default_branch = request.branch
|
|
484
|
+
await session.flush()
|
|
485
|
+
|
|
486
|
+
# Advisory lock
|
|
487
|
+
await IncrementalPipeline.acquire_branch_lock(session, branch_obj.id)
|
|
488
|
+
|
|
489
|
+
# Decode file contents
|
|
490
|
+
file_contents: dict[str, bytes] = {}
|
|
491
|
+
for path, b64_data in request.files.items():
|
|
492
|
+
try:
|
|
493
|
+
file_contents[path] = base64.b64decode(b64_data)
|
|
494
|
+
except Exception:
|
|
495
|
+
logger.warning("Failed to decode base64 content for %s", path)
|
|
496
|
+
|
|
497
|
+
# Run pipeline directly (no debounce)
|
|
498
|
+
pipeline = IncrementalPipeline(session)
|
|
499
|
+
base_dir = repo.clone_path or repo.local_path
|
|
500
|
+
stats = await pipeline.process_file_changes(
|
|
501
|
+
branch_id=branch_obj.id,
|
|
502
|
+
paths=list(file_contents.keys()),
|
|
503
|
+
base_dir=base_dir,
|
|
504
|
+
file_contents=file_contents,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
if stats["processed"] > 0:
|
|
508
|
+
repo.index_status = "indexed"
|
|
509
|
+
repo.last_indexed_at = datetime.now(timezone.utc)
|
|
510
|
+
|
|
511
|
+
await session.commit()
|
|
512
|
+
|
|
513
|
+
# Trigger embeddings
|
|
514
|
+
if stats["processed"] > 0:
|
|
515
|
+
from attocode_intel.workers.job_utils import enqueue_embedding_job
|
|
516
|
+
await enqueue_embedding_job(request.project, request.branch)
|
|
517
|
+
|
|
518
|
+
return BulkSyncResponse(
|
|
519
|
+
processed=stats["processed"],
|
|
520
|
+
skipped_unchanged=stats["skipped_unchanged"],
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
except HTTPException:
|
|
524
|
+
raise
|
|
525
|
+
except Exception:
|
|
526
|
+
logger.exception("Error in bulk sync handler")
|
|
527
|
+
await session.rollback()
|
|
528
|
+
raise HTTPException(status_code=500, detail="Internal error during bulk sync")
|
|
529
|
+
|
|
530
|
+
raise HTTPException(status_code=503, detail="No database session available")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
class BlamePushRequest(BaseModel):
|
|
534
|
+
hunks: list[dict] = Field(..., description="List of blame hunk dicts")
|
|
535
|
+
project: str = Field(..., description="Repository ID")
|
|
536
|
+
branch: str = Field("main", description="Branch name")
|
|
537
|
+
path: str = Field(..., description="File path for blame data")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class BlamePushResponse(BaseModel):
|
|
541
|
+
stored: int = 0
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@router.post(
|
|
545
|
+
"/api/v1/notify/blame",
|
|
546
|
+
response_model=BlamePushResponse,
|
|
547
|
+
status_code=200,
|
|
548
|
+
)
|
|
549
|
+
async def push_blame(
|
|
550
|
+
request: BlamePushRequest,
|
|
551
|
+
auth: dict = Depends(resolve_auth),
|
|
552
|
+
) -> BlamePushResponse:
|
|
553
|
+
"""Push blame data for DB-backed blame (remote repos)."""
|
|
554
|
+
from sqlalchemy import select
|
|
555
|
+
|
|
556
|
+
from attocode_intel.db.engine import get_session
|
|
557
|
+
from attocode_intel.db.models import BlameHunk, Repository
|
|
558
|
+
|
|
559
|
+
if not request.hunks:
|
|
560
|
+
return BlamePushResponse()
|
|
561
|
+
|
|
562
|
+
stored = 0
|
|
563
|
+
|
|
564
|
+
async for session in get_session():
|
|
565
|
+
try:
|
|
566
|
+
repo_result = await session.execute(
|
|
567
|
+
select(Repository).where(Repository.id == uuid.UUID(request.project))
|
|
568
|
+
)
|
|
569
|
+
repo = repo_result.scalar_one_or_none()
|
|
570
|
+
if repo is None:
|
|
571
|
+
return BlamePushResponse()
|
|
572
|
+
|
|
573
|
+
# Delete existing blame for this file/branch (replace)
|
|
574
|
+
from sqlalchemy import delete
|
|
575
|
+
|
|
576
|
+
await session.execute(
|
|
577
|
+
delete(BlameHunk).where(
|
|
578
|
+
BlameHunk.repo_id == repo.id,
|
|
579
|
+
BlameHunk.path == request.path,
|
|
580
|
+
BlameHunk.branch_name == request.branch,
|
|
581
|
+
)
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
for h in request.hunks:
|
|
585
|
+
hunk = BlameHunk(
|
|
586
|
+
repo_id=repo.id,
|
|
587
|
+
path=request.path,
|
|
588
|
+
branch_name=request.branch,
|
|
589
|
+
commit_oid=h.get("commit_oid", ""),
|
|
590
|
+
author_name=h.get("author_name", ""),
|
|
591
|
+
author_email=h.get("author_email", ""),
|
|
592
|
+
timestamp=int(h.get("timestamp", 0)),
|
|
593
|
+
start_line=int(h.get("start_line", 0)),
|
|
594
|
+
end_line=int(h.get("end_line", 0)),
|
|
595
|
+
)
|
|
596
|
+
session.add(hunk)
|
|
597
|
+
stored += 1
|
|
598
|
+
|
|
599
|
+
await session.commit()
|
|
600
|
+
except Exception:
|
|
601
|
+
logger.exception("Error in push_blame handler")
|
|
602
|
+
await session.rollback()
|
|
603
|
+
|
|
604
|
+
return BlamePushResponse(stored=stored)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _detect_current_branch(project_dir: str) -> str:
|
|
608
|
+
"""Detect the current git branch from the project directory."""
|
|
609
|
+
import subprocess
|
|
610
|
+
|
|
611
|
+
try:
|
|
612
|
+
result = subprocess.run(
|
|
613
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
614
|
+
cwd=project_dir,
|
|
615
|
+
capture_output=True,
|
|
616
|
+
text=True,
|
|
617
|
+
timeout=5,
|
|
618
|
+
)
|
|
619
|
+
if result.returncode == 0:
|
|
620
|
+
return result.stdout.strip()
|
|
621
|
+
except Exception:
|
|
622
|
+
pass
|
|
623
|
+
return "main"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Structured operations used by both the dashboard and MCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
8
|
+
from jsonschema import ValidationError
|
|
9
|
+
|
|
10
|
+
from attocode_intel.api.auth import resolve_auth
|
|
11
|
+
from attocode_intel.remote import remote_identity
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/api/v2/operations", tags=["intelligence"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.post("/{name}")
|
|
17
|
+
async def execute_operation(
|
|
18
|
+
name: str, arguments: dict, request: Request, auth: Annotated[object, Depends(resolve_auth)]
|
|
19
|
+
):
|
|
20
|
+
token = remote_identity.set(auth)
|
|
21
|
+
try:
|
|
22
|
+
result = await request.app.state.mcp_transport.gateway.execute(name, arguments)
|
|
23
|
+
return result.structuredContent
|
|
24
|
+
except (ValueError, ValidationError) as exc:
|
|
25
|
+
raise HTTPException(422, str(exc)) from exc
|
|
26
|
+
finally:
|
|
27
|
+
remote_identity.reset(token)
|