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,614 @@
|
|
|
1
|
+
"""Repository snapshot endpoints — server-side point-in-time manifests.
|
|
2
|
+
|
|
3
|
+
Phase 3a ships CRUD + create over the ``repo_snapshots`` +
|
|
4
|
+
``repo_snapshot_components`` tables. A snapshot is a content-addressed
|
|
5
|
+
record of everything it takes to reproduce a repo's code-intel state
|
|
6
|
+
*right now*: the branch manifest, the symbol + dependency + embedding
|
|
7
|
+
coverage, and aggregate stats.
|
|
8
|
+
|
|
9
|
+
The manifest hash is the SHA-256 of a canonical JSON document shaped
|
|
10
|
+
like an OCI v1.1 image manifest — so Phase 3b's OCI adapter can push it
|
|
11
|
+
to a registry without changing the database schema.
|
|
12
|
+
|
|
13
|
+
Limitations of Phase 3a:
|
|
14
|
+
|
|
15
|
+
- The snapshot describes but does NOT materialize a downloadable tarball
|
|
16
|
+
of the underlying content/symbol/embedding blobs. That's Phase 3b.
|
|
17
|
+
- Restore is also Phase 3b. Right now this endpoint set gives you
|
|
18
|
+
create / list / get / delete — the "durable record of state" half.
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import uuid
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
|
|
30
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
31
|
+
from pydantic import BaseModel
|
|
32
|
+
from sqlalchemy import func, select
|
|
33
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
34
|
+
|
|
35
|
+
from attocode_intel.api.auth import resolve_auth
|
|
36
|
+
from attocode_intel.api.auth.context import AuthContext
|
|
37
|
+
from attocode_intel.api.deps import get_db_session
|
|
38
|
+
from attocode_intel.api.routes.orgs import _require_membership
|
|
39
|
+
from attocode_intel.api.utils import get_repo_by_org as _load_repo
|
|
40
|
+
from attocode_intel.db.models import (
|
|
41
|
+
Branch,
|
|
42
|
+
Dependency,
|
|
43
|
+
Embedding,
|
|
44
|
+
FileContent,
|
|
45
|
+
Repository,
|
|
46
|
+
RepoSnapshot,
|
|
47
|
+
RepoSnapshotComponent,
|
|
48
|
+
Symbol,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
router = APIRouter(prefix="/api/v1/orgs", tags=["snapshots"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Snapshot / component schema + media type strings match Phase 2a's
|
|
57
|
+
# ``snapshot_tools`` local format so a Phase 3b adapter can merge them.
|
|
58
|
+
SNAPSHOT_SCHEMA = "atto.snapshot.v1"
|
|
59
|
+
_MEDIA_CONTENT = "application/vnd.attocode.content-manifest.v1+json"
|
|
60
|
+
_MEDIA_SYMBOLS = "application/vnd.attocode.symbols-manifest.v1+json"
|
|
61
|
+
_MEDIA_DEPS = "application/vnd.attocode.deps-manifest.v1+json"
|
|
62
|
+
_MEDIA_EMBED = "application/vnd.attocode.embeddings-manifest.v1+json"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- Request / response models ---
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SnapshotCreateRequest(BaseModel):
|
|
69
|
+
name: str
|
|
70
|
+
description: str = ""
|
|
71
|
+
branch: str = "" # empty means "use repo default_branch"
|
|
72
|
+
commit_oid: str = "" # optional explicit commit pin
|
|
73
|
+
dry_run: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SnapshotComponentModel(BaseModel):
|
|
77
|
+
name: str
|
|
78
|
+
media_type: str
|
|
79
|
+
digest: str
|
|
80
|
+
size_bytes: int
|
|
81
|
+
extra: dict = {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SnapshotResponse(BaseModel):
|
|
85
|
+
id: str
|
|
86
|
+
repo_id: str
|
|
87
|
+
org_id: str
|
|
88
|
+
branch_id: str | None = None
|
|
89
|
+
name: str
|
|
90
|
+
description: str
|
|
91
|
+
manifest_hash: str
|
|
92
|
+
total_bytes: int
|
|
93
|
+
component_count: int
|
|
94
|
+
commit_oid: str | None = None
|
|
95
|
+
created_at: str
|
|
96
|
+
components: list[SnapshotComponentModel] = []
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SnapshotListResponse(BaseModel):
|
|
100
|
+
snapshots: list[SnapshotResponse]
|
|
101
|
+
total: int = 0
|
|
102
|
+
limit: int = 20
|
|
103
|
+
offset: int = 0
|
|
104
|
+
has_more: bool = False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SnapshotDryRunResponse(BaseModel):
|
|
108
|
+
repo_id: str
|
|
109
|
+
branch_id: str | None
|
|
110
|
+
manifest_hash: str
|
|
111
|
+
total_bytes: int
|
|
112
|
+
components: list[SnapshotComponentModel]
|
|
113
|
+
dry_run: bool = True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --- Helpers ---
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _canonical_json_bytes(obj: object) -> bytes:
|
|
120
|
+
return json.dumps(
|
|
121
|
+
obj,
|
|
122
|
+
sort_keys=True,
|
|
123
|
+
ensure_ascii=True,
|
|
124
|
+
separators=(",", ":"),
|
|
125
|
+
default=str,
|
|
126
|
+
).encode("ascii")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _sha256_of(data: bytes) -> str:
|
|
130
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _resolve_branch(
|
|
134
|
+
repo: Repository,
|
|
135
|
+
branch_name: str,
|
|
136
|
+
session: AsyncSession,
|
|
137
|
+
) -> Branch | None:
|
|
138
|
+
"""Return the Branch row matching ``branch_name``, or the repo default.
|
|
139
|
+
|
|
140
|
+
Returns None if the repo has no matching branch row — callers should
|
|
141
|
+
treat that as a snapshottable state with empty overlays.
|
|
142
|
+
"""
|
|
143
|
+
target = branch_name or repo.default_branch or "main"
|
|
144
|
+
result = await session.execute(
|
|
145
|
+
select(Branch).where(
|
|
146
|
+
Branch.repo_id == repo.id, Branch.name == target,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return result.scalar_one_or_none()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def _compute_components(
|
|
153
|
+
session: AsyncSession,
|
|
154
|
+
branch: Branch | None,
|
|
155
|
+
) -> tuple[list[SnapshotComponentModel], int]:
|
|
156
|
+
"""Compute the content-addressed component list for a snapshot.
|
|
157
|
+
|
|
158
|
+
Returns ``(components, total_bytes)``.
|
|
159
|
+
|
|
160
|
+
For each component, digest is the SHA-256 of a canonical JSON
|
|
161
|
+
document describing the component contents at this moment. The
|
|
162
|
+
component bodies themselves are NOT embedded in the response — they
|
|
163
|
+
live in the existing storage tables and can be reassembled from the
|
|
164
|
+
digests + the content_store / symbol / dependency / embedding tables
|
|
165
|
+
on read.
|
|
166
|
+
"""
|
|
167
|
+
components: list[SnapshotComponentModel] = []
|
|
168
|
+
total_bytes = 0
|
|
169
|
+
|
|
170
|
+
# --- Content component ---
|
|
171
|
+
#
|
|
172
|
+
# Every path in the branch overlay manifest mapped to its content
|
|
173
|
+
# SHA. This is the smallest possible representation of "what files
|
|
174
|
+
# are in the snapshot and what's in each one". The digest changes
|
|
175
|
+
# if any path moves, is added, or any content SHA changes.
|
|
176
|
+
content_shas: list[str] = []
|
|
177
|
+
if branch is not None:
|
|
178
|
+
from attocode_intel.storage.branch_overlay import BranchOverlay
|
|
179
|
+
overlay = BranchOverlay(session)
|
|
180
|
+
manifest = await overlay.resolve_manifest(branch.id)
|
|
181
|
+
content_shas = sorted(set(manifest.values()))
|
|
182
|
+
# Sum sizes from file_contents — lets the caller plan disk usage.
|
|
183
|
+
if content_shas:
|
|
184
|
+
result = await session.execute(
|
|
185
|
+
select(func.coalesce(func.sum(FileContent.size_bytes), 0))
|
|
186
|
+
.where(FileContent.sha256.in_(content_shas))
|
|
187
|
+
)
|
|
188
|
+
content_bytes = int(result.scalar_one() or 0)
|
|
189
|
+
total_bytes += content_bytes
|
|
190
|
+
content_body = {
|
|
191
|
+
"manifest": manifest,
|
|
192
|
+
"content_shas": content_shas,
|
|
193
|
+
"total_bytes": content_bytes,
|
|
194
|
+
}
|
|
195
|
+
else:
|
|
196
|
+
content_bytes = 0
|
|
197
|
+
content_body = {"manifest": {}, "content_shas": [], "total_bytes": 0}
|
|
198
|
+
components.append(SnapshotComponentModel(
|
|
199
|
+
name="content",
|
|
200
|
+
media_type=_MEDIA_CONTENT,
|
|
201
|
+
digest=_sha256_of(_canonical_json_bytes(content_body)),
|
|
202
|
+
size_bytes=content_bytes,
|
|
203
|
+
extra={"file_count": len(manifest), "sha_count": len(content_shas)},
|
|
204
|
+
))
|
|
205
|
+
else:
|
|
206
|
+
components.append(SnapshotComponentModel(
|
|
207
|
+
name="content",
|
|
208
|
+
media_type=_MEDIA_CONTENT,
|
|
209
|
+
digest=_sha256_of(b"{}"),
|
|
210
|
+
size_bytes=0,
|
|
211
|
+
extra={"file_count": 0, "sha_count": 0},
|
|
212
|
+
))
|
|
213
|
+
|
|
214
|
+
# --- Symbols component ---
|
|
215
|
+
#
|
|
216
|
+
# Convention: size_bytes is ALWAYS actual bytes. Row counts and
|
|
217
|
+
# other cardinalities go into ``extra``. Symbols / deps / embeddings
|
|
218
|
+
# components report ``size_bytes=0`` because there is no meaningful
|
|
219
|
+
# on-disk byte count for them yet (the OCI adapter will backfill
|
|
220
|
+
# these with real artifact sizes once it ships).
|
|
221
|
+
if content_shas:
|
|
222
|
+
sym_result = await session.execute(
|
|
223
|
+
select(Symbol.content_sha, func.count()).where(
|
|
224
|
+
Symbol.content_sha.in_(content_shas),
|
|
225
|
+
).group_by(Symbol.content_sha)
|
|
226
|
+
)
|
|
227
|
+
symbol_counts = sorted(
|
|
228
|
+
(row[0], int(row[1])) for row in sym_result
|
|
229
|
+
)
|
|
230
|
+
symbol_total = sum(c for _, c in symbol_counts)
|
|
231
|
+
else:
|
|
232
|
+
symbol_counts = []
|
|
233
|
+
symbol_total = 0
|
|
234
|
+
components.append(SnapshotComponentModel(
|
|
235
|
+
name="symbols",
|
|
236
|
+
media_type=_MEDIA_SYMBOLS,
|
|
237
|
+
digest=_sha256_of(_canonical_json_bytes({"counts": symbol_counts})),
|
|
238
|
+
size_bytes=0,
|
|
239
|
+
extra={"row_count": symbol_total},
|
|
240
|
+
))
|
|
241
|
+
|
|
242
|
+
# --- Dependencies component ---
|
|
243
|
+
if content_shas:
|
|
244
|
+
dep_result = await session.execute(
|
|
245
|
+
select(
|
|
246
|
+
Dependency.source_sha,
|
|
247
|
+
Dependency.target_sha,
|
|
248
|
+
Dependency.dep_type,
|
|
249
|
+
).where(
|
|
250
|
+
Dependency.source_sha.in_(content_shas),
|
|
251
|
+
).order_by(
|
|
252
|
+
Dependency.source_sha, Dependency.target_sha, Dependency.dep_type,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
dep_rows = [(r[0], r[1], r[2]) for r in dep_result]
|
|
256
|
+
else:
|
|
257
|
+
dep_rows = []
|
|
258
|
+
components.append(SnapshotComponentModel(
|
|
259
|
+
name="dependencies",
|
|
260
|
+
media_type=_MEDIA_DEPS,
|
|
261
|
+
digest=_sha256_of(_canonical_json_bytes({"edges": dep_rows})),
|
|
262
|
+
size_bytes=0,
|
|
263
|
+
extra={"edge_count": len(dep_rows)},
|
|
264
|
+
))
|
|
265
|
+
|
|
266
|
+
# --- Embeddings components ---
|
|
267
|
+
#
|
|
268
|
+
# Component name includes ``model_version`` so two versions of the
|
|
269
|
+
# same model (during a rotation) live under distinct names instead
|
|
270
|
+
# of colliding on ``embeddings.{model}``. Sort deterministically by
|
|
271
|
+
# (model, version) so DB iteration order can't leak into the
|
|
272
|
+
# manifest hash.
|
|
273
|
+
if content_shas:
|
|
274
|
+
emb_result = await session.execute(
|
|
275
|
+
select(
|
|
276
|
+
Embedding.embedding_model,
|
|
277
|
+
Embedding.embedding_model_version,
|
|
278
|
+
func.count(),
|
|
279
|
+
).where(
|
|
280
|
+
Embedding.content_sha.in_(content_shas),
|
|
281
|
+
).group_by(
|
|
282
|
+
Embedding.embedding_model, Embedding.embedding_model_version,
|
|
283
|
+
).order_by(
|
|
284
|
+
Embedding.embedding_model, Embedding.embedding_model_version,
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
for model, version, count in emb_result:
|
|
288
|
+
version_tag = version or "unversioned"
|
|
289
|
+
components.append(SnapshotComponentModel(
|
|
290
|
+
name=f"embeddings.{model}:{version_tag}",
|
|
291
|
+
media_type=_MEDIA_EMBED,
|
|
292
|
+
digest=_sha256_of(_canonical_json_bytes({
|
|
293
|
+
"model": model,
|
|
294
|
+
"version": version,
|
|
295
|
+
"chunk_count": int(count),
|
|
296
|
+
})),
|
|
297
|
+
size_bytes=0,
|
|
298
|
+
extra={
|
|
299
|
+
"model": model,
|
|
300
|
+
"model_version": version or "",
|
|
301
|
+
"chunk_count": int(count),
|
|
302
|
+
},
|
|
303
|
+
))
|
|
304
|
+
|
|
305
|
+
return components, total_bytes
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _compute_manifest_hash(
|
|
309
|
+
*,
|
|
310
|
+
repo_id: uuid.UUID,
|
|
311
|
+
branch_id: uuid.UUID | None,
|
|
312
|
+
commit_oid: str,
|
|
313
|
+
components: list[SnapshotComponentModel],
|
|
314
|
+
) -> str:
|
|
315
|
+
"""Top-level manifest hash.
|
|
316
|
+
|
|
317
|
+
Deliberately excludes transient metadata (name, description,
|
|
318
|
+
created_at, created_by) so two snapshots of identical state under
|
|
319
|
+
different names produce the same hash — required for OCI dedup.
|
|
320
|
+
|
|
321
|
+
Sorts by ``(name, digest)`` so identical-name components can't
|
|
322
|
+
reorder based on DB iteration. ``(name, digest)`` is stable even
|
|
323
|
+
when two components share the same logical name — distinct digests
|
|
324
|
+
still produce a deterministic ordering.
|
|
325
|
+
"""
|
|
326
|
+
body = {
|
|
327
|
+
"schema": SNAPSHOT_SCHEMA,
|
|
328
|
+
"repo_id": str(repo_id),
|
|
329
|
+
"branch_id": str(branch_id) if branch_id else None,
|
|
330
|
+
"commit_oid": commit_oid or "",
|
|
331
|
+
"components": [
|
|
332
|
+
{
|
|
333
|
+
"name": c.name,
|
|
334
|
+
"media_type": c.media_type,
|
|
335
|
+
"digest": c.digest,
|
|
336
|
+
"size_bytes": c.size_bytes,
|
|
337
|
+
}
|
|
338
|
+
for c in sorted(components, key=lambda x: (x.name, x.digest))
|
|
339
|
+
],
|
|
340
|
+
}
|
|
341
|
+
return _sha256_of(_canonical_json_bytes(body))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _iso(dt: datetime | None) -> str | None:
|
|
345
|
+
return dt.isoformat() if dt else None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _to_response(
|
|
349
|
+
snapshot: RepoSnapshot,
|
|
350
|
+
components: list[RepoSnapshotComponent],
|
|
351
|
+
) -> SnapshotResponse:
|
|
352
|
+
return SnapshotResponse(
|
|
353
|
+
id=str(snapshot.id),
|
|
354
|
+
repo_id=str(snapshot.repo_id),
|
|
355
|
+
org_id=str(snapshot.org_id),
|
|
356
|
+
branch_id=str(snapshot.branch_id) if snapshot.branch_id else None,
|
|
357
|
+
name=snapshot.name,
|
|
358
|
+
description=snapshot.description,
|
|
359
|
+
manifest_hash=snapshot.manifest_hash,
|
|
360
|
+
total_bytes=snapshot.total_bytes,
|
|
361
|
+
component_count=snapshot.component_count,
|
|
362
|
+
commit_oid=snapshot.commit_oid,
|
|
363
|
+
created_at=snapshot.created_at.isoformat(),
|
|
364
|
+
components=[
|
|
365
|
+
SnapshotComponentModel(
|
|
366
|
+
name=c.name,
|
|
367
|
+
media_type=c.media_type,
|
|
368
|
+
digest=c.digest,
|
|
369
|
+
size_bytes=c.size_bytes,
|
|
370
|
+
extra=dict(c.extra or {}),
|
|
371
|
+
)
|
|
372
|
+
for c in components
|
|
373
|
+
],
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# --- Endpoints ---
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@router.post(
|
|
381
|
+
"/{org_id}/repos/{repo_id}/snapshots",
|
|
382
|
+
response_model=SnapshotResponse | SnapshotDryRunResponse,
|
|
383
|
+
)
|
|
384
|
+
async def create_snapshot(
|
|
385
|
+
org_id: uuid.UUID,
|
|
386
|
+
repo_id: uuid.UUID,
|
|
387
|
+
req: SnapshotCreateRequest,
|
|
388
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
389
|
+
session: AsyncSession = Depends(get_db_session),
|
|
390
|
+
) -> SnapshotResponse | SnapshotDryRunResponse:
|
|
391
|
+
"""Create a content-addressed snapshot of the repo's current state.
|
|
392
|
+
|
|
393
|
+
``dry_run=true`` returns the computed manifest hash + component list
|
|
394
|
+
without inserting anything — useful for diffing against an existing
|
|
395
|
+
snapshot before committing.
|
|
396
|
+
"""
|
|
397
|
+
await _require_membership(org_id, auth, session)
|
|
398
|
+
repo = await _load_repo(org_id, repo_id, session)
|
|
399
|
+
|
|
400
|
+
if not req.name:
|
|
401
|
+
raise HTTPException(status_code=422, detail="name is required")
|
|
402
|
+
|
|
403
|
+
# Resolve branch (may be None if the repo hasn't been indexed yet).
|
|
404
|
+
branch = await _resolve_branch(repo, req.branch, session)
|
|
405
|
+
components, total_bytes = await _compute_components(session, branch)
|
|
406
|
+
manifest_hash = _compute_manifest_hash(
|
|
407
|
+
repo_id=repo_id,
|
|
408
|
+
branch_id=branch.id if branch else None,
|
|
409
|
+
commit_oid=req.commit_oid,
|
|
410
|
+
components=components,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
if req.dry_run:
|
|
414
|
+
return SnapshotDryRunResponse(
|
|
415
|
+
repo_id=str(repo_id),
|
|
416
|
+
branch_id=str(branch.id) if branch else None,
|
|
417
|
+
manifest_hash=manifest_hash,
|
|
418
|
+
total_bytes=total_bytes,
|
|
419
|
+
components=components,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Uniqueness: (repo_id, name). IntegrityError from the DB unique
|
|
423
|
+
# constraint would raise 500 — check explicitly for a nice 409.
|
|
424
|
+
existing = await session.execute(
|
|
425
|
+
select(RepoSnapshot).where(
|
|
426
|
+
RepoSnapshot.repo_id == repo_id, RepoSnapshot.name == req.name,
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
if existing.scalar_one_or_none():
|
|
430
|
+
raise HTTPException(
|
|
431
|
+
status_code=409,
|
|
432
|
+
detail=f"Snapshot named {req.name!r} already exists for this repo",
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
snapshot = RepoSnapshot(
|
|
436
|
+
org_id=org_id,
|
|
437
|
+
repo_id=repo_id,
|
|
438
|
+
branch_id=branch.id if branch else None,
|
|
439
|
+
name=req.name,
|
|
440
|
+
description=req.description,
|
|
441
|
+
manifest_hash=manifest_hash,
|
|
442
|
+
total_bytes=total_bytes,
|
|
443
|
+
component_count=len(components),
|
|
444
|
+
commit_oid=req.commit_oid or None,
|
|
445
|
+
created_by_user_id=auth.user_id,
|
|
446
|
+
extra={},
|
|
447
|
+
)
|
|
448
|
+
session.add(snapshot)
|
|
449
|
+
await session.flush()
|
|
450
|
+
|
|
451
|
+
component_rows: list[RepoSnapshotComponent] = []
|
|
452
|
+
for c in components:
|
|
453
|
+
row = RepoSnapshotComponent(
|
|
454
|
+
snapshot_id=snapshot.id,
|
|
455
|
+
name=c.name,
|
|
456
|
+
media_type=c.media_type,
|
|
457
|
+
digest=c.digest,
|
|
458
|
+
size_bytes=c.size_bytes,
|
|
459
|
+
extra=dict(c.extra or {}),
|
|
460
|
+
)
|
|
461
|
+
session.add(row)
|
|
462
|
+
component_rows.append(row)
|
|
463
|
+
|
|
464
|
+
from attocode_intel.audit import log_event
|
|
465
|
+
|
|
466
|
+
await log_event(
|
|
467
|
+
session,
|
|
468
|
+
org_id,
|
|
469
|
+
"snapshot.created",
|
|
470
|
+
repo_id=repo_id,
|
|
471
|
+
user_id=auth.user_id,
|
|
472
|
+
detail={
|
|
473
|
+
"snapshot_id": str(snapshot.id),
|
|
474
|
+
"name": snapshot.name,
|
|
475
|
+
"manifest_hash": snapshot.manifest_hash,
|
|
476
|
+
"component_count": snapshot.component_count,
|
|
477
|
+
"total_bytes": snapshot.total_bytes,
|
|
478
|
+
},
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
await session.commit()
|
|
482
|
+
await session.refresh(snapshot)
|
|
483
|
+
|
|
484
|
+
return _to_response(snapshot, component_rows)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@router.get(
|
|
488
|
+
"/{org_id}/repos/{repo_id}/snapshots",
|
|
489
|
+
response_model=SnapshotListResponse,
|
|
490
|
+
)
|
|
491
|
+
async def list_snapshots(
|
|
492
|
+
org_id: uuid.UUID,
|
|
493
|
+
repo_id: uuid.UUID,
|
|
494
|
+
limit: int = Query(20, ge=1, le=100),
|
|
495
|
+
offset: int = Query(0, ge=0),
|
|
496
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
497
|
+
session: AsyncSession = Depends(get_db_session),
|
|
498
|
+
) -> SnapshotListResponse:
|
|
499
|
+
"""List snapshots for a repository (most recent first)."""
|
|
500
|
+
await _require_membership(org_id, auth, session)
|
|
501
|
+
await _load_repo(org_id, repo_id, session)
|
|
502
|
+
|
|
503
|
+
count_result = await session.execute(
|
|
504
|
+
select(func.count()).select_from(
|
|
505
|
+
select(RepoSnapshot).where(RepoSnapshot.repo_id == repo_id).subquery()
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
total = int(count_result.scalar() or 0)
|
|
509
|
+
|
|
510
|
+
rows_result = await session.execute(
|
|
511
|
+
select(RepoSnapshot)
|
|
512
|
+
.where(RepoSnapshot.repo_id == repo_id)
|
|
513
|
+
.order_by(RepoSnapshot.created_at.desc())
|
|
514
|
+
.offset(offset).limit(limit)
|
|
515
|
+
)
|
|
516
|
+
snapshots: list[RepoSnapshot] = list(rows_result.scalars())
|
|
517
|
+
|
|
518
|
+
if not snapshots:
|
|
519
|
+
return SnapshotListResponse(
|
|
520
|
+
snapshots=[], total=total, limit=limit, offset=offset, has_more=False,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
# Batch-fetch components for every snapshot in this page.
|
|
524
|
+
snapshot_ids = [s.id for s in snapshots]
|
|
525
|
+
comp_result = await session.execute(
|
|
526
|
+
select(RepoSnapshotComponent).where(
|
|
527
|
+
RepoSnapshotComponent.snapshot_id.in_(snapshot_ids),
|
|
528
|
+
)
|
|
529
|
+
)
|
|
530
|
+
comps_by_snapshot: dict[uuid.UUID, list[RepoSnapshotComponent]] = {}
|
|
531
|
+
for c in comp_result.scalars():
|
|
532
|
+
comps_by_snapshot.setdefault(c.snapshot_id, []).append(c)
|
|
533
|
+
|
|
534
|
+
return SnapshotListResponse(
|
|
535
|
+
snapshots=[
|
|
536
|
+
_to_response(s, comps_by_snapshot.get(s.id, []))
|
|
537
|
+
for s in snapshots
|
|
538
|
+
],
|
|
539
|
+
total=total,
|
|
540
|
+
limit=limit,
|
|
541
|
+
offset=offset,
|
|
542
|
+
has_more=(offset + limit < total),
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@router.get(
|
|
547
|
+
"/{org_id}/repos/{repo_id}/snapshots/{snapshot_id}",
|
|
548
|
+
response_model=SnapshotResponse,
|
|
549
|
+
)
|
|
550
|
+
async def get_snapshot(
|
|
551
|
+
org_id: uuid.UUID,
|
|
552
|
+
repo_id: uuid.UUID,
|
|
553
|
+
snapshot_id: uuid.UUID,
|
|
554
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
555
|
+
session: AsyncSession = Depends(get_db_session),
|
|
556
|
+
) -> SnapshotResponse:
|
|
557
|
+
"""Fetch a single snapshot by id with its full component list."""
|
|
558
|
+
await _require_membership(org_id, auth, session)
|
|
559
|
+
await _load_repo(org_id, repo_id, session)
|
|
560
|
+
|
|
561
|
+
result = await session.execute(
|
|
562
|
+
select(RepoSnapshot).where(
|
|
563
|
+
RepoSnapshot.id == snapshot_id,
|
|
564
|
+
RepoSnapshot.repo_id == repo_id,
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
snapshot = result.scalar_one_or_none()
|
|
568
|
+
if snapshot is None:
|
|
569
|
+
raise HTTPException(status_code=404, detail="Snapshot not found")
|
|
570
|
+
|
|
571
|
+
comp_result = await session.execute(
|
|
572
|
+
select(RepoSnapshotComponent).where(
|
|
573
|
+
RepoSnapshotComponent.snapshot_id == snapshot.id,
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
return _to_response(snapshot, list(comp_result.scalars()))
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@router.delete("/{org_id}/repos/{repo_id}/snapshots/{snapshot_id}")
|
|
580
|
+
async def delete_snapshot(
|
|
581
|
+
org_id: uuid.UUID,
|
|
582
|
+
repo_id: uuid.UUID,
|
|
583
|
+
snapshot_id: uuid.UUID,
|
|
584
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
585
|
+
session: AsyncSession = Depends(get_db_session),
|
|
586
|
+
) -> dict:
|
|
587
|
+
"""Delete a snapshot. Requires admin+ role (destructive)."""
|
|
588
|
+
await _require_membership(org_id, auth, session, min_role="admin")
|
|
589
|
+
await _load_repo(org_id, repo_id, session)
|
|
590
|
+
|
|
591
|
+
result = await session.execute(
|
|
592
|
+
select(RepoSnapshot).where(
|
|
593
|
+
RepoSnapshot.id == snapshot_id,
|
|
594
|
+
RepoSnapshot.repo_id == repo_id,
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
snapshot = result.scalar_one_or_none()
|
|
598
|
+
if snapshot is None:
|
|
599
|
+
raise HTTPException(status_code=404, detail="Snapshot not found")
|
|
600
|
+
|
|
601
|
+
await session.delete(snapshot)
|
|
602
|
+
|
|
603
|
+
from attocode_intel.audit import log_event
|
|
604
|
+
|
|
605
|
+
await log_event(
|
|
606
|
+
session,
|
|
607
|
+
org_id,
|
|
608
|
+
"snapshot.deleted",
|
|
609
|
+
repo_id=repo_id,
|
|
610
|
+
user_id=auth.user_id,
|
|
611
|
+
detail={"snapshot_id": str(snapshot_id), "name": snapshot.name},
|
|
612
|
+
)
|
|
613
|
+
await session.commit()
|
|
614
|
+
return {"detail": "Snapshot deleted"}
|