#!/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: `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" 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("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 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