Files
tgit/app/repo/validate.py
T
ElebobaandClaude Opus 5.5 ae0cd5d42d feat(bot): Коммит — сообщение, Push, конфликты, публикация в репозиторий загрузок
- Publisher: пуши по одному (lock), fetch → проверка конфликта с base_sha → чистое дерево
  origin/main → черновик → коммит от tgit-bot с трейлером Telegram-User → push;
  отказ push → rebase и повтор (до 3 раз); защита от путей вне клона; лимит 20 пушей/час
- «💬 Сообщение коммита» (1–200 символов, первая строка ≤ 72) и «🚀 Push» с подтверждением;
  исходы: запушено (sha, ссылка, ⏳ CI) · конфликт → «Перезаписать» · нечего коммитить · лимит · сбой
- запись в pushes (chat_id, message_id — для обновления статуса CI/CD в #11), черновик очищается
- ruff: RUF001–003 выключены — кириллица в текстах это норма

Refs #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:23:09 +03:00

84 lines
2.4 KiB
Python

"""Checks for names and file contents before anything goes into a draft (SPEC C-10, C-12, C-16). No code runs here."""
import ast
import difflib
import re
from dataclasses import dataclass
MAX_FILE_BYTES = 100 * 1024
NAME = re.compile(r"^[A-Za-z0-9._-]{1,100}$")
# Bot users can't touch the CI of the uploads repo or its helper scripts (SPEC C-16, ADR-014)
PROTECTED_PREFIXES = (".sourcecraft/", ".tgit/", ".git/")
PROTECTED_FILES = {"ruff.toml"}
_FENCE = re.compile(r"^\s*```[\w+-]*[ \t]*\n(.*?)\n?```\s*$", re.DOTALL)
class ValidationError(ValueError):
pass
@dataclass(frozen=True)
class SyntaxIssue:
line: int
message: str
def check_name(name: str) -> str:
name = name.strip()
if not NAME.match(name) or name in {".", ".."}:
raise ValidationError("name")
return name
def is_protected(path: str) -> bool:
return path in PROTECTED_FILES or any(path.startswith(p) or path + "/" == p for p in PROTECTED_PREFIXES)
def join(directory: str, name: str) -> str:
return f"{directory}/{name}" if directory else name
def strip_fence(text: str) -> str:
"""Code pasted as a Telegram message often comes wrapped in ``` — keep only what's inside."""
match = _FENCE.match(text)
return match.group(1) if match else text
def check_content(data: bytes) -> str:
"""Size, encoding, NUL bytes. Returns the decoded text."""
if len(data) > MAX_FILE_BYTES:
raise ValidationError("too_big")
if b"\0" in data:
raise ValidationError("binary")
try:
return data.decode("utf-8")
except UnicodeDecodeError:
raise ValidationError("encoding") from None
def python_syntax(path: str, text: str) -> SyntaxIssue | None:
if not path.endswith(".py"):
return None
try:
ast.parse(text, filename=path)
except SyntaxError as e:
return SyntaxIssue(line=e.lineno or 0, message=e.msg)
return None
def diff_stat(old: str, new: str) -> tuple[int, int]:
added = removed = 0
for line in difflib.ndiff(old.splitlines(), new.splitlines()):
if line.startswith("+ "):
added += 1
elif line.startswith("- "):
removed += 1
return added, removed
def check_message(message: str) -> str:
message = message.strip()
if not 1 <= len(message) <= 200 or len(message.splitlines()[0]) > 72:
raise ValidationError("message")
return message