package mapping import ( "slices" "strings" sdk "code.gitea.io/sdk/gitea" "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" ) // domain -> Gitea. // RequestOptions are what the transport resolved before the call: label names // are ids by then, and a milestone title is a number. // // Both are lookups against one repository, which is why they cannot be done // here — this package never learns which repository it is translating for // beyond the name it is handed. type RequestOptions struct { // LabelIDs is name -> id for the labels this repository holds. A label the // repository does not have is silently left off rather than failing the // write — an unknown label is a bootstrap that has not run, not a reason to // lose the issue. // // It is read by ToCreate and ignored by ToEdit, because Gitea's edit // endpoint carries no labels at all. Changing them on an issue that exists // is a PUT of its own; push makes it. LabelIDs map[string]int64 // MilestoneID is the resolved milestone. nil leaves the key out, which on // an edit means "leave whatever is attached alone". MilestoneID *int64 // IncludeState sends `state` on an edit. An edit that means to open or // close says so; a create takes the tracker's default. IncludeState bool } // ToCreate is the body of a create. // // The prose is sent verbatim — see the package doc on why slugs in // `## Depends on` are not rewritten to `#N`. The one addition is the id marker, // prepended (never appended) so the tracker remembers the slug after push has // deleted the local file. FromPayload takes it straight back off, so the body // still round-trips byte for byte. // // Every field of a create is a value and every one of them is sent, which is // safe in a way an edit is not: there is nothing on the tracker's side yet for // an empty field to overwrite. func ToCreate(i *issue.Issue, opt RequestOptions) sdk.CreateIssueOption { out := sdk.CreateIssueOption{ Title: i.Title, Body: WithIDMarker(strings.TrimSpace(i.Body), i.ID), // Copied, so the request body and the issue it came from cannot alias // one slice: whatever a caller does to either afterwards is not a // change to what was sent. Assignees: slices.Clone(i.Assignees), Labels: LabelIDsFor(i, opt), Ref: strings.TrimSpace(i.Extra[BranchKey]), } if opt.MilestoneID != nil { out.Milestone = *opt.MilestoneID } return out } // ToEdit is the body of an edit. // // EVERY FIELD IS A POINTER AND MOST OF THEM ARE LEFT NIL, because Gitea reads // an absent value as "no opinion" and a present one as "make it this", and the // difference is not academic: an empty `ref` CLEARS the branch an issue is // pinned to, and an empty `assignees` clears its assignees. A caller meaning to // change only the state would do both by accident with plain zero values. // // The exception is the title, which the SDK spells as a plain string and always // sends. Gitea reads an EMPTY title as "leave it alone" — it is the one field // of an edit where the zero value already means no opinion — so a caller that // wants to rename says so by filling it, and `kettle close` stays a change of // state and nothing else. func ToEdit(i *issue.Issue, opt RequestOptions) sdk.EditIssueOption { out := sdk.EditIssueOption{ Title: i.Title, Body: sdk.OptionalString(WithIDMarker(strings.TrimSpace(i.Body), i.ID)), Milestone: opt.MilestoneID, } if len(i.Assignees) > 0 { out.Assignees = slices.Clone(i.Assignees) } if opt.IncludeState { state := sdk.StateType(i.State) out.State = &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. if branch := strings.TrimSpace(i.Extra[BranchKey]); branch != "" { out.Ref = sdk.OptionalString(branch) } return out } // LabelIDsFor is the ids this issue's labels resolve to, in the issue's own // order — what a create sends, and what a push PUTs at an issue whose labels // have to be made to match afterwards. // // One answer to one question, exported so those two cannot derive it // differently: an edit carries no labels at all, so every issue that already // exists gets its label set through a PUT, and a PUT that disagreed with what a // create would have sent would make a pushed issue and a re-pushed one two // different things. // // nil for a caller that resolved no ids, and an empty list — never nil — for // one that resolved some and matched none. The difference is a statement to the // tracker: `[]` clears every label on the issue. func LabelIDsFor(i *issue.Issue, opt RequestOptions) []int64 { if opt.LabelIDs == nil { return nil } ids := []int64{} for _, name := range i.Labels { if id, ok := opt.LabelIDs[name]; ok { ids = append(ids, id) } } return ids } // ApplyRemote stamps the sync-owned fields onto an issue after a successful // write. Mutates and returns it; `origin` is the one domain field this touches, // and it touches it because "this work exists somewhere else now" is exactly // what has just become true. func ApplyRemote(i *issue.Issue, p *sdk.Issue, repo wire.Repo, synced string) *issue.Issue { if i.Extra == nil { i.Extra = map[string]string{} } i.Origin = Origin i.Extra[GiteaKey] = wire.Key{Repo: repo, Number: int(p.Index)}.String() i.Extra[URLKey] = p.HTMLURL i.Extra[SyncedKey] = synced if stamp := Stamp(p.Updated); stamp != "" { i.Extra[RemoteUpdatedKey] = stamp } return i }