Spaces:
No application file
No application file
FORGE-X: Upload source (bf5f8cb4-api.zip)
Browse files- README.md +92 -7
- bf5f8cb4/Dockerfile +15 -0
- bf5f8cb4/README.md +89 -0
- bf5f8cb4/auth.py +53 -0
- bf5f8cb4/docker-compose.yml +10 -0
- bf5f8cb4/main.py +184 -0
- bf5f8cb4/models.py +47 -0
- bf5f8cb4/requirements.txt +9 -0
- bf5f8cb4/tests/__init__.py +0 -0
- bf5f8cb4/tests/test_main.py +96 -0
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 Matching and Recommendation 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 Matching and Recommendation API
|
| 24 |
|
| 25 |
+
Stop wasting hours sifting through thousands of GitHub Actions. Get precise, context-aware recommendations for your CI/CD pipeline in milliseconds.
|
| 26 |
+
|
| 27 |
+
This API solves the painful problem of action discovery and selection by using intelligent matching algorithms that consider your repo's language, framework, and platform. Unlike generic search, it delivers ranked, relevant actions with confidence scores, saving you time and ensuring you use the best tools for your workflow.
|
| 28 |
+
|
| 29 |
+
## What's Included
|
| 30 |
+
|
| 31 |
+
- Context-aware matching based on your repo's language, framework, and platform
|
| 32 |
+
- Real-time recommendations via a simple REST API endpoint
|
| 33 |
+
- Custom filters for stars, maintenance status, license, and more
|
| 34 |
+
- Seamless integration with existing CI/CD tools and scripts
|
| 35 |
+
- Returns ranked list with confidence scores and metadata for each action
|
| 36 |
+
|
| 37 |
+
## Who Is This For
|
| 38 |
+
|
| 39 |
+
- Developers building or optimizing CI/CD pipelines on GitHub Actions
|
| 40 |
+
- DevOps engineers seeking to automate workflow configuration
|
| 41 |
+
- Teams migrating from other CI/CD platforms to GitHub Actions
|
| 42 |
+
- Open-source maintainers wanting to standardize their action choices
|
| 43 |
+
|
| 44 |
+
## How It Works
|
| 45 |
+
|
| 46 |
+
Sign up to get your unique API key. Send a POST request with your workflow requirements (e.g., language, build tool, deployment target). Receive a JSON response with a ranked list of recommended GitHub Actions, including confidence scores, descriptions, and links.
|
| 47 |
+
|
| 48 |
+
## Frequently Asked Questions
|
| 49 |
+
|
| 50 |
+
**How is the pricing structured?**
|
| 51 |
+
The API costs $42.23 for lifetime access with 10,000 requests per month. No recurring fees.
|
| 52 |
+
|
| 53 |
+
**What are the rate limits?**
|
| 54 |
+
You get 10,000 requests per month, with a burst limit of 100 requests per minute.
|
| 55 |
+
|
| 56 |
+
**Is my data private?**
|
| 57 |
+
Yes, we do not store your workflow details. All requests are processed in memory and discarded immediately.
|
| 58 |
+
|
| 59 |
+
**Can I customize the matching criteria?**
|
| 60 |
+
Absolutely. You can pass optional parameters like minimum stars, last update date, license type, and more.
|
| 61 |
+
|
| 62 |
+
**Does this work with self-hosted runners or enterprise?**
|
| 63 |
+
Yes, the API is platform-agnostic and works with any GitHub Actions setup, including enterprise accounts.
|
| 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 matching the perfect actions for your pipeline.**
|
| 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-matching-and-recommendation-api-bf5f
|
| 81 |
+
cd github-actions-cicd-matching-and-recommendation-api-bf5f
|
| 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*
|
bf5f8cb4/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"]
|
bf5f8cb4/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GitHub Actions & CI/CD Matching and Recommendation API
|
| 2 |
+
|
| 3 |
+
> AI-powered matching between GitHub Actions & CI/CD entities — clients to services, products to needs. Smart recommendations tuned for your domain.
|
| 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
|
bf5f8cb4/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 |
+
)
|
bf5f8cb4/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
|
bf5f8cb4/main.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GitHub Actions & CI/CD Matching and Recommendation API — REST API Service
|
| 3 |
+
AI-powered matching between GitHub Actions & CI/CD entities — clients to services, products to needs. Smart recommendations tuned for your domain.
|
| 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 Matching and Recommendation API",
|
| 39 |
+
description="AI-powered matching between GitHub Actions & CI/CD entities — clients to services, products to needs. Smart recommendations tuned for your domain.",
|
| 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 Matching and Recommendation 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 Matching and Recommendation API",
|
| 183 |
+
"niche": "github_actions",
|
| 184 |
+
}
|
bf5f8cb4/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
|
bf5f8cb4/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
|
bf5f8cb4/tests/__init__.py
ADDED
|
File without changes
|
bf5f8cb4/tests/test_main.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for GitHub Actions & CI/CD Matching and Recommendation 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()
|