Files
marketplace/skills/issue/scripts/issue_new.py
T
naudachu 091dceec1d refactor: split issue domain from Gitea transport
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>
2026-08-09 23:37:32 +05:00

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