diff --git a/backend/app/api/repo_readme.py b/backend/app/api/repo_readme.py new file mode 100644 index 0000000..0617975 --- /dev/null +++ b/backend/app/api/repo_readme.py @@ -0,0 +1,26 @@ +"""README.md репозитория для задач, где описание — только ссылка на git. + +GET /api/repo-readme?url=... — селф-хостед git-системы поддерживаются +эвристикой (пробуем известные пути к сырому README: API github/gitlab/gitea, +raw-пути web-интерфейсов). Не нашли — 404, фронт оставляет описание как было. +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.dependencies import UserIdDep +from app.services import readme + +router = APIRouter(prefix="/api/repo-readme", tags=["repo-readme"]) + + +class RepoReadmeOut(BaseModel): + content: str + + +@router.get("", response_model=RepoReadmeOut) +async def get_repo_readme(url: str, user_id: UserIdDep) -> RepoReadmeOut: + content = await readme.fetch_readme(url) + if content is None: + raise HTTPException(status_code=404, detail="README.md not found") + return RepoReadmeOut(content=content) diff --git a/backend/app/main.py b/backend/app/main.py index 4ca790b..7c75ab9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ from app import mcp_server from app.api import attachments, events, garden, projects, tags, tasks, xp from app.api.mcp_tokens import router as mcp_tokens_router +from app.api.repo_readme import router as repo_readme_router from app.api.settings import router as settings_router from app.auth.routes import router as auth_router from app.auth.webhooks import router as auth_webhooks_router @@ -81,6 +82,7 @@ app.include_router(tags.router) app.include_router(attachments.router) app.include_router(settings_router) +app.include_router(repo_readme_router) app.include_router(mcp_tokens_router) app.include_router(xp.router) app.include_router(garden.router) diff --git a/backend/app/services/readme.py b/backend/app/services/readme.py new file mode 100644 index 0000000..8734050 --- /dev/null +++ b/backend/app/services/readme.py @@ -0,0 +1,114 @@ +"""README.md репозитория, если описание задачи — только ссылка на git. + +Не привязываемся к GitHub/GitLab (у пользователя может быть селф-хостед): +пробуем набор известных путей к сырому README — API github/gitlab/gitea +и raw-пути web-интерфейсов — пока какой-нибудь не ответит markdown'ом. +Нашли — отдаём, нет — None (фронт оставляет описание как было). +""" + +from __future__ import annotations + +import threading +import time +from urllib.parse import urlparse + +import httpx + +# Общий тайм-бюджет на одну ссылку: перебор кандидатов не дольше этого. +TOTAL_TIMEOUT = 15.0 +PER_TRY_TIMEOUT = 5.0 +MAX_BYTES = 256_000 +CACHE_TTL = 600.0 # повторные чтения одной ссылки не долбят git-хост +_CACHE: dict[str, tuple[float, str | None]] = {} +_CACHE_LOCK = threading.Lock() + +_HEADERS = { + # GitHub API отдаёт сырой README только с raw-мимом; остальным всё равно + "Accept": "application/vnd.github.raw+json, text/markdown;q=0.9, text/plain;q=0.8, */*;q=0.7", + "User-Agent": "gntodo/1.0 (README for task description)", +} + + +def repo_candidates(url: str) -> list[str]: + """Ссылка на репозиторий → кандидаты URL сырого README.md (пусто — не похоже).""" + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return [] + path = parsed.path.strip("/") + if path.endswith(".git"): + path = path[: -len(".git")] + segments = [s for s in path.split("/") if s] + # «Похоже на git»: ожидаем owner/repo (вложенность селф-хостеда — тоже git-система, + # берём последние два сегмента пути как owner/repo) + if len(segments) < 2: + return [] + owner, repo = segments[-2], segments[-1] + base = f"{parsed.scheme}://{parsed.netloc}" + full_path = "/".join(segments) + out: list[str] = [] + # raw-путь с HEAD (дефолтная ветка) — самый универсальный: github, GitBucket, + # Gogs; GitHub API вторым (умеет любой регистр/расширение README, но 60/час + # без токена), затем API GitLab/Gitea (работают и селф-хостед) + out.append(f"{base}/{full_path}/raw/HEAD/README.md") + if parsed.netloc == "github.com": + out.append(f"https://api.github.com/repos/{owner}/{repo}/readme") + # GitLab (gitlab.com и селф-хостед): url-encoded путь, HEAD = дефолтная ветка + quoted = full_path.replace("/", "%2F") + out.append( + f"{base}/api/v4/projects/{quoted}/repository/files/README%2Emd/raw?ref=HEAD" + ) + # Gitea/Gogs API + прочие raw-пути web-интерфейсов + out.append(f"{base}/api/v1/repos/{owner}/{repo}/raw/README.md?ref=HEAD") + out.extend( + [ + f"{base}/{full_path}/-/raw/HEAD/README.md", + f"{base}/{full_path}/raw/main/README.md", + f"{base}/{full_path}/raw/master/README.md", + ] + ) + return out + + +def _looks_html(text: str, content_type: str) -> bool: + if "html" in content_type.lower(): + return True + head = text.lstrip()[:200].lower() + return head.startswith(" str | None: + try: + resp = await client.get(url) + except httpx.HTTPError: + return None + if resp.status_code != 200: + return None + text = resp.text + if not text.strip() or _looks_html(text, resp.headers.get("content-type", "")): + return None + return text[:MAX_BYTES] + + +async def fetch_readme(url: str) -> str | None: + """Сырой README.md по ссылке на репозиторий; None — не нашли (кэш 10 минут).""" + now = time.monotonic() + with _CACHE_LOCK: + hit = _CACHE.get(url) + if hit is not None and now - hit[0] < CACHE_TTL: + return hit[1] + content: str | None = None + candidates = repo_candidates(url) + if candidates: + started = time.monotonic() + async with httpx.AsyncClient(follow_redirects=True, timeout=PER_TRY_TIMEOUT) as client: + for candidate in candidates: + if time.monotonic() - started > TOTAL_TIMEOUT: + break + content = await _try(client, candidate) + if content is not None: + break + with _CACHE_LOCK: + if len(_CACHE) > 128: + _CACHE.clear() + _CACHE[url] = (now, content) + return content diff --git a/backend/tests/test_repo_readme.py b/backend/tests/test_repo_readme.py new file mode 100644 index 0000000..688156a --- /dev/null +++ b/backend/tests/test_repo_readme.py @@ -0,0 +1,79 @@ +"""README.md репозитория для описаний-«только ссылка»: эвристики путей и кэш.""" + +import asyncio + +import pytest + +from app.services import readme + + +@pytest.fixture(autouse=True) +def clean_cache() -> None: + readme._CACHE.clear() + + +def test_candidates_github() -> None: + urls = readme.repo_candidates("https://github.com/o/r/") + # raw-путь первым (без рейт-лимита GitHub API), API вторым (любит raw-мим) + assert urls[0] == "https://github.com/o/r/raw/HEAD/README.md" + assert urls[1] == "https://api.github.com/repos/o/r/readme" + + +def test_candidates_self_hosted_git_suffix() -> None: + urls = readme.repo_candidates("https://git.corp/team/repo.git") + assert ( + "https://git.corp/api/v4/projects/team%2Frepo/repository/files/README%2Emd/raw?ref=HEAD" + in urls + ) + assert "https://git.corp/team/repo/raw/HEAD/README.md" in urls + # GitHub-API путь чужому хосту не предлагается + assert not any("api.github.com" in u for u in urls) + + +def test_candidates_not_a_repo() -> None: + # нет owner/repo — не похоже на репозиторий + assert readme.repo_candidates("https://example.com") == [] + assert readme.repo_candidates("ftp://example.com/o/r") == [] + + +def test_fetch_takes_first_working(monkeypatch: pytest.MonkeyPatch) -> None: + tried: list[str] = [] + + async def fake_try(client: object, url: str) -> str | None: + tried.append(url) + # первые кандидаты (raw/HEAD, gitlab-api) не работают, raw/main отдаёт README + return "# Readme\n\nтекст" if url.endswith("raw/main/README.md") else None + + readme._try = fake_try + assert asyncio.run(readme.fetch_readme("https://git.corp/t/r")) == "# Readme\n\nтекст" + # до первого удачного кандидата перебор продолжался + assert len(tried) > 1 + + +def test_fetch_caches(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + async def fake_try(client: object, url: str) -> str | None: + calls.append(url) + return "# R" + + readme._try = fake_try + url = "https://git.corp/t/r" + assert asyncio.run(readme.fetch_readme(url)) == "# R" + assert asyncio.run(readme.fetch_readme(url)) == "# R" + # второй вызов из кэша — кандидат лишь один фейково сработал + assert len(calls) == 1 + + +def test_fetch_not_found_cached(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + async def fake_try(client: object, url: str) -> str | None: + calls.append(url) + return None + + readme._try = fake_try + url = "https://git.corp/t/r" + assert asyncio.run(readme.fetch_readme(url)) is None + assert asyncio.run(readme.fetch_readme(url)) is None + assert len(calls) == len(readme.repo_candidates(url)) diff --git a/docs/TZ.md b/docs/TZ.md index a35c742..5ea881d 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.40 | +| Версия ТЗ | 0.41 | | Дата | 2026-09-22 | | Статус | На обсуждении | @@ -92,6 +92,12 @@ поля тоже показываются («— не указано») и редактируются кликом. То же — на странице проекта (название, заметка, актуальность, приоритет). Полный режим правки (вся форма целиком) сохраняется наряду с инлайном. +- **README вместо описания-ссылки** (0.41): если описание задачи состоит из одной + ссылки на git-репозиторий, на странице задачи вместо неё показывается + `README.md` из репозитория (с подписью-ссылкой на источник). Хост не обязан быть + GitHub/GitLab — селф-хостед системы ищутся эвристикой (API github/gitlab/gitea + + raw-пути web-интерфейсов, дефолтная ветка HEAD); не нашли — остаётся ссылка. + Сырое описание в БД не меняется; кэш прочтённого README — 10 минут. ### 3.4. Статусы, дедлайны, прогнозы, бюджет diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4b502f5..1d81895 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -288,6 +288,9 @@ body: JSON.stringify({ available_minutes: availableMinutes }), }), deleteTask: (id: number) => request<{ ok: boolean }>(`/api/tasks/${id}`, { method: 'DELETE' }), + // README.md репозитория, если описание задачи — только ссылка на git + getRepoReadme: (url: string) => + request<{ content: string }>('/api/repo-readme?' + new URLSearchParams({ url })), // attachments (привязаны к документу — описание задачи или заметка проекта) listAttachments: (documentId: number) => request(`/api/documents/${documentId}/attachments`), diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index 67d72f1..918199e 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -306,6 +306,7 @@ editDescription: 'Edit description', deadlineConflict: 'Deadline: either a date or a period — not both', descriptionEmpty: 'No description yet — detail the task.', + readmeFrom: 'README.md from the repository', attachments: 'Attachments', fields: 'Details', dates: 'Dates', diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index bd0887b..c3b0aa1 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -306,6 +306,7 @@ editDescription: 'Редактировать описание', deadlineConflict: 'Дедлайн: либо дата, либо период — не оба', descriptionEmpty: 'Описание пусто — детализируйте задачу.', + readmeFrom: 'README.md из репозитория', attachments: 'Вложения', fields: 'Параметры', dates: 'Даты', diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index f142d11..3951e3b 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -306,6 +306,7 @@ editDescription: 'Редагувати опис', deadlineConflict: 'Дедлайн: або дата, або період — не обидва', descriptionEmpty: 'Опис порожній — деталізуйте задачу.', + readmeFrom: 'README.md з репозиторію', attachments: 'Вкладення', fields: 'Параметри', dates: 'Дати', diff --git a/frontend/src/views/TaskView.vue b/frontend/src/views/TaskView.vue index 00d1b95..9cf92df 100644 --- a/frontend/src/views/TaskView.vue +++ b/frontend/src/views/TaskView.vue @@ -65,6 +65,29 @@ : '', ) +// Описание — только ссылка на репозиторий → вместо него показываем README.md +// оттуда (селф-хостед git тоже ищется — эвристика путей на бэке). Сырое +// описание в БД не трогаем: правка карандашом остаётся правкой ссылки. +const readmeUrl = computed(() => { + const desc = task.value?.description.trim() ?? '' + return /^https?:\/\/\S+$/.test(desc) ? desc : '' +}) +const readme = ref('') +const readmeHtml = computed(() => (readme.value ? renderMarkdown(readme.value) : '')) +watch( + readmeUrl, + async (url) => { + readme.value = '' + if (!url) return + try { + readme.value = (await api.getRepoReadme(url)).content + } catch { + // README не нашли (приватный/не-git) — остаётся описание-ссылка + } + }, + { immediate: true }, +) + // Тайтл вкладки — заголовок задачи (после загрузки уточняет meta маршрута) watchEffect(() => { setPageTitle(task.value?.title ?? t('nav.task')) @@ -222,6 +245,7 @@ ) const index = boxes.indexOf(target) if (index === -1) return + if (readmeUrl.value) return // чекбоксы из README в описание не патчим const updated = toggleMarkdownCheckbox(task0.description, index) if (updated === null) return target.disabled = true // второй клик, пока PATCH летит; при ошибке вернём @@ -758,8 +782,15 @@