Files
marketplace/skills/issue/scripts/issue_new.py
T
naudachu 6d01ead245 fix: resolve the issue store path independently of the working directory
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>
2026-08-10 15:40:07 +05:00

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()