From 43f9b9fb04390da49bdb78be7d4b224d53b8ea6f Mon Sep 17 00:00:00 2001 From: plenoir Date: Sat, 11 Apr 2026 20:04:32 +0200 Subject: [PATCH 01/10] webhookdemo -> webhookgitlabdemo --- gitpull.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gitpull.py b/gitpull.py index 221781c..318faf6 100644 --- a/gitpull.py +++ b/gitpull.py @@ -14,7 +14,7 @@ @app.get("/", response_class=HTMLResponse) async def home(): - # Page HTML simple avec un lien vers /webhookdemo + # Page HTML simple avec un lien vers /webhookgitlabdemo html_content = """ @@ -24,7 +24,7 @@ async def home():

Bienvenue sur le serveur de webhook GitHub

Cliquez ci-dessous pour tester le webhook :

- Tester le webhook + Tester le webhook """ @@ -50,8 +50,8 @@ def webhook(request: Request): return "Ignoré : ce n'est pas un push sur la branche principale.", 200 -@app.get('/webhookdemo') -def webhookdemo(): +@app.get('/webhookgitlabdemo') +def webhookgitlabdemo(): # Vérifier la signature (optionnel) import json with(open('demo/demo.json', 'r')) as openjson: From bed91889e2d3cf33b4dfd2b5ac5011cd9db16c5a Mon Sep 17 00:00:00 2001 From: plenoir Date: Sat, 11 Apr 2026 22:14:31 +0200 Subject: [PATCH 02/10] =?UTF-8?q?#11=20ajouter=20nohup=20=C3=A0=20.gitigno?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + gitpull.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 1c06c63..6ba5422 100644 --- a/.gitignore +++ b/.gitignore @@ -209,3 +209,4 @@ __marimo__/ demo/demo.json config.json /.idea/ +nohup.out diff --git a/gitpull.py b/gitpull.py index 318faf6..221781c 100644 --- a/gitpull.py +++ b/gitpull.py @@ -14,7 +14,7 @@ @app.get("/", response_class=HTMLResponse) async def home(): - # Page HTML simple avec un lien vers /webhookgitlabdemo + # Page HTML simple avec un lien vers /webhookdemo html_content = """ @@ -24,7 +24,7 @@ async def home():

Bienvenue sur le serveur de webhook GitHub

Cliquez ci-dessous pour tester le webhook :

- Tester le webhook + Tester le webhook """ @@ -50,8 +50,8 @@ def webhook(request: Request): return "Ignoré : ce n'est pas un push sur la branche principale.", 200 -@app.get('/webhookgitlabdemo') -def webhookgitlabdemo(): +@app.get('/webhookdemo') +def webhookdemo(): # Vérifier la signature (optionnel) import json with(open('demo/demo.json', 'r')) as openjson: From 2d37362f2d3fa29b25c85928a069222db414addd Mon Sep 17 00:00:00 2001 From: plenoir Date: Sat, 11 Apr 2026 22:41:47 +0200 Subject: [PATCH 03/10] #10 Avoir un beats pour valider le bon fonctionnement de l'API --- gitpull.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/gitpull.py b/gitpull.py index 221781c..516ab1b 100644 --- a/gitpull.py +++ b/gitpull.py @@ -11,7 +11,6 @@ with(open('config/config.json', 'r')) as githubjson: config_github = json.load(githubjson) - @app.get("/", response_class=HTMLResponse) async def home(): # Page HTML simple avec un lien vers /webhookdemo @@ -30,6 +29,9 @@ async def home(): """ return HTMLResponse(content=html_content, status_code=200) +@app.get('/beats') +def beats(): + return {"result": True}, 200 @app.post('/webhook') def webhook(request: Request): @@ -57,7 +59,7 @@ def webhookdemo(): with(open('demo/demo.json', 'r')) as openjson: webhook_github = json.load(openjson) - return update_webhook(webhook_github) + return update_webhook(webhook_github), 200 def update_webhook(webhook_github): @@ -65,14 +67,40 @@ def update_webhook(webhook_github): repo = webhook_github['repository']['full_name'] path_repo = config_github[repo]['path'] command = ['git', '-C', path_repo, 'pull'] + result = True + message = f"repo mis à jour dans {path_repo} avec la commande : {command}" if os.path.isdir(path_repo): print("Retour en arrière") command_reset = ['git', 'reset', '--hard', 'HEAD~1'] subprocess.run(command_reset) print("Mise à jour du dépot") - subprocess.run(command) - return f"repo mis à jour dans {path_repo} avec la commande : {command}" + retour_git = subprocess.run(command, + stdout=subprocess.PIPE, # Capturer la sortie standard + stderr=subprocess.PIPE, # Capturer la sortie d'erreur + text=True, # Retourner les sorties sous forme de chaînes de caractères + ) + + # Vérifier le code de retour + if retour_git.returncode == 0: + print("La commande a réussi.") + print(f"Sortie standard : {retour_git.stdout}") + message = retour_git.stdout # Message de retour en cas de succès + result = True + else: + print(f"La commande a échoué avec le code {retour_git.returncode}.") + print(f"Sortie d'erreur : {retour_git.stderr}") + message = retour_git.stderr # Message d'erreur + result = False + + retour = { + "result": result, + "message": message + } + + # retour vers home assistant + + return retour except Exception as ex: return ex From 198b60f314489ba1a84ea8d41b97fa0487ebe963 Mon Sep 17 00:00:00 2001 From: plenoir Date: Wed, 15 Apr 2026 21:51:56 +0200 Subject: [PATCH 04/10] pylint --- .github/workflows/build.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2916e6f..15c65d8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,3 +18,23 @@ jobs: uses: SonarSource/sonarqube-scan-action@f00de44f574073760c9deaf47f694e10431f3988 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + pylint: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pylint + - name: Analysing the code with pylint + run: | + pylint $(git ls-files '*.py') From 507b147ea61d2258838146cf949c22a9aee7b35b Mon Sep 17 00:00:00 2001 From: plenoir Date: Wed, 15 Apr 2026 21:58:56 +0200 Subject: [PATCH 05/10] pylint --- .sonarlint/connectedMode.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .sonarlint/connectedMode.json diff --git a/.sonarlint/connectedMode.json b/.sonarlint/connectedMode.json new file mode 100644 index 0000000..d08faa7 --- /dev/null +++ b/.sonarlint/connectedMode.json @@ -0,0 +1,5 @@ +{ + "sonarCloudOrganization": "lenoirpatrick", + "projectKey": "lenoirpatrick_githubwebhook", + "region": "EU" +} \ No newline at end of file From cf283321c1dcf734cb92de6020ab54c32f51dfa8 Mon Sep 17 00:00:00 2001 From: plenoir Date: Wed, 15 Apr 2026 22:11:33 +0200 Subject: [PATCH 06/10] pylint --- gitpull.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/gitpull.py b/gitpull.py index 516ab1b..37c2437 100644 --- a/gitpull.py +++ b/gitpull.py @@ -1,3 +1,5 @@ +""" Mies à jour automatique d'un dépot git """ + import subprocess import os import json @@ -8,7 +10,7 @@ app = FastAPI() -with(open('config/config.json', 'r')) as githubjson: +with(open('config/config.json', 'r', encoding="utf-8")) as githubjson: config_github = json.load(githubjson) @app.get("/", response_class=HTMLResponse) @@ -31,10 +33,12 @@ async def home(): @app.get('/beats') def beats(): + """ heart beats """ return {"result": True}, 200 @app.post('/webhook') def webhook(request: Request): + """ webhook pour lancer le pull de github """ # Vérifier la signature (optionnel) if request.headers.get('X-Hub-Signature-256'): # Ici, vous pouvez vérifier la signature avec votre clé secrète @@ -48,15 +52,14 @@ def webhook(request: Request): print("Nouveau push détecté ! Mise à jour en cours...") return update_webhook(webhook_github), 200 - else: - return "Ignoré : ce n'est pas un push sur la branche principale.", 200 + return "Ignoré : ce n'est pas un push sur la branche principale.", 200 @app.get('/webhookdemo') def webhookdemo(): + """ gitpull de demo """ # Vérifier la signature (optionnel) - import json - with(open('demo/demo.json', 'r')) as openjson: + with(open('demo/demo.json', 'r', encoding="utf-8")) as openjson: webhook_github = json.load(openjson) return update_webhook(webhook_github), 200 @@ -72,13 +75,14 @@ def update_webhook(webhook_github): if os.path.isdir(path_repo): print("Retour en arrière") command_reset = ['git', 'reset', '--hard', 'HEAD~1'] - subprocess.run(command_reset) + subprocess.run(command_reset, check=True) print("Mise à jour du dépot") retour_git = subprocess.run(command, stdout=subprocess.PIPE, # Capturer la sortie standard stderr=subprocess.PIPE, # Capturer la sortie d'erreur text=True, # Retourner les sorties sous forme de chaînes de caractères + check=True ) # Vérifier le code de retour @@ -101,11 +105,11 @@ def update_webhook(webhook_github): # retour vers home assistant return retour - except Exception as ex: + except (Exception, ) as ex: return ex if __name__ == '__main__': ip_address = config_github.get("ip") - uvicorn.run(app, host=ip_address, port=5000) \ No newline at end of file + uvicorn.run(app, host=ip_address, port=5000) From a8d27fd4c15b181aac0a7fc3df05a97a8f4234c3 Mon Sep 17 00:00:00 2001 From: plenoir Date: Wed, 15 Apr 2026 22:20:33 +0200 Subject: [PATCH 07/10] pylint --- gitpull.py | 81 +++++++++++++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/gitpull.py b/gitpull.py index 37c2437..469c48f 100644 --- a/gitpull.py +++ b/gitpull.py @@ -15,6 +15,7 @@ @app.get("/", response_class=HTMLResponse) async def home(): + """ Page index """ # Page HTML simple avec un lien vers /webhookdemo html_content = """ @@ -66,47 +67,45 @@ def webhookdemo(): def update_webhook(webhook_github): - try: - repo = webhook_github['repository']['full_name'] - path_repo = config_github[repo]['path'] - command = ['git', '-C', path_repo, 'pull'] - result = True - message = f"repo mis à jour dans {path_repo} avec la commande : {command}" - if os.path.isdir(path_repo): - print("Retour en arrière") - command_reset = ['git', 'reset', '--hard', 'HEAD~1'] - subprocess.run(command_reset, check=True) - - print("Mise à jour du dépot") - retour_git = subprocess.run(command, - stdout=subprocess.PIPE, # Capturer la sortie standard - stderr=subprocess.PIPE, # Capturer la sortie d'erreur - text=True, # Retourner les sorties sous forme de chaînes de caractères - check=True - ) - - # Vérifier le code de retour - if retour_git.returncode == 0: - print("La commande a réussi.") - print(f"Sortie standard : {retour_git.stdout}") - message = retour_git.stdout # Message de retour en cas de succès - result = True - else: - print(f"La commande a échoué avec le code {retour_git.returncode}.") - print(f"Sortie d'erreur : {retour_git.stderr}") - message = retour_git.stderr # Message d'erreur - result = False - - retour = { - "result": result, - "message": message - } - - # retour vers home assistant - - return retour - except (Exception, ) as ex: - return ex + """ Fonction commune de mise à jour du dépot """ + repo = webhook_github['repository']['full_name'] + path_repo = config_github[repo]['path'] + command = ['git', '-C', path_repo, 'pull'] + result = True + message = f"repo mis à jour dans {path_repo} avec la commande : {command}" + if os.path.isdir(path_repo): + print("Retour en arrière") + command_reset = ['git', 'reset', '--hard', 'HEAD~1'] + subprocess.run(command_reset, check=True) + + print("Mise à jour du dépot") + retour_git = subprocess.run(command, + stdout=subprocess.PIPE, # Capturer la sortie standard + stderr=subprocess.PIPE, # Capturer la sortie d'erreur + text=True, # Retourner les sorties sous forme de chaînes de caractères + check=True + ) + + # Vérifier le code de retour + if retour_git.returncode == 0: + print("La commande a réussi.") + print(f"Sortie standard : {retour_git.stdout}") + message = retour_git.stdout # Message de retour en cas de succès + result = True + else: + print(f"La commande a échoué avec le code {retour_git.returncode}.") + print(f"Sortie d'erreur : {retour_git.stderr}") + message = retour_git.stderr # Message d'erreur + result = False + + retour = { + "result": result, + "message": message + } + + # retour vers home assistant + + return retour if __name__ == '__main__': From 4c9f049a378bfb5bfd21fd0ac95fd3921fbcff26 Mon Sep 17 00:00:00 2001 From: plenoir Date: Sat, 16 May 2026 16:09:47 +0200 Subject: [PATCH 08/10] #16-23 Corrections bugs FastAPI et ajout couverture de tests 97% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - webhook: async + await request.body() + json.loads (fix #16) - Retours dict directs sans tuple Flask-style (fix #17) - Vérification HMAC-SHA256 via webhook_secret dans config (fix #18) - git reset -C path_repo + HEAD au lieu de HEAD~1 (fix #19) - try/except CalledProcessError remplace if/else returncode (fix #20) - Guard "repo not in config_github" avant accès dict (fix #21) - BASE_DIR = Path(__file__).parent pour chemins absolus (fix #22) - 12 tests pytest, 97% couverture, CI mis à jour (fix #23) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build.yml | 76 ++++++++---- CLAUDE.md | 65 ++++++++++ gitpull.py | 233 ++++++++++++++++++------------------ requirements.txt | 3 + test_gitpull.py | 208 ++++++++++++++++++++++++++++++++ 5 files changed, 451 insertions(+), 134 deletions(-) create mode 100644 CLAUDE.md create mode 100644 test_gitpull.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 15c65d8..8fc5c88 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,34 +7,70 @@ on: pull_request: types: [opened, synchronize, reopened] jobs: - sonarqube: - name: SonarQube + test: + name: Tests & Coverage runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@f00de44f574073760c9deaf47f694e10431f3988 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run tests with coverage + run: | + pytest --cov=gitpull --cov-report=xml --cov-report=term-missing + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.python-version }} + path: coverage.xml pylint: + name: Pylint runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pylint - - name: Analysing the code with pylint - run: | - pylint $(git ls-files '*.py') + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pylint + - name: Analysing the code with pylint + run: | + pylint $(git ls-files '*.py') + + sonarqube: + name: SonarQube + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install dependencies and run coverage + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pytest --cov=gitpull --cov-report=xml + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@f00de44f574073760c9deaf47f694e10431f3988 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d42a8d4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A FastAPI-based GitHub webhook server that automatically pulls the latest code from GitHub repositories when a push event is received. Intended for CI/CD deployment on a Raspberry Pi or similar server. + +## Commands + +### Install dependencies +```shell +pip install -r requirements.txt +``` + +### Run the server +```shell +python gitpull.py +``` + +### Lint +```shell +pylint gitpull.py +``` + +### Run tests +```shell +pytest +``` + +### Run tests with coverage +```shell +pytest --cov=gitpull --cov-report=term-missing +``` + +## Architecture + +The entire application is in a single file: `gitpull.py`. + +- **`/`** — HTML home page with a link to the demo endpoint. +- **`/beats`** — Health check endpoint; returns `{"result": true}`. +- **`/webhook`** (POST) — Receives GitHub push events. Checks the `ref` field; only processes pushes to `refs/heads/main`. Calls `update_webhook()`. +- **`/webhookdemo`** — Triggers a simulated webhook using `demo/demo.json` as the payload. Useful for manual testing. +- **`update_webhook(webhook_github)`** — Core logic: resolves the repo path from `config/config.json`, runs `git reset --hard HEAD~1` then `git pull` in that path. + +## Configuration + +`config/config.json` maps repository full names (e.g. `"lenoirpatrick/githubwebhook"`) to local paths, and contains the server bind IP: + +```json +{ + "ip": "127.0.0.1", + "lenoirpatrick/githubwebhook": { + "path": "/home/pi/app/githubwebhook" + } +} +``` + +The config is loaded at module startup (global scope), so the server must be started from the project root directory. + +## CI + +`.github/workflows/build.yml` runs two jobs on push to `main`/`develop` and on PRs: +- **SonarQube** — static analysis via SonarCloud (requires `SONAR_TOKEN` secret). +- **pylint** — runs across Python 3.11, 3.12, 3.13 matrix. \ No newline at end of file diff --git a/gitpull.py b/gitpull.py index 469c48f..c7f6a20 100644 --- a/gitpull.py +++ b/gitpull.py @@ -1,114 +1,119 @@ -""" Mies à jour automatique d'un dépot git """ - -import subprocess -import os -import json - -import uvicorn -from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse - -app = FastAPI() - -with(open('config/config.json', 'r', encoding="utf-8")) as githubjson: - config_github = json.load(githubjson) - -@app.get("/", response_class=HTMLResponse) -async def home(): - """ Page index """ - # Page HTML simple avec un lien vers /webhookdemo - html_content = """ - - - - Accueil - Webhook Demo - - -

Bienvenue sur le serveur de webhook GitHub

-

Cliquez ci-dessous pour tester le webhook :

- Tester le webhook - - - """ - return HTMLResponse(content=html_content, status_code=200) - -@app.get('/beats') -def beats(): - """ heart beats """ - return {"result": True}, 200 - -@app.post('/webhook') -def webhook(request: Request): - """ webhook pour lancer le pull de github """ - # Vérifier la signature (optionnel) - if request.headers.get('X-Hub-Signature-256'): - # Ici, vous pouvez vérifier la signature avec votre clé secrète - pass - - # Récupérer les données du webhook - webhook_github = request.json - - # Vérifier que c'est un push sur la branche principale - if webhook_github['ref'] == 'refs/heads/main': - print("Nouveau push détecté ! Mise à jour en cours...") - - return update_webhook(webhook_github), 200 - return "Ignoré : ce n'est pas un push sur la branche principale.", 200 - - -@app.get('/webhookdemo') -def webhookdemo(): - """ gitpull de demo """ - # Vérifier la signature (optionnel) - with(open('demo/demo.json', 'r', encoding="utf-8")) as openjson: - webhook_github = json.load(openjson) - - return update_webhook(webhook_github), 200 - - -def update_webhook(webhook_github): - """ Fonction commune de mise à jour du dépot """ - repo = webhook_github['repository']['full_name'] - path_repo = config_github[repo]['path'] - command = ['git', '-C', path_repo, 'pull'] - result = True - message = f"repo mis à jour dans {path_repo} avec la commande : {command}" - if os.path.isdir(path_repo): - print("Retour en arrière") - command_reset = ['git', 'reset', '--hard', 'HEAD~1'] - subprocess.run(command_reset, check=True) - - print("Mise à jour du dépot") - retour_git = subprocess.run(command, - stdout=subprocess.PIPE, # Capturer la sortie standard - stderr=subprocess.PIPE, # Capturer la sortie d'erreur - text=True, # Retourner les sorties sous forme de chaînes de caractères - check=True - ) - - # Vérifier le code de retour - if retour_git.returncode == 0: - print("La commande a réussi.") - print(f"Sortie standard : {retour_git.stdout}") - message = retour_git.stdout # Message de retour en cas de succès - result = True - else: - print(f"La commande a échoué avec le code {retour_git.returncode}.") - print(f"Sortie d'erreur : {retour_git.stderr}") - message = retour_git.stderr # Message d'erreur - result = False - - retour = { - "result": result, - "message": message - } - - # retour vers home assistant - - return retour - - -if __name__ == '__main__': - ip_address = config_github.get("ip") - - uvicorn.run(app, host=ip_address, port=5000) +""" Mies à jour automatique d'un dépot git """ + +import hashlib +import hmac +import json +import pathlib +import subprocess + +import uvicorn +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse + +app = FastAPI() + +BASE_DIR = pathlib.Path(__file__).parent + +with (BASE_DIR / 'config' / 'config.json').open(encoding="utf-8") as githubjson: + config_github = json.load(githubjson) + + +@app.get("/", response_class=HTMLResponse) +async def home(): + """ Page index """ + html_content = """ + + + + Accueil - Webhook Demo + + +

Bienvenue sur le serveur de webhook GitHub

+

Cliquez ci-dessous pour tester le webhook :

+ Tester le webhook + + + """ + return HTMLResponse(content=html_content, status_code=200) + + +@app.get('/beats') +def beats(): + """ heart beats """ + return {"result": True} + + +@app.post('/webhook') +async def webhook(request: Request): + """ webhook pour lancer le pull de github """ + body = await request.body() + + secret = config_github.get("webhook_secret", "") + if secret: + expected_sig = "sha256=" + hmac.new( + secret.encode(), body, hashlib.sha256 + ).hexdigest() + received_sig = request.headers.get("X-Hub-Signature-256", "") + if not hmac.compare_digest(expected_sig, received_sig): + raise HTTPException(status_code=401, detail="Signature invalide") + + webhook_github = json.loads(body) + + if webhook_github.get('ref') == 'refs/heads/main': + print("Nouveau push détecté ! Mise à jour en cours...") + return update_webhook(webhook_github) + + return {"result": False, "message": "Ignoré : ce n'est pas un push sur la branche principale."} + + +@app.get('/webhookdemo') +def webhookdemo(): + """ gitpull de demo """ + with (BASE_DIR / 'demo' / 'demo.json').open(encoding="utf-8") as openjson: + webhook_github = json.load(openjson) + + return update_webhook(webhook_github) + + +def update_webhook(webhook_github): + """ Fonction commune de mise à jour du dépot """ + repo = webhook_github['repository']['full_name'] + + if repo not in config_github: + return {"result": False, "message": f"Repo {repo} non configuré"} + + path_repo = config_github[repo]['path'] + + if not pathlib.Path(path_repo).is_dir(): + return {"result": False, "message": f"Chemin introuvable : {path_repo}"} + + try: + print("Retour à l'état propre") + subprocess.run( + ['git', '-C', path_repo, 'reset', '--hard', 'HEAD'], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + print("Mise à jour du dépot") + retour_git = subprocess.run( + ['git', '-C', path_repo, 'pull'], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + print(f"Sortie standard : {retour_git.stdout}") + return {"result": True, "message": retour_git.stdout} + + except subprocess.CalledProcessError as exc: + print(f"La commande a échoué : {exc.stderr}") + return {"result": False, "message": exc.stderr} + + +if __name__ == '__main__': + ip_address = config_github.get("ip", "127.0.0.1") + uvicorn.run(app, host=ip_address, port=5000) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3a29f1e..63e615d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ fastapi==0.135.3 uvicorn==0.44.0 +httpx==0.28.1 +pytest==8.3.5 +pytest-cov==6.1.0 \ No newline at end of file diff --git a/test_gitpull.py b/test_gitpull.py new file mode 100644 index 0000000..c1827f0 --- /dev/null +++ b/test_gitpull.py @@ -0,0 +1,208 @@ +""" Tests de couverture pour gitpull.py """ + +import hashlib +import hmac +import json +import pathlib +import subprocess +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + + +CONFIG_FIXTURE = { + "ip": "127.0.0.1", + "webhook_secret": "supersecret", + "lenoirpatrick/testrepo": {"path": "/fake/path/testrepo"}, +} + +DEMO_PAYLOAD = { + "ref": "refs/heads/main", + "repository": {"full_name": "lenoirpatrick/testrepo"}, +} + + +@pytest.fixture(autouse=True) +def patch_config(tmp_path): + """Remplace la config globale et les fichiers lus au module-level.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "config.json").write_text(json.dumps(CONFIG_FIXTURE), encoding="utf-8") + + demo_dir = tmp_path / "demo" + demo_dir.mkdir() + (demo_dir / "demo.json").write_text(json.dumps(DEMO_PAYLOAD), encoding="utf-8") + + import gitpull + original = dict(gitpull.config_github) + original_base = gitpull.BASE_DIR + + gitpull.config_github.clear() + gitpull.config_github.update(CONFIG_FIXTURE) + gitpull.BASE_DIR = tmp_path + (tmp_path / "demo" / "demo.json").write_text(json.dumps(DEMO_PAYLOAD), encoding="utf-8") + + yield + + gitpull.config_github.clear() + gitpull.config_github.update(original) + gitpull.BASE_DIR = original_base + + +@pytest.fixture() +def client(): + from gitpull import app + return TestClient(app, raise_server_exceptions=False) + + +def _make_signature(body: bytes, secret: str = "supersecret") -> str: + return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + + +# --------------------------------------------------------------------------- +# GET / +# --------------------------------------------------------------------------- + +class TestHome: + def test_returns_html(self, client): + resp = client.get("/") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + assert "webhook" in resp.text.lower() + + +# --------------------------------------------------------------------------- +# GET /beats +# --------------------------------------------------------------------------- + +class TestBeats: + def test_returns_true(self, client): + resp = client.get("/beats") + assert resp.status_code == 200 + assert resp.json() == {"result": True} + + +# --------------------------------------------------------------------------- +# POST /webhook +# --------------------------------------------------------------------------- + +class TestWebhook: + def _post(self, client, payload: dict, secret: str = "supersecret", headers=None): + body = json.dumps(payload).encode() + sig = _make_signature(body, secret) + h = {"X-Hub-Signature-256": sig} + if headers: + h.update(headers) + return client.post("/webhook", content=body, headers=h) + + def test_push_main_triggers_update(self, client): + with patch("gitpull.update_webhook", return_value={"result": True, "message": "ok"}) as mock_upd: + resp = self._post(client, DEMO_PAYLOAD) + assert resp.status_code == 200 + mock_upd.assert_called_once() + + def test_push_other_branch_ignored(self, client): + payload = {"ref": "refs/heads/develop", "repository": {"full_name": "lenoirpatrick/testrepo"}} + resp = self._post(client, payload) + assert resp.status_code == 200 + data = resp.json() + assert data["result"] is False + assert "principale" in data["message"] + + def test_invalid_signature_returns_401(self, client): + body = json.dumps(DEMO_PAYLOAD).encode() + resp = client.post( + "/webhook", + content=body, + headers={"X-Hub-Signature-256": "sha256=invalide"}, + ) + assert resp.status_code == 401 + + def test_no_secret_in_config_skips_check(self, client): + import gitpull + gitpull.config_github.pop("webhook_secret", None) + body = json.dumps(DEMO_PAYLOAD).encode() + with patch("gitpull.update_webhook", return_value={"result": True, "message": "ok"}): + resp = client.post("/webhook", content=body) + assert resp.status_code == 200 + gitpull.config_github["webhook_secret"] = "supersecret" + + +# --------------------------------------------------------------------------- +# GET /webhookdemo +# --------------------------------------------------------------------------- + +class TestWebhookDemo: + def test_calls_update_webhook(self, client): + with patch("gitpull.update_webhook", return_value={"result": True, "message": "demo ok"}) as mock_upd: + resp = client.get("/webhookdemo") + assert resp.status_code == 200 + mock_upd.assert_called_once_with(DEMO_PAYLOAD) + + +# --------------------------------------------------------------------------- +# update_webhook() +# --------------------------------------------------------------------------- + +class TestUpdateWebhook: + def test_repo_not_in_config(self): + from gitpull import update_webhook + payload = {"repository": {"full_name": "unknown/repo"}} + result = update_webhook(payload) + assert result["result"] is False + assert "non configuré" in result["message"] + + def test_path_not_a_directory(self): + from gitpull import update_webhook + result = update_webhook(DEMO_PAYLOAD) + assert result["result"] is False + assert "introuvable" in result["message"] + + def test_successful_pull(self, tmp_path): + from gitpull import update_webhook + import gitpull + gitpull.config_github["lenoirpatrick/testrepo"]["path"] = str(tmp_path) + + mock_result = MagicMock() + mock_result.stdout = "Already up to date." + mock_result.returncode = 0 + + with patch("subprocess.run", return_value=mock_result) as mock_run: + result = update_webhook(DEMO_PAYLOAD) + + assert result["result"] is True + assert "Already up to date." in result["message"] + assert mock_run.call_count == 2 # reset + pull + + reset_call = mock_run.call_args_list[0] + pull_call = mock_run.call_args_list[1] + assert "reset" in reset_call.args[0] + assert "-C" in reset_call.args[0] + assert "pull" in pull_call.args[0] + + def test_git_pull_failure(self, tmp_path): + from gitpull import update_webhook + 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]): + result = update_webhook(DEMO_PAYLOAD) + + assert result["result"] is False + assert "fatal: error" in result["message"] + + def test_git_reset_failure(self, tmp_path): + from gitpull import update_webhook + import gitpull + gitpull.config_github["lenoirpatrick/testrepo"]["path"] = str(tmp_path) + + error = subprocess.CalledProcessError(1, "git reset", stderr="fatal: reset error") + + with patch("subprocess.run", side_effect=error): + result = update_webhook(DEMO_PAYLOAD) + + assert result["result"] is False + assert "fatal: reset error" in result["message"] \ No newline at end of file From 312d796590febe74c2548b6889a399bf4db24d44 Mon Sep 17 00:00:00 2001 From: plenoir Date: Sat, 16 May 2026 17:03:44 +0200 Subject: [PATCH 09/10] =?UTF-8?q?webhookdemo:=20page=20terminal=20HTML=20a?= =?UTF-8?q?nim=C3=A9e=20+=20page=20d'accueil=20redesign=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /webhookdemo retourne une page HTML style terminal dark avec les commandes git simulées et leur pseudo-sortie - /: design GitHub dark avec badge "En ligne", bouton démo, footer avec liens health check et API docs - update_webhook() simplifié : paramètre demo retiré Co-Authored-By: Claude Sonnet 4.6 --- gitpull.py | 259 ++++++++++++++++++++++++++++++++++++++++++++---- test_gitpull.py | 18 +++- 2 files changed, 255 insertions(+), 22 deletions(-) diff --git a/gitpull.py b/gitpull.py index c7f6a20..9b15d9b 100644 --- a/gitpull.py +++ b/gitpull.py @@ -10,7 +10,11 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse -app = FastAPI() +app = FastAPI( + title="GitHub Webhook Server", + description="Serveur de webhook GitHub pour déploiement CI/CD automatique via `git pull`.", + version="1.2.0", +) BASE_DIR = pathlib.Path(__file__).parent @@ -21,19 +25,130 @@ @app.get("/", response_class=HTMLResponse) async def home(): """ Page index """ - html_content = """ - - - - Accueil - Webhook Demo - - -

Bienvenue sur le serveur de webhook GitHub

-

Cliquez ci-dessous pour tester le webhook :

- Tester le webhook - - - """ + html_content = """ + + + + + GitHub Webhook Server + + + +
+
🔗
+

GitHub Webhook Server

+

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

+
En ligne
+
+ ▶ Lancer la démo +
+ + + +""" return HTMLResponse(content=html_content, status_code=200) @@ -43,7 +158,7 @@ def beats(): return {"result": True} -@app.post('/webhook') +@app.post('/webhook', responses={401: {"description": "Signature HMAC-SHA256 invalide"}}) async def webhook(request: Request): """ webhook pour lancer le pull de github """ body = await request.body() @@ -66,13 +181,121 @@ async def webhook(request: Request): return {"result": False, "message": "Ignoré : ce n'est pas un push sur la branche principale."} -@app.get('/webhookdemo') +@app.get('/webhookdemo', response_class=HTMLResponse) def webhookdemo(): """ gitpull de demo """ with (BASE_DIR / 'demo' / 'demo.json').open(encoding="utf-8") as openjson: webhook_github = json.load(openjson) - return update_webhook(webhook_github) + repo = webhook_github['repository']['full_name'] + path_repo = config_github.get(repo, {}).get('path', '/home/pi/app/' + repo.split('/')[-1]) + + cmd_reset = f"git -C {path_repo} reset --hard HEAD" + cmd_pull = f"git -C {path_repo} pull" + out_reset = "HEAD is now at a8d27fd pylint" + out_pull = ( + "remote: Enumerating objects: 5, done.\n" + "remote: Counting objects: 100% (5/5), done.\n" + "remote: Compressing objects: 100% (3/3), done.\n" + "Unpacking objects: 100% (3/3), done.\n" + f"From https://github.com/{repo}\n" + " a8d27fd..4c9f049 main -> origin/main\n" + "Updating a8d27fd..4c9f049\n" + "Fast-forward\n" + " gitpull.py | 12 ++++++------\n" + " 1 file changed, 6 insertions(+), 6 deletions(-)" + ) + + html = f""" + + + + + Webhook Demo — {repo} + + + +

Simulation webhook — {repo}

+

Répertoire cible : {path_repo}

+ +
+
+ + + + bash — webhook deploy +
+
$ {cmd_reset} +{out_reset} + +$ {cmd_pull} +{out_pull} +✓ Mise à jour terminée avec succès.
+
+ + + +""" + return HTMLResponse(content=html, status_code=200) def update_webhook(webhook_github): @@ -116,4 +339,4 @@ def update_webhook(webhook_github): if __name__ == '__main__': ip_address = config_github.get("ip", "127.0.0.1") - uvicorn.run(app, host=ip_address, port=5000) \ No newline at end of file + uvicorn.run(app, host=ip_address, port=5000) diff --git a/test_gitpull.py b/test_gitpull.py index c1827f0..484f9ae 100644 --- a/test_gitpull.py +++ b/test_gitpull.py @@ -134,11 +134,21 @@ def test_no_secret_in_config_skips_check(self, client): # --------------------------------------------------------------------------- class TestWebhookDemo: - def test_calls_update_webhook(self, client): - with patch("gitpull.update_webhook", return_value={"result": True, "message": "demo ok"}) as mock_upd: - resp = client.get("/webhookdemo") + def test_returns_html(self, client): + resp = client.get("/webhookdemo") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_shows_repo_and_commands(self, client): + resp = client.get("/webhookdemo") + assert "lenoirpatrick/testrepo" in resp.text + assert "git" in resp.text + assert "pull" in resp.text + + def test_no_real_path_required(self, client): + """La page s'affiche même si le répertoire du repo n'existe pas.""" + resp = client.get("/webhookdemo") assert resp.status_code == 200 - mock_upd.assert_called_once_with(DEMO_PAYLOAD) # --------------------------------------------------------------------------- From 5b0d9b75192d5b12de5a4a3d414a57f8d934145b Mon Sep 17 00:00:00 2001 From: plenoir Date: Sat, 16 May 2026 17:57:43 +0200 Subject: [PATCH 10/10] =?UTF-8?q?README:=20d=C3=A9tail=20configuration=20c?= =?UTF-8?q?onfig.json=20et=20d=C3=A9marrage=20automatique=20systemd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- README.md | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 45557b9..be741a3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,24 @@ Gestion du webhook Github pour déploiement CI/CD sur vos environnements. [![GitHub stars](https://img.shields.io/github/stars/lenoirpatrick/githubwebhook)](https://github.com/lenoirpatrick/githubwebhook) [![GitHub license](https://img.shields.io/github/license/lenoirpatrick/githubwebhook)](https://github.com/lenoirpatrick/githubwebhook) +# Prérequis — Configurer le webhook GitHub + +Sur chaque dépôt à déployer, un webhook doit être configuré dans GitHub pour notifier cette application à chaque push. + +1. Aller dans **Settings → Webhooks → Add webhook** du dépôt concerné +2. Renseigner les champs suivants : + +| Champ | Valeur | +|-------|--------| +| **Payload URL** | `http://:5000/webhook` | +| **Content type** | `application/json` | +| **Secret** | La valeur de `webhook_secret` définie dans `config.json` (si configurée) | +| **Which events?** | *Just the push event* | + +3. Cocher **Active** et valider. + +GitHub enverra alors un événement `POST /webhook` à chaque push. Seuls les pushs sur la branche `main` déclenchent un `git pull`. + # Installation ```shell git clone githubwebhook.git @@ -23,14 +41,85 @@ chmod +x run.sh ``` # Configuration -Dans le répertoire config, créer un fichier config.json + +Dans le répertoire `config`, créer un fichier `config.json` : + ```json { - "lenoirpatrick/githubwebhook": { - "path": "/home/pi/app/githubwebhook" - } + "ip": "0.0.0.0", + "webhook_secret": "votre_secret_github", + "lenoirpatrick/githubwebhook": { + "path": "/home/pi/app/githubwebhook" + }, + "lenoirpatrick/autreprojet": { + "path": "/home/pi/app/autreprojet" + } } ``` +| Clé | Description | +|-----|-------------| +| `ip` | Adresse d'écoute : `0.0.0.0` pour toutes les interfaces, `127.0.0.1` pour local uniquement | +| `webhook_secret` | Secret partagé avec GitHub pour valider la signature HMAC-SHA256 (optionnel mais recommandé) | +| `"owner/repo"` | Chemin absolu local du dépôt à mettre à jour lors d'un push sur `main` | + +Plusieurs dépôts peuvent être configurés simultanément. + # Lancement -Executer le script ```run.sh``` \ No newline at end of file + +## Manuel + +```shell +./run.sh +``` + +Le script installe les dépendances, puis lance l'application en arrière-plan via `nohup`. Les logs sont disponibles dans `nohup.out`. + +## Démarrage automatique avec systemd (recommandé) + +Pour que le serveur se lance automatiquement au démarrage de Linux, créer un service systemd. + +**1. Créer le fichier de service** (adapter les chemins et l'utilisateur) : + +```shell +sudo nano /etc/systemd/system/githubwebhook.service +``` + +```ini +[Unit] +Description=GitHub Webhook Server +After=network.target + +[Service] +Type=simple +User=pi +WorkingDirectory=/home/pi/app/githubwebhook +ExecStart=/usr/bin/python3 /home/pi/app/githubwebhook/gitpull.py +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +**2. Activer et démarrer le service :** + +```shell +sudo systemctl daemon-reload +sudo systemctl enable githubwebhook +sudo systemctl start githubwebhook +``` + +**3. Vérifier que le service tourne :** + +```shell +sudo systemctl status githubwebhook +``` + +**Commandes utiles :** + +```shell +sudo systemctl stop githubwebhook # arrêter +sudo systemctl restart githubwebhook # redémarrer +journalctl -u githubwebhook -f # suivre les logs en temps réel +``` \ No newline at end of file