9234d8004f
Five tracker issues, all in the bridge layer except the last. pull.py fetches comments by default (#6). The thread was reachable only through --comments, and only for a single issue, so a bulk pull left every local copy silently incomplete: a missing <id>.comments.md could mean "no comments" or "never asked". Now every written issue gets its thread, in key and filter mode alike; an empty one costs no request (the count rides in the list payload) and writes no file, and a file left over from an earlier pull is deleted. --cached skips the thread along with the body. The --comments flag is gone. labels.py bootstraps the canonical label set (#7). Labels used to appear as a side effect of the first push that happened to use them, so a repo could not be filtered by type/bug until somebody pushed a bug. The set is finite and already described by the domain taxonomy — 6 type/* and 5 severity/* — which makes it a run, not a decision. Names and exclusivity come from issue.TYPES / SEVERITIES / EXCLUSIVE_NS, colors from map.label_specs; no list is duplicated. An exact name is never re-created or patched. Lookalikes (bug, Bug, "type: bug", kind/bug) are reported with their id and left alone — renaming somebody else's label is a decision, not a migration. Color or exclusive drift is printed, and changed only under --fix. branch: carries Gitea's ref (#8). map.to_payload sends ref only when the field is non-empty, since ref="" would clear whatever the server has; from_api reads it back; push fills an empty one from `git rev-parse --abbrev-ref HEAD` and writes it into the issue file. A hand-written value is never overwritten, on create or on --update. Detached HEAD and running outside a repo warn and send no ref. Reading the branch is the only thing these scripts ask of git. The domain needs no change: unknown keys already ride in Issue.extra and render after the domain fields. Bulk pulls no longer store closed issues (#10). Filter mode wrote every payload the server returned, so --state all dragged the closed backlog into a store that gets read whole — INDEX.md, grep over tmp/issues/*.md. They are still enumerated, the number left out goes to stderr, and an issue already on disk is refreshed either way so the local copy learns it was closed instead of staying open forever. --state closed stores them, and key mode is exempt: an address is not a bulk read. /tea:issue gains a "Writing a proper description" procedure (#9). Six steps from reading an issue to issue_check.py, the rule that a missing fact is found in the repository or asked about rather than invented, and the note that the procedure is identical for origin: local and origin: gitea while delivery to the tracker belongs to /tea:sync. No new script. Verified: labels.py run for real against claude-skills/tea (9 created, 2 already present) and idempotent on a second run; pull.py exercised live for the closed-skip, --state closed, key-mode and comment paths; the push write path covered offline with the transport stubbed. skills/issue/scripts/ still imports stdlib only, with no subprocess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
212 lines
8.9 KiB
Python
212 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
push.py — local store -> Gitea.
|
|
|
|
Pushing is additive. The local file is never deleted and never moves: it gains
|
|
`gitea:`, `url:` and `synced:`, and `origin:` flips from `local` to `gitea`.
|
|
One issue, two places it is visible — not two kinds of file. A local-only issue
|
|
is a finished state, not a step on the way to a tracker.
|
|
|
|
push.py every local-only issue, dependencies first
|
|
push.py wire-sqlc-appclick one issue
|
|
push.py --update <id …> PATCH issues that are already in Gitea
|
|
push.py --dry-run validate only, no network
|
|
|
|
Before anything is sent, each issue is validated against the canonical format
|
|
by the domain layer (exactly one type/*, English title with no type prefix,
|
|
`## Summary` / `## Spec` / `## Acceptance criteria` present). `--force` posts
|
|
anyway; say why when you use it.
|
|
|
|
Dependencies are pushed in topological order so a parent is created after the
|
|
issues it depends on. A dependency that is still local-only is reported, not
|
|
silently dropped — the body's `## Depends on` prose is sent verbatim either
|
|
way, so nothing is lost, but the `#N` cross-links will be missing.
|
|
|
|
Missing labels are created with the canonical color and, for type/* and
|
|
severity/*, `exclusive: true` — `tea labels create` cannot set that field.
|
|
|
|
`branch:` carries Gitea's `ref`, the branch the work lives on. An empty one is
|
|
filled with the current git branch and written back to the file; one that is
|
|
already set is never touched. Detached HEAD, or no repo at all: no `ref` is
|
|
sent and a warning says so.
|
|
|
|
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
|
"""
|
|
import argparse
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
|
|
|
import _gitea # noqa: E402
|
|
import issue # noqa: E402
|
|
import issue_index # noqa: E402
|
|
import map as gmap # noqa: E402
|
|
|
|
|
|
def select(issues, ids, update):
|
|
"""Which issues to send, and refuse the ambiguous combinations."""
|
|
if ids:
|
|
missing = [i for i in ids if i not in issues]
|
|
if missing:
|
|
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
|
|
chosen = list(ids)
|
|
else:
|
|
chosen = sorted(i for i in issues
|
|
if update or not issues[i].extra.get("gitea"))
|
|
if not chosen:
|
|
_gitea.die("nothing to push: every issue in the store is already in Gitea "
|
|
"(use --update to PATCH them, or issue_new.py to make one)")
|
|
if not update:
|
|
already = [i for i in chosen if issues[i].extra.get("gitea")]
|
|
if already:
|
|
_gitea.die("already in Gitea: %s — pass --update to PATCH them"
|
|
% ", ".join(already))
|
|
return chosen
|
|
|
|
|
|
def git_branch():
|
|
"""The branch HEAD is on, or None. The only git call these scripts make —
|
|
read, never write. A detached HEAD prints `HEAD` and outside a repo git
|
|
exits non-zero; both mean "no branch to name", which is not an error."""
|
|
try:
|
|
r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
capture_output=True, text=True)
|
|
except OSError:
|
|
return None
|
|
name = r.stdout.strip()
|
|
if r.returncode != 0 or not name or name == "HEAD":
|
|
return None
|
|
return name
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Push local issues to Gitea")
|
|
ap.add_argument("ids", nargs="*", help="issue ids (default: every local-only issue)")
|
|
ap.add_argument("--update", action="store_true",
|
|
help="PATCH issues that already carry a gitea: field")
|
|
ap.add_argument("--dry-run", action="store_true", help="validate only, no network")
|
|
ap.add_argument("--force", action="store_true", help="push despite format violations")
|
|
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
|
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
|
|
args = ap.parse_args()
|
|
|
|
root = args.out
|
|
issues = issue.load_all(root)
|
|
if not issues:
|
|
_gitea.die("store %s is empty — create an issue with issue_new.py first" % root)
|
|
|
|
chosen = select(issues, args.ids, args.update)
|
|
|
|
# ---- validate (domain layer, no network) -----------------------------
|
|
known = set(issues)
|
|
blocked = False
|
|
for id in chosen:
|
|
err, warn = issue.validate(issues[id], known_ids=known)
|
|
for w in warn:
|
|
_gitea.warn("%s: %s" % (id, w))
|
|
for e in err:
|
|
sys.stderr.write("%s: %s\n" % (id, e))
|
|
if err:
|
|
blocked = True
|
|
if blocked and not args.force:
|
|
_gitea.die("format violations (see above); --force overrides")
|
|
|
|
# ---- dependencies first ----------------------------------------------
|
|
edges = {i: [d for d in issues[i].depends if d in issues] for i in chosen}
|
|
order = [i for i in issue.topo_order(chosen, edges) if i in set(chosen)]
|
|
for c in issue.find_cycles(edges):
|
|
_gitea.warn("dependency cycle: %s" % " -> ".join(c))
|
|
|
|
# ---- branch: -> Gitea `ref` ------------------------------------------
|
|
# Only an empty field is filled: a branch written by hand is the author's
|
|
# decision and push does not argue with it. Nothing to read (detached HEAD,
|
|
# no repo) is not an error — the issue goes up without a `ref`.
|
|
blank = [id for id in order if not issues[id].extra.get(gmap.BRANCH_KEY)]
|
|
branch = git_branch() if blank else None
|
|
if branch:
|
|
for id in blank:
|
|
issues[id].extra[gmap.BRANCH_KEY] = branch
|
|
elif blank:
|
|
_gitea.warn("no current git branch (detached HEAD, or outside a git repo) "
|
|
"— no `ref` on: %s" % ", ".join(blank))
|
|
|
|
if args.dry_run:
|
|
for id in order:
|
|
iss = issues[id]
|
|
print("ok %s [type/%s] %s (%s)"
|
|
% (id, iss.type or "?", iss.title, ", ".join(iss.labels) or "no labels"))
|
|
print("%d issue(s) would be %s" % (len(order), "updated" if args.update else "created"))
|
|
return
|
|
|
|
login = _gitea.require_login()
|
|
base = _gitea.repo_base(args.repo)
|
|
repo = _gitea.repo_slug(login, args.repo)
|
|
|
|
wanted = sorted({l for id in order for l in issues[id].labels})
|
|
label_ids = _gitea.ensure_labels(login, base, gmap.label_specs(wanted), root) \
|
|
if wanted else {}
|
|
|
|
milestone_ids = {}
|
|
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
|
|
|
|
for id in order:
|
|
iss = issues[id]
|
|
|
|
unsynced = [d for d in iss.depends
|
|
if d in issues and not issues[d].extra.get("gitea")
|
|
and d not in order]
|
|
if unsynced:
|
|
_gitea.warn("%s: depends on local-only issue(s) %s — no #N cross-link in Gitea"
|
|
% (id, ", ".join(unsynced)))
|
|
|
|
ms_id = None
|
|
if iss.milestone:
|
|
if iss.milestone not in milestone_ids:
|
|
milestone_ids[iss.milestone] = _gitea.resolve_milestone_id(
|
|
login, base, iss.milestone)
|
|
ms_id = milestone_ids[iss.milestone]
|
|
if ms_id is None:
|
|
_gitea.warn("%s: milestone %r does not exist in %s — not set"
|
|
% (id, iss.milestone, repo))
|
|
|
|
number = gmap.number_of(iss)
|
|
if number:
|
|
payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True)
|
|
got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH", payload,
|
|
payload_name="issue-%s" % id, out_root=root)
|
|
verb = "updated"
|
|
else:
|
|
payload = gmap.to_payload(iss, label_ids, ms_id)
|
|
got = _gitea.api(login, "%s/issues" % base, "POST", payload,
|
|
payload_name="issue-%s" % id, out_root=root)
|
|
verb = "created"
|
|
if not isinstance(got, dict) or "number" not in got:
|
|
_gitea.die("%s: %s failed, unexpected response" % (id, verb))
|
|
number = got["number"]
|
|
|
|
# Gitea occasionally drops labels on create — re-apply rather than
|
|
# trust the echo.
|
|
applied = {l.get("name", "") for l in got.get("labels") or []}
|
|
missing = [l for l in iss.labels if l in label_ids and l not in applied]
|
|
if missing:
|
|
_gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT",
|
|
{"labels": [label_ids[l] for l in iss.labels if l in label_ids]},
|
|
payload_name="labels-%s" % id, out_root=root)
|
|
_gitea.warn("%s: labels re-applied via PUT (%s)" % (id, ", ".join(missing)))
|
|
|
|
gmap.apply_remote(iss, got, repo, _gitea.now_iso())
|
|
issue.save(root, iss)
|
|
remote_map[gmap.remote_key(repo, number)] = id
|
|
print("%s %s #%d %s" % (verb, id, number, got.get("html_url", "")))
|
|
|
|
_gitea.save_map(root, remote_map)
|
|
path, n = issue_index.build(root)
|
|
print("index: %s — %d issue(s)" % (path, n))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|