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") }