Files
marketplace/skills/sync/scripts/push.py
T
naudachu 9234d8004f feat: work the sync backlog — comments, labels, refs, closed issues
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>
2026-08-10 00:37:57 +05:00

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()