конвенции: перенести механизируемое в golangci-lint и internal/archrules
Правило, которое проверяет машина, не должно оставаться прозой: файл конвенций на сотни строк размазывает внимание по тривиальному — модель добросовестно проверит именование полей лога и не дойдёт до формы решения. Включены sloglint (константный msg, стиль ключ-значение), forbidigo (fmt.Print*, os.Getenv, time.Now мимо store.Now), errorlint (сравнение ошибок), depguard (сторонние пакеты ошибок). internal/archrules — сканеры на то, что линтером не выражается: направление зависимостей ядро↔транспорты, AUTOINCREMENT и серверное время в новых миграциях, матчинг ошибки по тексту. Код приведён к правилам: logging.StartCall как единая точка отсчёта длительности внешних вызовов, store.Now вместо time.Now в httpapi и часах воркера, slog.DiscardHandler в тестах. Перенесённое вычеркнуто из docs/conventions/* и openspec/config.yaml — прозой осталось только то, что правилом не выражается. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+65
-1
@@ -1,11 +1,61 @@
|
|||||||
# Конфиг golangci-lint (схема v2; устанавливается через `task setup`).
|
# Конфиг golangci-lint (схема v2; устанавливается через `task setup`).
|
||||||
|
#
|
||||||
# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign,
|
# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign,
|
||||||
# staticcheck, unused; дополнительно включаем misspell.
|
# staticcheck, unused. Сверх него включены линтеры, которые механизируют
|
||||||
|
# конвенции из docs/conventions/*: то, что проверяет правило, не должно
|
||||||
|
# оставаться прозой в конвенциях и в промптах ревью (см.
|
||||||
|
# .claude/skills/review-pipeline/references/promote.md).
|
||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- misspell
|
- misspell
|
||||||
|
# docs/conventions/logging.md: msg — константная категория, данные — в
|
||||||
|
# полях, единый стиль ключ-значение.
|
||||||
|
- sloglint
|
||||||
|
# docs/conventions/logging.md (без fmt.Println), config.md (конфиг только
|
||||||
|
# из TOML, env не используем), database.md (время — только store.Now()).
|
||||||
|
- forbidigo
|
||||||
|
# docs/conventions/errors.md: сравнение ошибок через errors.Is/As, а не
|
||||||
|
# `err == ErrX` и не приведением типа.
|
||||||
|
- errorlint
|
||||||
|
# docs/conventions/errors.md: ошибки — только stdlib.
|
||||||
|
- depguard
|
||||||
|
|
||||||
|
settings:
|
||||||
|
sloglint:
|
||||||
|
no-mixed-args: true # не мешать пары «ключ-значение» с slog.Attr
|
||||||
|
kv-only: true # принятый в проекте стиль вызова
|
||||||
|
static-msg: true # msg — константа, без fmt.Sprintf и интерполяции
|
||||||
|
# key-naming-case НЕ включаем: словарь полей намеренно смешанный —
|
||||||
|
# доменные поля snake_case, системные домены с точкой (`http.method`,
|
||||||
|
# `ext.service`, адаптация OpenTelemetry). См. logging.md, «Поля».
|
||||||
|
|
||||||
|
forbidigo:
|
||||||
|
forbid:
|
||||||
|
- pattern: ^fmt\.Print.*$
|
||||||
|
msg: логируем через slog, в stdout напрямую не пишем (docs/conventions/logging.md)
|
||||||
|
- pattern: ^os\.Getenv$
|
||||||
|
msg: конфигурация только из TOML, env для конфига не используем (docs/conventions/config.md)
|
||||||
|
- pattern: ^time\.Now$
|
||||||
|
msg: время генерирует store.Now() (UTC, единая точка) — docs/conventions/database.md
|
||||||
|
|
||||||
|
errorlint:
|
||||||
|
# Обёртка вида fmt.Errorf("%w: %v", ErrSentinel, err) осознанна: sentinel
|
||||||
|
# раскрываем для errors.Is, причину — намеренно нет (errors.md, «%w vs %v»).
|
||||||
|
errorf: false
|
||||||
|
asserts: true
|
||||||
|
comparison: true
|
||||||
|
|
||||||
|
depguard:
|
||||||
|
rules:
|
||||||
|
main:
|
||||||
|
deny:
|
||||||
|
- pkg: github.com/pkg/errors
|
||||||
|
desc: ошибки — только stdlib errors + fmt.Errorf (docs/conventions/errors.md)
|
||||||
|
- pkg: github.com/cockroachdb/errors
|
||||||
|
desc: стек-трейсы избыточны, контекст несёт slog (docs/conventions/errors.md)
|
||||||
|
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
presets:
|
presets:
|
||||||
@@ -17,6 +67,20 @@ linters:
|
|||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
|
rules:
|
||||||
|
# CLI — другая поверхность: печатает результат в stdout и меряет
|
||||||
|
# длительность своей работы, это не логирование и не время в БД.
|
||||||
|
- path: ^cmd/
|
||||||
|
linters: [forbidigo]
|
||||||
|
# Интеграционные тесты берут креды внешних сервисов из окружения —
|
||||||
|
# это не конфигурация приложения.
|
||||||
|
- path: _test\.go$
|
||||||
|
text: os.Getenv
|
||||||
|
linters: [forbidigo]
|
||||||
|
# Единые точки генерации id и времени — им time.Now по определению можно.
|
||||||
|
- path: ^internal/(ident|store)/
|
||||||
|
text: time.Now
|
||||||
|
linters: [forbidigo]
|
||||||
|
|
||||||
formatters:
|
formatters:
|
||||||
exclusions:
|
exclusions:
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
именование) — в отличие от `docs/specs/` и `openspec/specs/`, которые
|
именование) — в отличие от `docs/specs/` и `openspec/specs/`, которые
|
||||||
описывают, **что** система делает.
|
описывают, **что** система делает.
|
||||||
|
|
||||||
|
**Прозой здесь остаётся только то, что не выражается правилом.** Как только
|
||||||
|
свойство удаётся проверить машиной, оно уезжает в `.golangci.yml` или в
|
||||||
|
`internal/archrules`, а формулировка отсюда **удаляется** (остаётся пометка
|
||||||
|
«механизировано» со ссылкой на линтер). Процедура — [промоут находка →
|
||||||
|
конвенция → правило → удаление](../../.claude/skills/review-pipeline/references/promote.md).
|
||||||
|
Причина: файл на несколько сотен строк размазывает внимание по тривиальному —
|
||||||
|
и модель, и человек добросовестно проверят именование и не дойдут до формы
|
||||||
|
решения.
|
||||||
|
|
||||||
Конвенции **не** переносятся в OpenSpec: это не capability. Короткие
|
Конвенции **не** переносятся в OpenSpec: это не capability. Короткие
|
||||||
инварианты дублируются в [CLAUDE.md](../../CLAUDE.md) (агент читает его
|
инварианты дублируются в [CLAUDE.md](../../CLAUDE.md) (агент читает его
|
||||||
всегда) и кратко в `openspec/config.yaml` → `context` (подмешивается в
|
всегда) и кратко в `openspec/config.yaml` → `context` (подмешивается в
|
||||||
|
|||||||
@@ -14,9 +14,10 @@
|
|||||||
- **Конфигурация — только TOML.** Env-переменные для конфига **не
|
- **Конфигурация — только TOML.** Env-переменные для конфига **не
|
||||||
используем**: окружение наследуется дочерними процессами и видно через
|
используем**: окружение наследуется дочерними процессами и видно через
|
||||||
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
|
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
|
||||||
|
Запрет `os.Getenv` механизирован (`forbidigo`).
|
||||||
- Грузим **один раз при старте** в одну типизированную структуру `Config`
|
- Грузим **один раз при старте** в одну типизированную структуру `Config`
|
||||||
(под-структуры по секциям). Дальше по коду читаем только её — никаких
|
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
|
||||||
`os.Getenv`/чтения файла в бизнес-коде, только загрузчик `internal/config`.
|
бизнес-коде нет, только загрузчик `internal/config`.
|
||||||
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
|
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
|
||||||
|
|
||||||
## Файл и поиск
|
## Файл и поиск
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
[../specs/database.md](../specs/database.md); обоснование выбора ULID —
|
[../specs/database.md](../specs/database.md); обоснование выбора ULID —
|
||||||
`openspec/changes/ulid-identity/design.md` (после архивации — в истории git).
|
`openspec/changes/ulid-identity/design.md` (после архивации — в истории git).
|
||||||
|
|
||||||
|
**Механизировано:** `AUTOINCREMENT` и `DEFAULT (datetime('now'))` в новых
|
||||||
|
миграциях (`internal/archrules`), время мимо `store.Now()` (`forbidigo`).
|
||||||
|
|
||||||
## Первичные ключи — ULID, не автоинкремент
|
## Первичные ключи — ULID, не автоинкремент
|
||||||
|
|
||||||
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
|
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
|
||||||
**приложением** в момент создания записи. `INTEGER PRIMARY KEY
|
**приложением** в момент создания записи.
|
||||||
AUTOINCREMENT` в новых таблицах не используем.
|
|
||||||
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
|
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
|
||||||
компактен и удобен в URL/логах (без дефисов — grep и двойной клик берут id
|
компактен и удобен в URL/логах (без дефисов — grep и двойной клик берут id
|
||||||
целиком), глобально уникален across таблиц — поиск по голому id находит
|
целиком), глобально уникален across таблиц — поиск по голому id находит
|
||||||
@@ -43,9 +45,10 @@
|
|||||||
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
||||||
лексикографическую сортировку TEXT = хронологию (`ORDER BY created_at`).
|
лексикографическую сортировку TEXT = хронологию (`ORDER BY created_at`).
|
||||||
Единая точка генерации — приложение: `store.Now()` + `store.FormatTime`/
|
Единая точка генерации — приложение: `store.Now()` + `store.FormatTime`/
|
||||||
`ParseTime` (аналогично `ident.NewID` для id); `DEFAULT (datetime('now'))` на
|
`ParseTime` (аналогично `ident.NewID` для id), а не дефолт в схеме — так
|
||||||
колонках **не используется** (fail-loud при забытой вставке: `NOT NULL` без
|
забытая вставка падает громко (`NOT NULL` без дефолта). Измерение
|
||||||
дефолта). Зона хранения всегда UTC; таймзона отображения в UI — конфиг
|
длительности — не метка времени: для внешних вызовов его засекает
|
||||||
|
`logging.StartCall`. Зона хранения всегда UTC; таймзона отображения в UI — конфиг
|
||||||
`[general].timezone`.
|
`[general].timezone`.
|
||||||
- Миграции — goose (`internal/store/migrations`): SQL-файлы для DDL;
|
- Миграции — goose (`internal/store/migrations`): SQL-файлы для DDL;
|
||||||
Go-миграции (`goose.AddMigrationContext`) — когда нужен код (генерация
|
Go-миграции (`goose.AddMigrationContext`) — когда нужен код (генерация
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как
|
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как
|
||||||
ошибки строятся, оборачиваются и проверяются.
|
ошибки строятся, оборачиваются и проверяются.
|
||||||
|
|
||||||
|
**Механизировано:** сторонние пакеты ошибок — `depguard`; `err == ErrX` и
|
||||||
|
приведение типа — `errorlint`; матчинг по тексту сообщения — `internal/archrules`.
|
||||||
|
|
||||||
## Базовая идиома: stdlib
|
## Базовая идиома: stdlib
|
||||||
|
|
||||||
- Только стандартный `errors` + `fmt.Errorf`. Без `pkg/errors` (в режиме
|
- Только стандартный `errors` + `fmt.Errorf`: контекст ошибки несёт `slog`, а не
|
||||||
поддержки) и `cockroachdb/errors` (стек-трейсы/Sentry — избыточно для
|
стек — стек-трейсы и Sentry избыточны для домашнего сервиса.
|
||||||
домашнего сервиса). Контекст ошибки несёт `slog`, а не стек.
|
|
||||||
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
|
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
|
||||||
пересмотреть, а не дефолт.
|
пересмотреть, а не дефолт.
|
||||||
|
|
||||||
@@ -36,9 +38,6 @@ jellybit — **приложение, а не библиотека**: внешн
|
|||||||
|
|
||||||
## Проверка ошибок
|
## Проверка ошибок
|
||||||
|
|
||||||
- Сравнение — только `errors.Is(err, ErrX)` (не `err == ErrX`) и
|
|
||||||
`errors.As(err, &target)`. **Никогда** не матчим по тексту
|
|
||||||
(`strings.Contains(err.Error(), …)`).
|
|
||||||
- Граничные ошибки зависимостей **транслируем в доменные у источника**:
|
- Граничные ошибки зависимостей **транслируем в доменные у источника**:
|
||||||
`sql.ErrNoRows` → доменный `store.ErrNotFound` в слое store, чтобы выше по
|
`sql.ErrNoRows` → доменный `store.ErrNotFound` в слое store, чтобы выше по
|
||||||
коду не торчал `database/sql`.
|
коду не торчал `database/sql`.
|
||||||
|
|||||||
+12
-30
@@ -8,14 +8,16 @@ OpenSpec-спеках (`### Requirement` с `SHALL`).
|
|||||||
Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел
|
Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел
|
||||||
«Конвенции кода».
|
«Конвенции кода».
|
||||||
|
|
||||||
|
**Механизировано** (`.golangci.yml`): `slog` вместо `fmt.Print*` — `forbidigo`;
|
||||||
|
константный `msg` и стиль ключ-значение — `sloglint`. Ниже — только то, что
|
||||||
|
правилом не выражается.
|
||||||
|
|
||||||
## Принципы
|
## Принципы
|
||||||
|
|
||||||
- Только `log/slog`, без `fmt.Println` и прямой записи в stdout.
|
|
||||||
- Структурированный JSON (`slog.JSONHandler`), один формат для dev и prod.
|
- Структурированный JSON (`slog.JSONHandler`), один формат для dev и prod.
|
||||||
- Сообщение (`msg`) — константный шаблон/категория события; данные — в
|
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
|
||||||
полях (атрибутах `slog`), а не в интерполяции текста.
|
отдельный ключ с типизированным значением: это даёт фильтрацию и агрегацию
|
||||||
- Каждое поле — отдельный ключ с типизированным значением. Это даёт
|
через `jq`/DuckDB без регулярок.
|
||||||
фильтрацию и агрегацию через `jq`/DuckDB без регулярок.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"}
|
{"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"}
|
||||||
@@ -24,18 +26,8 @@ OpenSpec-спеках (`### Requirement` с `SHALL`).
|
|||||||
## Сообщение
|
## Сообщение
|
||||||
|
|
||||||
- `msg` — короткая константа в нижнем регистре: `download accepted`,
|
- `msg` — короткая константа в нижнем регистре: `download accepted`,
|
||||||
`recognition done`, `layout failed`. Без переменных в тексте.
|
`recognition done`, `layout failed`. Данные — в атрибутах:
|
||||||
- Данные кладём в атрибуты: `slog.Info("download accepted", "download_id",
|
`log.Info("download accepted", "download_id", id, "media_type", "movie")`.
|
||||||
id, "infohash", ih)`.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Правильно: msg — категория, данные — поля
|
|
||||||
log.Info("download accepted", "download_id", id, "media_type", "movie")
|
|
||||||
|
|
||||||
// Неправильно: данные зашиты в текст, агрегация ломается
|
|
||||||
log.Info(fmt.Sprintf("download %s accepted as movie", id))
|
|
||||||
```
|
|
||||||
|
|
||||||
- `msg` — чистая категория без неймспейс-префикса: `recognition done`, а не
|
- `msg` — чистая категория без неймспейс-префикса: `recognition done`, а не
|
||||||
`recognize: done`. Подсистему выносим в поле `capability`
|
`recognize: done`. Подсистему выносим в поле `capability`
|
||||||
(`ingest`/`recognition`/`file-layout`/`review`), не в текст.
|
(`ingest`/`recognition`/`file-layout`/`review`), не в текст.
|
||||||
@@ -131,20 +123,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
|||||||
|
|
||||||
## Ошибки
|
## Ошибки
|
||||||
|
|
||||||
Go-ошибки логируем как атрибут, не как текст сообщения.
|
Go-ошибки логируем как атрибут, не как текст сообщения:
|
||||||
|
`log.Error("layout failed", "error", err, "download_id", id)`. Ключ — `error`
|
||||||
|
(как по умолчанию в zap/zerolog; единый ключ важнее краткости).
|
||||||
|
|
||||||
```go
|
|
||||||
// Правильно: msg — категория, ошибка — поле
|
|
||||||
log.Error("layout failed", "error", err, "download_id", id)
|
|
||||||
|
|
||||||
// Неправильно: ошибка зашита в msg, агрегация по событию ломается
|
|
||||||
log.Error(err.Error())
|
|
||||||
```
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
|
|
||||||
- Ошибку передаём полем `"error", err` — не склеиваем в `msg`. Ключ —
|
|
||||||
`error` (как по умолчанию в zap/zerolog; единый ключ важнее краткости).
|
|
||||||
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
|
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
|
||||||
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя —
|
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя —
|
||||||
контекст накапливается в цепочке `%w`.
|
контекст накапливается в цепочке `%w`.
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Package archrules — тесты-сканеры исходников для правил, которые не
|
||||||
|
// выражаются линтером: структура проекта и SQL миграций.
|
||||||
|
//
|
||||||
|
// Каждое правило здесь — бывшая строка прозаической конвенции: у него есть
|
||||||
|
// детерминированный оракул, поэтому ему место в конвейере сборки, а не в
|
||||||
|
// промпте ревью (см. .claude/skills/review-pipeline/references/promote.md).
|
||||||
|
package archrules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const modulePath = "git.vakhrushev.me/av/jellybit"
|
||||||
|
|
||||||
|
// repoRoot — корень репозитория относительно каталога пакета.
|
||||||
|
const repoRoot = "../.."
|
||||||
|
|
||||||
|
// Транспорты — тонкие обёртки над ядром: не знают друг о друге и никем из ядра
|
||||||
|
// не импортируются (CLAUDE.md, «Единое ядро, тонкие транспорты»).
|
||||||
|
var transports = map[string]bool{
|
||||||
|
"internal/httpapi": true,
|
||||||
|
"internal/tgbot": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestТранспортыНеЗависятДругОтДруга(t *testing.T) {
|
||||||
|
for pkg, imports := range internalImports(t) {
|
||||||
|
if !transports[pkg] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, imp := range imports {
|
||||||
|
if transports[imp] && imp != pkg {
|
||||||
|
t.Errorf("%s импортирует транспорт %s: транспорты не знают друг о друге, общая логика живёт в ядре", pkg, imp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestЯдроНеЗависитОтТранспортов(t *testing.T) {
|
||||||
|
for pkg, imports := range internalImports(t) {
|
||||||
|
if transports[pkg] || pkg == "cmd/jellybit" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, imp := range imports {
|
||||||
|
if transports[imp] {
|
||||||
|
t.Errorf("%s импортирует транспорт %s: зависимость направлена не туда, ядро не знает о доставке", pkg, imp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastLegacyMigration — последняя миграция, написанная до того, как конвенция
|
||||||
|
// сложилась: 0001 заводила AUTOINCREMENT и DEFAULT datetime('now'), 0006 и 0008
|
||||||
|
// как раз уводили схему на ULID и RFC 3339 и потому упоминают старую форму.
|
||||||
|
// Миграции неизменяемы, переписывать их нельзя — правило действует на новые.
|
||||||
|
const lastLegacyMigration = 8
|
||||||
|
|
||||||
|
// docs/conventions/database.md: PK — TEXT ULID через internal/ident, время
|
||||||
|
// генерирует приложение (store.Now), а не SQLite.
|
||||||
|
func TestМиграцииБезAutoincrementИСерверногоВремени(t *testing.T) {
|
||||||
|
forbidden := []struct {
|
||||||
|
re *regexp.Regexp
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{regexp.MustCompile(`(?i)autoincrement`), "PK — TEXT ULID через internal/ident, без AUTOINCREMENT"},
|
||||||
|
{regexp.MustCompile(`(?i)default\s*\(?\s*(datetime\s*\(\s*'now'|current_timestamp)`), "время генерирует приложение через store.Now(), а не DEFAULT в схеме (fail-loud при забытой вставке)"},
|
||||||
|
}
|
||||||
|
dir := filepath.Join(repoRoot, "internal/store/migrations")
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читаю каталог миграций: %v", err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || migrationNumber(t, e.Name()) <= lastLegacyMigration {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читаю %s: %v", e.Name(), err)
|
||||||
|
}
|
||||||
|
for _, f := range forbidden {
|
||||||
|
if loc := f.re.FindIndex(body); loc != nil {
|
||||||
|
t.Errorf("%s: строка %d — %s", e.Name(), lineOf(body, loc[0]), f.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// docs/conventions/errors.md: сравнение ошибок — errors.Is/errors.As, никогда
|
||||||
|
// по тексту. errorlint ловит `err == ErrX` и приведение типа, но не матчинг
|
||||||
|
// подстрокой — его ловим здесь.
|
||||||
|
func TestОшибкиНеМатчатсяПоТексту(t *testing.T) {
|
||||||
|
re := regexp.MustCompile(`(strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\([^)]*\.Error\(\)|\.Error\(\)\s*==)`)
|
||||||
|
for _, path := range goFiles(t) {
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читаю %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if loc := re.FindIndex(body); loc != nil {
|
||||||
|
rel, _ := filepath.Rel(repoRoot, path)
|
||||||
|
t.Errorf("%s:%d — ошибку матчим через errors.Is/errors.As, а не по тексту сообщения", rel, lineOf(body, loc[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// internalImports возвращает карту «пакет репозитория → его внутренние импорты»
|
||||||
|
// (пути относительно корня модуля).
|
||||||
|
func internalImports(t *testing.T) map[string][]string {
|
||||||
|
t.Helper()
|
||||||
|
out := map[string][]string{}
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
for _, path := range goFiles(t) {
|
||||||
|
f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("разбираю %s: %v", path, err)
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(repoRoot, filepath.Dir(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("отношу путь %s: %v", path, err)
|
||||||
|
}
|
||||||
|
for _, imp := range f.Imports {
|
||||||
|
p := strings.Trim(imp.Path.Value, `"`)
|
||||||
|
if after, ok := strings.CutPrefix(p, modulePath+"/"); ok {
|
||||||
|
out[rel] = append(out[rel], after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// goFiles — все нетестовые .go файлы репозитория (без tmp и вендорных каталогов).
|
||||||
|
func goFiles(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
var files []string
|
||||||
|
err := filepath.WalkDir(repoRoot, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
switch d.Name() {
|
||||||
|
case "tmp", "vendor", ".git", "node_modules":
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") {
|
||||||
|
files = append(files, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("обхожу репозиторий: %v", err)
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrationNumber достаёт числовой префикс имени миграции (0009_… → 9).
|
||||||
|
func migrationNumber(t *testing.T, name string) int {
|
||||||
|
t.Helper()
|
||||||
|
prefix, _, ok := strings.Cut(name, "_")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("имя миграции без числового префикса: %s", name)
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(prefix)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("нечисловой префикс миграции %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineOf(body []byte, offset int) int {
|
||||||
|
return 1 + strings.Count(string(body[:offset]), "\n")
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -50,7 +49,7 @@ func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
|
|||||||
func testRouterAction(t *testing.T, r stubReader, rv Reviewer, cmd Commander, lv stubLive) http.Handler {
|
func testRouterAction(t *testing.T, r stubReader, rv Reviewer, cmd Commander, lv stubLive) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
h, err := NewRouter(Deps{
|
h, err := NewRouter(Deps{
|
||||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
Logger: slog.New(slog.DiscardHandler),
|
||||||
Reader: r,
|
Reader: r,
|
||||||
Reviewer: rv,
|
Reviewer: rv,
|
||||||
Commander: cmd,
|
Commander: cmd,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/naming"
|
"git.vakhrushev.me/av/jellybit/internal/naming"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
@@ -134,7 +133,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
|||||||
// как в порядке и карточках списка); неразбираемое время просто опускаем.
|
// как в порядке и карточках списка); неразбираемое время просто опускаем.
|
||||||
if t, ok := addedTime(d); ok {
|
if t, ok := addedTime(d); ok {
|
||||||
view.Added = fmtDate(t, s.deps.Loc)
|
view.Added = fmtDate(t, s.deps.Loc)
|
||||||
view.AddedAgo = humanizeAge(t, time.Now())
|
view.AddedAgo = humanizeAge(t, store.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
if rd.Recognition != nil {
|
if rd.Recognition != nil {
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
layoutSizes = nil // деградируем: размер уедет в фолбэк «—», страница не падает
|
layoutSizes = nil // деградируем: размер уедет в фолбэк «—», страница не падает
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := store.Now()
|
||||||
for _, d := range downloads {
|
for _, d := range downloads {
|
||||||
view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID]))
|
view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID]))
|
||||||
}
|
}
|
||||||
@@ -500,7 +500,7 @@ func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id s
|
|||||||
s.deps.Logger.Error("layout sizes", "download_id", id, "error", err)
|
s.deps.Logger.Error("layout sizes", "download_id", id, "error", err)
|
||||||
sizes = nil // деградируем: размер уедет в фолбэк «—», фрагмент не падает
|
sizes = nil // деградируем: размер уедет в фолбэк «—», фрагмент не падает
|
||||||
}
|
}
|
||||||
v := s.buildCardView(*d, time.Now(), sizes[id])
|
v := s.buildCardView(*d, store.Now(), sizes[id])
|
||||||
if actionErr != nil {
|
if actionErr != nil {
|
||||||
v.ActionError = userErr(r, actionErr, id)
|
v.ActionError = userErr(r, actionErr, id)
|
||||||
}
|
}
|
||||||
@@ -841,7 +841,7 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
|||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
start := time.Now()
|
start := time.Now() //nolint:forbidigo // измеряем длительность запроса, а не метку времени в БД
|
||||||
|
|
||||||
next.ServeHTTP(ww, r)
|
next.ServeHTTP(ww, r)
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[st
|
|||||||
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
|
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if d.Logger == nil {
|
if d.Logger == nil {
|
||||||
d.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
d.Logger = slog.New(slog.DiscardHandler)
|
||||||
}
|
}
|
||||||
h, err := httpapi.NewRouter(d)
|
h, err := httpapi.NewRouter(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
// layoutSize 0: у catched раскладки нет; в downloading размер берётся из
|
// layoutSize 0: у catched раскладки нет; в downloading размер берётся из
|
||||||
// живого снимка внутри buildCardView.
|
// живого снимка внутри buildCardView.
|
||||||
s.render(w, "card", s.buildCardView(*d, time.Now(), 0))
|
s.render(w, "card", s.buildCardView(*d, store.Now(), 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг).
|
// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг).
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -77,7 +76,7 @@ func testRouter(t *testing.T, r stubReader, rv stubReviewer) http.Handler {
|
|||||||
func testRouterLive(t *testing.T, r stubReader, rv stubReviewer, lv stubLive) http.Handler {
|
func testRouterLive(t *testing.T, r stubReader, rv stubReviewer, lv stubLive) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
h, err := NewRouter(Deps{
|
h, err := NewRouter(Deps{
|
||||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
Logger: slog.New(slog.DiscardHandler),
|
||||||
Reader: r,
|
Reader: r,
|
||||||
Reviewer: rv,
|
Reviewer: rv,
|
||||||
Live: lv,
|
Live: lv,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package ingest
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -79,7 +78,7 @@ func (r *raceStore) UpgradeCatchedMagnetToTorrent(_ context.Context, _ string, _
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newService(st Store) *Service {
|
func newService(st Store) *Service {
|
||||||
return New(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
return New(st, slog.New(slog.DiscardHandler))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Быстрый приём: сохраняем загрузку в catched и сразу отвечаем; qBittorrent и
|
// Быстрый приём: сохраняем загрузку в catched и сразу отвечаем; qBittorrent и
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func (c *Client) RefreshLibraries(ctx context.Context) error {
|
|||||||
req.Header.Set("X-Emby-Token", c.apiKey)
|
req.Header.Set("X-Emby-Token", c.apiKey)
|
||||||
|
|
||||||
log := logctx.FromOr(ctx, c.log)
|
log := logctx.FromOr(ctx, c.log)
|
||||||
call := logging.ExtCall{Service: logging.ServiceJellyfin, Operation: "library/refresh", Start: time.Now()}
|
call := logging.StartCall(logging.ServiceJellyfin, "library/refresh")
|
||||||
resp, err := c.hc.Do(req)
|
resp, err := c.hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
call.Failure(log, err)
|
call.Failure(log, err)
|
||||||
|
|||||||
@@ -128,12 +128,8 @@ func (c *openAICompat) Complete(ctx context.Context, req Request) (Response, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
call := logging.ExtCall{
|
call := logging.StartCall(logging.ServiceLLM, "chat.completions")
|
||||||
Service: logging.ServiceLLM,
|
call.Attempt = attempt
|
||||||
Operation: "chat.completions",
|
|
||||||
Start: time.Now(),
|
|
||||||
Attempt: attempt,
|
|
||||||
}
|
|
||||||
resp, retryable, err := c.do(ctx, body)
|
resp, retryable, err := c.do(ctx, body)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
call.Success(log, "model", resp.Model,
|
call.Success(log, "model", resp.Model,
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ type ExtCall struct {
|
|||||||
Attempt int // номер попытки; >0 — пишем поле retry (поле и метод Retry конфликтовали бы)
|
Attempt int // номер попытки; >0 — пишем поле retry (поле и метод Retry конфликтовали бы)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartCall заводит запись о начинающемся вызове внешнего сервиса, засекая
|
||||||
|
// время. Единая точка отсчёта длительности: клиентам не нужен собственный
|
||||||
|
// time.Now, а конвенция «время генерирует store.Now()» остаётся без исключений
|
||||||
|
// (здесь это не метка времени, а измерение — см. docs/conventions/logging.md).
|
||||||
|
func StartCall(service, operation string) ExtCall {
|
||||||
|
return ExtCall{Service: service, Operation: operation, Start: time.Now()} //nolint:forbidigo // единственная точка отсчёта длительности внешних вызовов
|
||||||
|
}
|
||||||
|
|
||||||
func (c ExtCall) attrs(extra ...any) []any {
|
func (c ExtCall) attrs(extra ...any) []any {
|
||||||
a := make([]any, 0, 10+len(extra))
|
a := make([]any, 0, 10+len(extra))
|
||||||
a = append(a,
|
a = append(a,
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func postJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, o
|
|||||||
// при отсутствии — переданный fallback.
|
// при отсутствии — переданный fallback.
|
||||||
func doJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, operation string, req *http.Request, out any) error {
|
func doJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, operation string, req *http.Request, out any) error {
|
||||||
log = logctx.FromOr(ctx, log)
|
log = logctx.FromOr(ctx, log)
|
||||||
call := logging.ExtCall{Service: service, Operation: operation, Start: time.Now()}
|
call := logging.StartCall(service, operation)
|
||||||
resp, err := hc.Do(req)
|
resp, err := hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Транспортный сбой несёт *url.Error с полным URL, а у TMDB api_key —
|
// Транспортный сбой несёт *url.Error с полным URL, а у TMDB api_key —
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ func (t *TVDB) rawGet(ctx context.Context, operation, path, token string) (int,
|
|||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
log := logctx.FromOr(ctx, t.log)
|
log := logctx.FromOr(ctx, t.log)
|
||||||
call := logging.ExtCall{Service: logging.ServiceTVDB, Operation: operation, Start: time.Now()}
|
call := logging.StartCall(logging.ServiceTVDB, operation)
|
||||||
resp, err := t.hc.Do(req)
|
resp, err := t.hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
call.Failure(log, err)
|
call.Failure(log, err)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package naming
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -11,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func testLogger() *slog.Logger {
|
func testLogger() *slog.Logger {
|
||||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
return slog.New(slog.DiscardHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fakeProvider отдаёт заранее заданные ответы по очереди; считает вызовы.
|
// fakeProvider отдаёт заранее заданные ответы по очереди; считает вызовы.
|
||||||
|
|||||||
+6
-6
@@ -141,7 +141,7 @@ func (c *Client) login(ctx context.Context) error {
|
|||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("Referer", c.base.String()) // qBit проверяет Referer/Host
|
req.Header.Set("Referer", c.base.String()) // qBit проверяет Referer/Host
|
||||||
log := logctx.FromOr(ctx, c.log)
|
log := logctx.FromOr(ctx, c.log)
|
||||||
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "auth/login", Start: time.Now()}
|
call := logging.StartCall(logging.ServiceQBittorrent, "auth/login")
|
||||||
resp, err := c.hc.Do(req)
|
resp, err := c.hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
call.Failure(log, err)
|
call.Failure(log, err)
|
||||||
@@ -221,7 +221,7 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
|
|||||||
payload := buf.Bytes()
|
payload := buf.Bytes()
|
||||||
|
|
||||||
log := logctx.FromOr(ctx, c.log)
|
log := logctx.FromOr(ctx, c.log)
|
||||||
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/add", Start: time.Now()}
|
call := logging.StartCall(logging.ServiceQBittorrent, "torrents/add")
|
||||||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
c.endpoint("/api/v2/torrents/add"), bytes.NewReader(payload))
|
c.endpoint("/api/v2/torrents/add"), bytes.NewReader(payload))
|
||||||
@@ -278,7 +278,7 @@ func (c *Client) Delete(ctx context.Context, hashes []string, deleteFiles bool)
|
|||||||
body := form.Encode()
|
body := form.Encode()
|
||||||
|
|
||||||
log := logctx.FromOr(ctx, c.log)
|
log := logctx.FromOr(ctx, c.log)
|
||||||
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/delete", Start: time.Now()}
|
call := logging.StartCall(logging.ServiceQBittorrent, "torrents/delete")
|
||||||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
c.endpoint("/api/v2/torrents/delete"), strings.NewReader(body))
|
c.endpoint("/api/v2/torrents/delete"), strings.NewReader(body))
|
||||||
@@ -319,7 +319,7 @@ func (c *Client) RenameTorrent(ctx context.Context, hash, name string) error {
|
|||||||
body := form.Encode()
|
body := form.Encode()
|
||||||
|
|
||||||
log := logctx.FromOr(ctx, c.log)
|
log := logctx.FromOr(ctx, c.log)
|
||||||
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/rename", Start: time.Now()}
|
call := logging.StartCall(logging.ServiceQBittorrent, "torrents/rename")
|
||||||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
c.endpoint("/api/v2/torrents/rename"), strings.NewReader(body))
|
c.endpoint("/api/v2/torrents/rename"), strings.NewReader(body))
|
||||||
@@ -350,7 +350,7 @@ func (c *Client) RenameTorrent(ctx context.Context, hash, name string) error {
|
|||||||
// Torrents возвращает задачи указанной категории (пустая — все).
|
// Torrents возвращает задачи указанной категории (пустая — все).
|
||||||
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
|
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
|
||||||
log := logctx.FromOr(ctx, c.log)
|
log := logctx.FromOr(ctx, c.log)
|
||||||
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/info", Start: time.Now()}
|
call := logging.StartCall(logging.ServiceQBittorrent, "torrents/info")
|
||||||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||||||
u := c.endpoint("/api/v2/torrents/info")
|
u := c.endpoint("/api/v2/torrents/info")
|
||||||
if category != "" {
|
if category != "" {
|
||||||
@@ -386,7 +386,7 @@ func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, erro
|
|||||||
// распознаванию как один из сигналов; абсолютный путь — join(save_path, Name).
|
// распознаванию как один из сигналов; абсолютный путь — join(save_path, Name).
|
||||||
func (c *Client) Files(ctx context.Context, hash string) ([]File, error) {
|
func (c *Client) Files(ctx context.Context, hash string) ([]File, error) {
|
||||||
log := logctx.FromOr(ctx, c.log)
|
log := logctx.FromOr(ctx, c.log)
|
||||||
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/files", Start: time.Now()}
|
call := logging.StartCall(logging.ServiceQBittorrent, "torrents/files")
|
||||||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||||||
u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash))
|
u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash))
|
||||||
return http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
return http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package recognize_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -43,7 +42,7 @@ func TestIntegration_RecognizeSeries(t *testing.T) {
|
|||||||
t.Fatalf("llm.New: %v", err)
|
t.Fatalf("llm.New: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
log := slog.New(slog.DiscardHandler)
|
||||||
r := recognize.New(provider, nil, recognize.Config{MaxRetries: 2}, log)
|
r := recognize.New(provider, nil, recognize.Config{MaxRetries: 2}, log)
|
||||||
|
|
||||||
const dir = "Аватар Легенда об Аанге.Книга 2.Земля(Avatar The Last Airbender The book 2.Earth)/"
|
const dir = "Аватар Легенда об Аанге.Книга 2.Земля(Avatar The Last Airbender The book 2.Earth)/"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package recognize
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -37,7 +36,7 @@ func (f *fakeLLM) Complete(_ context.Context, req llm.Request) (llm.Response, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testLogger() *slog.Logger {
|
func testLogger() *slog.Logger {
|
||||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
return slog.New(slog.DiscardHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecognize_Movie(t *testing.T) {
|
func TestRecognize_Movie(t *testing.T) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package tgbot
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -147,7 +146,7 @@ func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *
|
|||||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateDownloading}}
|
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateDownloading}}
|
||||||
rev := &fakeReviewer{data: reviewData(store.StateReview)}
|
rev := &fakeReviewer{data: reviewData(store.StateReview)}
|
||||||
b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
|
b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
|
||||||
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
slog.New(slog.DiscardHandler))
|
||||||
return b, api, ing, rev
|
return b, api, ing, rev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -753,7 +752,7 @@ func (f *fakeRecognizer) Director(_ context.Context, _ recognize.MediaType, _, _
|
|||||||
|
|
||||||
func testWorkerWith(st Store, qb QBittorrent, rec Recognizer, lay Layouter) *Worker {
|
func testWorkerWith(st Store, qb QBittorrent, rec Recognizer, lay Layouter) *Worker {
|
||||||
w := New(st, qb, rec, lay, Config{Category: "jellybit"},
|
w := New(st, qb, rec, lay, Config{Category: "jellybit"},
|
||||||
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
slog.New(slog.DiscardHandler))
|
||||||
n := 0
|
n := 0
|
||||||
w.newID = func() string { n++; return "batch-" + itoa(n) }
|
w.newID = func() string { n++; return "batch-" + itoa(n) }
|
||||||
return w
|
return w
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log
|
|||||||
layouter: lay,
|
layouter: lay,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
log: log,
|
log: log,
|
||||||
now: time.Now,
|
now: store.Now,
|
||||||
newID: defaultBatchID,
|
newID: defaultBatchID,
|
||||||
failNotified: map[string]time.Time{},
|
failNotified: map[string]time.Time{},
|
||||||
live: map[string]Live{},
|
live: map[string]Live{},
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -360,7 +359,7 @@ func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
|
|||||||
SavePath: "/srv/media/downloads",
|
SavePath: "/srv/media/downloads",
|
||||||
MagnetTimeout: 30 * time.Minute,
|
MagnetTimeout: 30 * time.Minute,
|
||||||
StuckAfter: time.Hour,
|
StuckAfter: time.Hour,
|
||||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
}, slog.New(slog.DiscardHandler))
|
||||||
w.now = func() time.Time { return time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC) }
|
w.now = func() time.Time { return time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC) }
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-13
@@ -29,19 +29,15 @@ context: |
|
|||||||
- Тривиальная задача — достаточно одного прохода (код).
|
- Тривиальная задача — достаточно одного прохода (код).
|
||||||
|
|
||||||
Конвенции кода (соблюдать при apply):
|
Конвенции кода (соблюдать при apply):
|
||||||
- Логирование — только log/slog (структурированный JSON), без fmt.Println.
|
- Механизируемое проверяет конвейер сборки (.golangci.yml + internal/archrules),
|
||||||
Логируем все вызовы внешних сервисов; healthcheck-эндпоинты — на DEBUG.
|
пересказывать его здесь не нужно: `task lint` и `task test` скажут точнее.
|
||||||
Детали: уровни, обязательные поля — docs/conventions/logging.md.
|
- Прозой остаётся то, что правилом не выражается, и это читаем в источнике:
|
||||||
- Безопасность: никаких секретов в полях логов (пароли qBittorrent,
|
docs/conventions/{logging,errors,config,database,web-ui}.md — уровень лога
|
||||||
API-ключи LLM/метабаз, auth-заголовки).
|
по адресату, единственный логирующий чокпоинт на доменной границе,
|
||||||
- Конфигурация — только TOML; секреты рендерит деплой (Ansible+Vault) в
|
трансляция доменной ошибки на внешней границе, самодокументируемый
|
||||||
файл (config.toml не коммитится, 0600), env для конфига не используем;
|
config.example.toml, htmx-партиалы.
|
||||||
валидация на старте. Детали: docs/conventions/config.md.
|
- Безопасность: никаких секретов в полях логов и в диагностике состояния
|
||||||
- Ошибки — stdlib, обёртка с контекстом (fmt.Errorf("...: %w", err)),
|
(пароли qBittorrent, API-ключи LLM/метабаз, auth-заголовки).
|
||||||
проверка errors.Is/errors.As, трансляция доменной ошибки в ответ на
|
|
||||||
внешней границе (наружу не отдаём текст внутренней ошибки). Детали:
|
|
||||||
docs/conventions/errors.md.
|
|
||||||
- Время — всегда с явным TZ (сервер в Europe/Moscow; логи — в UTC).
|
|
||||||
|
|
||||||
# Project context (optional)
|
# Project context (optional)
|
||||||
# This is shown to AI when creating artifacts.
|
# This is shown to AI when creating artifacts.
|
||||||
|
|||||||
Reference in New Issue
Block a user