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,924 @@
|
|
|
1
|
+
"""Organization management endpoints (service mode only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from sqlalchemy import func, select
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
14
|
+
|
|
15
|
+
from attocode_intel.api.auth import resolve_auth
|
|
16
|
+
from attocode_intel.api.auth.context import AuthContext
|
|
17
|
+
from attocode_intel.api.deps import get_db_session
|
|
18
|
+
from attocode_intel.db.models import Organization, OrgMembership, Repository, User
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
router = APIRouter(prefix="/api/v1/orgs", tags=["organizations"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --- Request/Response models ---
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CreateOrgRequest(BaseModel):
|
|
29
|
+
name: str
|
|
30
|
+
slug: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UpdateOrgRequest(BaseModel):
|
|
34
|
+
name: str | None = None
|
|
35
|
+
plan: str | None = None
|
|
36
|
+
settings: dict | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OrgResponse(BaseModel):
|
|
40
|
+
id: str
|
|
41
|
+
name: str
|
|
42
|
+
slug: str
|
|
43
|
+
plan: str
|
|
44
|
+
created_at: str
|
|
45
|
+
member_count: int = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OrgListResponse(BaseModel):
|
|
49
|
+
organizations: list[OrgResponse]
|
|
50
|
+
total: int = 0
|
|
51
|
+
limit: int = 20
|
|
52
|
+
offset: int = 0
|
|
53
|
+
has_more: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InviteMemberRequest(BaseModel):
|
|
57
|
+
email: str
|
|
58
|
+
role: str = "member"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MemberResponse(BaseModel):
|
|
62
|
+
user_id: str
|
|
63
|
+
email: str
|
|
64
|
+
name: str
|
|
65
|
+
role: str
|
|
66
|
+
accepted: bool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MemberListResponse(BaseModel):
|
|
70
|
+
members: list[MemberResponse]
|
|
71
|
+
total: int = 0
|
|
72
|
+
limit: int = 20
|
|
73
|
+
offset: int = 0
|
|
74
|
+
has_more: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class UpdateMemberRequest(BaseModel):
|
|
78
|
+
role: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CreateRepoRequest(BaseModel):
|
|
82
|
+
name: str
|
|
83
|
+
clone_url: str | None = None
|
|
84
|
+
local_path: str | None = None
|
|
85
|
+
default_branch: str = "main"
|
|
86
|
+
language: str | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class UpdateRepoRequest(BaseModel):
|
|
90
|
+
"""Fields that can be updated after a repository is created.
|
|
91
|
+
|
|
92
|
+
``None`` means "leave unchanged". Empty string explicitly clears a
|
|
93
|
+
nullable column. Immutable fields (``id``, ``created_at``) are not
|
|
94
|
+
present.
|
|
95
|
+
|
|
96
|
+
``target_org_id`` lets admins move a repo between orgs they have
|
|
97
|
+
admin rights in. Both source and target orgs are checked for
|
|
98
|
+
membership, and the target org must not already contain a repo with
|
|
99
|
+
the same name.
|
|
100
|
+
"""
|
|
101
|
+
name: str | None = None
|
|
102
|
+
clone_url: str | None = None
|
|
103
|
+
default_branch: str | None = None
|
|
104
|
+
language: str | None = None
|
|
105
|
+
local_path: str | None = None
|
|
106
|
+
target_org_id: uuid.UUID | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class RepoResponse(BaseModel):
|
|
110
|
+
id: str
|
|
111
|
+
name: str
|
|
112
|
+
clone_url: str | None = None
|
|
113
|
+
local_path: str | None = None
|
|
114
|
+
default_branch: str
|
|
115
|
+
language: str | None = None
|
|
116
|
+
index_status: str
|
|
117
|
+
last_indexed_at: str | None = None
|
|
118
|
+
created_at: str
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class RepoListResponse(BaseModel):
|
|
122
|
+
repositories: list[RepoResponse]
|
|
123
|
+
total: int = 0
|
|
124
|
+
limit: int = 20
|
|
125
|
+
offset: int = 0
|
|
126
|
+
has_more: bool = False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _slugify(name: str) -> str:
|
|
130
|
+
"""Generate a URL-safe slug from a name."""
|
|
131
|
+
slug = name.lower().strip()
|
|
132
|
+
slug = re.sub(r"[^\w\s-]", "", slug)
|
|
133
|
+
slug = re.sub(r"[\s_]+", "-", slug)
|
|
134
|
+
return slug[:64]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _require_membership(
|
|
138
|
+
org_id: uuid.UUID, auth: AuthContext, session: AsyncSession, min_role: str = "member"
|
|
139
|
+
) -> OrgMembership:
|
|
140
|
+
"""Verify the user is a member of the org with sufficient role."""
|
|
141
|
+
if not auth.user_id:
|
|
142
|
+
raise HTTPException(status_code=403, detail="Authentication required")
|
|
143
|
+
|
|
144
|
+
role_hierarchy = {"owner": 3, "admin": 2, "member": 1}
|
|
145
|
+
min_level = role_hierarchy.get(min_role, 1)
|
|
146
|
+
|
|
147
|
+
result = await session.execute(
|
|
148
|
+
select(OrgMembership).where(
|
|
149
|
+
OrgMembership.org_id == org_id,
|
|
150
|
+
OrgMembership.user_id == auth.user_id,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
membership = result.scalar_one_or_none()
|
|
154
|
+
if membership is None:
|
|
155
|
+
raise HTTPException(status_code=403, detail="Not a member of this organization")
|
|
156
|
+
|
|
157
|
+
user_level = role_hierarchy.get(membership.role, 0)
|
|
158
|
+
if user_level < min_level:
|
|
159
|
+
raise HTTPException(status_code=403, detail=f"Requires {min_role} role or higher")
|
|
160
|
+
|
|
161
|
+
return membership
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# --- Organization CRUD ---
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@router.post("", response_model=OrgResponse)
|
|
168
|
+
async def create_org(
|
|
169
|
+
req: CreateOrgRequest,
|
|
170
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
171
|
+
session: AsyncSession = Depends(get_db_session),
|
|
172
|
+
) -> OrgResponse:
|
|
173
|
+
"""Create a new organization. Creator becomes owner."""
|
|
174
|
+
if not auth.user_id:
|
|
175
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
176
|
+
|
|
177
|
+
slug = req.slug or _slugify(req.name)
|
|
178
|
+
existing = await session.execute(select(Organization).where(Organization.slug == slug))
|
|
179
|
+
if existing.scalar_one_or_none():
|
|
180
|
+
raise HTTPException(status_code=409, detail=f"Slug '{slug}' already taken")
|
|
181
|
+
|
|
182
|
+
org = Organization(name=req.name, slug=slug)
|
|
183
|
+
session.add(org)
|
|
184
|
+
await session.flush()
|
|
185
|
+
|
|
186
|
+
# Add creator as owner
|
|
187
|
+
membership = OrgMembership(
|
|
188
|
+
org_id=org.id,
|
|
189
|
+
user_id=auth.user_id,
|
|
190
|
+
role="owner",
|
|
191
|
+
accepted_at=datetime.now(UTC),
|
|
192
|
+
)
|
|
193
|
+
session.add(membership)
|
|
194
|
+
await session.commit()
|
|
195
|
+
await session.refresh(org)
|
|
196
|
+
|
|
197
|
+
from attocode_intel.audit import log_event
|
|
198
|
+
|
|
199
|
+
await log_event(session, org.id, "org.created", user_id=auth.user_id, detail={"name": org.name, "slug": org.slug})
|
|
200
|
+
|
|
201
|
+
return OrgResponse(
|
|
202
|
+
id=str(org.id),
|
|
203
|
+
name=org.name,
|
|
204
|
+
slug=org.slug,
|
|
205
|
+
plan=org.plan,
|
|
206
|
+
created_at=org.created_at.isoformat(),
|
|
207
|
+
member_count=1,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@router.get("", response_model=OrgListResponse)
|
|
212
|
+
async def list_orgs(
|
|
213
|
+
limit: int = Query(20, ge=1, le=100),
|
|
214
|
+
offset: int = Query(0, ge=0),
|
|
215
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
216
|
+
session: AsyncSession = Depends(get_db_session),
|
|
217
|
+
) -> OrgListResponse:
|
|
218
|
+
"""List organizations the current user belongs to."""
|
|
219
|
+
if not auth.user_id:
|
|
220
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
221
|
+
|
|
222
|
+
base_query = (
|
|
223
|
+
select(Organization)
|
|
224
|
+
.join(OrgMembership, Organization.id == OrgMembership.org_id)
|
|
225
|
+
.where(OrgMembership.user_id == auth.user_id)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
count_result = await session.execute(
|
|
229
|
+
select(func.count()).select_from(base_query.subquery())
|
|
230
|
+
)
|
|
231
|
+
total = count_result.scalar() or 0
|
|
232
|
+
|
|
233
|
+
result = await session.execute(base_query.offset(offset).limit(limit))
|
|
234
|
+
orgs = []
|
|
235
|
+
for org in result.scalars():
|
|
236
|
+
orgs.append(OrgResponse(
|
|
237
|
+
id=str(org.id),
|
|
238
|
+
name=org.name,
|
|
239
|
+
slug=org.slug,
|
|
240
|
+
plan=org.plan,
|
|
241
|
+
created_at=org.created_at.isoformat(),
|
|
242
|
+
))
|
|
243
|
+
return OrgListResponse(
|
|
244
|
+
organizations=orgs, total=total, limit=limit, offset=offset,
|
|
245
|
+
has_more=(offset + limit < total),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@router.get("/{org_id}", response_model=OrgResponse)
|
|
250
|
+
async def get_org(
|
|
251
|
+
org_id: uuid.UUID,
|
|
252
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
253
|
+
session: AsyncSession = Depends(get_db_session),
|
|
254
|
+
) -> OrgResponse:
|
|
255
|
+
"""Get organization details."""
|
|
256
|
+
await _require_membership(org_id, auth, session)
|
|
257
|
+
|
|
258
|
+
result = await session.execute(select(Organization).where(Organization.id == org_id))
|
|
259
|
+
org = result.scalar_one_or_none()
|
|
260
|
+
if org is None:
|
|
261
|
+
raise HTTPException(status_code=404, detail="Organization not found")
|
|
262
|
+
|
|
263
|
+
return OrgResponse(
|
|
264
|
+
id=str(org.id),
|
|
265
|
+
name=org.name,
|
|
266
|
+
slug=org.slug,
|
|
267
|
+
plan=org.plan,
|
|
268
|
+
created_at=org.created_at.isoformat(),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@router.patch("/{org_id}", response_model=OrgResponse)
|
|
273
|
+
async def update_org(
|
|
274
|
+
org_id: uuid.UUID,
|
|
275
|
+
req: UpdateOrgRequest,
|
|
276
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
277
|
+
session: AsyncSession = Depends(get_db_session),
|
|
278
|
+
) -> OrgResponse:
|
|
279
|
+
"""Update organization. Requires admin+ role."""
|
|
280
|
+
await _require_membership(org_id, auth, session, min_role="admin")
|
|
281
|
+
|
|
282
|
+
result = await session.execute(select(Organization).where(Organization.id == org_id))
|
|
283
|
+
org = result.scalar_one_or_none()
|
|
284
|
+
if org is None:
|
|
285
|
+
raise HTTPException(status_code=404, detail="Organization not found")
|
|
286
|
+
|
|
287
|
+
if req.name is not None:
|
|
288
|
+
org.name = req.name
|
|
289
|
+
if req.plan is not None:
|
|
290
|
+
org.plan = req.plan
|
|
291
|
+
if req.settings is not None:
|
|
292
|
+
org.settings = req.settings
|
|
293
|
+
|
|
294
|
+
await session.commit()
|
|
295
|
+
await session.refresh(org)
|
|
296
|
+
|
|
297
|
+
return OrgResponse(
|
|
298
|
+
id=str(org.id),
|
|
299
|
+
name=org.name,
|
|
300
|
+
slug=org.slug,
|
|
301
|
+
plan=org.plan,
|
|
302
|
+
created_at=org.created_at.isoformat(),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# --- Members ---
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@router.post("/{org_id}/members", response_model=MemberResponse)
|
|
310
|
+
async def invite_member(
|
|
311
|
+
org_id: uuid.UUID,
|
|
312
|
+
req: InviteMemberRequest,
|
|
313
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
314
|
+
session: AsyncSession = Depends(get_db_session),
|
|
315
|
+
) -> MemberResponse:
|
|
316
|
+
"""Invite a user to the organization. Requires admin+ role."""
|
|
317
|
+
await _require_membership(org_id, auth, session, min_role="admin")
|
|
318
|
+
|
|
319
|
+
if req.role not in ("member", "admin"):
|
|
320
|
+
raise HTTPException(status_code=400, detail="Role must be 'member' or 'admin'")
|
|
321
|
+
|
|
322
|
+
result = await session.execute(select(User).where(User.email == req.email))
|
|
323
|
+
user = result.scalar_one_or_none()
|
|
324
|
+
if user is None:
|
|
325
|
+
raise HTTPException(status_code=404, detail=f"User with email '{req.email}' not found")
|
|
326
|
+
|
|
327
|
+
# Check if already a member
|
|
328
|
+
existing = await session.execute(
|
|
329
|
+
select(OrgMembership).where(
|
|
330
|
+
OrgMembership.org_id == org_id,
|
|
331
|
+
OrgMembership.user_id == user.id,
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
if existing.scalar_one_or_none():
|
|
335
|
+
raise HTTPException(status_code=409, detail="User is already a member")
|
|
336
|
+
|
|
337
|
+
membership = OrgMembership(org_id=org_id, user_id=user.id, role=req.role)
|
|
338
|
+
session.add(membership)
|
|
339
|
+
await session.commit()
|
|
340
|
+
|
|
341
|
+
from attocode_intel.audit import log_event
|
|
342
|
+
|
|
343
|
+
await log_event(session, org_id, "member.invited", user_id=auth.user_id, detail={"email": req.email, "role": req.role})
|
|
344
|
+
|
|
345
|
+
return MemberResponse(
|
|
346
|
+
user_id=str(user.id),
|
|
347
|
+
email=user.email,
|
|
348
|
+
name=user.name,
|
|
349
|
+
role=req.role,
|
|
350
|
+
accepted=False,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@router.get("/{org_id}/members", response_model=MemberListResponse)
|
|
355
|
+
async def list_members(
|
|
356
|
+
org_id: uuid.UUID,
|
|
357
|
+
limit: int = Query(20, ge=1, le=100),
|
|
358
|
+
offset: int = Query(0, ge=0),
|
|
359
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
360
|
+
session: AsyncSession = Depends(get_db_session),
|
|
361
|
+
) -> MemberListResponse:
|
|
362
|
+
"""List organization members."""
|
|
363
|
+
await _require_membership(org_id, auth, session)
|
|
364
|
+
|
|
365
|
+
count_result = await session.execute(
|
|
366
|
+
select(func.count()).select_from(
|
|
367
|
+
select(OrgMembership).where(OrgMembership.org_id == org_id).subquery()
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
total = count_result.scalar() or 0
|
|
371
|
+
|
|
372
|
+
result = await session.execute(
|
|
373
|
+
select(OrgMembership, User)
|
|
374
|
+
.join(User, OrgMembership.user_id == User.id)
|
|
375
|
+
.where(OrgMembership.org_id == org_id)
|
|
376
|
+
.offset(offset).limit(limit)
|
|
377
|
+
)
|
|
378
|
+
members = []
|
|
379
|
+
for membership, user in result:
|
|
380
|
+
members.append(MemberResponse(
|
|
381
|
+
user_id=str(user.id),
|
|
382
|
+
email=user.email,
|
|
383
|
+
name=user.name,
|
|
384
|
+
role=membership.role,
|
|
385
|
+
accepted=membership.accepted_at is not None,
|
|
386
|
+
))
|
|
387
|
+
return MemberListResponse(
|
|
388
|
+
members=members, total=total, limit=limit, offset=offset,
|
|
389
|
+
has_more=(offset + limit < total),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@router.patch("/{org_id}/members/{user_id}", response_model=MemberResponse)
|
|
394
|
+
async def update_member_role(
|
|
395
|
+
org_id: uuid.UUID,
|
|
396
|
+
user_id: uuid.UUID,
|
|
397
|
+
req: UpdateMemberRequest,
|
|
398
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
399
|
+
session: AsyncSession = Depends(get_db_session),
|
|
400
|
+
) -> MemberResponse:
|
|
401
|
+
"""Change a member's role. Requires owner role."""
|
|
402
|
+
await _require_membership(org_id, auth, session, min_role="owner")
|
|
403
|
+
|
|
404
|
+
if req.role not in ("member", "admin", "owner"):
|
|
405
|
+
raise HTTPException(status_code=400, detail="Invalid role")
|
|
406
|
+
|
|
407
|
+
result = await session.execute(
|
|
408
|
+
select(OrgMembership).where(
|
|
409
|
+
OrgMembership.org_id == org_id,
|
|
410
|
+
OrgMembership.user_id == user_id,
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
membership = result.scalar_one_or_none()
|
|
414
|
+
if membership is None:
|
|
415
|
+
raise HTTPException(status_code=404, detail="Member not found")
|
|
416
|
+
|
|
417
|
+
membership.role = req.role
|
|
418
|
+
await session.commit()
|
|
419
|
+
|
|
420
|
+
user_result = await session.execute(select(User).where(User.id == user_id))
|
|
421
|
+
user = user_result.scalar_one()
|
|
422
|
+
|
|
423
|
+
from attocode_intel.audit import log_event
|
|
424
|
+
|
|
425
|
+
await log_event(session, org_id, "member.role_changed", user_id=auth.user_id, detail={"target_user_id": str(user_id), "new_role": req.role})
|
|
426
|
+
|
|
427
|
+
return MemberResponse(
|
|
428
|
+
user_id=str(user.id),
|
|
429
|
+
email=user.email,
|
|
430
|
+
name=user.name,
|
|
431
|
+
role=membership.role,
|
|
432
|
+
accepted=membership.accepted_at is not None,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@router.delete("/{org_id}/members/{user_id}")
|
|
437
|
+
async def remove_member(
|
|
438
|
+
org_id: uuid.UUID,
|
|
439
|
+
user_id: uuid.UUID,
|
|
440
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
441
|
+
session: AsyncSession = Depends(get_db_session),
|
|
442
|
+
) -> dict:
|
|
443
|
+
"""Remove a member from the organization. Requires admin+ role."""
|
|
444
|
+
await _require_membership(org_id, auth, session, min_role="admin")
|
|
445
|
+
|
|
446
|
+
result = await session.execute(
|
|
447
|
+
select(OrgMembership).where(
|
|
448
|
+
OrgMembership.org_id == org_id,
|
|
449
|
+
OrgMembership.user_id == user_id,
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
membership = result.scalar_one_or_none()
|
|
453
|
+
if membership is None:
|
|
454
|
+
raise HTTPException(status_code=404, detail="Member not found")
|
|
455
|
+
|
|
456
|
+
if membership.role == "owner":
|
|
457
|
+
raise HTTPException(status_code=400, detail="Cannot remove the org owner")
|
|
458
|
+
|
|
459
|
+
await session.delete(membership)
|
|
460
|
+
|
|
461
|
+
from attocode_intel.audit import log_event
|
|
462
|
+
|
|
463
|
+
await log_event(session, org_id, "member.removed", user_id=auth.user_id, detail={"removed_user_id": str(user_id)})
|
|
464
|
+
|
|
465
|
+
await session.commit()
|
|
466
|
+
return {"detail": "Member removed"}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# --- Org-scoped Repositories ---
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@router.post("/{org_id}/repos", response_model=RepoResponse)
|
|
473
|
+
async def create_repo(
|
|
474
|
+
org_id: uuid.UUID,
|
|
475
|
+
req: CreateRepoRequest,
|
|
476
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
477
|
+
session: AsyncSession = Depends(get_db_session),
|
|
478
|
+
) -> RepoResponse:
|
|
479
|
+
"""Create a repository in the organization."""
|
|
480
|
+
await _require_membership(org_id, auth, session)
|
|
481
|
+
from attocode_intel.api.repository_source import validate_remote_source
|
|
482
|
+
validate_remote_source(req.clone_url, req.local_path)
|
|
483
|
+
|
|
484
|
+
# Check uniqueness
|
|
485
|
+
existing = await session.execute(
|
|
486
|
+
select(Repository).where(
|
|
487
|
+
Repository.org_id == org_id,
|
|
488
|
+
Repository.name == req.name,
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
if existing.scalar_one_or_none():
|
|
492
|
+
raise HTTPException(status_code=409, detail=f"Repository '{req.name}' already exists in this org")
|
|
493
|
+
|
|
494
|
+
repo = Repository(
|
|
495
|
+
org_id=org_id,
|
|
496
|
+
name=req.name,
|
|
497
|
+
clone_url=req.clone_url,
|
|
498
|
+
local_path=req.local_path,
|
|
499
|
+
default_branch=req.default_branch,
|
|
500
|
+
language=req.language,
|
|
501
|
+
)
|
|
502
|
+
session.add(repo)
|
|
503
|
+
await session.commit()
|
|
504
|
+
await session.refresh(repo)
|
|
505
|
+
|
|
506
|
+
# Only enqueue full index if we have a clone_url (server can clone it).
|
|
507
|
+
# local_path-only repos use incremental updates via notify/watch.
|
|
508
|
+
if repo.clone_url:
|
|
509
|
+
try:
|
|
510
|
+
from arq import create_pool
|
|
511
|
+
|
|
512
|
+
from attocode_intel.workers.settings import get_redis_settings
|
|
513
|
+
|
|
514
|
+
pool = await create_pool(get_redis_settings())
|
|
515
|
+
await pool.enqueue_job("index_repository", str(repo.id))
|
|
516
|
+
await pool.aclose()
|
|
517
|
+
repo.index_status = "indexing"
|
|
518
|
+
await session.commit()
|
|
519
|
+
await session.refresh(repo)
|
|
520
|
+
except Exception:
|
|
521
|
+
logger.warning("Failed to enqueue indexing job for repo %s", repo.id, exc_info=True)
|
|
522
|
+
|
|
523
|
+
return RepoResponse(
|
|
524
|
+
id=str(repo.id),
|
|
525
|
+
name=repo.name,
|
|
526
|
+
clone_url=repo.clone_url,
|
|
527
|
+
local_path=repo.local_path,
|
|
528
|
+
default_branch=repo.default_branch,
|
|
529
|
+
language=repo.language,
|
|
530
|
+
index_status=repo.index_status,
|
|
531
|
+
last_indexed_at=repo.last_indexed_at.isoformat() if repo.last_indexed_at else None,
|
|
532
|
+
created_at=repo.created_at.isoformat(),
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@router.get("/{org_id}/repos", response_model=RepoListResponse)
|
|
537
|
+
async def list_repos(
|
|
538
|
+
org_id: uuid.UUID,
|
|
539
|
+
limit: int = Query(20, ge=1, le=100),
|
|
540
|
+
offset: int = Query(0, ge=0),
|
|
541
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
542
|
+
session: AsyncSession = Depends(get_db_session),
|
|
543
|
+
) -> RepoListResponse:
|
|
544
|
+
"""List repositories in the organization."""
|
|
545
|
+
await _require_membership(org_id, auth, session)
|
|
546
|
+
|
|
547
|
+
count_result = await session.execute(
|
|
548
|
+
select(func.count()).select_from(
|
|
549
|
+
select(Repository).where(Repository.org_id == org_id).subquery()
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
total = count_result.scalar() or 0
|
|
553
|
+
|
|
554
|
+
result = await session.execute(
|
|
555
|
+
select(Repository).where(Repository.org_id == org_id)
|
|
556
|
+
.offset(offset).limit(limit)
|
|
557
|
+
)
|
|
558
|
+
repos = []
|
|
559
|
+
for repo in result.scalars():
|
|
560
|
+
repos.append(RepoResponse(
|
|
561
|
+
id=str(repo.id),
|
|
562
|
+
name=repo.name,
|
|
563
|
+
clone_url=repo.clone_url,
|
|
564
|
+
local_path=repo.local_path,
|
|
565
|
+
default_branch=repo.default_branch,
|
|
566
|
+
language=repo.language,
|
|
567
|
+
index_status=repo.index_status,
|
|
568
|
+
last_indexed_at=repo.last_indexed_at.isoformat() if repo.last_indexed_at else None,
|
|
569
|
+
created_at=repo.created_at.isoformat(),
|
|
570
|
+
))
|
|
571
|
+
return RepoListResponse(
|
|
572
|
+
repositories=repos, total=total, limit=limit, offset=offset,
|
|
573
|
+
has_more=(offset + limit < total),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@router.get("/{org_id}/repos/{repo_id}", response_model=RepoResponse)
|
|
578
|
+
async def get_repo(
|
|
579
|
+
org_id: uuid.UUID,
|
|
580
|
+
repo_id: uuid.UUID,
|
|
581
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
582
|
+
session: AsyncSession = Depends(get_db_session),
|
|
583
|
+
) -> RepoResponse:
|
|
584
|
+
"""Get repository details."""
|
|
585
|
+
await _require_membership(org_id, auth, session)
|
|
586
|
+
|
|
587
|
+
result = await session.execute(
|
|
588
|
+
select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
|
|
589
|
+
)
|
|
590
|
+
repo = result.scalar_one_or_none()
|
|
591
|
+
if repo is None:
|
|
592
|
+
raise HTTPException(status_code=404, detail="Repository not found")
|
|
593
|
+
|
|
594
|
+
return RepoResponse(
|
|
595
|
+
id=str(repo.id),
|
|
596
|
+
name=repo.name,
|
|
597
|
+
clone_url=repo.clone_url,
|
|
598
|
+
local_path=repo.local_path,
|
|
599
|
+
default_branch=repo.default_branch,
|
|
600
|
+
language=repo.language,
|
|
601
|
+
index_status=repo.index_status,
|
|
602
|
+
last_indexed_at=repo.last_indexed_at.isoformat() if repo.last_indexed_at else None,
|
|
603
|
+
created_at=repo.created_at.isoformat(),
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@router.delete("/{org_id}/repos/{repo_id}")
|
|
608
|
+
async def delete_repo(
|
|
609
|
+
org_id: uuid.UUID,
|
|
610
|
+
repo_id: uuid.UUID,
|
|
611
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
612
|
+
session: AsyncSession = Depends(get_db_session),
|
|
613
|
+
) -> dict:
|
|
614
|
+
"""Delete a repository. Requires admin+ role."""
|
|
615
|
+
await _require_membership(org_id, auth, session, min_role="admin")
|
|
616
|
+
|
|
617
|
+
result = await session.execute(
|
|
618
|
+
select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
|
|
619
|
+
)
|
|
620
|
+
repo = result.scalar_one_or_none()
|
|
621
|
+
if repo is None:
|
|
622
|
+
raise HTTPException(status_code=404, detail="Repository not found")
|
|
623
|
+
|
|
624
|
+
await session.delete(repo)
|
|
625
|
+
await session.commit()
|
|
626
|
+
return {"detail": "Repository deleted"}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
@router.patch("/{org_id}/repos/{repo_id}", response_model=RepoResponse)
|
|
630
|
+
async def update_repo(
|
|
631
|
+
org_id: uuid.UUID,
|
|
632
|
+
repo_id: uuid.UUID,
|
|
633
|
+
req: UpdateRepoRequest,
|
|
634
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
635
|
+
session: AsyncSession = Depends(get_db_session),
|
|
636
|
+
) -> RepoResponse:
|
|
637
|
+
"""Update mutable repository fields (admin+ required).
|
|
638
|
+
|
|
639
|
+
Supports renaming the repo, retargeting its ``clone_url``, changing
|
|
640
|
+
the default branch, or updating the declared language. Phase 3a
|
|
641
|
+
does NOT support moving a repo between orgs (that would require
|
|
642
|
+
re-checking quotas and audit logging across two orgs); use create +
|
|
643
|
+
delete for that case.
|
|
644
|
+
|
|
645
|
+
Returns the updated :class:`RepoResponse`.
|
|
646
|
+
"""
|
|
647
|
+
await _require_membership(org_id, auth, session, min_role="admin")
|
|
648
|
+
|
|
649
|
+
result = await session.execute(
|
|
650
|
+
select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
|
|
651
|
+
)
|
|
652
|
+
repo = result.scalar_one_or_none()
|
|
653
|
+
if repo is None:
|
|
654
|
+
raise HTTPException(status_code=404, detail="Repository not found")
|
|
655
|
+
|
|
656
|
+
changes: dict[str, dict[str, str | None]] = {}
|
|
657
|
+
|
|
658
|
+
# Rename: enforce uniqueness within the org.
|
|
659
|
+
if req.name is not None and req.name != repo.name:
|
|
660
|
+
if not req.name:
|
|
661
|
+
raise HTTPException(status_code=422, detail="name cannot be empty")
|
|
662
|
+
existing = await session.execute(
|
|
663
|
+
select(Repository).where(
|
|
664
|
+
Repository.org_id == org_id,
|
|
665
|
+
Repository.name == req.name,
|
|
666
|
+
Repository.id != repo_id,
|
|
667
|
+
)
|
|
668
|
+
)
|
|
669
|
+
if existing.scalar_one_or_none():
|
|
670
|
+
raise HTTPException(
|
|
671
|
+
status_code=409,
|
|
672
|
+
detail=f"Repository '{req.name}' already exists in this org",
|
|
673
|
+
)
|
|
674
|
+
changes["name"] = {"from": repo.name, "to": req.name}
|
|
675
|
+
repo.name = req.name
|
|
676
|
+
|
|
677
|
+
from attocode_intel.api.repository_source import validate_remote_source
|
|
678
|
+
validate_remote_source(req.clone_url, req.local_path)
|
|
679
|
+
if req.clone_url is not None and req.clone_url != repo.clone_url:
|
|
680
|
+
changes["clone_url"] = {"from": repo.clone_url, "to": req.clone_url or None}
|
|
681
|
+
repo.clone_url = req.clone_url or None
|
|
682
|
+
|
|
683
|
+
if req.default_branch is not None and req.default_branch != repo.default_branch:
|
|
684
|
+
if not req.default_branch:
|
|
685
|
+
raise HTTPException(status_code=422, detail="default_branch cannot be empty")
|
|
686
|
+
changes["default_branch"] = {
|
|
687
|
+
"from": repo.default_branch,
|
|
688
|
+
"to": req.default_branch,
|
|
689
|
+
}
|
|
690
|
+
repo.default_branch = req.default_branch
|
|
691
|
+
|
|
692
|
+
if req.language is not None and req.language != repo.language:
|
|
693
|
+
changes["language"] = {"from": repo.language, "to": req.language or None}
|
|
694
|
+
repo.language = req.language or None
|
|
695
|
+
|
|
696
|
+
if req.local_path is not None and req.local_path != repo.local_path:
|
|
697
|
+
changes["local_path"] = {"from": repo.local_path, "to": req.local_path or None}
|
|
698
|
+
repo.local_path = req.local_path or None
|
|
699
|
+
|
|
700
|
+
# Optional cross-org move. Rejected if the caller doesn't hold admin
|
|
701
|
+
# in the target org, or if the target already has a repo with the
|
|
702
|
+
# same (post-rename) name.
|
|
703
|
+
moved_from_org: uuid.UUID | None = None
|
|
704
|
+
if req.target_org_id is not None and req.target_org_id != org_id:
|
|
705
|
+
# Verify target org membership (admin+).
|
|
706
|
+
await _require_membership(
|
|
707
|
+
req.target_org_id, auth, session, min_role="admin",
|
|
708
|
+
)
|
|
709
|
+
# Check name collision in the target org under the new name.
|
|
710
|
+
target_name = repo.name # already reflects any rename above
|
|
711
|
+
existing = await session.execute(
|
|
712
|
+
select(Repository).where(
|
|
713
|
+
Repository.org_id == req.target_org_id,
|
|
714
|
+
Repository.name == target_name,
|
|
715
|
+
Repository.id != repo_id,
|
|
716
|
+
)
|
|
717
|
+
)
|
|
718
|
+
if existing.scalar_one_or_none():
|
|
719
|
+
raise HTTPException(
|
|
720
|
+
status_code=409,
|
|
721
|
+
detail=(
|
|
722
|
+
f"Target org already has a repository named "
|
|
723
|
+
f"{target_name!r}; rename before moving."
|
|
724
|
+
),
|
|
725
|
+
)
|
|
726
|
+
moved_from_org = repo.org_id
|
|
727
|
+
changes["org_id"] = {
|
|
728
|
+
"from": str(moved_from_org),
|
|
729
|
+
"to": str(req.target_org_id),
|
|
730
|
+
}
|
|
731
|
+
repo.org_id = req.target_org_id
|
|
732
|
+
|
|
733
|
+
if changes:
|
|
734
|
+
from attocode_intel.audit import log_event
|
|
735
|
+
|
|
736
|
+
await log_event(
|
|
737
|
+
session,
|
|
738
|
+
org_id,
|
|
739
|
+
"repo.updated",
|
|
740
|
+
repo_id=repo_id,
|
|
741
|
+
user_id=auth.user_id,
|
|
742
|
+
detail={"changes": changes},
|
|
743
|
+
)
|
|
744
|
+
if moved_from_org is not None:
|
|
745
|
+
# Emit a distinct ``repo.moved`` event in the TARGET org so
|
|
746
|
+
# target-org admins see the move in their audit feed.
|
|
747
|
+
await log_event(
|
|
748
|
+
session,
|
|
749
|
+
req.target_org_id, # type: ignore[arg-type]
|
|
750
|
+
"repo.moved",
|
|
751
|
+
repo_id=repo_id,
|
|
752
|
+
user_id=auth.user_id,
|
|
753
|
+
detail={
|
|
754
|
+
"from_org_id": str(moved_from_org),
|
|
755
|
+
"to_org_id": str(req.target_org_id),
|
|
756
|
+
"name": repo.name,
|
|
757
|
+
},
|
|
758
|
+
)
|
|
759
|
+
await session.commit()
|
|
760
|
+
await session.refresh(repo)
|
|
761
|
+
|
|
762
|
+
return RepoResponse(
|
|
763
|
+
id=str(repo.id),
|
|
764
|
+
name=repo.name,
|
|
765
|
+
clone_url=repo.clone_url,
|
|
766
|
+
local_path=repo.local_path,
|
|
767
|
+
default_branch=repo.default_branch,
|
|
768
|
+
language=repo.language,
|
|
769
|
+
index_status=repo.index_status,
|
|
770
|
+
last_indexed_at=repo.last_indexed_at.isoformat() if repo.last_indexed_at else None,
|
|
771
|
+
created_at=repo.created_at.isoformat(),
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@router.post("/{org_id}/repos/{repo_id}/reindex")
|
|
776
|
+
async def reindex_repo(
|
|
777
|
+
org_id: uuid.UUID,
|
|
778
|
+
repo_id: uuid.UUID,
|
|
779
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
780
|
+
session: AsyncSession = Depends(get_db_session),
|
|
781
|
+
) -> dict:
|
|
782
|
+
"""Trigger a reindex of the repository."""
|
|
783
|
+
await _require_membership(org_id, auth, session)
|
|
784
|
+
|
|
785
|
+
result = await session.execute(
|
|
786
|
+
select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
|
|
787
|
+
)
|
|
788
|
+
repo = result.scalar_one_or_none()
|
|
789
|
+
if repo is None:
|
|
790
|
+
raise HTTPException(status_code=404, detail="Repository not found")
|
|
791
|
+
|
|
792
|
+
try:
|
|
793
|
+
from arq import create_pool
|
|
794
|
+
|
|
795
|
+
from attocode_intel.workers.settings import get_redis_settings
|
|
796
|
+
|
|
797
|
+
pool = await create_pool(get_redis_settings())
|
|
798
|
+
await pool.enqueue_job("index_repository", str(repo_id))
|
|
799
|
+
await pool.aclose()
|
|
800
|
+
except Exception:
|
|
801
|
+
logger.warning("Failed to enqueue reindex job for repo %s", repo_id, exc_info=True)
|
|
802
|
+
raise HTTPException(status_code=503, detail="Failed to enqueue reindex job")
|
|
803
|
+
|
|
804
|
+
repo.index_status = "indexing"
|
|
805
|
+
await session.commit()
|
|
806
|
+
|
|
807
|
+
return {"detail": "Reindex triggered", "repo_id": str(repo_id)}
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
# --- Repo Credential Management ---
|
|
811
|
+
|
|
812
|
+
_VALID_CRED_TYPES = {"pat", "deploy_token", "ssh_key"}
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
class SetCredentialRequest(BaseModel):
|
|
816
|
+
cred_type: str # pat|deploy_token|ssh_key
|
|
817
|
+
value: str
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
@router.post("/{org_id}/repos/{repo_id}/credentials")
|
|
821
|
+
async def set_repo_credential(
|
|
822
|
+
org_id: uuid.UUID,
|
|
823
|
+
repo_id: uuid.UUID,
|
|
824
|
+
body: SetCredentialRequest,
|
|
825
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
826
|
+
session: AsyncSession = Depends(get_db_session),
|
|
827
|
+
) -> dict:
|
|
828
|
+
"""Set or replace credential for a private repository (admin+)."""
|
|
829
|
+
await _require_membership(org_id, auth, session, min_role="admin")
|
|
830
|
+
|
|
831
|
+
if body.cred_type not in _VALID_CRED_TYPES:
|
|
832
|
+
raise HTTPException(
|
|
833
|
+
status_code=422,
|
|
834
|
+
detail=f"Invalid cred_type. Must be one of: {', '.join(sorted(_VALID_CRED_TYPES))}",
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
result = await session.execute(
|
|
838
|
+
select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
|
|
839
|
+
)
|
|
840
|
+
repo = result.scalar_one_or_none()
|
|
841
|
+
if repo is None:
|
|
842
|
+
raise HTTPException(status_code=404, detail="Repository not found")
|
|
843
|
+
|
|
844
|
+
from attocode_intel.crypto import encrypt_credential
|
|
845
|
+
from attocode_intel.db.models import RepoCredential
|
|
846
|
+
|
|
847
|
+
# Remove any existing credential for this repo
|
|
848
|
+
existing = await session.execute(
|
|
849
|
+
select(RepoCredential).where(RepoCredential.repo_id == repo_id)
|
|
850
|
+
)
|
|
851
|
+
for old_cred in existing.scalars().all():
|
|
852
|
+
await session.delete(old_cred)
|
|
853
|
+
|
|
854
|
+
cred = RepoCredential(
|
|
855
|
+
repo_id=repo_id,
|
|
856
|
+
cred_type=body.cred_type,
|
|
857
|
+
encrypted_value=encrypt_credential(body.value),
|
|
858
|
+
)
|
|
859
|
+
session.add(cred)
|
|
860
|
+
await session.commit()
|
|
861
|
+
|
|
862
|
+
return {"detail": "Credential set", "cred_type": body.cred_type}
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
@router.get("/{org_id}/repos/{repo_id}/credentials")
|
|
866
|
+
async def get_repo_credential_status(
|
|
867
|
+
org_id: uuid.UUID,
|
|
868
|
+
repo_id: uuid.UUID,
|
|
869
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
870
|
+
session: AsyncSession = Depends(get_db_session),
|
|
871
|
+
) -> dict:
|
|
872
|
+
"""Check whether a credential is configured for a repo (admin+). Never returns the value."""
|
|
873
|
+
await _require_membership(org_id, auth, session, min_role="admin")
|
|
874
|
+
|
|
875
|
+
result = await session.execute(
|
|
876
|
+
select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
|
|
877
|
+
)
|
|
878
|
+
if result.scalar_one_or_none() is None:
|
|
879
|
+
raise HTTPException(status_code=404, detail="Repository not found")
|
|
880
|
+
|
|
881
|
+
from attocode_intel.db.models import RepoCredential
|
|
882
|
+
|
|
883
|
+
result = await session.execute(
|
|
884
|
+
select(RepoCredential).where(RepoCredential.repo_id == repo_id).limit(1)
|
|
885
|
+
)
|
|
886
|
+
cred = result.scalar_one_or_none()
|
|
887
|
+
if cred is None:
|
|
888
|
+
return {"configured": False}
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
"configured": True,
|
|
892
|
+
"cred_type": cred.cred_type,
|
|
893
|
+
"created_at": cred.created_at.isoformat(),
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
@router.delete("/{org_id}/repos/{repo_id}/credentials")
|
|
898
|
+
async def delete_repo_credential(
|
|
899
|
+
org_id: uuid.UUID,
|
|
900
|
+
repo_id: uuid.UUID,
|
|
901
|
+
auth: AuthContext = Depends(resolve_auth),
|
|
902
|
+
session: AsyncSession = Depends(get_db_session),
|
|
903
|
+
) -> dict:
|
|
904
|
+
"""Remove credential for a repository (admin+)."""
|
|
905
|
+
await _require_membership(org_id, auth, session, min_role="admin")
|
|
906
|
+
|
|
907
|
+
result = await session.execute(
|
|
908
|
+
select(Repository).where(Repository.id == repo_id, Repository.org_id == org_id)
|
|
909
|
+
)
|
|
910
|
+
if result.scalar_one_or_none() is None:
|
|
911
|
+
raise HTTPException(status_code=404, detail="Repository not found")
|
|
912
|
+
|
|
913
|
+
from attocode_intel.db.models import RepoCredential
|
|
914
|
+
|
|
915
|
+
existing = await session.execute(
|
|
916
|
+
select(RepoCredential).where(RepoCredential.repo_id == repo_id)
|
|
917
|
+
)
|
|
918
|
+
deleted = 0
|
|
919
|
+
for cred in existing.scalars().all():
|
|
920
|
+
await session.delete(cred)
|
|
921
|
+
deleted += 1
|
|
922
|
+
|
|
923
|
+
await session.commit()
|
|
924
|
+
return {"detail": "Credential removed" if deleted else "No credential configured"}
|