feat: local issue cache and draft-then-push workflow
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>
This commit is contained in:
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user