Files
marketplace/skills/sync/scripts/map.py
T
naudachu e629d14585 feat: drop the local copy after a successful push
Gitea becomes the source of truth. Once a push is confirmed, push.py
deletes tmp/issues/<id>.md and <id>.comments.md and prints the number and
URL the issue now lives at; the current state is obtained by pulling
again rather than by reconciling. --update follows the same rule, with no
exception: what is local is what has not left.

This reverses three statements AGENTS.md used to make, and rewriting them
is part of the change:

  - "tmp/issues/ is the store, not a cache of Gitea" — it is both, split
    by origin:. An origin: local file is the only copy of the work; an
    origin: gitea file is a deletable working copy.
  - "Pushing is additive: the file is never deleted" — it is deleted.
  - "origin: local is a durable state" — complete, but not durable:
    pushing ends it.

Slug stability, which the format promises for the life of an issue, can
no longer rest on a file push is about to delete. The slug goes up in the
body as a hidden marker, <!-- tea:id <slug> -->, on the first line:
map.to_payload strips every marker and prepends exactly one, map.from_api
strips every marker on the way down, so the local file never holds one
and a body cannot accumulate them however many round trips it makes. The
marker survives a rename in the web UI, a lost .remote.json, a fresh
clone and another machine — none of which a local index does.

Deletion is the last thing that happens to an issue and only after the
transport returned, the answer carried a positive integer number (and, on
--update, the number that was PATCHed — push.confirmed_number), and
.remote.json was written. A raised transport, a non-2xx, an empty or
mismatched body each leave the file on disk and stop the run.

.remote.json is no longer "only an index over the files": its entries now
deliberately outlive them, so it is the local number -> slug ledger and
rebuild_map merges into it instead of reconstructing it from files that
may be gone. It stays recoverable, from the markers in Gitea rather than
from the files. push.dep_state reads it too, so a blocker whose file an
earlier push dropped still gets its native dependency link.

Also fixes a pre-existing bug the new tests hit: issue.all_ids treated
<id>.comments.md as an issue called "<id>.comments", so a bare push.py in
a store holding pulled threads tried to file a comment thread as a unit
of work. A slug has no dot in it.

tests/test_drop_after_push.py covers the round trip (push -> gone -> pull
-> identical in slug, depends: and body), the marker's algebra, and every
failure path separately. test_push_dependencies.py is updated where it
encoded the old "never deleted" contract. 183 tests, no network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 16:38:16 +05:00

355 lines
15 KiB
Python

#!/usr/bin/env python3
"""
map.py — md <-> Gitea JSON. The whole translation, and only the translation.
Pure functions: no network, no filesystem, no argparse. Give it a payload and
it hands back a domain Issue; give it an Issue and it hands back a request
body. That purity is the point — it can be reasoned about and tested without a
Gitea anywhere, and it is the single file to open when the two representations
disagree.
Direction of knowledge: this module imports the domain (issue.py) and is
imported by the transport's callers. The domain never imports this.
What crosses the boundary, and what does not:
domain Gitea note
----------------------------------------------------------------------
id (slug) body marker `<!-- tea:id … -->`, first line of
the tracker-side body; stripped out
of the local copy — see below
title title verbatim, both ways
body body verbatim up, verbatim down except
the marker and checkbox state — see
with_id_marker / merge_checkbox_state
state state open/closed, same vocabulary
labels labels[] names both ways; ids only on write
assignees assignees[] logins
milestone milestone.title resolved to an id on write
depends — slugs; #N is translated at the edge
— number, html_url lands in extra as gitea:/url:
— ref extra as branch:; push fills it from git
`depends:` is the authoritative graph and is always slugs. The body's
`## Depends on` section is human prose and is passed through UNCHANGED in both
directions: a pull seeds `depends:` from the `#N` it finds there, and a push
never rewrites what the author wrote. Deliberate — a translator that edits
prose churns the body on every round trip.
The ONE thing this module does add to a body is the id marker, and it does so
because the slug now has to survive a push: `push.py` deletes the local file,
so the tracker has to remember what the issue was called here. See
`with_id_marker`.
"""
import os
import re
import sys
sys.path.insert(0, os.path.normpath(os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "issue", "scripts")))
import issue # noqa: E402
# How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
# an issue IS, which is exactly why it lives here and not in the domain.
LABEL_COLORS = {
"type/bug": "#ee0701",
"type/task": "#0e8a16",
"type/refactor": "#1d76db",
"type/test": "#fbca04",
"type/feature": "#5319e7",
"type/draft": "#cccccc",
"severity/low": "#c2e0c6",
"severity/medium": "#fbca04",
"severity/high": "#eb6420",
"severity/showstopper": "#ee0701",
"severity/critical": "#b60205",
}
DEFAULT_COLOR = "#ededed"
# What this bridge writes into the domain's `origin:` field. The domain records
# that an issue exists somewhere else; only this module knows where.
ORIGIN = "gitea"
# Metadata key for Gitea's `ref` — the branch an issue is pinned to. A sync
# field: its value is a git branch name and means exactly `ref`, so the domain
# carries it in `extra` and never reads it.
BRANCH_KEY = "branch"
def label_specs(names):
"""{name: {color, description, exclusive}} for the transport to create.
Exclusivity and meaning come from the domain taxonomy; only the color is
decided here. `tea labels create` cannot set `exclusive` (as of 0.14.2),
which is why these go through the API."""
out = {}
for name in names:
desc = ""
if name.startswith("type/"):
desc = issue.TYPES.get(name.split("/", 1)[1], "")
out[name] = {
"color": LABEL_COLORS.get(name, DEFAULT_COLOR),
"description": desc,
"exclusive": name.startswith(issue.EXCLUSIVE_NS),
}
return out
def remote_key(repo, number):
"""Stable cross-repo handle: owner/repo#42."""
return "%s#%d" % (repo, int(number))
def parse_remote_key(key):
repo, _, num = (key or "").rpartition("#")
return (repo, int(num)) if repo and num.isdigit() else (None, None)
# --------------------------------------------------------------------------
# the id marker: the slug, kept tracker-side
# --------------------------------------------------------------------------
# `push.py` deletes the local file once the tracker has confirmed the write, so
# the slug — the issue's ONLY identity in the domain — cannot live only on this
# machine any more. It rides up in the body as an HTML comment:
#
# <!-- tea:id wire-sqlc-appclick -->
#
# Why the body and not `.remote.json`: the map is a local file, and "the local
# copy is not the record" is the whole point of deleting it. A marker in the
# body survives a rename in the web UI, a lost `.remote.json`, a fresh clone,
# and a second machine — none of which the map does. Why an HTML comment: Gitea
# renders markdown, so it is invisible to a human reader, and it comes back
# verbatim on every API read.
#
# WHERE: the first line of the tracker-side body, followed by one blank line.
# First because it is the one position that does not depend on what sections the
# issue happens to have, and because a human who does look at the raw markdown
# finds it before the prose rather than buried in it.
#
# WHAT THE LOCAL FILE SEES: nothing. `from_api` strips every marker before the
# body is written to disk, so `tmp/issues/<id>.md` holds exactly what the author
# wrote — checkbox line numbers, `issue_check.py`, and diffs are all unaffected,
# and the slug is already the file's name, so a copy of it in the body would be
# duplicated state.
#
# WHY IT CANNOT ACCUMULATE: the two operations are strip-all and
# strip-all-then-prepend-one. `with_id_marker` never appends to what is there,
# and `strip_id_marker` removes EVERY marker line, not the first. So a body that
# somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on the
# next pull and goes back up with exactly one. There is no code path that adds
# a marker to a body that has not just been stripped.
_MARKER_LINE = re.compile(r'^[ \t]*<!--[ \t]*tea:id[ \t]+(\S+)[ \t]*-->[ \t]*$')
def id_marker(id):
"""The marker line for a slug. One place formats it, one regex reads it."""
return "<!-- tea:id %s -->" % id
def id_in_body(body):
"""The slug a tracker-side body claims, or None.
The FIRST valid marker wins; a second one is ignored here and removed by
`strip_id_marker` on the way in. The captured text must be a slug by the
domain's own rule — a marker holding anything else is not a slug and is
treated as if it were not there, so a mangled comment falls back to the
title instead of naming a file after garbage."""
for line in (body or "").splitlines():
m = _MARKER_LINE.match(line)
if m and issue.SLUG_OK.match(m.group(1)):
return m.group(1)
return None
def strip_id_marker(body):
"""`body` with every marker line removed. Idempotent.
A body that carries no marker is returned byte for byte — the common case
(an issue filed in the web UI) costs nothing and is not reformatted. When a
marker is removed from the top, the blank line it was written with goes with
it, so the round trip is exact: strip(with_id_marker(b, id)) == b."""
text = body or ""
if not any(_MARKER_LINE.match(l) for l in text.splitlines()):
return text
kept = [l for l in text.splitlines() if not _MARKER_LINE.match(l)]
return "\n".join(kept).lstrip("\n")
def with_id_marker(body, id):
"""`body` with exactly one marker, as its first line.
Strip-then-prepend, always — that is the guarantee that a body can never end
up with two, however many it arrived with."""
return "%s\n\n%s" % (id_marker(id), strip_id_marker(body))
# --------------------------------------------------------------------------
# Gitea -> domain
# --------------------------------------------------------------------------
def numbers_in_body(body):
"""`#N` referenced from the body's dependency sections, as ints. Used only
to seed `depends:` on the first pull."""
return [int(r[1:]) for r in issue.body_dep_refs(body) if r.startswith("#")]
def merge_checkbox_state(remote_body, local_body):
"""The remote body with every tick the local copy already had put back.
The one exception to "a pull overwrites the body", and it is deliberately
the narrowest one that works. A tick is **monotone** — an item only ever
travels `[ ]` -> `[x]` — so the two sides are joined by a set union, not
reconciled: no base version, no drift tracking, no conflict to resolve. The
set is a set of item TEXTS, and an item comes out ticked when either side
has it ticked. Everything else in the body is still the remote's word.
Matching is on `Checkbox.text`, which the domain parser has already
stripped and rejoined with single spaces, so rewrapping a long item does
not cost it its tick. It is otherwise literal: reword an item and it is a
different item — the tick stays with the wording it was put on.
**The same text more than once** is read as the rule says, as a set: one
ticked local item ticks every remote item with that text. The alternative —
pairing duplicates up by order — is the reading that can still drop a tick
(local `[ ]` then `[x]`, remote a single line: the ticked one pairs with
nothing), and dropping a tick is the bug this exists to fix. Two items
whose text is identical are the same item to whoever reads them.
Pure: no store, no tracker, no I/O. A `local_body` of None or "" — a first
pull, an empty store — returns the remote body untouched.
The price, accepted explicitly: UNticking is not monotone, so a box
unticked in the web UI comes back on the next pull. Untick locally, push.
"""
ticked = {c.text for c in issue.checkboxes(local_body) if c.checked}
if not ticked:
return remote_body
body = remote_body
# set_checkbox trades one character for one character, so line numbers read
# off `remote_body` stay valid against the partially rewritten `body`.
for c in issue.checkboxes(remote_body):
if not c.checked and c.text in ticked:
body = issue.set_checkbox(body, c.line, True)
return body
def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None,
local_body=None):
"""Build a domain Issue from a Gitea issue payload.
id_for_number maps a Gitea number to a local slug — dependencies whose
target has not been pulled yet are dropped from `depends:` (the body still
names them, so nothing is lost) rather than invented.
`local_body` is the body of the copy already in the store, when there is
one. It contributes exactly one thing: its ticked checkboxes survive the
overwrite (merge_checkbox_state). Pass None and the remote body is taken
whole, which is what a first pull does.
The id marker is stripped before anything else looks at the body: it is
transport bookkeeping, and the caller has already read the slug off it
(`pull.id_for`). Everything downstream — checkboxes, `#N` references, what
lands on disk — sees the body the author wrote."""
body = merge_checkbox_state(
strip_id_marker((payload.get("body") or "").strip()), local_body)
id_for_number = id_for_number or {}
numbers = list(numbers_in_body(body))
for n in extra_numbers:
if n not in numbers:
numbers.append(n)
depends, unresolved = [], []
for n in numbers:
slug = id_for_number.get(n)
if slug and slug != id and slug not in depends:
depends.append(slug)
elif not slug:
unresolved.append(n)
extra = {
"gitea": remote_key(repo, payload["number"]),
"url": payload.get("html_url", ""),
"synced": synced or "",
}
if payload.get("ref"):
extra[BRANCH_KEY] = payload["ref"]
if payload.get("updated_at"):
extra["remote-updated"] = payload["updated_at"]
if payload.get("comments"):
extra["comments"] = payload["comments"]
iss = issue.Issue(
id=id,
title=payload.get("title", ""),
body=body,
state=payload.get("state") or "open",
labels=[l.get("name", "") for l in payload.get("labels") or []],
assignees=[a.get("login", "") for a in payload.get("assignees") or []],
milestone=(payload.get("milestone") or {}).get("title") or "",
depends=depends,
origin=ORIGIN,
extra=extra)
return iss, unresolved
def render_comments(comments):
"""Comment thread as flat markdown. Read-only: nothing writes it back."""
out = []
for c in comments:
out.append("## comment %s%s%s" % (
c.get("id"), (c.get("user") or {}).get("login", ""),
(c.get("created_at") or "")[:10]))
out.append("")
out.append((c.get("body") or "(empty)").strip())
out.append("")
return "\n".join(out)
# --------------------------------------------------------------------------
# domain -> Gitea
# --------------------------------------------------------------------------
def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
"""Request body for POST /issues or PATCH /issues/{n}.
The prose is sent verbatim — see the module docstring on why slugs in
`## Depends on` are not rewritten to `#N`. The one addition is the id
marker, prepended (never appended) so the tracker remembers the slug after
push has deleted the local file. `from_api` takes it straight back off, so
the body still round-trips byte for byte."""
payload = {"title": iss.title,
"body": with_id_marker(iss.body.strip(), iss.id)}
if label_ids is not None:
payload["labels"] = [label_ids[l] for l in iss.labels if l in label_ids]
if iss.assignees:
payload["assignees"] = list(iss.assignees)
if milestone_id is not None:
payload["milestone"] = milestone_id
if include_state:
payload["state"] = iss.state
# An empty `branch:` is "no opinion", not "no branch": sending ref="" would
# clear whatever is set on the Gitea side, so the key is left out instead.
branch = (iss.extra.get(BRANCH_KEY) or "").strip()
if branch:
payload["ref"] = branch
return payload
def apply_remote(iss, payload, repo, synced):
"""Stamp the sync-owned fields onto an issue after a successful write.
Mutates and returns it; `origin` is the one domain field this touches."""
iss.origin = ORIGIN
iss.extra["gitea"] = remote_key(repo, payload["number"])
iss.extra["url"] = payload.get("html_url", "")
iss.extra["synced"] = synced
if payload.get("updated_at"):
iss.extra["remote-updated"] = payload["updated_at"]
return iss
def number_of(iss):
"""Gitea number for an already-synced issue, or None."""
_repo, n = parse_remote_key(iss.extra.get("gitea", ""))
return n