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,313 @@
|
|
|
1
|
+
"""Webhook endpoints for GitHub/GitLab/Bitbucket push events (service mode only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
|
+
|
|
17
|
+
from attocode_intel.api.deps import get_db_session
|
|
18
|
+
from attocode_intel.db.models import Repository, WebhookConfig
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from cryptography.fernet import Fernet
|
|
22
|
+
|
|
23
|
+
router = APIRouter(prefix="/api/v1/webhooks", tags=["webhooks"])
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_fernet() -> Fernet:
|
|
28
|
+
"""Get Fernet instance using SECRET_KEY from config."""
|
|
29
|
+
from attocode_intel.crypto import _get_fernet as _shared_get_fernet
|
|
30
|
+
|
|
31
|
+
return _shared_get_fernet()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _encrypt_secret(secret: str) -> str:
|
|
35
|
+
"""Encrypt a webhook secret for storage."""
|
|
36
|
+
return _get_fernet().encrypt(secret.encode()).decode()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _decrypt_secret(encrypted: str) -> str:
|
|
40
|
+
"""Decrypt a stored webhook secret."""
|
|
41
|
+
return _get_fernet().decrypt(encrypted.encode()).decode()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WebhookConfigRequest(BaseModel):
|
|
45
|
+
repo_id: str
|
|
46
|
+
provider: str # github|gitlab|bitbucket
|
|
47
|
+
secret: str
|
|
48
|
+
events: list[str] = ["push"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class WebhookConfigResponse(BaseModel):
|
|
52
|
+
id: str
|
|
53
|
+
repo_id: str
|
|
54
|
+
provider: str
|
|
55
|
+
events: list[str]
|
|
56
|
+
active: bool
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.post("/config", response_model=WebhookConfigResponse)
|
|
60
|
+
async def create_webhook_config(
|
|
61
|
+
req: WebhookConfigRequest,
|
|
62
|
+
session: AsyncSession = Depends(get_db_session),
|
|
63
|
+
) -> WebhookConfigResponse:
|
|
64
|
+
"""Create a webhook configuration for a repository."""
|
|
65
|
+
repo_result = await session.execute(
|
|
66
|
+
select(Repository).where(Repository.id == uuid.UUID(req.repo_id))
|
|
67
|
+
)
|
|
68
|
+
repo = repo_result.scalar_one_or_none()
|
|
69
|
+
if repo is None:
|
|
70
|
+
raise HTTPException(status_code=404, detail="Repository not found")
|
|
71
|
+
|
|
72
|
+
secret_hash = hashlib.sha256(req.secret.encode()).hexdigest()
|
|
73
|
+
secret_encrypted = _encrypt_secret(req.secret)
|
|
74
|
+
config = WebhookConfig(
|
|
75
|
+
repo_id=repo.id,
|
|
76
|
+
provider=req.provider,
|
|
77
|
+
secret_hash=secret_hash,
|
|
78
|
+
secret_encrypted=secret_encrypted,
|
|
79
|
+
events=req.events,
|
|
80
|
+
)
|
|
81
|
+
session.add(config)
|
|
82
|
+
await session.commit()
|
|
83
|
+
await session.refresh(config)
|
|
84
|
+
|
|
85
|
+
return WebhookConfigResponse(
|
|
86
|
+
id=str(config.id),
|
|
87
|
+
repo_id=str(config.repo_id),
|
|
88
|
+
provider=config.provider,
|
|
89
|
+
events=list(config.events),
|
|
90
|
+
active=config.active,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@router.post("/github")
|
|
95
|
+
async def github_webhook(
|
|
96
|
+
request: Request,
|
|
97
|
+
x_hub_signature_256: str | None = Header(None),
|
|
98
|
+
x_github_event: str | None = Header(None),
|
|
99
|
+
session: AsyncSession = Depends(get_db_session),
|
|
100
|
+
) -> dict:
|
|
101
|
+
"""Handle GitHub webhook events."""
|
|
102
|
+
body = await request.body()
|
|
103
|
+
|
|
104
|
+
# Parse payload
|
|
105
|
+
try:
|
|
106
|
+
payload = json.loads(body)
|
|
107
|
+
except json.JSONDecodeError:
|
|
108
|
+
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
|
109
|
+
|
|
110
|
+
# Identify repository
|
|
111
|
+
repo_url = payload.get("repository", {}).get("clone_url", "")
|
|
112
|
+
if not repo_url:
|
|
113
|
+
repo_url = payload.get("repository", {}).get("html_url", "")
|
|
114
|
+
|
|
115
|
+
if not repo_url:
|
|
116
|
+
raise HTTPException(status_code=400, detail="Cannot identify repository from payload")
|
|
117
|
+
|
|
118
|
+
# Find matching webhook config
|
|
119
|
+
result = await session.execute(
|
|
120
|
+
select(WebhookConfig).where(
|
|
121
|
+
WebhookConfig.provider == "github",
|
|
122
|
+
WebhookConfig.active == True, # noqa: E712
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
webhook = None
|
|
126
|
+
for wh in result.scalars():
|
|
127
|
+
# Verify the repo matches
|
|
128
|
+
repo_result = await session.execute(
|
|
129
|
+
select(Repository).where(Repository.id == wh.repo_id)
|
|
130
|
+
)
|
|
131
|
+
repo = repo_result.scalar_one_or_none()
|
|
132
|
+
if repo and repo.clone_url and repo_url in (repo.clone_url, repo.clone_url.rstrip(".git")):
|
|
133
|
+
webhook = wh
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
if webhook is None:
|
|
137
|
+
raise HTTPException(status_code=404, detail="No matching webhook config found")
|
|
138
|
+
|
|
139
|
+
# Verify signature using decrypted secret
|
|
140
|
+
if x_hub_signature_256:
|
|
141
|
+
if webhook.secret_encrypted:
|
|
142
|
+
secret = _decrypt_secret(webhook.secret_encrypted)
|
|
143
|
+
_verify_github_signature(body, x_hub_signature_256, secret)
|
|
144
|
+
else:
|
|
145
|
+
# Legacy fallback: no encrypted secret stored yet (pre-migration data)
|
|
146
|
+
logger.warning("Webhook %s has no encrypted secret; signature verification skipped", webhook.id)
|
|
147
|
+
|
|
148
|
+
# Log webhook received
|
|
149
|
+
from attocode_intel.audit import log_event
|
|
150
|
+
|
|
151
|
+
repo_result2 = await session.execute(
|
|
152
|
+
select(Repository).where(Repository.id == webhook.repo_id)
|
|
153
|
+
)
|
|
154
|
+
wh_repo = repo_result2.scalar_one_or_none()
|
|
155
|
+
if wh_repo:
|
|
156
|
+
await log_event(session, wh_repo.org_id, "webhook.received", repo_id=webhook.repo_id, detail={"provider": "github", "event": x_github_event or "push"})
|
|
157
|
+
await session.commit()
|
|
158
|
+
|
|
159
|
+
# Handle events
|
|
160
|
+
event = x_github_event or "push"
|
|
161
|
+
if event == "push":
|
|
162
|
+
ref = payload.get("ref", "")
|
|
163
|
+
branch = ref.removeprefix("refs/heads/")
|
|
164
|
+
before = payload.get("before", "")
|
|
165
|
+
after = payload.get("after", "")
|
|
166
|
+
|
|
167
|
+
logger.info("GitHub push: %s %s → %s", branch, before[:8], after[:8])
|
|
168
|
+
|
|
169
|
+
# Enqueue delta index job
|
|
170
|
+
try:
|
|
171
|
+
from arq import create_pool
|
|
172
|
+
|
|
173
|
+
from attocode_intel.workers.settings import get_redis_settings
|
|
174
|
+
|
|
175
|
+
pool = await create_pool(get_redis_settings())
|
|
176
|
+
await pool.enqueue_job(
|
|
177
|
+
"index_branch_delta",
|
|
178
|
+
str(webhook.repo_id),
|
|
179
|
+
branch,
|
|
180
|
+
before,
|
|
181
|
+
after,
|
|
182
|
+
)
|
|
183
|
+
await pool.aclose()
|
|
184
|
+
return {"status": "queued", "branch": branch, "job": "index_branch_delta"}
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.warning("Failed to enqueue job: %s", e)
|
|
187
|
+
return {"status": "received", "branch": branch, "note": "Job queuing unavailable"}
|
|
188
|
+
|
|
189
|
+
elif event == "pull_request":
|
|
190
|
+
action = payload.get("action", "")
|
|
191
|
+
pr = payload.get("pull_request", {})
|
|
192
|
+
merged = pr.get("merged", False)
|
|
193
|
+
|
|
194
|
+
if action == "closed" and merged:
|
|
195
|
+
# Auto-trigger merge + delete source branch in DB
|
|
196
|
+
base_branch = pr.get("base", {}).get("ref", "")
|
|
197
|
+
head_branch = pr.get("head", {}).get("ref", "")
|
|
198
|
+
|
|
199
|
+
if base_branch and head_branch:
|
|
200
|
+
logger.info("GitHub PR merged: %s → %s", head_branch, base_branch)
|
|
201
|
+
try:
|
|
202
|
+
from attocode_intel.db.models import Branch
|
|
203
|
+
from attocode_intel.storage.branch_overlay import BranchOverlay
|
|
204
|
+
|
|
205
|
+
repo_result3 = await session.execute(
|
|
206
|
+
select(Repository).where(Repository.id == webhook.repo_id)
|
|
207
|
+
)
|
|
208
|
+
merge_repo = repo_result3.scalar_one_or_none()
|
|
209
|
+
if merge_repo:
|
|
210
|
+
src_result = await session.execute(
|
|
211
|
+
select(Branch).where(
|
|
212
|
+
Branch.repo_id == merge_repo.id, Branch.name == head_branch
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
tgt_result = await session.execute(
|
|
216
|
+
select(Branch).where(
|
|
217
|
+
Branch.repo_id == merge_repo.id, Branch.name == base_branch
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
src_branch = src_result.scalar_one_or_none()
|
|
221
|
+
tgt_branch = tgt_result.scalar_one_or_none()
|
|
222
|
+
|
|
223
|
+
if src_branch and tgt_branch:
|
|
224
|
+
overlay = BranchOverlay(session)
|
|
225
|
+
await overlay.merge_branch(
|
|
226
|
+
src_branch.id, tgt_branch.id, delete_source=True,
|
|
227
|
+
)
|
|
228
|
+
await session.commit()
|
|
229
|
+
return {
|
|
230
|
+
"status": "merged",
|
|
231
|
+
"source": head_branch,
|
|
232
|
+
"target": base_branch,
|
|
233
|
+
}
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.warning("Auto-merge failed for PR: %s", e)
|
|
236
|
+
|
|
237
|
+
return {"status": "received", "event": "pull_request", "action": action}
|
|
238
|
+
|
|
239
|
+
return {"status": "ignored", "event": event}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@router.post("/gitlab")
|
|
243
|
+
async def gitlab_webhook(
|
|
244
|
+
request: Request,
|
|
245
|
+
x_gitlab_token: str | None = Header(None),
|
|
246
|
+
session: AsyncSession = Depends(get_db_session),
|
|
247
|
+
) -> dict:
|
|
248
|
+
"""Handle GitLab webhook events."""
|
|
249
|
+
body = await request.body()
|
|
250
|
+
payload = json.loads(body)
|
|
251
|
+
|
|
252
|
+
# GitLab uses a shared secret token
|
|
253
|
+
if not x_gitlab_token:
|
|
254
|
+
raise HTTPException(status_code=401, detail="X-Gitlab-Token header required")
|
|
255
|
+
|
|
256
|
+
token_hash = hashlib.sha256(x_gitlab_token.encode()).hexdigest()
|
|
257
|
+
result = await session.execute(
|
|
258
|
+
select(WebhookConfig).where(
|
|
259
|
+
WebhookConfig.provider == "gitlab",
|
|
260
|
+
WebhookConfig.secret_hash == token_hash,
|
|
261
|
+
WebhookConfig.active == True, # noqa: E712
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
webhook = result.scalar_one_or_none()
|
|
265
|
+
if webhook is None:
|
|
266
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
267
|
+
|
|
268
|
+
event_type = payload.get("object_kind", "push")
|
|
269
|
+
if event_type == "push":
|
|
270
|
+
ref = payload.get("ref", "")
|
|
271
|
+
branch = ref.removeprefix("refs/heads/")
|
|
272
|
+
before = payload.get("before", "")
|
|
273
|
+
after = payload.get("after", "")
|
|
274
|
+
|
|
275
|
+
logger.info("GitLab push: %s %s → %s", branch, before[:8], after[:8])
|
|
276
|
+
return {"status": "received", "branch": branch}
|
|
277
|
+
|
|
278
|
+
return {"status": "ignored", "event": event_type}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@router.post("/bitbucket")
|
|
282
|
+
async def bitbucket_webhook(
|
|
283
|
+
request: Request,
|
|
284
|
+
session: AsyncSession = Depends(get_db_session),
|
|
285
|
+
) -> dict:
|
|
286
|
+
"""Handle Bitbucket webhook events (Cloud)."""
|
|
287
|
+
body = await request.body()
|
|
288
|
+
payload = json.loads(body)
|
|
289
|
+
|
|
290
|
+
# Bitbucket Cloud uses a shared UUID key in the URL or body
|
|
291
|
+
event = request.headers.get("x-event-key", "repo:push")
|
|
292
|
+
|
|
293
|
+
if event == "repo:push":
|
|
294
|
+
changes = payload.get("push", {}).get("changes", [])
|
|
295
|
+
for change in changes:
|
|
296
|
+
new = change.get("new", {})
|
|
297
|
+
branch = new.get("name", "")
|
|
298
|
+
commit = new.get("target", {}).get("hash", "")
|
|
299
|
+
logger.info("Bitbucket push: %s → %s", branch, commit[:8])
|
|
300
|
+
|
|
301
|
+
return {"status": "received", "changes": len(changes)}
|
|
302
|
+
|
|
303
|
+
return {"status": "ignored", "event": event}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _verify_github_signature(body: bytes, signature_header: str, secret: str) -> None:
|
|
307
|
+
"""Verify GitHub webhook HMAC-SHA256 signature."""
|
|
308
|
+
if not signature_header.startswith("sha256="):
|
|
309
|
+
raise HTTPException(status_code=401, detail="Invalid signature format")
|
|
310
|
+
|
|
311
|
+
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
312
|
+
if not hmac.compare_digest(f"sha256={expected}", signature_header):
|
|
313
|
+
raise HTTPException(status_code=401, detail="Invalid signature")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""WebSocket endpoints for real-time event streaming + presence (service mode only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
|
10
|
+
|
|
11
|
+
router = APIRouter(tags=["websocket"])
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.websocket("/ws/repos/{repo_id}/events")
|
|
16
|
+
async def repo_events(
|
|
17
|
+
websocket: WebSocket,
|
|
18
|
+
repo_id: uuid.UUID,
|
|
19
|
+
token: str = Query(""),
|
|
20
|
+
last_event_id: str = Query("$", alias="last_event_id"),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Stream real-time events for a repository with bidirectional presence support.
|
|
23
|
+
|
|
24
|
+
Authenticate via query parameter `token` (JWT).
|
|
25
|
+
Pass `last_event_id` to replay missed events (e.g. after reconnect).
|
|
26
|
+
Use "$" for new events only, or "0" to replay all available events.
|
|
27
|
+
|
|
28
|
+
Outgoing events: index.started, index.progress, index.completed, index.failed,
|
|
29
|
+
presence.joined, presence.left, presence.file_changed.
|
|
30
|
+
|
|
31
|
+
Incoming messages:
|
|
32
|
+
{"type": "presence.update", "file": "src/main.py"}
|
|
33
|
+
{"type": "presence.heartbeat"}
|
|
34
|
+
"""
|
|
35
|
+
# Validate auth
|
|
36
|
+
user_id = ""
|
|
37
|
+
user_name = ""
|
|
38
|
+
if token:
|
|
39
|
+
from attocode_intel.api.auth.jwt import decode_token
|
|
40
|
+
|
|
41
|
+
payload = decode_token(token)
|
|
42
|
+
if payload is None:
|
|
43
|
+
await websocket.close(code=4001, reason="Invalid token")
|
|
44
|
+
return
|
|
45
|
+
user_id = payload.get("sub", "")
|
|
46
|
+
user_name = payload.get("name", user_id)
|
|
47
|
+
else:
|
|
48
|
+
# Allow unauthenticated in development
|
|
49
|
+
from attocode_intel.api.deps import get_config
|
|
50
|
+
|
|
51
|
+
config = get_config()
|
|
52
|
+
if config.api_key or config.is_service_mode:
|
|
53
|
+
await websocket.close(code=4001, reason="Token required")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
await websocket.accept()
|
|
57
|
+
session_id = str(uuid.uuid4())
|
|
58
|
+
repo_id_str = str(repo_id)
|
|
59
|
+
logger.info("WebSocket connected for repo %s (user=%s)", repo_id, user_id)
|
|
60
|
+
|
|
61
|
+
async def _pubsub_reader() -> None:
|
|
62
|
+
"""Read events from Redis pubsub and forward to WebSocket."""
|
|
63
|
+
from attocode_intel.pubsub import subscribe
|
|
64
|
+
|
|
65
|
+
async for event in subscribe(repo_id_str, last_event_id=last_event_id):
|
|
66
|
+
await websocket.send_json(event)
|
|
67
|
+
|
|
68
|
+
async def _ws_reader() -> None:
|
|
69
|
+
"""Read incoming messages from WebSocket for presence updates."""
|
|
70
|
+
while True:
|
|
71
|
+
try:
|
|
72
|
+
data = await websocket.receive_json()
|
|
73
|
+
except (WebSocketDisconnect, RuntimeError):
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
msg_type = data.get("type", "")
|
|
77
|
+
if msg_type == "presence.update":
|
|
78
|
+
file_path = data.get("file", "")
|
|
79
|
+
if user_id:
|
|
80
|
+
from attocode_intel.presence import set_presence
|
|
81
|
+
|
|
82
|
+
await set_presence(
|
|
83
|
+
repo_id_str, user_id, user_name, file_path, session_id,
|
|
84
|
+
)
|
|
85
|
+
elif msg_type == "presence.heartbeat":
|
|
86
|
+
if user_id:
|
|
87
|
+
from attocode_intel.presence import heartbeat
|
|
88
|
+
|
|
89
|
+
await heartbeat(repo_id_str, user_id, session_id)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
# Run both tasks concurrently
|
|
93
|
+
pubsub_task = asyncio.create_task(_pubsub_reader())
|
|
94
|
+
ws_task = asyncio.create_task(_ws_reader())
|
|
95
|
+
done, pending = await asyncio.wait(
|
|
96
|
+
[pubsub_task, ws_task], return_when=asyncio.FIRST_COMPLETED,
|
|
97
|
+
)
|
|
98
|
+
for task in pending:
|
|
99
|
+
task.cancel()
|
|
100
|
+
except WebSocketDisconnect:
|
|
101
|
+
logger.info("WebSocket disconnected for repo %s", repo_id)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.warning("WebSocket error for repo %s: %s", repo_id, e)
|
|
104
|
+
try:
|
|
105
|
+
await websocket.close(code=1011, reason="Internal error")
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
finally:
|
|
109
|
+
# Clean up presence on disconnect
|
|
110
|
+
if user_id:
|
|
111
|
+
try:
|
|
112
|
+
from attocode_intel.presence import remove_presence
|
|
113
|
+
|
|
114
|
+
await remove_presence(repo_id_str, user_id, session_id)
|
|
115
|
+
except Exception:
|
|
116
|
+
logger.debug("Failed to remove presence on disconnect", exc_info=True)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Code Intelligence — Dependency Graph</title>
|
|
7
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
8
|
+
<style>
|
|
9
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
10
|
+
body{font-family:system-ui,-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;overflow:hidden}
|
|
11
|
+
#controls{position:fixed;top:12px;left:12px;z-index:10;display:flex;gap:8px;align-items:center;
|
|
12
|
+
background:#161b22;border:1px solid #30363d;border-radius:8px;padding:8px 12px;font-size:13px}
|
|
13
|
+
#controls label{color:#8b949e}
|
|
14
|
+
#controls input[type=range]{width:80px}
|
|
15
|
+
#controls input[type=text]{background:#0d1117;border:1px solid #30363d;color:#c9d1d9;
|
|
16
|
+
border-radius:4px;padding:2px 8px;width:160px;font-size:13px}
|
|
17
|
+
#controls button{background:#238636;color:#fff;border:none;border-radius:4px;padding:4px 10px;
|
|
18
|
+
cursor:pointer;font-size:12px}
|
|
19
|
+
#controls button:hover{background:#2ea043}
|
|
20
|
+
#controls button.secondary{background:#30363d}
|
|
21
|
+
#controls button.secondary:hover{background:#484f58}
|
|
22
|
+
#info{position:fixed;bottom:12px;left:12px;z-index:10;background:#161b22;border:1px solid #30363d;
|
|
23
|
+
border-radius:8px;padding:8px 12px;font-size:12px;color:#8b949e;max-width:300px}
|
|
24
|
+
#tooltip{position:fixed;pointer-events:none;z-index:20;background:#1c2128;border:1px solid #30363d;
|
|
25
|
+
border-radius:6px;padding:8px 12px;font-size:12px;display:none;color:#c9d1d9;
|
|
26
|
+
box-shadow:0 4px 12px rgba(0,0,0,.4)}
|
|
27
|
+
#tooltip .tt-path{font-weight:bold;margin-bottom:4px}
|
|
28
|
+
#tooltip .tt-line{color:#8b949e}
|
|
29
|
+
svg{display:block;width:100vw;height:100vh}
|
|
30
|
+
.link{stroke-opacity:.4}
|
|
31
|
+
.link:hover{stroke-opacity:.8}
|
|
32
|
+
.node-circle{stroke-width:1.5;cursor:pointer}
|
|
33
|
+
.node-label{font-size:10px;fill:#8b949e;pointer-events:none;text-anchor:middle}
|
|
34
|
+
</style>
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<div id="controls">
|
|
38
|
+
<label>Depth</label>
|
|
39
|
+
<input type="range" id="depthSlider" min="1" max="6" value="3">
|
|
40
|
+
<span id="depthVal">3</span>
|
|
41
|
+
<label>Max</label>
|
|
42
|
+
<input type="range" id="maxSlider" min="20" max="300" value="100" step="10">
|
|
43
|
+
<span id="maxVal">100</span>
|
|
44
|
+
<input type="text" id="searchBox" placeholder="Search files...">
|
|
45
|
+
<button id="goBtn">Reload</button>
|
|
46
|
+
<button id="resetBtn" class="secondary">Reset</button>
|
|
47
|
+
</div>
|
|
48
|
+
<div id="info">Loading graph data...</div>
|
|
49
|
+
<div id="tooltip">
|
|
50
|
+
<div class="tt-path"></div>
|
|
51
|
+
<div class="tt-line tt-lang"></div>
|
|
52
|
+
<div class="tt-line tt-lines"></div>
|
|
53
|
+
<div class="tt-line tt-imp"></div>
|
|
54
|
+
<div class="tt-line tt-comm"></div>
|
|
55
|
+
</div>
|
|
56
|
+
<svg></svg>
|
|
57
|
+
|
|
58
|
+
<script>
|
|
59
|
+
const LANG_COLORS = {
|
|
60
|
+
python:'#3572A5',typescript:'#3178c6',javascript:'#f1e05a',go:'#00ADD8',
|
|
61
|
+
rust:'#dea584',java:'#b07219',c:'#555555',cpp:'#f34b7d',ruby:'#701516',
|
|
62
|
+
swift:'#F05138',kotlin:'#A97BFF',html:'#e34c26',css:'#563d7c',
|
|
63
|
+
shell:'#89e051',yaml:'#cb171e',json:'#292929',markdown:'#083fa1',
|
|
64
|
+
};
|
|
65
|
+
const COMMUNITY_COLORS = d3.schemeTableau10;
|
|
66
|
+
|
|
67
|
+
let projectId = 'default';
|
|
68
|
+
let currentRoot = '';
|
|
69
|
+
let simulation, svg, g, linkG, nodeG, labelG;
|
|
70
|
+
let width = window.innerWidth, height = window.innerHeight;
|
|
71
|
+
|
|
72
|
+
function init() {
|
|
73
|
+
svg = d3.select('svg').attr('width', width).attr('height', height);
|
|
74
|
+
|
|
75
|
+
svg.append('defs').append('marker')
|
|
76
|
+
.attr('id','arrow').attr('viewBox','0 -4 8 8').attr('refX',18)
|
|
77
|
+
.attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
|
|
78
|
+
.append('path').attr('d','M0,-4L8,0L0,4').attr('fill','#484f58');
|
|
79
|
+
|
|
80
|
+
g = svg.append('g');
|
|
81
|
+
linkG = g.append('g').attr('class','links');
|
|
82
|
+
nodeG = g.append('g').attr('class','nodes');
|
|
83
|
+
labelG = g.append('g').attr('class','labels');
|
|
84
|
+
|
|
85
|
+
const zoom = d3.zoom().scaleExtent([0.1, 8])
|
|
86
|
+
.on('zoom', e => g.attr('transform', e.transform));
|
|
87
|
+
svg.call(zoom);
|
|
88
|
+
|
|
89
|
+
const depthSlider = document.getElementById('depthSlider');
|
|
90
|
+
const maxSlider = document.getElementById('maxSlider');
|
|
91
|
+
const depthVal = document.getElementById('depthVal');
|
|
92
|
+
const maxVal = document.getElementById('maxVal');
|
|
93
|
+
depthSlider.oninput = () => { depthVal.textContent = depthSlider.value; };
|
|
94
|
+
maxSlider.oninput = () => { maxVal.textContent = maxSlider.value; };
|
|
95
|
+
document.getElementById('goBtn').onclick = () => fetchAndRender();
|
|
96
|
+
document.getElementById('resetBtn').onclick = () => {
|
|
97
|
+
currentRoot = '';
|
|
98
|
+
document.getElementById('searchBox').value = '';
|
|
99
|
+
fetchAndRender();
|
|
100
|
+
};
|
|
101
|
+
document.getElementById('searchBox').addEventListener('keydown', e => {
|
|
102
|
+
if (e.key === 'Enter') fetchAndRender();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
fetchAndRender();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function fetchAndRender() {
|
|
109
|
+
const depth = document.getElementById('depthSlider').value;
|
|
110
|
+
const maxNodes = document.getElementById('maxSlider').value;
|
|
111
|
+
const search = document.getElementById('searchBox').value.trim();
|
|
112
|
+
|
|
113
|
+
let root = currentRoot;
|
|
114
|
+
if (search) root = search;
|
|
115
|
+
|
|
116
|
+
const params = new URLSearchParams({ depth, max_nodes: maxNodes });
|
|
117
|
+
if (root) params.set('root', root);
|
|
118
|
+
|
|
119
|
+
const info = document.getElementById('info');
|
|
120
|
+
info.textContent = 'Loading...';
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const resp = await fetch('/api/v2/projects/' + encodeURIComponent(projectId) + '/graph-viz?' + params);
|
|
124
|
+
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
125
|
+
const data = await resp.json();
|
|
126
|
+
info.textContent = data.nodes.length + ' nodes, ' + data.links.length + ' edges' +
|
|
127
|
+
(data.communities.length ? ', ' + data.communities.length + ' communities' : '');
|
|
128
|
+
render(data);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
info.textContent = 'Error: ' + err.message;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function render(data) {
|
|
135
|
+
const { nodes, links, communities } = data;
|
|
136
|
+
if (!nodes.length) return;
|
|
137
|
+
|
|
138
|
+
const commIds = [...new Set(nodes.map(n => n.group))].filter(g => g >= 0).sort();
|
|
139
|
+
const commColor = id => id < 0 ? '#484f58' : COMMUNITY_COLORS[commIds.indexOf(id) % COMMUNITY_COLORS.length];
|
|
140
|
+
|
|
141
|
+
const maxImp = d3.max(nodes, d => d.importance) || 1;
|
|
142
|
+
const r = d => 4 + 12 * (d.importance / maxImp);
|
|
143
|
+
|
|
144
|
+
const color = d => {
|
|
145
|
+
if (d.language && LANG_COLORS[d.language]) return LANG_COLORS[d.language];
|
|
146
|
+
return commColor(d.group);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (simulation) simulation.stop();
|
|
150
|
+
|
|
151
|
+
linkG.selectAll('*').remove();
|
|
152
|
+
const linkSel = linkG.selectAll('line').data(links).enter().append('line')
|
|
153
|
+
.attr('class', 'link')
|
|
154
|
+
.attr('stroke', '#30363d')
|
|
155
|
+
.attr('stroke-width', 1)
|
|
156
|
+
.attr('marker-end', 'url(#arrow)');
|
|
157
|
+
|
|
158
|
+
nodeG.selectAll('*').remove();
|
|
159
|
+
const nodeSel = nodeG.selectAll('circle').data(nodes, d => d.id).enter().append('circle')
|
|
160
|
+
.attr('class', 'node-circle')
|
|
161
|
+
.attr('r', r)
|
|
162
|
+
.attr('fill', color)
|
|
163
|
+
.attr('stroke', d => d3.color(color(d)).brighter(0.8))
|
|
164
|
+
.call(drag());
|
|
165
|
+
|
|
166
|
+
labelG.selectAll('*').remove();
|
|
167
|
+
const labelSel = labelG.selectAll('text').data(nodes, d => d.id).enter().append('text')
|
|
168
|
+
.attr('class', 'node-label')
|
|
169
|
+
.attr('dy', d => r(d) + 12)
|
|
170
|
+
.text(d => {
|
|
171
|
+
const parts = d.id.split('/');
|
|
172
|
+
return parts[parts.length - 1];
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Tooltip via textContent (safe DOM updates)
|
|
176
|
+
const tooltip = document.getElementById('tooltip');
|
|
177
|
+
const ttPath = tooltip.querySelector('.tt-path');
|
|
178
|
+
const ttLang = tooltip.querySelector('.tt-lang');
|
|
179
|
+
const ttLines = tooltip.querySelector('.tt-lines');
|
|
180
|
+
const ttImp = tooltip.querySelector('.tt-imp');
|
|
181
|
+
const ttComm = tooltip.querySelector('.tt-comm');
|
|
182
|
+
|
|
183
|
+
nodeSel
|
|
184
|
+
.on('mouseover', (e, d) => {
|
|
185
|
+
tooltip.style.display = 'block';
|
|
186
|
+
ttPath.textContent = d.id;
|
|
187
|
+
ttLang.textContent = 'Language: ' + (d.language || '(unknown)');
|
|
188
|
+
ttLines.textContent = 'Lines: ' + d.lines;
|
|
189
|
+
ttImp.textContent = 'Importance: ' + d.importance;
|
|
190
|
+
ttComm.textContent = 'Community: ' + (d.group >= 0 ? d.group : 'none');
|
|
191
|
+
})
|
|
192
|
+
.on('mousemove', e => {
|
|
193
|
+
tooltip.style.left = (e.clientX + 12) + 'px';
|
|
194
|
+
tooltip.style.top = (e.clientY - 10) + 'px';
|
|
195
|
+
})
|
|
196
|
+
.on('mouseout', () => { tooltip.style.display = 'none'; })
|
|
197
|
+
.on('click', (e, d) => {
|
|
198
|
+
currentRoot = d.id;
|
|
199
|
+
document.getElementById('searchBox').value = d.id;
|
|
200
|
+
fetchAndRender();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
simulation = d3.forceSimulation(nodes)
|
|
204
|
+
.force('link', d3.forceLink(links).id(d => d.id).distance(60))
|
|
205
|
+
.force('charge', d3.forceManyBody().strength(-120))
|
|
206
|
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
207
|
+
.force('collision', d3.forceCollide().radius(d => r(d) + 2))
|
|
208
|
+
.on('tick', () => {
|
|
209
|
+
linkSel
|
|
210
|
+
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
|
211
|
+
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
|
212
|
+
nodeSel.attr('cx', d => d.x).attr('cy', d => d.y);
|
|
213
|
+
labelSel.attr('x', d => d.x).attr('y', d => d.y);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function drag() {
|
|
218
|
+
return d3.drag()
|
|
219
|
+
.on('start', (e, d) => {
|
|
220
|
+
if (!e.active) simulation.alphaTarget(0.3).restart();
|
|
221
|
+
d.fx = d.x; d.fy = d.y;
|
|
222
|
+
})
|
|
223
|
+
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
|
|
224
|
+
.on('end', (e, d) => {
|
|
225
|
+
if (!e.active) simulation.alphaTarget(0);
|
|
226
|
+
d.fx = null; d.fy = null;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
window.addEventListener('resize', () => {
|
|
231
|
+
width = window.innerWidth; height = window.innerHeight;
|
|
232
|
+
svg.attr('width', width).attr('height', height);
|
|
233
|
+
if (simulation) simulation.force('center', d3.forceCenter(width / 2, height / 2)).alpha(0.3).restart();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
init();
|
|
237
|
+
</script>
|
|
238
|
+
</body>
|
|
239
|
+
</html>
|