Spaces:
No application file
No application file
FORGE-X: Upload source (6cf96d62-api.zip)
Browse files- 6cf96d62/Dockerfile +15 -0
- 6cf96d62/README.md +89 -0
- 6cf96d62/auth.py +53 -0
- 6cf96d62/docker-compose.yml +10 -0
- 6cf96d62/main.py +184 -0
- 6cf96d62/models.py +47 -0
- 6cf96d62/requirements.txt +9 -0
- 6cf96d62/tests/__init__.py +0 -0
- 6cf96d62/tests/test_main.py +96 -0
- README.md +92 -7
6cf96d62/Dockerfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 8000
|
| 11 |
+
|
| 12 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 13 |
+
CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"
|
| 14 |
+
|
| 15 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
6cf96d62/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GitHub Actions & CI/CD Analytics and Insights API
|
| 2 |
+
|
| 3 |
+
> Transform raw GitHub Actions & CI/CD data into actionable insights, trends, and predictions. Business intelligence without building from scratch.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- Full REST API
|
| 8 |
+
|
| 9 |
+
## Quick Start
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
# 1. Install dependencies
|
| 13 |
+
pip install -r requirements.txt
|
| 14 |
+
|
| 15 |
+
# 2. Configure environment
|
| 16 |
+
cp .env.example .env
|
| 17 |
+
# Edit .env with your settings
|
| 18 |
+
|
| 19 |
+
# 3. Run locally
|
| 20 |
+
uvicorn main:app --reload --port 8000
|
| 21 |
+
|
| 22 |
+
# 4. View interactive docs
|
| 23 |
+
open http://localhost:8000/docs
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
## Docker Deployment
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
# Build and run
|
| 30 |
+
docker compose up -d
|
| 31 |
+
|
| 32 |
+
# Check health
|
| 33 |
+
curl http://localhost:8000/health
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## Authentication
|
| 37 |
+
|
| 38 |
+
Get a token first:
|
| 39 |
+
```bash
|
| 40 |
+
curl -X POST "http://localhost:8000/auth/token?username=admin&password=admin123"
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
Use the token in subsequent requests:
|
| 44 |
+
```bash
|
| 45 |
+
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8000/items
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## API Endpoints
|
| 49 |
+
|
| 50 |
+
| Method | Path | Description |
|
| 51 |
+
|--------|------|-------------|
|
| 52 |
+
| GET | `/health` | System health |
|
| 53 |
+
| POST | `/auth/token` | Get JWT token |
|
| 54 |
+
| GET | `/items` | List all items |
|
| 55 |
+
| POST | `/items` | Create item |
|
| 56 |
+
| GET | `/items/{id}` | Get item |
|
| 57 |
+
| PATCH | `/items/{id}` | Update item |
|
| 58 |
+
| DELETE | `/items/{id}` | Delete item |
|
| 59 |
+
| GET | `/stats` | API statistics |
|
| 60 |
+
|
| 61 |
+
Full interactive docs: `http://localhost:8000/docs`
|
| 62 |
+
|
| 63 |
+
## Rate Limits
|
| 64 |
+
|
| 65 |
+
| Endpoint | Limit |
|
| 66 |
+
|----------|-------|
|
| 67 |
+
| `/auth/token` | 10/minute |
|
| 68 |
+
| `GET /items` | 60/minute |
|
| 69 |
+
| `POST /items` | 30/minute |
|
| 70 |
+
| `DELETE /items` | 20/minute |
|
| 71 |
+
|
| 72 |
+
## Running Tests
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
pip install pytest httpx
|
| 76 |
+
pytest tests/ -v
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
## Production Notes
|
| 80 |
+
|
| 81 |
+
- Change `SECRET_KEY` in `.env` before deploying
|
| 82 |
+
- Replace in-memory `_db` with a real database
|
| 83 |
+
- Add proper user management to `auth.py`
|
| 84 |
+
- Configure `ALLOWED_ORIGINS` for CORS
|
| 85 |
+
- Use Nginx/Traefik as reverse proxy
|
| 86 |
+
|
| 87 |
+
## License
|
| 88 |
+
|
| 89 |
+
MIT
|
6cf96d62/auth.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simple JWT auth β swap for OAuth2/API keys in production.
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from fastapi import Depends, HTTPException, status
|
| 8 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 9 |
+
from jose import JWTError, jwt
|
| 10 |
+
from passlib.context import CryptContext
|
| 11 |
+
|
| 12 |
+
SECRET_KEY = os.getenv("SECRET_KEY", "changeme-in-production-please")
|
| 13 |
+
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
| 14 |
+
EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
| 15 |
+
|
| 16 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 17 |
+
security = HTTPBearer()
|
| 18 |
+
|
| 19 |
+
# Demo users β replace with real DB lookup
|
| 20 |
+
USERS: dict[str, str] = {
|
| 21 |
+
"admin": pwd_context.hash("admin123"),
|
| 22 |
+
"demo": pwd_context.hash("demo123"),
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def verify_password(username: str, password: str) -> bool:
|
| 27 |
+
hashed = USERS.get(username)
|
| 28 |
+
if not hashed:
|
| 29 |
+
return False
|
| 30 |
+
return pwd_context.verify(password, hashed)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def create_access_token(data: dict) -> str:
|
| 34 |
+
to_encode = data.copy()
|
| 35 |
+
expire = datetime.now(timezone.utc) + timedelta(minutes=EXPIRE_MINUTES)
|
| 36 |
+
to_encode["exp"] = expire
|
| 37 |
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
| 41 |
+
token = credentials.credentials
|
| 42 |
+
try:
|
| 43 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 44 |
+
username: Optional[str] = payload.get("sub")
|
| 45 |
+
if not username:
|
| 46 |
+
raise HTTPException(status_code=401, detail="Invalid token payload")
|
| 47 |
+
return username
|
| 48 |
+
except JWTError:
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 51 |
+
detail="Invalid or expired token",
|
| 52 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 53 |
+
)
|
6cf96d62/docker-compose.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
services:
|
| 3 |
+
api:
|
| 4 |
+
build: .
|
| 5 |
+
ports:
|
| 6 |
+
- "8000:8000"
|
| 7 |
+
env_file: .env
|
| 8 |
+
restart: unless-stopped
|
| 9 |
+
volumes:
|
| 10 |
+
- ./data:/app/data
|
6cf96d62/main.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GitHub Actions & CI/CD Analytics and Insights API β REST API Service
|
| 3 |
+
Transform raw GitHub Actions & CI/CD data into actionable insights, trends, and predictions. Business intelligence without building from scratch.
|
| 4 |
+
|
| 5 |
+
Features:
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
from contextlib import asynccontextmanager
|
| 10 |
+
from typing import Optional, List
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
|
| 13 |
+
from fastapi import FastAPI, Depends, HTTPException, Request, status
|
| 14 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 15 |
+
from fastapi.responses import JSONResponse
|
| 16 |
+
from slowapi import Limiter
|
| 17 |
+
from slowapi.util import get_remote_address
|
| 18 |
+
from slowapi.errors import RateLimitExceeded
|
| 19 |
+
from dotenv import load_dotenv
|
| 20 |
+
|
| 21 |
+
from models import ItemCreate, ItemUpdate, ItemResponse, HealthResponse
|
| 22 |
+
from auth import get_current_user, create_access_token, verify_password
|
| 23 |
+
|
| 24 |
+
load_dotenv()
|
| 25 |
+
|
| 26 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 27 |
+
_db: dict = {} # In-memory store β swap for real DB in production
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@asynccontextmanager
|
| 31 |
+
async def lifespan(app: FastAPI):
|
| 32 |
+
print(f"{app.title} starting up...")
|
| 33 |
+
yield
|
| 34 |
+
print(f"{app.title} shutting down...")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
app = FastAPI(
|
| 38 |
+
title="GitHub Actions & CI/CD Analytics and Insights API",
|
| 39 |
+
description="Transform raw GitHub Actions & CI/CD data into actionable insights, trends, and predictions. Business intelligence without building from scratch.",
|
| 40 |
+
version="1.0.0",
|
| 41 |
+
lifespan=lifespan,
|
| 42 |
+
docs_url="/docs",
|
| 43 |
+
redoc_url="/redoc",
|
| 44 |
+
openapi_url="/openapi.json",
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
app.state.limiter = limiter
|
| 48 |
+
|
| 49 |
+
app.add_middleware(
|
| 50 |
+
CORSMiddleware,
|
| 51 |
+
allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","),
|
| 52 |
+
allow_credentials=True,
|
| 53 |
+
allow_methods=["*"],
|
| 54 |
+
allow_headers=["*"],
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@app.exception_handler(RateLimitExceeded)
|
| 59 |
+
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
| 60 |
+
return JSONResponse(
|
| 61 |
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 62 |
+
content={"error": "Rate limit exceeded", "detail": str(exc)},
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@app.exception_handler(Exception)
|
| 67 |
+
async def global_exception_handler(request: Request, exc: Exception):
|
| 68 |
+
return JSONResponse(
|
| 69 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 70 |
+
content={"error": "Internal server error", "detail": str(exc)},
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ββ Health ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 75 |
+
|
| 76 |
+
@app.get("/health", response_model=HealthResponse, tags=["System"])
|
| 77 |
+
async def health():
|
| 78 |
+
return {
|
| 79 |
+
"status": "healthy",
|
| 80 |
+
"service": "GitHub Actions & CI/CD Analytics and Insights API",
|
| 81 |
+
"version": "1.0.0",
|
| 82 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 83 |
+
"items_count": len(_db),
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ββ Auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 88 |
+
|
| 89 |
+
@app.post("/auth/token", tags=["Auth"], summary="Get API token")
|
| 90 |
+
@limiter.limit("10/minute")
|
| 91 |
+
async def login(request: Request, username: str, password: str):
|
| 92 |
+
if not verify_password(username, password):
|
| 93 |
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
| 94 |
+
token = create_access_token({"sub": username})
|
| 95 |
+
return {"access_token": token, "token_type": "bearer"}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ββ CRUD ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
|
| 100 |
+
@app.get("/items", response_model=List[ItemResponse], tags=["Items"])
|
| 101 |
+
@limiter.limit("60/minute")
|
| 102 |
+
async def list_items(
|
| 103 |
+
request: Request,
|
| 104 |
+
skip: int = 0,
|
| 105 |
+
limit: int = 50,
|
| 106 |
+
search: Optional[str] = None,
|
| 107 |
+
current_user: str = Depends(get_current_user),
|
| 108 |
+
):
|
| 109 |
+
items = list(_db.values())
|
| 110 |
+
if search:
|
| 111 |
+
items = [i for i in items if search.lower() in i.get("name", "").lower()]
|
| 112 |
+
return items[skip : skip + limit]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@app.post("/items", response_model=ItemResponse, status_code=201, tags=["Items"])
|
| 116 |
+
@limiter.limit("30/minute")
|
| 117 |
+
async def create_item(
|
| 118 |
+
request: Request,
|
| 119 |
+
item: ItemCreate,
|
| 120 |
+
current_user: str = Depends(get_current_user),
|
| 121 |
+
):
|
| 122 |
+
item_id = f"item_{len(_db) + 1:06d}"
|
| 123 |
+
record = {
|
| 124 |
+
"id": item_id,
|
| 125 |
+
**item.model_dump(),
|
| 126 |
+
"created_by": current_user,
|
| 127 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 128 |
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
| 129 |
+
}
|
| 130 |
+
_db[item_id] = record
|
| 131 |
+
return record
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@app.get("/items/{item_id}", response_model=ItemResponse, tags=["Items"])
|
| 135 |
+
@limiter.limit("120/minute")
|
| 136 |
+
async def get_item(
|
| 137 |
+
request: Request,
|
| 138 |
+
item_id: str,
|
| 139 |
+
current_user: str = Depends(get_current_user),
|
| 140 |
+
):
|
| 141 |
+
if item_id not in _db:
|
| 142 |
+
raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
|
| 143 |
+
return _db[item_id]
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@app.patch("/items/{item_id}", response_model=ItemResponse, tags=["Items"])
|
| 147 |
+
@limiter.limit("30/minute")
|
| 148 |
+
async def update_item(
|
| 149 |
+
request: Request,
|
| 150 |
+
item_id: str,
|
| 151 |
+
item: ItemUpdate,
|
| 152 |
+
current_user: str = Depends(get_current_user),
|
| 153 |
+
):
|
| 154 |
+
if item_id not in _db:
|
| 155 |
+
raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
|
| 156 |
+
record = _db[item_id]
|
| 157 |
+
updates = item.model_dump(exclude_unset=True)
|
| 158 |
+
record.update({**updates, "updated_at": datetime.now(timezone.utc).isoformat()})
|
| 159 |
+
_db[item_id] = record
|
| 160 |
+
return record
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.delete("/items/{item_id}", status_code=204, tags=["Items"])
|
| 164 |
+
@limiter.limit("20/minute")
|
| 165 |
+
async def delete_item(
|
| 166 |
+
request: Request,
|
| 167 |
+
item_id: str,
|
| 168 |
+
current_user: str = Depends(get_current_user),
|
| 169 |
+
):
|
| 170 |
+
if item_id not in _db:
|
| 171 |
+
raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
|
| 172 |
+
del _db[item_id]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ββ Stats βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
|
| 177 |
+
@app.get("/stats", tags=["System"])
|
| 178 |
+
@limiter.limit("30/minute")
|
| 179 |
+
async def stats(request: Request, current_user: str = Depends(get_current_user)):
|
| 180 |
+
return {
|
| 181 |
+
"total_items": len(_db),
|
| 182 |
+
"service": "GitHub Actions & CI/CD Analytics and Insights API",
|
| 183 |
+
"niche": "github_actions",
|
| 184 |
+
}
|
6cf96d62/models.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ItemCreate(BaseModel):
|
| 7 |
+
name: str = Field(..., min_length=1, max_length=200, description="Item name")
|
| 8 |
+
description: Optional[str] = Field(None, max_length=2000)
|
| 9 |
+
category: Optional[str] = Field(None, max_length=100)
|
| 10 |
+
tags: list[str] = Field(default_factory=list)
|
| 11 |
+
metadata: dict = Field(default_factory=dict)
|
| 12 |
+
|
| 13 |
+
model_config = {"json_schema_extra": {"example": {
|
| 14 |
+
"name": "Sample Item",
|
| 15 |
+
"description": "A sample github_actions item",
|
| 16 |
+
"category": "default",
|
| 17 |
+
"tags": ["github_actions"],
|
| 18 |
+
"metadata": {},
|
| 19 |
+
}}}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ItemUpdate(BaseModel):
|
| 23 |
+
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
| 24 |
+
description: Optional[str] = Field(None, max_length=2000)
|
| 25 |
+
category: Optional[str] = None
|
| 26 |
+
tags: Optional[list[str]] = None
|
| 27 |
+
metadata: Optional[dict] = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ItemResponse(BaseModel):
|
| 31 |
+
id: str
|
| 32 |
+
name: str
|
| 33 |
+
description: Optional[str]
|
| 34 |
+
category: Optional[str]
|
| 35 |
+
tags: list[str]
|
| 36 |
+
metadata: dict
|
| 37 |
+
created_by: str
|
| 38 |
+
created_at: str
|
| 39 |
+
updated_at: str
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class HealthResponse(BaseModel):
|
| 43 |
+
status: str
|
| 44 |
+
service: str
|
| 45 |
+
version: str
|
| 46 |
+
timestamp: str
|
| 47 |
+
items_count: int
|
6cf96d62/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110.0
|
| 2 |
+
uvicorn[standard]>=0.27.0
|
| 3 |
+
pydantic>=2.0.0
|
| 4 |
+
python-jose[cryptography]>=3.3.0
|
| 5 |
+
passlib[bcrypt]>=1.7.4
|
| 6 |
+
python-multipart>=0.0.9
|
| 7 |
+
slowapi>=0.1.9
|
| 8 |
+
python-dotenv>=1.0.0
|
| 9 |
+
httpx>=0.27.0
|
6cf96d62/tests/__init__.py
ADDED
|
File without changes
|
6cf96d62/tests/test_main.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for GitHub Actions & CI/CD Analytics and Insights API API.
|
| 3 |
+
"""
|
| 4 |
+
import pytest
|
| 5 |
+
from fastapi.testclient import TestClient
|
| 6 |
+
from main import app
|
| 7 |
+
|
| 8 |
+
client = TestClient(app)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_health():
|
| 12 |
+
r = client.get("/health")
|
| 13 |
+
assert r.status_code == 200
|
| 14 |
+
data = r.json()
|
| 15 |
+
assert data["status"] == "healthy"
|
| 16 |
+
assert "version" in data
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_unauthorized():
|
| 20 |
+
r = client.get("/items")
|
| 21 |
+
assert r.status_code == 403
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_login():
|
| 25 |
+
r = client.post("/auth/token", params={"username": "admin", "password": "admin123"})
|
| 26 |
+
assert r.status_code == 200
|
| 27 |
+
assert "access_token" in r.json()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _auth_headers():
|
| 31 |
+
r = client.post("/auth/token", params={"username": "admin", "password": "admin123"})
|
| 32 |
+
token = r.json()["access_token"]
|
| 33 |
+
return {"Authorization": f"Bearer {token}"}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_create_item():
|
| 37 |
+
headers = _auth_headers()
|
| 38 |
+
payload = {"name": "Test Item", "description": "A test", "tags": ["test"]}
|
| 39 |
+
r = client.post("/items", json=payload, headers=headers)
|
| 40 |
+
assert r.status_code == 201
|
| 41 |
+
data = r.json()
|
| 42 |
+
assert data["name"] == "Test Item"
|
| 43 |
+
assert "id" in data
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_list_items():
|
| 47 |
+
headers = _auth_headers()
|
| 48 |
+
r = client.get("/items", headers=headers)
|
| 49 |
+
assert r.status_code == 200
|
| 50 |
+
assert isinstance(r.json(), list)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_get_item():
|
| 54 |
+
headers = _auth_headers()
|
| 55 |
+
# Create
|
| 56 |
+
payload = {"name": "Get Test", "tags": []}
|
| 57 |
+
r = client.post("/items", json=payload, headers=headers)
|
| 58 |
+
item_id = r.json()["id"]
|
| 59 |
+
# Get
|
| 60 |
+
r = client.get(f"/items/{item_id}", headers=headers)
|
| 61 |
+
assert r.status_code == 200
|
| 62 |
+
assert r.json()["id"] == item_id
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_update_item():
|
| 66 |
+
headers = _auth_headers()
|
| 67 |
+
payload = {"name": "Update Test", "tags": []}
|
| 68 |
+
r = client.post("/items", json=payload, headers=headers)
|
| 69 |
+
item_id = r.json()["id"]
|
| 70 |
+
r = client.patch(f"/items/{item_id}", json={"name": "Updated"}, headers=headers)
|
| 71 |
+
assert r.status_code == 200
|
| 72 |
+
assert r.json()["name"] == "Updated"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_delete_item():
|
| 76 |
+
headers = _auth_headers()
|
| 77 |
+
payload = {"name": "Delete Test", "tags": []}
|
| 78 |
+
r = client.post("/items", json=payload, headers=headers)
|
| 79 |
+
item_id = r.json()["id"]
|
| 80 |
+
r = client.delete(f"/items/{item_id}", headers=headers)
|
| 81 |
+
assert r.status_code == 204
|
| 82 |
+
r = client.get(f"/items/{item_id}", headers=headers)
|
| 83 |
+
assert r.status_code == 404
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_stats():
|
| 87 |
+
headers = _auth_headers()
|
| 88 |
+
r = client.get("/stats", headers=headers)
|
| 89 |
+
assert r.status_code == 200
|
| 90 |
+
assert "total_items" in r.json()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_openapi_docs():
|
| 94 |
+
r = client.get("/openapi.json")
|
| 95 |
+
assert r.status_code == 200
|
| 96 |
+
assert "openapi" in r.json()
|
README.md
CHANGED
|
@@ -1,13 +1,98 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
|
|
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: "GitHub Actions & CI/CD Analytics and Insights API"
|
| 3 |
+
emoji: β‘
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
tags:
|
| 12 |
+
- github
|
| 13 |
+
- ci-cd
|
| 14 |
+
- automation
|
| 15 |
+
- devops
|
| 16 |
+
- actions
|
| 17 |
+
- data
|
| 18 |
+
- validation
|
| 19 |
+
- enrichment
|
| 20 |
+
- document
|
| 21 |
+
- generation
|
| 22 |
---
|
| 23 |
+
# GitHub Actions & CI/CD Analytics and Insights API
|
| 24 |
|
| 25 |
+
Stop guessing why your CI/CD pipelines are slow or expensive. Get real-time data-driven insights with the GitHub Actions & CI/CD Analytics and Insights API.
|
| 26 |
+
|
| 27 |
+
This API eliminates the opacity of GitHub Actions by exposing granular metrics on workflow runs, durations, failure rates, and resource consumption. It's the only REST API that gives you both historical trends and live anomaly detection, so you can pinpoint bottlenecks and slash costs without manual log digging.
|
| 28 |
+
|
| 29 |
+
## What's Included
|
| 30 |
+
|
| 31 |
+
- Real-time workflow run analytics with start/end timestamps and status
|
| 32 |
+
- Failure rate and duration trend curves for any time window
|
| 33 |
+
- Cost and resource usage breakdown per job, runner type, and repository
|
| 34 |
+
- Customizable dashboards via paginated REST endpoints with filters
|
| 35 |
+
- Anomaly alerting webhooks for sudden performance drops or cost spikes
|
| 36 |
+
|
| 37 |
+
## Who Is This For
|
| 38 |
+
|
| 39 |
+
- DevOps engineers optimizing CI/CD cycle times across multiple repos
|
| 40 |
+
- Engineering managers tracking team productivity and pipeline reliability
|
| 41 |
+
- Platform teams managing GitHub Actions costs for enterprise accounts
|
| 42 |
+
- Indie developers scaling their workflows and wanting to avoid bill shocks
|
| 43 |
+
|
| 44 |
+
## How It Works
|
| 45 |
+
|
| 46 |
+
Install the GitHub Actions app in your organization or authenticate via OAuth. Use our REST API to fetch metrics on any workflow, job, or run. Set up webhooks to receive real-time alerts. All data is stored securely and accessible via simple GET endpoints.
|
| 47 |
+
|
| 48 |
+
## Frequently Asked Questions
|
| 49 |
+
|
| 50 |
+
**How do I authenticate with the API?**
|
| 51 |
+
You authenticate using a personal access token or OAuth app. The API supports both GitHub Apps and PATs. Include the token in the Authorization header.
|
| 52 |
+
|
| 53 |
+
**What data retention period do you offer?**
|
| 54 |
+
We retain detailed run data for 90 days on the base plan. Aggregated trends are kept for 12 months. Higher tiers offer extended retention.
|
| 55 |
+
|
| 56 |
+
**Is there a free tier or trial?**
|
| 57 |
+
Yes, you get 1,000 API calls free per month. No credit card required. The paid plan at $42.23/month includes 50,000 calls and all features.
|
| 58 |
+
|
| 59 |
+
**Can I integrate this with my existing monitoring stack?**
|
| 60 |
+
Absolutely. The API returns JSON, so you can pipe data into Grafana, Datadog, or your own dashboard. We also provide a webhook endpoint for streaming.
|
| 61 |
+
|
| 62 |
+
**How does this help me reduce GitHub Actions costs?**
|
| 63 |
+
By showing you which workflows consume the most minutes, which jobs fail most often, and which runner types are overused, you can refactor workflows and adjust runner configurations to cut waste.
|
| 64 |
+
|
| 65 |
+
## What You Get
|
| 66 |
+
|
| 67 |
+
- Instant digital download
|
| 68 |
+
- Complete REST API with full documentation
|
| 69 |
+
- Free updates for life β pay once, own forever
|
| 70 |
+
- Setup guide and usage instructions
|
| 71 |
+
|
| 72 |
+
**Get your API key now and start optimizing your CI/CD pipelines in minutes β no more blind guessing.**
|
| 73 |
+
|
| 74 |
+
## π Usage
|
| 75 |
+
|
| 76 |
+
1. Click **Use in Spaces** above to run the demo directly
|
| 77 |
+
2. Or clone the repository and run locally:
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
git clone https://huggingface.co/spaces/WealthFromAI/github-actions-cicd-analytics-and-insights-api-6cf9
|
| 81 |
+
cd github-actions-cicd-analytics-and-insights-api-6cf9
|
| 82 |
+
pip install -r requirements.txt
|
| 83 |
+
python app.py
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
## π° Pricing
|
| 87 |
+
|
| 88 |
+
- **Demo**: Free on Hugging Face Spaces
|
| 89 |
+
- **Full Source Code**: $42.23
|
| 90 |
+
- Available on [Gumroad](https://gumroad.com) and [Whop](https://whop.com)
|
| 91 |
+
|
| 92 |
+
## π License
|
| 93 |
+
|
| 94 |
+
MIT License β free to use, modify, and distribute.
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
*Built with [FORGE-X](https://github.com/WealthFromAI) β automated digital product engine*
|