gitlytics 0.1.2__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {gitlytics-0.1.2 → gitlytics-0.1.3}/PKG-INFO +15 -10
  2. {gitlytics-0.1.2 → gitlytics-0.1.3}/README.md +2 -2
  3. gitlytics-0.1.3/pyproject.toml +74 -0
  4. gitlytics-0.1.3/src/gitlytics/__init__.py +140 -0
  5. gitlytics-0.1.3/src/gitlytics/__main__.py +9 -0
  6. gitlytics-0.1.3/src/gitlytics/api.py +177 -0
  7. gitlytics-0.1.3/src/gitlytics/automation.py +217 -0
  8. {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics/cli.py +35 -7
  9. gitlytics-0.1.3/src/gitlytics/core.py +288 -0
  10. gitlytics-0.1.3/src/gitlytics/process.py +237 -0
  11. gitlytics-0.1.2/src/gitlytics/static/assets/index-BPCIBQz4.js → gitlytics-0.1.3/src/gitlytics/static/assets/index-B5LHR_NK.js +3 -3
  12. gitlytics-0.1.3/src/gitlytics/static/assets/index-Cbu2tSV_.css +2 -0
  13. {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics/static/index.html +2 -2
  14. {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics.egg-info/PKG-INFO +15 -10
  15. {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics.egg-info/SOURCES.txt +6 -3
  16. gitlytics-0.1.3/src/gitlytics.egg-info/requires.txt +16 -0
  17. gitlytics-0.1.3/tests/test_api.py +264 -0
  18. gitlytics-0.1.3/tests/test_automation.py +178 -0
  19. gitlytics-0.1.3/tests/test_cli.py +130 -0
  20. gitlytics-0.1.3/tests/test_core.py +226 -0
  21. gitlytics-0.1.3/tests/test_process.py +321 -0
  22. gitlytics-0.1.2/pyproject.toml +0 -52
  23. gitlytics-0.1.2/src/gitlytics/__init__.py +0 -73
  24. gitlytics-0.1.2/src/gitlytics/api.py +0 -112
  25. gitlytics-0.1.2/src/gitlytics/automation.py +0 -153
  26. gitlytics-0.1.2/src/gitlytics/core.py +0 -188
  27. gitlytics-0.1.2/src/gitlytics/process.py +0 -117
  28. gitlytics-0.1.2/src/gitlytics/static/assets/index-DytQw1pB.css +0 -2
  29. gitlytics-0.1.2/src/gitlytics.egg-info/requires.txt +0 -13
  30. gitlytics-0.1.2/tests/test_automation.py +0 -72
  31. gitlytics-0.1.2/tests/test_cli.py +0 -56
  32. gitlytics-0.1.2/tests/test_core.py +0 -55
  33. {gitlytics-0.1.2 → gitlytics-0.1.3}/LICENSE +0 -0
  34. {gitlytics-0.1.2 → gitlytics-0.1.3}/setup.cfg +0 -0
  35. {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics/static/favicon.svg +0 -0
  36. {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics/static/icons.svg +0 -0
  37. {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics.egg-info/dependency_links.txt +0 -0
  38. {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics.egg-info/entry_points.txt +0 -0
  39. {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics.egg-info/top_level.txt +0 -0
@@ -1,10 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlytics
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Monitor and automate your GitHub repository traffic analytics.
5
5
  Author: Ameya Chopade
6
6
  License: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/ameyac11/gitlytics
8
+ Project-URL: Repository, https://github.com/ameyac11/gitlytics
9
+ Project-URL: Bug Tracker, https://github.com/ameyac11/gitlytics/issues
8
10
  Keywords: github,traffic,analytics,automation,cli
9
11
  Classifier: Programming Language :: Python :: 3
10
12
  Classifier: License :: OSI Approved :: Apache Software License
@@ -12,17 +14,20 @@ Classifier: Operating System :: OS Independent
12
14
  Requires-Python: >=3.9
13
15
  Description-Content-Type: text/markdown
14
16
  License-File: LICENSE
15
- Requires-Dist: requests
16
- Requires-Dist: pandas
17
- Requires-Dist: python-dotenv
18
- Requires-Dist: croniter
17
+ Requires-Dist: requests>=2.32.0
18
+ Requires-Dist: pandas>=2.2.0
19
+ Requires-Dist: python-dotenv>=1.0.1
20
+ Requires-Dist: croniter>=2.0.0
19
21
  Provides-Extra: dashboard
20
- Requires-Dist: fastapi; extra == "dashboard"
21
- Requires-Dist: uvicorn; extra == "dashboard"
22
- Requires-Dist: python-multipart; extra == "dashboard"
22
+ Requires-Dist: fastapi>=0.111.0; extra == "dashboard"
23
+ Requires-Dist: uvicorn>=0.30.0; extra == "dashboard"
24
+ Requires-Dist: python-multipart>=0.0.9; extra == "dashboard"
23
25
  Provides-Extra: dev
24
26
  Requires-Dist: pytest>=8.0.0; extra == "dev"
25
27
  Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
28
+ Requires-Dist: httpx>=0.27.0; extra == "dev"
29
+ Requires-Dist: Faker>=20.0.0; extra == "dev"
30
+ Requires-Dist: anyio[trio]>=4.0.0; extra == "dev"
26
31
  Dynamic: license-file
27
32
 
28
33
  <div align="center">
@@ -159,7 +164,7 @@ import gitlytics
159
164
  # Fetch traffic for all repositories accessible by the token
160
165
  df = gitlytics.fetch_traffic(
161
166
  token="ghp_your_token",
162
- return_format="dataframe" # Options: "dataframe" (Pandas), "timeseries" (React Chart-ready dict), or "raw" (JSON API payload)
167
+ return_format="dataframe" # Options: "dataframe" (Pandas), "timeseries" (chart-ready dict), or "summary" (per-repo totals dict)
163
168
  )
164
169
 
165
170
  # Fetch traffic for a single specific repository and print the table to stdout
@@ -183,7 +188,7 @@ gitlytics.fetch_traffic(
183
188
  | `token` | `str` | *Required* | GitHub Personal Access Token with `repo` scope enabled. |
184
189
  | `repo_name` | `str` | `None` | Specific repository name (e.g. `"user/repo"`). If `None`, fetches all repositories. |
185
190
  | `print_table` | `bool` | `False` | If `True`, formats and prints a detailed ASCII traffic table to the console. |
186
- | `return_format` | `str` | `"dataframe"` | The format of returned data: `"dataframe"` (Pandas DataFrame), `"timeseries"`, or `"raw"`. |
191
+ | `return_format` | `str` | `"dataframe"` | The format of returned data: `"dataframe"` (Pandas DataFrame), `"timeseries"` (chart-ready nested dict), or `"summary"` (per-repo totals dict). |
187
192
  | `save_file` | `str` | `None` | Optional. File path where the fetched data will be saved (CSV or JSON). |
188
193
 
189
194
  ---
@@ -132,7 +132,7 @@ import gitlytics
132
132
  # Fetch traffic for all repositories accessible by the token
133
133
  df = gitlytics.fetch_traffic(
134
134
  token="ghp_your_token",
135
- return_format="dataframe" # Options: "dataframe" (Pandas), "timeseries" (React Chart-ready dict), or "raw" (JSON API payload)
135
+ return_format="dataframe" # Options: "dataframe" (Pandas), "timeseries" (chart-ready dict), or "summary" (per-repo totals dict)
136
136
  )
137
137
 
138
138
  # Fetch traffic for a single specific repository and print the table to stdout
@@ -156,7 +156,7 @@ gitlytics.fetch_traffic(
156
156
  | `token` | `str` | *Required* | GitHub Personal Access Token with `repo` scope enabled. |
157
157
  | `repo_name` | `str` | `None` | Specific repository name (e.g. `"user/repo"`). If `None`, fetches all repositories. |
158
158
  | `print_table` | `bool` | `False` | If `True`, formats and prints a detailed ASCII traffic table to the console. |
159
- | `return_format` | `str` | `"dataframe"` | The format of returned data: `"dataframe"` (Pandas DataFrame), `"timeseries"`, or `"raw"`. |
159
+ | `return_format` | `str` | `"dataframe"` | The format of returned data: `"dataframe"` (Pandas DataFrame), `"timeseries"` (chart-ready nested dict), or `"summary"` (per-repo totals dict). |
160
160
  | `save_file` | `str` | `None` | Optional. File path where the fetched data will be saved (CSV or JSON). |
161
161
 
162
162
  ---
@@ -0,0 +1,74 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gitlytics"
7
+ version = "0.1.3"
8
+ description = "Monitor and automate your GitHub repository traffic analytics."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [
13
+ { name = "Ameya Chopade" }
14
+ ]
15
+ # Fix #20: Add version lower-bounds to prevent silent breakage on old environments.
16
+ # These match the constraints already documented in requirements.txt.
17
+ dependencies = [
18
+ "requests>=2.32.0",
19
+ "pandas>=2.2.0",
20
+ "python-dotenv>=1.0.1",
21
+ "croniter>=2.0.0"
22
+ ]
23
+ keywords = [
24
+ "github",
25
+ "traffic",
26
+ "analytics",
27
+ "automation",
28
+ "cli"
29
+ ]
30
+ classifiers = [
31
+ "Programming Language :: Python :: 3",
32
+ "License :: OSI Approved :: Apache Software License",
33
+ "Operating System :: OS Independent",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/ameyac11/gitlytics"
38
+ Repository = "https://github.com/ameyac11/gitlytics"
39
+ "Bug Tracker" = "https://github.com/ameyac11/gitlytics/issues"
40
+
41
+ [project.optional-dependencies]
42
+ # Fix #20: dashboard extras are optional — base package works without them.
43
+ # requirements.txt was including them as mandatory; that was wrong.
44
+ dashboard = [
45
+ "fastapi>=0.111.0",
46
+ "uvicorn>=0.30.0",
47
+ "python-multipart>=0.0.9"
48
+ ]
49
+ dev = [
50
+ "pytest>=8.0.0",
51
+ "pytest-cov>=5.0.0",
52
+ "httpx>=0.27.0", # required by FastAPI TestClient (used in test_api.py)
53
+ "Faker>=20.0.0", # used by pytest-faker plugin for generating test data
54
+ "anyio[trio]>=4.0.0" # async test support
55
+ ]
56
+
57
+
58
+ [project.scripts]
59
+ gitlytics = "gitlytics.cli:main"
60
+
61
+ [tool.setuptools.packages.find]
62
+ where = ["src"]
63
+ include = ["gitlytics*"]
64
+
65
+ [tool.setuptools.package-data]
66
+ "gitlytics" = ["static/**/*", "static/*"]
67
+
68
+ [tool.pytest.ini_options]
69
+ # Only collect files that match the offline test naming convention.
70
+ # live_*.py scripts are excluded — they need a real GitHub token.
71
+ testpaths = ["tests"]
72
+ python_files = ["test_*.py"]
73
+ python_classes = ["Test*"]
74
+ python_functions = ["test_*"]
@@ -0,0 +1,140 @@
1
+ """
2
+ gitlytics/__init__.py
3
+ The public API for the gitlytics package.
4
+ """
5
+ import os
6
+ import logging
7
+ import json
8
+
9
+ # Single source of truth for the package version.
10
+ # Mirrors the version in pyproject.toml — keep them in sync.
11
+ __version__ = "0.1.3"
12
+
13
+ # Import the internal building blocks — users never call these directly
14
+ from .core import fetch_traffic_data, print_repo_table
15
+ from .automation import run_sync
16
+ from .process import build_json_payload
17
+
18
+ # Set up a silent logger so gitlytics never messes with your app's logging
19
+ logger = logging.getLogger(__name__)
20
+ logger.addHandler(logging.NullHandler())
21
+
22
+
23
+ def fetch_traffic(token: str, repo_name=None, print_table: bool = False, return_format: str = "dataframe", save_file: str = None):
24
+ """
25
+ Fetches the last 14 days of traffic data for one or all repositories.
26
+
27
+ Args:
28
+ token: GitHub Personal Access Token with `repo` scope.
29
+ repo_name: Specific repo name (e.g. ``"user/repo"``) or list of names.
30
+ If ``None``, fetches all repositories accessible by the token.
31
+ print_table: If ``True``, prints an ASCII summary table to stdout.
32
+ return_format: Shape of the returned data. One of:
33
+ ``"dataframe"`` (default) — returns a ``pandas.DataFrame``.
34
+ ``"timeseries"`` — returns a nested JSON-serialisable dict.
35
+ ``"summary"`` — returns a per-repo totals dict.
36
+ save_file: Optional path to save the output. Extension determines
37
+ format: ``.json`` writes JSON, anything else writes CSV.
38
+
39
+ Returns:
40
+ A ``pandas.DataFrame`` when ``return_format="dataframe"``, otherwise
41
+ a ``dict`` matching the requested format.
42
+ """
43
+ # Hit the GitHub API and get back a tidy DataFrame (one row per day per repo)
44
+ df = fetch_traffic_data(token, repo_name)
45
+
46
+ # Print the ASCII table to the console if the user asked for it
47
+ if print_table:
48
+ print_repo_table(df)
49
+
50
+ # --- dataframe mode: just return the raw DataFrame, optionally save it ---
51
+ if return_format == "dataframe":
52
+ if save_file:
53
+ if save_file.endswith(".json"):
54
+ # Save as a chart-ready JSON file
55
+ payload = build_json_payload(df, return_format="timeseries", export_public_only=True)
56
+ with open(save_file, "w", encoding="utf-8") as f:
57
+ json.dump(payload, f, indent=2)
58
+ else:
59
+ # Save as a standard CSV file
60
+ df.to_csv(save_file, index=False)
61
+ return df
62
+
63
+ # Reject anything that isn't a known format before doing more work
64
+ valid_formats = {"timeseries", "summary"}
65
+ if return_format not in valid_formats:
66
+ raise ValueError(
67
+ f"Invalid return_format={return_format!r}. "
68
+ f"Choose one of: 'dataframe', 'timeseries', 'summary'."
69
+ )
70
+
71
+ # Build the JSON-serialisable payload in the requested shape
72
+ payload = build_json_payload(df, return_format=return_format, export_public_only=False)
73
+
74
+ # Save to disk if the user gave us a file path
75
+ if save_file:
76
+ with open(save_file, "w", encoding="utf-8") as f:
77
+ json.dump(payload, f, indent=2)
78
+
79
+ return payload
80
+
81
+
82
+ def sync(token: str, repo_name=None, data_dir: str = "./data", output_mode: str = "monthly", schedule_cron: str = None, export_json: str = None, export_public_only: bool = True):
83
+ """
84
+ Fetches data and appends it to a local CSV database, optionally running as a permanent background daemon.
85
+
86
+ Args:
87
+ token: GitHub Personal Access Token.
88
+ repo_name: Specific repository name(s) to sync. If ``None``, syncs all.
89
+ data_dir: Directory where CSV files are stored.
90
+ output_mode: ``"monthly"`` (``traffic_YYYY-MM.csv``) or ``"yearly"`` (``traffic_YYYY.csv``).
91
+ schedule_cron: Standard cron expression (e.g. ``"0 23 * * *"``). If set,
92
+ runs an infinite scheduler loop.
93
+ export_json: Path to export the merged historical database as a JSON file.
94
+ export_public_only: If ``True`` (default), strips private repos from the
95
+ exported JSON — acts as a security firewall.
96
+ """
97
+ # Hand off to the automation engine — it handles deduplication and schema migration
98
+ run_sync(
99
+ token=token,
100
+ repo_names=repo_name,
101
+ data_dir=data_dir,
102
+ output_mode=output_mode,
103
+ schedule_cron=schedule_cron,
104
+ export_json=export_json,
105
+ export_public_only=export_public_only
106
+ )
107
+
108
+
109
+ def serve_dashboard(host: str = "127.0.0.1", port: int = 8000, token: str = None, data_dir: str = None):
110
+ """
111
+ Starts the React + FastAPI dashboard server.
112
+
113
+ ``uvicorn`` and ``fastapi`` are optional dependencies installed via::
114
+
115
+ pip install "gitlytics[dashboard]"
116
+
117
+ Args:
118
+ host: Host IP to bind. Use ``"0.0.0.0"`` to listen on all interfaces.
119
+ port: Port number (default ``8000``).
120
+ token: Optional GitHub token — pre-authenticates the dashboard session.
121
+ data_dir: Optional path to the historical CSV database directory.
122
+ """
123
+ # Only import uvicorn when the user actually calls serve_dashboard,
124
+ # so the base `pip install gitlytics` doesn't crash without it
125
+ try:
126
+ import uvicorn
127
+ except ImportError:
128
+ raise ImportError(
129
+ "The dashboard requires additional dependencies. "
130
+ "Install them with: pip install \"gitlytics[dashboard]\""
131
+ )
132
+
133
+ # Pass the token and data folder to the FastAPI app via environment variables
134
+ if token:
135
+ os.environ["GITLYTICS_TOKEN"] = token
136
+ if data_dir:
137
+ os.environ["GITLYTICS_DATA_DIR"] = os.path.abspath(data_dir)
138
+
139
+ # Start the web server — it won't return until the user presses Ctrl+C
140
+ uvicorn.run("gitlytics.api:app", host=host, port=port, reload=False)
@@ -0,0 +1,9 @@
1
+ """
2
+ gitlytics/__main__.py
3
+ Makes `python -m gitlytics` work identically to the `gitlytics` console command.
4
+ This is the entry point Python calls when the package is run with -m.
5
+ """
6
+ from gitlytics.cli import main
7
+
8
+ # Run the CLI when invoked as `python -m gitlytics`
9
+ main()
@@ -0,0 +1,177 @@
1
+ """
2
+ gitlytics/api.py
3
+ Powers the FastAPI backend — serves traffic data and the React dashboard to the browser.
4
+ """
5
+ import logging
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import pandas as pd
10
+ from fastapi import FastAPI, HTTPException, Body, File, UploadFile
11
+ from fastapi.responses import FileResponse, JSONResponse
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.staticfiles import StaticFiles
14
+
15
+ from gitlytics.core import validate_token, get_user_profile, fetch_traffic_data
16
+ from gitlytics.process import process_uploaded_csv, build_react_payload
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ app = FastAPI(title="GitHub Traffic API")
21
+
22
+ # Only allow requests from localhost — this dashboard is never deployed publicly
23
+ _ALLOWED_ORIGINS = [
24
+ "http://localhost",
25
+ "http://localhost:3000",
26
+ "http://localhost:5173",
27
+ "http://localhost:8000",
28
+ "http://127.0.0.1",
29
+ "http://127.0.0.1:3000",
30
+ "http://127.0.0.1:5173",
31
+ "http://127.0.0.1:8000",
32
+ ]
33
+
34
+ app.add_middleware(
35
+ CORSMiddleware,
36
+ allow_origins=_ALLOWED_ORIGINS,
37
+ allow_credentials=True,
38
+ allow_methods=["GET", "POST"],
39
+ allow_headers=["Content-Type"],
40
+ )
41
+
42
+
43
+ def _get_token(token: str = None) -> str:
44
+ # Use the token from the request body, or fall back to the one set in the environment
45
+ return token or os.environ.get("GITLYTICS_TOKEN")
46
+
47
+
48
+ @app.get("/api/config")
49
+ def get_config():
50
+ # Lets the frontend know if it's running in headless/TV mode with a pre-set token
51
+ return {
52
+ "has_token": bool(os.environ.get("GITLYTICS_TOKEN")),
53
+ "has_data_dir": bool(os.environ.get("GITLYTICS_DATA_DIR"))
54
+ }
55
+
56
+
57
+ @app.post("/api/auth")
58
+ def auth(token: str = Body("", embed=True)):
59
+ # Validate the token and return the user's GitHub profile info
60
+ active_token = _get_token(token)
61
+ if not active_token:
62
+ raise HTTPException(status_code=401, detail="No token provided and no environment token found.")
63
+
64
+ ok, username = validate_token(active_token)
65
+ if not ok:
66
+ # Log a warning without echoing the token value into logs
67
+ logger.warning("Authentication attempt failed for a provided token.")
68
+ raise HTTPException(status_code=401, detail=username)
69
+
70
+ # Fetch the real display name and avatar URL — validate_token only gives us the login
71
+ profile = get_user_profile(active_token)
72
+
73
+ return {
74
+ "authenticated": True,
75
+ "username": profile["login"] or username,
76
+ "name": profile["name"] or username, # Real display name, e.g. "Ameya Chopade"
77
+ "avatar_url": profile["avatar_url"], # Real GitHub avatar URL
78
+ }
79
+
80
+
81
+ @app.post("/api/traffic")
82
+ def get_traffic(token: str = Body("", embed=True)):
83
+ # Serve traffic data — either from the historical CSV database or live from GitHub
84
+ active_token = _get_token(token)
85
+ if not active_token:
86
+ raise HTTPException(status_code=401, detail="No token provided")
87
+
88
+ ok, _ = validate_token(active_token)
89
+ if not ok:
90
+ raise HTTPException(status_code=401, detail="Invalid token")
91
+
92
+ data_dir = os.environ.get("GITLYTICS_DATA_DIR")
93
+ if data_dir:
94
+ # Load from the historical CSV database (headless/TV mode)
95
+ data_dir_path = Path(data_dir)
96
+ csv_files = list(data_dir_path.glob("traffic_*.csv")) if data_dir_path.exists() else []
97
+ dfs = []
98
+ for f in csv_files:
99
+ try:
100
+ dfs.append(pd.read_csv(f))
101
+ except Exception as exc:
102
+ logger.warning(f"Skipping unreadable CSV '{f}': {exc}")
103
+ if dfs:
104
+ df = pd.concat(dfs, ignore_index=True)
105
+ # Clean up any duplicate day-repo rows that crept in somehow
106
+ df = df.drop_duplicates(subset=["date", "repository"], keep="last")
107
+ else:
108
+ # No CSVs found — fall through to a live fetch
109
+ df = fetch_traffic_data(active_token)
110
+ else:
111
+ # Default: hit GitHub and get the live 14-day window
112
+ df = fetch_traffic_data(active_token)
113
+
114
+ # Replace any infinity or NaN values before JSON serialisation
115
+ df = df.replace([float('inf'), float('-inf')], None).where(pd.notnull(df), None)
116
+
117
+ # Transform the DataFrame into the array of objects the React app expects
118
+ payload = build_react_payload(df)
119
+ return payload
120
+
121
+
122
+ @app.post("/api/upload-csv")
123
+ def upload_csv(file: UploadFile = File(...)):
124
+ # Accept a user-uploaded CSV and convert it to the same format as the API response
125
+ try:
126
+ df = process_uploaded_csv(file.file)
127
+ df = df.replace([float('inf'), float('-inf')], None).where(pd.notnull(df), None)
128
+ payload = build_react_payload(df)
129
+ return payload
130
+ except Exception as e:
131
+ raise HTTPException(status_code=400, detail=str(e))
132
+
133
+
134
+ # ── Static file serving ───────────────────────────────────────────────────────
135
+ # The React build output lands in gitlytics/static/ after `npm run build`
136
+ frontend_dir = Path(__file__).parent / "static"
137
+
138
+
139
+ @app.get("/")
140
+ def serve_index():
141
+ # Serve the React app's index.html for the root URL
142
+ index_file = frontend_dir / "index.html"
143
+ if index_file.exists():
144
+ return FileResponse(index_file)
145
+ return JSONResponse(
146
+ status_code=503,
147
+ content={"error": "Frontend not found. Run 'npm run build' in the frontend directory."}
148
+ )
149
+
150
+
151
+ @app.get("/{full_path:path}")
152
+ def serve_spa_fallback(full_path: str):
153
+ """
154
+ SPA catch-all — any URL that doesn't match an API route returns index.html
155
+ so React Router can handle client-side navigation on hard refresh.
156
+ Real static assets (JS/CSS) are served by the StaticFiles mount first.
157
+ """
158
+ # Serve the actual file if it exists (e.g. a JS or CSS asset)
159
+ asset_file = frontend_dir / full_path
160
+ if asset_file.exists() and asset_file.is_file():
161
+ return FileResponse(asset_file)
162
+
163
+ # For everything else (like /repos/my-repo), hand control to React Router
164
+ index_file = frontend_dir / "index.html"
165
+ if index_file.exists():
166
+ return FileResponse(index_file)
167
+
168
+ return JSONResponse(
169
+ status_code=503,
170
+ content={"error": "Frontend not found. Run 'npm run build' in the frontend directory."}
171
+ )
172
+
173
+
174
+ # Mount the /assets directory for compiled JS and CSS — must come after route definitions
175
+ assets_dir = frontend_dir / "assets"
176
+ if assets_dir.exists():
177
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")