1239fdee70
The transport was hand-rolled net/http against the REST API. The payload shapes were ours, in internal/wire, which meant every field Gitea learned was a field somebody here had to notice; and "does this instance have issue dependencies?" had to be guessed from a status code, because a 404 from a missing route and a 404 from a missing issue look alike. The SDK settles both. The shapes are maintained by the people who maintain the server, and the client negotiates the server's version when it is built, so the dependency endpoint is now gated on `>= 1.20.0` — verified against the release where the route appears, not assumed. Below the gate nothing is requested at all. internal/wire keeps what the SDK has no answer for: addressing. The SDK takes an owner, a name and an int64 and never parses, while `42`, `#42`, `owner/repo#42` and an issue URL are four spellings of one address, all four are what somebody has in hand, and Key is what the ledger is keyed by. The payload structs go. Four things that had to survive the move, and did: - request bodies still land in .kettle/payload/, now via an http.RoundTripper on the client the SDK is given — which is better than before, because it files every request rather than the ones a call site remembered to name; - errors still carry the HTTP status AND the response body, and a decode failure on a 2xx is deliberately not an APIError, so the dependency probe cannot read a bad decode as "feature missing"; - the number -> slug ledger is untouched, entries still outlive the files they name; - Client.For(repo) still re-points at another repository for one call. What it cost, written down in AGENTS.md where it happened. internal/mapping's layering test was a fact about the import graph — nothing in its closure could open a socket — and the SDK ships its types and its client in one package, so the test now asserts what is still true: the bridge performs no I/O, checked on direct imports plus a grep for time.Now. A run makes one extra request before it does anything. Gitea's issue edit endpoint carries no labels, so a push whose labels changed needs a second call; push makes it and says so. go.mod requires go 1.26, which the SDK sets and which is now the floor for building this binary. internal/issue and internal/project are byte-identical. The domain did not notice, which is the whole argument for the layering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
261 lines
8.7 KiB
Go
261 lines
8.7 KiB
Go
package mapping
|
|
|
|
import (
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
sdk "code.gitea.io/sdk/gitea"
|
|
|
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
|
)
|
|
|
|
// Gitea -> domain.
|
|
|
|
// PayloadOptions are the things a caller knows and this package cannot: what
|
|
// the store already holds, what the tracker's numbers mean locally, and what
|
|
// time it is.
|
|
type PayloadOptions struct {
|
|
// IDForNumber maps a Gitea number to a local slug. A dependency whose
|
|
// target has not been pulled yet is dropped from `depends:` rather than
|
|
// invented — the body still names it, so nothing is lost, and a made-up
|
|
// slug would be an edge to a file that does not exist.
|
|
IDForNumber map[int]string
|
|
// ExtraNumbers are dependencies the caller learned somewhere other than the
|
|
// body, folded in with the ones the body names.
|
|
ExtraNumbers []int
|
|
// Synced is the timestamp stamped into `synced:`. The clock belongs to the
|
|
// caller: a package with a clock in it is not a pure one.
|
|
Synced string
|
|
// LocalBody 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. Empty is what a first pull passes.
|
|
LocalBody string
|
|
}
|
|
|
|
// FromPayload builds a domain issue from a Gitea issue payload, and returns the
|
|
// numbers it could not resolve to a slug.
|
|
//
|
|
// 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 to
|
|
// decide which id to pass. Everything downstream — checkboxes, `#N` references,
|
|
// what lands on disk — sees the body the author wrote.
|
|
func FromPayload(p *sdk.Issue, id string, repo wire.Repo, opt PayloadOptions) (*issue.Issue, []int) {
|
|
body := MergeCheckboxState(StripIDMarker(strings.TrimSpace(p.Body)), opt.LocalBody)
|
|
|
|
numbers := NumbersInBody(body)
|
|
for _, n := range opt.ExtraNumbers {
|
|
if !slices.Contains(numbers, n) {
|
|
numbers = append(numbers, n)
|
|
}
|
|
}
|
|
|
|
// A number that resolves to this issue itself is dropped without a word: a
|
|
// body may well name its own number, and a self-edge is a cycle the graph
|
|
// would report as an error the author cannot fix.
|
|
var deps []string
|
|
var unresolved []int
|
|
for _, n := range numbers {
|
|
slug := opt.IDForNumber[n]
|
|
switch {
|
|
case slug != "" && slug != id && !slices.Contains(deps, slug):
|
|
deps = append(deps, slug)
|
|
case slug == "":
|
|
unresolved = append(unresolved, n)
|
|
}
|
|
}
|
|
|
|
// The repository the caller asked for, never the one the payload names: a
|
|
// dependency listing answers with issues from elsewhere, and this is the
|
|
// handle for the copy landing in THIS store.
|
|
extra := map[string]string{
|
|
GiteaKey: wire.Key{Repo: repo, Number: int(p.Index)}.String(),
|
|
URLKey: p.HTMLURL,
|
|
SyncedKey: opt.Synced,
|
|
}
|
|
if p.Ref != "" {
|
|
extra[BranchKey] = p.Ref
|
|
}
|
|
if stamp := Stamp(p.Updated); stamp != "" {
|
|
extra[RemoteUpdatedKey] = stamp
|
|
}
|
|
// Zero comments is not a fact worth a line in the file — every issue that
|
|
// has never been discussed would carry one.
|
|
if p.Comments > 0 {
|
|
extra[CommentsKey] = strconv.Itoa(p.Comments)
|
|
}
|
|
|
|
state := string(p.State)
|
|
if state == "" {
|
|
state = "open"
|
|
}
|
|
|
|
return &issue.Issue{
|
|
ID: id,
|
|
Title: p.Title,
|
|
Body: body,
|
|
State: state,
|
|
Labels: LabelNames(p),
|
|
Assignees: AssigneeLogins(p),
|
|
Milestone: MilestoneTitle(p),
|
|
Depends: deps,
|
|
Origin: Origin,
|
|
Extra: extra,
|
|
}, unresolved
|
|
}
|
|
|
|
// LabelNames are a payload's label names, in the order the tracker listed them.
|
|
//
|
|
// Appended into a nil slice, so an issue with no labels is the same value as
|
|
// one loaded from a file — the store's own parser yields nothing, not an empty
|
|
// list, and two spellings of "none" is a comparison bug waiting. The same goes
|
|
// for the two below.
|
|
func LabelNames(p *sdk.Issue) []string {
|
|
var out []string
|
|
for _, l := range p.Labels {
|
|
if l != nil {
|
|
out = append(out, l.Name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// AssigneeLogins are a payload's assignees, as logins.
|
|
//
|
|
// Only the login crosses this boundary — it is the one field of a Gitea user
|
|
// that means anything to a command, it is what `assignees:` holds, and a
|
|
// display name is not an identity anything can be pushed against.
|
|
func AssigneeLogins(p *sdk.Issue) []string {
|
|
var out []string
|
|
for _, a := range p.Assignees {
|
|
if a != nil {
|
|
out = append(out, a.UserName)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// MilestoneTitle is a payload's milestone title, or "" when it has none. The
|
|
// domain carries the title; the id exists only long enough to be sent back.
|
|
func MilestoneTitle(p *sdk.Issue) string {
|
|
if p.Milestone == nil {
|
|
return ""
|
|
}
|
|
return p.Milestone.Title
|
|
}
|
|
|
|
// Stamp is how a tracker timestamp is written into an issue's metadata, and ""
|
|
// for a time the payload did not carry.
|
|
//
|
|
// The zero time is not a date: an issue whose `updated_at` was absent would
|
|
// otherwise be stamped `0001-01-01`, which reads as a fact and is not one.
|
|
//
|
|
// RFC3339 both ways. These values are written into a file, compared as opaque
|
|
// strings and handed back; the SDK parses them into a time.Time on the way in,
|
|
// so something has to spell them out again, and the format Gitea sends is the
|
|
// format they go back out in. What was true when this was a string end to end —
|
|
// that no round trip could change the spelling — is not any more: a timestamp
|
|
// with a fraction of a second in it comes back without one.
|
|
func Stamp(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.Format(time.RFC3339)
|
|
}
|
|
|
|
// NumbersInBody is every `#N` referenced from the body's dependency sections.
|
|
// Used only to seed `depends:` on the first pull — after that the metadata
|
|
// field is the graph and the prose is prose.
|
|
func NumbersInBody(body string) []int {
|
|
var out []int
|
|
for _, ref := range issue.BodyDepRefs(body) {
|
|
if !strings.HasPrefix(ref.Ref, "#") {
|
|
continue
|
|
}
|
|
if n, err := strconv.Atoi(ref.Ref[1:]); err == nil {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// MergeCheckboxState is the remote body with every tick the local copy already
|
|
// had put back.
|
|
//
|
|
// The one exception to "a pull overwrites the body", and 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.
|
|
//
|
|
// 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.
|
|
func MergeCheckboxState(remoteBody, localBody string) string {
|
|
ticked := map[string]bool{}
|
|
for _, c := range issue.Checkboxes(localBody) {
|
|
if c.Checked {
|
|
ticked[c.Text] = true
|
|
}
|
|
}
|
|
if len(ticked) == 0 {
|
|
return remoteBody
|
|
}
|
|
body := remoteBody
|
|
// SetCheckbox trades one character for one character, so line numbers read
|
|
// off remoteBody stay valid against the partially rewritten body.
|
|
for _, c := range issue.Checkboxes(remoteBody) {
|
|
if c.Checked || !ticked[c.Text] {
|
|
continue
|
|
}
|
|
// The line was just read off remoteBody by the same parser, so this
|
|
// cannot fail; if it ever did, one unticked item is a smaller loss than
|
|
// abandoning the merge and dropping every other tick with it.
|
|
if next, err := issue.SetCheckbox(body, c.Line, true); err == nil {
|
|
body = next
|
|
}
|
|
}
|
|
return body
|
|
}
|
|
|
|
// RenderComments flattens a comment thread to markdown. Read-only: nothing
|
|
// writes it back, which is why it may be as lossy as a reader needs.
|
|
func RenderComments(comments []*sdk.Comment) string {
|
|
var out []string
|
|
for _, c := range comments {
|
|
if c == nil {
|
|
continue
|
|
}
|
|
day := Stamp(c.Created)
|
|
if len(day) > 10 {
|
|
day = day[:10]
|
|
}
|
|
who := ""
|
|
if c.Poster != nil {
|
|
who = c.Poster.UserName
|
|
}
|
|
body := strings.TrimSpace(c.Body)
|
|
if body == "" {
|
|
body = "(empty)"
|
|
}
|
|
out = append(out,
|
|
"## comment "+strconv.FormatInt(c.ID, 10)+" — "+who+" — "+day,
|
|
"", body, "")
|
|
}
|
|
return strings.Join(out, "\n")
|
|
}
|