tasks.py: спринт снят, гейт готовности переехал в команду ready
Спринт был вплетён в 215 мест: конфиг, разбор индексов, состояния задачи, add/edit/move/close/reopen, блок здоровья и CLI. Снято всё: SPRINT.md, четыре команды sprint, автотег урожая sprint:<слаг>, проверки набора в check. Гейт готовности стоял на sprint take — единственном месте, где запись судили целиком. Момент нужен и без спринта, иначе задача уезжает в работу без критериев приёмки. Теперь это команда ready <слаг>: тип, цель у feature, пустой раздел вопросов, схема типа. Отказ там рабочая ситуация, а не ошибка употребления, — код 1, не 2. Приоритет стал порядком строк в беклоге, и два места это уже знают: reopen и check --fix ставят восстановленную строку в конец секции и говорят, что позицию назначает человек. Молчаливое восстановление выдавало бы машинную позицию за его решение. Переезд проекта: снести SPRINT.md, прогнать check --fix — задачи из набора вернутся в беклог. Проверено на фикстуре.
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Детерминированный инструмент управления задачами: файлы против индексов.
|
"""Детерминированный инструмент управления задачами: файлы против индексов.
|
||||||
|
|
||||||
Преемник backlog.py. Разница по существу одна: **порядка нет, есть цель**.
|
Преемник backlog.py. Разница по существу одна: **секция-как-уровень заменена
|
||||||
Приоритеты и секция-как-уровень заменены на цель (`goal:<слаг>` тегом) и на
|
целью** (`goal:<слаг>` тегом), а приоритет стал тем, чем он и является, —
|
||||||
спринт — замороженный набор задач, обычно под одну цель. Индексов теперь
|
**порядком строк в беклоге**. Индексов три, и задача живёт ровно в одном из них
|
||||||
четыре, и задача живёт ровно в одном из них за раз.
|
за раз.
|
||||||
|
|
||||||
Раскладка. Путь каталога — `tasks/` в корне репозитория, жёстко. Каталог
|
Раскладка. Путь каталога — `tasks/` в корне репозитория, жёстко. Каталог
|
||||||
принадлежит этому плагину, а не канону документов: `docs/` ведёт другой плагин, и
|
принадлежит этому плагину, а не канону документов: `docs/` ведёт другой плагин, и
|
||||||
@@ -17,16 +17,17 @@
|
|||||||
Секции канонические и в этом порядке: Запланировано |
|
Секции канонические и в этом порядке: Запланировано |
|
||||||
Направления | Сопровождение | Готово (или Planned |
|
Направления | Сопровождение | Готово (или Planned |
|
||||||
Directions | Operations | Done — один язык на индекс)
|
Directions | Operations | Done — один язык на индекс)
|
||||||
BACKLOG.md что можно взять — только задачи, целей здесь нет
|
BACKLOG.md что можно взять — только задачи, целей здесь нет.
|
||||||
SPRINT.md текущий спринт: цель (или её отсутствие), набор, дата, слаг
|
Порядок строк внутри секции значим: это очередь
|
||||||
REJECTED.md ушедшее БЕЗ реализации, с причиной и датой
|
REJECTED.md ушедшее БЕЗ реализации, с причиной и датой
|
||||||
|
|
||||||
Источник истины — файл задачи в items/. Индексы производны: расходятся —
|
Источник истины — файл задачи в items/. Индексы производны: расходятся —
|
||||||
неправ индекс. **«Зачем» живёт в мете файла**, а не только в строке индекса:
|
неправ индекс. **«Зачем» живёт в мете файла**, а не только в строке индекса:
|
||||||
иначе восстановление пропавшей строки (`check --fix`) теряло бы его навсегда.
|
иначе восстановление пропавшей строки (`check --fix`) теряло бы его навсегда.
|
||||||
Исключение из производности одно и оно намеренное: **в каком индексе лежит
|
Исключений из производности два, и оба намеренные: **в каком индексе лежит
|
||||||
задача — знают индексы**, потому что «в спринте» это свойство спринта, а не
|
запись — знают индексы** (поля-состояния в файле нет), и **порядок строк в
|
||||||
задачи; поля-состояния в файле нет, а рассогласование ловит check.
|
беклоге** — приоритет это свойство очереди, а не задачи, и в файле ему места
|
||||||
|
нет. Рассогласование по обоим ловит check.
|
||||||
|
|
||||||
Тип — единственная ось записи и **закрытый словарь из пяти значений**:
|
Тип — единственная ось записи и **закрытый словарь из пяти значений**:
|
||||||
goal | feature | fix | chore | research, по-английски, как и прочие токены
|
goal | feature | fix | chore | research, по-английски, как и прочие токены
|
||||||
@@ -50,18 +51,17 @@ goal | feature | fix | chore | research, по-английски, как и пр
|
|||||||
|
|
||||||
**Цель обязательна не у всякой задачи.** Новая возможность (`feature`) без цели
|
**Цель обязательна не у всякой задачи.** Новая возможность (`feature`) без цели
|
||||||
не бывает — цель и есть её содержание. Починка, техдолг и разведка служат
|
не бывает — цель и есть её содержание. Починка, техдолг и разведка служат
|
||||||
работоспособности, а не направлению, и живут без цели законно; в набор спринта
|
работоспособности, а не направлению, и живут без цели законно.
|
||||||
они входят помимо его цели.
|
|
||||||
|
|
||||||
**Тип определяет схему записи**: какие разделы тела обязательны, какие
|
**Тип определяет схему записи**: какие разделы тела обязательны, какие
|
||||||
допустимы, нужна ли цель, берётся ли запись в спринт. Схема — TYPE_SCHEMA;
|
допустимы, нужна ли цель, берётся ли запись в работу. Схема — TYPE_SCHEMA;
|
||||||
проза с алгоритмом работы над каждым типом — `references/task-<тип>.md`.
|
проза с алгоритмом работы над каждым типом — `references/task-<тип>.md`.
|
||||||
|
|
||||||
Использование:
|
Использование:
|
||||||
tasks.py init [--dir DIR] [--sections …] [--items …] [--backlog …] …
|
tasks.py init [--dir DIR] [--sections …] [--items …] [--backlog …] …
|
||||||
tasks.py check [--dir DIR] [--fix]
|
tasks.py check [--dir DIR] [--fix]
|
||||||
tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b]
|
tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b]
|
||||||
[--goal S] [--index backlog|sprint|roadmap|all] [--questions]
|
[--goal S] [--index backlog|roadmap|all] [--questions]
|
||||||
tasks.py add --slug S --title T --type goal|feature|fix|chore|research
|
tasks.py add --slug S --title T --type goal|feature|fix|chore|research
|
||||||
[--section S] [--goal G] [--why H] [--reason R] [--tag a,b] [--dir DIR]
|
[--section S] [--goal G] [--why H] [--reason R] [--tag a,b] [--dir DIR]
|
||||||
tasks.py edit S [--title T] [--why H] [--type T] [--goal G]
|
tasks.py edit S [--title T] [--why H] [--type T] [--goal G]
|
||||||
@@ -69,10 +69,7 @@ goal | feature | fix | chore | research, по-английски, как и пр
|
|||||||
tasks.py move S --section S [--reason R] [--after S | --first] [--dir DIR]
|
tasks.py move S --section S [--reason R] [--after S | --first] [--dir DIR]
|
||||||
tasks.py close S (--reason R | --implemented) [--dir DIR]
|
tasks.py close S (--reason R | --implemented) [--dir DIR]
|
||||||
tasks.py reopen S [--reason R] [--dir DIR]
|
tasks.py reopen S [--reason R] [--dir DIR]
|
||||||
tasks.py sprint start (--goal S | --no-goal) [--date ГГГГ-ММ-ДД] [--slug S] [--dir DIR]
|
tasks.py ready S [S …] [--dir DIR]
|
||||||
tasks.py sprint take S [S …] [--dir DIR]
|
|
||||||
tasks.py sprint drop S [S …] --reason R [--dir DIR]
|
|
||||||
tasks.py sprint close [--dissolve --reason R] [--dir DIR]
|
|
||||||
tasks.py adopt scan --from PATH [PATH …] [--target DIR] [--out PLAN.json]
|
tasks.py adopt scan --from PATH [PATH …] [--target DIR] [--out PLAN.json]
|
||||||
tasks.py adopt apply --plan PLAN.json [--refs PATH …] [--dry-run]
|
tasks.py adopt apply --plan PLAN.json [--refs PATH …] [--dry-run]
|
||||||
|
|
||||||
@@ -122,9 +119,7 @@ DEFAULTS = {
|
|||||||
"items": "items",
|
"items": "items",
|
||||||
"backlog": "BACKLOG.md",
|
"backlog": "BACKLOG.md",
|
||||||
"roadmap": "ROADMAP.md",
|
"roadmap": "ROADMAP.md",
|
||||||
"sprint": "SPRINT.md",
|
|
||||||
"rejected": "REJECTED.md",
|
"rejected": "REJECTED.md",
|
||||||
"sprint_section": "Набор",
|
|
||||||
"criteria_heading": "Критерии приёмки",
|
"criteria_heading": "Критерии приёмки",
|
||||||
"surface_heading": "Затрагивает",
|
"surface_heading": "Затрагивает",
|
||||||
"questions_heading": "Вопросы",
|
"questions_heading": "Вопросы",
|
||||||
@@ -138,7 +133,7 @@ DEFAULTS = {
|
|||||||
|
|
||||||
# Какие ключи конфига — имена файлов и каталогов (их существование сверяется
|
# Какие ключи конфига — имена файлов и каталогов (их существование сверяется
|
||||||
# с диском первым делом, иначе кривой ключ выглядит как пропавший файл).
|
# с диском первым делом, иначе кривой ключ выглядит как пропавший файл).
|
||||||
PATH_KEYS = ("items", "backlog", "roadmap", "sprint", "rejected")
|
PATH_KEYS = ("items", "backlog", "roadmap", "rejected")
|
||||||
|
|
||||||
DEFAULT_SECTIONS = "Ядро,Инфра"
|
DEFAULT_SECTIONS = "Ядро,Инфра"
|
||||||
|
|
||||||
@@ -227,9 +222,6 @@ TYPE_PREFIX = re.compile(r"^\[(.+?)\]\s*(.*)$")
|
|||||||
SLUG_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
SLUG_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
||||||
SLUG = re.compile(SLUG_RE.pattern + r"\.md")
|
SLUG = re.compile(SLUG_RE.pattern + r"\.md")
|
||||||
GOAL_LINE = re.compile(r"^(?:-\s+)?\*\*(?:Цель|Goal):\*\*\s*\[(.+?)\]\((.+?\.md)\)")
|
GOAL_LINE = re.compile(r"^(?:-\s+)?\*\*(?:Цель|Goal):\*\*\s*\[(.+?)\]\((.+?\.md)\)")
|
||||||
# `search`, а не `match`: строка меты идёт пунктом списка, а прежняя форма —
|
|
||||||
# третьим полем строки через `·`. Обе читаются, пишется новая.
|
|
||||||
SPRINT_SLUG_LINE = re.compile(r"\*\*(?:Спринт|Sprint):\*\*\s*`?([a-z0-9][a-z0-9.-]*)`?")
|
|
||||||
DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
||||||
BULLET = re.compile(r"^[-*]\s+(.*)$")
|
BULLET = re.compile(r"^[-*]\s+(.*)$")
|
||||||
# Строка кладбища: - ГГГГ-ММ-ДД `slug` — текст
|
# Строка кладбища: - ГГГГ-ММ-ДД `slug` — текст
|
||||||
@@ -249,14 +241,13 @@ TYPES = ("goal", "feature", "fix", "chore", RESEARCH)
|
|||||||
TYPE_EMOJI = {"goal": "🎯", "feature": "✨", "fix": "🐞",
|
TYPE_EMOJI = {"goal": "🎯", "feature": "✨", "fix": "🐞",
|
||||||
"chore": "🧹", RESEARCH: "🔬"}
|
"chore": "🧹", RESEARCH: "🔬"}
|
||||||
EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()}
|
EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()}
|
||||||
TAKEABLE = ("feature", "fix", "chore", RESEARCH) # что берётся в спринт
|
TAKEABLE = ("feature", "fix", "chore", RESEARCH) # что берётся в работу
|
||||||
# Заголовок в форме действия требуется там, где исход работы — изменение
|
# Заголовок в форме действия требуется там, где исход работы — изменение
|
||||||
# системы. У цели он называет возможность, у разведки — предмет: её исход
|
# системы. У цели он называет возможность, у разведки — предмет: её исход
|
||||||
# знание, и заголовок-действие обещал бы решённость, которой ещё нет.
|
# знание, и заголовок-действие обещал бы решённость, которой ещё нет.
|
||||||
ACTION_TYPES = ("feature", "fix", "chore")
|
ACTION_TYPES = ("feature", "fix", "chore")
|
||||||
QUESTION_TAG = "question"
|
QUESTION_TAG = "question"
|
||||||
GOAL_TAG = "goal:"
|
GOAL_TAG = "goal:"
|
||||||
SPRINT_TAG = "sprint:"
|
|
||||||
# Цель обязательна только у новой возможности: цель и есть возможность.
|
# Цель обязательна только у новой возможности: цель и есть возможность.
|
||||||
# Починка, техдолг и разведка служат работоспособности, а не направлению —
|
# Починка, техдолг и разведка служат работоспособности, а не направлению —
|
||||||
# придуманная им цель это то же враньё, от которого спасает тип.
|
# придуманная им цель это то же враньё, от которого спасает тип.
|
||||||
@@ -266,8 +257,8 @@ NEEDS_GOAL = ("feature",)
|
|||||||
# **ключи конфига**, а не сами заголовки: имена заголовков проект настраивает,
|
# **ключи конфига**, а не сами заголовки: имена заголовков проект настраивает,
|
||||||
# и схема, хранящая текст, разошлась бы с ними на первой же настройке.
|
# и схема, хранящая текст, разошлась бы с ними на первой же настройке.
|
||||||
#
|
#
|
||||||
# Обязательность проверяется там, где по ней принимают решение, — при взятии в
|
# Обязательность проверяется там, где по ней принимают решение, — командой
|
||||||
# спринт (и у задачи, уже стоящей в наборе). Раздел не из схемы даёт
|
# `ready` на входе в работу. Раздел не из схемы даёт
|
||||||
# **замечание**, а не ошибку: свой раздел в теле — законная вольность проекта,
|
# **замечание**, а не ошибку: свой раздел в теле — законная вольность проекта,
|
||||||
# а вот раздел, которого тип не предполагает, чаще всего означает, что тип
|
# а вот раздел, которого тип не предполагает, чаще всего означает, что тип
|
||||||
# проставлен не тот.
|
# проставлен не тот.
|
||||||
@@ -380,8 +371,7 @@ class Plan:
|
|||||||
Правило одно: **мутация сперва проверяет всё и складывает правки сюда, и
|
Правило одно: **мутация сперва проверяет всё и складывает правки сюда, и
|
||||||
только когда ни одна проверка не отказала, зовёт `commit`.** Иначе отказ на
|
только когда ни одна проверка не отказала, зовёт `commit`.** Иначе отказ на
|
||||||
втором слаге оставляет первый файл переписанным при нетронутых индексах —
|
втором слаге оставляет первый файл переписанным при нетронутых индексах —
|
||||||
задача числится в спринте и одновременно объясняет причиной, почему из него
|
задача числится закрытой и одновременно лежит строкой в беклоге.
|
||||||
вышла.
|
|
||||||
|
|
||||||
`commit` пишет в два такта: сперва все временные файлы (тут и происходит
|
`commit` пишет в два такта: сперва все временные файлы (тут и происходит
|
||||||
ввод-вывод, тут же и все возможные отказы), потом переименования подряд.
|
ввод-вывод, тут же и все возможные отказы), потом переименования подряд.
|
||||||
@@ -438,7 +428,7 @@ class Layout:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def indexes(self) -> tuple[str, ...]:
|
def indexes(self) -> tuple[str, ...]:
|
||||||
return ("backlog", "sprint", "roadmap")
|
return ("backlog", "roadmap")
|
||||||
|
|
||||||
|
|
||||||
def load_config(root: Path) -> dict:
|
def load_config(root: Path) -> dict:
|
||||||
@@ -538,7 +528,7 @@ def config_problems(lay: Layout) -> list[str]:
|
|||||||
out = []
|
out = []
|
||||||
if not lay.items.is_dir():
|
if not lay.items.is_dir():
|
||||||
out.append(f"{where}: items = «{lay.cfg['items']}» → {lay.items} — каталога нет")
|
out.append(f"{where}: items = «{lay.cfg['items']}» → {lay.items} — каталога нет")
|
||||||
for kind in ("backlog", "roadmap", "sprint", "rejected"):
|
for kind in ("backlog", "roadmap", "rejected"):
|
||||||
p = lay.index(kind)
|
p = lay.index(kind)
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
out.append(f"{where}: {kind} = «{lay.cfg[kind]}» → {p} — файла нет")
|
out.append(f"{where}: {kind} = «{lay.cfg[kind]}» → {p} — файла нет")
|
||||||
@@ -668,7 +658,7 @@ def spaced_sections(lines: list[str]) -> list[str]:
|
|||||||
def raw_last(lines: list[str], raw: set[str]) -> list[str]:
|
def raw_last(lines: list[str], raw: set[str]) -> list[str]:
|
||||||
"""Строки индекса, у которых сырьё снесено в конец своей секции.
|
"""Строки индекса, у которых сырьё снесено в конец своей секции.
|
||||||
|
|
||||||
Сырьё (`research` без раздела «Вопрос») в спринт не берётся, и стоя между
|
Сырьё (`research` без раздела «Вопрос») в работу не берётся, и стоя между
|
||||||
берущимися оно каждый раз требует открыть файл, чтобы это понять. Порядка
|
берущимися оно каждый раз требует открыть файл, чтобы это понять. Порядка
|
||||||
«по важности» в беклоге по-прежнему нет: этот порядок **производен от
|
«по важности» в беклоге по-прежнему нет: этот порядок **производен от
|
||||||
типа**, а не назначен человеком, — потому его и можно проверять машиной.
|
типа**, а не назначен человеком, — потому его и можно проверять машиной.
|
||||||
@@ -837,8 +827,7 @@ def tasks_of(lay: Layout) -> dict[str, dict]:
|
|||||||
|
|
||||||
|
|
||||||
def home_index(task: dict) -> str:
|
def home_index(task: dict) -> str:
|
||||||
"""Индекс, которому задача принадлежит по типу. Спринт — исключение: туда
|
"""Индекс, которому запись принадлежит по типу."""
|
||||||
задача попадает не по типу, а решением набора."""
|
|
||||||
return "roadmap" if task["type"] == GOAL else "backlog"
|
return "roadmap" if task["type"] == GOAL else "backlog"
|
||||||
|
|
||||||
|
|
||||||
@@ -935,7 +924,7 @@ SCHEMA_WHY = {
|
|||||||
" раздела ссылаются её задачи",
|
" раздела ссылаются её задачи",
|
||||||
"repro_heading": "расхождение, которое не воспроизводится, — это research,"
|
"repro_heading": "расхождение, которое не воспроизводится, — это research,"
|
||||||
" а не fix: чинить нечего, пока непонятно, что ломается",
|
" а не fix: чинить нечего, пока непонятно, что ломается",
|
||||||
"question_heading": "без вопроса это не разведка, а сырьё — в спринт не берётся",
|
"question_heading": "без вопроса это не разведка, а сырьё — в работу не берётся",
|
||||||
"answer_heading": "приёмка разведки — записанный ответ, и место ему"
|
"answer_heading": "приёмка разведки — записанный ответ, и место ему"
|
||||||
" (docs/research/, ADR, тело задачи) называется заранее,"
|
" (docs/research/, ADR, тело задачи) называется заранее,"
|
||||||
" иначе ответ останется в переписке",
|
" иначе ответ останется в переписке",
|
||||||
@@ -946,8 +935,8 @@ def place_key_of(rtype: str) -> str:
|
|||||||
"""Имя поля меты, называющего, где запись числится.
|
"""Имя поля меты, называющего, где запись числится.
|
||||||
|
|
||||||
У цели это «Секция» — часть роадмапа, то есть состояние очереди. У всех
|
У цели это «Секция» — часть роадмапа, то есть состояние очереди. У всех
|
||||||
прочих «Категория» — полка домена, в которую задача возвращается из
|
прочих «Категория» — полка домена, на которой задача лежит. Одно имя на два
|
||||||
спринта. Одно имя на два смысла и было конфляцией: секция роадмапа не
|
смысла и было конфляцией: секция роадмапа не
|
||||||
категория, а категория беклога не состояние очереди.
|
категория, а категория беклога не состояние очереди.
|
||||||
"""
|
"""
|
||||||
return "Секция" if rtype == GOAL else "Категория"
|
return "Секция" if rtype == GOAL else "Категория"
|
||||||
@@ -1010,70 +999,6 @@ def questions_open(lay: Layout, task: dict) -> bool:
|
|||||||
return bool(task["body"].get(lay.cfg["questions_heading"].lower()))
|
return bool(task["body"].get(lay.cfg["questions_heading"].lower()))
|
||||||
|
|
||||||
|
|
||||||
# --- Спринт ---
|
|
||||||
|
|
||||||
def sprint_goal(lay: Layout) -> tuple[str, str]:
|
|
||||||
"""Слаг и заголовок цели текущего спринта; ('', '') — цели нет.
|
|
||||||
|
|
||||||
Пустой ответ значит «цель не названа», а НЕ «спринта нет»: спринт бывает
|
|
||||||
без цели законно (`--no-goal`). Идёт ли спринт — отвечает `sprint_started`.
|
|
||||||
"""
|
|
||||||
for line in read_lines(lay.index("sprint")):
|
|
||||||
if (m := GOAL_LINE.match(line.strip())):
|
|
||||||
return Path(m.group(2)).stem, m.group(1)
|
|
||||||
return "", ""
|
|
||||||
|
|
||||||
|
|
||||||
def sprint_slug(lay: Layout) -> str:
|
|
||||||
"""Слаг самого спринта — им метится урожай (`sprint:<слаг>`)."""
|
|
||||||
for line in read_lines(lay.index("sprint")):
|
|
||||||
if (m := SPRINT_SLUG_LINE.search(line)):
|
|
||||||
return m.group(1)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def sprint_started(lay: Layout) -> bool:
|
|
||||||
"""Идёт ли спринт. Признак — слаг, а не цель: цели может не быть.
|
|
||||||
|
|
||||||
Слаг пишет `sprint start` и стирает `sprint close`, он есть у любого
|
|
||||||
спринта — без него не проставить `sprint:<слаг>`, то есть не собрать
|
|
||||||
урожай. Поэтому он и есть наблюдаемое «спринт открыт».
|
|
||||||
"""
|
|
||||||
return bool(sprint_slug(lay))
|
|
||||||
|
|
||||||
|
|
||||||
def sprint_header(lay: Layout, goal_slug: str, goal_title: str,
|
|
||||||
date: str, slug: str) -> list[str]:
|
|
||||||
"""Шапка спринта — мета-блок той же формы, что у задачи: поле на строку.
|
|
||||||
|
|
||||||
Прежняя форма (все три поля одной строкой через `·`) читается по-прежнему
|
|
||||||
— обе регулярки её берут, — но чинить её нечем и не нужно: `SPRINT.md`
|
|
||||||
переписывается целиком на `sprint start` и очищается на `sprint close`,
|
|
||||||
так что старая шапка живёт не дольше идущего спринта.
|
|
||||||
|
|
||||||
Спринт без цели пишет ту же строку прозой, без ссылки: поле остаётся на
|
|
||||||
месте, читатель видит решение, а `GOAL_LINE` его не берёт — «цель не
|
|
||||||
названа» и «цель потерялась» этим и различаются.
|
|
||||||
"""
|
|
||||||
goal_line = (f"- **Цель:** [{goal_title}]({lay.cfg['items']}/{goal_slug}.md)"
|
|
||||||
if goal_slug else "- **Цель:** не названа — набор без цели")
|
|
||||||
return ["# Спринт", "",
|
|
||||||
goal_line,
|
|
||||||
f"- **Начат:** {date}",
|
|
||||||
f"- **Спринт:** `{slug}`", "",
|
|
||||||
f"Урожай спринта поднимается `tasks.py list --tag {SPRINT_TAG}{slug}`"
|
|
||||||
" — это первая порция переоценки на сессии.", ""]
|
|
||||||
|
|
||||||
|
|
||||||
def empty_sprint(lay: Layout) -> str:
|
|
||||||
return ("# Спринт\n\n"
|
|
||||||
"Спринта нет. Цель называет человек, набор собирает агент:\n"
|
|
||||||
"`tasks.py sprint start --goal <слаг>`. Набор без цели —"
|
|
||||||
" `sprint start --no-goal`\n(багфикс, техдолг, здоровье:"
|
|
||||||
" работа на работоспособность, а не на направление).\n\n"
|
|
||||||
f"## {lay.cfg['sprint_section']}\n")
|
|
||||||
|
|
||||||
|
|
||||||
# --- check ---
|
# --- check ---
|
||||||
|
|
||||||
def check(lay: Layout, fix: bool = False) -> int:
|
def check(lay: Layout, fix: bool = False) -> int:
|
||||||
@@ -1107,7 +1032,6 @@ def check(lay: Layout, fix: bool = False) -> int:
|
|||||||
known = {k: {s.lower() for s in sections[k]} for k in lay.indexes}
|
known = {k: {s.lower() for s in sections[k]} for k in lay.indexes}
|
||||||
goal_slugs = {p[:-3] for p, t in tasks.items() if t["type"] == GOAL}
|
goal_slugs = {p[:-3] for p, t in tasks.items() if t["type"] == GOAL}
|
||||||
raw_names = {n for n, t in tasks.items() if raw_research(lay, t)}
|
raw_names = {n for n, t in tasks.items() if raw_research(lay, t)}
|
||||||
goal_of_sprint, _ = sprint_goal(lay)
|
|
||||||
|
|
||||||
for s in sections["backlog"]:
|
for s in sections["backlog"]:
|
||||||
if s.lower() in BLOCKER_SECTIONS:
|
if s.lower() in BLOCKER_SECTIONS:
|
||||||
@@ -1125,13 +1049,13 @@ def check(lay: Layout, fix: bool = False) -> int:
|
|||||||
errors.append(f"{name}: нет заголовка H1")
|
errors.append(f"{name}: нет заголовка H1")
|
||||||
|
|
||||||
# 0. Тип — единственная ось, и от него зависит всё остальное: схема
|
# 0. Тип — единственная ось, и от него зависит всё остальное: схема
|
||||||
# тела, дом строки, имя поля меты, право на взятие в спринт.
|
# тела, дом строки, имя поля меты, право на взятие в работу.
|
||||||
# Пропуск — замечание: записи, заведённые до появления типа,
|
# Пропуск — замечание: записи, заведённые до появления типа,
|
||||||
# законны, и переоформлять беклог «заодно» здесь не просят.
|
# законны, и переоформлять беклог «заодно» здесь не просят.
|
||||||
# Обязательным тип становится там, где по нему принимают решение.
|
# Обязательным тип становится там, где по нему принимают решение.
|
||||||
if not task["type"]:
|
if not task["type"]:
|
||||||
notes.append(f"{name}: тип не назван — `tasks.py edit {name[:-3]}"
|
notes.append(f"{name}: тип не назван — `tasks.py edit {name[:-3]}"
|
||||||
f" --type {'|'.join(TYPES)}`; в спринт без него не возьмут")
|
f" --type {'|'.join(TYPES)}`; в работу без него не возьмут")
|
||||||
elif task["type"] not in TYPES:
|
elif task["type"] not in TYPES:
|
||||||
errors.append(f"{name}: тип «{task['type']}» вне словаря"
|
errors.append(f"{name}: тип «{task['type']}» вне словаря"
|
||||||
f" ({', '.join(TYPES)}) — словарь закрыт, иначе отбор"
|
f" ({', '.join(TYPES)}) — словарь закрыт, иначе отбор"
|
||||||
@@ -1157,7 +1081,7 @@ def check(lay: Layout, fix: bool = False) -> int:
|
|||||||
f" поставит `check --fix`")
|
f" поставит `check --fix`")
|
||||||
|
|
||||||
# 1. Задача живёт ровно в одном индексе за раз.
|
# 1. Задача живёт ровно в одном индексе за раз.
|
||||||
allowed = {home} | ({"sprint"} if home == "backlog" else set())
|
allowed = {home}
|
||||||
if not where:
|
if not where:
|
||||||
errors.append(f"{name}: нет ни в одном индексе (ожидался {label[home]})")
|
errors.append(f"{name}: нет ни в одном индексе (ожидался {label[home]})")
|
||||||
elif len(where) > 1:
|
elif len(where) > 1:
|
||||||
@@ -1171,8 +1095,7 @@ def check(lay: Layout, fix: bool = False) -> int:
|
|||||||
place = where[0] if len(where) == 1 else None
|
place = where[0] if len(where) == 1 else None
|
||||||
entry = entries[place][name] if place else None
|
entry = entries[place][name] if place else None
|
||||||
|
|
||||||
# 2. Секция файла — истина, секция индекса производна. В спринте секция
|
# 2. Секция файла — истина, секция индекса производна.
|
||||||
# файла хранит, куда задачу вернуть, и с «Набором» не сверяется.
|
|
||||||
if task["legacy_meta"]:
|
if task["legacy_meta"]:
|
||||||
errors.append(f"{name}: мета одной строкой — старая форма;"
|
errors.append(f"{name}: мета одной строкой — старая форма;"
|
||||||
f" `check --fix` перепишет её списком")
|
f" `check --fix` перепишет её списком")
|
||||||
@@ -1187,15 +1110,14 @@ def check(lay: Layout, fix: bool = False) -> int:
|
|||||||
and task["place_key"] != want_key.lower():
|
and task["place_key"] != want_key.lower():
|
||||||
errors.append(f"{name}: поле меты названо «{task['place_key']}», а у типа"
|
errors.append(f"{name}: поле меты названо «{task['place_key']}», а у типа"
|
||||||
f" «{task['type']}» оно «{want_key}»: у цели это часть"
|
f" «{task['type']}» оно «{want_key}»: у цели это часть"
|
||||||
f" роадмапа (состояние очереди), у задачи — полка домена,"
|
f" роадмапа (состояние очереди), у задачи — полка домена."
|
||||||
f" в которую она возвращается из спринта."
|
|
||||||
f" Переименует `check --fix`")
|
f" Переименует `check --fix`")
|
||||||
if not task["section"]:
|
if not task["section"]:
|
||||||
errors.append(f"{name}: нет поля **{want_key}:** в мета-блоке")
|
errors.append(f"{name}: нет поля **{want_key}:** в мета-блоке")
|
||||||
elif task["section"] not in known[home]:
|
elif task["section"] not in known[home]:
|
||||||
errors.append(f"{name}: секция «{task['section']}» не совпадает ни с одной"
|
errors.append(f"{name}: секция «{task['section']}» не совпадает ни с одной"
|
||||||
f" секцией {label[home]} ({', '.join(sections[home])})")
|
f" секцией {label[home]} ({', '.join(sections[home])})")
|
||||||
elif place is not None and place != "sprint" and entry and entry["section"] \
|
elif place is not None and entry and entry["section"] \
|
||||||
and entry["section"].lower() != task["section"]:
|
and entry["section"].lower() != task["section"]:
|
||||||
errors.append(f"{name}: секция в файле «{task['section']}»,"
|
errors.append(f"{name}: секция в файле «{task['section']}»,"
|
||||||
f" а в {label[place]} — «{entry['section']}»")
|
f" а в {label[place]} — «{entry['section']}»")
|
||||||
@@ -1233,38 +1155,13 @@ def check(lay: Layout, fix: bool = False) -> int:
|
|||||||
errors.append(f"{name}: тег {GOAL_TAG}{task['goal']} указывает на цель,"
|
errors.append(f"{name}: тег {GOAL_TAG}{task['goal']} указывает на цель,"
|
||||||
f" которой нет в {lay.cfg['items']}/")
|
f" которой нет в {lay.cfg['items']}/")
|
||||||
|
|
||||||
# 3а. Схема тела вне спринта — только замечанием: обязательным раздел
|
# 3а. Схема тела — только замечанием: обязательным раздел становится
|
||||||
# становится там, где по нему принимают решение (`sprint take`).
|
# там, где по нему принимают решение, а решение это `ready` на входе
|
||||||
# Лишний раздел говорит о неверном типе, и сказать об этом стоит
|
# в работу. Лишний раздел говорит о неверном типе, и сказать об этом
|
||||||
# сразу, не дожидаясь набора.
|
# стоит сразу, не дожидаясь взятия.
|
||||||
if task["type"] in TYPES:
|
if task["type"] in TYPES:
|
||||||
notes += schema_verdict(lay, task)[1]
|
notes += schema_verdict(lay, task)[1]
|
||||||
|
|
||||||
# 4. Спринт: задача с открытым вопросом в набор не берётся, и судит об
|
|
||||||
# этом раздел, а не тег.
|
|
||||||
if place == "sprint":
|
|
||||||
if questions_open(lay, task):
|
|
||||||
errors.append(f"{name}: в спринте с непустым разделом"
|
|
||||||
f" «{lay.cfg['questions_heading']}» — вопрос разбирается"
|
|
||||||
f" до взятия")
|
|
||||||
elif QUESTION_TAG in task["tags"]:
|
|
||||||
errors.append(f"{name}: в спринте с тегом «{QUESTION_TAG}» —"
|
|
||||||
f" либо вопрос открыт и задача выходит, либо тег снимается")
|
|
||||||
if task["type"] and task["type"] not in TAKEABLE:
|
|
||||||
errors.append(f"{name}: в спринте тип «{task['type']}»"
|
|
||||||
f" — цель не берут вовсе, её берут её задачи")
|
|
||||||
# Спринт без цели ничьей цели и не противоречит: проверять нечему,
|
|
||||||
# набор там собран по другому признаку (работоспособность).
|
|
||||||
if goal_of_sprint and task["goal"] and task["goal"] != goal_of_sprint:
|
|
||||||
errors.append(f"{name}: цель задачи «{task['goal']}» не цель спринта"
|
|
||||||
f" «{goal_of_sprint}» — набор служит одной цели")
|
|
||||||
if not task["type"]:
|
|
||||||
errors.append(f"{name}: в спринте без типа"
|
|
||||||
f" (`edit {name[:-3]} --type {'|'.join(TAKEABLE)}`)")
|
|
||||||
# Замечания схемы уже собраны выше — здесь берутся только отказы,
|
|
||||||
# иначе один и тот же лишний раздел печатался бы дважды.
|
|
||||||
errors += [f"{x} (в спринте)" for x in schema_verdict(lay, task)[0]]
|
|
||||||
|
|
||||||
# 5. Тег «question» производен от раздела: раздел — факт, тег — метка.
|
# 5. Тег «question» производен от раздела: раздел — факт, тег — метка.
|
||||||
has_q_section = questions_open(lay, task)
|
has_q_section = questions_open(lay, task)
|
||||||
if has_q_section and QUESTION_TAG not in task["tags"]:
|
if has_q_section and QUESTION_TAG not in task["tags"]:
|
||||||
@@ -1304,22 +1201,6 @@ def check(lay: Layout, fix: bool = False) -> int:
|
|||||||
f" секции — его не берут, и между берущимся оно требует"
|
f" секции — его не берут, и между берущимся оно требует"
|
||||||
f" открыть файл, чтобы это понять; переставит `check --fix`")
|
f" открыть файл, чтобы это понять; переставит `check --fix`")
|
||||||
|
|
||||||
if goal_of_sprint and goal_of_sprint + ".md" not in tasks:
|
|
||||||
errors.append(f"{label['sprint']}: цель «{goal_of_sprint}» не найдена"
|
|
||||||
f" в {lay.cfg['items']}/")
|
|
||||||
# Спринт без цели законен (`sprint start --no-goal`), и по цели о том, идёт
|
|
||||||
# ли он, судить нельзя. Судим по слагу: он есть у любого спринта, потому
|
|
||||||
# что без него не проставить `sprint:<слаг>` и не собрать урожай.
|
|
||||||
if entries["sprint"] and not sprint_started(lay):
|
|
||||||
errors.append(f"{label['sprint']}: набор есть, а шапки спринта нет"
|
|
||||||
f" (- **Спринт:** `…`) — урожай не отобрать, а «спринт идёт»"
|
|
||||||
f" не отличить от «строки остались от прошлого»;"
|
|
||||||
f" перезапусти `sprint start`")
|
|
||||||
elif goal_of_sprint and not sprint_started(lay):
|
|
||||||
errors.append(f"{label['sprint']}: цель названа, а слага спринта нет"
|
|
||||||
f" (- **Спринт:** `…`) — урожай не отобрать;"
|
|
||||||
f" перезапусти `sprint start`")
|
|
||||||
|
|
||||||
rejected = lay.index("rejected")
|
rejected = lay.index("rejected")
|
||||||
if rejected.is_file():
|
if rejected.is_file():
|
||||||
for num, line in enumerate(read_lines(rejected), 1):
|
for num, line in enumerate(read_lines(rejected), 1):
|
||||||
@@ -1352,7 +1233,7 @@ def check(lay: Layout, fix: bool = False) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None:
|
def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None:
|
||||||
"""Метрики здоровья: размер секций, спринт, открытые вопросы, залежалость,
|
"""Метрики здоровья: размер секций, открытые вопросы, залежалость,
|
||||||
цели без задач. Механизирует то, что иначе держится на дисциплине."""
|
цели без задач. Механизирует то, что иначе держится на дисциплине."""
|
||||||
backlog = [t for n, t in tasks.items() if n in entries["backlog"]]
|
backlog = [t for n, t in tasks.items() if n in entries["backlog"]]
|
||||||
by_section = {s.lower(): 0 for s in sections["backlog"]}
|
by_section = {s.lower(): 0 for s in sections["backlog"]}
|
||||||
@@ -1371,9 +1252,9 @@ def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None:
|
|||||||
if k in by_type)
|
if k in by_type)
|
||||||
+ (f" (сырьём, без «{lay.cfg['question_heading']}», {raw})" if raw else ""))
|
+ (f" (сырьём, без «{lay.cfg['question_heading']}», {raw})" if raw else ""))
|
||||||
|
|
||||||
# Готовность к взятию — та же проверка, что откажет `sprint take`. Число, а
|
# Готовность к взятию — та же проверка, что откажет `ready`. Число, а не
|
||||||
# не перечень: оно отвечает на «есть ли из чего собрать спринт», и когда
|
# перечень: оно отвечает на «есть ли что брать сегодня», и когда ответ
|
||||||
# ответ «нет», разбирать надо не список, а порцию переоценки.
|
# «нет», разбирать надо не список, а порцию груминга.
|
||||||
if backlog:
|
if backlog:
|
||||||
ready = [t for t in backlog
|
ready = [t for t in backlog
|
||||||
if t["type"] in TAKEABLE and not questions_open(lay, t)
|
if t["type"] in TAKEABLE and not questions_open(lay, t)
|
||||||
@@ -1384,18 +1265,10 @@ def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None:
|
|||||||
else " — прочим не хватает разделов своего типа, цели или ждут"
|
else " — прочим не хватает разделов своего типа, цели или ждут"
|
||||||
" ответа на вопрос"))
|
" ответа на вопрос"))
|
||||||
|
|
||||||
goal_slug, _ = sprint_goal(lay)
|
|
||||||
if sprint_started(lay):
|
|
||||||
goal_part = f"цель «{goal_slug}»" if goal_slug else "без цели"
|
|
||||||
print(f" спринт: {goal_part}, слаг «{sprint_slug(lay)}»,"
|
|
||||||
f" задач {len(entries['sprint'])}")
|
|
||||||
else:
|
|
||||||
print(" спринт: не начат")
|
|
||||||
|
|
||||||
questions = [n for n, t in tasks.items() if questions_open(lay, t)]
|
questions = [n for n, t in tasks.items() if questions_open(lay, t)]
|
||||||
if questions:
|
if questions:
|
||||||
print(f" с открытым вопросом: {len(questions)}"
|
print(f" с открытым вопросом: {len(questions)}"
|
||||||
f" — в спринт не берутся, разбор первым шагом сессии")
|
f" — в работу не берутся, разбор первым шагом груминга")
|
||||||
|
|
||||||
# Форма заголовка — счётчиком, а не замечанием на файл. Правило верное, но
|
# Форма заголовка — счётчиком, а не замечанием на файл. Правило верное, но
|
||||||
# проверка эвристическая, а беклог, заведённый до правила, переоформляют не
|
# проверка эвристическая, а беклог, заведённый до правила, переоформляют не
|
||||||
@@ -1505,7 +1378,7 @@ def build_meta(rtype: str, section: str, reason: str, why: str, tags: list[str])
|
|||||||
"""Мета-блок: тип первой строкой, дальше место, «зачем» и теги.
|
"""Мета-блок: тип первой строкой, дальше место, «зачем» и теги.
|
||||||
|
|
||||||
Тип стоит первым не для красоты: он решает, что у записи вообще может быть
|
Тип стоит первым не для красоты: он решает, что у записи вообще может быть
|
||||||
— какие разделы обязательны, нужна ли цель, берётся ли она в спринт, — и
|
— какие разделы обязательны, нужна ли цель, берётся ли она в работу, — и
|
||||||
читается раньше всего остального.
|
читается раньше всего остального.
|
||||||
"""
|
"""
|
||||||
out = [f"- **Тип:** {rtype}"] if rtype else []
|
out = [f"- **Тип:** {rtype}"] if rtype else []
|
||||||
@@ -1738,7 +1611,7 @@ SECTION_HINT = {
|
|||||||
" видно вместо ожидаемого. Не воспроизводится — это research,"
|
" видно вместо ожидаемого. Не воспроизводится — это research,"
|
||||||
" а не fix",
|
" а не fix",
|
||||||
"question_heading": "вопрос, на который отвечает эта разведка, — одной фразой."
|
"question_heading": "вопрос, на который отвечает эта разведка, — одной фразой."
|
||||||
" Пока его нет, это сырьё: в спринт не берут",
|
" Пока его нет, это сырьё: в работу не берут",
|
||||||
"answer_heading": "куда ляжет ответ: docs/research/<тема>.md, ADR, тело этой"
|
"answer_heading": "куда ляжет ответ: docs/research/<тема>.md, ADR, тело этой"
|
||||||
" задачи. Приёмка разведки — записанный ответ, а не"
|
" задачи. Приёмка разведки — записанный ответ, а не"
|
||||||
" изменённый код",
|
" изменённый код",
|
||||||
@@ -1760,7 +1633,7 @@ def body_template(rtype: str, lay: Layout) -> str:
|
|||||||
"""Шаблон тела по схеме типа: обязательные разделы плюс «Рамки».
|
"""Шаблон тела по схеме типа: обязательные разделы плюс «Рамки».
|
||||||
|
|
||||||
Шаблон и проверка растут из одного TYPE_SCHEMA: разойтись им нельзя, иначе
|
Шаблон и проверка растут из одного TYPE_SCHEMA: разойтись им нельзя, иначе
|
||||||
`add` кладёт то, на чём `sprint take` потом откажет.
|
`add` кладёт то, на чём `ready` потом откажет.
|
||||||
"""
|
"""
|
||||||
schema = TYPE_SCHEMA.get(rtype, TYPE_SCHEMA["feature"])
|
schema = TYPE_SCHEMA.get(rtype, TYPE_SCHEMA["feature"])
|
||||||
out = [f"<!-- {BODY_LEAD.get(rtype, BODY_LEAD['feature'])} -->"]
|
out = [f"<!-- {BODY_LEAD.get(rtype, BODY_LEAD['feature'])} -->"]
|
||||||
@@ -1809,11 +1682,6 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
if rtype in NEEDS_GOAL and not any(t.startswith(GOAL_TAG) for t in tags):
|
if rtype in NEEDS_GOAL and not any(t.startswith(GOAL_TAG) for t in tags):
|
||||||
print(f" тип «{rtype}» без цели: новая возможность и есть содержание"
|
print(f" тип «{rtype}» без цели: новая возможность и есть содержание"
|
||||||
f" цели — проставь `tasks.py edit {a.slug} --goal <слаг>`")
|
f" цели — проставь `tasks.py edit {a.slug} --goal <слаг>`")
|
||||||
# Урожай спринта метится сам: тег, который никто не ставит, не отбирает
|
|
||||||
# первую порцию переоценки, а именно на ней держится правило «сперва урожай».
|
|
||||||
sslug = sprint_slug(lay)
|
|
||||||
if rtype != GOAL and sslug and not any(t.startswith(SPRINT_TAG) for t in tags):
|
|
||||||
tags.append(f"{SPRINT_TAG}{sslug}")
|
|
||||||
if QUESTION_TAG in tags:
|
if QUESTION_TAG in tags:
|
||||||
print(f" тег «{QUESTION_TAG}»: не забудь раздел «{lay.cfg['questions_heading']}» в теле")
|
print(f" тег «{QUESTION_TAG}»: не забудь раздел «{lay.cfg['questions_heading']}» в теле")
|
||||||
|
|
||||||
@@ -1836,8 +1704,6 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
print(f"создано: {lay.cfg['items']}/{a.slug}.md,"
|
print(f"создано: {lay.cfg['items']}/{a.slug}.md,"
|
||||||
f" строка в {lay.name(target)} (секция «{section}»); допиши тело редактором")
|
f" строка в {lay.name(target)} (секция «{section}»); допиши тело редактором")
|
||||||
if sslug and f"{SPRINT_TAG}{sslug}" in tags:
|
|
||||||
print(f" помечено тегом {SPRINT_TAG}{sslug} — урожай идущего спринта")
|
|
||||||
if not a.why:
|
if not a.why:
|
||||||
print(f" без «зачем» — задай: tasks.py edit {a.slug} --why …")
|
print(f" без «зачем» — задай: tasks.py edit {a.slug} --why …")
|
||||||
warn_rejected(lay, a.slug, a.title)
|
warn_rejected(lay, a.slug, a.title)
|
||||||
@@ -1883,14 +1749,6 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
bare = a.title if a.title is not None else task["bare"]
|
bare = a.title if a.title is not None else task["bare"]
|
||||||
rtype = task["type"] if a.type is None else a.type.strip().lower()
|
rtype = task["type"] if a.type is None else a.type.strip().lower()
|
||||||
old_home, new_home = home_index(task), home_index({"type": rtype})
|
old_home, new_home = home_index(task), home_index({"type": rtype})
|
||||||
in_sprint = "sprint" in places
|
|
||||||
|
|
||||||
# Задача в спринте меняет тип только через выход из набора: цель в наборе —
|
|
||||||
# состояние, которое check объявит ошибкой, а молчаливый успех оставит
|
|
||||||
# набор в нём.
|
|
||||||
if in_sprint and rtype not in TAKEABLE:
|
|
||||||
raise Usage(f"{a.slug} в спринте, а тип «{rtype}» в наборе не живёт:"
|
|
||||||
f" сперва выведи задачу — tasks.py sprint drop {a.slug} --reason …")
|
|
||||||
|
|
||||||
h1 = h1_of(rtype, bare)
|
h1 = h1_of(rtype, bare)
|
||||||
|
|
||||||
@@ -1986,9 +1844,9 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
if old_home != new_home:
|
if old_home != new_home:
|
||||||
print(f" строка переехала: {lay.name(old_home)} → {lay.name(new_home)}"
|
print(f" строка переехала: {lay.name(old_home)} → {lay.name(new_home)}"
|
||||||
f" (секция «{section}»)")
|
f" (секция «{section}»)")
|
||||||
if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"] and in_sprint:
|
if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"]:
|
||||||
print(" задача в спринте, а вопрос открыт: либо остаток есть и вопрос ждёт сессии,"
|
print(f" вопрос открыт — в работу задача не берётся, пока он не разобран"
|
||||||
f" либо задача выходит — `tasks.py sprint drop {a.slug} --reason …`")
|
f" (`tasks.py ready {a.slug}` это и скажет)")
|
||||||
return EXIT_OK
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
@@ -2002,9 +1860,6 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
places = locate_all(lay, a.slug)
|
places = locate_all(lay, a.slug)
|
||||||
if not places:
|
if not places:
|
||||||
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
|
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
|
||||||
if "sprint" in places:
|
|
||||||
raise Usage(f"{a.slug} в спринте: секция — свойство беклога."
|
|
||||||
f" Сперва верни задачу: tasks.py sprint drop {a.slug} --reason …")
|
|
||||||
if len(places) > 1:
|
if len(places) > 1:
|
||||||
raise Usage(f"{a.slug} сразу в нескольких индексах"
|
raise Usage(f"{a.slug} сразу в нескольких индексах"
|
||||||
f" ({', '.join(lay.name(k) for k in places)}) — неоднозначно,"
|
f" ({', '.join(lay.name(k) for k in places)}) — неоднозначно,"
|
||||||
@@ -2122,8 +1977,6 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
f" {lay.name('roadmap')}, секция «{where}», файл удалён")
|
f" {lay.name('roadmap')}, секция «{where}», файл удалён")
|
||||||
else:
|
else:
|
||||||
print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}")
|
print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}")
|
||||||
if "sprint" in places and a.reason:
|
|
||||||
print(" задача закрыта прямо из спринта без реализации — назови это в докладе спринта")
|
|
||||||
if not a.reason:
|
if not a.reason:
|
||||||
print(" дорога назад: файл восстанавливается из git —"
|
print(" дорога назад: файл восстанавливается из git —"
|
||||||
f" `tasks.py reopen {a.slug} --reason «приёмка не сошлась: …»`")
|
f" `tasks.py reopen {a.slug} --reason «приёмка не сошлась: …»`")
|
||||||
@@ -2185,15 +2038,8 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
plan = Plan()
|
plan = Plan()
|
||||||
plan.file(path, text)
|
plan.file(path, text)
|
||||||
|
|
||||||
goal_of_sprint, _ = sprint_goal(lay)
|
|
||||||
target = home_index({"type": rtype})
|
target = home_index({"type": rtype})
|
||||||
section = tmp["section"]
|
section = tmp["section"]
|
||||||
# Возврат идёт в набор идущего спринта — тем же тестом, что и `sprint take`:
|
|
||||||
# мешает только чужая цель. Спринт без цели не мешает ничем, и задача без
|
|
||||||
# цели не мешает спринту с целью. Спринта нет — возврат в беклог.
|
|
||||||
fits_sprint = not (goal_of_sprint and tmp["goal"] and tmp["goal"] != goal_of_sprint)
|
|
||||||
if target == "backlog" and sprint_started(lay) and fits_sprint:
|
|
||||||
target, section = "sprint", lay.cfg["sprint_section"]
|
|
||||||
lines = read_lines(lay.index(target))
|
lines = read_lines(lay.index(target))
|
||||||
# Строка достигнутого снимается ДО вставки и на том же списке: иначе вторая
|
# Строка достигнутого снимается ДО вставки и на том же списке: иначе вторая
|
||||||
# правка читает индекс с диска, где первой ещё нет, и затирает её.
|
# правка читает индекс с диска, где первой ещё нет, и затирает её.
|
||||||
@@ -2230,8 +2076,12 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
removed = []
|
removed = []
|
||||||
plan.commit()
|
plan.commit()
|
||||||
|
|
||||||
|
# Возвращённая строка встаёт в конец своей секции: позиция это приоритет,
|
||||||
|
# а его назначает человек. Молча вернуть задачу наверх очереди значило бы
|
||||||
|
# принять за него решение, которого он не принимал.
|
||||||
print(f"{a.slug}: возвращён в {lay.name(target)} из истории git"
|
print(f"{a.slug}: возвращён в {lay.name(target)} из истории git"
|
||||||
+ (f" (секция «{section}»)" if target != "sprint" else " (набор спринта)"))
|
f" (секция «{section}», в конец: позиция это приоритет, и её"
|
||||||
|
f" назначает человек)")
|
||||||
for line in removed:
|
for line in removed:
|
||||||
print(f" снята строка {lay.name('rejected')}: {line.strip()}")
|
print(f" снята строка {lay.name('rejected')}: {line.strip()}")
|
||||||
for line in unachieved:
|
for line in unachieved:
|
||||||
@@ -2259,83 +2109,21 @@ def meta_updated_text(text: str, reason: str, rtype: str | None = None) -> str |
|
|||||||
return None if out is None else "\n".join(out) + "\n"
|
return None if out is None else "\n".join(out) + "\n"
|
||||||
|
|
||||||
|
|
||||||
# --- Спринт ---
|
# --- Готовность записи к работе ---
|
||||||
|
|
||||||
def cmd_sprint_start(lay: Layout, a: argparse.Namespace) -> int:
|
def cmd_ready(lay: Layout, a: argparse.Namespace) -> int:
|
||||||
# Цель либо названа, либо явно не названа. Голое отсутствие `--goal` не
|
"""Схема типа выполнена — запись можно брать в работу.
|
||||||
# проходит намеренно: забытый флаг и решение «этот спринт без цели» иначе
|
|
||||||
# неразличимы, а второе — продуктовое решение человека.
|
|
||||||
if not a.goal and not a.no_goal:
|
|
||||||
raise Usage("цель спринта: --goal <слаг> или явно --no-goal."
|
|
||||||
" Спринт без цели законен (багфикс, техдолг, здоровье),"
|
|
||||||
" но называется решением, а не пропуском флага")
|
|
||||||
if a.goal and (err := bad_slug(a.goal)):
|
|
||||||
raise Usage(err)
|
|
||||||
entries, _ = parse_entries(read_lines(lay.index("sprint")))
|
|
||||||
if entries:
|
|
||||||
raise Usage(f"в {lay.name('sprint')} ещё есть набор ({len(entries)}) —"
|
|
||||||
f" закрой спринт: tasks.py sprint close")
|
|
||||||
goal_title = ""
|
|
||||||
if a.goal:
|
|
||||||
gpath = lay.items / f"{a.goal}.md"
|
|
||||||
if not gpath.exists():
|
|
||||||
raise Usage(f"цели {a.goal}.md нет в {lay.cfg['items']}/")
|
|
||||||
goal = parse_task(gpath)
|
|
||||||
if goal["type"] != GOAL:
|
|
||||||
raise Usage(f"{a.goal} не цель (тип «{goal['type'] or '—'}») —"
|
|
||||||
f" спринт набирается под одну цель")
|
|
||||||
goal_title = goal["title"]
|
|
||||||
date = a.date or datetime.date.today().isoformat()
|
|
||||||
if not DATE_RE.fullmatch(date):
|
|
||||||
raise Usage("дата в формате ГГГГ-ММ-ДД")
|
|
||||||
# Слаг спринта — дата: естественный, монотонный и уже уникальный. Он нужен
|
|
||||||
# не для красоты: без него тег `sprint:<слаг>` некому проставить, а на нём
|
|
||||||
# держится правило «первая порция переоценки — урожай прошедшего спринта».
|
|
||||||
slug = (a.slug or date).lower()
|
|
||||||
if not re.fullmatch(r"[a-z0-9][a-z0-9.-]*", slug):
|
|
||||||
raise Usage(f"слаг спринта «{slug}» — латиница, цифры, дефис и точка")
|
|
||||||
tasks = tasks_of(lay)
|
|
||||||
if any(f"{SPRINT_TAG}{slug}" in t["tags"] for t in tasks.values()):
|
|
||||||
print(f" внимание: тег {SPRINT_TAG}{slug} уже стоит на задачах прошлого спринта"
|
|
||||||
f" — урожаи склеятся; задай другой `--slug`")
|
|
||||||
lines = [*sprint_header(lay, a.goal or "", goal_title, date, slug),
|
|
||||||
f"## {lay.cfg['sprint_section']}", ""]
|
|
||||||
plan = Plan()
|
|
||||||
plan.index(lay, "sprint", lines)
|
|
||||||
plan.commit()
|
|
||||||
ready = [n[:-3] for n, t in tasks.items()
|
|
||||||
if t["type"] in TAKEABLE
|
|
||||||
and (t["goal"] == a.goal if a.goal
|
|
||||||
else not (t["type"] in NEEDS_GOAL and not t["goal"]))
|
|
||||||
and not questions_open(lay, t) and not schema_verdict(lay, t)[0]]
|
|
||||||
if a.goal:
|
|
||||||
print(f"спринт начат: цель «{a.goal}», {date}, слаг «{slug}»")
|
|
||||||
print(f" кандидатов под цель без открытых вопросов: {len(ready)}"
|
|
||||||
+ (f" ({', '.join(sorted(ready))})" if ready else ""))
|
|
||||||
else:
|
|
||||||
print(f"спринт начат: без цели, {date}, слаг «{slug}»")
|
|
||||||
print(f" набор без цели: цель не проверяется, в него идёт что угодно"
|
|
||||||
f" из готового к взятию ({len(ready)})."
|
|
||||||
f" Перечень — `tasks.py list --index backlog`")
|
|
||||||
print(" докладу это не безразлично: спринт без цели называется таковым"
|
|
||||||
" и объясняется (багфикс, техдолг, здоровье)")
|
|
||||||
print(f" заводимое по ходу метится тегом {SPRINT_TAG}{slug} само — это урожай")
|
|
||||||
print(" набери: tasks.py sprint take <слаг> …; набор показывается человеку до старта")
|
|
||||||
return EXIT_OK
|
|
||||||
|
|
||||||
|
Прежде этот гейт стоял на взятии задачи в спринт: набор и был моментом,
|
||||||
|
когда запись впервые судили целиком. Спринтов нет, а момент нужен — иначе задача
|
||||||
|
уезжает в работу без критериев приёмки, и узнают об этом на приёмке, когда
|
||||||
|
сверять уже не с чем. Теперь момент называет тот, кто берёт: скилл решения
|
||||||
|
задачи зовёт `ready` первым делом.
|
||||||
|
|
||||||
def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int:
|
Отказ здесь — **рабочая ситуация**, а не ошибка употребления: запись просто
|
||||||
goal_slug, _ = sprint_goal(lay)
|
ещё не дописана. Поэтому код 1, а не 2, и ветвиться на них надо по-разному.
|
||||||
# Начат ли спринт, судим по слагу, а не по цели: спринт без цели идёт так же.
|
"""
|
||||||
if not sprint_started(lay):
|
verdicts, warn = [], []
|
||||||
raise Usage("спринт не начат:"
|
|
||||||
" tasks.py sprint start (--goal <слаг> | --no-goal)")
|
|
||||||
sprint_lines = read_lines(lay.index("sprint"))
|
|
||||||
backlog_lines = read_lines(lay.index("backlog"))
|
|
||||||
hi, section = find_section(sprint_lines, lay.cfg["sprint_section"])
|
|
||||||
if hi is None:
|
|
||||||
raise Usage(f"в {lay.name('sprint')} нет секции «{lay.cfg['sprint_section']}»")
|
|
||||||
taken, warn = [], []
|
|
||||||
for slug in a.slugs:
|
for slug in a.slugs:
|
||||||
if (err := bad_slug(slug)):
|
if (err := bad_slug(slug)):
|
||||||
raise Usage(err)
|
raise Usage(err)
|
||||||
@@ -2343,133 +2131,50 @@ def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int:
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise Usage(f"{slug}.md не найден в {lay.cfg['items']}/")
|
raise Usage(f"{slug}.md не найден в {lay.cfg['items']}/")
|
||||||
t = parse_task(path)
|
t = parse_task(path)
|
||||||
|
errs = []
|
||||||
if not t["type"]:
|
if not t["type"]:
|
||||||
raise Usage(f"{slug}: тип не назван —"
|
errs.append(f"тип не назван — `edit {slug} --type {'|'.join(TAKEABLE)}`."
|
||||||
f" `edit {slug} --type {'|'.join(TAKEABLE)}`."
|
f" Тип решает, каких разделов запись обязана иметь,"
|
||||||
f" Тип решает, каких разделов задача обязана иметь,"
|
|
||||||
f" и без него проверять нечего")
|
f" и без него проверять нечего")
|
||||||
if t["type"] not in TAKEABLE:
|
elif t["type"] not in TAKEABLE:
|
||||||
raise Usage(f"{slug}: тип «{t['type']}» в спринт не берётся —"
|
errs.append(f"тип «{t['type']}» в работу не берётся —"
|
||||||
f" цель не берут вовсе, берут её задачи")
|
f" цель не берут вовсе, берут её задачи")
|
||||||
# Цель сверяется, только когда она у спринта есть. Спринт без цели
|
else:
|
||||||
# берёт что угодно готовое: он собран по работоспособности, и чужой
|
if not t["goal"] and t["type"] in NEEDS_GOAL:
|
||||||
# цели там нет — не с чем расходиться.
|
errs.append(f"тип «{t['type']}» без цели — новая возможность и есть"
|
||||||
if goal_slug and t["goal"] and t["goal"] != goal_slug:
|
f" содержание цели (`edit {slug} --goal <слаг>`)")
|
||||||
raise Usage(f"{slug}: цель «{t['goal']}» не цель спринта «{goal_slug}» —"
|
# Отказ по факту, а не по метке: непустой раздел «Вопросы» держит
|
||||||
f" набор служит одной цели, даже если взять удобно")
|
# запись независимо от тега. Забывший тег иначе проходил бы, а
|
||||||
if not t["goal"] and t["type"] in NEEDS_GOAL:
|
# поставивший спотыкался — стимул ровно обратный правилу.
|
||||||
raise Usage(f"{slug}: тип «{t['type']}» без цели —"
|
if questions_open(lay, t):
|
||||||
f" новая возможность и есть содержание цели"
|
errs.append(f"непустой раздел «{lay.cfg['questions_heading']}» —"
|
||||||
f" (`edit {slug} --goal <слаг>`)")
|
f" вопрос разбирается до взятия. Отвечен — запиши ответ"
|
||||||
# Отказ по факту, а не по метке: непустой раздел «Вопросы» блокирует
|
f" в тело и очисти раздел"
|
||||||
# взятие независимо от тега. Забывший тег иначе проходил бы, а
|
f" (тег снимается `edit {slug} --rm-tag {QUESTION_TAG}`)")
|
||||||
# поставивший спотыкался — стимул ровно обратный правилу.
|
if QUESTION_TAG in t["tags"] and not questions_open(lay, t):
|
||||||
if questions_open(lay, t):
|
errs.append(f"тег «{QUESTION_TAG}» стоит, а раздела"
|
||||||
raise Usage(f"{slug}: непустой раздел «{lay.cfg['questions_heading']}» —"
|
f" «{lay.cfg['questions_heading']}» нет — либо вопрос записан"
|
||||||
f" вопрос разбирается до взятия, вне очереди порции."
|
f" не туда, либо тег пора снять:"
|
||||||
f" Отвечен — запиши ответ в тело и очисти раздел"
|
f" `edit {slug} --rm-tag {QUESTION_TAG}`")
|
||||||
f" (тег снимается `edit {slug} --rm-tag {QUESTION_TAG}`)")
|
serrs, notes = schema_verdict(lay, t)
|
||||||
if QUESTION_TAG in t["tags"]:
|
errs += serrs
|
||||||
raise Usage(f"{slug}: тег «{QUESTION_TAG}» стоит, а раздела"
|
warn += [f"{slug}: {n}" for n in notes]
|
||||||
f" «{lay.cfg['questions_heading']}» нет — либо вопрос записан не туда,"
|
verdicts.append((slug, errs))
|
||||||
f" либо тег пора снять: `edit {slug} --rm-tag {QUESTION_TAG}`")
|
|
||||||
errs, notes = schema_verdict(lay, t)
|
|
||||||
if errs:
|
|
||||||
raise Usage("; ".join(errs))
|
|
||||||
warn += notes
|
|
||||||
ei = find_entry_index(backlog_lines, slug)
|
|
||||||
if ei is None:
|
|
||||||
raise Usage(f"{slug}: строки в {lay.name('backlog')} нет"
|
|
||||||
f" (уже в спринте? прогони check)")
|
|
||||||
entry = backlog_lines.pop(ei)
|
|
||||||
insert_entry(sprint_lines, section, entry)
|
|
||||||
taken.append(slug)
|
|
||||||
|
|
||||||
plan = Plan()
|
bad = [(s, e) for s, e in verdicts if e]
|
||||||
plan.index(lay, "backlog", backlog_lines)
|
for slug, errs in verdicts:
|
||||||
plan.index(lay, "sprint", sprint_lines)
|
if errs:
|
||||||
plan.commit()
|
print(f"НЕ ГОТОВА {slug}")
|
||||||
total = len(parse_entries(sprint_lines)[0])
|
for e in errs:
|
||||||
print(f"взято в спринт: {', '.join(taken)}; в наборе {total}")
|
print(f" {e}")
|
||||||
|
else:
|
||||||
|
print(f"готова {slug}")
|
||||||
for w in warn:
|
for w in warn:
|
||||||
print(f" замечание: {w}")
|
print(f" замечание: {w}")
|
||||||
return EXIT_OK
|
if bad:
|
||||||
|
print(f"\nИтог: не готово {len(bad)} из {len(verdicts)}."
|
||||||
|
f" Дописывается это на груминге или тем, кто берёт задачу.")
|
||||||
def cmd_sprint_drop(lay: Layout, a: argparse.Namespace) -> int:
|
return EXIT_DRIFT
|
||||||
if (err := bad_reason(a.reason)):
|
|
||||||
raise Usage(err)
|
|
||||||
sprint_lines = read_lines(lay.index("sprint"))
|
|
||||||
backlog_lines = read_lines(lay.index("backlog"))
|
|
||||||
plan = Plan()
|
|
||||||
dropped = []
|
|
||||||
# Сперва все проверки и весь план правок, потом запись. Иначе отказ на
|
|
||||||
# втором слаге оставлял первый файл переписанным при нетронутых индексах:
|
|
||||||
# задача числилась в спринте и одновременно объясняла, почему из него вышла.
|
|
||||||
for slug in a.slugs:
|
|
||||||
if (err := bad_slug(slug)):
|
|
||||||
raise Usage(err)
|
|
||||||
ei = find_entry_index(sprint_lines, slug)
|
|
||||||
if ei is None:
|
|
||||||
raise Usage(f"{slug}: в наборе спринта такой строки нет")
|
|
||||||
path = lay.items / f"{slug}.md"
|
|
||||||
if not path.exists():
|
|
||||||
raise Usage(f"{slug}: строка в {lay.name('sprint')} есть, а файла"
|
|
||||||
f" {lay.cfg['items']}/{slug}.md нет — прогони check")
|
|
||||||
t = parse_task(path)
|
|
||||||
hi, section = find_section(backlog_lines, t["section"])
|
|
||||||
if hi is None:
|
|
||||||
avail = ", ".join(n for _, n in section_headers(backlog_lines))
|
|
||||||
raise Usage(f"{slug}: секция «{t['section'] or '—'}» не найдена"
|
|
||||||
f" в {lay.name('backlog')} (есть: {avail})")
|
|
||||||
new_text = meta_updated(path, section=section, reason=a.reason,
|
|
||||||
rtype=t["type"] or None)
|
|
||||||
if new_text is None:
|
|
||||||
raise Usage(f"{slug}.md без поля **{place_key_of(t['type'])}:** в мете —"
|
|
||||||
f" прогони check и почини")
|
|
||||||
plan.file(path, new_text)
|
|
||||||
insert_entry(backlog_lines, section, sprint_lines.pop(ei))
|
|
||||||
dropped.append(slug)
|
|
||||||
plan.index(lay, "backlog", backlog_lines)
|
|
||||||
plan.index(lay, "sprint", sprint_lines)
|
|
||||||
plan.commit()
|
|
||||||
print(f"вышло из спринта: {', '.join(dropped)} — причина записана в мету")
|
|
||||||
print(" живого предложения оставаться не должно; наработки, которые жалко,"
|
|
||||||
" переносятся в тело задачи текстом")
|
|
||||||
return EXIT_OK
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_sprint_close(lay: Layout, a: argparse.Namespace) -> int:
|
|
||||||
if (err := bad_reason(a.reason)):
|
|
||||||
raise Usage(err)
|
|
||||||
entries, _ = parse_entries(read_lines(lay.index("sprint")))
|
|
||||||
goal_slug, _ = sprint_goal(lay)
|
|
||||||
slug = sprint_slug(lay)
|
|
||||||
if entries and not a.dissolve:
|
|
||||||
raise Usage(f"в наборе осталось задач: {len(entries)}"
|
|
||||||
f" ({', '.join(sorted(n[:-3] for n in entries))}). Спринт кончается, когда"
|
|
||||||
f" каждая либо сделана (close --implemented), либо вышла (sprint drop"
|
|
||||||
f" --reason …). Роспуск при блокере — sprint close --dissolve --reason …")
|
|
||||||
if a.dissolve:
|
|
||||||
if not a.reason:
|
|
||||||
raise Usage("--dissolve без --reason: роспуск объясняется —"
|
|
||||||
" сработавшим блокером или тем, что набор протух")
|
|
||||||
drop = argparse.Namespace(slugs=sorted(n[:-3] for n in entries), reason=a.reason)
|
|
||||||
if entries and (rc := cmd_sprint_drop(lay, drop)) != EXIT_OK:
|
|
||||||
return rc
|
|
||||||
plan = Plan()
|
|
||||||
plan.file(lay.index("sprint"), empty_sprint(lay))
|
|
||||||
plan.commit()
|
|
||||||
print(f"спринт закрыт ({f'цель «{goal_slug}»' if goal_slug else 'без цели'},"
|
|
||||||
f" слаг «{slug or '—'}»)"
|
|
||||||
+ (", набор распущен" if a.dissolve and entries else ""))
|
|
||||||
print(f" история наборов остаётся в git: `git log -p {lay.index('sprint')}`")
|
|
||||||
if slug:
|
|
||||||
print(f" урожай спринта — `tasks.py list --tag {SPRINT_TAG}{slug}`:"
|
|
||||||
f" завести найденное по ходу обязан закрывающий спринт, а не пайплайн")
|
|
||||||
print(f" автотег снят вместе со спринтом: заводимое СЕЙЧАС метится только"
|
|
||||||
f" вручную — `add … --tag {SPRINT_TAG}{slug}`, иначе выпадет из урожая")
|
|
||||||
print(" дальше — сессия: разбор вопросов → разбор спринта → переоценка → новый набор")
|
|
||||||
return EXIT_OK
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
@@ -2614,8 +2319,7 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
|
|||||||
fixed.append(f"{lay.name(kind)}: строка синхронизирована с файлом: {name}")
|
fixed.append(f"{lay.name(kind)}: строка синхронизирована с файлом: {name}")
|
||||||
dirty.add(kind)
|
dirty.add(kind)
|
||||||
|
|
||||||
# 3. Нет строки вовсе / строка в чужом индексе / не в своей секции. Задачу
|
# 3. Нет строки вовсе / строка в чужом индексе / не в своей секции.
|
||||||
# из спринта не трогаем: «в спринте» — решение набора, а не свойство файла.
|
|
||||||
for name, task in tasks.items():
|
for name, task in tasks.items():
|
||||||
where = [k for k in lay.indexes if find_entry_index(idx[k], name[:-3]) is not None]
|
where = [k for k in lay.indexes if find_entry_index(idx[k], name[:-3]) is not None]
|
||||||
if len(where) > 1:
|
if len(where) > 1:
|
||||||
@@ -2635,13 +2339,17 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
|
|||||||
continue
|
continue
|
||||||
insert_entry(idx[home], section, entry_line(lay, task["title"], name[:-3],
|
insert_entry(idx[home], section, entry_line(lay, task["title"], name[:-3],
|
||||||
task["why"]))
|
task["why"]))
|
||||||
|
# Восстановленная строка встаёт в конец секции, и это надо сказать:
|
||||||
|
# позиция в беклоге — приоритет, а его назначает человек. Молчаливое
|
||||||
|
# восстановление выдало бы машинную позицию за его решение.
|
||||||
fixed.append(f"{lay.name(home)}: восстановлена строка {name}"
|
fixed.append(f"{lay.name(home)}: восстановлена строка {name}"
|
||||||
|
+ (" — в конце секции, позицию назначь сам:"
|
||||||
|
" порядок строк это приоритет"
|
||||||
|
if home == "backlog" else "")
|
||||||
+ ("" if task["why"] else " (в файле нет «зачем» — допиши)"))
|
+ ("" if task["why"] else " (в файле нет «зачем» — допиши)"))
|
||||||
dirty.add(home)
|
dirty.add(home)
|
||||||
continue
|
continue
|
||||||
kind = where[0]
|
kind = where[0]
|
||||||
if kind == "sprint":
|
|
||||||
continue
|
|
||||||
if kind != home:
|
if kind != home:
|
||||||
# Задача ровно в одном индексе и не в своём: истина в типе, а тип
|
# Задача ровно в одном индексе и не в своём: истина в типе, а тип
|
||||||
# в файле — неоднозначности нет, переносим и говорим об этом.
|
# в файле — неоднозначности нет, переносим и говорим об этом.
|
||||||
@@ -2762,15 +2470,16 @@ def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str],
|
|||||||
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
|
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
|
||||||
"+ строка здесь. Целей тут нет — они в "
|
"+ строка здесь. Целей тут нет — они в "
|
||||||
f"[{lay.name('roadmap')}]({lay.name('roadmap')}): беклог — то, что берут,\n"
|
f"[{lay.name('roadmap')}]({lay.name('roadmap')}): беклог — то, что берут,\n"
|
||||||
"роадмап — то, подо что берут. Порядка «по важности» внутри секции нет:\n"
|
"роадмап — то, подо что берут. **Порядок строк внутри секции значим:**\n"
|
||||||
"«что делать дальше» отвечает набор спринта. Единственное исключение\n"
|
"это очередь, и первая строка — то, что делают следующим. Порядок\n"
|
||||||
|
"назначает человек на груминге, машина его не выводит. Одно исключение\n"
|
||||||
f"производно от типа — сырьё (`{RESEARCH}` без раздела"
|
f"производно от типа — сырьё (`{RESEARCH}` без раздела"
|
||||||
f" «{lay.cfg['question_heading']}»)\nстоит в конце секции: его не берут."
|
f" «{lay.cfg['question_heading']}»)\nстоит в конце секции: его не берут."
|
||||||
" Ведётся скиллом `tasks`.\n\n"
|
" Ведётся скиллом `tasks`.\n\n"
|
||||||
"Тип записи стоит первым полем меты и решает, что у неё может быть:\n"
|
"Тип записи стоит первым полем меты и решает, что у неё может быть:\n"
|
||||||
+ "".join(f"{TYPE_EMOJI[t]} `{t}` " for t in TAKEABLE) + "\n\n"
|
+ "".join(f"{TYPE_EMOJI[t]} `{t}` " for t in TAKEABLE) + "\n\n"
|
||||||
"Секции «блокеры» здесь нет и не заводится: блокер — это состояние\n"
|
"Секции «блокеры» здесь нет и не заводится: блокер — это состояние\n"
|
||||||
"(спринт не может продолжаться ни одной задачей), оно живёт до ответа\n"
|
"(работа не может продолжаться ни одной задачей), оно живёт до ответа\n"
|
||||||
"человека, а его следы — вопросами в файлах задач.\n\n"
|
"человека, а его следы — вопросами в файлах задач.\n\n"
|
||||||
+ "".join(f"## {s}\n\n" for s in sections))
|
+ "".join(f"## {s}\n\n" for s in sections))
|
||||||
out[lay.index("roadmap")] = (
|
out[lay.index("roadmap")] = (
|
||||||
@@ -2793,7 +2502,6 @@ def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str],
|
|||||||
f"вариант — {' | '.join(pair[1] for pair in ROADMAP_SECTIONS)},"
|
f"вариант — {' | '.join(pair[1] for pair in ROADMAP_SECTIONS)},"
|
||||||
" один язык на весь\nиндекс.\n\n"
|
" один язык на весь\nиндекс.\n\n"
|
||||||
+ "".join(f"## {s}\n\n" for s in roadmap_sections))
|
+ "".join(f"## {s}\n\n" for s in roadmap_sections))
|
||||||
out[lay.index("sprint")] = empty_sprint(lay)
|
|
||||||
out[lay.index("rejected")] = (
|
out[lay.index("rejected")] = (
|
||||||
"# Ушедшее без реализации\n\n"
|
"# Ушедшее без реализации\n\n"
|
||||||
"Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n"
|
"Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n"
|
||||||
@@ -2816,7 +2524,7 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
|||||||
if not dir_within_cwd(root):
|
if not dir_within_cwd(root):
|
||||||
raise Usage(f"--dir вне рабочего каталога: {root}")
|
raise Usage(f"--dir вне рабочего каталога: {root}")
|
||||||
cfg = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("roadmap", a.roadmap),
|
cfg = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("roadmap", a.roadmap),
|
||||||
("sprint", a.sprint), ("rejected", a.rejected)) if v}
|
("rejected", a.rejected)) if v}
|
||||||
lay = Layout(root, cfg)
|
lay = Layout(root, cfg)
|
||||||
if lay.index("backlog").exists():
|
if lay.index("backlog").exists():
|
||||||
raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён")
|
raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён")
|
||||||
@@ -3302,11 +3010,11 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
|||||||
f" (правится `tasks.py edit <слаг> --goal <цель>`)"
|
f" (правится `tasks.py edit <слаг> --goal <цель>`)"
|
||||||
+ (f": {', '.join(sorted(x[:-3] for x in no_goal)[:5])}…" if no_goal else ""))
|
+ (f": {', '.join(sorted(x[:-3] for x in no_goal)[:5])}…" if no_goal else ""))
|
||||||
print(f" задач, не собравших разделы своего типа: {len(unfit)} —"
|
print(f" задач, не собравших разделы своего типа: {len(unfit)} —"
|
||||||
f" check это ошибкой не считает, но `sprint take` их не возьмёт:"
|
f" check это ошибкой не считает, но `ready` их не пропустит:"
|
||||||
f" собрать спринт сегодня физически нечем")
|
f" брать сегодня физически нечего")
|
||||||
print(f" закрывается порциями переоценки по 5–8 задач (скилл session, шаг 3):"
|
print(f" закрывается порциями переоценки по 5–8 задач (скилл session, шаг 3):"
|
||||||
f" проставить цели, превратить «готово, когда» в критерии с оракулами,"
|
f" проставить цели, превратить «готово, когда» в критерии с оракулами,"
|
||||||
f" вынуть вопросы из прозы в раздел. Готовность к первому спринту —"
|
f" вынуть вопросы из прозы в раздел. Готовность к первой задаче —"
|
||||||
f" не «check зелёный», а «есть {CRITERIA_MIN}+ критериев хотя бы у набора"
|
f" не «check зелёный», а «есть {CRITERIA_MIN}+ критериев хотя бы у набора"
|
||||||
f" под одну цель».")
|
f" под одну цель».")
|
||||||
print(" источники не удалены: сверь глазами и убери сам"
|
print(" источники не удалены: сверь глазами и убери сам"
|
||||||
@@ -3331,11 +3039,11 @@ def main() -> int:
|
|||||||
p.add_argument("--section", help="категория беклога или часть роадмапа")
|
p.add_argument("--section", help="категория беклога или часть роадмапа")
|
||||||
p.add_argument("--type", choices=TYPES)
|
p.add_argument("--type", choices=TYPES)
|
||||||
p.add_argument("--tag", help="тег или список через запятую (нужны ВСЕ):"
|
p.add_argument("--tag", help="тег или список через запятую (нужны ВСЕ):"
|
||||||
" goal:<слаг>, question, sprint:<слаг>")
|
" goal:<слаг>, question")
|
||||||
p.add_argument("--goal", help="задачи одной цели (перечень выводится, а не хранится)")
|
p.add_argument("--goal", help="задачи одной цели (перечень выводится, а не хранится)")
|
||||||
p.add_argument("--raw", action="store_true",
|
p.add_argument("--raw", action="store_true",
|
||||||
help=f"только сырьё: {RESEARCH} без раздела «Вопрос»")
|
help=f"только сырьё: {RESEARCH} без раздела «Вопрос»")
|
||||||
p.add_argument("--index", choices=("backlog", "sprint", "roadmap", "all"))
|
p.add_argument("--index", choices=("backlog", "roadmap", "all"))
|
||||||
p.add_argument("--questions", action="store_true", help="только с открытым вопросом")
|
p.add_argument("--questions", action="store_true", help="только с открытым вопросом")
|
||||||
|
|
||||||
p = sub.add_parser("add", help="завести запись: цель, задачу или разведку")
|
p = sub.add_parser("add", help="завести запись: цель, задачу или разведку")
|
||||||
@@ -3343,7 +3051,7 @@ def main() -> int:
|
|||||||
p.add_argument("--slug", required=True)
|
p.add_argument("--slug", required=True)
|
||||||
p.add_argument("--title", required=True)
|
p.add_argument("--title", required=True)
|
||||||
p.add_argument("--type", choices=TYPES, required=True,
|
p.add_argument("--type", choices=TYPES, required=True,
|
||||||
help="тип решает схему записи: разделы, цель, право на спринт")
|
help="тип решает схему записи: разделы, цель, право на взятие")
|
||||||
p.add_argument("--section", help="категория беклога или часть роадмапа")
|
p.add_argument("--section", help="категория беклога или часть роадмапа")
|
||||||
p.add_argument("--goal", help="слаг цели → тег goal:<слаг>")
|
p.add_argument("--goal", help="слаг цели → тег goal:<слаг>")
|
||||||
p.add_argument("--why")
|
p.add_argument("--why")
|
||||||
@@ -3382,31 +3090,9 @@ def main() -> int:
|
|||||||
p.add_argument("--reason", help="почему возвращена — уедет в мету")
|
p.add_argument("--reason", help="почему возвращена — уедет в мету")
|
||||||
p.add_argument("--dir")
|
p.add_argument("--dir")
|
||||||
|
|
||||||
p = sub.add_parser("sprint", help="операции спринта")
|
p = sub.add_parser("ready", help="схема типа выполнена — запись можно брать в работу")
|
||||||
ssub = p.add_subparsers(dest="sprint_command", required=True)
|
p.add_argument("slugs", nargs="+")
|
||||||
s = ssub.add_parser("start", help="начать спринт под названную цель или без цели")
|
p.add_argument("--dir")
|
||||||
# Группа взаимоисключающая, но не required: отказ за отсутствие обоих
|
|
||||||
# флагов даёт cmd_sprint_start — его текст объясняет, а argparse только
|
|
||||||
# называет флаги.
|
|
||||||
g = s.add_mutually_exclusive_group()
|
|
||||||
g.add_argument("--goal", help="слаг цели, под которую набирается спринт")
|
|
||||||
g.add_argument("--no-goal", dest="no_goal", action="store_true",
|
|
||||||
help="набор без цели: багфикс, техдолг, здоровье —"
|
|
||||||
" работа на работоспособность, а не на направление")
|
|
||||||
s.add_argument("--date")
|
|
||||||
s.add_argument("--slug", help="слаг спринта; по умолчанию дата начала")
|
|
||||||
s.add_argument("--dir")
|
|
||||||
s = ssub.add_parser("take", help="взять задачи в набор")
|
|
||||||
s.add_argument("slugs", nargs="+")
|
|
||||||
s.add_argument("--dir")
|
|
||||||
s = ssub.add_parser("drop", help="вернуть задачи в беклог")
|
|
||||||
s.add_argument("slugs", nargs="+")
|
|
||||||
s.add_argument("--reason", required=True)
|
|
||||||
s.add_argument("--dir")
|
|
||||||
s = ssub.add_parser("close", help="закрыть спринт")
|
|
||||||
s.add_argument("--dissolve", action="store_true", help="распустить набор (блокер)")
|
|
||||||
s.add_argument("--reason")
|
|
||||||
s.add_argument("--dir")
|
|
||||||
|
|
||||||
p = sub.add_parser("init", help="завести каталог задач в новом проекте")
|
p = sub.add_parser("init", help="завести каталог задач в новом проекте")
|
||||||
p.add_argument("--dir")
|
p.add_argument("--dir")
|
||||||
@@ -3414,7 +3100,6 @@ def main() -> int:
|
|||||||
p.add_argument("--items")
|
p.add_argument("--items")
|
||||||
p.add_argument("--backlog")
|
p.add_argument("--backlog")
|
||||||
p.add_argument("--roadmap")
|
p.add_argument("--roadmap")
|
||||||
p.add_argument("--sprint")
|
|
||||||
p.add_argument("--rejected")
|
p.add_argument("--rejected")
|
||||||
|
|
||||||
p = sub.add_parser("adopt", help="вывести каталог задач из того, что уже есть в репозитории")
|
p = sub.add_parser("adopt", help="вывести каталог задач из того, что уже есть в репозитории")
|
||||||
@@ -3436,9 +3121,6 @@ def main() -> int:
|
|||||||
if a.command == "adopt":
|
if a.command == "adopt":
|
||||||
return cmd_adopt_scan(a) if a.adopt_command == "scan" else cmd_adopt_apply(a)
|
return cmd_adopt_scan(a) if a.adopt_command == "scan" else cmd_adopt_apply(a)
|
||||||
lay = resolve_layout(a.dir)
|
lay = resolve_layout(a.dir)
|
||||||
if a.command == "sprint":
|
|
||||||
return {"start": cmd_sprint_start, "take": cmd_sprint_take,
|
|
||||||
"drop": cmd_sprint_drop, "close": cmd_sprint_close}[a.sprint_command](lay, a)
|
|
||||||
return {
|
return {
|
||||||
"check": lambda: check(lay, a.fix),
|
"check": lambda: check(lay, a.fix),
|
||||||
"list": lambda: list_tasks(lay, a),
|
"list": lambda: list_tasks(lay, a),
|
||||||
@@ -3447,6 +3129,7 @@ def main() -> int:
|
|||||||
"move": lambda: cmd_move(lay, a),
|
"move": lambda: cmd_move(lay, a),
|
||||||
"close": lambda: cmd_close(lay, a),
|
"close": lambda: cmd_close(lay, a),
|
||||||
"reopen": lambda: cmd_reopen(lay, a),
|
"reopen": lambda: cmd_reopen(lay, a),
|
||||||
|
"ready": lambda: cmd_ready(lay, a),
|
||||||
}[a.command]()
|
}[a.command]()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user