091dceec1d
An issue was a Gitea row that happened to be cached locally: its identity was the tracker's number (42.md), its dependencies were tracker numbers (depends: [#12]), and a local issue existed only as a draft that push deleted on success. Nothing could be planned or tracked without a tracker. Split into layers, with knowledge flowing one way: skills/issue DOMAIN what an issue is: format, validation, dep graph ^ offline; stdlib imports only, no subprocess | imports skills/sync BRIDGE map.py md <-> Gitea JSON, pure, no I/O _gitea.py login pin, api, pagination, filters skills/use REFERENCE tea CLI docs for non-issue entities skills/issue never imports skills/sync. Delete the sync layer and the domain keeps working. Identity is now a slug derived from the title (wire-sqlc-appclick.md) and is stable across retitles and pushes. Tracker numbers live in a `gitea:` field, never in a file name and never in `depends:`; the pair is indexed in .remote.json, which is a cache over the files, not a second source of truth. Behavior changes: - Pushing is additive. The file is never deleted; it gains gitea:/url:/ synced: and origin: flips from local to gitea. `origin: local` is a durable state, not a pending one. - Pushes go in topological order so dependencies get numbers first. - The dependency graph is computed offline from `depends:` metadata; body prose is passed through unchanged in both directions rather than being rewritten between slugs and #N. - `origin` is domain-owned (whether work exists elsewhere is a fact about the work); the handle and how to reach it stay with sync. Script moves: issue_get.py -> sync/pull.py issue_push.py -> sync/push.py issue_list.py -> sync/remote.py issue_index.py -> issue/issue_index.py _tea.py -> split into issue/issue.py, sync/map.py, sync/_gitea.py New: issue/issue_new.py, issue/issue_check.py, issue/issue_tree.py, and sync/comment.py — comment posting was the last issue operation still hand-rolled through raw `tea api`. references/issue-format.md moves to skills/issue/references/format.md; label hex colors move out of it into map.py, since a color is how a tracker paints a chip, not what an issue is. Verified: offline path end to end (new, check, tree, index, push --dry-run) and read-only against Gitea (remote listing, pull with mapping, comment guard). Write paths of push.py and comment.py are not exercised here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
188 lines
6.5 KiB
Python
188 lines
6.5 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: 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)
|
|
|
|
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()
|