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,428 @@
|
|
|
1
|
+
"""Authentication endpoints (service mode only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from sqlalchemy import select
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
14
|
+
from starlette.responses import RedirectResponse
|
|
15
|
+
|
|
16
|
+
from attocode_intel.api.auth import resolve_auth
|
|
17
|
+
from attocode_intel.api.auth.context import AuthContext
|
|
18
|
+
from attocode_intel.api.auth.jwt import create_access_token, create_refresh_token, decode_token
|
|
19
|
+
from attocode_intel.api.auth.passwords import hash_password, verify_password
|
|
20
|
+
from attocode_intel.api.deps import get_config, get_db_session
|
|
21
|
+
from attocode_intel.db.models import OrgMembership, User
|
|
22
|
+
|
|
23
|
+
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --- OAuth state store (single-instance, 10 min TTL) ---
|
|
27
|
+
|
|
28
|
+
_oauth_states: dict[str, float] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _store_state(state: str) -> None:
|
|
32
|
+
"""Store an OAuth state token with the current timestamp."""
|
|
33
|
+
now = time.time()
|
|
34
|
+
expired = [k for k, v in _oauth_states.items() if now - v > 600]
|
|
35
|
+
for k in expired:
|
|
36
|
+
del _oauth_states[k]
|
|
37
|
+
_oauth_states[state] = now
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _validate_state(state: str) -> bool:
|
|
41
|
+
"""Validate and consume an OAuth state token (single-use)."""
|
|
42
|
+
ts = _oauth_states.pop(state, None)
|
|
43
|
+
if ts is None:
|
|
44
|
+
return False
|
|
45
|
+
return time.time() - ts < 600
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --- Request/Response models ---
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RegisterRequest(BaseModel):
|
|
52
|
+
email: str
|
|
53
|
+
password: str
|
|
54
|
+
name: str = ""
|
|
55
|
+
registration_key: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LoginRequest(BaseModel):
|
|
59
|
+
email: str
|
|
60
|
+
password: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TokenResponse(BaseModel):
|
|
64
|
+
access_token: str
|
|
65
|
+
refresh_token: str
|
|
66
|
+
token_type: str = "bearer"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RefreshRequest(BaseModel):
|
|
70
|
+
refresh_token: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class UserProfile(BaseModel):
|
|
74
|
+
id: str
|
|
75
|
+
email: str
|
|
76
|
+
name: str
|
|
77
|
+
avatar_url: str | None = None
|
|
78
|
+
auth_provider: str
|
|
79
|
+
orgs: list[dict]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- Endpoints ---
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.post("/register", response_model=TokenResponse)
|
|
86
|
+
async def register(
|
|
87
|
+
req: RegisterRequest,
|
|
88
|
+
session: AsyncSession = Depends(get_db_session),
|
|
89
|
+
) -> TokenResponse:
|
|
90
|
+
"""Register a new user with email + password."""
|
|
91
|
+
config = get_config()
|
|
92
|
+
if config.registration_key and req.registration_key != config.registration_key:
|
|
93
|
+
raise HTTPException(status_code=403, detail="Invalid registration key")
|
|
94
|
+
|
|
95
|
+
# Check if email already exists
|
|
96
|
+
existing = await session.execute(select(User).where(User.email == req.email))
|
|
97
|
+
if existing.scalar_one_or_none():
|
|
98
|
+
raise HTTPException(status_code=409, detail="Email already registered")
|
|
99
|
+
|
|
100
|
+
user = User(
|
|
101
|
+
email=req.email,
|
|
102
|
+
name=req.name or req.email.split("@")[0],
|
|
103
|
+
password_hash=hash_password(req.password),
|
|
104
|
+
auth_provider="email",
|
|
105
|
+
)
|
|
106
|
+
session.add(user)
|
|
107
|
+
await session.commit()
|
|
108
|
+
await session.refresh(user)
|
|
109
|
+
|
|
110
|
+
config = get_config()
|
|
111
|
+
return TokenResponse(
|
|
112
|
+
access_token=create_access_token(user.id, expires_minutes=config.jwt_expiry_minutes),
|
|
113
|
+
refresh_token=create_refresh_token(user.id, expires_days=config.refresh_expiry_days),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.post("/login", response_model=TokenResponse)
|
|
118
|
+
async def login(
|
|
119
|
+
req: LoginRequest,
|
|
120
|
+
session: AsyncSession = Depends(get_db_session),
|
|
121
|
+
) -> TokenResponse:
|
|
122
|
+
"""Login with email + password, returns JWT tokens."""
|
|
123
|
+
result = await session.execute(select(User).where(User.email == req.email))
|
|
124
|
+
user = result.scalar_one_or_none()
|
|
125
|
+
if user is None or not user.password_hash or not verify_password(req.password, user.password_hash):
|
|
126
|
+
raise HTTPException(status_code=401, detail="Invalid email or password")
|
|
127
|
+
|
|
128
|
+
config = get_config()
|
|
129
|
+
return TokenResponse(
|
|
130
|
+
access_token=create_access_token(user.id, expires_minutes=config.jwt_expiry_minutes),
|
|
131
|
+
refresh_token=create_refresh_token(user.id, expires_days=config.refresh_expiry_days),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@router.post("/refresh", response_model=TokenResponse)
|
|
136
|
+
async def refresh(
|
|
137
|
+
req: RefreshRequest,
|
|
138
|
+
session: AsyncSession = Depends(get_db_session),
|
|
139
|
+
) -> TokenResponse:
|
|
140
|
+
"""Exchange a refresh token for a new access token."""
|
|
141
|
+
payload = decode_token(req.refresh_token)
|
|
142
|
+
if payload is None or payload.get("type") != "refresh":
|
|
143
|
+
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
|
144
|
+
|
|
145
|
+
user_id = uuid.UUID(payload["sub"])
|
|
146
|
+
result = await session.execute(select(User).where(User.id == user_id))
|
|
147
|
+
user = result.scalar_one_or_none()
|
|
148
|
+
if user is None:
|
|
149
|
+
raise HTTPException(status_code=401, detail="User not found")
|
|
150
|
+
|
|
151
|
+
config = get_config()
|
|
152
|
+
return TokenResponse(
|
|
153
|
+
access_token=create_access_token(user.id, expires_minutes=config.jwt_expiry_minutes),
|
|
154
|
+
refresh_token=create_refresh_token(user.id, expires_days=config.refresh_expiry_days),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@router.get("/github")
|
|
159
|
+
async def github_authorize() -> dict:
|
|
160
|
+
"""Redirect URL for GitHub OAuth authorization."""
|
|
161
|
+
from attocode_intel.api.auth.oauth import get_github_auth_url
|
|
162
|
+
|
|
163
|
+
config = get_config()
|
|
164
|
+
if not config.github_client_id:
|
|
165
|
+
raise HTTPException(status_code=501, detail="GitHub OAuth not configured")
|
|
166
|
+
|
|
167
|
+
state = secrets.token_urlsafe(32)
|
|
168
|
+
_store_state(state)
|
|
169
|
+
url = await get_github_auth_url(
|
|
170
|
+
client_id=config.github_client_id,
|
|
171
|
+
redirect_uri=f"{config.effective_base_url}/api/v1/auth/github/callback",
|
|
172
|
+
state=state,
|
|
173
|
+
)
|
|
174
|
+
return {"authorize_url": url, "state": state}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@router.get("/github/callback")
|
|
178
|
+
async def github_callback(
|
|
179
|
+
code: str = Query(...),
|
|
180
|
+
state: str = Query(""),
|
|
181
|
+
session: AsyncSession = Depends(get_db_session),
|
|
182
|
+
) -> RedirectResponse:
|
|
183
|
+
"""Handle GitHub OAuth callback — create/link user, redirect with JWT."""
|
|
184
|
+
if not _validate_state(state):
|
|
185
|
+
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state")
|
|
186
|
+
|
|
187
|
+
from attocode_intel.api.auth.oauth import exchange_github_code
|
|
188
|
+
|
|
189
|
+
config = get_config()
|
|
190
|
+
if not config.github_client_id or not config.github_client_secret:
|
|
191
|
+
raise HTTPException(status_code=501, detail="GitHub OAuth not configured")
|
|
192
|
+
|
|
193
|
+
gh_user = await exchange_github_code(
|
|
194
|
+
code=code,
|
|
195
|
+
client_id=config.github_client_id,
|
|
196
|
+
client_secret=config.github_client_secret,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Try to find existing user by github_id
|
|
200
|
+
result = await session.execute(
|
|
201
|
+
select(User).where(User.github_id == gh_user["github_id"])
|
|
202
|
+
)
|
|
203
|
+
user = result.scalar_one_or_none()
|
|
204
|
+
|
|
205
|
+
if user is None:
|
|
206
|
+
# Try by email
|
|
207
|
+
result = await session.execute(
|
|
208
|
+
select(User).where(User.email == gh_user["email"])
|
|
209
|
+
)
|
|
210
|
+
user = result.scalar_one_or_none()
|
|
211
|
+
if user:
|
|
212
|
+
# Link GitHub to existing account
|
|
213
|
+
user.github_id = gh_user["github_id"]
|
|
214
|
+
user.avatar_url = user.avatar_url or gh_user["avatar_url"]
|
|
215
|
+
else:
|
|
216
|
+
# Create new user
|
|
217
|
+
user = User(
|
|
218
|
+
email=gh_user["email"],
|
|
219
|
+
name=gh_user["name"],
|
|
220
|
+
github_id=gh_user["github_id"],
|
|
221
|
+
avatar_url=gh_user["avatar_url"],
|
|
222
|
+
auth_provider="github",
|
|
223
|
+
)
|
|
224
|
+
session.add(user)
|
|
225
|
+
|
|
226
|
+
await session.commit()
|
|
227
|
+
await session.refresh(user)
|
|
228
|
+
|
|
229
|
+
access_token = create_access_token(user.id, expires_minutes=config.jwt_expiry_minutes)
|
|
230
|
+
refresh_token = create_refresh_token(user.id, expires_days=config.refresh_expiry_days)
|
|
231
|
+
fragment = urlencode({"access_token": access_token, "refresh_token": refresh_token})
|
|
232
|
+
return RedirectResponse(url=f"/auth/callback#{fragment}", status_code=302)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@router.get("/google")
|
|
236
|
+
async def google_authorize() -> dict:
|
|
237
|
+
"""Redirect URL for Google OAuth authorization."""
|
|
238
|
+
from attocode_intel.api.auth.oauth import get_google_auth_url
|
|
239
|
+
|
|
240
|
+
config = get_config()
|
|
241
|
+
if not config.google_client_id:
|
|
242
|
+
raise HTTPException(status_code=501, detail="Google OAuth not configured")
|
|
243
|
+
|
|
244
|
+
state = secrets.token_urlsafe(32)
|
|
245
|
+
_store_state(state)
|
|
246
|
+
url = await get_google_auth_url(
|
|
247
|
+
client_id=config.google_client_id,
|
|
248
|
+
redirect_uri=f"{config.effective_base_url}/api/v1/auth/google/callback",
|
|
249
|
+
state=state,
|
|
250
|
+
)
|
|
251
|
+
return {"authorize_url": url, "state": state}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@router.get("/google/callback")
|
|
255
|
+
async def google_callback(
|
|
256
|
+
code: str = Query(...),
|
|
257
|
+
state: str = Query(""),
|
|
258
|
+
session: AsyncSession = Depends(get_db_session),
|
|
259
|
+
) -> RedirectResponse:
|
|
260
|
+
"""Handle Google OAuth callback — create/link user, redirect with JWT."""
|
|
261
|
+
if not _validate_state(state):
|
|
262
|
+
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state")
|
|
263
|
+
|
|
264
|
+
from attocode_intel.api.auth.oauth import exchange_google_code
|
|
265
|
+
|
|
266
|
+
config = get_config()
|
|
267
|
+
if not config.google_client_id or not config.google_client_secret:
|
|
268
|
+
raise HTTPException(status_code=501, detail="Google OAuth not configured")
|
|
269
|
+
|
|
270
|
+
g_user = await exchange_google_code(
|
|
271
|
+
code=code,
|
|
272
|
+
client_id=config.google_client_id,
|
|
273
|
+
client_secret=config.google_client_secret,
|
|
274
|
+
redirect_uri=f"{config.effective_base_url}/api/v1/auth/google/callback",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Lookup by google_id -> email -> create new
|
|
278
|
+
result = await session.execute(select(User).where(User.google_id == g_user["google_id"]))
|
|
279
|
+
user = result.scalar_one_or_none()
|
|
280
|
+
|
|
281
|
+
if user is None:
|
|
282
|
+
result = await session.execute(select(User).where(User.email == g_user["email"]))
|
|
283
|
+
user = result.scalar_one_or_none()
|
|
284
|
+
if user:
|
|
285
|
+
user.google_id = g_user["google_id"]
|
|
286
|
+
user.avatar_url = user.avatar_url or g_user["avatar_url"]
|
|
287
|
+
else:
|
|
288
|
+
user = User(
|
|
289
|
+
email=g_user["email"],
|
|
290
|
+
name=g_user["name"],
|
|
291
|
+
google_id=g_user["google_id"],
|
|
292
|
+
avatar_url=g_user["avatar_url"],
|
|
293
|
+
auth_provider="google",
|
|
294
|
+
)
|
|
295
|
+
session.add(user)
|
|
296
|
+
|
|
297
|
+
await session.commit()
|
|
298
|
+
await session.refresh(user)
|
|
299
|
+
|
|
300
|
+
access_token = create_access_token(user.id, expires_minutes=config.jwt_expiry_minutes)
|
|
301
|
+
refresh_token = create_refresh_token(user.id, expires_days=config.refresh_expiry_days)
|
|
302
|
+
fragment = urlencode({"access_token": access_token, "refresh_token": refresh_token})
|
|
303
|
+
return RedirectResponse(url=f"/auth/callback#{fragment}", status_code=302)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@router.get("/providers")
|
|
307
|
+
async def list_providers() -> dict:
|
|
308
|
+
"""List available auth providers. No auth required — called from login page."""
|
|
309
|
+
config = get_config()
|
|
310
|
+
providers = ["email"]
|
|
311
|
+
if config.github_client_id:
|
|
312
|
+
providers.append("github")
|
|
313
|
+
if config.google_client_id:
|
|
314
|
+
providers.append("google")
|
|
315
|
+
return {
|
|
316
|
+
"providers": providers,
|
|
317
|
+
"registration_enabled": True,
|
|
318
|
+
"registration_key_required": bool(config.registration_key),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class UpdateProfileRequest(BaseModel):
|
|
323
|
+
name: str | None = None
|
|
324
|
+
avatar_url: str | None = None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class ChangePasswordRequest(BaseModel):
|
|
328
|
+
current_password: str
|
|
329
|
+
new_password: str
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@router.patch("/me", response_model=UserProfile)
|
|
333
|
+
async def update_profile(
|
|
334
|
+
req: UpdateProfileRequest,
|
|
335
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
336
|
+
session: AsyncSession = Depends(get_db_session),
|
|
337
|
+
) -> UserProfile:
|
|
338
|
+
"""Update current user's profile (name, avatar_url)."""
|
|
339
|
+
if not auth.user_id:
|
|
340
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
341
|
+
|
|
342
|
+
result = await session.execute(select(User).where(User.id == auth.user_id))
|
|
343
|
+
user = result.scalar_one_or_none()
|
|
344
|
+
if user is None:
|
|
345
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
346
|
+
|
|
347
|
+
if req.name is not None:
|
|
348
|
+
user.name = req.name
|
|
349
|
+
if req.avatar_url is not None:
|
|
350
|
+
user.avatar_url = req.avatar_url
|
|
351
|
+
|
|
352
|
+
await session.commit()
|
|
353
|
+
await session.refresh(user)
|
|
354
|
+
|
|
355
|
+
memberships = await session.execute(
|
|
356
|
+
select(OrgMembership).where(OrgMembership.user_id == user.id)
|
|
357
|
+
)
|
|
358
|
+
orgs = [{"org_id": str(m.org_id), "role": m.role} for m in memberships.scalars()]
|
|
359
|
+
|
|
360
|
+
return UserProfile(
|
|
361
|
+
id=str(user.id),
|
|
362
|
+
email=user.email,
|
|
363
|
+
name=user.name,
|
|
364
|
+
avatar_url=user.avatar_url,
|
|
365
|
+
auth_provider=user.auth_provider,
|
|
366
|
+
orgs=orgs,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@router.post("/me/password")
|
|
371
|
+
async def change_password(
|
|
372
|
+
req: ChangePasswordRequest,
|
|
373
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
374
|
+
session: AsyncSession = Depends(get_db_session),
|
|
375
|
+
) -> dict:
|
|
376
|
+
"""Change password (requires current password)."""
|
|
377
|
+
if not auth.user_id:
|
|
378
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
379
|
+
|
|
380
|
+
result = await session.execute(select(User).where(User.id == auth.user_id))
|
|
381
|
+
user = result.scalar_one_or_none()
|
|
382
|
+
if user is None:
|
|
383
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
384
|
+
|
|
385
|
+
if not user.password_hash:
|
|
386
|
+
raise HTTPException(status_code=400, detail="Account uses OAuth — no password to change")
|
|
387
|
+
|
|
388
|
+
if not verify_password(req.current_password, user.password_hash):
|
|
389
|
+
raise HTTPException(status_code=401, detail="Current password is incorrect")
|
|
390
|
+
|
|
391
|
+
if len(req.new_password) < 8:
|
|
392
|
+
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
|
393
|
+
|
|
394
|
+
user.password_hash = hash_password(req.new_password)
|
|
395
|
+
await session.commit()
|
|
396
|
+
return {"detail": "Password changed"}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@router.get("/me", response_model=UserProfile)
|
|
400
|
+
async def me(
|
|
401
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
402
|
+
session: AsyncSession = Depends(get_db_session),
|
|
403
|
+
) -> UserProfile:
|
|
404
|
+
"""Get current user profile with org memberships."""
|
|
405
|
+
if not auth.user_id:
|
|
406
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
407
|
+
|
|
408
|
+
result = await session.execute(select(User).where(User.id == auth.user_id))
|
|
409
|
+
user = result.scalar_one_or_none()
|
|
410
|
+
if user is None:
|
|
411
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
412
|
+
|
|
413
|
+
# Get org memberships
|
|
414
|
+
memberships = await session.execute(
|
|
415
|
+
select(OrgMembership).where(OrgMembership.user_id == user.id)
|
|
416
|
+
)
|
|
417
|
+
orgs = []
|
|
418
|
+
for m in memberships.scalars():
|
|
419
|
+
orgs.append({"org_id": str(m.org_id), "role": m.role})
|
|
420
|
+
|
|
421
|
+
return UserProfile(
|
|
422
|
+
id=str(user.id),
|
|
423
|
+
email=user.email,
|
|
424
|
+
name=user.name,
|
|
425
|
+
avatar_url=user.avatar_url,
|
|
426
|
+
auth_provider=user.auth_provider,
|
|
427
|
+
orgs=orgs,
|
|
428
|
+
)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Branch management and comparison endpoints (service mode only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from sqlalchemy import func, select
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
|
|
12
|
+
from attocode_intel.api.auth import resolve_auth
|
|
13
|
+
from attocode_intel.api.auth.context import AuthContext
|
|
14
|
+
from attocode_intel.api.deps import get_db_session
|
|
15
|
+
from attocode_intel.db.models import Branch, Repository
|
|
16
|
+
|
|
17
|
+
router = APIRouter(prefix="/api/v1/repos/{repo_id}/branches", tags=["branches"])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BranchResponse(BaseModel):
|
|
21
|
+
id: str
|
|
22
|
+
name: str
|
|
23
|
+
head_commit: str | None = None
|
|
24
|
+
is_default: bool
|
|
25
|
+
last_indexed_at: str | None = None
|
|
26
|
+
overlay_stats: dict = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BranchListResponse(BaseModel):
|
|
30
|
+
branches: list[BranchResponse]
|
|
31
|
+
total: int = 0
|
|
32
|
+
limit: int = 20
|
|
33
|
+
offset: int = 0
|
|
34
|
+
has_more: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BranchCompareRequest(BaseModel):
|
|
38
|
+
base_branch: str
|
|
39
|
+
compare_branch: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BranchCompareResponse(BaseModel):
|
|
43
|
+
base: str
|
|
44
|
+
compare: str
|
|
45
|
+
files_changed: dict # path → change_type
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _get_repo(repo_id: uuid.UUID, session: AsyncSession) -> Repository:
|
|
49
|
+
result = await session.execute(select(Repository).where(Repository.id == repo_id))
|
|
50
|
+
repo = result.scalar_one_or_none()
|
|
51
|
+
if repo is None:
|
|
52
|
+
raise HTTPException(status_code=404, detail="Repository not found")
|
|
53
|
+
return repo
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.get("", response_model=BranchListResponse)
|
|
57
|
+
async def list_branches(
|
|
58
|
+
repo_id: uuid.UUID,
|
|
59
|
+
limit: int = Query(20, ge=1, le=100),
|
|
60
|
+
offset: int = Query(0, ge=0),
|
|
61
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
62
|
+
session: AsyncSession = Depends(get_db_session),
|
|
63
|
+
) -> BranchListResponse:
|
|
64
|
+
"""List branches with overlay stats."""
|
|
65
|
+
await _get_repo(repo_id, session)
|
|
66
|
+
|
|
67
|
+
from attocode_intel.storage.branch_overlay import BranchOverlay
|
|
68
|
+
|
|
69
|
+
overlay = BranchOverlay(session)
|
|
70
|
+
|
|
71
|
+
count_result = await session.execute(
|
|
72
|
+
select(func.count()).select_from(
|
|
73
|
+
select(Branch).where(Branch.repo_id == repo_id).subquery()
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
total = count_result.scalar() or 0
|
|
77
|
+
|
|
78
|
+
result = await session.execute(
|
|
79
|
+
select(Branch).where(Branch.repo_id == repo_id)
|
|
80
|
+
.offset(offset).limit(limit)
|
|
81
|
+
)
|
|
82
|
+
branches = []
|
|
83
|
+
for b in result.scalars():
|
|
84
|
+
stats = await overlay.get_overlay_stats(b.id)
|
|
85
|
+
branches.append(BranchResponse(
|
|
86
|
+
id=str(b.id),
|
|
87
|
+
name=b.name,
|
|
88
|
+
head_commit=b.head_commit,
|
|
89
|
+
is_default=b.is_default,
|
|
90
|
+
last_indexed_at=b.last_indexed_at.isoformat() if b.last_indexed_at else None,
|
|
91
|
+
overlay_stats=stats,
|
|
92
|
+
))
|
|
93
|
+
return BranchListResponse(
|
|
94
|
+
branches=branches, total=total, limit=limit, offset=offset,
|
|
95
|
+
has_more=(offset + limit < total),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.get("/{branch_name}", response_model=BranchResponse)
|
|
100
|
+
async def get_branch(
|
|
101
|
+
repo_id: uuid.UUID,
|
|
102
|
+
branch_name: str,
|
|
103
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
104
|
+
session: AsyncSession = Depends(get_db_session),
|
|
105
|
+
) -> BranchResponse:
|
|
106
|
+
"""Get branch detail."""
|
|
107
|
+
await _get_repo(repo_id, session)
|
|
108
|
+
|
|
109
|
+
result = await session.execute(
|
|
110
|
+
select(Branch).where(Branch.repo_id == repo_id, Branch.name == branch_name)
|
|
111
|
+
)
|
|
112
|
+
branch = result.scalar_one_or_none()
|
|
113
|
+
if branch is None:
|
|
114
|
+
raise HTTPException(status_code=404, detail=f"Branch '{branch_name}' not found")
|
|
115
|
+
|
|
116
|
+
from attocode_intel.storage.branch_overlay import BranchOverlay
|
|
117
|
+
|
|
118
|
+
overlay = BranchOverlay(session)
|
|
119
|
+
stats = await overlay.get_overlay_stats(branch.id)
|
|
120
|
+
|
|
121
|
+
return BranchResponse(
|
|
122
|
+
id=str(branch.id),
|
|
123
|
+
name=branch.name,
|
|
124
|
+
head_commit=branch.head_commit,
|
|
125
|
+
is_default=branch.is_default,
|
|
126
|
+
last_indexed_at=branch.last_indexed_at.isoformat() if branch.last_indexed_at else None,
|
|
127
|
+
overlay_stats=stats,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@router.post("/compare", response_model=BranchCompareResponse)
|
|
132
|
+
async def compare_branches(
|
|
133
|
+
repo_id: uuid.UUID,
|
|
134
|
+
req: BranchCompareRequest,
|
|
135
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
136
|
+
session: AsyncSession = Depends(get_db_session),
|
|
137
|
+
) -> BranchCompareResponse:
|
|
138
|
+
"""Compare two branches — returns file-level diff."""
|
|
139
|
+
await _get_repo(repo_id, session)
|
|
140
|
+
|
|
141
|
+
base_result = await session.execute(
|
|
142
|
+
select(Branch).where(Branch.repo_id == repo_id, Branch.name == req.base_branch)
|
|
143
|
+
)
|
|
144
|
+
base = base_result.scalar_one_or_none()
|
|
145
|
+
if base is None:
|
|
146
|
+
raise HTTPException(status_code=404, detail=f"Branch '{req.base_branch}' not found")
|
|
147
|
+
|
|
148
|
+
compare_result = await session.execute(
|
|
149
|
+
select(Branch).where(Branch.repo_id == repo_id, Branch.name == req.compare_branch)
|
|
150
|
+
)
|
|
151
|
+
compare = compare_result.scalar_one_or_none()
|
|
152
|
+
if compare is None:
|
|
153
|
+
raise HTTPException(status_code=404, detail=f"Branch '{req.compare_branch}' not found")
|
|
154
|
+
|
|
155
|
+
from attocode_intel.storage.branch_overlay import BranchOverlay
|
|
156
|
+
|
|
157
|
+
overlay = BranchOverlay(session)
|
|
158
|
+
diff = await overlay.diff_branches(base.id, compare.id)
|
|
159
|
+
|
|
160
|
+
return BranchCompareResponse(
|
|
161
|
+
base=req.base_branch,
|
|
162
|
+
compare=req.compare_branch,
|
|
163
|
+
files_changed=diff,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class BranchMergeRequest(BaseModel):
|
|
168
|
+
source_branch: str
|
|
169
|
+
target_branch: str
|
|
170
|
+
delete_source: bool = True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class BranchMergeResponse(BaseModel):
|
|
174
|
+
source: str
|
|
175
|
+
target: str
|
|
176
|
+
added: int = 0
|
|
177
|
+
modified: int = 0
|
|
178
|
+
deleted: int = 0
|
|
179
|
+
total: int = 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@router.post("/merge", response_model=BranchMergeResponse)
|
|
183
|
+
async def merge_branches(
|
|
184
|
+
repo_id: uuid.UUID,
|
|
185
|
+
req: BranchMergeRequest,
|
|
186
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
187
|
+
session: AsyncSession = Depends(get_db_session),
|
|
188
|
+
) -> BranchMergeResponse:
|
|
189
|
+
"""Merge source branch overlay into target branch."""
|
|
190
|
+
await _get_repo(repo_id, session)
|
|
191
|
+
|
|
192
|
+
source_result = await session.execute(
|
|
193
|
+
select(Branch).where(Branch.repo_id == repo_id, Branch.name == req.source_branch)
|
|
194
|
+
)
|
|
195
|
+
source = source_result.scalar_one_or_none()
|
|
196
|
+
if source is None:
|
|
197
|
+
raise HTTPException(status_code=404, detail=f"Branch '{req.source_branch}' not found")
|
|
198
|
+
|
|
199
|
+
target_result = await session.execute(
|
|
200
|
+
select(Branch).where(Branch.repo_id == repo_id, Branch.name == req.target_branch)
|
|
201
|
+
)
|
|
202
|
+
target = target_result.scalar_one_or_none()
|
|
203
|
+
if target is None:
|
|
204
|
+
raise HTTPException(status_code=404, detail=f"Branch '{req.target_branch}' not found")
|
|
205
|
+
|
|
206
|
+
if source.is_default:
|
|
207
|
+
raise HTTPException(status_code=400, detail="Cannot merge the default branch as source")
|
|
208
|
+
|
|
209
|
+
from attocode_intel.storage.branch_overlay import BranchOverlay
|
|
210
|
+
|
|
211
|
+
overlay = BranchOverlay(session)
|
|
212
|
+
stats = await overlay.merge_branch(
|
|
213
|
+
source.id, target.id, delete_source=req.delete_source,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
from attocode_intel.audit import log_event
|
|
217
|
+
|
|
218
|
+
repo = await _get_repo(repo_id, session)
|
|
219
|
+
await log_event(
|
|
220
|
+
session, repo.org_id, "branch.merged", repo_id=repo_id,
|
|
221
|
+
detail={"source": req.source_branch, "target": req.target_branch, "stats": stats},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
await session.commit()
|
|
225
|
+
|
|
226
|
+
return BranchMergeResponse(
|
|
227
|
+
source=req.source_branch,
|
|
228
|
+
target=req.target_branch,
|
|
229
|
+
**stats,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@router.delete("/{branch_name}")
|
|
234
|
+
async def delete_branch(
|
|
235
|
+
repo_id: uuid.UUID,
|
|
236
|
+
branch_name: str,
|
|
237
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
238
|
+
session: AsyncSession = Depends(get_db_session),
|
|
239
|
+
) -> dict:
|
|
240
|
+
"""Delete a branch overlay (not the git branch)."""
|
|
241
|
+
await _get_repo(repo_id, session)
|
|
242
|
+
|
|
243
|
+
result = await session.execute(
|
|
244
|
+
select(Branch).where(Branch.repo_id == repo_id, Branch.name == branch_name)
|
|
245
|
+
)
|
|
246
|
+
branch = result.scalar_one_or_none()
|
|
247
|
+
if branch is None:
|
|
248
|
+
raise HTTPException(status_code=404, detail=f"Branch '{branch_name}' not found")
|
|
249
|
+
|
|
250
|
+
if branch.is_default:
|
|
251
|
+
raise HTTPException(status_code=400, detail="Cannot delete the default branch overlay")
|
|
252
|
+
|
|
253
|
+
repo = await _get_repo(repo_id, session)
|
|
254
|
+
|
|
255
|
+
await session.delete(branch)
|
|
256
|
+
|
|
257
|
+
from attocode_intel.audit import log_event
|
|
258
|
+
|
|
259
|
+
await log_event(session, repo.org_id, "branch.deleted", repo_id=repo_id, detail={"branch_name": branch_name})
|
|
260
|
+
|
|
261
|
+
await session.commit()
|
|
262
|
+
return {"detail": f"Branch overlay '{branch_name}' deleted"}
|