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>
209 lines
7.9 KiB
Python
209 lines
7.9 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) — local only; the tracker never sees it
|
|
title, body title, body verbatim, both ways
|
|
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.
|
|
"""
|
|
import os
|
|
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)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 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 from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=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."""
|
|
body = (payload.get("body") or "").strip()
|
|
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 body is sent verbatim — see the module docstring on why slugs in
|
|
`## Depends on` are not rewritten to `#N`."""
|
|
payload = {"title": iss.title, "body": iss.body.strip()}
|
|
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
|