335b0bbd54
Replace fetch_issue.py with four scripts around a flat, greppable cache in tmp/issues/. Planning stays offline and issues reach Gitea in one push: - issue_get.py: fetch by key or by filter (--milestone/--label/-q). The list endpoint carries issue bodies, so a whole milestone costs one request per 50 issues. Gitea silently ignores an unresolvable milestones= filter and returns the entire backlog, so the milestone is resolved up front and every returned issue is re-checked locally. --deps walks the dependency graph downwards via the structured sections plus native dependencies and writes tree-<slug>.md. - issue_push.py: validate a local draft against the canonical format, create missing labels with the right colors and exclusivity, POST, delete the draft. - issue_list.py: discovery to stdout, writes nothing. - issue_index.py: rebuild INDEX.md from what is on disk. Files use one metadata field per line with inline lists so plain grep works without a parser. This is a cache and a drafting area, not a mirror: no drift tracking, no sync back. Projects are not fetchable — the projects API is 404 on Gitea 1.26; documented alongside the milestone caveat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
7.3 KiB
Python
Executable File
189 lines
7.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
issue_push.py — create Gitea issues from local drafts, then drop the drafts.
|
||
|
||
A draft is a plain markdown file under tmp/issues/drafts/ written during
|
||
planning, with no network involved:
|
||
|
||
---
|
||
labels: [type/task, tech/sql]
|
||
---
|
||
# Wire sqlc into the appclick repo layer
|
||
|
||
## Summary
|
||
...
|
||
|
||
This script does what /tea:issue used to do by hand: validate the canonical
|
||
format, create any missing labels (exclusive for type/* and severity/*), POST
|
||
the issue, print its URL, and delete the draft — the issue now lives in Gitea,
|
||
the local copy is not a mirror and must not linger. --keep turns the draft into
|
||
a cache file (tmp/issues/<n>.md) instead of deleting it.
|
||
|
||
Usage:
|
||
issue_push.py <draft.md> [<draft.md> …] [--keep] [--dry-run] [--force]
|
||
issue_push.py --all [--keep] [--dry-run] [--force]
|
||
issue_push.py --all --repo owner/repo --out DIR
|
||
|
||
Format reference: ../references/issue-format.md
|
||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||
"""
|
||
import argparse
|
||
import glob
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
import issue_index # noqa: E402
|
||
from _tea import (DRAFT_DIR, ISSUE_ROOT, die, issue_path, load_label_ids, # noqa: E402
|
||
parse_meta, read_file, render_issue, repo_base,
|
||
require_login, tea_api, warn, write_file)
|
||
|
||
# Sections every type must carry; acceptance criteria is waived for drafts.
|
||
REQUIRED = ["## Summary", "## Spec"]
|
||
AC = "## Acceptance criteria"
|
||
# Per-type sections from the templates — missing ones are a warning, not a stop.
|
||
EXPECTED = {
|
||
"bug": ["## Steps to reproduce", "## Expected", "## Actual", "## Environment"],
|
||
"task": ["## Motivation"],
|
||
"refactor": ["## Motivation", "## Invariants"],
|
||
"test": ["## Motivation", "## Test cases"],
|
||
"feature": ["## Motivation", "## Issues"],
|
||
"draft": ["## Notes"],
|
||
}
|
||
TITLE_PREFIX = re.compile(r'^\s*(\[[^\]]+\]|(fix|feat|feature|bug|task|test|chore|refactor)\s*:)',
|
||
re.I)
|
||
CYRILLIC = re.compile(r'[а-яё]', re.I)
|
||
|
||
|
||
def section_body(body, header):
|
||
"""Text under `header` up to the next `## ` heading."""
|
||
out, active = [], False
|
||
for line in body.splitlines():
|
||
if line.startswith("## "):
|
||
if active:
|
||
break
|
||
active = line.strip() == header
|
||
continue
|
||
if active:
|
||
out.append(line)
|
||
return "\n".join(out).strip()
|
||
|
||
|
||
def validate(path, force):
|
||
"""Return (title, body, labels, type). Exits on a hard format violation."""
|
||
meta, title, body = parse_meta(read_file(path))
|
||
err = []
|
||
|
||
if "number" in meta:
|
||
err.append("draft carries `number:` — this script only creates issues; "
|
||
"edit existing ones with `tea api -X PATCH`")
|
||
|
||
labels = meta.get("labels") or []
|
||
if isinstance(labels, str):
|
||
labels = [l.strip() for l in labels.split(",") if l.strip()]
|
||
types = [l for l in labels if l.startswith("type/")]
|
||
if len(types) != 1:
|
||
err.append("need exactly one type/* label, found %d: %s"
|
||
% (len(types), ", ".join(types) or "none"))
|
||
if len([l for l in labels if l.startswith("severity/")]) > 1:
|
||
err.append("at most one severity/* label")
|
||
kind = types[0].split("/", 1)[1] if types else ""
|
||
|
||
if not title:
|
||
err.append("no `# Title` heading below the metadata block")
|
||
else:
|
||
if TITLE_PREFIX.match(title):
|
||
err.append("title carries a type prefix (%r) — the type lives in the label"
|
||
% title[:24])
|
||
if CYRILLIC.search(title):
|
||
err.append("title must be English, imperative mood (prose stays Russian)")
|
||
|
||
for h in REQUIRED:
|
||
if h not in body:
|
||
err.append("missing section %s" % h)
|
||
if kind != "draft" and AC not in body:
|
||
err.append("missing section %s" % AC)
|
||
if "## Spec" in body and not section_body(body, "## Spec"):
|
||
err.append("## Spec is empty — put a repo path, a URL, or the literal `none`")
|
||
|
||
if err:
|
||
for e in err:
|
||
sys.stderr.write("%s: %s\n" % (path, e))
|
||
if not force:
|
||
die("%s: format violations (see above); --force overrides" % path)
|
||
|
||
for h in EXPECTED.get(kind, []):
|
||
if h not in body:
|
||
warn("%s: type/%s template usually has %s" % (path, kind, h))
|
||
|
||
return title, body.strip(), labels, kind
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="Create Gitea issues from tmp/issues/drafts/")
|
||
ap.add_argument("drafts", nargs="*", help="draft markdown files")
|
||
ap.add_argument("--all", action="store_true", help="push every draft in the drafts dir")
|
||
ap.add_argument("--keep", action="store_true",
|
||
help="keep the issue locally as tmp/issues/<n>.md instead of deleting")
|
||
ap.add_argument("--dry-run", action="store_true", help="validate only, no network")
|
||
ap.add_argument("--force", action="store_true", help="post despite format violations")
|
||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||
ap.add_argument("--out", default=ISSUE_ROOT, help="cache root (default: tmp/issues)")
|
||
args = ap.parse_args()
|
||
|
||
root = args.out
|
||
paths = list(args.drafts)
|
||
if args.all:
|
||
paths += sorted(glob.glob(os.path.join(root, DRAFT_DIR, "*.md")))
|
||
paths = list(dict.fromkeys(paths))
|
||
if not paths:
|
||
die("no drafts given (pass files or --all; drafts live in %s/)"
|
||
% os.path.join(root, DRAFT_DIR))
|
||
for p in paths:
|
||
if not os.path.isfile(p):
|
||
die("no such draft: %s" % p)
|
||
|
||
parsed = [(p,) + validate(p, args.force) for p in paths]
|
||
if args.dry_run:
|
||
for p, title, _body, labels, kind in parsed:
|
||
print("ok %s [type/%s] %s (%s)" % (p, kind, title, ", ".join(labels)))
|
||
return
|
||
|
||
base = repo_base(args.repo)
|
||
login = require_login()
|
||
wanted = sorted({l for _p, _t, _b, labels, _k in parsed for l in labels})
|
||
ids = load_label_ids(login, base, root, wanted)
|
||
|
||
for path, title, body, labels, _kind in parsed:
|
||
payload = {"title": title, "body": body, "labels": [ids[l] for l in labels]}
|
||
slug = os.path.splitext(os.path.basename(path))[0]
|
||
iss = tea_api(login, "%s/issues" % base, "POST", payload,
|
||
payload_name="issue-%s" % slug, out_root=root)
|
||
if not isinstance(iss, dict) or "number" not in iss:
|
||
die("%s: create failed, unexpected response" % path)
|
||
n = iss["number"]
|
||
|
||
got = [l.get("name", "") for l in iss.get("labels") or []]
|
||
missing = [l for l in labels if l not in got]
|
||
if missing:
|
||
tea_api(login, "%s/issues/%d/labels" % (base, n), "PUT",
|
||
{"labels": [ids[l] for l in labels]},
|
||
payload_name="labels-%d" % n, out_root=root)
|
||
warn("#%d: labels re-applied via PUT (%s)" % (n, ", ".join(missing)))
|
||
|
||
if args.keep:
|
||
write_file(issue_path(root, n), render_issue(iss))
|
||
os.remove(path)
|
||
print("#%d %s %s -> %s" % (n, title, iss.get("html_url", ""),
|
||
issue_path(root, n)))
|
||
else:
|
||
os.remove(path)
|
||
print("#%d %s %s (draft removed)" % (n, title, iss.get("html_url", "")))
|
||
|
||
issue_index.build(root)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|