Files
marketplace/skills/use/scripts/issue_push.py
T
naudachu 335b0bbd54 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>
2026-08-07 19:35:38 +05:00

189 lines
7.3 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()