Author SHA1 Message Date
ElebobaandClaude Opus 5.5 12b2ca22a5 ci: CI бота на Gitea Actions — ruff, mypy, миграции на Postgres, pytest
CI / ruff · mypy · pytest (pull_request) Successful in 1m28s
Как в Vobot: PR и push в dev/main → джоб «ruff · mypy · pytest»; uv через pip (setup-uv на
self-hosted Gitea не работает); миграции up/down/up на сервисе postgres:16 без маппинга портов.

Refs #41

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:13:47 +03:00
Eleboba d1addd2c25 Merge pull request 'feat(bot): кнопка «🚀 Закоммитить» прямо в папке' (#40) from feat/commit-button into dev
Reviewed-on: #40
2026-10-16 00:07:44 +03:00
ElebobaandClaude Opus 5.5 9e1d6322b6 feat(bot): кнопка «🚀 Закоммитить» прямо в папке
При непустом черновике в папке — «🚀 Закоммитить · N»: нет сообщения → спрашивает его и
сразу показывает подтверждение пуша; есть → сразу подтверждение. На подтверждении —
«✏️ Изменить сообщение». Раньше Push был спрятан в «🧾 Черновик» → «💬» → «🚀».

Refs #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:06:52 +03:00
Eleboba 595368b0a2 Merge pull request 'chore: локальный запуск (make run, make demo-repo) + fix относительного GIT_CLONE_DIR' (#39) from chore/local-run into dev
Reviewed-on: #39
2026-10-15 23:56:53 +03:00
5 changed files with 137 additions and 7 deletions
+72
View File
@@ -0,0 +1,72 @@
name: CI
# Гейт качества на каждый PR и push в dev/main — как в Vobot (.gitea/workflows/backend.yml).
# CD (.gitea/workflows/deploy.yml) тесты не гоняет: они прошли здесь, на PR.
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-test:
# Имя джоба — контекст статуса в PR; если включать branch protection, требовать именно его.
name: ruff · mypy · pytest
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: tgit_test
POSTGRES_USER: tgit
POSTGRES_PASSWORD: tgit
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
# без маппинга портов: act_runner запускает job в контейнере, сервис доступен по имени `postgres`
# (опыт Vobot: маппинг 5432 на хост конфликтует с postgres самого Gitea)
steps:
- uses: actions/checkout@v4
- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
# pip, а не astral-sh/setup-uv: на self-hosted Gitea экшен ходит в GitHub API и падает (опыт Vobot)
run: pip install uv
- name: Install deps
run: uv sync --frozen
- name: Ruff
run: |
uv run ruff check .
uv run ruff format --check .
- name: Mypy
run: uv run mypy
- name: Migrations on PostgreSQL — up, down, up
# pytest гоняет схему на SQLite; здесь — настоящий Postgres: JSONB, BIGINT, CHECK, откат
env:
DATABASE_URL: postgresql+asyncpg://tgit:tgit@postgres:5432/tgit_test
run: |
uv run alembic upgrade head
uv run alembic downgrade base
uv run alembic upgrade head
- name: Pytest (+ порог покрытия из pyproject)
run: |
git config --global user.name ci
git config --global user.email ci@ci
uv run pytest -q --cov --cov-report=term
+33 -6
View File
@@ -59,6 +59,7 @@ class Act(StrEnum):
PUSH = "ps" PUSH = "ps"
PUSH_GO = "pg" PUSH_GO = "pg"
PUSH_FORCE = "pf" PUSH_FORCE = "pf"
COMMIT = "cm"
class CommitCb(CallbackData, prefix="c"): 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)) kb.button(text=t.new_dir, callback_data=CommitCb(a=Act.NEW_DIR))
sizes.append(2) sizes.append(2)
if draft_count: 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)) kb.button(text=t.draft_button.format(count=draft_count), callback_data=CommitCb(a=Act.DRAFT))
sizes.append(1) sizes += [1, 1]
if path: if path:
kb.button(text=t.up, callback_data=CommitCb(a=Act.UP)) kb.button(text=t.up, callback_data=CommitCb(a=Act.UP))
sizes.append(1) sizes.append(1)
@@ -260,6 +263,14 @@ def build_router(
kb.adjust(1) kb.adjust(1)
return text, kb.as_markup() 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: def summary(draft: DraftView) -> str:
ops = {ChangeOp.ADD: t.op_add, ChangeOp.MODIFY: t.op_modify, ChangeOp.DELETE: t.op_delete} 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) return "\n".join(f"{ops[c.op]} /{c.path}" for c in draft.changes)
@@ -466,17 +477,28 @@ def build_router(
return return
case Act.MESSAGE: case Act.MESSAGE:
await state.set_state(Flow.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)) await msg.edit_text(t.ask_message, reply_markup=to_dir_keyboard(texts))
case Act.PUSH: case Act.PUSH:
draft = await drafts.get(uid) draft = await drafts.get(uid)
if draft is None or not draft.changes or not draft.message: if draft is None or not draft.changes or not draft.message:
await query.answer() await query.answer()
return return
builder = InlineKeyboardBuilder() confirm, markup = confirm_screen(draft)
builder.button(text=t.push_go, callback_data=CommitCb(a=Act.PUSH_GO)) await msg.edit_text(confirm, reply_markup=markup)
builder.button(text=texts.menu.back, callback_data=CommitCb(a=Act.DRAFT)) case Act.COMMIT:
confirm = t.push_confirm.format(summary=summary(draft), message=draft.message) draft = await drafts.get(uid)
await msg.edit_text(confirm, reply_markup=builder.as_markup()) 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: case Act.PUSH_GO | Act.PUSH_FORCE:
await query.answer() await query.answer()
await do_push(msg, state, member, overwrite=callback_data.a is Act.PUSH_FORCE) await do_push(msg, state, member, overwrite=callback_data.a is Act.PUSH_FORCE)
@@ -549,6 +571,11 @@ def build_router(
return return
await drafts.set_message(member.tg_id, text) await drafts.set_message(member.tg_id, text)
await state.set_state(None) await state.set_state(None)
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) screen, kb = await draft_screen(state, member.tg_id)
await message.answer(screen, reply_markup=kb) await message.answer(screen, reply_markup=kb)
+2
View File
@@ -110,6 +110,8 @@ class CommitTexts(_Strict):
ask_message: str ask_message: str
bad_message: str bad_message: str
push_button: str push_button: str
commit_button: str
edit_message: str
push_confirm: str push_confirm: str
push_go: str push_go: str
pushing: str pushing: str
+2
View File
@@ -113,6 +113,8 @@ commit:
ask_message: "Сообщение коммита: до 200 символов, первая строка — до 72." ask_message: "Сообщение коммита: до 200 символов, первая строка — до 72."
bad_message: "Сообщение: 1–200 символов, первая строка — до 72. Пришлите другое." bad_message: "Сообщение: 1–200 символов, первая строка — до 72. Пришлите другое."
push_button: "🚀 Push" push_button: "🚀 Push"
commit_button: "🚀 Закоммитить · {count}"
edit_message: "✏️ Изменить сообщение"
push_confirm: |- push_confirm: |-
Запушить в main? Запушить в main?
+27
View File
@@ -63,3 +63,30 @@ async def test_message_then_push_lands_in_remote(tg: TgClient, texts: Texts, rem
tg.session.requests.clear() tg.session.requests.clear()
await tg.send(ADMIN, texts.menu.commit) await tg.send(ADMIN, texts.menu.commit)
assert not any(label.startswith("🧾") for label in labels(tg.session.sent(SendMessage)[-1].reply_markup)) 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") == "быстро"