diff --git a/CLAUDE.md b/CLAUDE.md index 8493fd4..0425dea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,12 @@ pip install -r requirements.txt python gitpull.py ``` +### Run in background (production-style) +```shell +./run.sh +``` +Uses `nohup python gitpull.py &`; logs go to `nohup.out`. + ### Lint ```shell pylint gitpull.py @@ -46,7 +52,7 @@ The entire application is in a single file: `gitpull.py`. - **`/`** — Dark-themed HTML home page. Lists configured repos (loaded via `GET /config/repos`) with Add / Edit / Delete buttons. All CRUD actions use JS `fetch` — no page reload. - **`/beats`** — Health check; returns `{"result": true}`. - **`/webhook`** (POST) — Receives GitHub push events. Verifies the HMAC-SHA256 signature if `webhook_secret` is set in config. Only processes pushes to `refs/heads/main`. Calls `update_webhook()`. -- **`/webhookdemo`** — Returns an HTML terminal-style page simulating `git reset` and `git pull` output. No real git commands are run. +- **`/webhookdemo`** — Returns an HTML terminal-style page simulating `git reset` and `git pull` output. Reads `demo/demo.json` for repo metadata. No real git commands are run. - **`/docs`** — Swagger UI (built into FastAPI). - **`GET /config/repos`** — Lists configured repos (excludes `ip` and `webhook_secret` keys). - **`POST /config/repos`** — Adds a repo `{"repo": "owner/repo", "path": "/abs/path"}`. Returns 409 if already exists. @@ -82,11 +88,40 @@ The entire application is in a single file: `gitpull.py`. ## Tests -`test_gitpull.py` — 23 tests, 98% coverage. Uses `fastapi.testclient.TestClient` (synchronous). The `patch_config` autouse fixture swaps `gitpull.config_github`, `gitpull.BASE_DIR`, and `gitpull.CONFIG_PATH` in-process for each test, then restores them — no real config files are read or written during tests (except in `TestLoadConfig` which explicitly tests file creation). +`test_gitpull.py` — 23 tests, 98% coverage. Uses `fastapi.testclient.TestClient` (synchronous). + +**`patch_config` autouse fixture** swaps three module-level globals for each test, then restores them: +- `gitpull.config_github` — replaced with `CONFIG_FIXTURE` dict +- `gitpull.BASE_DIR` — replaced with `tmp_path` +- `gitpull.CONFIG_PATH` — replaced with `tmp_path/config/config.json` + +It also creates `tmp_path/demo/demo.json` so `/webhookdemo` can read it. No real config files are read or written during tests (except in `TestLoadConfig` which explicitly tests file creation). + +## Demo fixture + +`demo/demo.json` is a minimal GitHub push-event payload used by `/webhookdemo` and the test fixture. It contains `ref` and `repository.full_name`. `demo/demo_full.json` is a full GitHub payload sample (reference only, not used at runtime). ## CI -`.github/workflows/build.yml` runs three jobs on push to `main` and on PRs: +`.github/workflows/build.yml` triggers on push to `main` or `develop`, and on pull requests. Runs three jobs: - **test** — `pytest --cov` across Python 3.11, 3.12, 3.13; uploads `coverage.xml` as artifact. -- **pylint** — linting across the same Python matrix. +- **pylint** — linting (`pylint $(git ls-files '*.py')`) across the same Python matrix. - **sonarqube** — runs after `test`, regenerates `coverage.xml` and sends it to SonarCloud (requires `SONAR_TOKEN` secret). + +When bumping the app version (`app = FastAPI(version=...)`) also update `sonar-project.properties` → `sonar.projectVersion`. + +## Milestone 1.4.0 — features in progress + +Issues open in the GitHub project (all in Backlog): +- **#29** — README badge → version 1.4.0 +- **#30** — `POST /deploy/{owner}/{repo}` endpoint + Deploy button on home page +- **#31** — `POST /reload` endpoint + Reload button on home page (uses `os.execv`; page polls `/beats` and auto-redirects) +- **#32** — SQLite `data/deployments.db`, table `deployment_log`, function `log_action()` +- **#33** — Call `log_action()` for every significant action: `startup`, `webhook`, `git_reset`, `git_pull`, `deploy`, `reload` +- **#34** — `GET /history` HTML page + `GET /api/history` JSON (paginated, filterable by repo/status) +- **#35** — Error badge on home page rows when last deployment failed (enriches `GET /config/repos` response) +- **#36** — Epic grouping #32–#35 +- **#37** — Swagger UI in dark mode +- **#38** — Footer link to `https://github.com/lenoirpatrick/githubwebhook` + +Dependency order: **#32 → #33 → #34 and #35**. Issues #30, #31, #37, #38 are independent. diff --git a/README.md b/README.md index 2980efd..fcf5fb9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # githubwebhook Gestion du webhook Github pour déploiement CI/CD sur vos environnements. +![version](https://img.shields.io/badge/version-1.4.0-blue.svg?style=flat) ![Python 3.11](https://img.shields.io/badge/python-3.11-green.svg?style=flat&logo=python&logoColor=white) ![Python 3.14](https://img.shields.io/badge/python-3.14-green.svg?style=flat&logo=python&logoColor=white) ![FastAPI](https://img.shields.io/badge/FastAPI-0.135-green.svg?style=flat&logo=flask&logoColor=white) diff --git a/gitpull.py b/gitpull.py index 2aadef0..f8a480e 100644 --- a/gitpull.py +++ b/gitpull.py @@ -3,22 +3,23 @@ import hashlib import hmac import json +import os import pathlib +import sqlite3 import subprocess +import sys +import threading +from contextlib import asynccontextmanager import uvicorn -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.openapi.docs import get_swagger_ui_html from fastapi.responses import HTMLResponse from pydantic import BaseModel -app = FastAPI( - title="GitHub Webhook Server", - description="Serveur de webhook GitHub pour déploiement CI/CD automatique via `git pull`.", - version="1.3.0", -) - BASE_DIR = pathlib.Path(__file__).parent CONFIG_PATH = BASE_DIR / 'config' / 'config.json' +DB_PATH = BASE_DIR / 'data' / 'deployments.db' def _load_config() -> dict: @@ -40,11 +41,141 @@ def _save_config() -> None: config_github = _load_config() +def _init_db() -> None: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(DB_PATH) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS deployment_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + action TEXT NOT NULL, + repo TEXT, + status TEXT NOT NULL DEFAULT 'ok', + message TEXT + ) + """) + + +def log_action(action: str, repo: str = None, status: str = 'ok', message: str = '') -> None: + _init_db() + with sqlite3.connect(DB_PATH) as conn: + conn.execute( + "INSERT INTO deployment_log (action, repo, status, message) VALUES (?, ?, ?, ?)", + (action, repo, status, message), + ) + + +@asynccontextmanager +async def _lifespan(_app: FastAPI): + log_action('startup') + yield + + +app = FastAPI( + title="GitHub Webhook Server", + description="Serveur de webhook GitHub pour déploiement CI/CD automatique via `git pull`.", + version="1.4.0", + docs_url=None, + lifespan=_lifespan, +) + + class RepoConfig(BaseModel): repo: str path: str +_SWAGGER_DARK_CSS = """ +body { background-color: #0d1117 !important; } +.swagger-ui { background-color: #0d1117; color: #c9d1d9; } +.swagger-ui .info .title, +.swagger-ui .info p, +.swagger-ui .info li, +.swagger-ui .info a { color: #c9d1d9; } +.swagger-ui .info a { color: #58a6ff; } +.swagger-ui .scheme-container { background: #161b22; box-shadow: none; border-bottom: 1px solid #30363d; } +.swagger-ui .opblock-tag { color: #c9d1d9; border-bottom: 1px solid #30363d; } +.swagger-ui .opblock-tag:hover { background: rgba(255,255,255,0.04); } +.swagger-ui .opblock { border-color: #30363d !important; background: rgba(255,255,255,0.02) !important; } +.swagger-ui .opblock .opblock-summary-description { color: #8b949e; } +.swagger-ui .opblock .opblock-summary-path { color: #c9d1d9; } +.swagger-ui .opblock.opblock-get .opblock-summary-method { background: #1f6feb; } +.swagger-ui .opblock.opblock-post .opblock-summary-method { background: #238636; } +.swagger-ui .opblock.opblock-put .opblock-summary-method { background: #9e6a03; } +.swagger-ui .opblock.opblock-delete .opblock-summary-method { background: #b91c1c; } +.swagger-ui .opblock-body pre.microlight, +.swagger-ui .microlight { background: #161b22 !important; color: #c9d1d9 !important; } +.swagger-ui .opblock-description-wrapper p, +.swagger-ui .opblock-external-docs-wrapper p, +.swagger-ui .opblock-title_normal p { color: #c9d1d9; } +.swagger-ui table thead tr td, +.swagger-ui table thead tr th { color: #8b949e; border-bottom: 1px solid #30363d; } +.swagger-ui .response-col_status { color: #c9d1d9; } +.swagger-ui .response-col_description p { color: #c9d1d9; } +.swagger-ui .response-col_links { color: #8b949e; } +.swagger-ui .responses-inner h4, +.swagger-ui .responses-inner h5 { color: #c9d1d9; } +.swagger-ui .model-box, +.swagger-ui .model { background: #161b22; color: #c9d1d9; } +.swagger-ui section.models { background: #161b22; border-color: #30363d; } +.swagger-ui section.models .model-container { background: #0d1117; border-color: #30363d; } +.swagger-ui section.models .model-container:hover { background: #0d1117; } +/* Titres de schémas : h4 + tous ses enfants inline (span, button, small, svg) */ +.swagger-ui section.models h4 { color: #c9d1d9; border-color: #30363d; } +.swagger-ui section.models h4 span, +.swagger-ui section.models h4 small, +.swagger-ui section.models h4 button { color: #c9d1d9; background: none; border: none; cursor: pointer; } +/* Bouton "Collapse all" / flèche inline dans le titre de chaque schéma */ +.swagger-ui .model-box-control, +.swagger-ui .model-box-control:focus { background: none; border: none; color: #c9d1d9; } +.swagger-ui .model-box-control svg, +.swagger-ui section.models h4 svg { fill: #c9d1d9; } +.swagger-ui .model-title { color: #c9d1d9; } +.swagger-ui span.model-title__text { color: #c9d1d9; } +.swagger-ui .models-control { background: none; color: #c9d1d9; } +.swagger-ui .models-control svg { fill: #c9d1d9; } +/* Flèches toggle dans l'arbre des propriétés */ +.swagger-ui .model-toggle:after { filter: invert(1); } +.swagger-ui .model-toggle { background: none; } +/* Propriétés */ +.swagger-ui .prop-type { color: #58a6ff; } +.swagger-ui .prop-format { color: #8b949e; } +.swagger-ui table.model tr.property-row td { color: #c9d1d9; border-color: #30363d; } +.swagger-ui .model span, +.swagger-ui .model .property, +.swagger-ui .model span.prop-name { color: #c9d1d9; } +.swagger-ui .model .property.primitive { color: #3fb950; } +/* "Any of", "One of", "All of" wrappers */ +.swagger-ui .model .inner-object { background: #161b22; } +.swagger-ui .model span.model { background: #161b22; color: #c9d1d9; } +.swagger-ui .model-hint { background: #30363d; color: #c9d1d9; } +.swagger-ui .opblock-body .model-example { background: #161b22; } +.swagger-ui .tab li { color: #8b949e; } +.swagger-ui .tab li.active { color: #c9d1d9; } +.swagger-ui .highlight-code > .microlight { background: #161b22 !important; color: #c9d1d9 !important; } +.swagger-ui .responses-wrapper { background: #0d1117; } +.swagger-ui .response-control-media-type__accept-message { color: #8b949e; } +.swagger-ui .response-control-media-type select { background: #0d1117; color: #c9d1d9; border-color: #30363d; } +.swagger-ui input[type=text], +.swagger-ui input[type=password], +.swagger-ui textarea, +.swagger-ui select { background: #0d1117; color: #c9d1d9; border-color: #30363d; } +.swagger-ui .btn { color: #c9d1d9; border-color: #30363d; background: transparent; } +.swagger-ui .btn.execute { background: #238636; border-color: #238636; color: #fff; } +.swagger-ui .btn.authorize { color: #58a6ff; border-color: #58a6ff; } +.swagger-ui .btn.cancel { color: #f85149; border-color: #f85149; } +.swagger-ui .topbar { display: none; } +.swagger-ui .arrow { filter: invert(1); } +""" + + +@app.get("/docs", include_in_schema=False) +async def custom_docs(): + html = get_swagger_ui_html(openapi_url="/openapi.json", title=f"{app.title} — Swagger UI") + content = html.body.decode().replace("", f"") + return HTMLResponse(content=content) + + @app.get("/", response_class=HTMLResponse) async def home(): """ Page index """ @@ -153,6 +284,11 @@ async def home(): .btn-edit:hover { background: #30363d; } .btn-delete { background: transparent; color: #f85149; border-color: #f8514944; margin-left: 0.4rem; } .btn-delete:hover { background: #f8514911; } + .btn-deploy { background: transparent; color: #3fb950; border-color: #3fb95044; margin-left: 0.4rem; } + .btn-deploy:hover { background: #3fb95011; } + .btn-reload { background: #9e6a03; color: #fff; border: 1px solid #bb8009; margin-left: 0.75rem; } + .btn-reload:hover { opacity: 0.85; } + .btn-reload:disabled { opacity: 0.5; cursor: not-allowed; } /* Modal */ .modal-overlay { @@ -196,6 +332,7 @@ async def home():

Déploiement CI/CD automatique via git pull au push sur main.

En ligne

▶ Lancer la démo + @@ -219,8 +356,10 @@ async def home(): @@ -249,15 +388,22 @@ async def home(): tbody.innerHTML = 'Aucun dépôt configuré — cliquez sur « Ajouter ».'; return; } - tbody.innerHTML = entries.map(([repo, cfg]) => ` - - ${repo} + tbody.innerHTML = entries.map(([repo, cfg]) => { + const dot = cfg.last_status === 'error' + ? `` + : cfg.last_status === 'ok' + ? `` + : ``; + return ` + ${dot}${repo} ${cfg.path} + - `).join(''); + `; + }).join(''); } function showModal(repo='', path='') { @@ -304,6 +450,27 @@ async def home(): loadRepos(); } + async function deployRepo(repo) { + if (!confirm(`Lancer le deploy de ${repo} ?`)) return; + const [owner, repoName] = repo.split('/'); + const res = await fetch(`/deploy/${owner}/${repoName}`, {method: 'POST'}); + const data = await res.json(); + alert(data.result ? `✓ ${data.message}` : `✗ ${data.message}`); + } + + async function reloadServer() { + if (!confirm('Redémarrer le serveur ?')) return; + const btn = document.getElementById('reload-btn'); + btn.disabled = true; + btn.textContent = '↺ Redémarrage…'; + try { await fetch('/reload', {method: 'POST'}); } catch {} + await new Promise(r => setTimeout(r, 1500)); + (function poll() { + fetch('/beats').then(r => { if (r.ok) location.reload(); else setTimeout(poll, 500); }) + .catch(() => setTimeout(poll, 500)); + })(); + } + window.onload = loadRepos; @@ -319,8 +486,21 @@ def beats(): @app.get('/config/repos') def list_repos(): - """ Liste les dépôts configurés """ - return {k: v for k, v in config_github.items() if k not in ('ip', 'webhook_secret')} + """ Liste les dépôts configurés avec leur dernier statut de déploiement """ + repos = {k: dict(v) for k, v in config_github.items() if k not in ('ip', 'webhook_secret')} + _init_db() + with sqlite3.connect(DB_PATH) as conn: + conn.row_factory = sqlite3.Row + for repo in repos: + row = conn.execute( + "SELECT status, timestamp FROM deployment_log " + "WHERE repo = ? AND action IN ('git_pull', 'git_reset') " + "ORDER BY id DESC LIMIT 1", + (repo,), + ).fetchone() + repos[repo]['last_status'] = row['status'] if row else None + repos[repo]['last_timestamp'] = row['timestamp'] if row else None + return repos @app.post('/config/repos', status_code=201) @@ -371,10 +551,13 @@ async def webhook(request: Request): webhook_github = json.loads(body) + repo = webhook_github.get('repository', {}).get('full_name') if webhook_github.get('ref') == 'refs/heads/main': print("Nouveau push détecté ! Mise à jour en cours...") + log_action('webhook', repo=repo, status='ok') return update_webhook(webhook_github) + log_action('webhook', repo=repo, status='ok', message='ignored: not main branch') return {"result": False, "message": "Ignoré : ce n'est pas un push sur la branche principale."} @@ -463,12 +646,216 @@ def webhookdemo(): ← Accueil Health check API docs + GitHub """ return HTMLResponse(content=html, status_code=200) +@app.post('/deploy/{owner}/{repo_name}') +def deploy(owner: str, repo_name: str): + """ Déclenche un git pull sur le dépôt configuré """ + key = f"{owner}/{repo_name}" + if key not in config_github: + raise HTTPException(status_code=404, detail=f"Repo {key} non configuré") + result = update_webhook({"repository": {"full_name": key}}) + log_action('deploy', repo=key, + status='ok' if result['result'] else 'error', + message=result.get('message', '')) + return result + + +@app.post('/reload') +def reload_server(): + """ Redémarre le processus serveur via os.execv """ + log_action('reload', status='ok') + def _restart(): + import time + time.sleep(0.5) + os.execv(sys.executable, [sys.executable] + sys.argv) + threading.Thread(target=_restart, daemon=True).start() + return {"result": True, "message": "Redémarrage en cours…"} + + +@app.get('/api/history') +def api_history( + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=200), + repo: str = Query(None), + status: str = Query(None), +): + """ Historique paginé des actions (filtrable par repo et statut) """ + _init_db() + conditions, params = [], [] + if repo: + conditions.append("repo = ?") + params.append(repo) + if status: + conditions.append("status = ?") + params.append(status) + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + with sqlite3.connect(DB_PATH) as conn: + conn.row_factory = sqlite3.Row + total = conn.execute(f"SELECT COUNT(*) FROM deployment_log {where}", params).fetchone()[0] + rows = conn.execute( + f"SELECT * FROM deployment_log {where} ORDER BY id DESC LIMIT ? OFFSET ?", + params + [per_page, (page - 1) * per_page], + ).fetchall() + return {"total": total, "page": page, "per_page": per_page, "items": [dict(r) for r in rows]} + + +@app.get('/history', response_class=HTMLResponse) +def history_page(): + """ Page HTML d'historique des déploiements """ + html = """ + + + + + Historique — GitHub Webhook Server + + + +

Historique des déploiements

+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + +
HorodatageActionDépôtStatutMessage
Chargement…
+
+ + + + + + + +""" + return HTMLResponse(content=html) + + def update_webhook(webhook_github): """ Fonction commune de mise à jour du dépot """ repo = webhook_github['repository']['full_name'] @@ -486,13 +873,20 @@ def update_webhook(webhook_github): ['git', '-C', path_repo, 'reset', '--hard', 'HEAD'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) + log_action('git_reset', repo=repo, status='ok') + except subprocess.CalledProcessError as exc: + log_action('git_reset', repo=repo, status='error', message=exc.stderr) + return {"result": False, "message": exc.stderr} + + try: retour_git = subprocess.run( ['git', '-C', path_repo, 'pull'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) + log_action('git_pull', repo=repo, status='ok', message=retour_git.stdout) return {"result": True, "message": retour_git.stdout} - except subprocess.CalledProcessError as exc: + log_action('git_pull', repo=repo, status='error', message=exc.stderr) return {"result": False, "message": exc.stderr} diff --git a/sonar-project.properties b/sonar-project.properties index 90db189..b54b070 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,7 +4,7 @@ sonar.organization=lenoirpatrick # This is the name and version displayed in the SonarCloud UI. sonar.projectName=githubwebhook -sonar.projectVersion=1.1.0 +sonar.projectVersion=1.4.0 # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. #sonar.sources=. diff --git a/test_gitpull.py b/test_gitpull.py index a305826..5336811 100644 --- a/test_gitpull.py +++ b/test_gitpull.py @@ -30,6 +30,7 @@ def patch_config(tmp_path): original = dict(gitpull.config_github) original_base = gitpull.BASE_DIR original_config_path = gitpull.CONFIG_PATH + original_db_path = gitpull.DB_PATH demo_dir = tmp_path / "demo" demo_dir.mkdir() @@ -44,6 +45,8 @@ def patch_config(tmp_path): gitpull.config_github.update(CONFIG_FIXTURE) gitpull.BASE_DIR = tmp_path gitpull.CONFIG_PATH = config_path + gitpull.DB_PATH = tmp_path / "data" / "deployments.db" + gitpull._init_db() yield @@ -51,6 +54,7 @@ def patch_config(tmp_path): gitpull.config_github.update(original) gitpull.BASE_DIR = original_base gitpull.CONFIG_PATH = original_config_path + gitpull.DB_PATH = original_db_path @pytest.fixture() @@ -286,4 +290,124 @@ def test_creates_default_config_if_missing(self, tmp_path): result = gitpull._load_config() assert result == {"ip": "127.0.0.1"} assert missing_path.exists() - gitpull.CONFIG_PATH = tmp_path / "config" / "config.json" \ No newline at end of file + gitpull.CONFIG_PATH = tmp_path / "config" / "config.json" + + +# --------------------------------------------------------------------------- +# #32 — log_action / _init_db +# --------------------------------------------------------------------------- + +class TestLogAction: + def test_log_action_creates_db(self, tmp_path): + import gitpull + gitpull.log_action('startup') + assert gitpull.DB_PATH.exists() + + def test_log_action_inserts_row(self, tmp_path): + import gitpull, sqlite3 + gitpull.log_action('deploy', repo='owner/repo', status='ok', message='done') + with sqlite3.connect(gitpull.DB_PATH) as conn: + row = conn.execute("SELECT * FROM deployment_log WHERE action='deploy'").fetchone() + assert row is not None + assert row[3] == 'owner/repo' + assert row[4] == 'ok' + + +# --------------------------------------------------------------------------- +# #30 — POST /deploy +# --------------------------------------------------------------------------- + +class TestDeploy: + def test_deploy_unknown_repo_returns_404(self, client): + resp = client.post("/deploy/unknown/repo") + assert resp.status_code == 404 + + def test_deploy_triggers_update(self, client, tmp_path): + import gitpull + gitpull.config_github["lenoirpatrick/testrepo"]["path"] = str(tmp_path) + with patch("subprocess.run", return_value=MagicMock(stdout="Already up to date.", returncode=0)): + resp = client.post("/deploy/lenoirpatrick/testrepo") + assert resp.status_code == 200 + assert resp.json()["result"] is True + + +# --------------------------------------------------------------------------- +# #31 — POST /reload +# --------------------------------------------------------------------------- + +class TestReload: + def test_reload_returns_ok(self, client): + with patch("os.execv"), patch("threading.Thread"): + resp = client.post("/reload") + assert resp.status_code == 200 + assert resp.json()["result"] is True + + +# --------------------------------------------------------------------------- +# #34 — GET /api/history + GET /history +# --------------------------------------------------------------------------- + +class TestHistory: + def test_api_history_empty(self, client): + resp = client.get("/api/history") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0 + assert data["items"] == [] + + def test_api_history_with_entries(self, client): + import gitpull + gitpull.log_action('deploy', repo='owner/repo', status='ok', message='done') + gitpull.log_action('deploy', repo='owner/repo', status='error', message='fail') + resp = client.get("/api/history") + assert resp.json()["total"] == 2 + + def test_api_history_filter_by_status(self, client): + import gitpull + gitpull.log_action('deploy', repo='owner/repo', status='ok') + gitpull.log_action('deploy', repo='owner/repo', status='error') + resp = client.get("/api/history?status=error") + data = resp.json() + assert data["total"] == 1 + assert data["items"][0]["status"] == "error" + + def test_api_history_filter_by_repo(self, client): + import gitpull + gitpull.log_action('deploy', repo='owner/repoA', status='ok') + gitpull.log_action('deploy', repo='owner/repoB', status='ok') + resp = client.get("/api/history?repo=owner/repoA") + assert resp.json()["total"] == 1 + + def test_history_page_returns_html(self, client): + resp = client.get("/history") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + assert "Historique" in resp.text + + +# --------------------------------------------------------------------------- +# #35 — last_status dans GET /config/repos +# --------------------------------------------------------------------------- + +class TestLastStatus: + def test_last_status_none_when_no_deploy(self, client): + resp = client.get("/config/repos") + data = resp.json() + assert data["lenoirpatrick/testrepo"]["last_status"] is None + + def test_last_status_ok_after_successful_pull(self, client, tmp_path): + import gitpull + gitpull.config_github["lenoirpatrick/testrepo"]["path"] = str(tmp_path) + with patch("subprocess.run", return_value=MagicMock(stdout="ok", returncode=0)): + client.post("/deploy/lenoirpatrick/testrepo") + resp = client.get("/config/repos") + assert resp.json()["lenoirpatrick/testrepo"]["last_status"] == "ok" + + def test_last_status_error_after_failed_pull(self, client, tmp_path): + import gitpull + gitpull.config_github["lenoirpatrick/testrepo"]["path"] = str(tmp_path) + error = subprocess.CalledProcessError(1, "git pull", stderr="fatal error") + with patch("subprocess.run", side_effect=[MagicMock(), error]): + client.post("/deploy/lenoirpatrick/testrepo") + resp = client.get("/config/repos") + assert resp.json()["lenoirpatrick/testrepo"]["last_status"] == "error" \ No newline at end of file