Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f4754f15e |
+1
-2
@@ -24,6 +24,5 @@ build/
|
|||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# local run without Docker: SQLite DB, uploads clone, demo repo
|
# local SQLite DB (bot without Docker)
|
||||||
*.db
|
*.db
|
||||||
data/
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install lint fmt typecheck test cov up down logs ps run demo-repo
|
.PHONY: install lint fmt typecheck test cov up down logs ps
|
||||||
|
|
||||||
install:
|
install:
|
||||||
uv sync
|
uv sync
|
||||||
@@ -30,18 +30,3 @@ logs:
|
|||||||
|
|
||||||
ps:
|
ps:
|
||||||
docker compose 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,7 +59,6 @@ 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"):
|
||||||
@@ -180,10 +179,8 @@ 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 += [1, 1]
|
sizes.append(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)
|
||||||
@@ -263,14 +260,6 @@ 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)
|
||||||
@@ -477,28 +466,17 @@ 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
|
||||||
confirm, markup = confirm_screen(draft)
|
builder = InlineKeyboardBuilder()
|
||||||
await msg.edit_text(confirm, reply_markup=markup)
|
builder.button(text=t.push_go, callback_data=CommitCb(a=Act.PUSH_GO))
|
||||||
case Act.COMMIT:
|
builder.button(text=texts.menu.back, callback_data=CommitCb(a=Act.DRAFT))
|
||||||
draft = await drafts.get(uid)
|
confirm = t.push_confirm.format(summary=summary(draft), message=draft.message)
|
||||||
if draft is None or not draft.changes:
|
await msg.edit_text(confirm, reply_markup=builder.as_markup())
|
||||||
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)
|
||||||
@@ -571,12 +549,7 @@ 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)
|
screen, kb = await draft_screen(state, 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)
|
await message.answer(screen, reply_markup=kb)
|
||||||
|
|
||||||
@router.message(Flow.content, F.text | F.document)
|
@router.message(Flow.content, F.text | F.document)
|
||||||
|
|||||||
+1
-2
@@ -47,8 +47,7 @@ class CommitInfo:
|
|||||||
class UploadsRepo:
|
class UploadsRepo:
|
||||||
def __init__(self, settings: GitSettings) -> None:
|
def __init__(self, settings: GitSettings) -> None:
|
||||||
self._s = settings
|
self._s = settings
|
||||||
# absolute: git clone runs from the parent dir, a relative path would land one level deeper
|
self._dir = settings.clone_dir
|
||||||
self._dir = settings.clone_dir.resolve()
|
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._fetched_at: float | None = None
|
self._fetched_at: float | None = None
|
||||||
self._key_path = self._materialize_key()
|
self._key_path = self._materialize_key()
|
||||||
|
|||||||
@@ -110,8 +110,6 @@ 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
|
||||||
|
|||||||
@@ -113,8 +113,6 @@ 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?
|
||||||
|
|
||||||
|
|||||||
@@ -63,30 +63,3 @@ 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") == "быстро"
|
|
||||||
|
|||||||
@@ -83,14 +83,3 @@ async def test_unreachable_remote(tmp_path: Path) -> None:
|
|||||||
broken = UploadsRepo(GitSettings(uploads_url=str(tmp_path / "missing.git"), clone_dir=tmp_path / "c3"))
|
broken = UploadsRepo(GitSettings(uploads_url=str(tmp_path / "missing.git"), clone_dir=tmp_path / "c3"))
|
||||||
with pytest.raises(GitError, match="clone"):
|
with pytest.raises(GitError, match="clone"):
|
||||||
await broken.refresh()
|
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()]
|
|
||||||
|
|||||||
Reference in New Issue
Block a user