diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..44d242428e57b00621963c5b21f99cce110266f1 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,28 @@ +# Use an official lightweight Python image. +# https://hub.docker.com/_/python +FROM python:3.9-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Set work directory +WORKDIR /code + +# Install dependencies +COPY requirements.txt /code/requirements.txt +RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt + +# Copy project including `backend_app` directory +COPY . /code/ + +# Expose the API port (Hugging Face Spaces defaults to 7860) +EXPOSE 7860 + +# Add both /code and /code/backend to PYTHONPATH to ensure backend_app can be imported +# regardless of whether the build context was root or the backend folder. +ENV PYTHONPATH="/code:/code/backend:$PYTHONPATH" + +# Command to run the application using uvicorn. +# We use 'sh -c' to inspect the directory structure before starting, which helps debug 'ModuleNotFoundError'. +CMD ["sh", "-c", "ls -R /code && uvicorn backend_app.main:app --host 0.0.0.0 --port 7860"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1761744c48345220648ddc5b45a09c23c81b98a2 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,64 @@ +# Tribal Knowledge Risk Index & Auto-Correct Planning Engine + +A FastAPI service to analyze knowledge concentration (bus factor) and auto-correct sprint plans based on reality gaps. + +## Setup + +1. **Install dependencies**: + ```bash + pip install -r requirements.txt + ``` + +2. **Ensure Data is present**: + Place JSON files in `data/`. + - GitHub Dummy Data: `prs.json`, `reviews.json`, `commits.json`, `modules.json` + - Jira Dummy Data: `jira_sprints.json`, `jira_issues.json`, `jira_issue_events.json` + +## Running the Service + +Start the server: +```bash +python app/main.py +``` +Or: +```bash +uvicorn app.main:app --reload +``` +API: `http://127.0.0.1:8000` + +## API Endpoints + +### 1. Source System Loading (Run First) +- `POST /load_data`: Load GitHub data. +- `POST /planning/load_jira_dummy`: Load Jira data. + +### 2. Computation +- `POST /compute`: Compute Tribal Knowledge Risks. +- `POST /planning/compute_autocorrect`: Compute Reality Gaps & Plan Corrections. + +### 3. Features + +**Tribal Knowledge**: +- `GET /modules`: List modules by risk. +- `GET /modules/{id}`: Detailed knowledge metrics. + +**Auto-Correct Planning**: +- `GET /planning/sprints`: List sprints with reality gaps and predictions. +- `GET /planning/sprints/{id}`: Detailed sprint metrics. +- `GET /planning/autocorrect/rules`: Learned historical correction rules. + +## Example Flow + +```bash +# 1. Load All Data +curl -X POST http://127.0.0.1:8000/load_data +curl -X POST http://127.0.0.1:8000/planning/load_jira_dummy + +# 2. Compute Insights +curl -X POST http://127.0.0.1:8000/compute +curl -X POST http://127.0.0.1:8000/planning/compute_autocorrect + +# 3. Check "Auto-Correct" Insights +# See the reality gap for the current sprint +curl http://127.0.0.1:8000/planning/sprints +``` diff --git a/backend/backend_app/__init__.py b/backend/backend_app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/backend_app/__pycache__/__init__.cpython-312.pyc b/backend/backend_app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fad65f0fca42525a5f0c74964a4da36bdf550e55 Binary files /dev/null and b/backend/backend_app/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/backend_app/__pycache__/main.cpython-311.pyc b/backend/backend_app/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5aaff68097a42eebd3778f06f67145ccc1a3a3e Binary files /dev/null and b/backend/backend_app/__pycache__/main.cpython-311.pyc differ diff --git a/backend/backend_app/__pycache__/main.cpython-312.pyc b/backend/backend_app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..90fb0061e19ec62ab5dd7e69726ab9bfcfc8d32f Binary files /dev/null and b/backend/backend_app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/backend_app/api/__pycache__/planning_routes.cpython-311.pyc b/backend/backend_app/api/__pycache__/planning_routes.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ce313d749c4114d3a945453dcc3f8db3804124de Binary files /dev/null and b/backend/backend_app/api/__pycache__/planning_routes.cpython-311.pyc differ diff --git a/backend/backend_app/api/__pycache__/planning_routes.cpython-312.pyc b/backend/backend_app/api/__pycache__/planning_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4f6a7823070cd4a2f9feb0a85da2e06ddf7dad91 Binary files /dev/null and b/backend/backend_app/api/__pycache__/planning_routes.cpython-312.pyc differ diff --git a/backend/backend_app/api/__pycache__/proxy_routes.cpython-312.pyc b/backend/backend_app/api/__pycache__/proxy_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fa07c2af4b5cb0581f84e7b1e2254e1b62050301 Binary files /dev/null and b/backend/backend_app/api/__pycache__/proxy_routes.cpython-312.pyc differ diff --git a/backend/backend_app/api/__pycache__/risk_analysis.cpython-311.pyc b/backend/backend_app/api/__pycache__/risk_analysis.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf91c2c23139aa4e687b17b7d29cb446953881a5 Binary files /dev/null and b/backend/backend_app/api/__pycache__/risk_analysis.cpython-311.pyc differ diff --git a/backend/backend_app/api/__pycache__/risk_analysis.cpython-312.pyc b/backend/backend_app/api/__pycache__/risk_analysis.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ea8e7984bbc80303cc312777343b905abcf73a0b Binary files /dev/null and b/backend/backend_app/api/__pycache__/risk_analysis.cpython-312.pyc differ diff --git a/backend/backend_app/api/__pycache__/routes.cpython-311.pyc b/backend/backend_app/api/__pycache__/routes.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aa0c9f3d096af63949461eaa40c58b13d9f028f5 Binary files /dev/null and b/backend/backend_app/api/__pycache__/routes.cpython-311.pyc differ diff --git a/backend/backend_app/api/__pycache__/routes.cpython-312.pyc b/backend/backend_app/api/__pycache__/routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed35c476792a29cfc9c1e78fe2706302274cef81 Binary files /dev/null and b/backend/backend_app/api/__pycache__/routes.cpython-312.pyc differ diff --git a/backend/backend_app/api/__pycache__/strategic_routes.cpython-311.pyc b/backend/backend_app/api/__pycache__/strategic_routes.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..69ada5570f73eb7815d615a713e7110fefaebba8 Binary files /dev/null and b/backend/backend_app/api/__pycache__/strategic_routes.cpython-311.pyc differ diff --git a/backend/backend_app/api/__pycache__/strategic_routes.cpython-312.pyc b/backend/backend_app/api/__pycache__/strategic_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..473cf22114e56522da3231c09e4f1c56b0fce540 Binary files /dev/null and b/backend/backend_app/api/__pycache__/strategic_routes.cpython-312.pyc differ diff --git a/backend/backend_app/api/planning_routes.py b/backend/backend_app/api/planning_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..baa60cb6086468802816f0be236e935e2284d6aa --- /dev/null +++ b/backend/backend_app/api/planning_routes.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, HTTPException, Path +from typing import List, Dict +from backend_app.state.store import store +from backend_app.core.planning_models import AutoCorrectHeadline, SprintMetrics, CorrectionRule + +router = APIRouter(prefix="/planning", tags=["planning"]) + +@router.post("/load_jira_dummy") +def load_jira_dummy(): + try: + counts = store.load_jira_data() + return {"status": "loaded", "counts": counts} + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/compute_autocorrect", response_model=AutoCorrectHeadline) +def compute_autocorrect(): + try: + store.compute_planning() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + return AutoCorrectHeadline(headline=store.planning_headline) + +@router.get("/sprints", response_model=List[SprintMetrics]) +def list_sprints(): + if not store.jira_loaded: + raise HTTPException(status_code=400, detail="Jira data not loaded. Call /planning/load_jira_dummy first.") + + return store.get_sprints() + +@router.get("/sprints/{sprint_id}", response_model=SprintMetrics) +def get_sprint(sprint_id: str): + sprint = store.get_sprint(sprint_id) + if not sprint: + # Check if loaded but not computed? + if not store.jira_loaded: + raise HTTPException(status_code=400, detail="Jira data not loaded.") + raise HTTPException(status_code=404, detail="Sprint not found or metrics not computed.") + return sprint + +@router.get("/autocorrect/rules", response_model=List[CorrectionRule]) +def list_rules(): + return store.get_corrections() diff --git a/backend/backend_app/api/risk_analysis.py b/backend/backend_app/api/risk_analysis.py new file mode 100644 index 0000000000000000000000000000000000000000..d27c076b99dc62d61dfc3f57520565e859eca79b --- /dev/null +++ b/backend/backend_app/api/risk_analysis.py @@ -0,0 +1,118 @@ +from fastapi import APIRouter, HTTPException +from typing import Dict, Any +from pydantic import BaseModel +from backend_app.state.store import store + +router = APIRouter() + +class RiskAnalysisRequest(BaseModel): + org: str + repo: str + +@router.post("/analyze/risk", response_model=Dict[str, Any]) +def analyze_risk(req: RiskAnalysisRequest): + """ + One-shot API to: + 1. Load Live Data + 2. Compute Metrics + 3. Return 'Bus Factor' Risk Analysis (Feature #1) + PLUS Detailed raw stats: commits, PRs, merges per user. + """ + try: + # 1. Load Data + print(f"Loading data for {req.org}/{req.repo}...") + store.load_live_data(req.org, req.repo) + + # 2. Compute + print("Computing metrics...") + store.compute() + + # 3. Collect Detailed Stats + # We want: commits, comments (reviews), PRs, Merge counts per user. + + stats = {} + # Structure: { "user": { "commits": 0, "prs_opened": 0, "prs_merged": 0, "reviews": 0 } } + + def get_stat(u): + if u not in stats: stats[u] = {"commits": 0, "prs_opened": 0, "prs_merged": 0, "reviews": 0} + return stats[u] + + # Commits + for c in store.commits: + u = c.author or "unknown" + get_stat(u)["commits"] += 1 + + # PRs + for p in store.prs: + u = p.author or "unknown" + get_stat(u)["prs_opened"] += 1 + if p.merged_at: + get_stat(u)["prs_merged"] += 1 + + # Reviews + for r in store.reviews: + u = r.reviewer or "unknown" + get_stat(u)["reviews"] += 1 + + # Format for response + detailed_stats = [] + for user, data in stats.items(): + detailed_stats.append({ + "user": user, + **data + }) + + # Sort by commits desc + detailed_stats.sort(key=lambda x: x["commits"], reverse=True) + + modules = store.get_modules() + if not modules: + return { + "headline": "No activity found.", + "overall_repo_risk": 0, + "user_stats": detailed_stats, + "modules_analysis": [] + } + + top_risk_module = modules[0] + + results = [] + for mod in modules: + if not mod.people: continue + top_person = mod.people[0] + share = top_person.share_pct * 100 + + # Bus Factor Check + bus_factor = mod.bus_factor + insight = f"Healthy distribution." + if bus_factor == 1: + insight = f"CRITICAL: {top_person.person_id} is a single point of failure (Bus Factor 1). If they leave, {share:.1f}% of module logic is orphaned." + elif share > 50: + insight = f"HIGH RISK: {top_person.person_id} dominates ({share:.1f}%)." + + results.append({ + "module": mod.module_id, + "risk_score": mod.risk_index, + "severity": mod.severity, + "bus_factor": bus_factor, + "key_person": top_person.person_id, + "knowledge_share_pct": round(share, 1), + "insight": insight, + "evidence": mod.evidence + }) + + headline = f"Repo Analysis: {top_risk_module.module_id} is at {top_risk_module.severity} risk." + if top_risk_module.bus_factor == 1: + headline += f" {top_risk_module.people[0].person_id} is a Single Point of Failure." + + return { + "headline": headline, + "overall_repo_risk": top_risk_module.risk_index, + "user_stats": detailed_stats, + "modules_analysis": results + } + + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/backend_app/api/routes.py b/backend/backend_app/api/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..74e9416f7b4df5eb8dc520f0d2cbf52f6c5f45cf --- /dev/null +++ b/backend/backend_app/api/routes.py @@ -0,0 +1,147 @@ +from fastapi import APIRouter, HTTPException, Path, Body +from typing import List, Dict, Optional +from pydantic import BaseModel +from backend_app.state.store import store +from backend_app.core.models import LoadStatus, ComputeHeadline, ModuleMetric +from backend_app.integrations.supabase_client import supabase +import requests +import uuid + +router = APIRouter() + +class LiveDataRequest(BaseModel): + org: str + repo: str + +@router.get("/health") +def health_check(): + return {"status": "ok"} + +@router.get("/test-supabase") +def test_supabase_connection(): + if not supabase: + return {"status": "error", "message": "Supabase client is None (failed to init)"} + + try: + # Try a lightweight query + print("Testing Supabase connection...") + response = supabase.table("pull_requests").select("count", count="exact").limit(1).execute() + print(f"Supabase Test Result: {response}") + return { + "status": "ok", + "data": response.data, + "message": "Connection successful" + } + except Exception as e: + print(f"Supabase Test Failed: {e}") + return {"status": "error", "message": str(e)} + +@router.post("/load_data", response_model=Dict) +def load_data(req: Optional[LiveDataRequest] = None): + try: + if req and req.org and req.repo: + return store.load_live_data(req.org, req.repo) + else: + counts = store.load_data() + return { + "prs": counts.get("prs", 0), + "reviews": counts.get("reviews", 0), + "commits": counts.get("commits", 0), + "modules": counts.get("modules", 0), + "source": "Dummy Data" + } + except Exception as e: + # Check for specific integration failure message + msg = str(e) + if "Integration failed" in msg: + raise HTTPException(status_code=502, detail=msg) + if "missing" in msg.lower(): # File missing + raise HTTPException(status_code=404, detail=msg) + + raise HTTPException(status_code=500, detail=f"Error loading data: {msg}") + +@router.post("/compute", response_model=ComputeHeadline) +def compute(): + try: + store.compute() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + # Generate headline from the highest risk module + modules = store.get_modules() + if not modules: + return ComputeHeadline(headline="No modules found or computed.") + + # Pick top risk + top_mod = modules[0] + risk_level = top_mod.severity + + # Extract top person + top_person_name = "No one" + if top_mod.people: + top_person_name = top_mod.people[0].person_id + + headline = f"{top_mod.module_id} module is at {risk_level} risk because {top_person_name} owns most of the knowledge signals." + + return ComputeHeadline(headline=headline) + +@router.get("/modules", response_model=List[ModuleMetric], response_model_exclude={"people", "evidence", "plain_explanation"}) +def list_modules(): + """ + List modules sorted by risk_index desc. + Excludes detailed fields for the list view to keep it lightweight if needed, + but the prompt asks for specific fields. + Prompt: "List modules... with fields: module_id, risk_index, severity, top1_share_pct, top2_share_pct, bus_factor, total_knowledge_weight, signals_count" + The response_model_exclude in FastAPI handles hiding fields. + """ + if not store.loaded and not store.module_metrics: + # If not loaded/computed, return empty or error? + # Prompt doesn't specify. Implicitly empty list or 400. + # But if compute hasn't run, module_metrics is empty. + pass + + return store.get_modules() + +@router.get("/modules/{module_id}", response_model=ModuleMetric) +def get_module(module_id: str = Path(..., description="The ID of the module")): + metric = store.get_module(module_id) + if not metric: + raise HTTPException(status_code=404, detail=f"Module '{module_id}' not found. Ensure signals are computed.") + return metric + +@router.get("/commits") +def get_commits_list(): + """ + Returns the list of loaded commits. + """ + total_count = len(store.commits) + # Sort by timestamp desc + sorted_commits = sorted(store.commits, key=lambda c: c.timestamp, reverse=True) + + return { + "count": total_count, + "commits": sorted_commits + } + +@router.post("/run-workflow") +def run_workflow_endpoint(input_text: str = Body(default="hello world!", embed=True)): + api_key = 'sk-y2mGytaDwLg927nc2LqZDOs-Go1dWGzjvjlHUN7zXj8' + url = "http://localhost:7860/api/v1/run/7e37cb01-7c44-44df-be5e-9969091a5ffe" + + payload = { + "output_type": "chat", + "input_type": "text", + "input_value": input_text + } + payload["session_id"] = str(uuid.uuid4()) + headers = {"x-api-key": api_key} + + try: + response = requests.post(url, json=payload, headers=headers) + response.raise_for_status() + return {"output": response.text} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Workflow Error: {str(e)}") + diff --git a/backend/backend_app/api/strategic_routes.py b/backend/backend_app/api/strategic_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..875d7efc76ad9d1daf1faff5dd42b704546e7b5a --- /dev/null +++ b/backend/backend_app/api/strategic_routes.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, HTTPException +from backend_app.state.store import store +from backend_app.core.strategic_controller import get_strategic_audit, analyze_jira_from_db +from pydantic import BaseModel + +class StrategicAuditResponse(BaseModel): + briefing: str + +router = APIRouter(prefix="/strategic", tags=["strategic"]) + +@router.post("/audit", response_model=StrategicAuditResponse) +def compute_strategic_audit(): + # Make sure we have GitHub data loaded + if not store.loaded: + raise HTTPException(status_code=400, detail="GitHub data not loaded. Call /load_data first.") + + # We can run without Jira loaded but it will default to empty plan. + # But best to encourage full load. + + briefing_text = get_strategic_audit() + return StrategicAuditResponse(briefing=briefing_text) + +@router.post("/jira-audit", response_model=StrategicAuditResponse) +def compute_jira_audit(): + """ + Compares the latest Jira data from DB with active GitHub data. + """ + if not store.loaded: + raise HTTPException(status_code=400, detail="GitHub data not loaded. Call /load_data first.") + + analysis = analyze_jira_from_db() + return StrategicAuditResponse(briefing=analysis) diff --git a/backend/backend_app/core/__pycache__/config.cpython-311.pyc b/backend/backend_app/core/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..95d167fe36589434edfb567830c672c09dd4c076 Binary files /dev/null and b/backend/backend_app/core/__pycache__/config.cpython-311.pyc differ diff --git a/backend/backend_app/core/__pycache__/config.cpython-312.pyc b/backend/backend_app/core/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..44c72313b072cd0e28ce145340f2096aac575174 Binary files /dev/null and b/backend/backend_app/core/__pycache__/config.cpython-312.pyc differ diff --git a/backend/backend_app/core/__pycache__/explain.cpython-311.pyc b/backend/backend_app/core/__pycache__/explain.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9f31f6c653c2f96485903699252d427b77d87874 Binary files /dev/null and b/backend/backend_app/core/__pycache__/explain.cpython-311.pyc differ diff --git a/backend/backend_app/core/__pycache__/explain.cpython-312.pyc b/backend/backend_app/core/__pycache__/explain.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f59b96f4a51c879c1bbeea5f7338480f521ad26e Binary files /dev/null and b/backend/backend_app/core/__pycache__/explain.cpython-312.pyc differ diff --git a/backend/backend_app/core/__pycache__/github_client.cpython-311.pyc b/backend/backend_app/core/__pycache__/github_client.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d51e77ed6641e6d160455fa4f5eda92f1e72ca2d Binary files /dev/null and b/backend/backend_app/core/__pycache__/github_client.cpython-311.pyc differ diff --git a/backend/backend_app/core/__pycache__/metrics.cpython-311.pyc b/backend/backend_app/core/__pycache__/metrics.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..10daa4b56b3f6694fe1d4fda6472e4ed53e46172 Binary files /dev/null and b/backend/backend_app/core/__pycache__/metrics.cpython-311.pyc differ diff --git a/backend/backend_app/core/__pycache__/metrics.cpython-312.pyc b/backend/backend_app/core/__pycache__/metrics.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5c51347b9773c10703d05ba047a38803f7ead3b Binary files /dev/null and b/backend/backend_app/core/__pycache__/metrics.cpython-312.pyc differ diff --git a/backend/backend_app/core/__pycache__/models.cpython-311.pyc b/backend/backend_app/core/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..298a558014ed5ccd49652fa08a1a1b0d6c73e65b Binary files /dev/null and b/backend/backend_app/core/__pycache__/models.cpython-311.pyc differ diff --git a/backend/backend_app/core/__pycache__/models.cpython-312.pyc b/backend/backend_app/core/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..55f695bd7166a9c964d83e71b35b398d0f7b3fc5 Binary files /dev/null and b/backend/backend_app/core/__pycache__/models.cpython-312.pyc differ diff --git a/backend/backend_app/core/__pycache__/planning_engine.cpython-311.pyc b/backend/backend_app/core/__pycache__/planning_engine.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5bf0318c1a77bc2c9ea6f5b9099410485984d222 Binary files /dev/null and b/backend/backend_app/core/__pycache__/planning_engine.cpython-311.pyc differ diff --git a/backend/backend_app/core/__pycache__/planning_engine.cpython-312.pyc b/backend/backend_app/core/__pycache__/planning_engine.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ce368053673cd765807b3e9488ba2238837d7a2 Binary files /dev/null and b/backend/backend_app/core/__pycache__/planning_engine.cpython-312.pyc differ diff --git a/backend/backend_app/core/__pycache__/planning_loader.cpython-311.pyc b/backend/backend_app/core/__pycache__/planning_loader.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a2e90f7dfa393af119711074c9d791bd8f1fa25 Binary files /dev/null and b/backend/backend_app/core/__pycache__/planning_loader.cpython-311.pyc differ diff --git a/backend/backend_app/core/__pycache__/planning_loader.cpython-312.pyc b/backend/backend_app/core/__pycache__/planning_loader.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7d032d40e468ad1ede5891851d5c4070faed23c2 Binary files /dev/null and b/backend/backend_app/core/__pycache__/planning_loader.cpython-312.pyc differ diff --git a/backend/backend_app/core/__pycache__/planning_models.cpython-311.pyc b/backend/backend_app/core/__pycache__/planning_models.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d840a89b2ce2fcde05248e28d679a921074f33c Binary files /dev/null and b/backend/backend_app/core/__pycache__/planning_models.cpython-311.pyc differ diff --git a/backend/backend_app/core/__pycache__/planning_models.cpython-312.pyc b/backend/backend_app/core/__pycache__/planning_models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12bca6b1915bc4783a96bf23f5163b755e245f02 Binary files /dev/null and b/backend/backend_app/core/__pycache__/planning_models.cpython-312.pyc differ diff --git a/backend/backend_app/core/__pycache__/signals.cpython-311.pyc b/backend/backend_app/core/__pycache__/signals.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..460f9b5a6fce667d7f0bae92b16852ba735db9cd Binary files /dev/null and b/backend/backend_app/core/__pycache__/signals.cpython-311.pyc differ diff --git a/backend/backend_app/core/__pycache__/signals.cpython-312.pyc b/backend/backend_app/core/__pycache__/signals.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..842b8932f8d8817423cff3c59b0e527dc524c904 Binary files /dev/null and b/backend/backend_app/core/__pycache__/signals.cpython-312.pyc differ diff --git a/backend/backend_app/core/__pycache__/strategic_controller.cpython-311.pyc b/backend/backend_app/core/__pycache__/strategic_controller.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9ab2f5536ca5560e83c1ff8e44f941ee4fead70 Binary files /dev/null and b/backend/backend_app/core/__pycache__/strategic_controller.cpython-311.pyc differ diff --git a/backend/backend_app/core/__pycache__/strategic_controller.cpython-312.pyc b/backend/backend_app/core/__pycache__/strategic_controller.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40c39d3fab5a20022e22f5a8ce7210cfe2ac848a Binary files /dev/null and b/backend/backend_app/core/__pycache__/strategic_controller.cpython-312.pyc differ diff --git a/backend/backend_app/core/config.py b/backend/backend_app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..8930774ca76afc9eb3b17b1d929c873ff0c8c66f --- /dev/null +++ b/backend/backend_app/core/config.py @@ -0,0 +1,13 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +DATA_DIR = BASE_DIR / "data" + +PRS_FILE = DATA_DIR / "prs.json" +REVIEWS_FILE = DATA_DIR / "reviews.json" +COMMITS_FILE = DATA_DIR / "commits.json" +MODULES_FILE = DATA_DIR / "modules.json" + +JIRA_SPRINTS_FILE = DATA_DIR / "jira_sprints.json" +JIRA_ISSUES_FILE = DATA_DIR / "jira_issues.json" +JIRA_EVENTS_FILE = DATA_DIR / "jira_issue_events.json" diff --git a/backend/backend_app/core/explain.py b/backend/backend_app/core/explain.py new file mode 100644 index 0000000000000000000000000000000000000000..7c2113982e8d3d507baf2573bd679609e682fec3 --- /dev/null +++ b/backend/backend_app/core/explain.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backend_app.core.models import ModuleMetric + +def generate_explanation(metric: 'ModuleMetric') -> str: + """ + A deterministic explanation that mentions: + - risk score + - top1 share % + - bus factor interpretation + - 1–2 evidence lines + """ + # Headline + text = f"Risk Score: {metric.risk_index} ({metric.severity}). " + + # Top Share + top_person = metric.people[0] if metric.people else None + if top_person: + text += f"Top contributor {top_person.person_id} holds {top_person.share_pct*100:.1f}% of the knowledge. " + else: + text += "No knowledge signals recorded. " + return text + + # Bus Factor + if metric.bus_factor == 0: + text += "Bus factor is 0 (CRITICAL: No one has >10% share? Check data). " + elif metric.bus_factor == 1: + text += "Bus factor is 1 (Single point of failure). " + elif metric.bus_factor < 3: + text += f"Bus factor is {metric.bus_factor} (Low redundancy). " + else: + text += f"Bus factor is {metric.bus_factor} (Good redundancy). " + + # Evidence (1-2 lines) + if metric.evidence: + text += "Key evidence: " + text += "; ".join(metric.evidence[:2]) + "." + + return text diff --git a/backend/backend_app/core/github_client.py b/backend/backend_app/core/github_client.py new file mode 100644 index 0000000000000000000000000000000000000000..3346a64e81eba58d599f35f602884f0734f5af59 --- /dev/null +++ b/backend/backend_app/core/github_client.py @@ -0,0 +1,148 @@ +from datetime import datetime, timezone +from typing import List, Dict, Any, Optional +import requests +import json +import random # Fallback for story points +from backend_app.core.models import RawCommit, RawPR, RawReview +from backend_app.core.planning_models import RawIssue, RawIssueEvent, RawSprint + +# Base URL for the custom GitHub App/API +BASE_URL = "https://samyak000-github-app.hf.space/insights" + +class GitHubClient: + def __init__(self, org: str, repo: str): + self.org = org + self.repo = repo + + def _parse_ts(self, ts_str: Optional[str]) -> datetime: + if not ts_str: + return datetime.now(timezone.utc) + try: + return datetime.fromisoformat(ts_str.replace("Z", "+00:00")) + except: + return datetime.now(timezone.utc) + + def fetch_commits(self) -> List[RawCommit]: + url = f"{BASE_URL}/commits" + payload = {"org": self.org, "repo": self.repo} + try: + resp = requests.post(url, json=payload, timeout=10) + if resp.status_code != 200: return [] + data = resp.json() + commits = [] + for item in data.get("commits", []): + try: + c = item.get("commit", {}) + author_info = c.get("author", {}) + ts = self._parse_ts(author_info.get("date")) + author_name = author_info.get("name", "Unknown") + if item.get("author") and "login" in item["author"]: + author_name = item["author"]["login"] + + files = [] + if "files" in item: + files = [f.get("filename") for f in item["files"] if "filename" in f] + + commits.append(RawCommit( + commit_id=item.get("sha", ""), + author=author_name, + timestamp=ts, + files_changed=files + )) + except Exception: continue + return commits + except Exception: return [] + + def fetch_prs(self) -> List[RawPR]: + url = f"{BASE_URL}/pull-requests" + payload = {"org": self.org, "repo": self.repo} + try: + resp = requests.post(url, json=payload, timeout=15) + if resp.status_code != 200: return [] + data = resp.json() + + # Adjust based on actual key. + # If endpoint is /pull-requests, maybe key is "pull_requests" or "prs"? + # I'll check generic keys if specific fails + raw_list = data.get("pull_requests", data.get("prs", [])) + + prs = [] + for item in raw_list: + try: + # Generic structure mapping + pid = str(item.get("number", item.get("id", "unknown"))) + user = item.get("user", {}) + author = user.get("login", "unknown") + created = self._parse_ts(item.get("created_at")) + merged = self._parse_ts(item.get("merged_at")) if item.get("merged_at") else None + + # Files? Usually not in list view. + # If this API is "smart", maybe it includes them? + # If not, we assume empty or try "files" key + files = [] # item.get("files", []) if we're lucky + + prs.append(RawPR( + pr_id=pid, + author=author, + created_at=created, + merged_at=merged, + files_changed=files + )) + except: continue + return prs + except Exception: return [] + + def fetch_issues(self) -> List[RawIssue]: + url = f"{BASE_URL}/pull-issues" + payload = {"org": self.org, "repo": self.repo} + try: + resp = requests.post(url, json=payload, timeout=15) + if resp.status_code != 200: return [] + data = resp.json() + raw_list = data.get("issues", []) + + issues = [] + for item in raw_list: + try: + # Skip PRs if they come through this endpoint + if "pull_request" in item and item["pull_request"]: + continue + + iid = f"GH-{item.get('number')}" + title = item.get("title", "") + + # Map to Planning Model (Jira-style) + # We need to fabricate some data for the Planning Engine to work + assignees = item.get("assignees", []) + assignee = assignees[0].get("login") if assignees else "unassigned" + + # Module? Try label + labels = [l.get("name") for l in item.get("labels", [])] + module_id = "general" + for l in labels: + if "module:" in l: # Convention? + module_id = l.replace("module:", "") + break + + # Sprint? Milestone? + sprint_id = "SPR-LIVE" # Default bucket + if item.get("milestone"): + sprint_id = f"SPR-{item['milestone'].get('title')}" + + issues.append(RawIssue( + issue_id=iid, + sprint_id=sprint_id, + title=title, + issue_type="Story", # Default + story_points=1, # Default + assignee=assignee, + module_id=module_id, + created_at=self._parse_ts(item.get("created_at")) + )) + except: continue + return issues + except Exception: return [] + + def fetch_activity(self) -> List[RawIssueEvent]: + # Maps activity timeline to issue events (transitions) + return [] # Placeholder, complex to map generic activity stream to "status changes" reliably without more info diff --git a/backend/backend_app/core/metrics.py b/backend/backend_app/core/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..b9423a33c2af74e9a1c4b30b780c21f84d3b1fae --- /dev/null +++ b/backend/backend_app/core/metrics.py @@ -0,0 +1,140 @@ +from typing import List, Dict +import math +from backend_app.core.models import Signal, ModuleMetric, PersonMetric +from backend_app.core.explain import generate_explanation + +def compute_metrics(module_id: str, signals: List[Signal], max_total_score_global: float) -> ModuleMetric: + # 1. Aggregate scores per person + person_scores: Dict[str, float] = {} + person_signal_counts: Dict[str, Dict[str, int]] = {} # person -> type -> count + + total_score = 0.0 + + for s in signals: + total_score += s.weight + person_scores[s.person_id] = person_scores.get(s.person_id, 0.0) + s.weight + + if s.person_id not in person_signal_counts: + person_signal_counts[s.person_id] = {} + person_signal_counts[s.person_id][s.signal_type] = person_signal_counts[s.person_id].get(s.signal_type, 0) + 1 + + # 2. Calculate person metrics + people_metrics: List[PersonMetric] = [] + + # Sort people by score desc + sorted_people = sorted(person_scores.items(), key=lambda x: x[1], reverse=True) + + for person_id, score in sorted_people: + share = score / total_score if total_score > 0 else 0.0 + people_metrics.append(PersonMetric( + person_id=person_id, + knowledge_score=score, + share_pct=share, # Keep as 0-1 float for now, will format later or in model? Model says float. + type_counts=person_signal_counts.get(person_id, {}) + )) + + # 3. Module level metrics + top1_share = people_metrics[0].share_pct if len(people_metrics) > 0 else 0.0 + top2_share = people_metrics[1].share_pct if len(people_metrics) > 1 else 0.0 + + bus_factor = sum(1 for p in people_metrics if p.share_pct >= 0.10) + + # Risk Index Formula + # silo = clamp((top1_share - 0.4)/0.6, 0, 1) + # bus = clamp((2 - bus_factor)/2, 0, 1) + # criticality = clamp(total_score / max_total_score_across_modules, 0, 1) + # risk = 100 * (0.5*silo + 0.3*bus + 0.2*criticality) + + def clamp(val, min_v, max_v): + return max(min_v, min(val, max_v)) + + if not signals: + # No signals = No Risk (or No Data) + return ModuleMetric( + module_id=module_id, + risk_index=0.0, + severity="HEALTHY", + top1_share_pct=0.0, + top2_share_pct=0.0, + bus_factor=0, + total_knowledge_weight=0.0, + signals_count=0, + people=[], + evidence=[], + plain_explanation="No activity detected." + ) + + + silo_factor = (top1_share - 0.4) / 0.6 + bus_risk_factor = (2 - bus_factor) / 2.0 + criticality_factor = total_score / max_total_score_global if max_total_score_global > 0 else 0.0 + + # Ensure non-negative before clamping + silo_factor = max(silo_factor, 0.0) + bus_risk_factor = max(bus_risk_factor, 0.0) + criticality_factor = max(criticality_factor, 0.0) + + # Calculate raw risk + risk_index_raw = 100.0 * (0.6 * silo_factor + 0.25 * bus_risk_factor + 0.15 * criticality_factor) + + # Remove dampening logic entirely as it's suppressing real risk on small repos + # if len(signals) < 10: + # risk_index_raw = risk_index_raw * (len(signals) / 10.0) + + risk_index = round(min(risk_index_raw, 100.0), 2) + + # Severity - lowered thresholds slightly to show more "risk" + if risk_index >= 60: + severity = "SEVERE" + elif risk_index >= 30: + severity = "MODERATE" + else: + severity = "HEALTHY" + + # 4. Evidence + # Generate 2-5 evidence strings for top contributors + evidence_lines = [] + for p in people_metrics[:5]: + # “dev_a: share 84.0% | approvals=2, review_notes=0, commits=2” + # Map internal types to display names if needed, or just use raw keys + # The prompt examples: approvals, review_notes, commits + # My keys: review_approval, review_comment, review_changes_requested, commit + + # Helper to get count + def gc(k): return p.type_counts.get(k, 0) + + # Just dumping all counts for simplicity or Mapping to prettier names? + # Prompt: approvals=2, review_notes=0, commits=2 + # I'll try to map to something readable. + + parts = [] + parts.append(f"commits={gc('commit')}") + approvals = gc('review_approval') + if approvals > 0: parts.append(f"approvals={approvals}") + comments = gc('review_comment') + if comments > 0: parts.append(f"comments={comments}") + changes = gc('review_changes_requested') + if changes > 0: parts.append(f"changes_requested={changes}") + + counts_str = ", ".join(parts) + line = f"{p.person_id}: share {p.share_pct*100:.1f}% | {counts_str}" + evidence_lines.append(line) + + mod_metric = ModuleMetric( + module_id=module_id, + risk_index=risk_index, + severity=severity, + top1_share_pct=top1_share, + top2_share_pct=top2_share, + bus_factor=bus_factor, + total_knowledge_weight=total_score, + signals_count=len(signals), + people=people_metrics, + evidence=evidence_lines, + plain_explanation="" + ) + + # Generate explanation + mod_metric.plain_explanation = generate_explanation(mod_metric) + + return mod_metric diff --git a/backend/backend_app/core/models.py b/backend/backend_app/core/models.py new file mode 100644 index 0000000000000000000000000000000000000000..899261925d4cb0ce5d2a6391665de19d434ce615 --- /dev/null +++ b/backend/backend_app/core/models.py @@ -0,0 +1,67 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Dict +from datetime import datetime + +# --- Input Models --- + +class RawPR(BaseModel): + pr_id: str + author: str + created_at: datetime + merged_at: Optional[datetime] = None + files_changed: List[str] + +class RawReview(BaseModel): + pr_id: str + reviewer: str + state: str # APPROVED, CHANGES_REQUESTED, COMMENTED + timestamp: datetime + +class RawCommit(BaseModel): + commit_id: str + author: str + timestamp: datetime + message: Optional[str] = "" + files_changed: List[str] + +# Dictionary mapping module_id -> list of path prefixes +ModulesConfig = Dict[str, List[str]] + + +# --- Output / Internal Models --- + +class Signal(BaseModel): + person_id: str + module_id: str + signal_type: str + weight: float + timestamp: datetime + source_id: str # pr_id or commit_id + +class PersonMetric(BaseModel): + person_id: str + knowledge_score: float + share_pct: float + type_counts: Dict[str, int] = Field(default_factory=dict) + +class ModuleMetric(BaseModel): + module_id: str + risk_index: float + severity: str # SEVERE, MODERATE, HEALTHY + top1_share_pct: float + top2_share_pct: float + bus_factor: int + total_knowledge_weight: float + signals_count: int + people: List[PersonMetric] + evidence: List[str] + plain_explanation: str + +class ComputeHeadline(BaseModel): + headline: str + +class LoadStatus(BaseModel): + prs_count: int + reviews_count: int + commits_count: int + modules_count: int diff --git a/backend/backend_app/core/planning_engine.py b/backend/backend_app/core/planning_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..7ec750d15c5bba7cb1d2f94bc2a747ed3784da2f --- /dev/null +++ b/backend/backend_app/core/planning_engine.py @@ -0,0 +1,312 @@ +from datetime import datetime, timezone, timedelta +from typing import List, Dict, Optional, Tuple +import math + +from backend_app.core.planning_models import ( + RawSprint, RawIssue, RawIssueEvent, + SprintMetrics, CorrectionRule, AutoCorrectHeadline +) +from backend_app.core.models import Signal, RawPR, RawReview +# We need access to GitHub data (processed signals or raw) + +# Heuristic Constants +DEFAULT_POINTS_PER_DAY_DEV = 1.0 # Fallback +REALITY_GAP_WEIGHT_POINTS = 0.6 +REALITY_GAP_WEIGHT_REVIEW = 0.4 + +def compute_autocorrect( + sprints: List[RawSprint], + issues: List[RawIssue], + events: List[RawIssueEvent], + github_prs: List[RawPR], + github_reviews: List[RawReview], + modules_config: Dict[str, List[str]] +) -> Tuple[List[SprintMetrics], List[CorrectionRule], str]: + + # 1. Organize Data + # Issues per sprint + issues_by_sprint = {s.sprint_id: [] for s in sprints} + for i in issues: + if i.sprint_id in issues_by_sprint: + issues_by_sprint[i.sprint_id].append(i) + + # Events by issue + events_by_issue = {i.issue_id: [] for i in issues} + for e in events: + if e.issue_id in events_by_issue: + events_by_issue[e.issue_id].append(e) + + # Sort events by time + for iid in events_by_issue: + events_by_issue[iid].sort(key=lambda x: x.timestamp) + + # 2. Historical Analysis (Correction Rules) + # We look at COMPLETED sprints to learn multipliers. + # Current time is "now" (simulated). We can assume "now" is the end of the last sprint or mid-current. + # The prompt says "current local time is 2026-02-07". + # Sprint 1 (Jan 15-29) is done. Sprint 2 (Feb 1-14) is in progress. + + correction_rules = _learn_correction_rules(sprints, issues, events_by_issue) + + # 3. Compute Metrics for Sprints (focus on active/recent) + sprint_metrics_list = [] + + # We need to simulate "current status" relative to 2026-02-07 (NOW) + NOW = datetime(2026, 2, 7, 14, 0, 0, tzinfo=timezone.utc) + + headline = "No active sprint analysis." + + for sprint in sprints: + # Determine if sprint is past, current, or future + # Simple check + is_current = sprint.start_date <= NOW <= sprint.end_date + is_past = sprint.end_date < NOW + + # Calculate Planned + total_points = sprint.planned_story_points + days_duration = (sprint.end_date - sprint.start_date).days + 1 + points_per_day_planned = total_points / days_duration if days_duration > 0 else 0 + + # Calculate Actual / Projected + # Points completed within sprint window (for past) or up to NOW (for current) + completed_points = 0 + + sprint_issues = issues_by_sprint[sprint.sprint_id] + + # Track module breakdown + # mod_id -> {planned: int, completed: int} + mod_stats = {} + + for issue in sprint_issues: + mid = issue.module_id + if mid not in mod_stats: mod_stats[mid] = {"planned": 0, "completed": 0} + mod_stats[mid]["planned"] += issue.story_points + + # Check if done + # Issue is done if it has a transition to DONE within the sprint window + # For current sprint, within start -> NOW + # For past, within start -> end + + cutoff = NOW if is_current else sprint.end_date + + done_time = None + evt_list = events_by_issue.get(issue.issue_id, []) + for evt in evt_list: + if evt.to_status == "DONE": + done_time = evt.timestamp + break # Assuming once done stays done for simplicity + + if done_time and done_time <= cutoff and done_time >= sprint.start_date: + completed_points += issue.story_points + mod_stats[mid]["completed"] += issue.story_points + + # --- Gap Analysis --- + + # Expected completion based on linear burn + # For past sprints, expected at end is 100%. + # For current, expected is proportional to time passed. + + if is_past: + time_progress_pct = 1.0 + else: + days_passed = (NOW - sprint.start_date).days + if days_passed < 0: days_passed = 0 + time_progress_pct = days_passed / days_duration + + expected_points = total_points * time_progress_pct + points_gap = expected_points - completed_points + + # Review Delay Signal from GitHub + # Get PRs created during this sprint + sprint_prs = [] + # Naive PR filter by created_at in sprint window + # Note: timezone awareness might be tricky if mixed naive/aware. + # Assuming GitHub data is loaded as datetime (model). + for pr in github_prs: + # check overlap? created_at inside sprint + # Handle tz: ensure both are consistent. + # Our models define datetime, likely parsed as aware or naive. + # We'll assume both are UTC aware for this exercise. + if sprint.start_date <= pr.created_at <= sprint.end_date: + sprint_prs.append(pr) + + # Calculate avg review time + # We need reviews for these PRs + # Map needed. + # This is expensive if unrelated, but dataset is small. + review_delays = [] + for pr in sprint_prs: + # Find approval + approval_ts = None + for rev in github_reviews: + if rev.pr_id == pr.pr_id and rev.state == "APPROVED": + approval_ts = rev.timestamp + break + + if approval_ts: + delay = (approval_ts - pr.created_at).total_seconds() / 86400.0 # days + review_delays.append(delay) + elif is_current: + # If not approved yet, delay is (NOW - created) + current_wait = (NOW - pr.created_at).total_seconds() / 86400.0 + if current_wait > 1.0: # Only count if waiting > 1 day + review_delays.append(current_wait) + + avg_review_delay = sum(review_delays)/len(review_delays) if review_delays else 0.5 # default 0.5d + + # Baseline review delay? Say 0.6 is good. + review_gap = max(0, avg_review_delay - 0.6) + + # Reality Gap Score (0-100) + # normalize points gap: if we are 30% behind, that's bad. + pct_behind = points_gap / total_points if total_points > 0 else 0 + score_points = min(100, max(0, pct_behind * 100 * 2)) # Multiplier 2x: 50% behind = 100 risk + + score_review = min(100, review_gap * 20) # 1 day late = 20 pts, 5 days = 100 + + reality_gap_score = int(score_points * 0.7 + score_review * 0.3) + + # Prediction + # Simple velocity based on current completed vs time used + predicted_slip = 0 + predicted_finish = sprint.end_date + + if is_current and completed_points < total_points and time_progress_pct > 0.1: + # Pace: points per day actual + days_spent = (NOW - sprint.start_date).days + if days_spent < 1: days_spent = 1 + avg_pace = completed_points / days_spent + + remaining = total_points - completed_points + if avg_pace > 0: + days_needed = remaining / avg_pace + finish_date = NOW + timedelta(days=days_needed) + slip = (finish_date - sprint.end_date).days + if slip > 0: + predicted_slip = int(slip) + predicted_finish = finish_date + else: + # Stall + predicted_slip = 99 + predicted_finish = NOW + timedelta(days=30) + + # Explainability + top_drivers = [] + # Who is missing points? + # Which modules? + bad_modules = [] + for m, stats in mod_stats.items(): + if stats["planned"] > 0: + p = stats["completed"] / stats["planned"] + # Adjust expectation: expected p should be time_progress_pct + if p < (time_progress_pct * 0.7): # 30% buffer + bad_modules.append(m) + + if bad_modules: + top_drivers.append(f"Modules behind schedule: {', '.join(bad_modules)}") + + if review_gap > 1.0: + top_drivers.append(f"High review delays (avg {avg_review_delay:.1f}d)") + + if points_gap > 5: + top_drivers.append(f"Point completion gap: {points_gap} pts behind plan") + + # Recommendations + actions = [] + if is_current and "payments" in bad_modules and review_gap > 1.0: + actions.append("Payments module is bottlenecked by reviews. Assign 1 extra reviewer.") + if predicted_slip > 2: + actions.append(f"Predicted slip {predicted_slip} days. Reduce scope by {int(points_gap)} pts.") + + metric = SprintMetrics( + sprint_id=sprint.sprint_id, + name=sprint.name, + start_date=sprint.start_date, + end_date=sprint.end_date, + planned_story_points=total_points, + completed_story_points=completed_points, + completion_pct=round(completed_points / total_points * 100, 1) if total_points else 0, + reality_gap_score=reality_gap_score, + points_completion_gap=round(points_gap, 1), + predicted_slip_days=predicted_slip, + predicted_finish_date=predicted_finish.strftime("%Y-%m-%d"), + module_breakdown=mod_stats, + top_drivers=top_drivers, + recommended_actions=actions + ) + sprint_metrics_list.append(metric) + + if is_current: + drivers_short = "; ".join(top_drivers[:1]) if top_drivers else "on track" + headline = f"{sprint.name} is trending {predicted_slip} days late: {drivers_short}." + + return sprint_metrics_list, correction_rules, headline + + +def _learn_correction_rules(sprints: List[RawSprint], issues: List[RawIssue], events_by_issue: Dict[str, List[RawIssueEvent]]) -> List[CorrectionRule]: + """ + Learn from past COMPLETED sprints. + Correction = actual_duration / planned_duration + Wait, issues don't have "planned duration", they have points. + We need: + planned_days = points / sprint_avg_velocity (points/day) + actual_days = DONE - IN_PROGRESS timestamp + """ + rules = [] + + # Group by (team, module, type) -> list of ratios + history: Dict[Tuple[str, str, str], List[float]] = {} + + # Pre-calc sprint velocities + sprint_velocities = {} # sprint_id -> points/day + for s in sprints: + duration = (s.end_date - s.start_date).days + 1 + vel = s.planned_story_points / duration if duration > 0 else 1.0 + sprint_velocities[s.sprint_id] = vel + + for issue in issues: + # Only look at fully done issues + evts = events_by_issue.get(issue.issue_id, []) + start_ts = None + end_ts = None + + for e in evts: + if e.to_status == "IN_PROGRESS": start_ts = e.timestamp + if e.to_status == "DONE": end_ts = e.timestamp + + if start_ts and end_ts: + actual_days = (end_ts - start_ts).total_seconds() / 86400.0 + if actual_days < 0.1: actual_days = 0.1 # min + + # Planned days + vel = sprint_velocities.get(issue.sprint_id, 1.0) + planned_days = issue.story_points / vel + + ratio = actual_days / planned_days + + # Key + # We assume team_alpha for all as per dummy data + key = ("team_alpha", issue.module_id, issue.issue_type) + if key not in history: history[key] = [] + history[key].append(ratio) + + # Compile rules + for key, ratios in history.items(): + team, mod, itype = key + avg_ratio = sum(ratios) / len(ratios) + # Clamp + multiplier = max(1.0, min(avg_ratio, 2.5)) + + # Build explanation + expl = f"Historically {mod}/{itype} tasks take {multiplier:.1f}x longer than planned." + + rules.append(CorrectionRule( + team_id=team, + module_id=mod, + issue_type=itype, + multiplier=round(multiplier, 2), + samples_count=len(ratios), + explanation=expl + )) + + return rules diff --git a/backend/backend_app/core/planning_loader.py b/backend/backend_app/core/planning_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..ed8d394fbf288e73e0579e65a7d46be8cfdb9aa1 --- /dev/null +++ b/backend/backend_app/core/planning_loader.py @@ -0,0 +1,19 @@ +import json +from typing import List, Dict, Tuple +from backend_app.core.config import JIRA_SPRINTS_FILE, JIRA_ISSUES_FILE, JIRA_EVENTS_FILE +from backend_app.core.planning_models import RawSprint, RawIssue, RawIssueEvent + +def load_jira_files() -> Tuple[List[RawSprint], List[RawIssue], List[RawIssueEvent]]: + if not JIRA_SPRINTS_FILE.exists() or not JIRA_ISSUES_FILE.exists() or not JIRA_EVENTS_FILE.exists(): + raise FileNotFoundError("One or more Jira data files are missing.") + + with open(JIRA_SPRINTS_FILE, 'r') as f: + sprints = [RawSprint(**i) for i in json.load(f)] + + with open(JIRA_ISSUES_FILE, 'r') as f: + issues = [RawIssue(**i) for i in json.load(f)] + + with open(JIRA_EVENTS_FILE, 'r') as f: + events = [RawIssueEvent(**i) for i in json.load(f)] + + return sprints, issues, events diff --git a/backend/backend_app/core/planning_models.py b/backend/backend_app/core/planning_models.py new file mode 100644 index 0000000000000000000000000000000000000000..1edc18e06b3e5ba3971732734e57a4ccd00b9382 --- /dev/null +++ b/backend/backend_app/core/planning_models.py @@ -0,0 +1,66 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Dict +from datetime import datetime + +# --- Jira Data Models --- + +class RawSprint(BaseModel): + sprint_id: str + name: str + start_date: datetime + end_date: datetime + team_id: str + planned_story_points: int + +class RawIssue(BaseModel): + issue_id: str + sprint_id: str + title: str + issue_type: str # Story|Bug|Task + story_points: int + assignee: str + module_id: str + created_at: datetime + +class RawIssueEvent(BaseModel): + issue_id: str + timestamp: datetime + from_status: str + to_status: str + +# --- Planning Output Models --- + +class SprintMetrics(BaseModel): + sprint_id: str + name: str + start_date: datetime + end_date: datetime + planned_story_points: int + completed_story_points: int + completion_pct: float + + # Gap Metrics + reality_gap_score: int # 0-100 + points_completion_gap: float + + # Prediction + predicted_slip_days: int + predicted_finish_date: str # Just string for simplicity in display + + # Breakdown by module for detailed views + module_breakdown: Dict[str, Dict[str, float]] = Field(default_factory=dict) # mod -> {planned, actual} + + # Evidence & Recs + top_drivers: List[str] + recommended_actions: List[str] + +class CorrectionRule(BaseModel): + team_id: str + module_id: str + issue_type: str + multiplier: float + samples_count: int + explanation: str + +class AutoCorrectHeadline(BaseModel): + headline: str diff --git a/backend/backend_app/core/signals.py b/backend/backend_app/core/signals.py new file mode 100644 index 0000000000000000000000000000000000000000..4c47ddd42b406b3f696f0491c68c1fa256f817ee --- /dev/null +++ b/backend/backend_app/core/signals.py @@ -0,0 +1,146 @@ +from typing import List, Dict, Set +from datetime import datetime +from backend_app.core.models import RawPR, RawReview, RawCommit, Signal + +WEIGHT_COMMIT = 1.0 +WEIGHT_REVIEW_APPROVED = 3.0 +WEIGHT_REVIEW_COMMENTED = 2.0 +WEIGHT_REVIEW_CHANGES_REQUESTED = 2.5 + +def get_modules_for_paths(paths: List[str], modules_config: Dict[str, List[str]]) -> Set[str]: + """ + Given a list of file paths changed in a PR or Commit, + return the set of module_ids that apply. + A path belongs to a module if it starts with any of the module's prefixes. + """ + matched_modules = set() + # Debug: if no paths, maybe root? No, we need changed paths. + if not paths: + # If no paths info (e.g. from API limitation), assume root if config has root + if "root" in modules_config: + return {"root"} + return set() + + for path in paths: + path_str = str(path) + mapped = False + for mod_id, prefixes in modules_config.items(): + for prefix in prefixes: + # Handle root special case: prefix "" matches everything + if prefix == "" or path_str.startswith(prefix): + matched_modules.add(mod_id) + mapped = True + + # Fallback: if path didn't map to anything specific, map to 'root' if it exists + if not mapped and "root" in modules_config: + matched_modules.add("root") + + return matched_modules + +def process_signals( + prs: List[RawPR], + reviews: List[RawReview], + commits: List[RawCommit], + modules_config: Dict[str, List[str]] +) -> Dict[str, List[Signal]]: + """ + Convert raw events into signals grouped by module_id. + """ + signals_by_module: Dict[str, List[Signal]] = {} + + # Init empty list for all modules so we get a result even if 0 signals + for mod_id in modules_config: + signals_by_module[mod_id] = [] + + # Helper to append signal + def add_signal(mod_id: str, sig: Signal): + if mod_id not in signals_by_module: + signals_by_module[mod_id] = [] + signals_by_module[mod_id].append(sig) + + # 1. Process Commits + + # 1. Process Commits + for commit in commits: + files = commit.files_changed + if not files: + # If files list is empty from Supabase, try to fallback to 'root' + files = [] + + affected_modules = get_modules_for_paths(files, modules_config) + + for mod_id in affected_modules: + # Create signal from commit + sig = Signal( + person_id=commit.author, + module_id=mod_id, + signal_type="commit", + weight=1.0, # WEIGHT_COMMIT + timestamp=commit.timestamp, + source_id=commit.commit_id + ) + # Add to module list + if mod_id not in signals_by_module: + signals_by_module[mod_id] = [] + signals_by_module[mod_id].append(sig) + + + # 2. Process PRs and Reviews + # NEW RULE: If reviews are missing in Supabase, treat PR creation/merge as a signal for the AUTHOR. + # This ensures we get risk data even if no reviews exist. + + # Process PR Author signals + for pr in prs: + # Fallback for empty files list + files = pr.files_changed if pr.files_changed else [] + affected_modules = get_modules_for_paths(files, modules_config) + + for mod_id in affected_modules: + # Treat PR creation as a signal (e.g. weight 1.5) + sig = Signal( + person_id=pr.author, + module_id=mod_id, + signal_type="pr_created", + weight=1.5, + timestamp=pr.created_at, + source_id=pr.pr_id + ) + add_signal(mod_id, sig) + + # Process Reviews (if any) + pr_map = {pr.pr_id: pr for pr in prs} + + for review in reviews: + if review.pr_id not in pr_map: + continue # Skip reviews for unknown PRs + + pr = pr_map[review.pr_id] + affected_modules = get_modules_for_paths(pr.files_changed, modules_config) + + # Determine weight and type + w = 0.0 + s_type = "" + if review.state == "APPROVED": + w = WEIGHT_REVIEW_APPROVED + s_type = "review_approval" + elif review.state == "COMMENTED": + w = WEIGHT_REVIEW_COMMENTED + s_type = "review_comment" + elif review.state == "CHANGES_REQUESTED": + w = WEIGHT_REVIEW_CHANGES_REQUESTED + s_type = "review_changes_requested" + else: + continue # Unknown state + + for mod_id in affected_modules: + sig = Signal( + person_id=review.reviewer, + module_id=mod_id, + signal_type=s_type, + weight=w, + timestamp=review.timestamp, + source_id=review.pr_id + ) + add_signal(mod_id, sig) + + return signals_by_module diff --git a/backend/backend_app/core/strategic_controller.py b/backend/backend_app/core/strategic_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..6419586302200831c1298e5ec3d34b12959fb4fe --- /dev/null +++ b/backend/backend_app/core/strategic_controller.py @@ -0,0 +1,256 @@ +from datetime import datetime, timezone +import json +from openai import OpenAI +from backend_app.state.store import store +from backend_app.core.planning_engine import compute_autocorrect # Re-use metrics if needed +from backend_app.integrations.supabase_client import supabase + +# --- Configuration --- +FEATHERLESS_API_KEY = "rc_3a397e668b06eae8d8e477e2f5434b97dc9f3ffd8bf0856563a6d1cd9941fcac" +FEATHERLESS_BASE_URL = "https://api.featherless.ai/v1" +MODEL_NAME = "Qwen/Qwen2.5-7B-Instruct" + +def get_strategic_audit(): + """ + Gather data from the store, format it for the LLM, and request an executive briefing. + """ + + # 1. Gather Data (Jira Plan vs Github Reality) + + # Jira: Current Active Sprint (Sprint 02 in dummy data) + # We need to find the "current" sprint relative to NOW (2026-02-07T15:00:39+05:30) + # Using fixed NOW from previous context: 2026-02-07 + NOW = datetime(2026, 2, 7, 15, 0, 0, tzinfo=timezone.utc) + + current_sprint = None + if store.sprints: + for s in store.sprints: + s_start = s.start_date.replace(tzinfo=timezone.utc) if s.start_date.tzinfo is None else s.start_date + s_end = s.end_date.replace(tzinfo=timezone.utc) if s.end_date.tzinfo is None else s.end_date + + if s_start <= NOW <= s_end: + current_sprint = s + break + if not current_sprint: + current_sprint = store.sprints[-1] + + jira_summary = { + "sprint": current_sprint.name if current_sprint else "Unknown", + "planned_points": current_sprint.planned_story_points if current_sprint else 0, + "team": current_sprint.team_id if current_sprint else "Unknown", + "active_issues": [] + } + + # Filter issues for this sprint + issues_list = [] + if current_sprint: + # Be careful with ID match, assuming string match on sprint_id + issues_list = [i for i in store.issues if i.sprint_id == current_sprint.sprint_id] + + for i in issues_list: + jira_summary["active_issues"].append({ + "id": i.issue_id, + "title": i.title, + "points": i.story_points, + "module": i.module_id, + "assignee": i.assignee, + "type": i.issue_type # Assuming stored as issue_type + }) + + # GitHub: Recent Activity (in Sprint Window) + sprint_start = current_sprint.start_date.replace(tzinfo=timezone.utc) if current_sprint and current_sprint.start_date.tzinfo is None else (current_sprint.start_date if current_sprint else NOW) + sprint_end = current_sprint.end_date.replace(tzinfo=timezone.utc) if current_sprint and current_sprint.end_date.tzinfo is None else (current_sprint.end_date if current_sprint else NOW) + + github_summary = { + "recent_commits_count": 0, + "recent_prs": [], + "active_contributors": set() + } + + # Scan Commits + for c in store.commits: + c_ts = c.timestamp.replace(tzinfo=timezone.utc) if c.timestamp.tzinfo is None else c.timestamp + if sprint_start <= c_ts <= sprint_end: + github_summary["recent_commits_count"] += 1 + github_summary["active_contributors"].add(c.author) + + # Scan PRs + for p in store.prs: + p_ts = p.created_at.replace(tzinfo=timezone.utc) if p.created_at.tzinfo is None else p.created_at + relevant = False + if sprint_start <= p_ts <= sprint_end: relevant = True + + if p.merged_at: + p_m = p.merged_at.replace(tzinfo=timezone.utc) if p.merged_at.tzinfo is None else p.merged_at + if sprint_start <= p_m <= sprint_end: relevant = True + + if relevant: + github_summary["recent_prs"].append({ + "id": p.pr_id, + "author": p.author, + "files": p.files_changed[:2], + "merged": bool(p.merged_at) + }) + github_summary["active_contributors"].add(p.author) + + github_summary["active_contributors"] = list(github_summary["active_contributors"]) + + + # 2. Construct Prompt for LLM + + system_prompt = ( + "You are a 'Strategic Engineering Controller.' Your job is to reconcile two conflicting data sources: " + "Jira (The Plan) and GitHub (The Technical Reality). " + "You must identify 'Strategic Drift'—the gap between what the company thinks it's doing and what is actually happening. " + "Output your analysis in a concise, high-impact 'Executive Briefing' format." + ) + + user_prompt = f""" + DATA INPUTS: + + Jira Sprint Data: + {json.dumps(jira_summary, indent=2, default=str)} + + GitHub Activity: + {json.dumps(github_summary, indent=2, default=str)} + + TASK: Analyze these inputs and provide: + + 1. The Reality Score: A percentage (0-100%) of how "on track" the project truly is compared to the Jira board. + 2. The Shadow Work Audit: Identify what percentage of time is being spent on tasks NOT in Jira (e.g., maintenance, mentoring, or technical debt) based on GitHub activity vs Jira tickets. + 3. The Tribal Knowledge Hero: Identify the developer who is providing the most "unseen" value through mentoring and code reviews (infer from PRs/commits). + 4. Financial Risk Alert: Estimate the dollar cost of current delays (assume $100/hr avg cost) and suggest one specific resource reallocation to fix it. + 5. Executive Summary: A 3-sentence briefing for the CEO. + + Format the output clearly with headers. Be direct and concise. + """ + + # 3. Call LLM + try: + client = OpenAI( + base_url=FEATHERLESS_BASE_URL, + api_key=FEATHERLESS_API_KEY, + ) + + response = client.chat.completions.create( + model=MODEL_NAME, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.7, + max_tokens=600 + ) + + return response.choices[0].message.content + + except Exception as e: + return f"Error generating strategic audit: {str(e)}" + +def get_latest_jira_payload(): + """ + Fetch the latest Jira payload from the 'jira_data' table in Supabase. + """ + if not supabase: + print("Warning: Supabase client not initialized.") + return None + + try: + # Fetch the latest record based on synced_at + res = supabase.table("jira_data").select("jira_payload").order("synced_at", desc=True).limit(1).execute() + if res.data: + return res.data[0]['jira_payload'] + return None + except Exception as e: + print(f"Error fetching Jira payload: {e}") + return None + +def analyze_jira_from_db(): + """ + Compare Jira data from DB with GitHub reality using Featherless API. + """ + # 1. Fetch Jira Payload + jira_payload = get_latest_jira_payload() + if not jira_payload: + return "No Jira data found in database." + + # 2. Gather GitHub Data (Reality) + # Using existing store data (populated via /load_data or /load_live_data) + # We'll use a summary similar to get_strategic_audit but tailored for this comparison + + # Calculate time window based on data available or assume last 2 weeks if not specified + # For now, let's use the full loaded data context to find active sprint window if possible, + # or just summarize recent activity. + + # If jira_payload has sprint info, use that window. + # Assuming jira_payload is a dict with sprint info. + sprint_info = jira_payload.get("sprint", {}) if isinstance(jira_payload, dict) else {} + # If payload structure is list of issues, we might need to infer. + + # Simplification: pass the raw payload structure to LLM to interpret, + # along with a structured summary of GitHub activity. + + github_summary = { + "total_commits": len(store.commits), + "recent_commits": [ + { + "author": c.author, + "message": c.message if hasattr(c, 'message') else "", + "timestamp": str(c.timestamp) + } for c in store.commits[:20] # Last 20 commits + ], + "active_prs": [ + { + "id": str(p.pr_id), + "author": p.author, + "status": "merged" if p.merged_at else "open", + "created_at": str(p.created_at) + } for p in store.prs if not p.merged_at or (p.merged_at and (datetime.now(timezone.utc) - p.merged_at).days < 14) + ] + } + + # 3. Construct Prompt + system_prompt = ( + "You are an expert Engineering Analyst. Your goal is to compare the planned work (Jira) " + "against the actual engineering activity (GitHub) to identify discrepancies, risks, and " + "undocumented work." + ) + + user_prompt = f""" + JIRA DATA (The Plan): + {json.dumps(jira_payload, indent=2, default=str)} + + GITHUB DATA (The Reality): + {json.dumps(github_summary, indent=2, default=str)} + + TASK: + Analyze the alignment between the Jira plan and GitHub activity. + 1. Identify any work in GitHub that is not tracked in Jira (Shadow Work). + 2. Identify any Jira items that show no corresponding GitHub activity (Stalled Work). + 3. Provide a 'Reality Score' (0-100%) indicating how well the plan matches reality. + 4. Highlight top risks. + + Output in a clear, executive summary format. + """ + + # 4. Call Featherless API + try: + client = OpenAI( + base_url=FEATHERLESS_BASE_URL, + api_key=FEATHERLESS_API_KEY, + ) + + response = client.chat.completions.create( + model=MODEL_NAME, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.7, + max_tokens=800 + ) + + return response.choices[0].message.content + + except Exception as e: + return f"Error generating analysis: {str(e)}" diff --git a/backend/backend_app/integrations/__pycache__/repo_api.cpython-311.pyc b/backend/backend_app/integrations/__pycache__/repo_api.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a4f10a0618959e32c07d7537dda38919b3ecb487 Binary files /dev/null and b/backend/backend_app/integrations/__pycache__/repo_api.cpython-311.pyc differ diff --git a/backend/backend_app/integrations/__pycache__/repo_api.cpython-312.pyc b/backend/backend_app/integrations/__pycache__/repo_api.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..732c7a4751bcf2d7d02215ca73fcb305d144e83f Binary files /dev/null and b/backend/backend_app/integrations/__pycache__/repo_api.cpython-312.pyc differ diff --git a/backend/backend_app/integrations/__pycache__/supabase_client.cpython-311.pyc b/backend/backend_app/integrations/__pycache__/supabase_client.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07a70e35b022bc005718a1ffedec7793472f5fe2 Binary files /dev/null and b/backend/backend_app/integrations/__pycache__/supabase_client.cpython-311.pyc differ diff --git a/backend/backend_app/integrations/__pycache__/supabase_client.cpython-312.pyc b/backend/backend_app/integrations/__pycache__/supabase_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..62c2f0b58491ae379bd4fc90131b2668f0327f4a Binary files /dev/null and b/backend/backend_app/integrations/__pycache__/supabase_client.cpython-312.pyc differ diff --git a/backend/backend_app/integrations/repo_api.py b/backend/backend_app/integrations/repo_api.py new file mode 100644 index 0000000000000000000000000000000000000000..8243f124bd77da31e2e4276240b4f5e950564ddf --- /dev/null +++ b/backend/backend_app/integrations/repo_api.py @@ -0,0 +1,130 @@ +from typing import List, Dict, Optional, Any +from backend_app.integrations.supabase_client import supabase + +# Contract required by user: +# 1) get_pull_requests(org, repo) -> list[dict] +# 2) get_reviews(org, repo) -> list[dict] +# 3) get_commits(org, repo) -> list[dict] +# 4) get_modules_mapping(org, repo) -> dict + +def get_github_id(org: str, repo: str) -> Optional[str]: + """ + Helper to resolve GitHub ID from 'github' table. + Tries exact full_name match first, then falls back to repo name match. + """ + full_name = f"{org}/{repo}" + print(f"Resolving GitHub ID for {full_name}...") + + # 1. Try exact match on full_name + res = supabase.table("github").select("id").eq("full_name", full_name).limit(1).execute() + if res.data: + uid = res.data[0]['id'] + print(f"Found GitHub ID (exact match): {uid}") + return uid + + # 2. Fallback: Try match on 'repo' name only (User might use different org in request) + # This expects 'repo' column to exist as seen in user screenshot + print(f"Exact match failed. Trying fallback by repo name '{repo}'...") + res = supabase.table("github").select("id, full_name").eq("repo", repo).limit(1).execute() + if res.data: + uid = res.data[0]['id'] + found_name = res.data[0]['full_name'] + print(f"Found GitHub ID (fallback repo match): {uid} (Matches {found_name})") + return uid + + print(f"Error: Repository '{full_name}' (or repo '{repo}') not found in 'github' table.") + return None + +def get_pull_requests(org: str, repo: str) -> List[Dict]: + """ + Fetch PRs from Supabase 'pull_requests' table. + """ + if not supabase: + print("Warning: Supabase client not initialized.") + return [] + + github_id = get_github_id(org, repo) + if not github_id: + return [] + + try: + # 2. Query Pull Requests using github_id + res = supabase.table("pull_requests").select("*").eq("github_id", github_id).limit(500).execute() + data = res.data if res else [] + print(f"DEBUG: Fetched {len(data)} PRs. First item: {data[0] if data else 'None'}") + + parsed = [] + for item in data: + pr_id = str(item.get("pr_number", "unknown")) + author = item.get("author", "unknown") + created_at = item.get("created_at") + merged_at = item.get("merged_at") + files = [] + + parsed.append({ + "pr_id": pr_id, + "author": author, + "created_at": created_at, + "merged_at": merged_at, + "files_changed": files + }) + return parsed + except Exception as e: + print(f"Supabase PR fetch error: {e}") + return [] + +def get_reviews(org: str, repo: str) -> List[Dict]: + # Reviews not strictly required for risk analysis MVP (used for evidence but optional) + # User didn't show reviews table (showed 'pull_requests', 'issues', 'commits', 'contributors') + # Maybe inside pull_requests? For now return empty. + return [] + +def get_commits(org: str, repo: str) -> List[Dict]: + """ + Fetch commits from Supabase 'commits' table. + """ + if not supabase: + return [] + + github_id = get_github_id(org, repo) + if not github_id: + return [] + + try: + # 2. Query Commits using github_id + res = supabase.table("commits").select("*").eq("github_id", github_id).limit(1000).execute() + data = res.data if res else [] + print(f"DEBUG: Fetched {len(data)} Commits. First item: {data[0] if data else 'None'}") + + parsed = [] + for item in data: + # Map standard fields from PROVIDED SCHEMA + sha = item.get("sha", "unknown") + author = item.get("author", "unknown") + ts = item.get("committed_date") + message = item.get("message", "") + + # Again, commits table schema has additions/deletions but NO file list. + files = [] + + parsed.append({ + "commit_id": sha, + "author": author, + "timestamp": ts, + "message": message, + "files_changed": files + }) + return parsed + except Exception as e: + print(f"Supabase Commits fetch error: {e}") + # Return empty list gracefully if table is missing or column mismatch + return [] + +def get_modules_mapping(org: str, repo: str) -> Dict[str, List[str]]: + # Fallback / Default Contract + return { + "backend": ["backend/", "app/", "api/"], + "frontend": ["frontend/", "src/", "ui/"], + "docs": ["README.md", "docs/"], + "root": [""] + } diff --git a/backend/backend_app/integrations/supabase_client.py b/backend/backend_app/integrations/supabase_client.py new file mode 100644 index 0000000000000000000000000000000000000000..f8366dd9125000fdb847fd3edec8b90a69cdb055 --- /dev/null +++ b/backend/backend_app/integrations/supabase_client.py @@ -0,0 +1,17 @@ + +import os +from supabase import create_client, Client + +# Hardcoded for demo/hackathon convenience +SUPABASE_URL = "https://mikrcsxkyggxjeaqyfaw.supabase.co" +SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1pa3Jjc3hreWdneGplYXF5ZmF3Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3MDQyNjQ1OCwiZXhwIjoyMDg2MDAyNDU4fQ.pX3hcJJUQwJa0YC88iy8NUhORDZbB44fgTojvbZZO7A" + +supabase: Client = None + +try: + print(f"Initializing Supabase client with URL: {SUPABASE_URL}...") + supabase = create_client(SUPABASE_URL, SUPABASE_KEY) + print("Supabase client initialized.") +except Exception as e: + print(f"Error initializing Supabase client: {e}") + supabase = None diff --git a/backend/backend_app/main.py b/backend/backend_app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..2d67afc47fc8c02c6ca8ee7ed23a926320402294 --- /dev/null +++ b/backend/backend_app/main.py @@ -0,0 +1,42 @@ +import sys +import os +from pathlib import Path + +# Add the project root (backend directory) to sys.path so 'app' module can be found +#featherless_api_key = rc_3a397e668b06eae8d8e477e2f5434b97dc9f3ffd8bf0856563a6d1cd9941fcac +# when running this script directly. +BASE_DIR = Path(__file__).resolve().parent.parent +sys.path.append(str(BASE_DIR)) + +from fastapi import FastAPI +import uvicorn +from backend_app.api.routes import router as risk_router +from backend_app.api.planning_routes import router as planning_router +from backend_app.api.strategic_routes import router as strategic_router +from backend_app.api.risk_analysis import router as risk_analysis_router + +app = FastAPI(title="Tribal Knowledge Risk Index", version="1.0.0") + +from fastapi.middleware.cors import CORSMiddleware + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allows all origins + allow_credentials=True, + allow_methods=["*"], # Allows all methods + allow_headers=["*"], # Allows all headers +) + +app.include_router(risk_router) +app.include_router(planning_router) +app.include_router(strategic_router) +app.include_router(risk_analysis_router) + +@app.get("/") +def root(): + return {"message": "Service is running. Use /load_data or /analyze/risk."} + +if __name__ == "__main__": + # When running directly, use uvicorn to serve the app + # reload=True allows auto-restart on code changes (dev mode) + uvicorn.run("backend_app.main:app", host="127.0.0.1", port=5000, reload=True) diff --git a/backend/backend_app/state/__pycache__/store.cpython-311.pyc b/backend/backend_app/state/__pycache__/store.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9f795a39c0999daade1d32df89f502d7634a34ce Binary files /dev/null and b/backend/backend_app/state/__pycache__/store.cpython-311.pyc differ diff --git a/backend/backend_app/state/__pycache__/store.cpython-312.pyc b/backend/backend_app/state/__pycache__/store.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e6c606cacd376a3df0b55c64891911158f420125 Binary files /dev/null and b/backend/backend_app/state/__pycache__/store.cpython-312.pyc differ diff --git a/backend/backend_app/state/store.py b/backend/backend_app/state/store.py new file mode 100644 index 0000000000000000000000000000000000000000..b2d80f0f91fb785e0a08c1b79f3c19666f9e8d5d --- /dev/null +++ b/backend/backend_app/state/store.py @@ -0,0 +1,250 @@ +import json +from pathlib import Path +from typing import List, Dict, Optional +from datetime import datetime + +from backend_app.core.config import PRS_FILE, REVIEWS_FILE, COMMITS_FILE, MODULES_FILE +from backend_app.core.models import ( + RawPR, RawReview, RawCommit, ModulesConfig, ModuleMetric, Signal +) +from backend_app.core.planning_models import ( + RawSprint, RawIssue, RawIssueEvent, + SprintMetrics, CorrectionRule, AutoCorrectHeadline +) +from backend_app.core.signals import process_signals +from backend_app.core.metrics import compute_metrics +from backend_app.core.planning_loader import load_jira_files +from backend_app.core.planning_engine import compute_autocorrect + +from backend_app.integrations.repo_api import get_pull_requests, get_reviews, get_commits, get_modules_mapping + +class Store: + def __init__(self): + self.prs: List[RawPR] = [] + self.reviews: List[RawReview] = [] + self.commits: List[RawCommit] = [] + self.modules_config: ModulesConfig = {} + + self.module_metrics: Dict[str, ModuleMetric] = {} + + # Jira Data + self.sprints: List[RawSprint] = [] + self.issues: List[RawIssue] = [] + self.issue_events: List[RawIssueEvent] = [] + + # Planning metrics + self.sprint_metrics: List[SprintMetrics] = [] + self.correction_rules: List[CorrectionRule] = [] + self.planning_headline: str = "" + + self.loaded = False + self.jira_loaded = False + self.is_live_data = False + + def load_data(self) -> Dict[str, int]: + return self._load_dummy_data() + + def _load_dummy_data(self) -> Dict[str, int]: + if not PRS_FILE.exists() or not REVIEWS_FILE.exists() or not COMMITS_FILE.exists() or not MODULES_FILE.exists(): + raise FileNotFoundError("One or more data files are missing.") + + def load_json(path): + with open(path, 'r') as f: + return json.load(f) + + # Load PRs + raw_prs = load_json(PRS_FILE) + self.prs = [RawPR(**item) for item in raw_prs] + + # Load Reviews + raw_reviews = load_json(REVIEWS_FILE) + self.reviews = [RawReview(**item) for item in raw_reviews] + + # Load Commits + raw_commits = load_json(COMMITS_FILE) + self.commits = [RawCommit(**item) for item in raw_commits] + + # Load Modules + self.modules_config = load_json(MODULES_FILE) + + self.loaded = True + self.is_live_data = False + return { + "prs": len(self.prs), + "reviews": len(self.reviews), + "commits": len(self.commits), + "modules": len(self.modules_config) + } + + def load_live_data(self, org: str, repo: str) -> Dict[str, int]: + """ + Fetches live data via the integration functions and stores it in memory. + """ + # Clear existing + self.prs = [] + self.reviews = [] + self.commits = [] + + try: + print(f"Calling integration functions for {org}/{repo}...") + + # 1. Modules Mapping + self.modules_config = get_modules_mapping(org, repo) + + # 2. Commits + raw_commits = get_commits(org, repo) + self.commits = [] + for item in raw_commits: + ts_str = item.get("timestamp") + ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) if ts_str else datetime.now() + self.commits.append(RawCommit( + commit_id=item["commit_id"], + author=item["author"], + timestamp=ts, + message=item.get("message", ""), + files_changed=item.get("files_changed", []) + )) + + # 3. Pull Requests + raw_prs = get_pull_requests(org, repo) + self.prs = [] + for item in raw_prs: + ts_created = item.get("created_at") + ts_merged = item.get("merged_at") + + c_ts = datetime.fromisoformat(ts_created.replace("Z", "+00:00")) if ts_created else datetime.now() + m_ts = datetime.fromisoformat(ts_merged.replace("Z", "+00:00")) if ts_merged else None + + self.prs.append(RawPR( + pr_id=item["pr_id"], + author=item["author"], + created_at=c_ts, + merged_at=m_ts, + files_changed=item.get("files_changed", []) + )) + + # 4. Reviews + raw_reviews = get_reviews(org, repo) + self.reviews = [] + # ... map if reviews actually returned ... + + # Issues not in spec, clear them to avoid stale data + self.issues = [] + self.sprints = [] + + self.loaded = True + self.is_live_data = True + + return { + "prs": len(self.prs), + "reviews": len(self.reviews), + "commits": len(self.commits), + "modules": len(self.modules_config), + "source": "Integration Module (repo_api)" + } + + except Exception as e: + # Route handler should catch this to return 502 + # We raise a specific message + print(f"Integration failure: {e}") + raise Exception(f"Integration failed: {str(e)}") + + def compute(self) -> str: + """ + Compute all metrics for the loaded data. + Returns the headline string. + """ + if not self.loaded: + raise ValueError("Data not loaded. Call load_data first.") + + # Import here to avoid circular dependency if any (usually metrics imports models not store) + from backend_app.core.signals import process_signals + from backend_app.core.metrics import compute_metrics + + # 1. Process signals + signals_map = process_signals(self.prs, self.reviews, self.commits, self.modules_config) + + # 2. Metrics + # Iterate over ALL modules in config to ensure we include those with 0 signals. + all_mod_ids = set(self.modules_config.keys()) + + # Pre-calculate signal sums per module to find global max + module_sums = {} + for mid in all_mod_ids: + sigs = signals_map.get(mid, []) + module_sums[mid] = sum(s.weight for s in sigs) + + # Avoid zero division + max_total = max(module_sums.values()) if module_sums else 1.0 + if max_total == 0: max_total = 1.0 + + self.module_metrics = {} + for mid in all_mod_ids: + sigs = signals_map.get(mid, []) + # Compute metric + metric = compute_metrics(mid, sigs, max_total) + self.module_metrics[mid] = metric + + # Find max risk module for headline + sorted_mods = sorted(self.module_metrics.values(), key=lambda x: x.risk_index, reverse=True) + if not sorted_mods: + return "No modules analyzed." + + top_risk = sorted_mods[0] + top_person = top_risk.people[0].person_id if top_risk.people else "nobody" + + return f"{top_risk.module_id} module is at {top_risk.risk_index} risk ({top_risk.severity}) because {top_person} owns most of the knowledge signals." + + def get_modules(self) -> List[ModuleMetric]: + return sorted(self.module_metrics.values(), key=lambda x: x.risk_index, reverse=True) + + def load_jira_data(self) -> Dict[str, int]: + self.sprints, self.issues, self.issue_events = load_jira_files() + self.jira_loaded = True + return { + "sprints": len(self.sprints), + "issues": len(self.issues), + "events": len(self.issue_events) + } + + def compute_planning(self): + if not self.loaded: + raise ValueError("GitHub data must be loaded first (call /load_data).") + # If not jira_loaded, we can't compute planning metrics + if not self.jira_loaded: + raise ValueError("Jira data not loaded (call /load_jira_dummy).") + + # Reuse existing PRs/Reviews for Reality Gap analysis + items = compute_autocorrect( + self.sprints, + self.issues, + self.issue_events, + self.prs, + self.reviews, + self.modules_config + ) + self.sprint_metrics = items[0] + self.correction_rules = items[1] + self.planning_headline = items[2] + + def get_modules(self) -> List[ModuleMetric]: + return sorted(self.module_metrics.values(), key=lambda x: x.risk_index, reverse=True) + + def get_module(self, module_id: str) -> Optional[ModuleMetric]: + return self.module_metrics.get(module_id) + + def get_sprints(self) -> List[SprintMetrics]: + # Sort by reality gap desc + return sorted(self.sprint_metrics, key=lambda x: x.reality_gap_score, reverse=True) + + def get_sprint(self, sprint_id: str) -> Optional[SprintMetrics]: + for s in self.sprint_metrics: + if s.sprint_id == sprint_id: + return s + return None + + def get_corrections(self) -> List[CorrectionRule]: + return self.correction_rules # Fix naming consistency + +# Singleton instance +store = Store() diff --git a/backend/check_db_content.py b/backend/check_db_content.py new file mode 100644 index 0000000000000000000000000000000000000000..a36425f4867cc675a6b0f819d9409801e2a6dc9e --- /dev/null +++ b/backend/check_db_content.py @@ -0,0 +1,32 @@ + +import os +from supabase import create_client + +SUPABASE_URL = "https://mikrcsxkyggxjeaqyfaw.supabase.co" +SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1pa3Jjc3hreWdneGplYXF5ZmF3Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3MDQyNjQ1OCwiZXhwIjoyMDg2MDAyNDU4fQ.pX3hcJJUQwJa0YC88iy8NUhORDZbB44fgTojvbZZO7A" + +supabase = create_client(SUPABASE_URL, SUPABASE_KEY) + +print("--- Checking 'github' table (Top 5) ---") +try: + res = supabase.table("github").select("id, full_name, owner, repo").limit(5).execute() + for row in res.data: + print(row) +except Exception as e: + print(e) + +print("\n--- Checking 'commits' table (Top 5) ---") +try: + res = supabase.table("commits").select("id, sha, author, github_id").limit(5).execute() + for row in res.data: + print(row) +except Exception as e: + print(e) + +print("\n--- Checking 'pull_requests' table (Top 5) ---") +try: + res = supabase.table("pull_requests").select("id, pr_number, author, github_id").limit(5).execute() + for row in res.data: + print(row) +except Exception as e: + print(e) diff --git a/backend/data/commits.json b/backend/data/commits.json new file mode 100644 index 0000000000000000000000000000000000000000..a0546ad373fc3f0add5ecbc74a1f2f69736a3438 --- /dev/null +++ b/backend/data/commits.json @@ -0,0 +1,42 @@ +[ + { + "commit_id": "C-201", + "author": "dev_a", + "timestamp": "2026-02-01T09:10:00Z", + "files_changed": [ + "services/payments/transaction_service.py" + ] + }, + { + "commit_id": "C-202", + "author": "dev_a", + "timestamp": "2026-02-02T10:25:00Z", + "files_changed": [ + "services/payments/refund_service.py" + ] + }, + { + "commit_id": "C-203", + "author": "dev_b", + "timestamp": "2026-02-02T11:40:00Z", + "files_changed": [ + "services/payments/validator.py" + ] + }, + { + "commit_id": "C-204", + "author": "dev_d", + "timestamp": "2026-02-03T09:00:00Z", + "files_changed": [ + "services/auth/login_service.py" + ] + }, + { + "commit_id": "C-205", + "author": "dev_e", + "timestamp": "2026-02-04T14:30:00Z", + "files_changed": [ + "services/search/query_engine.py" + ] + } +] diff --git a/backend/data/jira_issue_events.json b/backend/data/jira_issue_events.json new file mode 100644 index 0000000000000000000000000000000000000000..ea559d7e2ba6423cb3067b4ab3ce5e7c60c1080c --- /dev/null +++ b/backend/data/jira_issue_events.json @@ -0,0 +1,116 @@ +[ + { + "issue_id": "JIRA-001", + "timestamp": "2026-01-15T10:00:00Z", + "from_status": "TODO", + "to_status": "IN_PROGRESS" + }, + { + "issue_id": "JIRA-001", + "timestamp": "2026-01-20T14:00:00Z", + "from_status": "IN_PROGRESS", + "to_status": "DONE" + }, + { + "issue_id": "JIRA-002", + "timestamp": "2026-01-16T09:30:00Z", + "from_status": "TODO", + "to_status": "IN_PROGRESS" + }, + { + "issue_id": "JIRA-002", + "timestamp": "2026-01-18T11:00:00Z", + "from_status": "IN_PROGRESS", + "to_status": "DONE" + }, + { + "issue_id": "JIRA-003", + "timestamp": "2026-01-15T13:00:00Z", + "from_status": "TODO", + "to_status": "IN_PROGRESS" + }, + { + "issue_id": "JIRA-003", + "timestamp": "2026-01-19T16:00:00Z", + "from_status": "IN_PROGRESS", + "to_status": "DONE" + }, + { + "issue_id": "JIRA-004", + "timestamp": "2026-01-17T09:00:00Z", + "from_status": "TODO", + "to_status": "IN_PROGRESS" + }, + { + "issue_id": "JIRA-004", + "timestamp": "2026-01-25T15:00:00Z", + "from_status": "IN_PROGRESS", + "to_status": "DONE" + }, + { + "issue_id": "JIRA-005", + "timestamp": "2026-01-19T10:00:00Z", + "from_status": "TODO", + "to_status": "IN_PROGRESS" + }, + { + "issue_id": "JIRA-005", + "timestamp": "2026-01-28T17:00:00Z", + "from_status": "IN_PROGRESS", + "to_status": "DONE" + }, + { + "issue_id": "JIRA-101", + "timestamp": "2026-02-01T10:00:00Z", + "from_status": "TODO", + "to_status": "IN_PROGRESS" + }, + { + "issue_id": "JIRA-101", + "timestamp": "2026-02-03T18:00:00Z", + "from_status": "IN_PROGRESS", + "to_status": "DONE" + }, + { + "issue_id": "JIRA-102", + "timestamp": "2026-02-01T11:00:00Z", + "from_status": "TODO", + "to_status": "IN_PROGRESS" + }, + { + "issue_id": "JIRA-103", + "timestamp": "2026-02-02T09:00:00Z", + "from_status": "TODO", + "to_status": "IN_PROGRESS" + }, + { + "issue_id": "JIRA-104", + "timestamp": "2026-02-03T10:00:00Z", + "from_status": "TODO", + "to_status": "IN_PROGRESS" + }, + { + "issue_id": "JIRA-104", + "timestamp": "2026-02-05T16:00:00Z", + "from_status": "IN_PROGRESS", + "to_status": "DONE" + }, + { + "issue_id": "JIRA-105", + "timestamp": "2026-02-04T09:30:00Z", + "from_status": "TODO", + "to_status": "IN_PROGRESS" + }, + { + "issue_id": "JIRA-105", + "timestamp": "2026-02-06T14:00:00Z", + "from_status": "IN_PROGRESS", + "to_status": "DONE" + }, + { + "issue_id": "JIRA-106", + "timestamp": "2026-02-06T15:00:00Z", + "from_status": "TODO", + "to_status": "IN_PROGRESS" + } +] \ No newline at end of file diff --git a/backend/data/jira_issues.json b/backend/data/jira_issues.json new file mode 100644 index 0000000000000000000000000000000000000000..7a5a6256dfcb1b0647de7ada953780206be78d18 --- /dev/null +++ b/backend/data/jira_issues.json @@ -0,0 +1,112 @@ +[ + { + "issue_id": "JIRA-001", + "sprint_id": "SPR-01", + "title": "Initial user setup", + "issue_type": "Story", + "story_points": 8, + "assignee": "dev_a", + "module_id": "auth", + "created_at": "2026-01-14T09:00:00Z" + }, + { + "issue_id": "JIRA-002", + "sprint_id": "SPR-01", + "title": "Basic search UI", + "issue_type": "Story", + "story_points": 5, + "assignee": "dev_b", + "module_id": "search", + "created_at": "2026-01-14T10:00:00Z" + }, + { + "issue_id": "JIRA-003", + "sprint_id": "SPR-01", + "title": "Payment gateway integration spike", + "issue_type": "Task", + "story_points": 5, + "assignee": "dev_c", + "module_id": "payments", + "created_at": "2026-01-15T09:00:00Z" + }, + { + "issue_id": "JIRA-004", + "sprint_id": "SPR-01", + "title": "User profile page", + "issue_type": "Story", + "story_points": 8, + "assignee": "dev_d", + "module_id": "auth", + "created_at": "2026-01-16T11:00:00Z" + }, + { + "issue_id": "JIRA-005", + "sprint_id": "SPR-01", + "title": "Search indexing optimization", + "issue_type": "Task", + "story_points": 14, + "assignee": "dev_e", + "module_id": "search", + "created_at": "2026-01-18T14:00:00Z" + }, + { + "issue_id": "JIRA-101", + "sprint_id": "SPR-02", + "title": "Refund service validation", + "issue_type": "Story", + "story_points": 5, + "assignee": "dev_b", + "module_id": "payments", + "created_at": "2026-02-01T09:00:00Z" + }, + { + "issue_id": "JIRA-102", + "sprint_id": "SPR-02", + "title": "Bulk refund API", + "issue_type": "Story", + "story_points": 8, + "assignee": "dev_c", + "module_id": "payments", + "created_at": "2026-02-01T09:15:00Z" + }, + { + "issue_id": "JIRA-103", + "sprint_id": "SPR-02", + "title": "Refactor payment legacy code", + "issue_type": "Task", + "story_points": 13, + "assignee": "dev_a", + "module_id": "payments", + "created_at": "2026-02-01T10:00:00Z" + }, + { + "issue_id": "JIRA-104", + "sprint_id": "SPR-02", + "title": "Login with Google", + "issue_type": "Story", + "story_points": 5, + "assignee": "dev_d", + "module_id": "auth", + "created_at": "2026-02-02T11:00:00Z" + }, + { + "issue_id": "JIRA-105", + "sprint_id": "SPR-02", + "title": "Search filters UI", + "issue_type": "Story", + "story_points": 8, + "assignee": "dev_e", + "module_id": "search", + "created_at": "2026-02-03T09:00:00Z" + }, + { + "issue_id": "JIRA-106", + "sprint_id": "SPR-02", + "title": "Fix search pagination bug", + "issue_type": "Bug", + "story_points": 3, + "assignee": "dev_e", + "module_id": "search", + "created_at": "2026-02-05T14:00:00Z" + } +] \ No newline at end of file diff --git a/backend/data/jira_sprints.json b/backend/data/jira_sprints.json new file mode 100644 index 0000000000000000000000000000000000000000..5adbefb43926886bf5fad01555ad3fd880ee0cf5 --- /dev/null +++ b/backend/data/jira_sprints.json @@ -0,0 +1,18 @@ +[ + { + "sprint_id": "SPR-01", + "name": "Sprint 01", + "start_date": "2026-01-15T00:00:00Z", + "end_date": "2026-01-29T23:59:59Z", + "team_id": "team_alpha", + "planned_story_points": 40 + }, + { + "sprint_id": "SPR-02", + "name": "Sprint 02", + "start_date": "2026-02-01T00:00:00Z", + "end_date": "2026-02-14T23:59:59Z", + "team_id": "team_alpha", + "planned_story_points": 45 + } +] \ No newline at end of file diff --git a/backend/data/modules.json b/backend/data/modules.json new file mode 100644 index 0000000000000000000000000000000000000000..530a6dc7a98c555e54b1436e1b77ea01f90061e4 --- /dev/null +++ b/backend/data/modules.json @@ -0,0 +1,5 @@ +{ + "payments": ["services/payments/"], + "auth": ["services/auth/"], + "search": ["services/search/"] +} diff --git a/backend/data/prs.json b/backend/data/prs.json new file mode 100644 index 0000000000000000000000000000000000000000..cda07379b09e523abb1954c771bd05895428d9c8 --- /dev/null +++ b/backend/data/prs.json @@ -0,0 +1,39 @@ +[ + { + "pr_id": "PR-101", + "author": "dev_b", + "created_at": "2026-02-01T10:15:00Z", + "merged_at": "2026-02-02T14:20:00Z", + "files_changed": [ + "services/payments/transaction_service.py", + "services/payments/validator.py" + ] + }, + { + "pr_id": "PR-102", + "author": "dev_c", + "created_at": "2026-02-03T09:30:00Z", + "merged_at": "2026-02-03T18:10:00Z", + "files_changed": [ + "services/payments/refund_service.py" + ] + }, + { + "pr_id": "PR-103", + "author": "dev_d", + "created_at": "2026-02-04T11:00:00Z", + "merged_at": "2026-02-05T16:45:00Z", + "files_changed": [ + "services/auth/login_service.py" + ] + }, + { + "pr_id": "PR-104", + "author": "dev_e", + "created_at": "2026-02-05T08:40:00Z", + "merged_at": "2026-02-05T12:30:00Z", + "files_changed": [ + "services/search/query_engine.py" + ] + } +] diff --git a/backend/data/reviews.json b/backend/data/reviews.json new file mode 100644 index 0000000000000000000000000000000000000000..6c8cf5faaf20659f31a171ab330198fb6e18c9e1 --- /dev/null +++ b/backend/data/reviews.json @@ -0,0 +1,26 @@ +[ + { + "pr_id": "PR-101", + "reviewer": "dev_a", + "state": "APPROVED", + "timestamp": "2026-02-02T13:50:00Z" + }, + { + "pr_id": "PR-102", + "reviewer": "dev_a", + "state": "APPROVED", + "timestamp": "2026-02-03T17:45:00Z" + }, + { + "pr_id": "PR-103", + "reviewer": "dev_b", + "state": "COMMENTED", + "timestamp": "2026-02-04T15:10:00Z" + }, + { + "pr_id": "PR-104", + "reviewer": "dev_c", + "state": "APPROVED", + "timestamp": "2026-02-05T11:50:00Z" + } +] diff --git a/backend/test_supabase.py b/backend/test_supabase.py new file mode 100644 index 0000000000000000000000000000000000000000..2b502fbecd73ddfcae3c1acf5af533e88f2a92f9 --- /dev/null +++ b/backend/test_supabase.py @@ -0,0 +1,34 @@ + +import os +from supabase import create_client + +SUPABASE_URL = "https://mikrcsxkyggxjeaqyfaw.supabase.co" +SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1pa3Jjc3hreWdneGplYXF5ZmF3Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3MDQyNjQ1OCwiZXhwIjoyMDg2MDAyNDU4fQ.pX3hcJJUQwJa0YC88iy8NUhORDZbB44fgTojvbZZO7A" + +print(f"1. Connecting to {SUPABASE_URL}...") +try: + supabase = create_client(SUPABASE_URL, SUPABASE_KEY) + print(" Connection object created.") +except Exception as e: + print(f" Connection FAILED: {e}") + exit(1) + +print("\n2. Testing Fetch (Pull Requests)...") +try: + # Fetch just ONE row to test connectivity + res = supabase.table("pull_requests").select("count", count="exact").limit(1).execute() + print(" Success! Result:", res) + if res.data: + print(f" Found data: {res.data[0]}") + else: + print(" Connection successful but returned NO data.") +except Exception as e: + print(f" Fetch FAILED: {e}") + +print("\n3. Testing Fetch (Commits)...") +try: + # Fetch just ONE row + res = supabase.table("commits").select("count", count="exact").limit(1).execute() + print(" Success! Result:", res) +except Exception as e: + print(f" Fetch FAILED: {e}")