#!/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/.md) instead of deleting it. Usage: issue_push.py [ …] [--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/.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()