6d01ead245
ISSUE_ROOT was the relative `tmp/issues`, so "the store" was whatever
directory the shell happened to be standing in. It is the --out default
in all eight scripts of both layers, which made one `cd` — and a `cd`
outlives the command that ran it — enough for readers to report an empty
store on a full one and for writers to quietly build a second store
beside the first. `issue_index.py` run from inside tmp/issues left
tmp/issues/tmp/issues/ behind and exited 0.
The anchor is issue.py's own __file__, not cwd. A script's location is a
fact about the installation; cwd is a fact about the last `cd`, and the
scripts are invoked by path from wherever the agent happens to be. From
there `store_root()` walks up to the nearest repo marker — `.git`
(exists(), not isdir(): a worktree's .git is a file) or AGENTS.md for a
copy taken out of git — and joins tmp/issues. Markers rather than a
fixed number of `..` hops, because the layout is not a promise. cwd is
tried only if the scripts are not inside a repository at all.
The function lives in the domain layer and skills/sync imports it, so
both layers agree by construction — the direction the layering rule
allows. skills/issue stays stdlib-only.
An explicit --out still wins and is used exactly as typed: a relative
--out stays relative to cwd, because that is what the operator asked
for. No new environment surface.
Two consequences the issue also asked for:
- Missing is no longer reported as empty. `store_error()` returns one
message for a path that is not there and another for a store with no
issues in it.
- Nothing conjures a store as a side effect of a write. save() and
issue_index.build() require it instead of os.makedirs'ing it; only
issue_new.py and pull.py create one, and both say so on stderr.
Establishes tests/ — plain stdlib unittest, no pytest, no dependencies.
The store tests build a throwaway repo in a TemporaryDirectory (a .git
marker, a copy of both script layers, fixture issues) and run the real
scripts inside it as subprocesses from five different working
directories; tmp/issues/ is never touched. Against the pre-fix scripts
15 of the 21 fail, reproducing the report exactly — five stray stores,
including tmp/issues/tmp/issues.
python3 -m unittest discover -s tests -v
Closes claude-skills/tea#15
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
194 lines
6.7 KiB
Python
194 lines
6.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
issue_new.py — create an issue in the local store. Offline, always.
|
|
|
|
The issue is real the moment this writes the file. Nothing is pending, nothing
|
|
is a draft awaiting a tracker: `origin: local` is a durable state, and pushing
|
|
it to Gitea later (see /tea:sync) is optional and additive.
|
|
|
|
issue_new.py --type task --title "Wire sqlc into the appclick repo layer" \
|
|
--label tech/sql --label comp/appclick
|
|
|
|
issue_new.py --type bug --title "Fix tea-guard crash on empty settings" \
|
|
--depends wire-sqlc-appclick --milestone v0.2
|
|
|
|
Writes tmp/issues/<slug>.md prefilled with the type's template, prints the
|
|
path, and rebuilds INDEX.md. Fill the sections in an editor or with Edit; run
|
|
issue_check.py when done.
|
|
|
|
Body prose is Russian, section headers and the title are English — see
|
|
../references/format.md.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import issue # noqa: E402
|
|
import issue_index # noqa: E402
|
|
|
|
SPEC = """## Spec
|
|
none
|
|
"""
|
|
|
|
TEMPLATES = {
|
|
"bug": """## Summary
|
|
Что сломано и где проявляется, одно-два предложения.
|
|
|
|
""" + SPEC + """
|
|
## Steps to reproduce
|
|
1. …
|
|
2. …
|
|
|
|
## Expected
|
|
Что должно было произойти.
|
|
|
|
## Actual
|
|
Что происходит на самом деле: вывод команды, лог.
|
|
|
|
## Environment
|
|
Только релевантное: версии, ОС, конфигурация.
|
|
|
|
## Acceptance criteria
|
|
- [ ] баг не воспроизводится по шагам выше
|
|
- [ ] добавлена проверка на регрессию (если применимо)
|
|
""",
|
|
"task": """## Summary
|
|
Что нужно сделать, одно-два предложения.
|
|
|
|
""" + SPEC + """
|
|
## Motivation
|
|
Какую проблему пользователя/системы это решает.
|
|
|
|
## Acceptance criteria
|
|
- [ ] проверяемое условие
|
|
- [ ] …
|
|
""",
|
|
"refactor": """## Summary
|
|
Что перестраиваем и в каких файлах (`path/file:line`).
|
|
|
|
""" + SPEC + """
|
|
## Motivation
|
|
Чем плохо текущее состояние: дублирование, связность, читаемость.
|
|
|
|
## Invariants
|
|
Что НЕ должно измениться: поведение, публичные API, форматы данных.
|
|
|
|
## Acceptance criteria
|
|
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
|
|
""",
|
|
"test": """## Summary
|
|
Что покрываем тестами и где (`path/file:line`).
|
|
|
|
""" + SPEC + """
|
|
## Motivation
|
|
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
|
|
|
|
## Test cases
|
|
- сценарий → ожидаемый результат
|
|
- …
|
|
|
|
## Acceptance criteria
|
|
- [ ] перечисленные кейсы покрыты и зелёные
|
|
- [ ] тесты проходят в CI
|
|
""",
|
|
"feature": """## Summary
|
|
Бизнес-ценность одним-двумя предложениями.
|
|
|
|
""" + SPEC + """
|
|
## Motivation
|
|
Какую проблему пользователя/системы это решает.
|
|
|
|
## Issues
|
|
- [ ] slug-дочернего-issue — краткое описание части
|
|
- [ ] …
|
|
|
|
## Acceptance criteria
|
|
- [ ] все дочерние issues закрыты
|
|
- [ ] проверяемое условие уровня фичи
|
|
""",
|
|
"draft": """## Summary
|
|
Идея одним-двумя предложениями.
|
|
|
|
""" + SPEC + """
|
|
## Notes
|
|
Свободные заметки: что известно, открытые вопросы, варианты.
|
|
""",
|
|
}
|
|
|
|
DEPENDS_BLOCK = """## Depends on
|
|
%s
|
|
"""
|
|
|
|
|
|
def with_depends(body, depends):
|
|
"""Insert `## Depends on` right after `## Spec`, per the format."""
|
|
if not depends:
|
|
return body
|
|
block = DEPENDS_BLOCK % "\n".join("- %s" % d for d in depends)
|
|
lines, out, placed = body.splitlines(True), [], False
|
|
for line in lines:
|
|
if not placed and line.startswith("## ") and not line.startswith("## Summary") \
|
|
and not line.startswith("## Spec") and out:
|
|
out.append(block + "\n")
|
|
placed = True
|
|
out.append(line)
|
|
if not placed:
|
|
out.append("\n" + block)
|
|
return "".join(out)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Create a local issue from its type template")
|
|
ap.add_argument("--type", required=True, choices=sorted(issue.TYPES),
|
|
help="issue type (becomes the exclusive type/* label)")
|
|
ap.add_argument("--title", required=True, help="English, imperative, no type prefix")
|
|
ap.add_argument("--id", help="slug (default: derived from the title)")
|
|
ap.add_argument("--label", action="append", default=[],
|
|
help="extra label, e.g. tech/sql; repeat")
|
|
ap.add_argument("--severity", choices=list(issue.SEVERITIES), help="severity/* label")
|
|
ap.add_argument("--milestone", default="", help="milestone title")
|
|
ap.add_argument("--assignee", action="append", default=[], help="assignee; repeat")
|
|
ap.add_argument("--depends", action="append", default=[],
|
|
help="id this issue depends on; repeat")
|
|
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
|
help="store root (default: <repo>/tmp/issues)")
|
|
args = ap.parse_args()
|
|
|
|
labels = ["type/%s" % args.type]
|
|
if args.severity:
|
|
labels.append("severity/%s" % args.severity)
|
|
labels += [l for l in args.label if l not in labels]
|
|
|
|
id = args.id or issue.unique_id(args.out, issue.slugify(args.title))
|
|
if args.id and not issue.SLUG_OK.match(args.id):
|
|
sys.exit("issue_new.py: --id %r is not a slug (lowercase, digits, single dashes)"
|
|
% args.id)
|
|
if os.path.exists(issue.path_of(args.out, id)):
|
|
sys.exit("issue_new.py: %s already exists" % issue.path_of(args.out, id))
|
|
|
|
known = set(issue.all_ids(args.out))
|
|
for d in args.depends:
|
|
if d not in known:
|
|
sys.stderr.write("warning: depends on %r, which is not in the store yet\n" % d)
|
|
|
|
iss = issue.Issue(
|
|
id=id, title=args.title,
|
|
body=with_depends(TEMPLATES[args.type], args.depends),
|
|
labels=labels, assignees=args.assignee, milestone=args.milestone,
|
|
depends=args.depends)
|
|
|
|
# The first issue in a fresh checkout has to create the store, but it says
|
|
# so — and it says where, because the path is absolute.
|
|
if issue.create_store(args.out):
|
|
sys.stderr.write("created store %s\n" % os.path.abspath(args.out))
|
|
|
|
path = issue.save(args.out, iss)
|
|
issue_index.build(args.out)
|
|
print("%s [type/%s] %s" % (path, args.type, args.title))
|
|
print("fill the sections, then: issue_check.py %s" % id)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|