// Package mapping is md <-> Gitea JSON. The whole translation, and only the // translation. // // Pure functions: no network, no filesystem, no flags, no clock. 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 one package to open when the // two representations disagree. // // Direction of knowledge: this package imports the domain and the protocol // (internal/wire), and nothing imports it but the command layer. The domain // never imports it, and TestDomainDependsOnNothing over in internal/issue fails // the moment it does; the transport never imports it either, and // TestTransportDoesNotImportTheDomain over in internal/gitea says so. Both // sides speak wire's shapes, which is what lets the two meet without either one // reaching into the other. // // What crosses the boundary, and what does not: // // domain Gitea note // ---------------------------------------------------------------------- // id (slug) body marker , first line of the // tracker-side body; stripped out of the // local copy — see marker.go // title title verbatim, both ways // body body verbatim up, verbatim down except the // marker and checkbox state // state state open/closed, the 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 this 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 package adds to a body is the id marker, and it does so // because the slug has to survive a push: push deletes the local file, so the // tracker has to be the thing that remembers what the issue was called here. package mapping import ( "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" ) // Origin is what this bridge writes into the domain's `origin:` field. The // domain records that an issue exists somewhere else; only this layer knows // where, and what the handle beside it means. const Origin = "gitea" // The sync-owned metadata fields, named once. Every one of them is bookkeeping // about a tracker, which is why the domain carries them verbatim in // Issue.Extra and never reads them — the format's ownership table draws the // same line. A field spelled in three call sites is a field that gets renamed // in two. const ( // GiteaKey is the handle in the tracker: owner/repo#42, a wire.Key written // out. Cross-repo on purpose — a number alone is only unique inside one // repository, and an issue that has been moved, or a store that has ever // pointed at two repositories, needs the answer to say which. GiteaKey = "gitea" // URLKey is the issue's web address, for a receipt a human can click. URLKey = "url" // SyncedKey is when this copy was last written from or to the tracker — // how old the working copy is, and nothing more. SyncedKey = "synced" // RemoteUpdatedKey is the tracker's own updated_at. RemoteUpdatedKey = "remote-updated" // CommentsKey is how many comments the tracker holds, so a reader knows a // thread exists without fetching it. CommentsKey = "comments" // BranchKey is Gitea's `ref` — the branch an issue is pinned to. Its value // is a git branch name and means exactly `ref`, which is what makes it a // sync field rather than a domain one. BranchKey = "branch" ) // RemoteKeyOf is the handle an issue carries, and whether it carries one at // all. // // ok is false for anything that is not a handle: an empty field on a // never-pushed issue, a line somebody hand-edited, a key written by a format // that predates this one — and a bare `#42`, which names a number without the // repository that makes it mean something. Callers act on ok rather than on a // zero number, because "#0" and "not synced" would otherwise be the same // answer. func RemoteKeyOf(i *issue.Issue) (key wire.Key, ok bool) { k, err := wire.ParseKey(i.Extra[GiteaKey]) if err != nil || k.Repo.Zero() { return wire.Key{}, false } return k, true } // NumberOf is the Gitea number of an already-synced issue; ok is false for one // that has never been pushed. func NumberOf(i *issue.Issue) (number int, ok bool) { k, ok := RemoteKeyOf(i) return k.Number, ok }