9480e48312
The plugin resolved its issue store from `__file__`, which put it inside a versioned plugin cache: issues written from one project were invisible from the next, and `origin: local` files — the only copy of that work by definition — were stranded a version bump at a time. The walk that answers "which directory is the project" was written three times over, and in a linked worktree the three disagreed. Both are runtime failures rather than logic ones, so the fix is a compiled binary: one walk, imported rather than re-derived, and a layering rule the build graph enforces instead of a grep. Seven packages, knowledge flowing one way. `project` answers which directory is the project and depends on nothing. `issue` is the domain — format, taxonomy, validation, checkboxes, dependency graph, the store, eviction — offline, with no tracker in it. `wire` holds the protocol shapes. `gitea` is the transport, `mapping` the bridge, `config` the credentials, `cmd` the command tree. Four tests hold the boundaries, each failing on a real mistake rather than a naming convention. The marker moves to `.kettle/` and the login pin moves out of the harness's settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens live in one file per machine, mode 0600, outside every working tree. That retires the PreToolUse guard hook entirely — the binary holds its own credentials, so a command running under a login nobody chose is not expressible rather than caught. `kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a move: a store left behind at an old path is one somebody edits by accident months later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
98 lines
3.5 KiB
Go
98 lines
3.5 KiB
Go
package mapping
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
|
|
"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 nil map
|
|
// leaves `labels` out of the request; a non-nil one sends the list, empty
|
|
// included, and 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.
|
|
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`. An edit that means to open or close says so;
|
|
// a create takes the tracker's default.
|
|
IncludeState bool
|
|
}
|
|
|
|
// ToRequest is the request body for creating or editing an issue.
|
|
//
|
|
// 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.
|
|
//
|
|
// A create needs a title and a body, so those two are always filled. Every
|
|
// other key is left out unless the caller has an opinion about it: on a PATCH
|
|
// an absent key leaves the tracker's value alone, and a present one overwrites
|
|
// it — see wire.IssueRequest for what each of them clears when it is sent
|
|
// empty.
|
|
func ToRequest(i *issue.Issue, opt RequestOptions) *wire.IssueRequest {
|
|
r := &wire.IssueRequest{
|
|
Title: wire.Set(i.Title),
|
|
Body: wire.Set(WithIDMarker(strings.TrimSpace(i.Body), i.ID)),
|
|
}
|
|
if opt.LabelIDs != nil {
|
|
ids := []int64{}
|
|
for _, name := range i.Labels {
|
|
if id, ok := opt.LabelIDs[name]; ok {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
r.Labels = &ids
|
|
}
|
|
// 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.
|
|
if len(i.Assignees) > 0 {
|
|
r.Assignees = wire.Set(slices.Clone(i.Assignees))
|
|
}
|
|
if opt.MilestoneID != nil {
|
|
r.Milestone = opt.MilestoneID
|
|
}
|
|
if opt.IncludeState {
|
|
r.State = wire.Set(i.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 != "" {
|
|
r.Ref = wire.Set(branch)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// 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 *wire.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: p.Number}.String()
|
|
i.Extra[URLKey] = p.HTMLURL
|
|
i.Extra[SyncedKey] = synced
|
|
if p.UpdatedAt != "" {
|
|
i.Extra[RemoteUpdatedKey] = p.UpdatedAt
|
|
}
|
|
return i
|
|
}
|