Guide: GitHub events → PartyFlow через прокси
GitHub умеет слать webhook'и и подписывать их своей схемой (X-Hub-Signature-256 от raw body). PartyFlow ждёт Slack-совместимый или PartyFlow-native формат с своей HMAC-подписью. Эти схемы несовместимы — нужен прокси.
В этом гайде — minimal FastAPI прокси на Python: принимает GitHub webhook, проверяет GitHub-подпись, конвертирует событие в Slack-формат, подписывает PartyFlow-native схемой и форвардит на PartyFlow.
Покрыты три типовых события: pull_request.opened, pull_request.closed (merged), push.
Архитектура
[GitHub] ──POST + X-Hub-Signature-256──▶ [Your proxy] ──POST + X-PartyFlow-Signature──▶ [PartyFlow]
(raw GitHub payload) (Slack-формат message)Прокси хранит два секрета:
GITHUB_WEBHOOK_SECRET— задан в настройках GitHub repo (Settings → Webhooks → Secret).PARTYFLOW_SIGNING_SECRET— выдан при создании incoming webhook в PartyFlow UI, форматwhsec_<base64url>.
Предварительные шаги
- Создать incoming webhook в PartyFlow. Space → Integrations → Incoming Webhooks → New → выбрать канал → сохранить
url(содержит token) иsigning_secret. - Создать GitHub webhook. Repo Settings → Webhooks → Add webhook.
- Payload URL: публичный адрес вашего прокси (например,
https://ghproxy.example.com/gh/partyflow). - Content type:
application/json. - Secret: любая случайная строка (сохраните как
GITHUB_WEBHOOK_SECRET). - Events: Pull requests, Pushes (или "Send me everything" для простоты — прокси сам отфильтрует).
- Payload URL: публичный адрес вашего прокси (например,
- Задеплоить прокси (раздел Deployment ниже).
Код прокси (FastAPI)
Полный рабочий файл proxy.py. Зависимости: fastapi, uvicorn, httpx.
"""
GitHub → PartyFlow webhook proxy.
Accepts GitHub webhooks, verifies X-Hub-Signature-256, converts a handful
of event types into Slack-compatible payloads, signs with PartyFlow
native HMAC and forwards to the incoming webhook URL.
"""
import hashlib
import hmac
import json
import os
import time
import httpx
from fastapi import FastAPI, Header, HTTPException, Request
# ── Config ────────────────────────────────────────────────────────────
GITHUB_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()
PARTYFLOW_URL = os.environ["PARTYFLOW_WEBHOOK_URL"] # https://.../api/v1/webhooks/incoming/whk_...
PARTYFLOW_SECRET = os.environ["PARTYFLOW_SIGNING_SECRET"].encode()
app = FastAPI()
# ── GitHub signature verification ─────────────────────────────────────
def verify_github_signature(secret: bytes, body: bytes, header: str) -> bool:
if not header or not header.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
# ── Event converters: GitHub → Slack-format ───────────────────────────
def convert_pull_request(event: dict) -> dict | None:
action = event.get("action")
pr = event.get("pull_request", {})
repo = event.get("repository", {}).get("full_name", "?")
author = pr.get("user", {}).get("login", "?")
title = pr.get("title", "")
url = pr.get("html_url", "")
number = pr.get("number", 0)
if action == "opened":
return {
"text": f":inbox_tray: New PR #{number} in `{repo}`",
"attachments": [{
"color": "#36a64f",
"title": title,
"title_link": url,
"author_name": author,
"text": (pr.get("body") or "")[:500],
}],
}
if action == "closed" and pr.get("merged"):
return {
"text": f":white_check_mark: PR #{number} merged to `{repo}`",
"attachments": [{
"color": "#6f42c1",
"title": title,
"title_link": url,
"author_name": author,
}],
}
# Other PR actions — skip.
return None
def convert_push(event: dict) -> dict | None:
# Skip branch-delete events and pushes with no commits.
commits = event.get("commits") or []
if not commits:
return None
repo = event.get("repository", {}).get("full_name", "?")
ref = event.get("ref", "").replace("refs/heads/", "")
pusher = event.get("pusher", {}).get("name", "?")
compare_url = event.get("compare", "")
lines = [f"- `{c['id'][:7]}` {c['message'].splitlines()[0]}" for c in commits[:5]]
more = f"\n… and {len(commits) - 5} more commits" if len(commits) > 5 else ""
return {
"text": f":arrow_up: {pusher} pushed {len(commits)} commit(s) to `{repo}/{ref}`",
"attachments": [{
"color": "#0366d6",
"title": "Compare diff",
"title_link": compare_url,
"text": "\n".join(lines) + more,
}],
}
# ── PartyFlow forwarding ──────────────────────────────────────────────
def sign_and_forward(payload: dict) -> int:
body = json.dumps(payload, separators=(",", ":")).encode()
ts = str(int(time.time()))
signing_string = ts.encode() + b"." + body
signature = "sha256=" + hmac.new(PARTYFLOW_SECRET, signing_string, hashlib.sha256).hexdigest()
with httpx.Client(timeout=10.0) as client:
resp = client.post(
PARTYFLOW_URL,
content=body,
headers={
"Content-Type": "application/json",
"X-PartyFlow-Timestamp": ts,
"X-PartyFlow-Signature": signature,
},
)
resp.raise_for_status()
return resp.status_code
# ── HTTP endpoint ─────────────────────────────────────────────────────
@app.post("/gh/partyflow")
async def github_proxy(
request: Request,
x_hub_signature_256: str = Header(default=""),
x_github_event: str = Header(default=""),
):
body = await request.body()
if not verify_github_signature(GITHUB_SECRET, body, x_hub_signature_256):
raise HTTPException(401, "invalid GitHub signature")
event = json.loads(body)
if x_github_event == "pull_request":
converted = convert_pull_request(event)
elif x_github_event == "push":
converted = convert_push(event)
else:
converted = None
# Not every event is interesting — skip quietly with 204.
if converted is None:
return {"skipped": True, "event": x_github_event}
status = sign_and_forward(converted)
return {"forwarded": True, "partyflow_status": status}Запустить локально
pip install fastapi uvicorn httpx
export GITHUB_WEBHOOK_SECRET='my-github-secret'
export PARTYFLOW_WEBHOOK_URL='https://api.partyflow.ru/api/v1/webhooks/incoming/whk_...'
export PARTYFLOW_SIGNING_SECRET='whsec_...'
uvicorn proxy:app --host 0.0.0.0 --port 8080Для локального тестирования с GitHub'ом используйте ngrok, cloudflared или smee.io — они дают публичный URL, который прокидывается в вашу localhost.
Проверка
В репозитории GitHub → Settings → Webhooks → ваш webhook → Recent Deliveries. Каждая доставка показывает payload, статус ответа и заголовки. Успешная — HTTP 200 от прокси.
В PartyFlow — сообщение должно появиться в канале. Если не появилось:
- Проверьте логи прокси — пришёл ли webhook от GitHub, не отвалилась ли верификация подписи, какой статус вернул PartyFlow.
- Проверьте incoming webhook в admin UI PartyFlow — не
disabledли он, какойconsecutive_failures.
Расширение под другие события
Чтобы добавить issues, issue_comment, workflow_run и так далее — добавьте converter-функцию по образцу convert_pull_request и ветку в github_proxy. Для справки по полям событий — GitHub webhook events docs.
Формат Slack attachments — reference/message-format.md в этой же папке. Для более богатой разметки доступны Slack Block Kit блоки.
Deployment
Три типичных способа запуска в production:
Docker
FROM python:3.12-slim
WORKDIR /app
COPY proxy.py /app/
RUN pip install --no-cache-dir fastapi uvicorn httpx
CMD ["uvicorn", "proxy:app", "--host", "0.0.0.0", "--port", "8080"]Секреты — через переменные окружения из secrets manager (Kubernetes Secret, Docker secret, Hashicorp Vault).
systemd
[Unit]
Description=GitHub→PartyFlow proxy
After=network.target
[Service]
EnvironmentFile=/etc/gh-partyflow/env
ExecStart=/usr/local/bin/uvicorn proxy:app --host 0.0.0.0 --port 8080
Restart=on-failure
[Install]
WantedBy=multi-user.targetServerless (AWS Lambda + API Gateway, Cloudflare Workers, Google Cloud Run)
FastAPI заворачивается в Mangum (AWS Lambda) или запускается напрямую в Cloud Run как HTTP-сервис. Секреты — через native secret store платформы. Cold-start в пределах 500 ms — укладываетесь в GitHub's 10-секундный timeout для webhook response.
Обязательно: HTTPS на endpoint (GitHub отказывается слать на plain HTTP в production).
Troubleshooting
| Симптом | Причина | Решение |
|---|---|---|
GitHub показывает 500 в Recent Deliveries |
Исключение в прокси (неожиданная структура события, httpx timeout). | Логи прокси. Типично — GitHub добавил новое event поле. |
GitHub показывает 401 в Recent Deliveries |
X-Hub-Signature-256 не сходится. |
Проверьте, что GITHUB_WEBHOOK_SECRET в прокси совпадает со значением в Settings → Webhooks. После ротации секрета в GitHub — обновите env. |
GitHub показывает 200, в PartyFlow тишина |
Прокси получил webhook, но событие не попало в converter (напр., issue_comment, не обрабатывается в примере). |
Либо не нужно — работает as intended, либо добавьте converter. Логи покажут {"skipped": true, "event": "..."}. |
PartyFlow 401 invalid signature |
PARTYFLOW_SIGNING_SECRET не тот, что выдан при создании webhook'а в PartyFlow. |
Пересоздайте incoming webhook в PartyFlow UI или используйте "rotate signing secret". |
PartyFlow 401 timestamp too old |
Запаздывание между timestamp и отправкой > 5 минут. Обычно — clock skew или retry через длинный timeout. | Убедитесь, что int(time.time()) на хосте прокси — UTC и верный. Установите NTP. |
PartyFlow 404 webhook not found |
Неверный token в URL (PARTYFLOW_WEBHOOK_URL) либо webhook удалён/auto-disabled. |
Проверьте URL в admin UI. Для disabled — включите обратно. |
PartyFlow 429 rate limited |
Burst from GitHub (массовый push, монорепа). | Retry с backoff на стороне прокси, либо увеличьте rate limit через support. |
| Дубликаты сообщений в канале | GitHub webhook "Redeliver" + идемпотентность отсутствует на стороне прокси. | Для точной дедупликации — проверяйте X-GitHub-Delivery header и храните недавние ID в кеше (Redis TTL 10 мин). Для большинства сценариев можно проигнорировать. |
Связанное
- reference/http-webhook-endpoint.md — что PartyFlow ждёт от payload'а.
- guides/verify-signatures.md — разбор схемы подписи PartyFlow-native, snippets на Python/Node/Go/Bash.
- concepts/security.md — best practices по хранению секретов, ротация.
- reference/message-format.md — Slack attachments, Block Kit.