Compare commits
7
Commits
873111aade
...
ci/12-cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
164f055c5e | ||
|
|
526eb56c82 | ||
|
|
df5224e813 | ||
|
|
d1addd2c25 | ||
|
|
9e1d6322b6 | ||
|
|
595368b0a2 | ||
|
|
0f27f405a7 |
@@ -5,6 +5,7 @@
|
||||
TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather
|
||||
ADMIN_IDS= # Telegram ID первых администраторов через запятую
|
||||
LOG_LEVEL=INFO
|
||||
TELEGRAM_PROXY_URL= # (secret) http://user:pass@host:port или socks5://… — на RF-сервере Telegram недоступен напрямую
|
||||
|
||||
# --- База данных ---
|
||||
POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: CD
|
||||
|
||||
# Как в Vobot: тесты здесь не гоняем — они прошли в CI на PR.
|
||||
# dev → /srv/tgit/dev, main → /srv/tgit/prod на хосте Vobot (infra/deploy/deploy.sh).
|
||||
# Окружение без /srv/tgit/<env>/.env на сервере пропускается (prod — когда будет свой токен бота).
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Деплои одной ветки — строго по очереди, без отмены.
|
||||
concurrency:
|
||||
group: cd-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: deploy → ${{ github.ref_name == 'main' && 'prod' || 'dev' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DEPLOY_ENV: ${{ github.ref_name == 'main' && 'prod' || 'dev' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Серия мержей подряд: устаревший прогон сам сворачивается, задеплоит актуальный (опыт Vobot)
|
||||
- id: guard
|
||||
name: "Актуален ли ещё этот пуш?"
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "go=true" >> "$GITHUB_OUTPUT"; exit 0; fi
|
||||
REMOTE_SHA=$(git ls-remote origin "refs/heads/${{ github.ref_name }}" | cut -f1)
|
||||
if [ "$REMOTE_SHA" = "${{ github.sha }}" ]; then echo "go=true" >> "$GITHUB_OUTPUT"
|
||||
else echo "⏭ ${{ github.sha }} уже не HEAD ${{ github.ref_name }} — пропускаю"; echo "go=false" >> "$GITHUB_OUTPUT"; fi
|
||||
|
||||
- name: Deploy
|
||||
if: steps.guard.outputs.go == 'true'
|
||||
env:
|
||||
# Через env:, а не инлайном в run: — многострочный ключ иначе может попасть в лог (опыт Vobot)
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
run: |
|
||||
install -d -m 700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
export DEPLOY_SSH_OPTS="-F /dev/null -i $HOME/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
|
||||
infra/deploy/deploy.sh "$DEPLOY_ENV" HEAD
|
||||
+2
-1
@@ -24,5 +24,6 @@ build/
|
||||
.claude/worktrees/
|
||||
.DS_Store
|
||||
|
||||
# local SQLite DB (bot without Docker)
|
||||
# local run without Docker: SQLite DB, uploads clone, demo repo
|
||||
*.db
|
||||
data/
|
||||
|
||||
@@ -49,6 +49,7 @@ Telegram-бот «git в телеграме» для команды (учебн
|
||||
| [`docs/dev/ISSUE_TEMPLATE.md`](docs/dev/ISSUE_TEMPLATE.md) | Шаблон тела issue |
|
||||
| [`docs/dev/GITEA_API.md`](docs/dev/GITEA_API.md) | Gitea API: issue, PR, зависимости, зеркало |
|
||||
| [`docs/dev/SOURCECRAFT_API.md`](docs/dev/SOURCECRAFT_API.md) | SourceCraft API: CI-прогоны, логи, секреты |
|
||||
| [`docs/dev/DEPLOY.md`](docs/dev/DEPLOY.md) | **Деплой:** сервер, окружения dev/prod, руками, откат |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: install lint fmt typecheck test cov up down logs ps
|
||||
.PHONY: install lint fmt typecheck test cov up down logs ps run demo-repo
|
||||
|
||||
install:
|
||||
uv sync
|
||||
@@ -30,3 +30,18 @@ logs:
|
||||
|
||||
ps:
|
||||
docker compose ps
|
||||
|
||||
# Run the bot on this machine without Docker: SQLite (./tgit.db) and the settings from .env
|
||||
run:
|
||||
uv run python -m app
|
||||
|
||||
# A local uploads repo seeded from the template — try the bot without SourceCraft:
|
||||
# make demo-repo → put the printed GIT_UPLOADS_URL into .env → make run
|
||||
DEMO_REPO := $(CURDIR)/data/demo-uploads.git
|
||||
demo-repo:
|
||||
@test ! -e $(DEMO_REPO) || { echo "$(DEMO_REPO) already exists"; exit 1; }
|
||||
@rm -rf data/demo-seed && mkdir -p data && cp -r infra/uploads-template data/demo-seed
|
||||
@cd data/demo-seed && git init -q -b main && git add -A \
|
||||
&& git -c user.name=tgit-bot -c user.email=tgit-bot@users.noreply.tgit commit -qm "init: uploads template"
|
||||
@git clone -q --bare data/demo-seed $(DEMO_REPO) && rm -rf data/demo-seed
|
||||
@echo "GIT_UPLOADS_URL=$(DEMO_REPO)"
|
||||
|
||||
@@ -59,6 +59,7 @@ class Act(StrEnum):
|
||||
PUSH = "ps"
|
||||
PUSH_GO = "pg"
|
||||
PUSH_FORCE = "pf"
|
||||
COMMIT = "cm"
|
||||
|
||||
|
||||
class CommitCb(CallbackData, prefix="c"):
|
||||
@@ -179,8 +180,10 @@ def render_dir(
|
||||
kb.button(text=t.new_dir, callback_data=CommitCb(a=Act.NEW_DIR))
|
||||
sizes.append(2)
|
||||
if draft_count:
|
||||
# the main action once something is staged — right in the folder, not two screens deep
|
||||
kb.button(text=t.commit_button.format(count=draft_count), callback_data=CommitCb(a=Act.COMMIT))
|
||||
kb.button(text=t.draft_button.format(count=draft_count), callback_data=CommitCb(a=Act.DRAFT))
|
||||
sizes.append(1)
|
||||
sizes += [1, 1]
|
||||
if path:
|
||||
kb.button(text=t.up, callback_data=CommitCb(a=Act.UP))
|
||||
sizes.append(1)
|
||||
@@ -260,6 +263,14 @@ def build_router(
|
||||
kb.adjust(1)
|
||||
return text, kb.as_markup()
|
||||
|
||||
def confirm_screen(draft: DraftView) -> tuple[str, InlineKeyboardMarkup]:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=t.push_go, callback_data=CommitCb(a=Act.PUSH_GO))
|
||||
builder.button(text=t.edit_message, callback_data=CommitCb(a=Act.MESSAGE))
|
||||
builder.button(text=texts.menu.back, callback_data=CommitCb(a=Act.TO_DIR))
|
||||
builder.adjust(1)
|
||||
return t.push_confirm.format(summary=summary(draft), message=draft.message), builder.as_markup()
|
||||
|
||||
def summary(draft: DraftView) -> str:
|
||||
ops = {ChangeOp.ADD: t.op_add, ChangeOp.MODIFY: t.op_modify, ChangeOp.DELETE: t.op_delete}
|
||||
return "\n".join(f"{ops[c.op]} /{c.path}" for c in draft.changes)
|
||||
@@ -466,17 +477,28 @@ def build_router(
|
||||
return
|
||||
case Act.MESSAGE:
|
||||
await state.set_state(Flow.message)
|
||||
await state.update_data(commit_then_push=False)
|
||||
await msg.edit_text(t.ask_message, reply_markup=to_dir_keyboard(texts))
|
||||
case Act.PUSH:
|
||||
draft = await drafts.get(uid)
|
||||
if draft is None or not draft.changes or not draft.message:
|
||||
await query.answer()
|
||||
return
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=t.push_go, callback_data=CommitCb(a=Act.PUSH_GO))
|
||||
builder.button(text=texts.menu.back, callback_data=CommitCb(a=Act.DRAFT))
|
||||
confirm = t.push_confirm.format(summary=summary(draft), message=draft.message)
|
||||
await msg.edit_text(confirm, reply_markup=builder.as_markup())
|
||||
confirm, markup = confirm_screen(draft)
|
||||
await msg.edit_text(confirm, reply_markup=markup)
|
||||
case Act.COMMIT:
|
||||
draft = await drafts.get(uid)
|
||||
if draft is None or not draft.changes:
|
||||
await query.answer()
|
||||
return
|
||||
if draft.message:
|
||||
confirm, markup = confirm_screen(draft)
|
||||
await msg.edit_text(confirm, reply_markup=markup)
|
||||
else:
|
||||
# ask for the message, then go straight to the push confirmation
|
||||
await state.set_state(Flow.message)
|
||||
await state.update_data(commit_then_push=True)
|
||||
await msg.edit_text(t.ask_message, reply_markup=to_dir_keyboard(texts))
|
||||
case Act.PUSH_GO | Act.PUSH_FORCE:
|
||||
await query.answer()
|
||||
await do_push(msg, state, member, overwrite=callback_data.a is Act.PUSH_FORCE)
|
||||
@@ -549,7 +571,12 @@ def build_router(
|
||||
return
|
||||
await drafts.set_message(member.tg_id, text)
|
||||
await state.set_state(None)
|
||||
screen, kb = await draft_screen(state, member.tg_id)
|
||||
draft = await drafts.get(member.tg_id)
|
||||
if (await state.get_data()).get("commit_then_push") and draft is not None and draft.changes:
|
||||
await state.update_data(commit_then_push=False)
|
||||
screen, kb = confirm_screen(draft)
|
||||
else:
|
||||
screen, kb = await draft_screen(state, member.tg_id)
|
||||
await message.answer(screen, reply_markup=kb)
|
||||
|
||||
@router.message(Flow.content, F.text | F.document)
|
||||
|
||||
@@ -13,6 +13,8 @@ class TelegramSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_")
|
||||
|
||||
bot_token: SecretStr
|
||||
# http://user:pass@host:port or socks5://… — the RF server has no route to api.telegram.org (same as meditron)
|
||||
proxy_url: SecretStr | None = None
|
||||
|
||||
|
||||
class AccessSettings(BaseSettings):
|
||||
|
||||
+10
-2
@@ -5,6 +5,7 @@ import contextlib
|
||||
import logging
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
|
||||
from app import __version__
|
||||
from app.access.service import AccessService
|
||||
@@ -54,8 +55,15 @@ async def run(settings: Settings) -> None:
|
||||
asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)),
|
||||
asyncio.create_task(cleanup_loop(sessions)),
|
||||
]
|
||||
bot = Bot(settings.telegram.bot_token.get_secret_value())
|
||||
log.info("tgit %s starting, %d bootstrap admin(s)", __version__, len(settings.access.admin_ids))
|
||||
proxy = settings.telegram.proxy_url
|
||||
session = AiohttpSession(proxy=proxy.get_secret_value()) if proxy else None
|
||||
bot = Bot(settings.telegram.bot_token.get_secret_value(), session=session)
|
||||
log.info(
|
||||
"tgit %s starting, %d bootstrap admin(s), telegram %s",
|
||||
__version__,
|
||||
len(settings.access.admin_ids),
|
||||
"via proxy" if proxy else "direct",
|
||||
)
|
||||
try:
|
||||
await dp.start_polling(bot, handle_signals=True)
|
||||
finally:
|
||||
|
||||
+2
-1
@@ -47,7 +47,8 @@ class CommitInfo:
|
||||
class UploadsRepo:
|
||||
def __init__(self, settings: GitSettings) -> None:
|
||||
self._s = settings
|
||||
self._dir = settings.clone_dir
|
||||
# absolute: git clone runs from the parent dir, a relative path would land one level deeper
|
||||
self._dir = settings.clone_dir.resolve()
|
||||
self._lock = asyncio.Lock()
|
||||
self._fetched_at: float | None = None
|
||||
self._key_path = self._materialize_key()
|
||||
|
||||
@@ -110,6 +110,8 @@ class CommitTexts(_Strict):
|
||||
ask_message: str
|
||||
bad_message: str
|
||||
push_button: str
|
||||
commit_button: str
|
||||
edit_message: str
|
||||
push_confirm: str
|
||||
push_go: str
|
||||
pushing: str
|
||||
|
||||
@@ -113,6 +113,8 @@ commit:
|
||||
ask_message: "Сообщение коммита: до 200 символов, первая строка — до 72."
|
||||
bad_message: "Сообщение: 1–200 символов, первая строка — до 72. Пришлите другое."
|
||||
push_button: "🚀 Push"
|
||||
commit_button: "🚀 Закоммитить · {count}"
|
||||
edit_message: "✏️ Изменить сообщение"
|
||||
push_confirm: |-
|
||||
Запушить в main?
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
mem_limit: 256m
|
||||
mem_limit: 384m # ~190 MB idle; git clone/push and aiogram spikes need headroom
|
||||
labels:
|
||||
tgit.managed: "true"
|
||||
logging:
|
||||
|
||||
@@ -129,3 +129,11 @@
|
||||
**Решение:** первый куб задачи `check` в `tgit-uploads` — `meta`: `echo "TGIT_COMMIT_SHA=$SOURCECRAFT_COMMIT_SHA"`. Бот читает его лог (`SourceCraftClient.run_commit_sha`) и сопоставляет прогон с пушем.
|
||||
**Альтернативы:** запускать CI из бота вручную (`POST /cicd/runs` с `head.commit`) — номер прогона известен сразу, но push-триггер пришлось бы убрать, и ручные пуши в репозиторий остались бы без CI; сопоставление по времени — ломается при параллельных пушах.
|
||||
**Последствия:** +1 короткий куб на прогон; куб `meta` нельзя удалять или переименовывать (он в `.sourcecraft/`, который бот защищает от правок); прогоны старше этого решения sha не имеют.
|
||||
|
||||
## ADR-015: CI/CD бота — на Gitea Actions (как в Vobot)
|
||||
**Дата:** 2026-10-16 · **Статус:** принято (меняет ADR-010 в части CI/CD бота)
|
||||
|
||||
**Контекст:** SourceCraft отложен: ключ бота там не принимается, триггеры зависят от основной ветки, раннеры с очередью до минут. Гейт и деплой нужны сейчас.
|
||||
**Решение:** CI (`.gitea/workflows/ci.yml`: ruff, mypy, миграции на Postgres, pytest) и CD (`.gitea/workflows/deploy.yml` → `infra/deploy/deploy.sh`) — на Gitea Actions, как у Vobot и meditron, на общих раннерах Gitea. Деплой — на хост Vobot, `dev`/`prod` по веткам, через пользователя `tgit-deploy`. Репозиторий загрузок на сервере — временно `HSE/tgit-uploads` в Gitea.
|
||||
**Альтернативы:** ждать SourceCraft — блокирует проверку и демо.
|
||||
**Последствия:** `.sourcecraft/ci.yaml` бота остаётся для будущего возврата; при возврате к SourceCraft меняются `GIT_UPLOADS_URL` и ключ, код не трогаем. На RF-сервере Telegram — только через прокси (`TELEGRAM_PROXY_URL`).
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Деплой бота
|
||||
|
||||
CD — `.gitea/workflows/deploy.yml` (Gitea Actions, ADR-015): push в `dev` → окружение **dev**, push в `main` → **prod**.
|
||||
Под капотом — `infra/deploy/deploy.sh`, тот же скрипт работает руками и для любого Docker-хоста.
|
||||
|
||||
## Сервер (хост Vobot, 72.56.247.166)
|
||||
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| Пользователь | `tgit-deploy` (группа `docker`), ключ CI в `~/.ssh/authorized_keys` |
|
||||
| Окружение | `/srv/tgit/<env>/` — `.env` (только здесь, 600), `app/` (текущий релиз), `app.old/` (предыдущий), `REVISION` |
|
||||
| Контейнеры | Compose-проект `tgit-<env>`: `tgit-<env>-bot-1`, `tgit-<env>-postgres-1`; volumes `pgdata`, `uploads` |
|
||||
| Telegram | с сервера напрямую недоступен → `TELEGRAM_PROXY_URL` (тот же прокси, что у meditron) |
|
||||
| Репозиторий загрузок | временно `ssh://git@gitea.24studbank.ru:2222/HSE/tgit-uploads.git`, deploy key бота с записью |
|
||||
|
||||
Секреты репозитория в Gitea (Settings → Actions → Secrets): `DEPLOY_SSH_KEY` (приватный ключ `tgit-cd@gitea`), `DEPLOY_HOST` (`tgit-deploy@72.56.247.166`).
|
||||
|
||||
## Руками
|
||||
|
||||
```bash
|
||||
DEPLOY_SSH_OPTS="-F /dev/null -i ~/.ssh/tgit_ci_deploy -o IdentitiesOnly=yes" infra/deploy/deploy.sh dev HEAD
|
||||
```
|
||||
|
||||
Другой сервер: `DEPLOY_HOST=user@host`, на нём — Docker, пользователь в группе `docker`, `/srv/tgit/<env>/.env`.
|
||||
|
||||
## Проверить
|
||||
|
||||
```bash
|
||||
ssh tgit-deploy@72.56.247.166 'cat /srv/tgit/dev/REVISION; docker ps --filter name=tgit-dev; docker logs --tail 20 tgit-dev-bot-1'
|
||||
```
|
||||
|
||||
## Откат
|
||||
|
||||
Ошибка health при деплое печатает команду; руками:
|
||||
|
||||
```bash
|
||||
ssh tgit-deploy@72.56.247.166
|
||||
cd /srv/tgit/dev && rm -rf app && mv app.old app && cd app && docker compose -p tgit-dev up -d --build
|
||||
```
|
||||
|
||||
## Завести prod
|
||||
|
||||
Нужен **отдельный токен бота** (один токен — один процесс polling): создать бота у @BotFather →
|
||||
`/srv/tgit/prod/.env` по образцу dev (`TELEGRAM_BOT_TOKEN`, `ADMIN_IDS`, `POSTGRES_PASSWORD`, `GIT_UPLOADS_URL`,
|
||||
`GIT_SSH_KEY_B64`, `TELEGRAM_PROXY_URL`, `LLM_PROVIDER`). Пока файла нет, деплой `main` пропускается.
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy a git ref of the bot to a server. Used by hand and by CD (.gitea/workflows/deploy.yml).
|
||||
# infra/deploy/deploy.sh <dev|prod> [git-ref] default ref: HEAD
|
||||
# Env: DEPLOY_HOST (default tgit-deploy@72.56.247.166 — the vobot host; any Docker host works)
|
||||
# DEPLOY_SSH_OPTS (extra ssh/scp options, e.g. -i key)
|
||||
# Server layout: /srv/tgit/<env>/{.env, app/, app.old/, REVISION}; secrets live only in /srv/tgit/<env>/.env.
|
||||
# The host drops some ssh connections — every remote step is retried, and each one is safe to repeat.
|
||||
set -euo pipefail
|
||||
ENV=${1:?usage: deploy.sh <dev|prod> [ref]}
|
||||
REF=${2:-HEAD}
|
||||
case "$ENV" in dev | prod) ;; *) echo "env must be dev or prod" >&2; exit 2 ;; esac
|
||||
HOST=${DEPLOY_HOST:-tgit-deploy@72.56.247.166}
|
||||
OPTS=${DEPLOY_SSH_OPTS:-}
|
||||
DIR=/srv/tgit/$ENV
|
||||
SHA=$(git rev-parse --short "$REF")
|
||||
TGZ=$(mktemp --suffix=.tgz)
|
||||
trap 'rm -f "$TGZ"' EXIT
|
||||
git archive --format=tar.gz -o "$TGZ" "$REF"
|
||||
|
||||
retry() { local n=0; until "$@"; do n=$((n + 1)); [ $n -ge 5 ] && return 1; echo "retry $n: $1" >&2; sleep $((n * 5)); done; }
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
if ! retry ssh $OPTS "$HOST" "test -f $DIR/.env"; then
|
||||
echo "::notice::$HOST:$DIR/.env not found — environment '$ENV' is not configured, skipping deploy"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "deploy $REF ($SHA) -> $HOST:$DIR"
|
||||
# shellcheck disable=SC2086
|
||||
retry scp -q $OPTS "$TGZ" "$HOST:$DIR/release-$SHA.tgz"
|
||||
# shellcheck disable=SC2086
|
||||
retry scp -q $OPTS infra/deploy/remote.sh "$HOST:$DIR/remote.sh"
|
||||
# shellcheck disable=SC2086
|
||||
retry ssh $OPTS "$HOST" "bash $DIR/remote.sh $ENV $SHA"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs on the server: unpack release-<sha>.tgz, swap app/ atomically, compose up, wait for healthy.
|
||||
# bash /srv/tgit/<env>/remote.sh <env> <sha> (uploaded by deploy.sh; safe to re-run)
|
||||
set -euo pipefail
|
||||
ENV=$1
|
||||
SHA=$2
|
||||
DIR=/srv/tgit/$ENV
|
||||
PROJECT=tgit-$ENV
|
||||
cd "$DIR"
|
||||
|
||||
if [ -f "release-$SHA.tgz" ]; then
|
||||
rm -rf app.new && mkdir app.new && tar -xzf "release-$SHA.tgz" -C app.new && rm -f "release-$SHA.tgz"
|
||||
cp .env app.new/.env
|
||||
rm -rf app.old
|
||||
[ -d app ] && mv app app.old
|
||||
mv app.new app
|
||||
fi
|
||||
|
||||
cd app
|
||||
docker compose -p "$PROJECT" up -d --build --remove-orphans </dev/null
|
||||
|
||||
status=""
|
||||
for _ in $(seq 1 40); do
|
||||
status=$(docker inspect -f '{{.State.Health.Status}}' "$PROJECT-bot-1" 2>/dev/null || true)
|
||||
[ "$status" = healthy ] && break
|
||||
sleep 3
|
||||
done
|
||||
if [ "$status" != healthy ]; then
|
||||
echo "bot is '$status' after 2 min — last logs:" >&2
|
||||
docker logs --tail 50 "$PROJECT-bot-1" >&2 || true
|
||||
echo "rollback: cd $DIR && rm -rf app && mv app.old app && cd app && docker compose -p $PROJECT up -d --build" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$SHA" > ../REVISION
|
||||
docker image prune -f >/dev/null
|
||||
echo "bot healthy @ $SHA ($ENV)"
|
||||
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"pyyaml>=6.0",
|
||||
"regex>=2024.7",
|
||||
"segno>=1.6",
|
||||
"aiohttp-socks>=0.9",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -63,3 +63,30 @@ async def test_message_then_push_lands_in_remote(tg: TgClient, texts: Texts, rem
|
||||
tg.session.requests.clear()
|
||||
await tg.send(ADMIN, texts.menu.commit)
|
||||
assert not any(label.startswith("🧾") for label in labels(tg.session.sent(SendMessage)[-1].reply_markup))
|
||||
|
||||
|
||||
async def test_commit_button_in_folder_asks_message_then_confirms(
|
||||
tg: TgClient, texts: Texts, remote: Path, tmp_path: Path
|
||||
) -> None:
|
||||
t = texts.commit
|
||||
await tg.send(ADMIN, texts.menu.commit)
|
||||
await tg.press(ADMIN, cb(Act.NEW_FILE))
|
||||
await tg.send(ADMIN, "quick.txt")
|
||||
await tg.send(ADMIN, "быстро")
|
||||
|
||||
folder = tg.session.sent(SendMessage)[-1]
|
||||
assert t.commit_button.format(count=1) in labels(folder.reply_markup)
|
||||
|
||||
await tg.press(ADMIN, cb(Act.COMMIT))
|
||||
assert tg.session.sent(EditMessageText)[-1].text == t.ask_message
|
||||
await tg.send(ADMIN, "feat: быстрый коммит")
|
||||
|
||||
confirm = tg.session.sent(SendMessage)[-1]
|
||||
assert confirm.text.startswith("Запушить в main?") and "feat: быстрый коммит" in confirm.text
|
||||
assert labels(confirm.reply_markup) == [t.push_go, t.edit_message, texts.menu.back]
|
||||
|
||||
await tg.press(ADMIN, cb(Act.PUSH_GO))
|
||||
assert tg.session.sent(EditMessageText)[-1].text.startswith("✅ Запушено: ")
|
||||
clone = tmp_path / "verify2"
|
||||
git(tmp_path, "clone", "-q", str(remote), str(clone))
|
||||
assert (clone / "quick.txt").read_text(encoding="utf-8") == "быстро"
|
||||
|
||||
@@ -83,3 +83,14 @@ async def test_unreachable_remote(tmp_path: Path) -> None:
|
||||
broken = UploadsRepo(GitSettings(uploads_url=str(tmp_path / "missing.git"), clone_dir=tmp_path / "c3"))
|
||||
with pytest.raises(GitError, match="clone"):
|
||||
await broken.refresh()
|
||||
|
||||
|
||||
async def test_relative_clone_dir(remote: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.config import GitSettings
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
relative = UploadsRepo(GitSettings(uploads_url=str(remote), clone_dir=Path("data/uploads")))
|
||||
await relative.refresh()
|
||||
|
||||
assert (tmp_path / "data" / "uploads" / ".git").is_dir()
|
||||
assert "README.md" in [e.name for e in await relative.list_dir()]
|
||||
|
||||
@@ -74,6 +74,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp-socks"
|
||||
version = "0.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "python-socks" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/1d/a306e0111222180e60f17131a3f5d9bc694dd999a8115959a7dd76c2238e/aiohttp_socks-0.12.0.tar.gz", hash = "sha256:3caf9f5a4164611122d412bc11b2f9114fd29c85e1ba27bb38060d3c236bdc8d", size = 12061, upload-time = "2026-08-12T04:43:15.791Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/86/64/ca6289632020523ea1841f01363f83ada10eeb0f21dd931fc1118dd85668/aiohttp_socks-0.12.0-py3-none-any.whl", hash = "sha256:ba6f95ec775c761d87f8578ab48f137d0457c676da104984202bf75e747d5ee6", size = 10693, upload-time = "2026-08-12T04:43:14.524Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.4.0"
|
||||
@@ -642,6 +655,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-socks"
|
||||
version = "3.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/ad/484ffb79532517b11a90af38647c38652224650b31a7ae1cedd5a418d8ab/python_socks-3.1.1.tar.gz", hash = "sha256:8d3e817cdbe858dc0bb8c8fdc8e79b6ce37acce110d33374c6f57a675cc9029e", size = 232781, upload-time = "2026-09-08T13:03:31.058Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/23/2c2cef1b4313c55d1713201acd4ee2043fb2cf22e546ca9d36bf4317faea/python_socks-3.1.1-py3-none-any.whl", hash = "sha256:327e0d6378702c73a7790bf732e9f01392f17b48c7348a50b5bd1f710c2df1be", size = 49566, upload-time = "2026-09-08T13:03:29.604Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@@ -763,6 +785,7 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiogram" },
|
||||
{ name = "aiohttp-socks" },
|
||||
{ name = "alembic" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "httpx" },
|
||||
@@ -789,6 +812,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiogram", specifier = ">=3.13" },
|
||||
{ name = "aiohttp-socks", specifier = ">=0.9" },
|
||||
{ name = "alembic", specifier = ">=1.13" },
|
||||
{ name = "asyncpg", specifier = ">=0.29" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
|
||||
Reference in New Issue
Block a user