feat(bot): Коммит — создание, замена и удаление файлов, черновик
- новый файл: имя → содержимое текстом (```-обёртка снимается) или документом - новая папка (виртуальная, пока в ней нет файлов), замена с diff +N/−M, удаление - валидация: имя, ≤ 100 КБ, UTF-8, без NUL; .py — ast.parse с выбором «исправить / всё равно»; .sourcecraft/, .tgit/, ruff.toml из бота не меняются - DraftService: черновик в БД, 24 ч, ≤ 50 изменений; удаление нового файла убирает его из черновика - дерево показывает черновик поверх репозитория: 🆕 / ✏️ / 🗑, размер — как будет после пуша - экран черновика (A/M/D) с «↩️ убрать» Refs #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""A member's draft: pending add/modify/delete changes until Push (SPEC C-14, C-15). One draft per member."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db.models import ChangeOp, Draft, DraftChange, utcnow
|
||||
from app.db.session import SessionMaker
|
||||
|
||||
MAX_CHANGES = 50
|
||||
|
||||
|
||||
class DraftFull(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Change:
|
||||
path: str
|
||||
op: ChangeOp
|
||||
content: bytes | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DraftView:
|
||||
base_sha: str
|
||||
message: str | None
|
||||
changes: list[Change]
|
||||
|
||||
def change_for(self, path: str) -> Change | None:
|
||||
return next((c for c in self.changes if c.path == path), None)
|
||||
|
||||
|
||||
class DraftService:
|
||||
def __init__(
|
||||
self, sessions: SessionMaker, ttl: timedelta = timedelta(hours=24), clock: Callable[[], datetime] = utcnow
|
||||
):
|
||||
self._sessions = sessions
|
||||
self._ttl = ttl
|
||||
self._clock = clock
|
||||
|
||||
async def get(self, user_id: int) -> DraftView | None:
|
||||
async with self._sessions() as s:
|
||||
draft = await self._load(s, user_id)
|
||||
if draft is None:
|
||||
return None
|
||||
return DraftView(
|
||||
base_sha=draft.base_sha,
|
||||
message=draft.message,
|
||||
changes=[Change(c.path, c.op, c.content) for c in draft.changes],
|
||||
)
|
||||
|
||||
async def put(self, user_id: int, base_sha: str, path: str, op: ChangeOp, content: bytes | None) -> None:
|
||||
"""Record a change. Deleting a file that only exists in the draft just drops it from the draft."""
|
||||
async with self._sessions.begin() as s:
|
||||
draft = await self._load(s, user_id)
|
||||
if draft is None:
|
||||
draft = Draft(
|
||||
user_id=user_id, base_sha=base_sha, created_at=self._clock(), expires_at=self._clock() + self._ttl
|
||||
)
|
||||
s.add(draft)
|
||||
existing = next((c for c in draft.changes if c.path == path), None)
|
||||
if existing is not None:
|
||||
if op is ChangeOp.DELETE and existing.op is ChangeOp.ADD:
|
||||
draft.changes.remove(existing)
|
||||
return
|
||||
# editing a file added in this draft keeps it an "add"
|
||||
existing.op = ChangeOp.ADD if existing.op is ChangeOp.ADD and op is ChangeOp.MODIFY else op
|
||||
existing.content = content
|
||||
else:
|
||||
if len(draft.changes) >= MAX_CHANGES:
|
||||
raise DraftFull
|
||||
draft.changes.append(DraftChange(path=path, op=op, content=content))
|
||||
draft.expires_at = self._clock() + self._ttl
|
||||
|
||||
async def drop(self, user_id: int, path: str) -> None:
|
||||
async with self._sessions.begin() as s:
|
||||
draft = await self._load(s, user_id)
|
||||
if draft is None:
|
||||
return
|
||||
draft.changes = [c for c in draft.changes if c.path != path]
|
||||
|
||||
async def set_message(self, user_id: int, message: str) -> None:
|
||||
async with self._sessions.begin() as s:
|
||||
draft = await self._load(s, user_id)
|
||||
if draft is not None:
|
||||
draft.message = message
|
||||
|
||||
async def clear(self, user_id: int) -> None:
|
||||
async with self._sessions.begin() as s:
|
||||
await s.execute(delete(Draft).where(Draft.user_id == user_id))
|
||||
|
||||
async def _load(self, s: AsyncSession, user_id: int) -> Draft | None:
|
||||
draft = await s.scalar(select(Draft).where(Draft.user_id == user_id).options(selectinload(Draft.changes)))
|
||||
if draft is not None and _aware(draft.expires_at) <= self._clock():
|
||||
await s.delete(draft)
|
||||
await s.flush()
|
||||
return None
|
||||
return draft
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user