feat: add the kettle CLI, replacing the plugin's Python scripts

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>
This commit is contained in:
naudachu
2026-08-11 19:05:39 +05:00
parent fb5445915f
commit 9480e48312
83 changed files with 23894 additions and 0 deletions
+233
View File
@@ -0,0 +1,233 @@
# AGENTS.md — the kettle CLI
`kettle` is a globally installed binary. It owns everything that used to be a
Python script under `plugins/tea/skills/*/scripts/`: what an issue is, where the
store lives, who this machine is, and (once the transport lands) how issues move
to and from Gitea.
The plugin keeps what only a plugin can carry — the rules an operator states and
a binary cannot enforce. Everything mechanical is here.
## Why a binary
Three failures in the Python version were failures of *runtime*, not of logic:
- the store resolved from `__file__`, so it landed inside a versioned plugin
cache and issues written from one project were invisible from the next;
- the walk that answers "which directory is the project" was written three
times — store, login pin, guard hook — and in a linked worktree the three
disagreed;
- `sys.path.insert` was the import mechanism, so the layering rule was a
convention checked by grep.
A compiled binary answers all three by construction. There is one walk
(`internal/project`), it is imported rather than re-derived, and the layering
rule is a build graph a test walks.
## One dependency
`gopkg.in/yaml.v3`, vendored, and that is the whole list. Everything else is the
standard library: the transport is plain `net/http` against a documented REST
API, and the CLI has no cobra — commands are values in a registry, which is what
lets the plugin's SKILL.md files be generated from the same struct that holds
the code.
`vendor/` is committed, so a build needs no network.
## Layers
Knowledge flows one way. The arrow means "imports".
```
cmd/kettle thin main; exit status only
internal/cmd the command tree: flags, receipts, exit codes
│ │ │
│ │ └────► internal/config who this machine is, what this
│ │ project points at; yaml lives here
│ │ and only here
│ └───────────► internal/gitea TRANSPORT: one door for every
│ │ request, pagination, payload dumps,
│ │ the number -> slug ledger
│ ▼
├────────────────────► internal/wire PROTOCOL: the JSON shapes and the
│ ▲ identifiers. Imports nothing.
│ │
└──► internal/mapping ─────┘ BRIDGE: md <-> those shapes, pure,
│ no I/O; label colours live here
internal/issue DOMAIN what an issue is: format, taxonomy, validation,
│ checkboxes, dependency graph, the store, eviction
│ offline — no tracker, no network, no JSON
internal/project ROOT which directory is the project, and every path
resolved from it: store, payload, config
depends on nothing
```
Read it bottom-up and each layer knows strictly less about trackers than the one
above it. Four tests hold the line, and each fails on a real mistake rather than
on a naming convention:
- `internal/issue` may import `internal/project` and the standard library, and
nothing else. One test walks `go list -deps` and fails on any path with a dot
in its first element — which is also what keeps yaml out of the domain — and
another names `net/http`, `net`, `os/exec` and `encoding/json`, standard
library the first test would not catch.
- `internal/wire` imports only the standard library, checked the same two ways.
- `internal/gitea` must not import `internal/issue` **or** `internal/mapping`:
the transport knows numbers, logins, HTTP and JSON, and none of what they mean.
- `internal/mapping` performs no I/O and imports neither the transport nor the
configuration.
`wire` exists because Go needs the JSON shapes to be one type. The transport and
the bridge were written in parallel and each invented its own `Issue`, `Label`,
`Milestone` and `Comment`; every command on top would then have copied fields
from one struct into the other by hand, which is two vocabularies for one thing —
exactly what this layering exists to prevent. Python did not have the problem
because it passed dicts.
If a tracker concept — an issue number, a login, an HTTP call, a label colour —
shows up in `internal/issue`, it is in the wrong place.
## The walk
`internal/project` answers one question and everything else reads the answer.
Anchors, first hit wins: `$CLAUDE_PROJECT_DIR`, then the working directory. Each
is searched up its parent chain for a `.kettle/` marker, and then — only if that
found nothing — up the parent chain of the **main working tree of any linked
worktree** met on the way, reached by reading `gitdir:` out of a `.git` *file*
and following `commondir`.
Nothing here resolves from the executable's own location. Where an installation
keeps its files is a fact about the installation; whose issues a tree has is a
fact about the tree, and a binary installed in one place and pointed at another
must answer from the one it was pointed at.
**The marker is created by `kettle init`, never inferred.** `.git` was tried and
is in every clone. No marker anywhere is an answer, not a fallback: the command
reports which directories it searched and stops.
## Configuration, and where secrets are not
Two files, and the split is the whole design.
`<project>/.kettle/config.yaml` — written by `kettle init`, says which tracker
repository the issues belong to and which login to reach it under. It pins a
login by **name**. The name is worth nothing on its own, which is what makes it
safe to keep in a file inside a working tree.
`~/.config/kettle/logins.yaml` (`$KETTLE_CONFIG_HOME` or `$XDG_CONFIG_HOME`
override it) — one file per machine, mode 0600, holding the tokens. Managed with
`kettle auth`, which reads the token from standard input by default because an
argument is in the shell history the moment it is typed. Nothing prints a token
back: `kettle config` shows `(set)`.
A token in a file inside a working tree ends up in a commit. Not always, not
immediately, and not by anyone careless — but a project config is exactly the
file somebody eventually decides to share, and a secret that has ever been
pushed has to be rotated.
`KETTLE_LOGIN`, `KETTLE_REPO`, `KETTLE_URL` and `KETTLE_TOKEN` each override the
file they shadow, for CI and for anyone who would rather have no token on disk.
Unknown keys in either file are an **error**, not a silent drop: an older binary
reading a newer config would otherwise delete the setting it did not recognize
the next time it wrote the file.
`init` gitignores `.kettle/` wholesale. An `origin: local` issue is the only copy
of that work, and what goes into a shared history is the operator's call, not
this binary's — drop the line if the team decides otherwise.
## The store
`<project root>/.kettle/issues`, one flat markdown file per issue, named by its
slug, one metadata field per line so plain grep works without a parser.
It holds two kinds of file and only one of them is a store. An `origin: local`
issue lives here and nowhere else — that file **is** the issue. Anything with a
tracker origin is a working copy, deleted the moment a push confirms the tracker
is up to date. Eviction makes the same trade one step earlier and asks the file
instead of the tracker, which is why it lives in the domain.
`.kettle/payload/` is a sibling, never a child: request bodies are debris of the
transport, and a scratchpad inside a store makes `ls .kettle/issues` lie about
what exists.
`kettle init` migrates older layouts in, oldest first — `tmp/issues` and then
`.tea/issues` — and each is a **move**. A store left behind at an old path is a
store somebody will edit by accident months later. It refuses to pick a winner
when both sides hold a file of the same name.
## Tests
```bash
cd cli && go test ./...
```
`internal/cmd` builds the binary once in `TestMain` and runs it as a subprocess
against a throwaway project in a temp directory — the same discipline the Python
suite arrived at, for the same reason. **The binary is never run in the
directory it was built in**, because that is exactly the arrangement that hid
the `__file__` bug: a tool is installed in one place and used on projects in
another, and a test that collapses the two proves nothing about resolution.
Every fixture strips `CLAUDE_PROJECT_DIR`. It is the first anchor of the walk,
so the harness's own value would point every fixture at this repository.
Anything touching credentials sets `KETTLE_CONFIG_HOME` at a temp directory, so
a test run can neither read nor overwrite the developer's own tokens.
## The round trip
`push` and `pull` are the two halves of one rule, and the rule is that **the
store holds what has not left this machine.**
A successful push deletes `<id>.md` and every sidecar under that slug, on create
and on `--update` alike, and prints the number and URL the issue now lives at.
The deletion happens **only after a confirmed tracker response and only after the
number -> slug ledger has been written** — network down, non-2xx, or an answer
that does not carry the right number, and the file stays where it is while the
run stops. A never-pushed `origin: local` issue is never touched by any of it.
The slug survives that round trip two ways over, and a test proves both: it goes
up in the body as `<!-- kettle:id … -->`, and it is indexed by number in
`.kettle/issues/.remote.json`. A rename in the web UI, a lost ledger, a fresh
clone, another machine — the file comes back under the same name and every
`depends:` pointing at it still resolves. The marker is written in that spelling
and read in both it and the older `<!-- tea:id … -->`, because issues pushed
before the rename are still in the tracker.
Pull by number fetches an issue in **any** state: a number is an address, not a
query, and `42`, `#42`, `owner/repo#42` and a URL all name one. Only filter mode
leaves closed issues out. A pull returns the unit of work rather than one row of
it — blockers come down with it unless `--no-deps` says otherwise — and it
overwrites the body, because it is a fetch and not a merge. The one exception is
checkbox state, which is the local half of the work and is merged rather than
clobbered.
## No guard hook
The Python version needed a `PreToolUse` hook to block any `tea` command that
would run under a login the model picked instead of the operator. That whole
apparatus is gone. The binary holds its own credentials and reads the login out
of the project's own configuration, so there is no argument to police and no way
for the transport and the guard to disagree — the failure the hook existed to
catch is not expressible any more.
There is also no `--login` and no `--repo` on any sync command bar `labels`.
Which login a project runs under, and which repository its issues belong to, are
facts about the project, stated once by `kettle init`. A cross-repository address
is still an address: `kettle pull owner/repo#42` re-points the client for that
one call.
## Status
Done and tested: all seven packages, and the commands `init`, `auth`, `config`,
`new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`, `push`, `remote`,
`comment`, `close`, `labels`, `sync-evict`. 89 tests.
Not done: the plugin still ships the Python scripts and the guard hook, and
still resolves `.tea/`. Rewiring `plugins/tea` onto this binary — and generating
its SKILL.md files from the command registry, so the docs cannot drift from the
CLI — is the remaining work.
+14
View File
@@ -0,0 +1,14 @@
// Command kettle keeps a project's issues as local markdown and moves them to
// and from a tracker.
//
// An issue is a unit of work first and a tracker row second. Everything under
// `kettle help` that is not in the sync group works with the network off.
package main
import (
"os"
"git.noodles.cam/claude-skills/marketplace/cli/internal/cmd"
)
func main() { os.Exit(cmd.Main(os.Args[1:])) }
+5
View File
@@ -0,0 +1,5 @@
module git.noodles.cam/claude-skills/marketplace/cli
go 1.24
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+187
View File
@@ -0,0 +1,187 @@
package cmd
import (
"flag"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
var numberRe = regexp.MustCompile(`^\d+$`)
func init() {
register(&Command{
Name: "ac",
Group: GroupIssue,
Args: "<id>",
Short: "list and tick an issue's checkboxes",
Long: `A checkbox is the one part of a body that is *state* and not prose. Everything
else is written once; boxes get ticked as the work goes, and the only other ways
to tick one are a human with an editor or a model rewriting the whole body — the
second worse than the first, because the rewrite re-flows the text and the
issue's diff swells around a change of one character. This changes that one
character and nothing else.
Named after ` + "`## Acceptance criteria`" + `, where most boxes live, but every checkbox in
the body is listed and tickable: a type/feature keeps its children under
` + "`## Issues`" + `, and binding this to one heading would silently lose half of them.
A substring picks an item only when it picks exactly one. Two matches is an
error listing both — a coin flip would tick the wrong box and look like it
worked.
Delivering the changed body to a tracker is not part of this; that is
` + "`kettle push --update`" + `.`,
Examples: []Example{
{"kettle ac wire-sqlc-appclick", "numbered list with state"},
{"kettle ac wire-sqlc-appclick --check 3", "tick by number"},
{"kettle ac wire-sqlc-appclick --check регресс", "tick by substring"},
{"kettle ac wire-sqlc-appclick --uncheck 3", "untick it again"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
check := fs.String("check", "", "tick one item: number or substring")
uncheck := fs.String("uncheck", "", "untick one item: number or substring")
out := storeFlag(fs)
return func(args []string) error {
if len(args) != 1 {
return Fail("give exactly one issue id")
}
checking, unchecking := wasSet(fs, "check"), wasSet(fs, "uncheck")
if checking && unchecking {
return Fail("--check and --uncheck are mutually exclusive")
}
id := args[0]
root, err := storeRoot(*out)
if err != nil {
return err
}
path := issue.PathOf(root, id)
// Raw bytes in and raw bytes out: byte-for-byte means the line
// endings too. Reading a CRLF file with translation and writing
// it back would rewrite every line while claiming to have
// changed one character.
raw, err := os.ReadFile(path)
if err != nil {
return Fail("no issue %q in %s", id, root)
}
text := string(raw)
// The whole file, not just the body: line numbers then point at
// the file, and the metadata block is rewritten by nobody.
// Round-tripping through the parser would re-render metadata and
// re-strip the body, which is exactly the churn this avoids.
items := issue.Checkboxes(text)
needle, checked := *check, true
if unchecking {
needle, checked = *uncheck, false
}
selecting := checking || unchecking
if len(items) == 0 {
if selecting {
return Fail("%s has no checkboxes", id)
}
fmt.Printf("%s — no checkboxes\n", id)
return nil
}
if !selecting {
done, total := issue.CheckboxProgress(text)
fmt.Printf("%s — %d/%d %s\n", id, done, total, path)
fmt.Println(strings.Join(listing(items), "\n"))
return nil
}
item, err := selectItem(items, needle)
if err != nil {
return err
}
updated, err := issue.SetCheckbox(text, item.Line, checked)
if err != nil {
return err
}
if updated == text {
fmt.Printf("unchanged %2d %s %s\n", item.Index, box(item.Checked), item.Text)
return nil
}
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
return err
}
if _, _, err := issue.BuildIndex(root); err != nil {
return err
}
verb := "checked"
if !checked {
verb = "unchecked"
}
done, total := issue.CheckboxProgress(updated)
fmt.Printf("%s %2d %s %s\n", verb, item.Index, box(checked), item.Text)
fmt.Printf("%s — %d/%d %s:%d\n", id, done, total, path, item.Line)
return nil
}
},
})
}
func box(checked bool) string {
if checked {
return "[x]"
}
return "[ ]"
}
// listing is the numbered list, grouped by the heading each item sits under.
func listing(items []issue.Checkbox) []string {
var out []string
section := "\x00" // no heading can equal this, so the first item opens a group
for _, c := range items {
if c.Section != section {
section = c.Section
head := section
if head == "" {
head = "(above the first heading)"
}
out = append(out, "", head)
}
out = append(out, fmt.Sprintf(" %2d %s %s", c.Index, box(c.Checked), c.Text))
}
return out
}
// selectItem resolves a --check/--uncheck argument to exactly one item.
func selectItem(items []issue.Checkbox, needle string) (issue.Checkbox, error) {
needle = strings.TrimSpace(needle)
if needle == "" {
return issue.Checkbox{}, Fail("empty selector — give an item number or a substring")
}
if numberRe.MatchString(needle) {
n, _ := strconv.Atoi(needle)
if n < 1 || n > len(items) {
return issue.Checkbox{}, Fail("no item %d — the issue has %d", n, len(items))
}
return items[n-1], nil
}
var hits []issue.Checkbox
for _, c := range items {
if strings.Contains(strings.ToLower(c.Text), strings.ToLower(needle)) {
hits = append(hits, c)
}
}
switch len(hits) {
case 0:
return issue.Checkbox{}, Fail("nothing matches %q", needle)
case 1:
return hits[0], nil
}
lines := []string{fmt.Sprintf("%q matches %d items — narrow it down, or use a number:", needle, len(hits))}
for _, c := range hits {
lines = append(lines, fmt.Sprintf(" %2d %s %s", c.Index, box(c.Checked), c.Text))
}
return issue.Checkbox{}, Fail("%s", strings.Join(lines, "\n"))
}
+140
View File
@@ -0,0 +1,140 @@
package cmd
import (
"bufio"
"flag"
"fmt"
"io"
"os"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
)
func init() {
register(&Command{
Name: "auth",
Group: GroupProject,
Args: "list | add | remove <name>",
Short: "manage the tokens this machine holds",
Long: `Credentials live in one file per machine, outside every working tree, mode
0600. A project pins a login by NAME; the name is worth nothing on its own,
which is what makes it safe to keep in a file inside the repository.
The token is read from standard input unless --token is given, because an
argument is in the shell history the moment it is typed:
kettle auth add --name noodles --url https://git.example.com < token.txt
pass show gitea/token | kettle auth add --name noodles --url https://git.example.com
` + "`list`" + ` never prints a token. There is no flag to make it.`,
Examples: []Example{
{"kettle auth list", "what this machine holds"},
{"pass show gitea | kettle auth add --name noodles --url https://git.example.com", "add one, token on stdin"},
{"kettle auth remove noodles", "forget it"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
name := fs.String("name", "", "login name (add)")
url := fs.String("url", "", "instance URL, e.g. https://git.example.com (add)")
user := fs.String("user", "", "account this token belongs to; documentation only (add)")
token := fs.String("token", "", "token, if you would rather not use stdin (add)")
return func(args []string) error {
verb := "list"
if len(args) > 0 {
verb = args[0]
}
logins, err := config.LoadLogins()
if err != nil {
return err
}
switch verb {
case "list":
if len(logins.Logins) == 0 {
fmt.Printf("no logins in %s\n", config.LoginsPath())
return nil
}
fmt.Printf("%s\n\n", config.LoginsPath())
for _, l := range logins.Logins {
who := l.User
if who == "" {
who = "—"
}
fmt.Printf(" %-16s %-40s %s\n", l.Name, l.URL, who)
}
return nil
case "add":
if *name == "" || *url == "" {
return Fail("--name and --url are both required")
}
secret := *token
if secret == "" {
if secret, err = readToken(os.Stdin); err != nil {
return err
}
}
if secret == "" {
return Fail("no token — pipe one in, or pass --token")
}
entry := config.Login{
Name: *name,
URL: strings.TrimRight(*url, "/"),
User: *user,
Token: secret,
}
if existing := logins.Find(*name); existing != nil {
*existing = entry
} else {
logins.Logins = append(logins.Logins, entry)
}
if err := config.SaveLogins(logins); err != nil {
return err
}
fmt.Printf("%s -> %s %s\n", entry.Name, entry.URL, config.LoginsPath())
return nil
case "remove":
if len(args) != 2 {
return Fail("give exactly one login name to remove")
}
target := args[1]
kept := logins.Logins[:0]
found := false
for _, l := range logins.Logins {
if l.Name == target {
found = true
continue
}
kept = append(kept, l)
}
if !found {
return Fail("no login %q in %s", target, config.LoginsPath())
}
logins.Logins = kept
if err := config.SaveLogins(logins); err != nil {
return err
}
fmt.Printf("removed %s\n", target)
return nil
}
return Fail("unknown subcommand %q — list, add, or remove", verb)
}
},
})
}
// readToken takes the first non-empty line of r, trimmed.
//
// The first line, not the whole stream: a token piped from a password manager
// often arrives with a trailing newline and sometimes with notes underneath it.
func readToken(r io.Reader) (string, error) {
sc := bufio.NewScanner(r)
for sc.Scan() {
if line := strings.TrimSpace(sc.Text()); line != "" {
return line, nil
}
}
return "", sc.Err()
}
+110
View File
@@ -0,0 +1,110 @@
package cmd
import (
"flag"
"fmt"
"sort"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
func init() {
register(&Command{
Name: "check",
Group: GroupIssue,
Args: "[<id>…]",
Short: "validate issues against the canonical format",
Long: `The same check the sync layer runs before it pushes anything, available on its
own so a local-only issue can be held to the format without a tracker being
involved.
Errors mean malformed; warnings mean it deviates from its type's template or its
graph looks suspect. An unticked checkbox is neither: work not done yet is the
normal state of a perfectly well-formed issue.
Exit status is 1 when anything has errors, which is what makes this usable in a
hook or a CI step.`,
Examples: []Example{
{"kettle check", "every issue in the store"},
{"kettle check wire-sqlc-appclick", "one issue"},
{"kettle check --quiet", "exit status only"},
{"kettle check --strict", "treat warnings as errors"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
quiet := fs.Bool("quiet", false, "exit status only, print nothing")
strict := fs.Bool("strict", false, "treat warnings as errors")
out := storeFlag(fs)
return func(args []string) error {
root, err := storeRoot(*out)
if err != nil {
return err
}
if err := issue.StoreError(root); err != nil {
return err
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
ids := args
if len(ids) == 0 {
for id := range issues {
ids = append(ids, id)
}
sort.Strings(ids)
}
known := map[string]bool{}
for id := range issues {
known[id] = true
}
for _, id := range ids {
if !known[id] {
return Fail("no issue %q in %s", id, root)
}
}
bad := 0
for _, id := range ids {
errs, warns := issue.Validate(issues[id], known)
if *strict {
errs, warns = append(errs, warns...), nil
}
if len(errs) > 0 {
bad++
}
if *quiet {
continue
}
if len(errs) == 0 && len(warns) == 0 {
fmt.Printf("ok %s\n", id)
continue
}
for _, e := range errs {
fmt.Printf("ERROR %s: %s\n", id, e)
}
for _, w := range warns {
fmt.Printf("warn %s: %s\n", id, w)
}
}
for _, c := range issue.FindCycles(issue.Graph(issues)) {
bad++
if !*quiet {
fmt.Printf("ERROR cycle: %s\n", strings.Join(c, " -> "))
}
}
if !*quiet {
fmt.Printf("%d issue(s) checked, %d with errors\n", len(ids), bad)
}
if bad > 0 {
return SilentError{Code: 1}
}
return nil
}
},
})
}
+522
View File
@@ -0,0 +1,522 @@
package cmd_test
// The CLI is tested the way the Python suite it replaces was: the binary is
// built once and run as a subprocess against a throwaway project somewhere
// else entirely.
//
// That separation IS the contract. A tool is installed in one place and used on
// projects in another, and the bug this discipline exists to catch — a store
// resolved from the executable's own directory rather than from the tree it was
// pointed at — is invisible to any test that runs the code in the directory it
// lives in.
//
// Every fixture also strips CLAUDE_PROJECT_DIR unless the test is about it: it
// is the first anchor of the walk, so the harness's own value would point every
// fixture at this repository.
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
var kettle string
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "kettle-bin")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
kettle = filepath.Join(dir, "kettle")
build := exec.Command("go", "build", "-o", kettle, "../../cmd/kettle")
if out, err := build.CombinedOutput(); err != nil {
panic("building kettle: " + err.Error() + "\n" + string(out))
}
os.Exit(m.Run())
}
type result struct {
stdout, stderr string
code int
}
func (r result) out() string { return r.stdout + r.stderr }
// run invokes the binary in dir with a clean environment.
func run(t *testing.T, dir string, args ...string) result {
t.Helper()
return runWith(t, dir, nil, "", args...)
}
// runWith is run plus extra environment and standard input.
func runWith(t *testing.T, dir string, env []string, stdin string, args ...string) result {
t.Helper()
cmd := exec.Command(kettle, args...)
cmd.Dir = dir
cmd.Env = append(append(os.Environ(), "CLAUDE_PROJECT_DIR="), env...)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
var stdout, stderr strings.Builder
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
code := 0
var ee *exec.ExitError
if err != nil {
if !asExitError(err, &ee) {
t.Fatalf("running kettle %v: %v", args, err)
}
code = ee.ExitCode()
}
return result{stdout.String(), stderr.String(), code}
}
func mustRun(t *testing.T, dir string, args ...string) result {
t.Helper()
r := run(t, dir, args...)
if r.code != 0 {
t.Fatalf("kettle %v exited %d:\n%s", args, r.code, r.out())
}
return r
}
// newProject makes an initialized project in a temp directory and returns it.
func newProject(t *testing.T) string {
t.Helper()
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
mustRun(t, dir, "init")
return dir
}
func TestInitIsIdempotentAndGitignoresTheStore(t *testing.T) {
dir := newProject(t)
for _, d := range []string{".kettle/issues", ".kettle/payload"} {
if fi, err := os.Stat(filepath.Join(dir, d)); err != nil || !fi.IsDir() {
t.Errorf("%s was not created", d)
}
}
// An `origin: local` issue is the only copy of that work, and what goes in
// a shared history is the operator's call, not this command's.
ignore, err := os.ReadFile(filepath.Join(dir, ".gitignore"))
if err != nil || !strings.Contains(string(ignore), ".kettle/") {
t.Errorf(".kettle/ was not gitignored: %q", ignore)
}
again := mustRun(t, dir, "init")
if !strings.Contains(again.stdout, "already initialized") {
t.Errorf("a second init should be a no-op, got:\n%s", again.stdout)
}
}
func TestNoMarkerIsReportedNotGuessed(t *testing.T) {
dir := t.TempDir()
r := run(t, dir, "check")
if r.code == 0 {
t.Fatal("a directory that is not a project must not read as an empty store")
}
// The operator is owed the directories the search began from — that is how
// they see whether it began where they meant it to.
if !strings.Contains(r.stderr, "no .kettle/ found") || !strings.Contains(r.stderr, dir) {
t.Errorf("the failure must name what it searched:\n%s", r.stderr)
}
}
func TestTheGoldenPath(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
const id = "wire-sqlc-into-the-appclick-layer"
if r := mustRun(t, dir, "check"); !strings.Contains(r.stdout, "ok "+id) {
t.Errorf("a fresh issue from its own template must validate:\n%s", r.out())
}
// Progress is counted off the body every time, never stored.
mustRun(t, dir, "ac", id, "--check", "1")
index, err := os.ReadFile(filepath.Join(dir, ".kettle", "issues", "INDEX.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(index), "| 1/2 |") {
t.Errorf("the index did not pick up the ticked box:\n%s", index)
}
if r := mustRun(t, dir, "tree"); !strings.Contains(r.stdout, id) {
t.Errorf("tree did not draw the issue:\n%s", r.stdout)
}
}
func TestTickingABoxChangesOneByte(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Tick one box")
path := filepath.Join(dir, ".kettle", "issues", "tick-one-box.md")
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
mustRun(t, dir, "ac", "tick-one-box", "--check", "1")
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if len(before) != len(after) {
t.Fatalf("length changed: %d -> %d", len(before), len(after))
}
diff := 0
for i := range before {
if before[i] != after[i] {
diff++
}
}
if diff != 1 {
t.Errorf("%d bytes changed, want 1 — a tick must not re-render the file", diff)
}
// And back again, byte for byte: the metadata block is rewritten by nobody.
mustRun(t, dir, "ac", "tick-one-box", "--uncheck", "1")
back, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(back) != string(before) {
t.Error("unticking did not restore the file byte for byte")
}
}
func TestFlagsWorkAfterPositionalArguments(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Order of arguments")
// `kettle ac <id> --check 1` is how everybody types it. A flag silently
// read as a positional would tick nothing and report success.
r := mustRun(t, dir, "ac", "order-of-arguments", "--check", "1")
if !strings.Contains(r.stdout, "checked") {
t.Errorf("the flag after the id was ignored:\n%s", r.out())
}
}
func TestTheStoreResolvesFromAnywhereInsideTheProject(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Seen from below")
deep := filepath.Join(dir, "internal", "adapters")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
r := mustRun(t, deep, "check")
if !strings.Contains(r.stdout, "seen-from-below") {
t.Errorf("a subdirectory saw a different store:\n%s", r.out())
}
}
func TestADifferentProjectAnswersWithItsOwnStore(t *testing.T) {
a, b := newProject(t), newProject(t)
mustRun(t, a, "new", "--type", "task", "--title", "Belongs to A")
mustRun(t, b, "new", "--type", "task", "--title", "Belongs to B")
r := mustRun(t, b, "check")
if strings.Contains(r.stdout, "belongs-to-a") {
t.Errorf("project B saw project A's issues:\n%s", r.out())
}
}
func TestALocalIssueIsNeverEvictedEvenWhenNamed(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Only copy there is")
path := filepath.Join(dir, ".kettle", "issues", "only-copy-there-is.md")
closeIssue(t, path)
r := mustRun(t, dir, "evict", "only-copy-there-is")
if _, err := os.Stat(path); err != nil {
t.Fatal("a closed origin: local issue was deleted — that file IS the work")
}
if !strings.Contains(r.stdout, "kept") {
t.Errorf("keeping it must be said out loud:\n%s", r.out())
}
}
func TestAClosedTrackedIssueIsEvictedWithItsSidecars(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Done and elsewhere")
store := filepath.Join(dir, ".kettle", "issues")
path := filepath.Join(store, "done-and-elsewhere.md")
closeIssue(t, path)
setField(t, path, "origin", "gitea")
sidecar := filepath.Join(store, "done-and-elsewhere.comments.md")
if err := os.WriteFile(sidecar, []byte("# thread\n"), 0o644); err != nil {
t.Fatal(err)
}
// A dry run touches nothing, and says so.
dry := mustRun(t, dir, "evict", "--dry-run")
if !strings.Contains(dry.stdout, "would evict") {
t.Errorf("dry run said nothing:\n%s", dry.out())
}
if _, err := os.Stat(path); err != nil {
t.Fatal("a dry run deleted the issue")
}
mustRun(t, dir, "evict")
if _, err := os.Stat(path); err == nil {
t.Error("the issue survived eviction")
}
// The domain does not need to know what a comment thread is to know a file
// named after this issue goes when it goes.
if _, err := os.Stat(sidecar); err == nil {
t.Error("the sidecar was left behind")
}
}
func TestCheckExitsNonZeroOnAMalformedIssue(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Loses its type")
path := filepath.Join(dir, ".kettle", "issues", "loses-its-type.md")
setField(t, path, "labels", "[]")
r := run(t, dir, "check")
if r.code != 1 {
t.Errorf("exit = %d, want 1 — this is what makes check usable in a hook", r.code)
}
if !strings.Contains(r.stdout, "need exactly one type/* label") {
t.Errorf("the finding was not reported:\n%s", r.out())
}
}
func TestNewRefusesToOverwriteAnExistingIssue(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Same title twice")
// Without an explicit id the slug is allocated around the collision…
mustRun(t, dir, "new", "--type", "task", "--title", "Same title twice")
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues", "same-title-twice-2.md")); err != nil {
t.Error("the second issue did not get its own slug")
}
// …but an id typed by hand is taken literally, and taken means taken.
r := run(t, dir, "new", "--type", "task", "--title", "Third", "--id", "same-title-twice")
if r.code == 0 || !strings.Contains(r.stderr, "already exists") {
t.Errorf("an explicit id must not overwrite:\n%s", r.out())
}
}
// An older layout is migrated in, and it is a MOVE: a store left behind at the
// old path is a store somebody will edit by accident months later.
func TestInitMigratesAnOlderStore(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
old := filepath.Join(dir, ".tea", "issues")
if err := os.MkdirAll(old, 0o755); err != nil {
t.Fatal(err)
}
const body = "---\nid: from-the-old-store\nstate: open\nlabels: [type/task]\norigin: local\n---\n# From the old store\n\n## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n"
if err := os.WriteFile(filepath.Join(old, "from-the-old-store.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
r := mustRun(t, dir, "init")
if !strings.Contains(r.stdout, "moved 1 file(s)") {
t.Errorf("the migration said nothing:\n%s", r.stdout)
}
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues", "from-the-old-store.md")); err != nil {
t.Fatal("the issue did not arrive in the new store")
}
if _, err := os.Stat(old); err == nil {
t.Error("the old store is still there — two stores is what the marker exists to prevent")
}
if r := mustRun(t, dir, "check"); !strings.Contains(r.stdout, "from-the-old-store") {
t.Errorf("the migrated issue is not readable:\n%s", r.out())
}
}
// A migration never picks a winner. Two files of the same name are two versions
// of one issue, and choosing quietly is how the wrong one survives.
func TestInitRefusesToResolveAMigrationClash(t *testing.T) {
dir := newProject(t)
old := filepath.Join(dir, ".tea", "issues")
if err := os.MkdirAll(old, 0o755); err != nil {
t.Fatal(err)
}
mustRun(t, dir, "new", "--type", "task", "--title", "Both sides have this")
if err := os.WriteFile(filepath.Join(old, "both-sides-have-this.md"), []byte("older\n"), 0o644); err != nil {
t.Fatal(err)
}
r := run(t, dir, "init")
if r.code == 0 {
t.Fatal("a clash must stop the run")
}
if !strings.Contains(r.stderr, "both hold") || !strings.Contains(r.stderr, "nothing was changed") {
t.Errorf("the clash was not explained:\n%s", r.stderr)
}
if _, err := os.Stat(filepath.Join(old, "both-sides-have-this.md")); err != nil {
t.Error("the older file was moved anyway")
}
}
func TestInitWritesTheConfigAndKeepsWhatItWasNotGiven(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
mustRun(t, dir, "init", "--login", "noodles", "--repo", "claude-skills/marketplace")
cfg := filepath.Join(dir, ".kettle", "config.yaml")
raw, err := os.ReadFile(cfg)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "login: noodles") ||
!strings.Contains(string(raw), "repo: claude-skills/marketplace") {
t.Fatalf("config did not record what it was given:\n%s", raw)
}
// Re-running init to change one setting must not drop the other.
mustRun(t, dir, "init", "--repo", "claude-skills/other")
raw, err = os.ReadFile(cfg)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "login: noodles") {
t.Errorf("the pinned login was dropped by an unrelated init:\n%s", raw)
}
if !strings.Contains(string(raw), "repo: claude-skills/other") {
t.Errorf("the repository was not updated:\n%s", raw)
}
}
func TestInitRefusesAMalformedRepo(t *testing.T) {
dir := t.TempDir()
r := run(t, dir, "init", "--repo", "marketplace")
if r.code == 0 || !strings.Contains(r.stderr, "owner/name") {
t.Errorf("a repo without an owner must be rejected before anything is written:\n%s", r.out())
}
if _, err := os.Stat(filepath.Join(dir, ".kettle")); err == nil {
t.Error("the marker was created despite the bad argument")
}
}
// The project pins a login by NAME. The credential lives in one file per
// machine, outside every working tree — a token in a repository ends up in a
// commit, and a secret that has been pushed has to be rotated.
func TestTokensNeverLandInTheProject(t *testing.T) {
dir := newProject(t)
home := t.TempDir()
env := []string{"KETTLE_CONFIG_HOME=" + home}
runWith(t, dir, env, "s3cr3t-token\n", "auth", "add",
"--name", "noodles", "--url", "https://git.example.com/")
mustRun(t, dir, "init", "--login", "noodles", "--repo", "owner/name")
logins, err := os.ReadFile(filepath.Join(home, "logins.yaml"))
if err != nil {
t.Fatal("the token file was not written where it was told to go")
}
if !strings.Contains(string(logins), "s3cr3t-token") {
t.Errorf("the token was not stored:\n%s", logins)
}
if fi, err := os.Stat(filepath.Join(home, "logins.yaml")); err != nil || fi.Mode().Perm() != 0o600 {
t.Errorf("the token file must be 0600, got %v", fi.Mode().Perm())
}
cfg, err := os.ReadFile(filepath.Join(dir, ".kettle", "config.yaml"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(cfg), "s3cr3t-token") {
t.Fatal("the token was written into the project — that file ends up in a commit")
}
// And nothing prints it back, either.
shown := runWith(t, dir, env, "", "config")
if strings.Contains(shown.out(), "s3cr3t-token") {
t.Errorf("`kettle config` printed the token:\n%s", shown.out())
}
if !strings.Contains(shown.stdout, "https://git.example.com") {
t.Errorf("the resolved URL was not shown:\n%s", shown.out())
}
if !strings.Contains(shown.stdout, "token (set)") {
t.Errorf("whether a token was found must still be visible:\n%s", shown.stdout)
}
}
func TestAuthListNeverPrintsATokenAndRemoveForgetsIt(t *testing.T) {
dir := newProject(t)
home := t.TempDir()
env := []string{"KETTLE_CONFIG_HOME=" + home}
runWith(t, dir, env, "s3cr3t-token\n", "auth", "add", "--name", "noodles", "--url", "https://git.example.com")
listed := runWith(t, dir, env, "", "auth", "list")
if strings.Contains(listed.out(), "s3cr3t-token") {
t.Errorf("`auth list` printed a token:\n%s", listed.out())
}
if !strings.Contains(listed.stdout, "noodles") {
t.Errorf("`auth list` did not list the login:\n%s", listed.out())
}
runWith(t, dir, env, "", "auth", "remove", "noodles")
after := runWith(t, dir, env, "", "auth", "list")
if strings.Contains(after.stdout, "noodles") {
t.Errorf("the login survived removal:\n%s", after.stdout)
}
}
// A pinned login that is not on this machine is a fixable mistake, and the
// message has to say which file was read and what it holds.
func TestAMissingLoginIsExplained(t *testing.T) {
dir := newProject(t)
home := t.TempDir()
env := []string{"KETTLE_CONFIG_HOME=" + home}
mustRun(t, dir, "init", "--login", "absent", "--repo", "owner/name")
r := runWith(t, dir, env, "", "config")
if r.code == 0 {
t.Fatal("a login that does not exist must not resolve")
}
if !strings.Contains(r.stderr, `no login "absent"`) || !strings.Contains(r.stderr, "kettle auth add") {
t.Errorf("the failure must name the file and the fix:\n%s", r.stderr)
}
}
func closeIssue(t *testing.T, path string) {
t.Helper()
setField(t, path, "state", "closed")
}
func setField(t *testing.T, path, key, value string) {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(string(raw), "\n")
for i, line := range lines {
if strings.HasPrefix(line, key+": ") {
lines[i] = key + ": " + value
}
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
t.Fatal(err)
}
}
func asExitError(err error, target **exec.ExitError) bool {
ee, ok := err.(*exec.ExitError)
if ok {
*target = ee
}
return ok
}
+327
View File
@@ -0,0 +1,327 @@
package cmd
import (
"flag"
"fmt"
"sort"
"strings"
"time"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func init() {
register(&Command{
Name: "close",
Group: GroupSync,
Args: "<id|number> [<id|number>…]",
Short: "close or reopen issues in the tracker, and on disk with them",
Long: `STATE ONLY. This sends ` + "`{\"state\": …}`" + ` and nothing else: no title, no body, no
labels, no milestone. Editing an issue is ` + "`kettle pull`" + ` -> edit ->
` + "`kettle push --update`" + `; closing it is not an edit.
EXPLICIT IDS ONLY. No --milestone, no --label, no "close everything that looks
done". Which issues are finished is a judgement about content; this carries that
judgement out, one named id at a time. Nothing here deletes an issue either —
the tracker can, and it is not an operation of this workflow.
WHAT MAY BE NAMED: a local slug, or a tracker key (42, #42, owner/repo#42, an
issue URL). Both, and for the same reason: a push deletes the local file, so
most issues in the tracker have no slug on disk to name them by. A slug is
resolved through the file's ` + "`gitea:`" + ` handle when the file is there, and through
the ledger (` + "`.remote.json`" + `) when push has already dropped it. A bare number is
this project's repository; a qualified key names its own, so a foreign #42 can
never be closed against the repository that happens to be configured here.
An ` + "`origin: local`" + ` issue cannot be closed. It is not in the tracker, so there is
no state there to change, and the run stops naming the id rather than quietly
editing one field of a local file. Push it first, or delete it.
THE LOCAL FILE IS WRITTEN ONLY AFTER THE TRACKER CONFIRMS: the answer has to be
the very issue that was patched, in the state that was asked for. Anything else
and the file is left exactly as it was. An issue whose local copy is gone
(pushed and dropped) is closed in the tracker and nothing is written; the state
comes down with the next pull.
A tracker that refuses to close an issue its own dependency graph still blocks
says so in the answer, and the run stops with its words: close the blockers
first, or unlink them.`,
Examples: []Example{
{"kettle close wire-sqlc-appclick", "one issue, by slug"},
{"kettle close wire-sqlc-appclick 42 #43", "several, by slug or number"},
{"kettle close --reopen 42", "the same thing backwards"},
{"kettle close --dry-run 42 43", "what would change; no request at all"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
reopen := fs.Bool("reopen", false, "set the state back to open instead of closed")
dryRun := fs.Bool("dry-run", false, "print what would change; makes no request")
out := storeFlag(fs)
return func(args []string) error {
if len(args) == 0 {
return Fail("name at least one issue: a slug, or 42, #42, owner/repo#42, a URL")
}
state, verb, past := "closed", "close", "closed"
if *reopen {
state, verb, past = "open", "reopen", "reopened"
}
root, client, err := syncStartExisting(*out)
if err != nil {
return err
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
ledger := closeLedger(root)
// Every argument is resolved before anything is sent, so a typo in
// the third id does not leave the first two closed.
var targets []closeTarget
for _, arg := range args {
t, err := closeResolve(arg, root, issues, ledger)
if err != nil {
return err
}
if !closeHas(targets, t) {
targets = append(targets, t)
}
}
if *dryRun {
for _, t := range targets {
where := "no local copy"
if i, ok := issues[t.id]; ok {
where = fmt.Sprintf("%s (state: %s)", issue.PathOf(root, t.id), i.State)
}
fmt.Printf("would %-6s %-24s %-20s %s\n",
verb, closeName(t.id), t.key.In(client.Repo()), where)
}
fmt.Printf("%d issue(s) would be %s; no request was made\n", len(targets), past)
return nil
}
touched := 0
for _, t := range targets {
c := client
if !t.key.Repo.Zero() {
c = client.For(t.key.Repo)
}
req := wire.IssueRequest{State: wire.Set(state)}
got, err := c.EditIssue(t.key.Number, req, fmt.Sprintf("state-%d", t.key.Number))
if err != nil {
return err
}
// The gate. Above it nothing local has been written; below it the
// file is about to say something the tracker had better agree
// with. An answer counts only when it is the very issue that was
// patched, in the state that was asked for.
if got.Number != t.key.Number || got.State != state {
return Fail("%s: the %s did not go through — the tracker answered for issue #%d in state %q. Nothing local was changed.",
t.key.In(c.Repo()), verb, got.Number, got.State)
}
fmt.Printf("%-8s %-24s %-20s %s\n",
past, closeName(t.id), t.key.In(c.Repo()), got.HTMLURL)
i, ok := issues[t.id]
if !ok {
fmt.Printf(" no local copy — `kettle pull %d` to get one\n", t.key.Number)
continue
}
path, err := closeApply(root, i, state, got)
if err != nil {
return err
}
fmt.Printf(" state: %s %s\n", state, path)
touched++
}
// Only when a file actually changed: INDEX.md is a view of the
// directory, and rewriting it after a run that wrote nothing local
// is a write nobody asked for.
if touched > 0 {
path, n, err := issue.BuildIndex(root)
if err != nil {
return err
}
fmt.Printf("index: %s — %d issue(s)\n", path, n)
}
return nil
}
},
})
}
// closeTarget is one issue a run will act on: where it is in the tracker, and
// what this machine calls it, when this machine has a name for it at all.
type closeTarget struct {
// id is the local slug, "" when nothing here names this issue. Closing one
// of those is ordinary — push deletes the file it would have been named by.
id string
// key is the tracker address. Its Repo is zero only when the argument was a
// bare number and no local copy or ledger entry qualified it, which means
// this project's own repository.
key wire.Key
}
// closeEntry is one row of the number -> slug ledger, parsed.
type closeEntry struct {
key wire.Key
slug string
}
// closeLedger is `.remote.json` as pairs, sorted so two identical runs report
// an ambiguity in the same order.
//
// Read rather than ignored because it is the only thing on this machine that
// still names an issue push has dropped: the file is gone, the slug is not.
func closeLedger(root string) []closeEntry {
var out []closeEntry
for raw, slug := range gitea.LoadRemoteMap(root) {
k, err := wire.ParseKey(raw)
if err != nil || k.Repo.Zero() || k.Number < 1 {
continue
}
out = append(out, closeEntry{key: k, slug: slug})
}
sort.Slice(out, func(i, j int) bool { return out[i].key.String() < out[j].key.String() })
return out
}
// closeResolve turns one argument into a target.
//
// The order is the order of what is most authoritative about this machine: a
// file on disk, then the ledger, then nothing. A key is already the tracker's
// answer, so the only thing still wanted for it is the slug — so that the local
// copy, if there is one, can be kept honest — and the file that carries the
// handle knows that before the ledger does.
//
// The repository travels with the number, because a key may name one and a
// `gitea:` handle always does. Sending a foreign key to whatever repository
// this project points at would close somebody else's issue of the same number.
func closeResolve(arg, root string, issues map[string]*issue.Issue, ledger []closeEntry) (closeTarget, error) {
// A key first, and a slug never looks like one: slugs hold no `#`, no `/`
// and no `:`, so the two vocabularies cannot collide.
if k, err := wire.ParseKey(arg); err == nil {
var hits []closeEntry
for id, i := range issues {
if h, ok := mapping.RemoteKeyOf(i); ok && h.Number == k.Number && (k.Repo.Zero() || h.Repo == k.Repo) {
hits = append(hits, closeEntry{key: h, slug: id})
}
}
sort.Slice(hits, func(a, b int) bool { return hits[a].slug < hits[b].slug })
if len(hits) == 0 {
for _, e := range ledger {
if e.key.Number == k.Number && (k.Repo.Zero() || e.key.Repo == k.Repo) {
hits = append(hits, e)
}
}
}
hit, err := closeOne(hits, arg, "a slug")
if err != nil {
return closeTarget{}, err
}
t := closeTarget{key: k}
if hit != nil {
t.id = hit.slug
t.key = k.In(hit.key.Repo)
}
return t, nil
}
if i, ok := issues[arg]; ok {
k, ok := mapping.RemoteKeyOf(i)
if !ok {
return closeTarget{}, Fail("%s is not in the tracker (origin: %s, no usable `%s:` handle) — "+
"there is no state there to change; `kettle push %s` first",
arg, i.Origin, mapping.GiteaKey, arg)
}
return closeTarget{id: arg, key: k}, nil
}
var hits []closeEntry
for _, e := range ledger {
if e.slug == arg {
hits = append(hits, e)
}
}
hit, err := closeOne(hits, arg, "a number")
if err != nil {
return closeTarget{}, err
}
if hit != nil {
return closeTarget{id: arg, key: hit.key}, nil // pushed, and its file went with the push
}
return closeTarget{}, Fail("no issue %q in %s or in its %s — name a tracker key "+
"(42, #42, owner/repo#42, or the issue's URL) to close one this machine has never seen",
arg, root, gitea.RemoteMapName)
}
// closeOne is the single ledger row for an argument, nil when the ledger knows
// nothing about it, or an error when it knows two.
//
// Two answers mean one number (or one slug) under more than one repository, and
// only a qualified key can settle that. Guessing would close the wrong issue.
func closeOne(hits []closeEntry, arg, what string) (*closeEntry, error) {
seen := map[string]bool{}
var uniq []closeEntry
for _, h := range hits {
if k := h.key.String() + " " + h.slug; !seen[k] {
seen[k] = true
uniq = append(uniq, h)
}
}
switch len(uniq) {
case 0:
return nil, nil
case 1:
return &uniq[0], nil
}
var where []string
for _, h := range uniq {
where = append(where, h.key.String())
}
return nil, Fail("%q matches %s under more than one repository (%s) — say which, as owner/repo#N",
arg, what, strings.Join(where, ", "))
}
// closeApply writes the confirmed state onto the local file and returns its
// path.
//
// `state:` is the domain's own field, so it is set on the issue and written out
// by the domain's own writer. The sync-owned freshness fields travel with it:
// the answer that authorized this write is also the newest thing the tracker has
// said about the issue, so `synced:` and `remote-updated:` are stamped from it
// rather than left describing an older read.
func closeApply(root string, i *issue.Issue, state string, got *wire.Issue) (string, error) {
i.State = state
if i.Extra == nil {
i.Extra = map[string]string{}
}
i.Extra[mapping.SyncedKey] = time.Now().UTC().Format(time.RFC3339)
if got.UpdatedAt != "" {
i.Extra[mapping.RemoteUpdatedKey] = got.UpdatedAt
}
return issue.Save(root, i)
}
func closeHas(targets []closeTarget, t closeTarget) bool {
for _, have := range targets {
if have == t {
return true
}
}
return false
}
// closeName is what a receipt calls an issue this machine has no name for.
func closeName(id string) string {
if id == "" {
return "(no local copy)"
}
return id
}
+269
View File
@@ -0,0 +1,269 @@
// Package cmd is the kettle command tree.
//
// Commands are values, not init() side effects on a framework: each one carries
// the metadata a human needs (what it does, what it takes, worked examples) in
// the same struct that carries the code. That is deliberate — the plugin's
// SKILL.md files are generated from this list, so a command whose flags changed
// cannot ship with documentation that says otherwise.
//
// The tree is flat. `kettle new`, not `kettle issue new`: an agent pays for
// every token of every invocation, and the grouping that matters for reading is
// carried in Group and only shows up in the docs.
package cmd
import (
"flag"
"fmt"
"os"
"sort"
"strings"
)
// Groups, in the order they are presented. They name the layer a command
// belongs to, which is the one thing a reader has to keep straight: the domain
// works offline and the tracker does not exist to it.
const (
GroupProject = "project"
GroupIssue = "issue"
GroupSync = "sync"
)
var groupOrder = []string{GroupProject, GroupIssue, GroupSync}
var groupBlurb = map[string]string{
GroupProject: "the project itself",
GroupIssue: "issues as units of work — offline, no tracker involved",
GroupSync: "moving issues between the store and the tracker",
}
// Example is one worked invocation. Both halves are shown in help and in the
// generated skill docs.
type Example struct {
Cmd string
What string
}
// Command is one verb.
type Command struct {
// Name is what the user types.
Name string
// Group is the layer it belongs to; documentation only.
Group string
// Args is the positional-argument spec, e.g. "<id> [<id>…]".
Args string
// Short is one line, shown in the command list.
Short string
// Long is the full explanation, shown by `kettle help <name>`.
Long string
// Examples are worked invocations.
Examples []Example
// Setup registers this command's flags on fs and returns the function that
// runs it, closing over them. Splitting it this way lets the doc generator
// walk the flags without running anything.
Setup func(fs *flag.FlagSet) func(args []string) error
}
var registry []*Command
func register(c *Command) { registry = append(registry, c) }
// Commands lists every command, sorted by group and then by name.
func Commands() []*Command {
out := append([]*Command{}, registry...)
sort.SliceStable(out, func(i, j int) bool {
gi, gj := groupIndex(out[i].Group), groupIndex(out[j].Group)
if gi != gj {
return gi < gj
}
return out[i].Name < out[j].Name
})
return out
}
// Lookup finds a command by name.
func Lookup(name string) *Command {
for _, c := range registry {
if c.Name == name {
return c
}
}
return nil
}
// Flags returns this command's flags without running it — what the doc
// generator walks.
func (c *Command) Flags() []*flag.Flag {
fs := flag.NewFlagSet(c.Name, flag.ContinueOnError)
fs.SetOutput(discard{})
c.Setup(fs)
var out []*flag.Flag
fs.VisitAll(func(f *flag.Flag) { out = append(out, f) })
return out
}
// Usage is the one-line synopsis.
func (c *Command) Usage() string {
s := "kettle " + c.Name
if c.Args != "" {
s += " " + c.Args
}
return s
}
// SilentError carries an exit status for a command that has already said
// everything it has to say. `check` uses it: findings went to stdout and a
// second copy on stderr would be noise.
type SilentError struct{ Code int }
func (e SilentError) Error() string { return "" }
// Fail is the error every command returns for an ordinary failure. Main
// prefixes it with the command name.
func Fail(format string, a ...any) error { return fmt.Errorf(format, a...) }
// Main runs argv (without the program name) and returns the exit status.
func Main(argv []string) int {
if len(argv) == 0 {
printUsage(os.Stdout)
return 0
}
name := argv[0]
switch name {
case "help", "-h", "--help":
if len(argv) > 1 {
c := Lookup(argv[1])
if c == nil {
fmt.Fprintf(os.Stderr, "kettle: no command %q\n", argv[1])
return 2
}
printCommand(os.Stdout, c)
return 0
}
printUsage(os.Stdout)
return 0
}
c := Lookup(name)
if c == nil {
fmt.Fprintf(os.Stderr, "kettle: no command %q — try `kettle help`\n", name)
return 2
}
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.Usage = func() { printCommand(os.Stderr, c) }
run := c.Setup(fs)
if err := fs.Parse(permute(fs, argv[1:])); err != nil {
if err == flag.ErrHelp {
return 0
}
return 2
}
switch err := run(fs.Args()).(type) {
case nil:
return 0
case SilentError:
return err.Code
default:
fmt.Fprintf(os.Stderr, "kettle %s: %v\n", name, err)
return 1
}
}
func printUsage(w *os.File) {
fmt.Fprint(w, "kettle — issues as local markdown, and the tracker they sync with\n\n")
fmt.Fprint(w, "usage: kettle <command> [flags] [args]\n")
current := ""
for _, c := range Commands() {
if c.Group != current {
current = c.Group
fmt.Fprintf(w, "\n%s — %s\n", current, groupBlurb[current])
}
fmt.Fprintf(w, " %-11s %s\n", c.Name, c.Short)
}
fmt.Fprint(w, "\n`kettle help <command>` for one command in full.\n")
}
func printCommand(w *os.File, c *Command) {
fmt.Fprintf(w, "%s\n\n%s\n", c.Usage(), c.Short)
if c.Long != "" {
fmt.Fprintf(w, "\n%s\n", strings.TrimSpace(c.Long))
}
if flags := c.Flags(); len(flags) > 0 {
fmt.Fprint(w, "\nflags:\n")
for _, f := range flags {
name := "--" + f.Name
if f.DefValue != "" && f.DefValue != "false" {
name += "=" + f.DefValue
}
fmt.Fprintf(w, " %-22s %s\n", name, f.Usage)
}
}
if len(c.Examples) > 0 {
fmt.Fprint(w, "\nexamples:\n")
for _, e := range c.Examples {
fmt.Fprintf(w, " %s\n %s\n", e.Cmd, e.What)
}
}
}
// permute moves flags ahead of positional arguments.
//
// The standard flag package stops parsing at the first non-flag argument, so
// `kettle ac <id> --check 3` would hand --check to the command as a positional
// and tick nothing. Every other CLI an operator uses interleaves the two, and
// a tool that silently ignores a flag because of where it was typed is worse
// than one that rejects it.
//
// A flag that takes a value swallows the next argument, which is why this needs
// the FlagSet: only the set knows whether --check wants one. `--` ends the
// permutation, and everything after it is positional whatever it looks like.
func permute(fs *flag.FlagSet, args []string) []string {
var flags, positional []string
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" {
positional = append(positional, args[i+1:]...)
break
}
if len(a) < 2 || a[0] != '-' {
positional = append(positional, a)
continue
}
flags = append(flags, a)
if strings.Contains(a, "=") {
continue
}
f := fs.Lookup(strings.TrimLeft(a, "-"))
// An unknown flag consumes nothing; Parse will reject it by name in a
// moment, which is a better message than one about its value.
if f == nil || isBoolFlag(f.Value) {
continue
}
if i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
return append(flags, positional...)
}
func isBoolFlag(v flag.Value) bool {
b, ok := v.(interface{ IsBoolFlag() bool })
return ok && b.IsBoolFlag()
}
func groupIndex(g string) int {
for i, name := range groupOrder {
if name == g {
return i
}
}
return len(groupOrder)
}
type discard struct{}
func (discard) Write(p []byte) (int, error) { return len(p), nil }
+146
View File
@@ -0,0 +1,146 @@
package cmd
import (
"flag"
"fmt"
"os"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func init() {
register(&Command{
Name: "comment",
Group: GroupSync,
Args: "<id>",
Short: "post or edit a comment on a synced issue",
Long: `The target is a LOCAL ID, not a number. Which issue this is, is a fact about the
work; where it lives in the tracker is bookkeeping, and the ` + "`gitea:`" + ` handle on the
file is what turns one into the other. An ` + "`origin: local`" + ` issue cannot be
commented on at all — it is not in the tracker, so there is nothing there to
comment on; push it first.
The body comes from a file or from --body, and multi-line prose is what --file
is for. This is why comments go through the API rather than through a tracker
CLI: an entity command with an empty-looking positional opens $EDITOR, and on a
TTY that does not exist it hangs forever.
After the write the whole thread is refetched into ` + "`<id>.comments.md`" + `, so the
local copy is not stale by one comment — the one this run just made.
COMMENTS ARE PULL-ONLY IN THE STORE. Nothing round-trips them back: editing
` + "`<id>.comments.md`" + ` by hand changes nothing in the tracker. Use --edit with a
comment id for that.`,
Examples: []Example{
{"kettle comment wire-sqlc-appclick --file notes.md", "post the contents of a file"},
{`kettle comment wire-sqlc-appclick --body "готово, задеплоено"`, "post one line"},
{"kettle comment wire-sqlc-appclick --file fix.md --edit 1234", "rewrite comment 1234 instead"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
file := fs.String("file", "", "markdown file holding the comment body")
body := fs.String("body", "", "comment body inline (short, single-line)")
edit := fs.Int64("edit", 0, "comment id to rewrite, instead of posting a new one")
out := storeFlag(fs)
return func(args []string) error {
if len(args) != 1 {
return Fail("give exactly one issue id")
}
id := args[0]
withFile, withBody := wasSet(fs, "file"), wasSet(fs, "body")
switch {
case withFile && withBody:
return Fail("--file and --body are mutually exclusive")
case !withFile && !withBody:
return Fail("give the comment body: --file <path>, or --body \"…\"")
}
root, client, err := syncStartExisting(*out)
if err != nil {
return err
}
i, err := issue.Load(root, id)
if err != nil {
return Fail("no issue %q in %s", id, root)
}
key, ok := mapping.RemoteKeyOf(i)
if !ok {
return Fail("%s is local-only (origin: %s, no usable `%s:` handle) — "+
"there is nothing in the tracker to comment on; `kettle push %s` first",
id, i.Origin, mapping.GiteaKey, id)
}
text, err := commentBodyFrom(*file, *body)
if err != nil {
return err
}
// The handle names the repository, so a comment lands where the
// issue actually is — even when the store has ever pointed at two.
client = client.For(key.Repo)
var got *wire.Comment
verb := "posted"
if *edit != 0 {
verb = "edited"
got, err = client.EditComment(*edit, text, fmt.Sprintf("comment-%d", *edit))
} else {
got, err = client.CreateComment(key.Number, text, "comment-"+id)
}
if err != nil {
return err
}
// A 2xx that carries no id is not a comment. Nothing local has been
// written yet, and nothing will be if the answer is that shape.
if got.ID == 0 {
return Fail("the %s answer carries no comment id — nothing local was changed", verb)
}
fmt.Printf("%s comment %d on %s (%s) %s\n", verb, got.ID, id, key, got.HTMLURL)
comments, err := client.ListComments(key.Number)
if err != nil {
return Fail("the comment went up, but refetching the thread failed: %v — "+
"`kettle pull %d` to refresh the local copy", err, key.Number)
}
path := commentsSidecarPath(root, id)
if len(comments) == 0 {
// Only reachable when the thread was emptied elsewhere between
// the write and the read. A stale sidecar for a thread that no
// longer exists is worse than no sidecar.
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
fmt.Printf("thread: none — %s removed\n", path)
return nil
}
if err := os.WriteFile(path, []byte(mapping.RenderComments(comments)), 0o644); err != nil {
return err
}
fmt.Printf("thread: %s (%d comment(s))\n", path, len(comments))
return nil
}
},
})
}
// commentBodyFrom reads the comment body from a file or takes it as given.
//
// Trimmed and then required to be non-empty: a file of whitespace is somebody
// pointing at the wrong path, and posting it would leave an empty comment in a
// thread that nobody can delete from here.
func commentBodyFrom(file, inline string) (string, error) {
if file != "" {
raw, err := os.ReadFile(file)
if err != nil {
return "", Fail("cannot read the comment body: %v", err)
}
inline = string(raw)
}
text := strings.TrimSpace(inline)
if text == "" {
return "", Fail("the comment body is empty — nothing was posted")
}
return text, nil
}
+59
View File
@@ -0,0 +1,59 @@
package cmd
import (
"flag"
"fmt"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
)
func init() {
register(&Command{
Name: "config",
Group: GroupProject,
Short: "show what this project resolved to",
Long: `Every path and every setting, with the overrides already applied, so a run that
went somewhere unexpected can be explained without guessing.
The token is never printed — only whether one was found.
This is the command to reach for when the store looks empty, when a push says
401, or when two directories disagree about which project they are in.`,
Examples: []Example{
{"kettle config", "resolved paths and settings"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
return func(args []string) error {
root := project.Root("")
if root == "" {
return project.NotFoundError("")
}
fmt.Printf("project %s\n", root)
fmt.Printf("store %s\n", issue.Root(""))
fmt.Printf("payload %s\n", project.PayloadRoot(""))
fmt.Printf("config %s\n", config.ProjectPath(""))
fmt.Printf("logins %s\n", config.LoginsPath())
r, err := config.Resolve("")
if err != nil {
fmt.Println()
return err
}
red := r.Redacted()
fmt.Println()
fmt.Printf("login %s\n", orNone(red.Login))
fmt.Printf("url %s\n", orNone(red.URL))
fmt.Printf("token %s\n", orNone(red.Token))
if r.Owner != "" {
fmt.Printf("repo %s\n", r.Slug())
} else {
fmt.Printf("repo none\n")
}
return nil
}
},
})
}
+115
View File
@@ -0,0 +1,115 @@
package cmd
import (
"flag"
"fmt"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
func init() {
register(&Command{
Name: "evict",
Group: GroupIssue,
Args: "[<id>…]",
Short: "remove closed issues from the local store",
Long: `The store is a working set, not an archive. What is evicted is two conditions,
both read off the file:
state: closed the work is done
origin: <tracker> the work is somewhere else too
THE SECOND CONDITION IS THE WHOLE SAFETY ARGUMENT. ` + "`origin: local`" + ` means this
file IS the issue — there is no other copy and deleting it deletes the work. It
is never evicted, in any state, not even when named explicitly on the command
line: a closed local issue is reported and kept.
Eviction asks the file rather than the tracker, because state and origin are
domain fields and the answer is already in the store — which is why this needs
no network and no login. ` + "`kettle sync-evict`" + ` is the variant that refreshes state
from the tracker first and then makes the same decision.
Not a one-off migration: a pull by number fetches an issue in any state, so a
closed issue pulled after an eviction lands on disk again. Evict it again when
you are done with it.
INDEX.md is rebuilt, because it IS a view of the directory. The number -> slug
ledger is deliberately not pruned: its entries outlive the files they name, and
that is what makes a pull land on the same slug afterwards.`,
Examples: []Example{
{"kettle evict", "every closed issue that is not origin: local"},
{"kettle evict old-thing another-thing", "only these"},
{"kettle evict --dry-run", "print what would go; touch nothing"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
dryRun := fs.Bool("dry-run", false, "print what would be removed; touch nothing")
out := storeFlag(fs)
return func(args []string) error {
root, err := storeRoot(*out)
if err != nil {
return err
}
if !issue.StoreExists(root) {
return Fail("store %s does not exist — nothing to evict", root)
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
var missing []string
for _, id := range args {
if _, ok := issues[id]; !ok {
missing = append(missing, id)
}
}
if len(missing) > 0 {
return Fail("no such issue(s) in the store: %s", strings.Join(missing, ", "))
}
rep, err := issue.Evict(root, issues, args, *dryRun)
if err != nil {
return err
}
printEviction(rep, len(args) > 0)
return nil
}
},
})
}
func printEviction(rep *issue.EvictReport, named bool) {
verb := "evicted"
if rep.DryRun {
verb = "would evict"
}
for _, e := range rep.Evicted {
fmt.Printf("%-11s %s\n", verb, e.ID)
for _, p := range e.Paths {
fmt.Printf(" %s\n", p)
}
}
for _, k := range rep.Kept {
// An open issue is the normal case and says nothing worth a line —
// unless the operator named it, in which case they are owed the reason.
if k.Open && !named {
continue
}
if k.Open {
fmt.Printf("%-11s %s %s\n", "kept", k.ID, k.Why)
} else {
fmt.Printf("%-11s %s closed, %s\n", "kept", k.ID, k.Why)
}
}
if rep.DryRun {
fmt.Printf("%d issue(s) would be evicted, %d kept — nothing was touched\n",
len(rep.Evicted), len(rep.Kept))
return
}
fmt.Printf("%d issue(s) evicted, %d kept\n", len(rep.Evicted), len(rep.Kept))
if rep.IndexPath != "" {
fmt.Printf("index: %s — %d issue(s)\n", rep.IndexPath, rep.IndexCount)
}
}
+229
View File
@@ -0,0 +1,229 @@
package cmd
import (
"flag"
"fmt"
"os"
"sort"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func init() {
register(&Command{
Name: "sync-evict",
Group: GroupSync,
Args: "[<id>…]",
Short: "refresh state from the tracker, then evict what is closed",
Long: `` + "`kettle evict`" + ` is the command that decides and deletes. This adds exactly one
thing in front of it: a ` + "`state:`" + ` that is not stale. A local ` + "`state:`" + ` is only as
fresh as the last pull, so an issue closed in the web UI an hour ago still reads
` + "`open`" + ` here and the offline command will — correctly — leave it alone. That is
the gap this closes, and before it existed the operator had to pull the five
closed issues back onto disk before anything could remove them.
ORDER OF OPERATIONS, AND IT IS THE WHOLE SAFETY ARGUMENT:
1. every candidate's state is fetched — ALL of them, before anything is
removed;
2. each answer must be the issue that was asked about, in a state the domain
recognizes;
3. only then is the eviction run, by handing the refreshed issues to the
domain — the same decision, the same deletion, the same protection of
` + "`origin: local`" + `, in one place.
A dead connection, a non-2xx, an answer about another issue, a state nobody
recognizes: the run stops at step 2 and NOTHING is deleted, not even the issues
whose answers had already arrived. That is stricter than push, which deletes as
it goes, and it costs nothing here — there is no ordering constraint between
evictions, so there is no reason to start before every answer is in.
A candidate is an issue carrying a ` + "`gitea:`" + ` handle. ` + "`origin: local`" + ` work has
none, is never asked about, and is never evicted — it is not in the tracker to
be closed. A tracked issue whose handle is missing or unreadable cannot be
verified, so it is reported and kept rather than guessed at.
Cost: one request per candidate. The store is a working set that push keeps
small, and a wrong answer here deletes a file, so each issue is asked about by
its own address rather than inferred from a list a limit could have truncated.
The refreshed state is written back even for the issues that stay: the answer is
already paid for, and a store that keeps a state the tracker has disowned is the
thing this command exists to fix.`,
Examples: []Example{
{"kettle sync-evict", "ask about every synced issue; evict the closed ones"},
{"kettle sync-evict old-thing another-thing", "only these"},
{"kettle sync-evict --dry-run", "ask, report, write and delete nothing"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
dryRun := fs.Bool("dry-run", false, "ask the tracker and report; write and delete nothing")
out := storeFlag(fs)
return func(args []string) error {
root, client, err := syncStartExisting(*out)
if err != nil {
return err
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
var missing []string
for _, id := range args {
if _, ok := issues[id]; !ok {
missing = append(missing, id)
}
}
if len(missing) > 0 {
return Fail("no such issue(s) in the store: %s", strings.Join(missing, ", "))
}
checkable, unverifiable, local := syncEvictCandidates(issues, args)
for _, s := range unverifiable {
fmt.Fprintf(os.Stderr, "warning: %s: %s — kept, and not asked about\n", s.id, s.why)
}
// A local issue is reported only when the operator named it: they
// asked about this file by name and are owed the reason it stayed.
if len(args) > 0 {
for _, s := range local {
fmt.Printf("%-11s %s %s\n", "kept", s.id, s.why)
}
}
if len(checkable) == 0 {
fmt.Println("nothing to check: nothing named carries a `gitea:` handle")
return nil
}
// Every answer first, deletions after.
fresh := make(map[string]string, len(checkable))
for _, c := range checkable {
got, err := client.For(c.key.Repo).GetIssue(c.key.Number)
if err != nil {
return Fail("%s: asking the tracker about %s failed: %v\nNothing was evicted.",
c.id, c.key, err)
}
state, ok := syncEvictConfirms(got, c.key.Number)
if !ok {
return Fail("%s: the answer for %s does not confirm a state "+
"(issue #%d, state %q). Nothing was evicted.",
c.id, c.key, got.Number, got.State)
}
fresh[c.id] = state
}
// The store stops lying even about the issues that stay. This is
// the only write made before the decision, and a dry run makes
// none.
changed := 0
for _, c := range checkable {
was := issues[c.id].State
if was == fresh[c.id] {
continue
}
fmt.Printf("%-11s %s %s -> %s\n", "state", c.id, was, fresh[c.id])
issues[c.id].State = fresh[c.id]
if *dryRun {
continue
}
if _, err := issue.Save(root, issues[c.id]); err != nil {
return err
}
changed++
}
ids := make([]string, 0, len(checkable))
for _, c := range checkable {
ids = append(ids, c.id)
}
rep, err := issue.Evict(root, issues, ids, *dryRun)
if err != nil {
return err
}
printEviction(rep, len(args) > 0)
// Evict rebuilds INDEX.md when something went; a state written back
// without an eviction changed the store too, and the index is a
// view of it. Neither happening means nothing changed on disk, and
// then nothing is rewritten.
if changed > 0 && rep.IndexPath == "" {
path, n, err := issue.BuildIndex(root)
if err != nil {
return err
}
fmt.Printf("index: %s — %d issue(s)\n", path, n)
}
return nil
}
},
})
}
// syncEvictTarget is one issue the tracker can be asked about, and the address
// to ask at — its own, so an issue that lives in another repository is asked
// about there.
type syncEvictTarget struct {
id string
key wire.Key
}
// syncEvictSkip is an issue that was not asked about, with the reason.
type syncEvictSkip struct{ id, why string }
// syncEvictCandidates splits the store into what the tracker can be asked
// about, what names a tracker but cannot be reached, and what is local.
//
// An unverifiable issue names a tracker but carries no handle to reach it by,
// which is a file to report and never one to delete on a guess. A local issue is
// in neither of those: it has no handle because it has never left this machine,
// and asking about it is not a question that has an answer.
//
// ids restricts the question to those issues; empty asks about the whole store.
func syncEvictCandidates(issues map[string]*issue.Issue, ids []string) (checkable []syncEvictTarget, unverifiable, local []syncEvictSkip) {
chosen := ids
if len(chosen) == 0 {
for id := range issues {
chosen = append(chosen, id)
}
sort.Strings(chosen)
}
for _, id := range chosen {
i, ok := issues[id]
if !ok {
continue
}
if i.IsLocal() {
local = append(local, syncEvictSkip{id, issue.LocalReason})
continue
}
key, ok := mapping.RemoteKeyOf(i)
if !ok {
unverifiable = append(unverifiable, syncEvictSkip{id,
fmt.Sprintf("origin: %s but no usable `%s:` handle", i.Origin, mapping.GiteaKey)})
continue
}
checkable = append(checkable, syncEvictTarget{id: id, key: key})
}
return checkable, unverifiable, local
}
// syncEvictConfirms is the state the tracker confirmed for this number — the
// deletion gate.
//
// Deliberately boring, and saying no by default, because everything downstream
// of a yes here may delete a file. An answer counts only when it is about the
// very issue that was asked about and names a state the domain recognizes. A
// non-2xx never reaches this: the transport has already returned an error.
func syncEvictConfirms(got *wire.Issue, number int) (string, bool) {
if got == nil || got.Number != number {
return "", false
}
for _, s := range issue.States {
if got.State == s {
return got.State, true
}
}
return "", false
}
+54
View File
@@ -0,0 +1,54 @@
package cmd
import (
"flag"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
)
// storeFlag registers the one flag almost every command has.
//
// An explicit --out overrides the resolved store and is used exactly as typed:
// a relative --out stays relative to the working directory, because that is
// what the operator asked for.
func storeFlag(fs *flag.FlagSet) *string {
return fs.String("out", "", "store root (default: <project>/.kettle/issues)")
}
// storeRoot resolves the store, or explains which directories were searched.
//
// No marker anywhere is an answer, not a fallback: a store placed in a
// plausible-looking directory is the failure the marker exists to replace.
func storeRoot(out string) (string, error) {
if root := issue.Root(out); root != "" {
return root, nil
}
return "", project.NotFoundError("")
}
// wasSet reports whether the operator actually typed this flag.
//
// Needed wherever the empty string is a legitimate value to reject rather than
// a synonym for "not given": `--check ""` is an empty selector and an error,
// while no --check at all means "just list the boxes".
func wasSet(fs *flag.FlagSet, name string) bool {
found := false
fs.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}
// stringList is a repeatable flag: --label tech/sql --label comp/appclick.
type stringList []string
func (l *stringList) String() string { return strings.Join(*l, ", ") }
func (l *stringList) Set(v string) error {
*l = append(*l, v)
return nil
}
+322
View File
@@ -0,0 +1,322 @@
package cmd
import (
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"unicode/utf8"
)
// The region markers. What sits between them belongs to the generator; the
// rest of the file belongs to whoever wrote it.
const (
genOpen = "<!-- kettle:gen -->"
genClose = "<!-- /kettle:gen -->"
)
// genBanner opens every generated region. The first thing anybody who finds
// the block wants to do is edit it in place, so the block says who wrote it and
// which command writes it again.
const genBanner = "**Generated from the kettle command registry by `kettle gen skills`.** " +
"Everything between the two markers is replaced on the next run — " +
"hand-written prose belongs outside them."
// exampleAlign is the widest example command that still gets its `# what`
// padded into a column. One long pipeline would otherwise push every other
// comment off the right edge of the page.
const exampleAlign = 56
func init() {
register(&Command{
Name: "gen",
Group: GroupProject,
Args: "skills",
Short: "write the plugin's SKILL.md files from the command registry",
Long: `A SKILL.md tells an agent how to invoke this binary. Hand-written, it drifts: a
flag is renamed here and the documentation goes on recommending the old one,
and the agent that reads it fails in a way nobody traces back to a stale
sentence. Everything those files say about a command — its usage line, its
flags with their defaults, its worked examples — is already in the registry
this binary is built from, so it is written from there and cannot disagree.
THE GENERATOR OWNS A REGION, NOT A FILE. Each SKILL.md carries a pair of HTML
comment markers — ` + "`kettle:gen`" + ` to open and ` + "`/kettle:gen`" + ` to close, both written in
the ` + "`<!-- … -->`" + ` form and visible at the top and bottom of the block below.
Everything between them is replaced on every run; every byte outside them comes
back exactly as it was, which matters most for ` + "`description:`" + `, the prose that
decides whether an agent loads the skill at all, and the one thing here that no
generator can write.
A file with no markers is REPORTED AND LEFT ALONE, never overwritten: clobbering
somebody's prose because they forgot a marker is the failure this design exists
to prevent. A file that does not exist yet is created with a frontmatter stub
around a generated block, for a human to fill in.
The output is deterministic to the byte — no timestamps, no map iteration — so
regenerating something that has not changed produces no diff. --check is that
property made useful: it writes nothing and exits 1 when any file on disk
differs from what would be generated, which is what a pre-commit hook or a CI
step calls. It wins over --dry-run when both are given.`,
Examples: []Example{
{"kettle gen skills --out ../plugins/tea/skills", "write the region in every group's SKILL.md"},
{"kettle gen skills --out ../plugins/tea/skills --dry-run", "print what would change; write nothing"},
{"kettle gen skills --out ../plugins/tea/skills --check", "exit 1 if the docs are out of date"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
out := fs.String("out", "", "directory the skills live in; one <group>/SKILL.md under it")
dryRun := fs.Bool("dry-run", false, "print what would change; write nothing")
check := fs.Bool("check", false, "write nothing, exit 1 if anything is out of date")
return func(args []string) error {
target := "skills"
if len(args) > 0 {
target = args[0]
}
if len(args) > 1 || target != "skills" {
return Fail("the only target is `skills` — try `kettle gen skills --out <dir>`")
}
if *out == "" {
return Fail("--out is required — the directory the SKILL.md files live under")
}
return genSkills(*out, *dryRun, *check)
}
},
})
}
// errNoRegion is what a file that the generator may not touch reports.
var errNoRegion = errors.New("no " + genOpen + " … " + genClose + " region")
func genSkills(dir string, dryRun, check bool) error {
// --check is a read-only question about the working tree, so it overrules
// --dry-run rather than combining with it.
if check {
dryRun = true
}
groups := docGroups()
var written, unchanged, outdated, kept int
for _, group := range groups {
path := filepath.Join(dir, group, "SKILL.md")
block, err := renderGroup(commandsIn(group))
if err != nil {
return err
}
existing, err := os.ReadFile(path)
switch {
case errors.Is(err, fs.ErrNotExist):
outdated++
if check {
fmt.Printf("%-13s %s\n", "missing", path)
continue
}
if dryRun {
fmt.Printf("%-13s %s\n", "would create", path)
continue
}
if err := writeFile(path, stubFile(group, block)); err != nil {
return err
}
written++
fmt.Printf("%-13s %s\n", "created", path)
case err != nil:
return err
default:
want, err := spliceRegion(string(existing), block)
if err != nil {
// Reported, never repaired: a missing marker is somebody's
// prose sitting where the block used to be.
kept++
fmt.Fprintf(os.Stderr, "kettle gen: %s left alone — %v\n", path, err)
continue
}
if want == string(existing) {
unchanged++
fmt.Printf("%-13s %s\n", "unchanged", path)
continue
}
outdated++
if check {
fmt.Printf("%-13s %s\n", "stale", path)
continue
}
if dryRun {
fmt.Printf("%-13s %s\n", "would update", path)
continue
}
if err := writeFile(path, want); err != nil {
return err
}
written++
fmt.Printf("%-13s %s\n", "updated", path)
}
}
switch {
case check:
fmt.Printf("%d file(s) checked, %d out of date, %d without a region\n",
len(groups), outdated, kept)
if outdated > 0 {
fmt.Printf("run `kettle gen skills --out %s`\n", dir)
return SilentError{Code: 1}
}
case dryRun:
fmt.Printf("%d file(s) would change, %d unchanged, %d without a region — nothing was written\n",
outdated, unchanged, kept)
default:
fmt.Printf("%d file(s) written, %d unchanged, %d without a region\n", written, unchanged, kept)
}
return nil
}
// docGroups lists the groups that have commands, in the order Commands()
// returns them — the same order twice, so two runs cannot differ.
func docGroups() []string {
var out []string
seen := map[string]bool{}
for _, c := range Commands() {
if c.Group == "" {
fmt.Fprintf(os.Stderr, "kettle gen: command %q has no group and is in no skill\n", c.Name)
continue
}
if !seen[c.Group] {
seen[c.Group] = true
out = append(out, c.Group)
}
}
return out
}
func commandsIn(group string) []*Command {
var out []*Command
for _, c := range Commands() {
if c.Group == group {
out = append(out, c)
}
}
return out
}
// renderGroup is the generated block for one group, without the markers and
// without a trailing newline.
func renderGroup(cmds []*Command) (string, error) {
var b strings.Builder
b.WriteString(genBanner)
b.WriteString("\n")
for _, c := range cmds {
text := renderCommand(c)
// A block holding either marker would cut itself in half on the next
// run — the splice would end the region in the middle of the prose that
// mentions it. Loud here rather than quietly truncated on disk.
if strings.Contains(text, genOpen) || strings.Contains(text, genClose) {
return "", Fail("command %q spells a region marker out in full; the generated block would then end inside itself — write it another way", c.Name)
}
b.WriteString(text)
}
return strings.TrimRight(b.String(), "\n"), nil
}
func renderCommand(c *Command) string {
var b strings.Builder
fmt.Fprintf(&b, "\n## `%s`\n\n%s\n", c.Usage(), c.Short)
if long := strings.TrimSpace(c.Long); long != "" {
b.WriteString("\n" + long + "\n")
}
if flags := c.Flags(); len(flags) > 0 {
b.WriteString("\n| flag | default | what it does |\n| --- | --- | --- |\n")
for _, f := range flags {
fmt.Fprintf(&b, "| `--%s` | %s | %s |\n", f.Name, defaultCell(f.DefValue), cell(f.Usage))
}
}
if len(c.Examples) > 0 {
w := exampleWidth(c.Examples)
b.WriteString("\n```bash\n")
for _, e := range c.Examples {
pad := w - utf8.RuneCountInString(e.Cmd)
if pad < 0 {
pad = 0
}
fmt.Fprintf(&b, "%s%s # %s\n", e.Cmd, strings.Repeat(" ", pad), e.What)
}
b.WriteString("```\n")
}
return b.String()
}
// exampleWidth is the column the `# what` comments line up at. Runes, not
// bytes: an example with Cyrillic in it would otherwise pull the column left by
// however many multi-byte characters it holds.
func exampleWidth(examples []Example) int {
w := 0
for _, e := range examples {
if n := utf8.RuneCountInString(e.Cmd); n > w && n <= exampleAlign {
w = n
}
}
return w
}
func defaultCell(v string) string {
if v == "" {
return "—"
}
return "`" + cell(v) + "`"
}
// cell keeps a value from breaking out of its table row.
func cell(s string) string {
s = strings.ReplaceAll(s, "\n", " ")
return strings.ReplaceAll(s, "|", `\|`)
}
func region(block string) string {
return genOpen + "\n" + block + "\n" + genClose
}
// spliceRegion swaps the block into existing, leaving every other byte alone.
func spliceRegion(existing, block string) (string, error) {
start := strings.Index(existing, genOpen)
if start < 0 {
return "", errNoRegion
}
rest := start + len(genOpen)
end := strings.Index(existing[rest:], genClose)
if end < 0 {
return "", fmt.Errorf("%s is missing its %s", genOpen, genClose)
}
return existing[:start] + region(block) + existing[rest+end+len(genClose):], nil
}
// stubFile is a new SKILL.md: the least frontmatter that is still a skill,
// and the region.
//
// The description is left as a TODO on purpose. It is the sentence that decides
// whether an agent loads this skill at all — prose a human tunes against real
// failures to trigger, and the one thing here a generator has no way to write.
func stubFile(group, block string) string {
title := "# kettle " + group + "\n"
if blurb := groupBlurb[group]; blurb != "" {
title += "\n" + blurb + "\n"
}
return "---\n" +
"name: " + group + "\n" +
"description: TODO — write this by hand. It is the only thing that decides whether an agent loads this skill at all, so it is prose a human tunes; kettle gen never reads or writes it.\n" +
"---\n\n" +
title + "\n" +
region(block) + "\n"
}
func writeFile(path, content string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(content), 0o644)
}
+281
View File
@@ -0,0 +1,281 @@
package cmd_test
// `kettle gen` writes documentation an agent reads to invoke this binary, into
// files a human also writes prose in. Both halves of that are tested here: what
// it produces has to be the same twice over, and what it does NOT own has to
// come back byte for byte.
import (
"os"
"path/filepath"
"strings"
"testing"
)
const (
genOpen = "<!-- kettle:gen -->"
genClose = "<!-- /kettle:gen -->"
)
func TestGenWritesOneFilePerGroupAndIsIdempotent(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
first := mustRun(t, dir, "gen", "skills", "--out", out)
for _, group := range []string{"project", "issue", "sync"} {
path := filepath.Join(out, group, "SKILL.md")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("%s was not created: %v\n%s", path, err, first.out())
}
body := string(raw)
// The frontmatter is what makes it a skill at all, and the description
// is prose a human tunes — the stub says so and generates nothing.
if !strings.HasPrefix(body, "---\nname: "+group+"\n") {
t.Errorf("%s has no frontmatter naming the group:\n%s", path, firstLines(body, 5))
}
if !strings.Contains(body, genOpen) || !strings.Contains(body, genClose) {
t.Errorf("%s was created without the region markers:\n%s", path, body)
}
// The block has to say what wrote it: the first thing anybody who finds
// it will want to do is edit it in place.
if !strings.Contains(body, "kettle gen skills") {
t.Errorf("%s does not name the command that regenerates it:\n%s", path, body)
}
}
// One command's documentation, end to end: usage line, short, a flag out of
// the flag set, and a worked example with its explanation beside it.
issues, err := os.ReadFile(filepath.Join(out, "issue", "SKILL.md"))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"## `kettle evict [<id>…]`",
"remove closed issues from the local store",
"| `--dry-run` | `false` | print what would be removed; touch nothing |",
"kettle evict --dry-run",
"# print what would go; touch nothing",
} {
if !strings.Contains(string(issues), want) {
t.Errorf("the issue group is missing %q:\n%s", want, issues)
}
}
// Deterministic to the byte: a regeneration of something that has not
// changed must produce no diff at all, or every run of a CI step is a
// spurious one.
before := readAll(t, out)
second := mustRun(t, dir, "gen", "skills", "--out", out)
if strings.Contains(second.stdout, "updated") {
t.Errorf("a second run rewrote a file:\n%s", second.out())
}
for path, content := range before {
if now := readFile(t, path); now != content {
t.Errorf("%s changed on a second run with nothing else changed", path)
}
}
if r := run(t, dir, "gen", "skills", "--out", out, "--check"); r.code != 0 {
t.Errorf("--check exited %d on files that were just written:\n%s", r.code, r.out())
}
}
// The generator owns a region, not a file. Everything outside the markers is
// somebody's prose and comes back exactly as it was.
func TestGenLeavesHandWrittenProseAlone(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
mustRun(t, dir, "gen", "skills", "--out", out)
path := filepath.Join(out, "issue", "SKILL.md")
raw := readFile(t, path)
start := strings.Index(raw, genOpen)
end := strings.Index(raw, genClose) + len(genClose)
if start < 0 || end < len(genClose) {
t.Fatalf("no region in the generated file:\n%s", raw)
}
const above = "\n## Identity: the slug\n\nThe file name is the id, and it never changes.\n\n"
const below = "\n\n## Layering rule\n\nThis skill must keep working with the sync skill deleted.\n"
// A description a human tuned, in the frontmatter the generator must not
// touch: it is the only thing that decides whether the skill loads at all.
edited := strings.Replace(raw[:start], "description: TODO", "description: Work with this project's issues as units of work", 1)
edited += above + raw[start:end] + below
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
mustRun(t, dir, "gen", "skills", "--out", out)
after := readFile(t, path)
if after != edited {
t.Errorf("a no-op regeneration did not return the file byte for byte:\n--- want ---\n%s\n--- got ---\n%s", edited, after)
}
// And the prose survives a regeneration that actually rewrites the block.
shortened := strings.Replace(after, genClose, "the block was gutted by hand\n"+genClose, 1)
if err := os.WriteFile(path, []byte(shortened), 0o644); err != nil {
t.Fatal(err)
}
mustRun(t, dir, "gen", "skills", "--out", out)
restored := readFile(t, path)
if restored != edited {
t.Error("regenerating the block did not restore it, or did not preserve the prose around it")
}
if !strings.Contains(restored, "description: Work with this project's issues") {
t.Errorf("the hand-tuned description was overwritten:\n%s", firstLines(restored, 5))
}
if !strings.Contains(restored, above) || !strings.Contains(restored, below) {
t.Errorf("hand-written prose outside the markers was lost:\n%s", restored)
}
}
// Clobbering somebody's prose because they forgot a marker is the failure this
// whole design exists to prevent.
func TestGenNeverOverwritesAFileWithoutMarkers(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
path := filepath.Join(out, "issue", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
const prose = "---\nname: issue\ndescription: hand written, every word of it\n---\n\n# Everything here is somebody's work\n"
if err := os.WriteFile(path, []byte(prose), 0o644); err != nil {
t.Fatal(err)
}
r := mustRun(t, dir, "gen", "skills", "--out", out)
if got := readFile(t, path); got != prose {
t.Fatalf("a file with no markers was rewritten:\n%s", got)
}
// Left alone silently is how it drifts unnoticed, so it is reported — and
// on stderr, where a warning belongs.
if !strings.Contains(r.stderr, path) {
t.Errorf("the skipped file was not named on stderr:\n%s", r.out())
}
if !strings.Contains(r.stdout, "without a region") {
t.Errorf("the receipt did not account for it:\n%s", r.stdout)
}
// The other groups still got written — one unmanaged file stops nothing.
if _, err := os.Stat(filepath.Join(out, "sync", "SKILL.md")); err != nil {
t.Error("one file without markers stopped the whole run")
}
}
func TestGenCheckFailsOnAStaleFileAndNamesIt(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
mustRun(t, dir, "gen", "skills", "--out", out)
stale := filepath.Join(out, "sync", "SKILL.md")
raw := readFile(t, stale)
edited := strings.Replace(raw, genClose, "kettle push --thoroughly-renamed-flag\n"+genClose, 1)
if err := os.WriteFile(stale, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
r := run(t, dir, "gen", "skills", "--out", out, "--check")
if r.code != 1 {
t.Fatalf("--check exited %d, want 1 — this is what a hook or a CI step calls:\n%s", r.code, r.out())
}
if !strings.Contains(r.stdout, stale) {
t.Errorf("--check did not say which file is out of date:\n%s", r.out())
}
// A question about the tree, never an answer written into it.
if got := readFile(t, stale); got != edited {
t.Error("--check wrote to the file it was asked about")
}
// A file that is not there at all is out of date too, not a nothing.
if err := os.Remove(stale); err != nil {
t.Fatal(err)
}
if r := run(t, dir, "gen", "skills", "--out", out, "--check"); r.code != 1 {
t.Errorf("--check exited %d for a missing file, want 1:\n%s", r.code, r.out())
}
if _, err := os.Stat(stale); err == nil {
t.Error("--check created the file it was asked about")
}
}
func TestGenDryRunWritesNothingAtAll(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
fresh := mustRun(t, dir, "gen", "skills", "--out", out, "--dry-run")
if !strings.Contains(fresh.stdout, "would create") {
t.Errorf("a dry run said nothing about what it would do:\n%s", fresh.out())
}
if _, err := os.Stat(out); err == nil {
t.Fatal("a dry run created the output directory")
}
// And on an existing tree: the file is described, never touched.
mustRun(t, dir, "gen", "skills", "--out", out)
path := filepath.Join(out, "issue", "SKILL.md")
edited := strings.Replace(readFile(t, path), genClose, "gutted\n"+genClose, 1)
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
r := mustRun(t, dir, "gen", "skills", "--out", out, "--dry-run")
if !strings.Contains(r.stdout, "would update") || !strings.Contains(r.stdout, "nothing was written") {
t.Errorf("the dry run did not report the pending change:\n%s", r.out())
}
if got := readFile(t, path); got != edited {
t.Error("a dry run rewrote the file")
}
}
func TestGenRefusesAnUnknownTargetAndAMissingOut(t *testing.T) {
dir := t.TempDir()
if r := run(t, dir, "gen", "skills"); r.code == 0 || !strings.Contains(r.stderr, "--out") {
t.Errorf("gen without --out must stop and say so:\n%s", r.out())
}
if r := run(t, dir, "gen", "agents", "--out", filepath.Join(dir, "x")); r.code == 0 {
t.Errorf("an unknown target must be refused:\n%s", r.out())
}
if _, err := os.Stat(filepath.Join(dir, "x")); err == nil {
t.Error("the refused run created its output directory anyway")
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// readAll is every file under root, by path, for a byte-for-byte comparison
// after a second run.
func readAll(t *testing.T, root string) map[string]string {
t.Helper()
out := map[string]string{}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
out[path] = string(raw)
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
func firstLines(s string, n int) string {
lines := strings.SplitN(s, "\n", n+1)
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
+46
View File
@@ -0,0 +1,46 @@
package cmd
import (
"flag"
"fmt"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
func init() {
register(&Command{
Name: "index",
Group: GroupIssue,
Short: "rebuild INDEX.md from what is on disk",
Long: `A map of the local store, nothing else. The ` + "`origin`" + ` column is the only place
the index acknowledges that a tracker exists: ` + "`local`" + ` means the issue has never
left this machine, anything else names the tracker it also lives in. Both are
ordinary issues here.
` + "`progress`" + ` counts the body's checkboxes, ticked over total, and is read off the
body at build time rather than stored — a second copy of that state in a
metadata field would be wrong by the next edit.
An existing store with nothing in it is a legitimate thing to index and gets an
"_empty_" table. A store that is not there is an error, not a directory to
create.`,
Examples: []Example{
{"kettle index", "rebuild the index for this project"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
out := storeFlag(fs)
return func(args []string) error {
root, err := storeRoot(*out)
if err != nil {
return err
}
path, n, err := issue.BuildIndex(root)
if err != nil {
return Fail("%v — nothing was created; create an issue with `kettle new`, or pass --out", err)
}
fmt.Printf("%s — %d issue(s)\n", path, n)
return nil
}
},
})
}
+157
View File
@@ -0,0 +1,157 @@
package cmd
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
)
// writeConfig creates or updates .kettle/config.yaml, touching only the
// settings it was given.
//
// Init is idempotent, and that has to include the config: re-running it to add
// a repository must not silently drop the login somebody pinned last week.
func writeConfig(root, login, repo string, dryRun bool) (string, error) {
path := filepath.Join(root, project.Marker, "config.yaml")
rel := filepath.Join(project.Marker, "config.yaml")
cfg, existed, err := config.ReadProjectFile(path)
if err != nil {
return "", err
}
changed := !existed
if login != "" && cfg.Login != login {
cfg.Login, changed = login, true
}
if repo != "" && cfg.Repo != repo {
cfg.Repo, changed = repo, true
}
if !changed {
return "", nil
}
verb := "updated"
if !existed {
verb = "created"
}
detail := "no login or repository pinned yet — `kettle init --login … --repo …`"
if cfg.Login != "" || cfg.Repo != "" {
detail = fmt.Sprintf("login: %s, repo: %s", orNone(cfg.Login), orNone(cfg.Repo))
}
if dryRun {
return fmt.Sprintf("%s %s (%s)", verb, rel, detail), nil
}
if err := config.SaveProject(path, cfg); err != nil {
return "", err
}
return fmt.Sprintf("%s %s (%s)", verb, rel, detail), nil
}
func orNone(s string) string {
if s == "" {
return "none"
}
return s
}
func init() {
register(&Command{
Name: "init",
Group: GroupProject,
Short: "make this directory a project that tracks issues",
Long: `Creates ` + "`.kettle/`" + ` — the marker every other command resolves the store from,
and ` + "`.kettle/config.yaml`" + `, which says which tracker repository these issues
belong to and which login to reach it under.
The marker is deliberately something an operator makes, not something inferred
from the tree: ` + "`.git`" + ` is in every clone, so anything that inferred a root from
one would write issues into whatever it happened to be installed in.
--login pins a name, never a credential. The tokens live in one file per
machine, outside every working tree, managed with ` + "`kettle auth`" + `.
All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates
an older store in if it finds one (tmp/ or .tea/), writes the config without
disturbing settings it was not given, and adds .kettle/ to .gitignore. Each
migration is a move, not a copy — two stores is the state the marker exists to
prevent — and it refuses to pick a winner when both sides hold a file of the
same name.
Do NOT run this inside a linked worktree. A worktree is the same project on
another branch and reaches the store by a hop out to the main checkout; a marker
here would give one project two stores, and the directory holding the second one
disappears with the branch.`,
Examples: []Example{
{"kettle init", "initialize the current directory"},
{"kettle init --login noodles --repo claude-skills/marketplace", "and point it at a tracker"},
{"kettle init --at ~/code/x", "initialize somewhere else"},
{"kettle init --dry-run", "say what it would do, touch nothing"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
at := fs.String("at", "", "directory to initialize (default: the working directory)")
login := fs.String("login", "", "name of a login in the machine-wide file (see `kettle auth`)")
repo := fs.String("repo", "", "tracker repository, as owner/name")
dryRun := fs.Bool("dry-run", false, "report what would happen; change nothing")
return func(args []string) error {
root := *at
if root == "" {
wd, err := os.Getwd()
if err != nil {
return err
}
root = wd
}
root, err := filepath.Abs(root)
if err != nil {
return err
}
if fi, err := os.Stat(root); err != nil || !fi.IsDir() {
return Fail("%s is not a directory", root)
}
// A second marker inside an existing project gives it a second
// store, and the nearer one wins — which is a surprise worth
// naming before it happens, not after.
if existing := project.Root(root); existing != "" && existing != root {
fmt.Fprintf(os.Stderr,
"warning: %s already sits inside the project at %s — a second marker here gives it a second store, and the nearer one wins.\n",
root, existing)
}
if *repo != "" {
if owner, name, ok := strings.Cut(*repo, "/"); !ok || owner == "" || name == "" {
return Fail("--repo %q is not owner/name", *repo)
}
}
done, err := project.Init(root, *dryRun)
if err != nil {
return err
}
line, err := writeConfig(root, *login, *repo, *dryRun)
if err != nil {
return err
}
if line != "" {
done = append(done, line)
}
prefix := ""
if *dryRun {
prefix = "would: "
}
for _, l := range done {
fmt.Println(prefix + l)
}
return nil
}
},
})
}
+285
View File
@@ -0,0 +1,285 @@
package cmd
import (
"flag"
"fmt"
"os"
"regexp"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func init() {
register(&Command{
Name: "labels",
Group: GroupSync,
Short: "put the canonical type/* and severity/* labels into a repository",
Long: `Every ` + "`type/*`" + ` and every ` + "`severity/*`" + ` the domain taxonomy defines, created up
front instead of trickling in as a side effect of whichever push first happens
to use one. Until a name exists in the repository nobody can filter by it in the
web UI, so somebody makes their own — foreign colour, no ` + "`exclusive`" + ` — and the
set arrives in pieces over months.
NO LABEL NAME IS SPELLED OUT HERE. The names come from the domain taxonomy and
are painted by the mapping layer, because a hex code is how a tracker paints a
chip and not what an issue is. Add a type over in the domain and the next run
creates it.
THE REPOSITORY'S OWN LABELS ARE READ BEFORE ANYTHING IS WRITTEN, and read from
the repository, never from a cache — a cache answers "what did we create last
time" and the question here is "what does this repository have right now". A
name that matches exactly is left alone; a colour or ` + "`exclusive`" + ` that disagrees
with the spec is reported, and corrected only under --fix. A name that merely
RESEMBLES a canonical one (the same tail, up to case, separator and whatever
namespace is in front: ` + "`x`" + `, ` + "`X`" + `, ` + "`kind/x`" + `, ` + "`type: x`" + ` against ` + "`type/x`" + `) is
reported with its id and never touched — renaming somebody else's label is a
decision, not a step.
Out of scope by design: ` + "`tech/*`" + ` and ` + "`comp/*`" + `, which are open-ended and are
created by push as they come up, and deleting or renaming anything at all. Only
repository labels are read; an organization's own labels sit behind a different
endpoint and are neither read nor written.
The issue store is out of scope too, and not incidentally: a label belongs to
the repository and not to any issue, so this neither reads the store nor creates
it. Request bodies go to the transport's own scratchpad, which is a sibling of
the store and never a child.`,
Examples: []Example{
{"kettle labels --dry-run", "print the plan; not one writing request"},
{"kettle labels", "create whatever is missing"},
{"kettle labels --fix", "also patch colour / exclusive drift"},
{"kettle labels --repo owner/name", "bootstrap another repository"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
dryRun := fs.Bool("dry-run", false, "print the plan; not one writing request")
fix := fs.Bool("fix", false, "also patch colour/exclusive on labels that already exist")
repo := fs.String("repo", "", "repository to bootstrap, as owner/name (default: this project's)")
return func(args []string) error {
if len(args) > 0 {
return Fail("labels takes no arguments — the set comes from the taxonomy, not the command line")
}
// The store root is resolved and then deliberately dropped: this
// command must fail the same way as every other sync command when
// there is no project, and must touch no issue once there is one.
_, client, err := syncStart("")
if err != nil {
return err
}
if *repo != "" {
r, err := wire.ParseRepo(*repo)
if err != nil {
return Fail("--repo %v", err)
}
client = client.For(r)
}
specs := mapping.CanonicalLabelSpecs()
existing, err := client.ListLabels()
if err != nil {
return err
}
rows, similar := labelPlan(specs, existing)
created, fixed, drifted := 0, 0, 0
for _, row := range rows {
mark := ""
if row.spec.Exclusive {
mark = " exclusive"
}
if row.got == nil {
created++
if *dryRun {
fmt.Printf("create %-20s %s%s\n", row.spec.Name, row.spec.Color, mark)
continue
}
made, err := client.CreateLabel(row.spec)
if err != nil {
return err
}
fmt.Printf("created %-20s id %-5d %s%s\n", row.spec.Name, made.ID, row.spec.Color, mark)
continue
}
if len(row.drift) == 0 {
fmt.Printf("present %-20s id %d\n", row.spec.Name, row.got.ID)
continue
}
drifted++
shown := labelShowDrift(row.drift)
if !*fix {
fmt.Printf("present %-20s id %-5d drift: %s\n", row.spec.Name, row.got.ID, shown)
continue
}
if *dryRun {
fmt.Printf("fix %-20s id %-5d %s\n", row.spec.Name, row.got.ID, shown)
continue
}
// The unchanged name rides along because a server that reads an
// absent field as empty would blank it, and the description is
// the repository's own: a description somebody rewrote is
// theirs, and this run is about colour and exclusivity.
patch := row.spec
patch.Description = row.got.Description
if _, err := client.EditLabel(row.got.ID, patch); err != nil {
return err
}
fixed++
fmt.Printf("fixed %-20s id %-5d %s\n", row.spec.Name, row.got.ID, shown)
}
for _, s := range similar {
fmt.Fprintf(os.Stderr, "warning: %q (id %d) resembles %s — left alone; rename it by hand or ignore it\n",
s.name, s.id, strings.Join(s.hits, ", "))
}
verb := "created"
if *dryRun {
verb = "to create"
}
line := fmt.Sprintf("%d canonical label(s): %d %s, %d present",
len(rows), created, verb, len(rows)-created)
if drifted > 0 {
line += fmt.Sprintf(" (%d drifted, %d fixed)", drifted, fixed)
}
if len(similar) > 0 {
line += fmt.Sprintf(", %d similar", len(similar))
}
fmt.Println(line)
if drifted > 0 && !*fix {
fmt.Println("drift is shown, not applied — re-run with --fix to patch colour/exclusive")
}
if *dryRun {
fmt.Println("dry-run — nothing was written")
}
return nil
}
},
})
}
// labelRow is one canonical label, decided before anything is written: what the
// taxonomy says it should be, what the repository already has under that exact
// name (nil when it has nothing), and where the two disagree.
type labelRow struct {
spec wire.LabelRequest
got *wire.Label
drift []labelDiff
}
// labelDiff is one field that disagrees, with both readings, so a receipt can
// show the change without the caller re-deriving it.
type labelDiff struct{ field, is, want string }
// labelLookalike is a label of the repository's own that resembles a canonical
// name. Reported with its id and never touched.
type labelLookalike struct {
name string
id int64
hits []string
}
// labelPlan pairs the canonical set with what the repository holds.
//
// In taxonomy order, because a bootstrap prints its plan in that order and a map
// would shuffle it on every run — two identical runs would look like different
// ones.
func labelPlan(specs []wire.LabelRequest, existing []wire.Label) ([]labelRow, []labelLookalike) {
byName := make(map[string]*wire.Label, len(existing))
for i := range existing {
byName[existing[i].Name] = &existing[i]
}
canonical := make(map[string]map[string]bool, len(specs))
rows := make([]labelRow, 0, len(specs))
for _, spec := range specs {
canonical[spec.Name] = labelAkin(spec.Name)
row := labelRow{spec: spec, got: byName[spec.Name]}
if row.got != nil {
row.drift = labelDrift(spec, row.got)
}
rows = append(rows, row)
}
var similar []labelLookalike
for i := range existing {
l := &existing[i]
if _, exact := canonical[l.Name]; exact {
continue
}
mine := labelAkin(l.Name)
var hits []string
for _, spec := range specs {
if labelIntersects(canonical[spec.Name], mine) {
hits = append(hits, spec.Name)
}
}
if len(hits) > 0 {
similar = append(similar, labelLookalike{name: l.Name, id: l.ID, hits: hits})
}
}
return rows, similar
}
// labelDrift is where an existing label disagrees with the spec.
//
// Colour and `exclusive` only. A description somebody rewrote is theirs, and the
// name matched exactly or this row would not exist.
func labelDrift(spec wire.LabelRequest, got *wire.Label) []labelDiff {
var out []labelDiff
if labelHex(got.Color) != labelHex(spec.Color) {
out = append(out, labelDiff{"color", labelHex(got.Color), labelHex(spec.Color)})
}
if got.Exclusive != spec.Exclusive {
out = append(out, labelDiff{"exclusive",
fmt.Sprintf("%t", got.Exclusive), fmt.Sprintf("%t", spec.Exclusive)})
}
return out
}
// labelHex normalizes a colour for comparison. Gitea reports them bare
// (`ee0701`) and the mapping layer writes them with a `#`; same colour, so a
// comparison has to strip before it compares.
func labelHex(v string) string { return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(v), "#")) }
var labelWords = regexp.MustCompile(`[^a-z0-9]+`)
// labelAkin is the comparison keys for a label name: its tail, and the whole
// name squashed.
//
// Case, separators and the namespace in front are noise — what a person meant is
// the tail. `x`, `X` and `kind/x` all reduce to the same tail as `type/x`, and
// `severity: x y` to the same squashed form as `severity/xy`. Two names resemble
// each other when these sets intersect.
func labelAkin(name string) map[string]bool {
var parts []string
for _, p := range labelWords.Split(strings.ToLower(name), -1) {
if p != "" {
parts = append(parts, p)
}
}
if len(parts) == 0 {
return nil
}
return map[string]bool{parts[len(parts)-1]: true, strings.Join(parts, ""): true}
}
func labelIntersects(a, b map[string]bool) bool {
for k := range a {
if b[k] {
return true
}
}
return false
}
func labelShowDrift(drift []labelDiff) string {
var out []string
for _, d := range drift {
out = append(out, fmt.Sprintf("%s %s -> %s", d.field, d.is, d.want))
}
return strings.Join(out, ", ")
}
+144
View File
@@ -0,0 +1,144 @@
package cmd
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
func init() {
register(&Command{
Name: "new",
Group: GroupIssue,
Short: "create a local issue from its type template",
Long: `The issue is real the moment this writes the file. Nothing is pending, nothing
is a draft awaiting a tracker: ` + "`origin: local`" + ` is a complete state and pushing it
later is optional.
While it says local, this file is the ONLY copy of the work — the store, not a
cache of anything. That is what a push changes: it hands the issue to the
tracker and removes the file.
Writes .tea/issues/<slug>.md prefilled with the type's template, prints the
path, and rebuilds INDEX.md. Fill the sections in an editor, then run
` + "`kettle check <id>`" + `.
Body prose is Russian, section headers and the title are English.`,
Examples: []Example{
{`kettle new --type task --title "Wire sqlc into the appclick repo layer" --label tech/sql --label comp/appclick`,
"a task with two free-form labels"},
{`kettle new --type bug --title "Fix tea-guard crash on empty settings" --depends wire-sqlc-appclick --milestone v0.2`,
"a bug that is blocked by another issue"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
typ := fs.String("type", "", "issue type, one of: "+strings.Join(issue.TypeNames(), ", ")+" (becomes the exclusive type/* label)")
title := fs.String("title", "", "English, imperative, no type prefix")
id := fs.String("id", "", "slug (default: derived from the title)")
severity := fs.String("severity", "", "severity/* label, one of: "+strings.Join(issue.Severities, ", "))
milestone := fs.String("milestone", "", "milestone title")
var labels, assignees, depends stringList
fs.Var(&labels, "label", "extra label, e.g. tech/sql; repeat")
fs.Var(&assignees, "assignee", "assignee login; repeat")
fs.Var(&depends, "depends", "id this issue depends on; repeat")
out := storeFlag(fs)
return func(args []string) error {
if *typ == "" || *title == "" {
return Fail("--type and --title are both required")
}
if !issue.KnownType(*typ) {
return Fail("unknown --type %q — known: %s", *typ, strings.Join(issue.TypeNames(), ", "))
}
if *severity != "" && !issue.KnownSeverity(*severity) {
return Fail("unknown --severity %q — known: %s", *severity, strings.Join(issue.Severities, ", "))
}
// Before anything reads the store path. There is no store to be
// second-guessed about when there is no project.
root, err := storeRoot(*out)
if err != nil {
return err
}
labelSet := []string{"type/" + *typ}
if *severity != "" {
labelSet = append(labelSet, "severity/"+*severity)
}
for _, l := range labels {
if !contains(labelSet, l) {
labelSet = append(labelSet, l)
}
}
slug := *id
if slug == "" {
if slug, err = issue.UniqueID(root, issue.Slugify(*title, 0), nil); err != nil {
return err
}
} else if !issue.IsSlug(slug) {
return Fail("--id %q is not a slug (lowercase, digits, single dashes)", slug)
}
if _, err := os.Stat(issue.PathOf(root, slug)); err == nil {
return Fail("%s already exists", issue.PathOf(root, slug))
}
known := map[string]bool{}
for _, k := range issue.AllIDs(root) {
known[k] = true
}
for _, d := range depends {
if !known[d] {
fmt.Fprintf(os.Stderr, "warning: depends on %q, which is not in the store yet\n", d)
}
}
i := &issue.Issue{
ID: slug,
Title: *title,
Body: issue.Template(*typ, depends),
State: "open",
Labels: labelSet,
Assignees: assignees,
Milestone: *milestone,
Depends: depends,
Origin: issue.Local,
}
// The first issue in a fresh checkout has to create the store,
// but it says so — and it says where, because the path is
// absolute.
created, err := issue.CreateStore(root)
if err != nil {
return err
}
if created {
abs, _ := filepath.Abs(root)
fmt.Fprintf(os.Stderr, "created store %s\n", abs)
}
path, err := issue.Save(root, i)
if err != nil {
return err
}
if _, _, err := issue.BuildIndex(root); err != nil {
return err
}
fmt.Printf("%s [type/%s] %s\n", path, *typ, *title)
fmt.Printf("fill the sections, then: kettle check %s\n", slug)
return nil
}
},
})
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
+594
View File
@@ -0,0 +1,594 @@
package cmd
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func init() {
register(&Command{
Name: "pull",
Group: GroupSync,
Args: "[<key>…]",
Short: "fetch issues from the tracker into the local store",
Long: `THIS IS HOW A PUSHED ISSUE COMES BACK. ` + "`kettle push`" + ` deletes the local file the
moment the tracker confirms the write, so a pull is not a refresh of a copy you
kept — it is how the copy comes to exist at all.
It lands under the SAME slug it had before, after a rename in the web UI and on
a machine that has never seen the issue. Three sources answer "what is this
issue called here", in this order:
.remote.json the number -> slug ledger; the only one that knows what
is on disk right now, so it wins
<!-- kettle:id … --> the marker in the tracker-side body; it survives a lost
ledger, a fresh clone, another machine, and a retitling
the title slugified — where an issue filed in the web UI gets its
first local name
A marker is taken at its word only when the slug is free; a name already in use
is a collision, not an identity, and is uniquified rather than allowed to
overwrite somebody else's issue. The marker itself is stripped out of what lands
on disk.
TWO WAYS TO NAME WHAT TO PULL, and they are not the same operation:
kettle pull 42 #43 owner/repo#44 by key — an ADDRESS
kettle pull --milestone v0.2 by filter — a QUERY
A key fetches an issue in ANY state, because a number is an address and not a
question about state. Only filter mode leaves closed issues out — a closed issue
is not a unit of work — and only ` + "`--state closed`" + ` puts one in the store. An issue
already on disk is refreshed either way, so a local copy learns it was closed
instead of staying open forever, and the count that stayed out goes to stderr.
` + "`--limit`" + ` IS ON THE WRITE, NOT ON THE SELECTION. It counts the issues this run puts
in the store and never the closed ones it enumerated and threw away, so pages
keep coming until the budget is full — and stop the moment it is. A filter that
matches almost only closed issues ends in a warning and a short answer rather
than a walk of the whole tracker.
A PULL RETURNS THE UNIT OF WORK, NOT ONE ROW OF IT. ` + "`depends:`" + ` is filled from the
tracker's own dependency graph and every blocker comes down with it, recursively,
to --depth. What that costs, stated rather than hidden: one request per issue
that lands in the store, plus one per blocker the selection did not already
carry. ` + "`--no-deps`" + ` is the way back to one request, and narrows the answer to the
one issue you asked for. Dependencies are outside --limit: a blocker is followed
because a stored issue named it, not because the filter selected it, so a
filtered pull can leave more files behind than its limit — including one from
another milestone. The one blocker that does not land is a closed one.
PULLING OVERWRITES THE BODY: a fetch, not a merge. Local edits you have not
pushed are lost, with exactly one exception — checkbox state. A tick is monotone,
so a ` + "`[x]`" + ` on either side wins for any item whose text matches; unticking is not,
so untick locally and push. ` + "`--cached`" + ` skips an issue before any of that.
Comments ride along: the thread lands beside the issue in <id>.comments.md. It
costs no request when the payload says there are none, and a file left over from
an earlier pull is deleted — so no file means "no comments", never "not asked
for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
Examples: []Example{
{"kettle pull 42", "the issue and everything blocking it, in any state"},
{"kettle pull 42 --no-deps", "just that one issue — one request"},
{"kettle pull owner/repo#42", "an issue in another repository"},
{"kettle pull --milestone v0.2 --limit 20", "20 open issues from a milestone, blockers included"},
{"kettle pull --label type/bug --state all", "every bug; the closed ones are enumerated, not stored"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
milestone := fs.String("milestone", "", "pull a whole milestone (id or title)")
var labels stringList
fs.Var(&labels, "label", "filter by label; repeat for AND")
// Both spellings, because both are what somebody has in hand: `-q` is
// what a person types and `--query` is what a script reads back.
var query string
fs.StringVar(&query, "q", "", "search text in title and body")
fs.StringVar(&query, "query", "", "the long spelling of -q")
state := fs.String("state", "open", "filter mode only: open, closed or all")
limit := fs.Int("limit", 100, "filter mode: how many issues to STORE, not to enumerate")
noDeps := fs.Bool("no-deps", false, "do not fill depends: and do not follow blockers")
depth := fs.Int("depth", 3, "how deep to follow blockers")
cached := fs.Bool("cached", false, "skip issues already on disk instead of refetching")
out := storeFlag(fs)
return func(args []string) error {
filtered := *milestone != "" || len(labels) > 0 || query != ""
switch {
case len(args) > 0 && filtered:
return Fail("pass issue keys OR filters, not both")
case len(args) == 0 && !filtered:
return Fail("nothing to pull: pass an issue key, or --milestone / --label / -q")
case !contains([]string{"open", "closed", "all"}, *state):
return Fail("--state %q must be open, closed or all", *state)
case *limit < 1:
return Fail("--limit must be 1 or more, got %d", *limit)
case *depth < 0:
return Fail("--depth must be 0 or more, got %d", *depth)
}
// Keys are parsed before anything is opened: a typo in a key is
// not a network problem and must not be reported as one.
keys, named, err := pullKeys(args)
if err != nil {
return err
}
root, client, err := syncStart(*out)
if err != nil {
return err
}
// A key may name its own repository; the project's is the
// fallback, never an override.
if !named.Zero() {
client = client.For(named)
}
repo := client.Repo()
// A first pull into a fresh checkout has to create the store, and
// it says so — with an absolute path, so it cannot be a stray
// working directory.
created, err := issue.CreateStore(root)
if err != nil {
return err
}
if created {
abs, _ := filepath.Abs(root)
fmt.Fprintf(os.Stderr, "created store %s\n", abs)
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
ledger := loadLedgerOrFold(root, issues)
namer := &pullNamer{root: root, repo: repo, ledger: ledger, taken: map[string]bool{}}
for id := range issues {
namer.taken[id] = true
}
// What the ledger already knows, so a `#N` in a body resolves to
// a slug this run never fetched.
numberOf := map[int]string{}
for raw, slug := range ledger {
if k, err := wire.ParseKey(raw); err == nil && k.Repo == repo {
numberOf[k.Number] = slug
}
}
// A closed issue is not a unit of work: filter mode enumerates it
// and keeps it out of the store unless the operator named the
// state. A key is an address, not a bulk read, so key mode is
// exempt.
dropClosed := filtered && *state != "closed"
queue, err := pullSeed(client, keys, filtered, gitea.IssueFilter{
State: *state, Labels: labels, Query: query, Milestone: *milestone,
Limit: *limit,
Keep: func(p *wire.Issue) bool {
return pullLandsInStore(p, dropClosed, namer)
},
})
if err != nil {
return err
}
var written, skipped []string
var dropped []int
type unresolved struct {
id string
numbers []int
}
var pending []unresolved
seen := map[int]bool{}
for _, t := range queue {
seen[t.payload.Number] = true
}
for len(queue) > 0 {
task := queue[0]
queue = queue[1:]
p := task.payload
id, err := namer.idFor(p)
if err != nil {
return err
}
stored := pullStored(root, id)
// Closed and not already ours: nothing is written and nothing
// is asked of the server for it — not its comments, not its
// links, and its blockers are not followed. The slug stays
// unclaimed too, so no other issue ends up pointing
// `depends:` at a file that is not there.
if dropClosed && p.State == "closed" && !stored {
dropped = append(dropped, p.Number)
continue
}
namer.taken[id] = true
numberOf[p.Number] = id
// The native links, fetched ONCE for the two things they are
// for: filling this issue's `depends:` and telling the walk
// where to go next. One request per issue that lands in the
// store, and only one.
var blockers []int
if !*noDeps {
if blockers, err = pullBlockers(client, p.Number, repo); err != nil {
return err
}
}
if *cached && stored {
skipped = append(skipped, id) // body and thread unread; only the links cost
} else {
// The copy already on disk, as it was when this run
// started. It contributes its ticked checkboxes and
// nothing else.
local := ""
if prev := issues[id]; prev != nil {
local = prev.Body
}
next, missing := mapping.FromPayload(p, id, repo, mapping.PayloadOptions{
IDForNumber: numberOf,
ExtraNumbers: blockers,
// The clock is the caller's: mapping is a pure layer
// and a package with a clock in it is not one.
Synced: time.Now().UTC().Format(time.RFC3339),
LocalBody: local,
})
if _, err := issue.Save(root, next); err != nil {
return err
}
if _, err := pullSyncComments(client, root, id, p.Number, p.Comments); err != nil {
return err
}
ledger.Set(wire.Key{Repo: repo, Number: p.Number}, id)
written = append(written, id)
pending = append(pending, unresolved{id, missing})
}
if *noDeps || task.depth >= *depth {
continue
}
for _, n := range append(mapping.NumbersInBody(p.Body), blockers...) {
if seen[n] {
continue
}
seen[n] = true
child, err := client.GetIssue(n)
if err != nil {
return err
}
queue = append(queue, pullTask{payload: child, depth: task.depth + 1})
}
}
// Nothing is dropped in silence.
if len(dropped) > 0 {
fmt.Fprintf(os.Stderr, "%d closed issue(s) enumerated, not stored"+
" (--state closed to pull them)\n", len(dropped))
}
// Second pass: a `#N` that named an issue this run had not written
// yet. The first write could not resolve it to a slug; by now the
// file it names is on disk.
for _, u := range pending {
var newly []string
for _, n := range u.numbers {
if slug := numberOf[n]; slug != "" && slug != u.id {
newly = append(newly, slug)
}
}
if len(newly) == 0 {
continue
}
i, err := issue.Load(root, u.id)
if err != nil {
return err
}
for _, slug := range newly {
if !contains(i.Depends, slug) {
i.Depends = append(i.Depends, slug)
}
}
if _, err := issue.Save(root, i); err != nil {
return err
}
}
if err := ledger.Save(root); err != nil {
return err
}
indexPath, _, err := issue.BuildIndex(root)
if err != nil {
return err
}
return pullReceipt(root, written, skipped, indexPath)
}
},
})
}
// pullTask is one issue to walk, and how far from a seed it was found.
type pullTask struct {
payload *wire.Issue
depth int
}
// pullKeys parses the positional arguments and the one repository they may name.
//
// All of them or none: a run addresses one repository, because the client, the
// ledger keys and the `gitea:` field all have to agree about which one.
func pullKeys(args []string) ([]wire.Key, wire.Repo, error) {
var keys []wire.Key
var named wire.Repo
for _, a := range args {
k, err := wire.ParseKey(a)
if err != nil {
return nil, wire.Repo{}, err
}
if !k.Repo.Zero() {
if !named.Zero() && named != k.Repo {
return nil, wire.Repo{}, Fail("all keys must name one repository, got %s and %s", named, k.Repo)
}
named = k.Repo
}
keys = append(keys, k)
}
return keys, named, nil
}
// pullSeed is what the walk starts from: the issues a key addresses, or the ones
// a filter selected.
func pullSeed(c *gitea.Client, keys []wire.Key, filtered bool, f gitea.IssueFilter) ([]pullTask, error) {
if !filtered {
out := make([]pullTask, 0, len(keys))
for _, k := range keys {
p, err := c.GetIssue(k.Number)
if err != nil {
return nil, err
}
out = append(out, pullTask{payload: p})
}
return out, nil
}
listing, err := c.ListIssues(f)
if err != nil {
return nil, err
}
if len(listing.Issues) == 0 {
return nil, Fail("no issues match that filter")
}
if listing.Warning != "" {
fmt.Fprintf(os.Stderr, "warning: %s\n", listing.Warning)
}
var what []string
if listing.Milestone != "" {
what = append(what, "milestone "+listing.Milestone)
}
for _, l := range f.Labels {
what = append(what, "label "+l)
}
if f.Query != "" {
what = append(what, fmt.Sprintf("q=%q", f.Query))
}
fmt.Fprintf(os.Stderr, "%d issue(s) match %s (%s)\n",
len(listing.Issues), strings.Join(what, " + "), f.State)
out := make([]pullTask, 0, len(listing.Issues))
for i := range listing.Issues {
out = append(out, pullTask{payload: &listing.Issues[i]})
}
return out, nil
}
// pullLandsInStore is the --limit predicate: would this payload leave a file in
// the store?
//
// It has to be the same test the walk applies, or the budget is spent on issues
// that never land — which is the bug it exists to prevent. A closed issue counts
// only when the store already has it (it is refreshed, and that is a write);
// anything else counts, including one --cached will skip, because a skipped
// issue is still an issue the store holds when the run ends.
func pullLandsInStore(p *wire.Issue, dropClosed bool, namer *pullNamer) bool {
if !dropClosed || p.State != "closed" {
return true
}
id, err := namer.idFor(p)
if err != nil {
// An id that cannot be allocated is the walk's failure to report, not a
// reason to spend the page budget differently.
return true
}
return pullStored(namer.root, id)
}
// pullBlockers is the numbers of the issues that block this one, in this
// repository.
//
// A blocker in ANOTHER repository is dropped here, and deliberately: everything
// downstream — `depends:`, the number -> slug ledger, the walk's own GETs — reads
// a bare number against the repository being pulled, so a foreign number would
// either resolve to the wrong issue or invent an edge. The body still names it,
// so nothing is lost.
func pullBlockers(c *gitea.Client, number int, repo wire.Repo) ([]int, error) {
deps, err := c.Dependencies(number)
if err != nil {
return nil, err
}
var out []int
for i := range deps {
if k := deps[i].KeyIn(repo); k.Repo == repo {
out = append(out, k.Number)
}
}
return out, nil
}
// pullSyncComments brings <id>.comments.md in line with the tracker and returns
// its path, or "" when the issue has no thread.
//
// count is the payload's own comment count, so an issue with none costs no
// request. A file from an earlier pull is removed when the thread is empty: the
// absence of the file is the answer, not a gap in what was asked for.
func pullSyncComments(c *gitea.Client, root, id string, number, count int) (string, error) {
path := commentsSidecarPath(root, id)
var thread []wire.Comment
if count > 0 {
var err error
if thread, err = c.ListComments(number); err != nil {
return "", err
}
}
if len(thread) > 0 {
if err := os.WriteFile(path, []byte(mapping.RenderComments(thread)), 0o644); err != nil {
return "", err
}
return path, nil
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return "", err
}
return "", nil
}
// pullReceipt is the only thing that lands in a reader's context: one line per
// issue, the raw payload nowhere.
func pullReceipt(root string, written, skipped []string, indexPath string) error {
cached := map[string]bool{}
all := map[string]bool{}
for _, id := range written {
all[id] = true
}
for _, id := range skipped {
cached[id], all[id] = true, true
}
ids := make([]string, 0, len(all))
for id := range all {
ids = append(ids, id)
}
sort.Strings(ids)
graph := false
for _, id := range ids {
i, err := issue.Load(root, id)
if err != nil {
return err
}
graph = graph || len(i.Depends) > 0
note := ""
if cached[id] {
note = " (cached)"
}
if path := commentsSidecarPath(root, id); pullIsFile(path) {
n := i.Extra[mapping.CommentsKey]
if n == "" {
n = "?"
}
note += fmt.Sprintf(" +%s comments: %s", n, path)
}
labels := strings.Join(i.Labels, ", ")
if labels == "" {
labels = "no labels"
}
fmt.Printf("%s [%s] %s — %s %s%s\n",
id, labels, i.Title, i.State, issue.PathOf(root, id), note)
}
fmt.Printf("index: %s\n", indexPath)
// Worth printing when there is something to draw, not on every run that
// could have drawn something.
if graph {
fmt.Println("graph: run `kettle tree` (offline) to draw it")
}
return nil
}
// --------------------------------------------------------------------------
// naming, and the files the sync layer parks beside an issue
// --------------------------------------------------------------------------
// pullNamer answers "what is this remote issue called here", and remembers what
// it has already handed out so one run cannot name two issues the same thing.
type pullNamer struct {
root string
repo wire.Repo
ledger gitea.RemoteMap
// taken is every slug the store holds plus every one this run has claimed.
taken map[string]bool
}
// idFor is the slug this remote issue belongs under. Three sources, in order —
// see the command's own documentation for why that order and not another.
func (n *pullNamer) idFor(p *wire.Issue) (string, error) {
if got := n.ledger.Slug(wire.Key{Repo: n.repo, Number: p.Number}); got != "" {
return got, nil
}
marked := mapping.IDInBody(p.Body)
// A marker is an identity only while the name is free. A file of that name
// already in the store, or a ledger entry holding it under another number,
// makes it a collision — and overwriting somebody else's issue is worse than
// allocating a suffix.
if marked != "" && !n.taken[marked] && !n.ledgerHolds(marked) {
return marked, nil
}
base := marked
if base == "" {
base = issue.Slugify(p.Title, 0)
}
taken := make([]string, 0, len(n.taken))
for id := range n.taken {
taken = append(taken, id)
}
return issue.UniqueID(n.root, base, taken)
}
func (n *pullNamer) ledgerHolds(slug string) bool {
for _, s := range n.ledger {
if s == slug {
return true
}
}
return false
}
// loadLedgerOrFold is the number -> slug ledger, with the `gitea:` fields still
// on disk folded in when there is no ledger to read.
//
// A MERGE and never a replacement, which is why the fold only happens when the
// file is missing or empty: push deletes the file it has just sent, so the store
// is a SUBSET of what the ledger knows and a rebuild from the files alone would
// throw away every entry it cannot see. What a fold cannot recover — a
// pushed-and-dropped issue whose entry was also lost — is not lost either: the
// next pull of that number reads the slug off the marker in the body and writes
// the entry back.
func loadLedgerOrFold(root string, issues map[string]*issue.Issue) gitea.RemoteMap {
m := gitea.LoadRemoteMap(root)
if len(m) > 0 {
return m
}
for id, i := range issues {
if k, ok := mapping.RemoteKeyOf(i); ok {
m.Set(k, id)
}
}
return m
}
// pullStored reports whether the store already holds this issue.
func pullStored(root, id string) bool { return pullIsFile(issue.PathOf(root, id)) }
func pullIsFile(path string) bool {
fi, err := os.Stat(path)
return err == nil && !fi.IsDir()
}
+652
View File
@@ -0,0 +1,652 @@
package cmd
import (
"flag"
"fmt"
"os"
"os/exec"
"sort"
"strings"
"time"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func init() {
register(&Command{
Name: "push",
Group: GroupSync,
Args: "[<id>…]",
Short: "send local issues to the tracker; the local copy goes with them",
Long: `A SUCCESSFUL PUSH DELETES THE LOCAL FILE — <id>.md and every sidecar under that
slug — and prints the number and the URL the issue now lives at. Once the tracker
has the issue, the tracker IS the issue: what is left in the store is what has
not left this machine. Get it back with ` + "`kettle pull <n>`" + `, which returns it under
the same slug, because the slug travelled up in the body as <!-- kettle:id … -->
and was recorded in the number -> slug ledger.
ONE RULE, NO EXCEPTION: --update deletes as well. A PATCH is a push, and an issue
that has just been sent is no more local than one that was just created. Two
rules would put back exactly the question this removes — "is my copy the fresh
one?".
THE DELETION IS THE LAST THING THAT HAPPENS TO AN ISSUE, and only after all
three of:
1. the call came back without an error and with a 2xx,
2. the answer carries a plausible number — on --update the very number that
was PATCHed, and
3. the ledger has been written with number -> slug.
Network down, non-2xx, an answer that does not confirm the write: the file stays
and the run stops. Nothing removes a file it has not just watched the tracker
accept, and nothing removes a file for an issue it did not send — ` + "`origin: local`" + `
work that has never been pushed is never touched by any of this. Get the ordering
wrong and a slug is lost at exactly the moment the local copy stops being the
record, which is why the ledger is written before anything is deleted and not
after.
Every issue is validated against the canonical format first, offline and before
a socket is opened. --force posts anyway; say why when you use it.
DEPENDENCIES GO FIRST, in topological order, so a blocker has its number before
the issue that names it. Every ` + "`depends:`" + ` entry that has a number becomes a NATIVE
tracker link — the same /dependencies a pull reads back, so the tracker shows the
blocking panel and refuses to close a blocked issue first. A link that is already
there is skipped, not re-POSTed, which is what makes a repeat push a no-op. A
dependency that is still local-only has no number and becomes no link: it is
reported, never silently dropped.
REMOVING a link is out of scope — push only ever adds. A dependency deleted from
` + "`depends:`" + ` leaves its tracker link standing; unlink it in the web UI.
The ` + "`## Depends on`" + ` prose is never touched: slugs stay slugs and are not rewritten
to #N, so a pull -> push round trip is byte for byte.
Labels the repository is missing are created with the canonical colour and, for
type/* and severity/*, exclusive: true. ` + "`branch:`" + ` carries the tracker's ` + "`ref`" + `: an
empty one is filled with the current git branch and an already-set one is sent as
written. Detached HEAD or no repository at all is not an error — no ref is sent
and a warning says so.`,
Examples: []Example{
{"kettle push", "every issue the tracker does not have yet, blockers first"},
{"kettle push wire-sqlc-appclick", "one issue"},
{"kettle push --update wire-sqlc-appclick", "PATCH one that is already there — the file still goes"},
{"kettle push --dry-run", "validate and print the plan; no network, nothing deleted"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
update := fs.Bool("update", false, "PATCH issues that already carry a gitea: field")
dryRun := fs.Bool("dry-run", false, "validate and print the plan; no network, nothing deleted")
force := fs.Bool("force", false, "push despite format violations")
out := storeFlag(fs)
return func(args []string) error {
// A dry run touches no network, so it must not need a credential
// to say what it would do. The real run goes through
// syncStartExisting below, which resolves the store before it
// builds a client — a missing store reported as a network
// problem sends the operator to the wrong place.
root, err := storeRoot(*out)
if err != nil {
return err
}
if err := issue.StoreError(root); err != nil {
return Fail("%s — create an issue with `kettle new` first", err)
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
chosen, err := pushSelect(issues, args, *update)
if err != nil {
return err
}
// The domain's own check, offline, before anything is sent.
known := map[string]bool{}
for id := range issues {
known[id] = true
}
blocked := false
for _, id := range chosen {
errs, warns := issue.Validate(issues[id], known)
for _, w := range warns {
fmt.Fprintf(os.Stderr, "warning: %s: %s\n", id, w)
}
for _, e := range errs {
fmt.Fprintf(os.Stderr, "%s: %s\n", id, e)
}
blocked = blocked || len(errs) > 0
}
if blocked && !*force {
return Fail("format violations (see above); --force overrides")
}
// Dependencies first, so a blocker has its number by the time the
// issue that names it is sent. A cycle is reported and ordered
// around rather than refused: it is a data problem, not a reason
// to send nothing.
edges := map[string][]string{}
for _, id := range chosen {
var deps []string
for _, d := range issues[id].Depends {
if _, ok := issues[d]; ok {
deps = append(deps, d)
}
}
edges[id] = deps
}
pushing := map[string]bool{}
for _, id := range chosen {
pushing[id] = true
}
var order []string
for _, id := range issue.TopoOrder(chosen, edges) {
if pushing[id] {
order = append(order, id)
}
}
for _, c := range issue.FindCycles(edges) {
fmt.Fprintf(os.Stderr, "warning: dependency cycle: %s\n", strings.Join(c, " -> "))
}
// Only an EMPTY branch: one written by hand is the author's
// decision and a push does not argue with it. The value is set on
// the in-memory issue only — the file it came from is about to be
// deleted, and the branch comes back with the next pull.
var blank []string
for _, id := range order {
if strings.TrimSpace(issues[id].Extra[mapping.BranchKey]) == "" {
blank = append(blank, id)
}
}
if len(blank) > 0 {
if branch := pushGitBranch(); branch != "" {
for _, id := range blank {
if issues[id].Extra == nil {
issues[id].Extra = map[string]string{}
}
issues[id].Extra[mapping.BranchKey] = branch
}
} else {
fmt.Fprintf(os.Stderr, "warning: no current git branch (detached HEAD, or "+
"outside a git repository) — no `ref` on: %s\n", strings.Join(blank, ", "))
}
}
if *dryRun {
// Not one request is made here: everything below is read off
// the store and the ledger, which costs nothing.
pushPlan(root, issues, order, pushing, *update)
return nil
}
_, client, err := syncStartExisting(*out)
if err != nil {
return err
}
repo := client.Repo()
var wanted []string
for _, id := range order {
for _, l := range issues[id].Labels {
if !contains(wanted, l) {
wanted = append(wanted, l)
}
}
}
sort.Strings(wanted)
labelIDs, err := pushLabelIDs(client, wanted)
if err != nil {
return err
}
milestones := map[string]*int64{}
ledger := loadLedgerOrFold(root, issues)
keyOf := pushLedgerKeys(ledger, repo)
for _, id := range order {
i := issues[id]
// Local-only means "this machine has never sent it": no
// `gitea:` on the file AND no entry in the ledger. A blocker
// whose file an earlier push already dropped is in the ledger
// and is not one of these.
var unsynced []string
for _, d := range i.Depends {
dep, onDisk := issues[d]
if !onDisk || pushing[d] {
continue
}
if _, synced := mapping.RemoteKeyOf(dep); synced {
continue
}
if _, inLedger := keyOf[d]; !inLedger {
unsynced = append(unsynced, d)
}
}
if len(unsynced) > 0 {
fmt.Fprintf(os.Stderr, "warning: %s: depends on local-only issue(s) %s"+
" — no cross-link in the tracker\n", id, strings.Join(unsynced, ", "))
}
msID, ok := milestones[i.Milestone]
if i.Milestone != "" && !ok {
m, err := client.FindMilestone(i.Milestone)
if err != nil {
return err
}
if m != nil {
msID = wire.Set(m.ID)
}
milestones[i.Milestone] = msID
}
if i.Milestone != "" && msID == nil {
fmt.Fprintf(os.Stderr, "warning: %s: milestone %q does not exist in %s"+
" — not set\n", id, i.Milestone, repo)
}
opt := mapping.RequestOptions{LabelIDs: labelIDs, MilestoneID: msID}
sent, synced := mapping.NumberOf(i)
var got *wire.Issue
verb := "created"
if synced {
// An edit says what state it means; a create takes the
// tracker's default.
opt.IncludeState = true
verb = "updated"
got, err = client.EditIssue(sent, *mapping.ToRequest(i, opt), "issue-"+id)
} else {
sent = 0
got, err = client.CreateIssue(*mapping.ToRequest(i, opt), "issue-"+id)
}
// THE GATE. Below this line a local file is going to be
// deleted, so anything short of a confirmed write stops the
// run right here.
if err != nil {
return Fail("%s: not %s: %v — %s is untouched", id, verb, err, issue.PathOf(root, id))
}
number, confirmed := pushConfirmedNumber(got, sent)
if !confirmed {
return Fail("%s: the tracker's answer does not confirm the write "+
"(it carries number %d) — %s is untouched, nothing was deleted",
id, got.Number, issue.PathOf(root, id))
}
// The number is confirmed, so the ledger learns it NOW —
// before the label fix-up and the links, both of which can
// still fail, and well before the file is removed. This entry
// is what a later `kettle pull <n>` lands on; an interrupted
// run must cost a re-pull, never a slug.
key := wire.Key{Repo: repo, Number: number}
ledger.Set(key, id)
keyOf[id] = key
if err := ledger.Save(root); err != nil {
return Fail("%s: the tracker has it as #%d but the ledger could not be "+
"written (%v) — %s is untouched", id, number, err, issue.PathOf(root, id))
}
// Gitea occasionally drops labels handed to it on create, so
// the echo is checked and the set re-applied rather than
// trusted. A failure here is a warning and not an abort: the
// issue IS in the tracker, and a run that stopped now would
// leave a file whose `gitea:` field was never written — which
// the next push would file all over again as a new issue.
applied := map[string]bool{}
for _, l := range got.Labels {
applied[l.Name] = true
}
var ids []int64
var missing []string
for _, name := range i.Labels {
lid, in := labelIDs[name]
if !in {
continue
}
ids = append(ids, lid)
if !applied[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
if _, err := client.SetLabels(number, ids, "labels-"+id); err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: could not re-apply labels (%s): %v\n",
id, strings.Join(missing, ", "), err)
} else {
fmt.Fprintf(os.Stderr, "warning: %s: labels re-applied via PUT (%s)\n",
id, strings.Join(missing, ", "))
}
}
// The in-memory issue is stamped even though its file is
// going: the rest of this loop reads `gitea:` off it to link
// dependencies, and a later issue in topological order asks
// the same of this one.
mapping.ApplyRemote(i, got, repo, time.Now().UTC().Format(time.RFC3339))
// The number and the URL lead, because in a moment the local
// path is gone and this is the only address the issue has.
fmt.Printf("%s %s #%d %s\n", verb, id, number, got.HTMLURL)
if err := pushLinks(client, id, number, i, issues, pushing, keyOf, repo); err != nil {
return err
}
// And now the local copy goes: the last thing that happens to
// this issue, after the write, the ledger and the links. A
// warning above lands here anyway — the issue is in the
// tracker, and keeping a stale file beside it would put back
// exactly the two-copies question this removes.
gone, err := issue.Remove(root, id)
for _, p := range gone {
fmt.Printf(" dropped %s\n", p)
}
if err != nil {
return Fail("%s: the tracker has it as #%d, but the local copy could not "+
"be removed: %v", id, number, err)
}
fmt.Printf(" kettle pull %d to work on it again\n", number)
}
if err := ledger.Save(root); err != nil {
return err
}
path, n, err := issue.BuildIndex(root)
if err != nil {
return err
}
fmt.Printf("index: %s — %d issue(s)\n", path, n)
return nil
}
},
})
}
// pushSelect is which issues to send, and the refusal of the ambiguous
// combinations.
//
// Named ids are taken as typed. With none, the default is everything this
// machine has never sent — pushing the whole store on a bare `kettle push` would
// re-PATCH every working copy in it.
func pushSelect(issues map[string]*issue.Issue, ids []string, update bool) ([]string, error) {
var chosen []string
if len(ids) > 0 {
var missing []string
for _, id := range ids {
if _, ok := issues[id]; !ok {
missing = append(missing, id)
}
}
if len(missing) > 0 {
return nil, Fail("no such issue(s) in the store: %s", strings.Join(missing, ", "))
}
chosen = append(chosen, ids...)
} else {
for id, i := range issues {
if _, synced := mapping.RemoteKeyOf(i); update || !synced {
chosen = append(chosen, id)
}
}
sort.Strings(chosen)
if len(chosen) == 0 {
return nil, Fail("nothing to push: every issue in the store is already in the " +
"tracker — pass --update to PATCH them, or `kettle new` to make one")
}
}
if !update {
var already []string
for _, id := range chosen {
if _, synced := mapping.RemoteKeyOf(issues[id]); synced {
already = append(already, id)
}
}
if len(already) > 0 {
return nil, Fail("already in the tracker: %s — pass --update to PATCH them",
strings.Join(already, ", "))
}
}
return chosen, nil
}
// pushLabelIDs is name -> id for the labels these issues carry, creating what
// the repository is missing.
//
// Decided against the repository as it is right now, in one request, and never
// against a cache: a cache answers "what did we create last time", and the
// question here is "what does this repository have". A label the tracker does
// not have and this cannot create is the one failure worth stopping for — an
// issue filed without its `type/*` label is an issue nothing can find again.
func pushLabelIDs(c *gitea.Client, names []string) (map[string]int64, error) {
out := map[string]int64{}
if len(names) == 0 {
return out, nil
}
have, err := c.ListLabels()
if err != nil {
return nil, err
}
known := make(map[string]int64, len(have))
for _, l := range have {
known[l.Name] = l.ID
}
// The spec — colour, description, exclusivity — is the bridge's, read off the
// domain's taxonomy. This layer only decides which names are wanted.
for _, spec := range mapping.LabelSpecs(names) {
if id, ok := known[spec.Name]; ok {
out[spec.Name] = id
continue
}
created, err := c.CreateLabel(spec)
if err != nil {
return nil, err
}
out[spec.Name] = created.ID
note := ""
if spec.Exclusive {
note = " (exclusive)"
}
fmt.Fprintf(os.Stderr, "created label %s%s\n", spec.Name, note)
}
return out, nil
}
// pushDep is what one `depends:` entry is, as far as linking is concerned.
type pushDep struct {
// Slug is the dependency as `depends:` spells it.
Slug string
// Key is where it lives in the tracker; HasKey is false while it is
// local-only.
Key wire.Key
HasKey bool
// InRun says this push is about to give it a number.
InRun bool
}
// pushDepState is every dependency this run can say anything about.
//
// A dependency's key is read from its `gitea:` field while the file is still on
// disk, and from the ledger when it is not — which, since push deletes what it
// sends, is the normal state of an already-published blocker. Without that
// fallback the graph would quietly lose an edge every time a blocker was pushed
// before its dependent: the file is gone, the field goes with it, and the link is
// never made.
//
// A slug that is in neither the store nor the ledger names nothing this machine
// has ever seen, and is dropped — validation has already warned about it.
func pushDepState(i *issue.Issue, issues map[string]*issue.Issue, pushing map[string]bool,
keyOf map[string]wire.Key) []pushDep {
var out []pushDep
for _, d := range i.Depends {
dep, onDisk := issues[d]
var key wire.Key
found := false
if onDisk {
key, found = mapping.RemoteKeyOf(dep)
}
if !found {
key, found = keyOf[d]
}
if !onDisk && !found {
continue
}
out = append(out, pushDep{Slug: d, Key: key, HasKey: found, InRun: pushing[d]})
}
return out
}
// pushLinks turns `depends:` into the tracker's own dependency links.
//
// Topological order means every blocker that is going to have a number has one
// already. The GET is the idempotence check — one request per issue that has
// dependencies at all, and what makes a repeat push a no-op. A failure is a
// warning, never an abort: one missing cross-link must not undo a push that has
// already created issues.
func pushLinks(c *gitea.Client, id string, number int, i *issue.Issue,
issues map[string]*issue.Issue, pushing map[string]bool,
keyOf map[string]wire.Key, repo wire.Repo) error {
var wanted []pushDep
for _, d := range pushDepState(i, issues, pushing, keyOf) {
if d.HasKey && d.Key.Number > 0 {
wanted = append(wanted, d)
}
}
if len(wanted) == 0 {
return nil
}
have, err := c.DependencyKeys(number)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: could not read the links #%d already has (%v)"+
" — no link was made\n", id, number, err)
return nil
}
for _, d := range wanted {
key := d.Key.In(repo)
if containsKey(have, key) {
continue
}
if err := c.AddDependency(number, key); err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: could not link #%d -> %s (%s): %v — link it by "+
"hand, or `kettle pull %d` and push it again\n", id, number, key, d.Slug, err, number)
continue
}
fmt.Printf(" depends on %s (%s)\n", key, d.Slug)
}
return nil
}
// pushPlan is the --dry-run receipt: what would be sent, and which links would
// exist. `#?` is a number this run has not handed out yet.
func pushPlan(root string, issues map[string]*issue.Issue, order []string,
pushing map[string]bool, update bool) {
// The ledger costs no request, so a dry run resolves an already-pushed
// blocker exactly the way the real run does.
keyOf := pushLedgerKeys(loadLedgerOrFold(root, issues), wire.Repo{})
links := 0
for _, id := range order {
i := issues[id]
typ := i.Type()
if typ == "" {
typ = "?"
}
labels := strings.Join(i.Labels, ", ")
if labels == "" {
labels = "no labels"
}
fmt.Printf("ok %s [type/%s] %s (%s)\n", id, typ, i.Title, labels)
for _, d := range pushDepState(i, issues, pushing, keyOf) {
switch {
case d.HasKey:
fmt.Printf(" link -> %s (%s)\n", d.Key, d.Slug)
links++
case d.InRun:
fmt.Printf(" link -> #? (%s, created by this run)\n", d.Slug)
links++
default:
fmt.Printf(" no link: %s is local-only\n", d.Slug)
}
}
}
verb := "created"
if update {
verb = "updated"
}
fmt.Printf("%d issue(s) would be %s, %d dependency link(s) would be created\n",
len(order), verb, links)
}
// pushLedgerKeys is slug -> key, the reverse of the ledger.
//
// Where a dependency's number comes from once push has deleted its file. The
// ledger is keyed by number because that is what a pull has in hand; a push has a
// slug, so it needs the other direction. An entry in the repository being pushed
// to wins when a slug somehow appears under two keys.
func pushLedgerKeys(m gitea.RemoteMap, repo wire.Repo) map[string]wire.Key {
raw := make([]string, 0, len(m))
for k := range m {
raw = append(raw, k)
}
sort.Strings(raw)
out := map[string]wire.Key{}
for _, r := range raw {
key, err := wire.ParseKey(r)
if err != nil {
continue
}
slug := m[r]
if _, seen := out[slug]; !seen || key.Repo == repo {
out[slug] = key
}
}
return out
}
// pushConfirmedNumber is the number the tracker confirmed for a write, or ok
// false — the deletion gate.
//
// Every local file this command removes is removed because this returned ok, so
// it is written to be boring and to say no by default: a positive number, and on
// a PATCH the very number that was addressed. What it does not have to catch,
// because none of it gets this far: a non-2xx answer, a body that is not the JSON
// expected, or a `number` that is not a number — the transport fails all three
// before returning, and the file survives by never reaching the delete.
func pushConfirmedNumber(got *wire.Issue, sent int) (int, bool) {
if got == nil || got.Number <= 0 {
return 0, false
}
if sent != 0 && got.Number != sent {
return 0, false
}
return got.Number, true
}
// pushGitBranch is the branch HEAD is on, or "".
//
// The one git call this binary makes — read, never write. A detached HEAD prints
// `HEAD` and outside a repository git exits non-zero; both mean "no branch to
// name", which is not an error.
func pushGitBranch() string {
out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
return ""
}
name := strings.TrimSpace(string(out))
if name == "HEAD" {
return ""
}
return name
}
func containsKey(keys []wire.Key, want wire.Key) bool {
for _, k := range keys {
if k == want {
return true
}
}
return false
}
+114
View File
@@ -0,0 +1,114 @@
package cmd
import (
"flag"
"fmt"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// labelColumn is how much of the label list a row shows before it is cut.
const labelColumn = 38
func init() {
register(&Command{
Name: "remote",
Group: GroupSync,
Short: "list what exists in the tracker, one line each",
Long: `Discovery only: this prints and WRITES NOTHING. The local store is a store, not a
search-results folder, and a listing that landed in it would leave files nobody
asked for beside the issues somebody did. Pick the numbers here, then pull them.
#42 open type/task, tech/sql Wire sqlc into the repo layer
└─ local: wire-sqlc-appclick
The second line appears when the number is already in the local ledger, so it is
obvious what a pull would refresh and what it would add.
--limit here caps the LISTING: N lines out, closed ones among them. That is not
what the same flag means to ` + "`kettle pull`" + `, and the difference is not an oversight —
pull bounds what it WRITES, this command writes nothing, and enumeration is the
whole job.
Projects are not filterable: the projects API is not exposed by Gitea. Use
milestones or labels, or the web UI.`,
Examples: []Example{
{"kettle remote", "the open issues, 30 of them"},
{"kettle remote --state all --label type/bug --limit 50", "every bug, open and closed"},
{"kettle remote --milestone v0.2", "what is in a milestone"},
{"kettle remote -q sqlc", "keyword search over title and body"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
state := fs.String("state", "open", "open, closed or all")
var labels stringList
fs.Var(&labels, "label", "filter by label; repeat for AND")
// Both spellings, the way the Python this replaces took them.
var query string
fs.StringVar(&query, "q", "", "search text in title and body")
fs.StringVar(&query, "query", "", "the long spelling of -q")
milestone := fs.String("milestone", "", "milestone id or title")
limit := fs.Int("limit", 30, "how many lines to print")
out := storeFlag(fs)
return func(args []string) error {
if len(args) > 0 {
return Fail("remote takes no arguments — filter with --label, --milestone or -q")
}
if !contains([]string{"open", "closed", "all"}, *state) {
return Fail("--state %q must be open, closed or all", *state)
}
if *limit < 1 {
return Fail("--limit must be 1 or more, got %d", *limit)
}
root, client, err := syncStart(*out)
if err != nil {
return err
}
listing, err := client.ListIssues(gitea.IssueFilter{
State: *state, Labels: labels, Query: query,
Milestone: *milestone, Limit: *limit,
})
if err != nil {
return err
}
// The ledger, not the files: a pushed issue has no file left and
// is still something a pull would land on a known slug.
ledger := gitea.LoadRemoteMap(root)
repo := client.Repo()
for i := range listing.Issues {
p := &listing.Issues[i]
labels := "-"
if names := p.LabelNames(); len(names) > 0 {
labels = strings.Join(names, ", ")
}
// One line per issue is the whole point; a repository that
// namespaces heavily would wrap the column otherwise.
if len(labels) > labelColumn {
labels = labels[:labelColumn]
}
fmt.Printf("#%-5d %-7s %-38s %s\n", p.Number, p.State, labels, p.Title)
if local := ledger.Slug(wire.Key{Repo: repo, Number: p.Number}); local != "" {
fmt.Printf("%13s└─ local: %s\n", "", local)
}
}
scope := ""
if listing.Milestone != "" {
scope = " in milestone " + listing.Milestone
}
hint := "<n>"
if *milestone != "" {
hint = "--milestone " + *milestone
}
fmt.Printf("%d issue(s)%s — pull them with: kettle pull %s\n",
len(listing.Issues), scope, hint)
return nil
}
},
})
}
+70
View File
@@ -0,0 +1,70 @@
package cmd
import (
"path/filepath"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
// commentsSidecarPath is where an issue's comment thread lives — beside it,
// under the same slug.
//
// A path, not a concept the domain needs: a thread is pulled from the tracker
// and never pushed back, so `internal/issue` has no reason to learn that the
// file exists. It does not have to — the file is named after the issue, and
// issue.SlugFiles takes it away when the issue goes.
//
// It sits here rather than in either command because `pull` writes it and
// `comment` rewrites it, and two spellings of one path is how the two come to
// disagree about where a thread is.
func commentsSidecarPath(root, id string) string {
return filepath.Join(root, id+".comments.md")
}
// The sync commands all start the same way and must fail the same way.
//
// Every one of them needs a store and a client, and the order matters: a
// command that dialled first would report a network problem for a project that
// was never initialized, and an operator would go looking at the wrong thing.
// So the store is resolved before a socket is opened, and each failure names
// the command that fixes it.
// syncStart resolves the store and builds a client for it.
//
// The client is built from the project's own configuration, which is why there
// is no --login flag anywhere in this tree: which login a project runs under is
// a fact about the project, stated once by `kettle init`, not a thing a caller
// gets to differ about per invocation. That the two could disagree is what the
// Python version needed a PreToolUse hook to police.
func syncStart(out string) (string, *gitea.Client, error) {
root, err := storeRoot(out)
if err != nil {
return "", nil, err
}
cfg, err := config.Require("")
if err != nil {
return "", nil, err
}
client, err := gitea.New(cfg)
if err != nil {
return "", nil, err
}
return root, client, nil
}
// syncStartExisting is syncStart for the commands that read the store rather
// than create it: push, comment, close and the sync form of evict all operate
// on issues that are already on disk, and a missing store is a mistake to
// report, not a directory to conjure.
func syncStartExisting(out string) (string, *gitea.Client, error) {
root, client, err := syncStart(out)
if err != nil {
return "", nil, err
}
if err := issue.RequireStore(root); err != nil {
return "", nil, err
}
return root, client, nil
}
+664
View File
@@ -0,0 +1,664 @@
package cmd_test
// The transport, end to end: the real binary, run as a subprocess against a
// throwaway project, talking to an httptest server that speaks enough of the
// Gitea REST API to answer it.
//
// Enough and no more. What is worth proving here is not that JSON round-trips —
// internal/mapping has tests for that, without a server anywhere — but the two
// rules that cost work when they are wrong: a confirmed push takes the local file
// with it, and an unconfirmed one does not touch it.
//
// The repository is always owner/repo, and the credentials arrive through
// KETTLE_URL / KETTLE_TOKEN / KETTLE_REPO, which is also what a CI run does.
// KETTLE_CONFIG_HOME points at a temp directory so no fixture can read or
// overwrite the developer's own tokens.
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// --------------------------------------------------------------------------
// the fake tracker
// --------------------------------------------------------------------------
// pullFakeGitea is a Gitea instance with one repository in it, owner/repo.
type pullFakeGitea struct {
mu sync.Mutex
issues map[int]*wire.Issue
deps map[int][]int
comments map[int][]wire.Comment
labels map[string]int64
next int
// writesFail makes every issue create and edit answer 500 — the failure a
// push has to survive without losing a file.
writesFail bool
}
func pullNewGitea() *pullFakeGitea {
return &pullFakeGitea{
issues: map[int]*wire.Issue{},
deps: map[int][]int{},
comments: map[int][]wire.Comment{},
labels: map[string]int64{},
}
}
// pullAdd puts an issue in the tracker the way the web UI would: it is there
// before this project ever hears about it.
func (g *pullFakeGitea) pullAdd(p wire.Issue) {
g.mu.Lock()
defer g.mu.Unlock()
if p.State == "" {
p.State = "open"
}
p.HTMLURL = pullURL(p.Number)
g.issues[p.Number] = &p
if p.Number > g.next {
g.next = p.Number
}
}
func (g *pullFakeGitea) pullIssue(n int) wire.Issue {
g.mu.Lock()
defer g.mu.Unlock()
if p := g.issues[n]; p != nil {
return *p
}
return wire.Issue{}
}
func (g *pullFakeGitea) pullRetitle(n int, title string) {
g.mu.Lock()
defer g.mu.Unlock()
g.issues[n].Title = title
}
func (g *pullFakeGitea) pullBlocks(blocked int, blockers ...int) {
g.mu.Lock()
defer g.mu.Unlock()
g.deps[blocked] = append(g.deps[blocked], blockers...)
}
func pullURL(n int) string {
return fmt.Sprintf("https://git.example.com/owner/repo/issues/%d", n)
}
var (
pullIssueRoute = regexp.MustCompile(`^issues/(\d+)$`)
pullSubRoute = regexp.MustCompile(`^issues/(\d+)/(dependencies|comments|labels)$`)
)
func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/owner/repo/")
if !ok {
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
return
}
switch {
case path == "labels" && r.Method == http.MethodGet:
out := []wire.Label{}
for name, id := range g.labels {
out = append(out, wire.Label{ID: id, Name: name})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
pullJSON(w, out)
case path == "labels" && r.Method == http.MethodPost:
var req wire.LabelRequest
pullDecode(r, &req)
id := int64(1000 + len(g.labels))
g.labels[req.Name] = id
pullJSON(w, wire.Label{ID: id, Name: req.Name, Color: req.Color, Exclusive: req.Exclusive})
case path == "milestones" && r.Method == http.MethodGet:
pullJSON(w, []wire.Milestone{})
case path == "issues" && r.Method == http.MethodPost:
if g.writesFail {
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
return
}
var req wire.IssueRequest
pullDecode(r, &req)
g.next++
p := &wire.Issue{
Number: g.next, Title: pullStr(req.Title), Body: pullStr(req.Body),
State: "open", HTMLURL: pullURL(g.next), Labels: g.pullLabelsFor(req.Labels),
}
g.issues[p.Number] = p
pullJSON(w, p)
case path == "issues" && r.Method == http.MethodGet:
g.pullList(w, r)
case pullIssueRoute.MatchString(path):
n := pullNumber(pullIssueRoute, path)
p := g.issues[n]
if p == nil {
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
return
}
if r.Method == http.MethodPatch {
if g.writesFail {
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
return
}
var req wire.IssueRequest
pullDecode(r, &req)
if req.Title != nil {
p.Title = *req.Title
}
if req.Body != nil {
p.Body = *req.Body
}
if req.State != nil {
p.State = *req.State
}
if req.Labels != nil {
p.Labels = g.pullLabelsFor(req.Labels)
}
}
pullJSON(w, p)
case pullSubRoute.MatchString(path):
m := pullSubRoute.FindStringSubmatch(path)
n, _ := strconv.Atoi(m[1])
switch {
case m[2] == "dependencies" && r.Method == http.MethodGet:
out := []wire.Issue{}
for _, d := range g.deps[n] {
if p := g.issues[d]; p != nil {
out = append(out, *p)
}
}
pullJSON(w, out)
case m[2] == "dependencies" && r.Method == http.MethodPost:
var req struct {
Index int `json:"index"`
}
pullDecode(r, &req)
g.deps[n] = append(g.deps[n], req.Index)
w.WriteHeader(http.StatusCreated)
case m[2] == "comments" && r.Method == http.MethodGet:
out := g.comments[n]
if out == nil {
out = []wire.Comment{}
}
pullJSON(w, out)
case m[2] == "labels" && r.Method == http.MethodPut:
var req struct {
Labels []int64 `json:"labels"`
}
pullDecode(r, &req)
g.issues[n].Labels = g.pullLabelsFor(&req.Labels)
pullJSON(w, g.issues[n].Labels)
default:
http.Error(w, `{"message":"not implemented"}`, http.StatusNotFound)
}
default:
http.Error(w, `{"message":"not implemented: `+path+`"}`, http.StatusNotFound)
}
}
// pullList is the filtered listing, paginated the way the client asks for it.
func (g *pullFakeGitea) pullList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
state, page, limit := q.Get("state"), 1, 50
if v, err := strconv.Atoi(q.Get("page")); err == nil && v > 0 {
page = v
}
if v, err := strconv.Atoi(q.Get("limit")); err == nil && v > 0 {
limit = v
}
var want []string
if v := q.Get("labels"); v != "" {
want = strings.Split(v, ",")
}
numbers := make([]int, 0, len(g.issues))
for n := range g.issues {
numbers = append(numbers, n)
}
sort.Ints(numbers)
out := []wire.Issue{}
for _, n := range numbers {
p := g.issues[n]
if state != "" && state != "all" && p.State != state {
continue
}
has := map[string]bool{}
for _, l := range p.Labels {
has[l.Name] = true
}
missing := false
for _, l := range want {
missing = missing || !has[l]
}
if missing {
continue
}
out = append(out, *p)
}
start := (page - 1) * limit
if start > len(out) {
start = len(out)
}
end := start + limit
if end > len(out) {
end = len(out)
}
pullJSON(w, out[start:end])
}
func (g *pullFakeGitea) pullLabelsFor(ids *[]int64) []wire.Label {
if ids == nil {
return nil
}
byID := map[int64]string{}
for name, id := range g.labels {
byID[id] = name
}
var out []wire.Label
for _, id := range *ids {
if name, ok := byID[id]; ok {
out = append(out, wire.Label{ID: id, Name: name})
}
}
return out
}
func pullNumber(re *regexp.Regexp, path string) int {
n, _ := strconv.Atoi(re.FindStringSubmatch(path)[1])
return n
}
func pullDecode(r *http.Request, into any) {
_ = json.NewDecoder(r.Body).Decode(into)
}
func pullJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func pullStr(p *string) string {
if p == nil {
return ""
}
return *p
}
// pullEnv starts the fake and returns the environment that points the binary at
// it. The credential home is a temp directory: a test run may neither read nor
// overwrite the developer's own tokens.
func pullEnv(t *testing.T, g *pullFakeGitea) []string {
t.Helper()
srv := httptest.NewServer(g)
t.Cleanup(srv.Close)
return []string{
config.EnvURL + "=" + srv.URL,
config.EnvToken + "=t0ken",
config.EnvRepo + "=owner/repo",
config.EnvHome + "=" + t.TempDir(),
}
}
func pullStore(dir string) string { return filepath.Join(dir, ".kettle", "issues") }
func pullRead(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func pullExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// --------------------------------------------------------------------------
// push
// --------------------------------------------------------------------------
// The rule the whole design rests on: once the tracker has the issue, the
// tracker IS the issue, and the local copy goes — sidecars included.
func TestPushCreatesTheIssueAndTakesTheLocalCopyWithIt(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
env := pullEnv(t, g)
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
const id = "wire-sqlc-into-the-appclick-layer"
store := pullStore(dir)
sidecar := filepath.Join(store, id+".comments.md")
if err := os.WriteFile(sidecar, []byte("# thread\n"), 0o644); err != nil {
t.Fatal(err)
}
r := runWith(t, dir, env, "", "push")
if r.code != 0 {
t.Fatalf("push exited %d:\n%s", r.code, r.out())
}
// The number and the URL lead: in a moment they are the only address the
// issue has.
if !strings.Contains(r.stdout, "created "+id+" #1 "+pullURL(1)) {
t.Errorf("the receipt does not say where the issue lives now:\n%s", r.stdout)
}
if pullExists(filepath.Join(store, id+".md")) {
t.Error("the local file survived a confirmed push — what is in the store is what has not left")
}
if pullExists(sidecar) {
t.Error("the sidecar was left behind; every file under the slug goes")
}
// The ledger is what makes the slug come back, so it has to hold the number.
ledger := pullRead(t, filepath.Join(store, ".remote.json"))
if !strings.Contains(ledger, `"owner/repo#1": "`+id+`"`) {
t.Errorf("the ledger does not index the number:\n%s", ledger)
}
// And the slug travelled up in the body, which is what survives a lost ledger.
if body := g.pullIssue(1).Body; !strings.Contains(body, "<!-- kettle:id "+id+" -->") {
t.Errorf("the id marker did not go up with the issue:\n%s", body)
}
if !strings.HasPrefix(g.pullIssue(1).Body, "<!-- kettle:id") {
t.Error("the marker must be the first line of the tracker-side body")
}
}
// Network down, non-2xx, an answer that does not confirm the write: the file
// stays and the run stops. Nothing is deleted that was not just accepted.
func TestPushLeavesTheFileWhenTheTrackerRefuses(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
g.writesFail = true
env := pullEnv(t, g)
mustRun(t, dir, "new", "--type", "task", "--title", "Never made it up")
const id = "never-made-it-up"
path := filepath.Join(pullStore(dir), id+".md")
before := pullRead(t, path)
r := runWith(t, dir, env, "", "push")
if r.code == 0 {
t.Fatalf("a tracker that refuses the write must fail the run:\n%s", r.out())
}
if after := pullRead(t, path); after != before {
t.Errorf("the file was touched by a push that never landed:\n%s", after)
}
// The message has to name the file, because "is my only copy still there" is
// the question an operator has at that moment.
if !strings.Contains(r.stderr, path) {
t.Errorf("the failure does not name the file it did not touch:\n%s", r.stderr)
}
if pullExists(filepath.Join(pullStore(dir), ".remote.json")) {
t.Error("a ledger entry was written for an issue the tracker never confirmed")
}
}
// --------------------------------------------------------------------------
// pull
// --------------------------------------------------------------------------
// A number is an address, not a query. Only filter mode leaves closed issues out.
func TestPullByNumberFetchesAClosedIssue(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
g.pullAdd(wire.Issue{
Number: 7, Title: "Closed but addressable", State: "closed",
Body: "## Summary\nДело сделано.\n", UpdatedAt: "2026-08-01T10:00:00Z",
})
env := pullEnv(t, g)
r := runWith(t, dir, env, "", "pull", "7")
if r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
file := pullRead(t, filepath.Join(pullStore(dir), "closed-but-addressable.md"))
if !strings.Contains(file, "state: closed") {
t.Errorf("the closed state did not land on disk:\n%s", file)
}
if !strings.Contains(file, "gitea: owner/repo#7") {
t.Errorf("the cross-repo handle is missing:\n%s", file)
}
if !strings.Contains(file, "origin: gitea") {
t.Errorf("the issue does not say it exists elsewhere:\n%s", file)
}
}
// A pull answers with the unit of work — the issue and what blocks it — and
// --no-deps is how you ask for one row of it.
func TestPullBringsTheBlockerDownWithIt(t *testing.T) {
g := pullNewGitea()
g.pullAdd(wire.Issue{Number: 1, Title: "Migrate the schema", Body: "## Summary\nx\n"})
g.pullAdd(wire.Issue{Number: 2, Title: "Wire sqlc into the layer", Body: "## Summary\nx\n"})
g.pullBlocks(2, 1)
env := pullEnv(t, g)
t.Run("by default", func(t *testing.T) {
dir := newProject(t)
if r := runWith(t, dir, env, "", "pull", "2"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if !pullExists(filepath.Join(pullStore(dir), "migrate-the-schema.md")) {
t.Fatal("the blocker did not come down — a pull returns the unit of work")
}
dependent := pullRead(t, filepath.Join(pullStore(dir), "wire-sqlc-into-the-layer.md"))
if !strings.Contains(dependent, "depends: [migrate-the-schema]") {
t.Errorf("depends: was not filled from the tracker's own graph:\n%s", dependent)
}
})
t.Run("--no-deps", func(t *testing.T) {
dir := newProject(t)
if r := runWith(t, dir, env, "", "pull", "2", "--no-deps"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if pullExists(filepath.Join(pullStore(dir), "migrate-the-schema.md")) {
t.Error("--no-deps followed a blocker anyway")
}
dependent := pullRead(t, filepath.Join(pullStore(dir), "wire-sqlc-into-the-layer.md"))
if !strings.Contains(dependent, "depends: []") {
t.Errorf("--no-deps filled depends: anyway:\n%s", dependent)
}
})
}
// The round trip, and the two things that carry the slug through it: the ledger,
// and — when the ledger is gone, as it is in a fresh clone — the marker in the
// body. A rename in the web UI changes neither.
func TestAPushedIssueComesBackUnderItsOriginalSlug(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
env := pullEnv(t, g)
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
const id = "wire-sqlc-into-the-appclick-layer"
store := pullStore(dir)
if r := runWith(t, dir, env, "", "push"); r.code != 0 {
t.Fatalf("push exited %d:\n%s", r.code, r.out())
}
if pullExists(filepath.Join(store, id+".md")) {
t.Fatal("push did not drop the local copy")
}
g.pullRetitle(1, "Somebody retitled this in the web UI")
// The ledger knows the number, so it wins.
if r := runWith(t, dir, env, "", "pull", "1"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
file := pullRead(t, filepath.Join(store, id+".md"))
if !strings.Contains(file, "# Somebody retitled this in the web UI") {
t.Errorf("the new title did not come down:\n%s", file)
}
// The marker is transport bookkeeping and never reaches the store.
if strings.Contains(file, "kettle:id") {
t.Errorf("the id marker was written into the local file:\n%s", file)
}
// Now lose both the file and the ledger, the way a fresh clone has neither.
// The marker in the body is all that is left, and it is enough.
for _, p := range []string{filepath.Join(store, id+".md"), filepath.Join(store, ".remote.json")} {
if err := os.Remove(p); err != nil {
t.Fatal(err)
}
}
if r := runWith(t, dir, env, "", "pull", "1"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if !pullExists(filepath.Join(store, id+".md")) {
names, _ := os.ReadDir(store)
var have []string
for _, e := range names {
have = append(have, e.Name())
}
t.Fatalf("the issue came back under another name — every depends: pointing at it now "+
"dangles; the store holds: %s", strings.Join(have, ", "))
}
}
// A closed issue is not a unit of work, so a FILTER enumerates it and leaves it
// out — the exact opposite of what a key does, and only --state closed changes
// it.
func TestPullFilterModeLeavesClosedIssuesOut(t *testing.T) {
g := pullNewGitea()
bug := []wire.Label{{ID: 1, Name: "type/bug"}}
g.pullAdd(wire.Issue{Number: 1, Title: "Still broken", Body: "## Summary\nx\n", Labels: bug})
g.pullAdd(wire.Issue{Number: 2, Title: "Fixed last week", State: "closed",
Body: "## Summary\nx\n", Labels: bug})
env := pullEnv(t, g)
dir := newProject(t)
r := runWith(t, dir, env, "", "pull", "--label", "type/bug", "--state", "all")
if r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if pullExists(filepath.Join(pullStore(dir), "fixed-last-week.md")) {
t.Error("a filter stored a closed issue")
}
// Nothing is dropped in silence.
if !strings.Contains(r.stderr, "1 closed issue(s) enumerated, not stored") {
t.Errorf("the closed issue went out without a word:\n%s", r.stderr)
}
// Naming the state is how you ask for one.
if r := runWith(t, dir, env, "", "pull", "--label", "type/bug", "--state", "closed"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if !pullExists(filepath.Join(pullStore(dir), "fixed-last-week.md")) {
t.Error("--state closed did not store the closed issue")
}
}
// ONE RULE, NO EXCEPTION: a PATCH is a push, and it drops the local copy too.
func TestPushUpdateDropsTheLocalCopyAsWell(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
g.pullAdd(wire.Issue{
Number: 3, Title: "Came down and went back up",
Body: "## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n",
Labels: []wire.Label{{ID: 1, Name: "type/task"}},
})
env := pullEnv(t, g)
if r := runWith(t, dir, env, "", "pull", "3"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
const id = "came-down-and-went-back-up"
path := filepath.Join(pullStore(dir), id+".md")
if !pullExists(path) {
t.Fatal("the issue did not arrive")
}
r := runWith(t, dir, env, "", "push", "--update", id)
if r.code != 0 {
t.Fatalf("push --update exited %d:\n%s", r.code, r.out())
}
if !strings.Contains(r.stdout, "updated "+id+" #3") {
t.Errorf("the receipt does not report the PATCH:\n%s", r.stdout)
}
if pullExists(path) {
t.Error("--update kept the local file — two rules would put back the question " +
"push exists to remove")
}
}
// A dry run makes no request, so it must not need a credential to say what it
// would do — no URL, no token, no repository in the environment at all.
func TestPushDryRunNeedsNoCredential(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Planned but not sent")
r := run(t, dir, "push", "--dry-run")
if r.code != 0 {
t.Fatalf("a dry run must not need a tracker:\n%s", r.out())
}
if !strings.Contains(r.stdout, "ok planned-but-not-sent") ||
!strings.Contains(r.stdout, "1 issue(s) would be created") {
t.Errorf("the plan was not printed:\n%s", r.stdout)
}
if !pullExists(filepath.Join(pullStore(dir), "planned-but-not-sent.md")) {
t.Error("a dry run deleted the issue")
}
}
// --------------------------------------------------------------------------
// remote
// --------------------------------------------------------------------------
// Discovery writes nothing: the store is a store, not a search-results folder.
func TestRemoteListsWithoutWritingAnything(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
g.pullAdd(wire.Issue{Number: 4, Title: "Something open", Body: "x"})
g.pullAdd(wire.Issue{Number: 5, Title: "Something closed", State: "closed", Body: "x"})
env := pullEnv(t, g)
r := runWith(t, dir, env, "", "remote")
if r.code != 0 {
t.Fatalf("remote exited %d:\n%s", r.code, r.out())
}
if !strings.Contains(r.stdout, "#4") || strings.Contains(r.stdout, "#5") {
t.Errorf("the default listing is the open issues:\n%s", r.stdout)
}
if entries, err := os.ReadDir(pullStore(dir)); err != nil || len(entries) != 0 {
t.Errorf("a listing left files in the store: %v", entries)
}
// A number the store already knows about says so, so it is obvious what a
// pull would refresh and what it would add.
if r := runWith(t, dir, env, "", "pull", "4"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
again := runWith(t, dir, env, "", "remote")
if !strings.Contains(again.stdout, "└─ local: something-open") {
t.Errorf("the local slug was not reported:\n%s", again.stdout)
}
}
+631
View File
@@ -0,0 +1,631 @@
package cmd_test
// The four commands that WRITE — comment, close, labels, sync-evict — end to
// end: the real binary, in a throwaway project, against an httptest server
// speaking enough of the Gitea API to answer them.
//
// A fake tracker rather than a mocked client, because what these commands are
// trusted to get right is exactly the part a mock would stand in for: what goes
// out, and what is believed about the answer. `sync-evict` deletes files on the
// strength of a payload, so the payload has to come off a socket.
//
// Every helper here is named `wr…` so it cannot collide with the read side's.
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
const (
wrRepo = "kettle/tests"
wrToken = "s3cr3t-token"
wrWhen = "2026-08-11T12:00:00Z"
)
// --------------------------------------------------------------------------
// the fake tracker
// --------------------------------------------------------------------------
type wrLabel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Exclusive bool `json:"exclusive"`
}
type wrIssue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
HTMLURL string `json:"html_url"`
UpdatedAt string `json:"updated_at"`
}
type wrUser struct {
Login string `json:"login"`
}
type wrComment struct {
ID int64 `json:"id"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
User wrUser `json:"user"`
CreatedAt string `json:"created_at"`
}
var (
wrIssuePath = regexp.MustCompile(`^issues/(\d+)$`)
wrCommentPath = regexp.MustCompile(`^issues/(\d+)/comments$`)
wrLabelPath = regexp.MustCompile(`^labels/(\d+)$`)
)
// wrTracker is one repository on a pretend Gitea. It records every call, so a
// test can assert that a dry run sent nothing and that a second bootstrap wrote
// nothing.
type wrTracker struct {
mu sync.Mutex
labels []wrLabel
issues map[int]*wrIssue
comments map[int][]wrComment
broken map[int]bool // numbers whose GET answers 500
calls []string
next int64
}
func wrNewTracker() *wrTracker {
return &wrTracker{
issues: map[int]*wrIssue{},
comments: map[int][]wrComment{},
broken: map[int]bool{},
next: 100,
}
}
func (tr *wrTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
tr.mu.Lock()
defer tr.mu.Unlock()
tr.calls = append(tr.calls, r.Method+" "+r.URL.Path)
// The scheme Gitea uses and the client sends: the word `token`.
if r.Header.Get("Authorization") != "token "+wrToken {
http.Error(w, `{"message":"token required"}`, http.StatusUnauthorized)
return
}
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/"+wrRepo+"/")
if !ok {
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
return
}
switch {
case path == "labels" && r.Method == http.MethodGet:
wrJSON(w, tr.labels)
case path == "labels" && r.Method == http.MethodPost:
var req wrLabel
wrDecode(r, &req)
tr.next++
req.ID = tr.next
tr.labels = append(tr.labels, req)
wrJSON(w, req)
case r.Method == http.MethodPatch && wrLabelPath.MatchString(path):
id, _ := strconv.ParseInt(wrLabelPath.FindStringSubmatch(path)[1], 10, 64)
var req wrLabel
wrDecode(r, &req)
for i := range tr.labels {
if tr.labels[i].ID == id {
req.ID = id
tr.labels[i] = req
wrJSON(w, req)
return
}
}
http.Error(w, `{"message":"no such label"}`, http.StatusNotFound)
case wrIssuePath.MatchString(path):
n, _ := strconv.Atoi(wrIssuePath.FindStringSubmatch(path)[1])
if tr.broken[n] {
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
return
}
got := tr.issues[n]
if got == nil {
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
return
}
if r.Method == http.MethodPatch {
var req struct {
State *string `json:"state"`
Title *string `json:"title"`
}
wrDecode(r, &req)
// A close is state and nothing else; a title arriving here would be
// the command editing an issue it was only asked to close.
if req.Title != nil {
http.Error(w, `{"message":"close sent a title"}`, http.StatusUnprocessableEntity)
return
}
if req.State != nil {
got.State = *req.State
}
got.UpdatedAt = wrWhen
}
wrJSON(w, got)
case wrCommentPath.MatchString(path):
n, _ := strconv.Atoi(wrCommentPath.FindStringSubmatch(path)[1])
if tr.issues[n] == nil {
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
return
}
if r.Method == http.MethodPost {
var req struct {
Body string `json:"body"`
}
wrDecode(r, &req)
tr.next++
c := wrComment{
ID: tr.next,
Body: req.Body,
HTMLURL: fmt.Sprintf("https://tracker.example/%s/issues/%d#issuecomment-%d", wrRepo, n, tr.next),
User: wrUser{Login: "tester"},
CreatedAt: wrWhen,
}
tr.comments[n] = append(tr.comments[n], c)
wrJSON(w, c)
return
}
wrJSON(w, tr.comments[n])
default:
http.Error(w, `{"message":"not implemented: `+path+`"}`, http.StatusNotFound)
}
}
func wrJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func wrDecode(r *http.Request, v any) {
_ = json.NewDecoder(r.Body).Decode(v)
}
// --- what the tracker holds, for a test to arrange and to read back ---------
func (tr *wrTracker) add(number int, title, state string) {
tr.mu.Lock()
defer tr.mu.Unlock()
tr.issues[number] = &wrIssue{
Number: number,
Title: title,
State: state,
HTMLURL: fmt.Sprintf("https://tracker.example/%s/issues/%d", wrRepo, number),
UpdatedAt: wrWhen,
}
}
func (tr *wrTracker) breaks(number int) {
tr.mu.Lock()
defer tr.mu.Unlock()
tr.broken[number] = true
}
func (tr *wrTracker) state(number int) string {
tr.mu.Lock()
defer tr.mu.Unlock()
if got := tr.issues[number]; got != nil {
return got.State
}
return ""
}
func (tr *wrTracker) label(name string) *wrLabel {
tr.mu.Lock()
defer tr.mu.Unlock()
for i := range tr.labels {
if tr.labels[i].Name == name {
out := tr.labels[i]
return &out
}
}
return nil
}
func (tr *wrTracker) labelCount() int {
tr.mu.Lock()
defer tr.mu.Unlock()
return len(tr.labels)
}
func (tr *wrTracker) thread(number int) []wrComment {
tr.mu.Lock()
defer tr.mu.Unlock()
return append([]wrComment{}, tr.comments[number]...)
}
// mark is where the log has got to, so a test can ask what one run sent.
func (tr *wrTracker) mark() int {
tr.mu.Lock()
defer tr.mu.Unlock()
return len(tr.calls)
}
func (tr *wrTracker) since(mark int) []string {
tr.mu.Lock()
defer tr.mu.Unlock()
return append([]string{}, tr.calls[mark:]...)
}
func (tr *wrTracker) count(method string) int {
tr.mu.Lock()
defer tr.mu.Unlock()
n := 0
for _, c := range tr.calls {
if strings.HasPrefix(c, method+" ") {
n++
}
}
return n
}
// --------------------------------------------------------------------------
// the fixture
// --------------------------------------------------------------------------
// wrProject is an initialized project pointed at a fake tracker.
//
// The credentials arrive through the environment, which is what they are there
// for — and KETTLE_CONFIG_HOME goes at a temp directory so a run can neither
// read nor overwrite the developer's own tokens. KETTLE_LOGIN is cleared for the
// same reason: a value in the developer's shell would send every fixture
// looking for a login that is not in the temp file.
func wrProject(t *testing.T) (dir string, tr *wrTracker, env []string) {
t.Helper()
dir = newProject(t)
tr = wrNewTracker()
srv := httptest.NewServer(tr)
t.Cleanup(srv.Close)
return dir, tr, []string{
"KETTLE_URL=" + srv.URL,
"KETTLE_TOKEN=" + wrToken,
"KETTLE_REPO=" + wrRepo,
"KETTLE_CONFIG_HOME=" + t.TempDir(),
"KETTLE_LOGIN=",
}
}
func wrStore(dir string) string { return filepath.Join(dir, ".kettle", "issues") }
// wrTracked writes an issue the tracker also holds — a working copy, as a pull
// would have left it.
func wrTracked(t *testing.T, dir, id string, number int, state string) string {
t.Helper()
return wrWrite(t, dir, id, fmt.Sprintf(
"---\nid: %s\nstate: %s\nlabels: [type/task]\nassignees: []\nmilestone: none\ndepends: []\n"+
"origin: gitea\ngitea: %s#%d\nsynced: 2026-01-01T00:00:00Z\nurl: https://tracker.example/%s/issues/%d\n---\n"+
"# %s\n\n## Summary\nчто-то\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] сделано\n",
id, state, wrRepo, number, wrRepo, number, id))
}
// wrLocal writes an `origin: local` issue — the only copy of that work.
func wrLocal(t *testing.T, dir, id, state string) string {
t.Helper()
return wrWrite(t, dir, id, fmt.Sprintf(
"---\nid: %s\nstate: %s\nlabels: [type/task]\nassignees: []\nmilestone: none\ndepends: []\n"+
"origin: local\n---\n# %s\n\n## Summary\nчто-то\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] сделано\n",
id, state, id))
}
func wrWrite(t *testing.T, dir, id, text string) string {
t.Helper()
path := filepath.Join(wrStore(dir), id+".md")
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func wrRead(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func wrGone(t *testing.T, path, why string) {
t.Helper()
if _, err := os.Stat(path); err == nil {
t.Errorf("%s is still there — %s", filepath.Base(path), why)
}
}
func wrThere(t *testing.T, path, why string) {
t.Helper()
if _, err := os.Stat(path); err != nil {
t.Fatalf("%s is gone — %s", filepath.Base(path), why)
}
}
// --------------------------------------------------------------------------
// labels
// --------------------------------------------------------------------------
func TestLabelsCreatesTheCanonicalSetAndThenChangesNothing(t *testing.T) {
dir, tr, env := wrProject(t)
r := runWith(t, dir, env, "", "labels")
if r.code != 0 {
t.Fatalf("labels exited %d:\n%s", r.code, r.out())
}
// The set is the domain's, name for name: nothing is spelled out in the
// command, so adding a type over there is what adds it here.
want := issue.CanonicalLabels()
if got := tr.labelCount(); got != len(want) {
t.Fatalf("the repository holds %d label(s), want %d:\n%s", got, len(want), r.out())
}
for _, name := range want {
l := tr.label(name)
if l == nil {
t.Fatalf("%s was not created:\n%s", name, r.out())
}
if l.Color == "" {
t.Errorf("%s was created with no colour", name)
}
// `exclusive` is the flag no tracker CLI could set, and the whole reason
// label creation goes through the API.
if !l.Exclusive {
t.Errorf("%s is not exclusive", name)
}
}
if !strings.Contains(r.stdout, "created type/bug") {
t.Errorf("the receipt does not name what it created:\n%s", r.stdout)
}
// A label belongs to the repository, not to any issue: this must not have
// touched the store.
if _, err := os.Stat(filepath.Join(wrStore(dir), "INDEX.md")); err == nil {
t.Error("a label bootstrap wrote into the issue store")
}
mark := tr.mark()
again := runWith(t, dir, env, "", "labels")
if again.code != 0 {
t.Fatalf("the second run exited %d:\n%s", again.code, again.out())
}
for _, c := range tr.since(mark) {
if !strings.HasPrefix(c, "GET ") {
t.Errorf("the second run wrote: %s", c)
}
}
if !strings.Contains(again.stdout, "present type/bug") ||
!strings.Contains(again.stdout, "0 created") {
t.Errorf("a second run must be a no-op and say so:\n%s", again.stdout)
}
}
// --------------------------------------------------------------------------
// close
// --------------------------------------------------------------------------
func TestCloseChangesTheStateOnTheTrackerAndOnDisk(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "Done and elsewhere", "open")
tr.add(99, "Never seen here", "open")
path := wrTracked(t, dir, "done-and-elsewhere", 42, "open")
dry := runWith(t, dir, env, "", "close", "--dry-run", "done-and-elsewhere")
if dry.code != 0 || !strings.Contains(dry.stdout, "would close") {
t.Fatalf("the dry run said nothing:\n%s", dry.out())
}
if n := tr.count("PATCH"); n != 0 {
t.Errorf("a dry run sent %d write(s) — it must make no request at all", n)
}
if !strings.Contains(wrRead(t, path), "state: open") {
t.Error("a dry run wrote to the local file")
}
r := runWith(t, dir, env, "", "close", "done-and-elsewhere")
if r.code != 0 {
t.Fatalf("close exited %d:\n%s", r.code, r.out())
}
if got := tr.state(42); got != "closed" {
t.Errorf("the tracker says %q, want closed", got)
}
local := wrRead(t, path)
if !strings.Contains(local, "state: closed") {
t.Errorf("the local copy was not brought along:\n%s", local)
}
// The answer that authorized the write is also the newest thing the tracker
// has said, so the freshness fields are stamped from it.
if !strings.Contains(local, "remote-updated: "+wrWhen) || !strings.Contains(local, "synced: 20") {
t.Errorf("the freshness fields were not stamped:\n%s", local)
}
index := filepath.Join(wrStore(dir), "INDEX.md")
if !strings.Contains(wrRead(t, index), "closed") {
t.Error("INDEX.md was not rebuilt from what is now on disk")
}
// A number this machine has never seen: closed in the tracker, nothing
// written here, and the receipt says which is which.
byNumber := runWith(t, dir, env, "", "close", "99")
if byNumber.code != 0 {
t.Fatalf("closing by number exited %d:\n%s", byNumber.code, byNumber.out())
}
if got := tr.state(99); got != "closed" {
t.Errorf("#99 says %q, want closed", got)
}
if !strings.Contains(byNumber.stdout, "no local copy") {
t.Errorf("the receipt hid that there was nothing to write:\n%s", byNumber.stdout)
}
// A number resolves through the file that carries the handle, so the local
// copy of #42 is kept honest even when it was named by number.
back := runWith(t, dir, env, "", "close", "--reopen", "42")
if back.code != 0 {
t.Fatalf("reopening by number exited %d:\n%s", back.code, back.out())
}
if got := tr.state(42); got != "open" {
t.Errorf("#42 says %q, want open", got)
}
if !strings.Contains(wrRead(t, path), "state: open") {
t.Errorf("a number named the tracker but not the local copy holding its handle:\n%s", wrRead(t, path))
}
// An issue that has never left this machine has no state in the tracker to
// change, and saying so beats editing one field of a local file.
wrLocal(t, dir, "never-left-here", "open")
refused := runWith(t, dir, env, "", "close", "never-left-here")
if refused.code == 0 || !strings.Contains(refused.stderr, "push") {
t.Errorf("closing a local issue must stop and say why:\n%s", refused.out())
}
}
// --------------------------------------------------------------------------
// comment
// --------------------------------------------------------------------------
func TestCommentPostsAndTheThreadLandsBesideTheIssue(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "Talk about it", "open")
wrTracked(t, dir, "talk-about-it", 42, "open")
wrLocal(t, dir, "never-left-here", "open")
const said = "готово, задеплоено"
r := runWith(t, dir, env, "", "comment", "talk-about-it", "--body", said)
if r.code != 0 {
t.Fatalf("comment exited %d:\n%s", r.code, r.out())
}
thread := tr.thread(42)
if len(thread) != 1 || thread[0].Body != said {
t.Fatalf("the tracker holds %v", thread)
}
sidecar := filepath.Join(wrStore(dir), "talk-about-it.comments.md")
got := wrRead(t, sidecar)
if !strings.Contains(got, said) || !strings.Contains(got, "## comment ") {
t.Errorf("the thread did not land beside the issue:\n%s", got)
}
if !strings.Contains(r.stdout, "posted comment") || !strings.Contains(r.stdout, "thread:") {
t.Errorf("the receipt does not say what happened:\n%s", r.stdout)
}
// The target is a local id resolved through the `gitea:` handle, so an issue
// that carries none cannot be commented on at all.
refused := runWith(t, dir, env, "", "comment", "never-left-here", "--body", "x")
if refused.code == 0 || !strings.Contains(refused.stderr, "push") {
t.Errorf("commenting on a local-only issue must stop and say why:\n%s", refused.out())
}
if n := tr.count("POST"); n != 1 {
t.Errorf("%d comment(s) went out, want 1 — the refused one was sent anyway", n)
}
}
// --------------------------------------------------------------------------
// sync-evict
// --------------------------------------------------------------------------
// The one thing this command adds to the offline evict: a `state:` that is not
// stale. The file says open, the tracker says closed, and the tracker is right.
func TestSyncEvictRefreshesTheStateBeforeItDecides(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "Closed in the web ui", "closed")
path := wrTracked(t, dir, "closed-in-the-web-ui", 42, "open")
offline := runWith(t, dir, env, "", "evict")
if offline.code != 0 || !strings.Contains(offline.stdout, "0 issue(s) evicted") {
t.Fatalf("the offline evict must keep an issue whose file reads open:\n%s", offline.out())
}
wrThere(t, path, "the offline evict asks the file, and the file says open")
// A dry run asks, reports, and touches nothing.
dry := runWith(t, dir, env, "", "sync-evict", "--dry-run")
if dry.code != 0 || !strings.Contains(dry.stdout, "would evict") {
t.Fatalf("the dry run said nothing:\n%s", dry.out())
}
wrThere(t, path, "a dry run deleted the issue")
if !strings.Contains(wrRead(t, path), "state: open") {
t.Error("a dry run wrote the refreshed state to disk")
}
r := runWith(t, dir, env, "", "sync-evict")
if r.code != 0 {
t.Fatalf("sync-evict exited %d:\n%s", r.code, r.out())
}
if !strings.Contains(r.stdout, "open -> closed") {
t.Errorf("the refresh was not reported:\n%s", r.stdout)
}
wrGone(t, path, "the tracker said it was closed")
if index := wrRead(t, filepath.Join(wrStore(dir), "INDEX.md")); strings.Contains(index, "closed-in-the-web-ui") {
t.Errorf("INDEX.md still lists the evicted issue:\n%s", index)
}
}
func TestSyncEvictKeepsALocalIssueTheTrackerNeverHeardOf(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "Done elsewhere", "closed")
tracked := wrTracked(t, dir, "done-elsewhere", 42, "closed")
local := wrLocal(t, dir, "only-copy-there-is", "closed")
r := runWith(t, dir, env, "", "sync-evict")
if r.code != 0 {
t.Fatalf("sync-evict exited %d:\n%s", r.code, r.out())
}
wrThere(t, local, "a closed origin: local issue was deleted, and that file IS the work")
wrGone(t, tracked, "it is closed and the tracker has it")
// It was never asked about either: an issue that has never left this machine
// is not a question the tracker has an answer to.
if n := tr.count("GET"); n != 1 {
t.Errorf("%d issue(s) were asked about, want 1", n)
}
// Naming it explicitly does not make deleting it safe, and the reason is
// said out loud rather than left to be inferred from silence.
named := runWith(t, dir, env, "", "sync-evict", "only-copy-there-is")
if named.code != 0 {
t.Fatalf("naming a local issue exited %d:\n%s", named.code, named.out())
}
if !strings.Contains(named.stdout, "kept") || !strings.Contains(named.stdout, "IS the issue") {
t.Errorf("keeping it must be said out loud:\n%s", named.out())
}
wrThere(t, local, "naming it on the command line deleted it")
}
// A failed answer evicts NOTHING AT ALL — not even the issues whose answers had
// already arrived. There is no ordering constraint between evictions, so there
// is no reason to start before every answer is in.
func TestATrackerFailureDuringSyncEvictEvictsNothing(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "First answer", "closed")
tr.add(43, "Second answer", "closed")
tr.breaks(43)
answered := wrTracked(t, dir, "aaa-answered", 42, "closed")
unanswered := wrTracked(t, dir, "bbb-unanswered", 43, "closed")
r := runWith(t, dir, env, "", "sync-evict")
if r.code == 0 {
t.Fatalf("a tracker failure must stop the run:\n%s", r.out())
}
if !strings.Contains(r.stderr, "Nothing was evicted") {
t.Errorf("the failure must say what it did not do:\n%s", r.stderr)
}
wrThere(t, answered, "its answer arrived, but another one did not")
wrThere(t, unanswered, "the tracker never answered for it")
}
+164
View File
@@ -0,0 +1,164 @@
package cmd
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
func init() {
register(&Command{
Name: "tree",
Group: GroupIssue,
Args: "[<id>…]",
Short: "draw the dependency graph of the local store",
Long: `Edges come from the ` + "`depends:`" + ` metadata, which is the authoritative edge list;
prose in the body is never walked. Because the graph is slugs all the way down,
this works identically for issues that were never pushed anywhere.
Downwards is what this draws — what an issue depends on. The other direction is
a grep, not a flag:
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md`,
Examples: []Example{
{"kettle tree", "every root (nothing depends on it)"},
{"kettle tree wire-sqlc-appclick", "one subtree"},
{"kettle tree --depth 2 --write", "shallow, and saved beside the issues"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
depth := fs.Int("depth", 6, "maximum depth")
write := fs.Bool("write", false, "also write <store>/tree-<slug>.md")
out := storeFlag(fs)
return func(args []string) error {
root, err := storeRoot(*out)
if err != nil {
return err
}
if err := issue.StoreError(root); err != nil {
return err
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
edges := issue.Graph(issues)
roots := args
for _, r := range roots {
if _, ok := issues[r]; !ok {
return Fail("no issue %q in %s", r, root)
}
}
if len(roots) == 0 {
dependedOn := map[string]bool{}
for _, deps := range edges {
for _, d := range deps {
dependedOn[d] = true
}
}
for id := range issues {
if !dependedOn[id] {
roots = append(roots, id)
}
}
if len(roots) == 0 { // every issue is somebody's dependency
for id := range issues {
roots = append(roots, id)
}
}
sort.Strings(roots)
}
text := renderTree(roots, issues, edges, *depth)
fmt.Print(text)
if *write {
slug := "all"
if len(roots) == 1 {
slug = roots[0]
}
path := filepath.Join(root, "tree-"+slug+".md")
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
return err
}
fmt.Printf("written: %s\n", path)
}
return nil
}
},
})
}
func renderTree(roots []string, issues map[string]*issue.Issue, edges map[string][]string, depth int) string {
var lines []string
seen := map[string]bool{}
var walk func(id, prefix string, isLast, isRoot bool, level int)
walk = func(id, prefix string, isLast, isRoot bool, level int) {
connector := ""
if !isRoot {
connector = "├── "
if isLast {
connector = "└── "
}
}
lines = append(lines, prefix+connector+treeLabel(id, issues, seen, edges))
if seen[id] || level >= depth {
return
}
seen[id] = true
kids := edges[id]
childPrefix := prefix
if !isRoot {
childPrefix = prefix + "│ "
if isLast {
childPrefix = prefix + " "
}
}
for i, k := range kids {
walk(k, childPrefix, i == len(kids)-1, false, level+1)
}
}
for _, r := range roots {
if seen[r] {
continue // already drawn as somebody's child — one tree, not two
}
walk(r, "", true, true, 0)
lines = append(lines, "")
}
head := fmt.Sprintf("%d root(s)", len(roots))
if len(roots) == 1 {
head = roots[0]
}
out := fmt.Sprintf("# Dependency tree — %s\n\n```\n%s```\n", head, strings.Join(lines, "\n"))
if cycles := issue.FindCycles(edges); len(cycles) > 0 {
out += "\n## Cycles\n\n"
for _, c := range cycles {
out += "- " + strings.Join(c, " -> ") + "\n"
}
}
return out
}
func treeLabel(id string, issues map[string]*issue.Issue, seen map[string]bool, edges map[string][]string) string {
i, ok := issues[id]
if !ok {
return id + " (not in the store)"
}
tail := ""
if seen[id] && len(edges[id]) > 0 {
tail = " (see above)"
}
typ := i.Type()
if typ == "" {
typ = "-"
}
return fmt.Sprintf("%s [%s] %s — %s %s.md%s", id, typ, i.Title, i.State, id, tail)
}
+326
View File
@@ -0,0 +1,326 @@
// Package config holds the two files kettle reads: what this project is, and
// who this machine is.
//
// The split is the whole design. `<project>/.kettle/config.yaml` says which
// tracker repository the issues belong to and which login to reach it under —
// facts about the project, written by `kettle init`. The credentials themselves
// live in one file per machine, outside any repository, mode 0600.
//
// A token in a file inside a working tree ends up in a commit. Not always, not
// immediately, and not by anyone careless — but a project config is exactly the
// file somebody eventually decides to share, and a secret that has ever been
// pushed is a secret that has to be rotated. So the project pins a login by
// NAME and the name is worth nothing on its own.
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
)
// Environment overrides, each winning over the file it shadows. They exist for
// CI, for a one-off run against another instance, and for anyone who would
// rather not have a token on disk at all.
const (
EnvLogin = "KETTLE_LOGIN"
EnvURL = "KETTLE_URL"
EnvToken = "KETTLE_TOKEN"
EnvRepo = "KETTLE_REPO"
// EnvHome relocates the machine-wide login file; the test suite sets it so
// a run can never read or write the developer's own.
EnvHome = "KETTLE_CONFIG_HOME"
)
const projectHeader = `# kettle — project configuration
#
# login the name of a login in the machine-wide file, NOT a credential.
# Manage those with ` + "`kettle auth`" + `; they live outside this tree.
# repo the tracker repository these issues belong to, as owner/name.
#
# Overrides, when you need one: ` + EnvLogin + `, ` + EnvRepo + `, ` + EnvURL + `, ` + EnvToken + `.
`
// Project is `<project>/.kettle/config.yaml`.
type Project struct {
// Login names an entry in the machine-wide login file. Never a token.
Login string `yaml:"login"`
// Repo is the tracker repository, as owner/name.
Repo string `yaml:"repo"`
}
// Login is one set of credentials for one Gitea instance.
type Login struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
User string `yaml:"user,omitempty"`
Token string `yaml:"token"`
}
// Logins is the machine-wide file.
type Logins struct {
Logins []Login `yaml:"logins"`
}
// ErrNoConfig means the project has no config.yaml yet.
var ErrNoConfig = errors.New("no project configuration")
// ProjectPath is where this project's config.yaml is, or "" with no project.
func ProjectPath(start string) string { return project.ConfigPath(start) }
// LoadProject reads the project configuration.
//
// A missing file is ErrNoConfig, not an empty config: "this project has not
// been told which tracker it belongs to" and "it belongs to no tracker" are
// different answers and only one of them is fixable by running init.
func LoadProject(start string) (*Project, error) {
path := ProjectPath(start)
if path == "" {
return nil, project.NotFoundError(start)
}
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w at %s — run `kettle init` there", ErrNoConfig, path)
}
if err != nil {
return nil, err
}
var p Project
if err := strictUnmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return &p, nil
}
// ReadProjectFile reads a config.yaml at a path already known, reporting
// whether the file was there.
//
// LoadProject resolves the path by walking for a marker, which is the right
// thing everywhere except inside `kettle init` — the command that is creating
// the marker, and on a dry run may not have created it at all.
func ReadProjectFile(path string) (*Project, bool, error) {
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return &Project{}, false, nil
}
if err != nil {
return nil, false, err
}
var p Project
if err := strictUnmarshal(raw, &p); err != nil {
return nil, true, fmt.Errorf("%s: %w", path, err)
}
return &p, true, nil
}
// SaveProject writes the project configuration, header comment and all.
func SaveProject(path string, p *Project) error {
body, err := yaml.Marshal(p)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, append([]byte(projectHeader+"\n"), body...), 0o644)
}
// LoginsPath is the machine-wide login file.
//
// One file per machine, deliberately outside every working tree: which tokens
// this computer holds is a fact about the computer, the way which issues a tree
// holds is a fact about the tree.
func LoginsPath() string {
if h := os.Getenv(EnvHome); h != "" {
return filepath.Join(h, "logins.yaml")
}
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
return filepath.Join(x, "kettle", "logins.yaml")
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "kettle", "logins.yaml")
}
// LoadLogins reads the machine-wide login file. A missing file is an empty
// list, not an error: a machine with no logins yet is an ordinary machine.
func LoadLogins() (*Logins, error) {
path := LoginsPath()
if path == "" {
return &Logins{}, nil
}
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return &Logins{}, nil
}
if err != nil {
return nil, err
}
var l Logins
if err := strictUnmarshal(raw, &l); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return &l, nil
}
// SaveLogins writes the machine-wide login file with 0600, and creates its
// directory with 0700. The file holds bearer tokens; nothing else on the
// machine has any business reading it.
func SaveLogins(l *Logins) error {
path := LoginsPath()
if path == "" {
return errors.New("cannot locate a home directory for the login file — set " + EnvHome)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
body, err := yaml.Marshal(l)
if err != nil {
return err
}
return os.WriteFile(path, body, 0o600)
}
// Find returns the login with this name.
func (l *Logins) Find(name string) *Login {
for i := range l.Logins {
if l.Logins[i].Name == name {
return &l.Logins[i]
}
}
return nil
}
// Names lists every login on this machine, for an error message that can
// actually be acted on.
func (l *Logins) Names() []string {
out := make([]string, 0, len(l.Logins))
for _, e := range l.Logins {
out = append(out, e.Name)
}
return out
}
// Resolved is everything the transport needs, with every override applied.
type Resolved struct {
Login string
URL string
Token string
Owner string
Repo string
}
// Slug is owner/name, the way a tracker writes it.
func (r *Resolved) Slug() string { return r.Owner + "/" + r.Repo }
// Redacted is the same thing with the token replaced, for printing.
func (r *Resolved) Redacted() Resolved {
out := *r
if out.Token != "" {
out.Token = "(set)"
}
return out
}
// Resolve merges the project config, the machine's login file, and the
// environment into what the transport needs.
//
// Every failure names the file it read and the command that fixes it. "401
// Unauthorized" is what happens when this function is allowed to return a
// half-filled struct.
func Resolve(start string) (*Resolved, error) {
var p Project
if loaded, err := LoadProject(start); err == nil {
p = *loaded
} else if !errors.Is(err, ErrNoConfig) {
return nil, err
}
out := &Resolved{Login: p.Login}
if v := os.Getenv(EnvLogin); v != "" {
out.Login = v
}
repo := p.Repo
if v := os.Getenv(EnvRepo); v != "" {
repo = v
}
if repo != "" {
owner, name, ok := strings.Cut(repo, "/")
if !ok || owner == "" || name == "" {
return nil, fmt.Errorf("repo %q is not owner/name", repo)
}
out.Owner, out.Repo = owner, name
}
if out.Login != "" {
logins, err := LoadLogins()
if err != nil {
return nil, err
}
entry := logins.Find(out.Login)
if entry == nil {
known := "none on this machine"
if names := logins.Names(); len(names) > 0 {
known = strings.Join(names, ", ")
}
return nil, fmt.Errorf("no login %q in %s — known: %s; add one with `kettle auth add`",
out.Login, LoginsPath(), known)
}
out.URL, out.Token = entry.URL, entry.Token
}
if v := os.Getenv(EnvURL); v != "" {
out.URL = v
}
if v := os.Getenv(EnvToken); v != "" {
out.Token = v
}
out.URL = strings.TrimRight(out.URL, "/")
return out, nil
}
// Require is Resolve plus the assertion that the result can actually reach a
// tracker.
func Require(start string) (*Resolved, error) {
r, err := Resolve(start)
if err != nil {
return nil, err
}
var missing []string
if r.URL == "" {
missing = append(missing, "a URL (pin a login with `kettle init --login`, or set "+EnvURL+")")
}
if r.Token == "" {
missing = append(missing, "a token (`kettle auth add`, or set "+EnvToken+")")
}
if r.Owner == "" {
missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+EnvRepo+")")
}
if len(missing) > 0 {
return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
}
return r, nil
}
// strictUnmarshal refuses keys the struct does not know.
//
// The alternative is silence: an older binary reading a newer config would drop
// the setting it did not recognize, and rewriting the file would delete it.
// Being told "unknown field" beats finding out later.
func strictUnmarshal(raw []byte, out any) error {
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
dec.KnownFields(true)
if err := dec.Decode(out); err != nil && err.Error() != "EOF" {
return err
}
return nil
}
+381
View File
@@ -0,0 +1,381 @@
// Package gitea is the transport: everything that talks to a tracker, and
// nothing else.
//
// It knows numbers, logins, HTTP verbs, pagination and JSON. It does not know
// what an issue IS — no sections, no acceptance criteria, no type taxonomy —
// and the import graph says so in both directions: this package may not reach
// into internal/issue, and internal/issue may not reach in here. A tracker
// number is not a domain concept and a checkbox is not a transport one.
// Translating between the two is a layer of its own — internal/mapping — and
// that layer is not imported here either: it sits above this package, not
// beside it.
//
// The JSON shapes and the issue keys are internal/wire's. They are not this
// package's to own, because the bridge needs exactly the same vocabulary and
// cannot import a transport to get it; a copy on each side is two structs that
// drift and a command that copies fields between them by hand.
//
// Every request goes through Call. One place sets the header, one place reads
// a status code, one place files the request body. When this was a Python
// module shelling out to `tea api`, "why did that fail" meant reading a
// subprocess's stderr and guessing; here a failure is an *APIError carrying the
// status AND the body the server actually sent, because "500" on its own has
// never helped anybody.
package gitea
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
const (
// apiPrefix is where every Gitea instance puts its REST API.
apiPrefix = "/api/v1"
// userAgent names this binary in the server's log. A tracker admin looking
// at a burst of requests should be able to tell what made them.
userAgent = "kettle"
// requestTimeout bounds a single call. A hung tracker must not hang a push
// half way through a set of issues.
requestTimeout = 30 * time.Second
// maxErrorBody caps what an error quotes back. A server having a bad day
// answers with an HTML page, and an error message is not a place to paste
// one.
maxErrorBody = 2000
)
// Client talks to one repository on one Gitea instance.
type Client struct {
// HTTP is the transport, exported so a caller can change the timeout or
// hand in an instrumented one. Never nil after New.
HTTP *http.Client
base string // instance URL with the API prefix, no trailing slash
token string
repo wire.Repo
// payloadRoot is resolved once, by New, and is never taken from a caller.
// The one time where a request body lands was an argument, it got pointed
// at the issue store — see writePayload.
payloadRoot string
}
// New builds a client for the repository this project points at.
//
// It refuses a half-filled configuration instead of letting the first call come
// back 401 or 404: those answers name nothing an operator can act on, and every
// field missing here has exactly one command that supplies it.
func New(cfg *config.Resolved) (*Client, error) {
if cfg == nil {
return nil, errors.New("no resolved configuration — call config.Require first")
}
var missing []string
if cfg.URL == "" {
missing = append(missing, "a URL (pin a login with `kettle init --login`, or set "+config.EnvURL+")")
}
if cfg.Token == "" {
missing = append(missing, "a token (`kettle auth add`, or set "+config.EnvToken+")")
}
if cfg.Owner == "" || cfg.Repo == "" {
missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+config.EnvRepo+")")
}
if len(missing) > 0 {
return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
}
return &Client{
HTTP: &http.Client{Timeout: requestTimeout},
base: strings.TrimRight(cfg.URL, "/") + apiPrefix,
token: cfg.Token,
repo: wire.Repo{Owner: cfg.Owner, Name: cfg.Repo},
payloadRoot: project.PayloadRoot(""),
}, nil
}
// Repo is the repository every path is built against.
func (c *Client) Repo() wire.Repo { return c.repo }
// For returns a copy of this client pointed at another repository, for the run
// that was given an explicit owner/name. The credentials and the scratchpad
// come along; only the paths change.
func (c *Client) For(r wire.Repo) *Client {
out := *c
out.repo = r
return &out
}
// Body is a request payload and the name its dump is filed under.
//
// The name is the caller's label for this call, not a path: it becomes
// `<name>.json` in the scratchpad, and something that identifies the call in a
// post-mortem — an issue's slug, a label's name — is worth more there than a
// serial number.
type Body struct {
Name string
Data any
}
// Call makes one request and decodes the answer into out, which may be nil when
// there is nothing to read.
//
// body may be nil. When it is not, its Data is marshalled once: the bytes filed
// in the scratchpad and the bytes on the wire are the same bytes, so a retry
// from the file sends what this call sent.
//
// An empty response body leaves out untouched — a 204 from a PATCH is a
// success, not a decode failure.
func (c *Client) Call(method, path string, body *Body, out any) error {
var payload []byte
if body != nil {
var err error
if payload, err = c.writePayload(body); err != nil {
return err
}
}
endpoint := c.base + "/" + strings.TrimLeft(path, "/")
var reader io.Reader
if payload != nil {
reader = bytes.NewReader(payload)
}
req, err := http.NewRequest(method, endpoint, reader)
if err != nil {
return fmt.Errorf("%s %s: %w", method, endpoint, err)
}
// Gitea's own scheme, and what the `tea` CLI this replaces sent: the word
// `token`, not `Bearer`. An instance answers 401 to the other spelling.
req.Header.Set("Authorization", "token "+c.token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", userAgent)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
// The token travels in a header and never in the URL, so an error is
// free to quote the URL in full.
return fmt.Errorf("%s %s: %w", method, endpoint, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("%s %s: reading the response: %w", method, endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return &APIError{Method: method, URL: endpoint, Status: resp.StatusCode, Body: string(raw)}
}
if out == nil || len(bytes.TrimSpace(raw)) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%s %s: %d answered with what is not the JSON expected (%w): %s",
method, endpoint, resp.StatusCode, err, truncate(string(raw)))
}
return nil
}
// APIError is a non-2xx answer, carrying both halves of what happened.
//
// The status on its own is not a diagnosis. Gitea answers 422 for a label that
// already exists, for a milestone id that belongs to another repository, and
// for a body missing a field, and the three are told apart only by the message
// sent with them — so the body travels with the code, always.
type APIError struct {
Method string
URL string
Status int
Body string
}
func (e *APIError) Error() string {
body := strings.TrimSpace(e.Body)
if body == "" {
body = "(the response body was empty)"
} else {
body = truncate(body)
}
status := http.StatusText(e.Status)
if status != "" {
status = " " + status
}
return fmt.Sprintf("%s %s: %d%s: %s", e.Method, e.URL, e.Status, status, body)
}
// StatusIs reports whether err is an API answer with this status code, for the
// handful of places where one code means something specific — a 409 from a
// dependency link that is already there, say.
func StatusIs(err error, status int) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.Status == status
}
func truncate(s string) string {
if len(s) <= maxErrorBody {
return s
}
cut := s[:maxErrorBody]
// Never split a rune: a truncated message that ends in a broken byte is a
// message a terminal renders as garbage.
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return fmt.Sprintf("%s… (%d bytes total)", cut, len(s))
}
// --------------------------------------------------------------------------
// where request bodies land
// --------------------------------------------------------------------------
// writePayload marshals a request body, files a copy under `.kettle/payload/`,
// and returns the bytes to send.
//
// The file survives the call, for a retry or a post-mortem.
//
// WHERE IT LANDS IS NOT THE CALLER'S BUSINESS, and never was. The directory is
// this package's scratchpad — a SIBLING of the issue store under the same
// marker, resolved by the same walk, so which command wrote a body cannot
// change where it went and the two can never end up in different projects. The
// one time it was a caller's argument it got pointed at the store, and a label
// bootstrap that touches no issue at all materialized an issue directory on a
// fresh checkout: store contents are the thing being tracked, request bodies
// are debris of the transport, and when they share a path `ls` starts lying
// about what the project holds.
//
// It is created lazily, by the first write of a run and only then, so a dry run
// or a run with nothing to send leaves no directory behind.
func (c *Client) writePayload(b *Body) ([]byte, error) {
if c.payloadRoot == "" {
return nil, project.NotFoundError("")
}
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
enc.SetIndent("", " ")
// An issue body carries `<!-- … -->` markers and prose full of `&`.
// Escaping those to < would make the dump unreadable exactly when
// somebody is reading it because something went wrong.
enc.SetEscapeHTML(false)
if err := enc.Encode(b.Data); err != nil {
return nil, fmt.Errorf("encoding the %s request body: %w", b.name(), err)
}
raw := buf.Bytes()
if err := os.MkdirAll(c.payloadRoot, 0o755); err != nil {
return nil, err
}
path := filepath.Join(c.payloadRoot, b.name()+".json")
if err := os.WriteFile(path, raw, 0o644); err != nil {
return nil, err
}
return raw, nil
}
// name is the file stem, with everything that is not plainly a file name folded
// away.
//
// Sanitizing here rather than trusting callers: label names are namespaced
// (`type/bug`), and a name passed straight through would write outside the
// scratchpad — which is the one thing this directory exists to prevent.
func (b *Body) name() string {
if b.Name == "" {
return "request"
}
safe := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
return r
}
return '-'
}, b.Name)
if safe = strings.Trim(safe, "-"); safe == "" {
return "request"
}
return safe
}
// --------------------------------------------------------------------------
// pagination
// --------------------------------------------------------------------------
const (
// pageLimit is how many rows a list request asks for at a time. Gitea's own
// default is smaller and its maximum is larger; 50 is what the Python this
// replaces used and what the page-budget arithmetic is written against.
pageLimit = 50
// maxPages bounds any single listing. A tracker with a runaway number of
// rows must not turn one command into an unbounded read.
maxPages = 40
// PageSlack is how far past the ideal page count a Keep-bounded listing may
// scan before it gives up. The ideal is what Limit would need if every
// payload counted; the slack pays for the ones that do not. Deliberately
// small: "fetch until N are kept" without a bound is "fetch the whole
// tracker" on any repository whose filter matches mostly closed issues.
PageSlack = 4
)
// pages GETs a list endpoint page by page and hands each page to each as it
// arrives, stopping when each returns false, when a short page says the list is
// exhausted, or when budget pages have been read.
//
// A callback rather than a slice, because a caller whose budget is spent on
// what it KEEPS cannot be served by a function that fetches everything first:
// the page after the one that completed the budget must never be requested.
func pages[T any](c *Client, path string, limit, budget int, each func([]T) (bool, error)) error {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
for page := 1; page <= budget; page++ {
var batch []T
if err := c.Call(http.MethodGet, fmt.Sprintf("%s%spage=%d&limit=%d", path, sep, page, limit), nil, &batch); err != nil {
return err
}
if len(batch) == 0 {
return nil
}
more, err := each(batch)
if err != nil || !more {
return err
}
if len(batch) < limit {
return nil // a short page is the last one
}
}
return nil
}
// paginate follows a list endpoint to exhaustion and returns the whole list.
func paginate[T any](c *Client, path string, limit int) ([]T, error) {
var out []T
err := pages(c, path, limit, maxPages, func(batch []T) (bool, error) {
out = append(out, batch...)
return true, nil
})
return out, err
}
// repoPath builds an endpoint under this client's repository. Owner and name
// are escaped: they arrive from a config file, and a file is a thing people
// type into.
func (c *Client) repoPath(suffix string) string {
return "repos/" + url.PathEscape(c.repo.Owner) + "/" + url.PathEscape(c.repo.Name) + "/" + suffix
}
// repoPathf is repoPath with the issue or label number formatted in.
func (c *Client) repoPathf(format string, args ...any) string {
return c.repoPath(fmt.Sprintf(format, args...))
}
+396
View File
@@ -0,0 +1,396 @@
package gitea_test
// The transport is tested against httptest, never against a tracker: a test
// that needs a server somewhere is a test nobody runs.
//
// Every fixture builds a throwaway project in a temp directory and points the
// project walk at it with CLAUDE_PROJECT_DIR. Without that the walk falls
// through to the working directory — which during a test run is this repository
// — and a request dump would land in the developer's own project. Nothing here
// reads a login file either, but KETTLE_CONFIG_HOME is redirected all the same,
// so a run can neither read nor overwrite the developer's own tokens.
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// newProject makes an initialized project and points the walk at it. Returns
// the project root.
func newProject(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".kettle"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("CLAUDE_PROJECT_DIR", dir)
t.Setenv(config.EnvHome, filepath.Join(dir, "config"))
return dir
}
func newClient(t *testing.T, url string) *gitea.Client {
t.Helper()
c, err := gitea.New(&config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"})
if err != nil {
t.Fatalf("New: %v", err)
}
return c
}
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Errorf("encoding the fake response: %v", err)
}
}
// A list endpoint is followed to the last page and no further: the short page
// ends it, and the page after that is never asked for.
func TestPaginationFollowsToTheLastPage(t *testing.T) {
newProject(t)
var asked []string
var auth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
asked = append(asked, r.URL.RequestURI())
auth = r.Header.Get("Authorization")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
n := limit
if page == 3 {
n = 7 // the short page
} else if page > 3 {
n = 0
}
out := []map[string]any{}
for i := 0; i < n; i++ {
out = append(out, map[string]any{"id": (page-1)*limit + i + 1, "body": "hello"})
}
writeJSON(t, w, out)
}))
defer srv.Close()
got, err := newClient(t, srv.URL).ListComments(42)
if err != nil {
t.Fatalf("ListComments: %v", err)
}
if len(got) != 107 {
t.Errorf("got %d comments, want 107 (50 + 50 + 7)", len(got))
}
if len(asked) != 3 {
t.Errorf("made %d requests (%v), want 3 — a short page is the last one", len(asked), asked)
}
if got[0].ID != 1 || got[106].ID != 107 {
t.Errorf("pages arrived out of order: first %d, last %d", got[0].ID, got[106].ID)
}
// Gitea's own scheme, and what the CLI this replaces sent.
if auth != "token s3cret" {
t.Errorf("Authorization was %q, want %q", auth, "token s3cret")
}
want := "/api/v1/repos/acme/widgets/issues/42/comments?page=1&limit=50"
if asked[0] != want {
t.Errorf("first request was %s, want %s", asked[0], want)
}
}
// A non-2xx carries the status AND the body, because the status alone has never
// told anybody which of the four things that answer 422 actually happened.
func TestErrorNamesTheStatusAndTheBody(t *testing.T) {
newProject(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
io.WriteString(w, `{"message":"label already exists","url":"https://example.test/docs"}`)
}))
defer srv.Close()
_, err := newClient(t, srv.URL).GetIssue(7)
if err == nil {
t.Fatal("a 422 returned no error")
}
var apiErr *gitea.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error is %T, want *gitea.APIError: %v", err, err)
}
if apiErr.Status != http.StatusUnprocessableEntity {
t.Errorf("Status is %d, want 422", apiErr.Status)
}
if !gitea.StatusIs(err, http.StatusUnprocessableEntity) {
t.Error("StatusIs did not recognize its own error")
}
for _, want := range []string{"422", "label already exists", "GET", "/api/v1/repos/acme/widgets/issues/7"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the error does not mention %q:\n%s", want, err)
}
}
// The token is in a header, so quoting the URL is safe — and it had better
// stay that way.
if strings.Contains(err.Error(), "s3cret") {
t.Errorf("the error quotes the token:\n%s", err)
}
}
// A request body is filed in the scratchpad, which is a SIBLING of the store
// and never inside it. A call that touches no issue must not materialize an
// issue directory.
func TestPayloadLandsBesideTheStoreAndNeverInIt(t *testing.T) {
root := newProject(t)
var sent []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sent, _ = io.ReadAll(r.Body)
writeJSON(t, w, map[string]any{
"number": 42, "id": 5, "html_url": "https://example.test/acme/widgets/issues/42"})
}))
defer srv.Close()
body := "<!-- kettle:id wire-sqlc --> a & b"
got, err := newClient(t, srv.URL).CreateIssue(
wire.IssueRequest{Title: wire.Set("wire sqlc"), Body: wire.Set(body)}, "issue-wire-sqlc")
if err != nil {
t.Fatalf("CreateIssue: %v", err)
}
if got.Number != 42 {
t.Errorf("got issue #%d, want #42", got.Number)
}
path := filepath.Join(root, ".kettle", "payload", "issue-wire-sqlc.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("the request body was not filed at %s: %v", path, err)
}
if string(raw) != string(sent) {
t.Errorf("the filed body is not the body that was sent:\nfiled: %s\nsent: %s", raw, sent)
}
// A dump escaped to \u003c is unreadable exactly when it is being read.
if !strings.Contains(string(raw), "<!-- kettle:id wire-sqlc --> a & b") {
t.Errorf("the dump escaped the markup it was meant to preserve:\n%s", raw)
}
// The whole reason the scratchpad is a sibling.
if _, err := os.Stat(filepath.Join(root, ".kettle", "issues")); !os.IsNotExist(err) {
t.Errorf("writing a request body materialized the issue store (%v)", err)
}
// A namespaced name must not climb out of the scratchpad.
if _, err := newClient(t, srv.URL).CreateLabel(wire.LabelRequest{Name: "type/bug", Color: "#ee0701"}); err != nil {
t.Fatalf("CreateLabel: %v", err)
}
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "label-type-bug.json")); err != nil {
t.Errorf("a label request body was not filed under a safe name: %v", err)
}
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "type")); !os.IsNotExist(err) {
t.Error("a label name with a slash in it made a directory inside the scratchpad")
}
}
// A run that sends no body leaves no directory behind — the scratchpad is
// created by the first write and only then.
func TestAReadOnlyCallCreatesNoScratchpad(t *testing.T) {
root := newProject(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, map[string]any{"number": 42})
}))
defer srv.Close()
if _, err := newClient(t, srv.URL).GetIssue(42); err != nil {
t.Fatalf("GetIssue: %v", err)
}
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload")); !os.IsNotExist(err) {
t.Errorf("a read created the payload directory (%v)", err)
}
}
// The milestone filter is re-checked on the client, because Gitea silently
// ignores one it cannot resolve and answers with the whole backlog. Pull
// requests go the same way.
func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) {
newProject(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/milestones") {
writeJSON(t, w, []map[string]any{{"id": 3, "title": "v1"}, {"id": 9, "title": "later"}})
return
}
if r.URL.Query().Get("page") != "1" {
writeJSON(t, w, []map[string]any{})
return
}
writeJSON(t, w, []map[string]any{
{"number": 1, "title": "in the milestone", "milestone": map[string]any{"id": 3, "title": "v1"}},
{"number": 2, "title": "another milestone", "milestone": map[string]any{"id": 9, "title": "later"}},
{"number": 3, "title": "no milestone at all"},
{"number": 4, "title": "a pull request", "milestone": map[string]any{"id": 3, "title": "v1"},
"pull_request": map[string]any{"merged": false}},
})
}))
defer srv.Close()
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "v1", Limit: 50})
if err != nil {
t.Fatalf("ListIssues: %v", err)
}
if got.Milestone != "v1" {
t.Errorf("resolved milestone is %q, want v1", got.Milestone)
}
if len(got.Issues) != 1 || got.Issues[0].Number != 1 {
t.Fatalf("got %d issue(s) %v, want only #1 — the backlog was not re-filtered", len(got.Issues), got.Issues)
}
if _, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "typo", Limit: 50}); err == nil {
t.Error("an unknown milestone was accepted — that reads as a milestone with the whole backlog in it")
} else if !strings.Contains(err.Error(), "have: v1 (id 3)") {
t.Errorf("the error does not say what the repo actually has: %v", err)
}
}
// A Keep predicate that rejects everything must not turn a bounded read into a
// walk of the whole tracker, and coming up short is reported rather than
// answered in silence.
func TestListIssuesStopsAtThePageBudget(t *testing.T) {
newProject(t)
pages := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pages++
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
out := []map[string]any{}
for i := 0; i < limit; i++ {
out = append(out, map[string]any{"number": pages*100 + i, "state": "closed"})
}
writeJSON(t, w, out)
}))
defer srv.Close()
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
Limit: 2,
Keep: func(i *wire.Issue) bool { return i.State == "open" },
})
if err != nil {
t.Fatalf("ListIssues: %v", err)
}
if pages != gitea.PageSlack {
t.Errorf("read %d page(s), want %d — one ideal page times the slack", pages, gitea.PageSlack)
}
if got.Warning == "" {
t.Error("stopped short of the limit and said nothing about it")
}
// Everything enumerated comes back even though none of it counted: a caller
// with something to say about the ones that did not still can.
if len(got.Issues) != gitea.PageSlack*2 {
t.Errorf("got %d issue(s), want every payload that was enumerated", len(got.Issues))
}
}
// A Keep-bounded read stops the moment the budget is full: the page after the
// one that completed it is never requested.
func TestListIssuesStopsAtTheLimit(t *testing.T) {
newProject(t)
pages := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pages++
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
out := []map[string]any{}
for i := 0; i < limit; i++ {
out = append(out, map[string]any{"number": pages*100 + i, "state": "open"})
}
writeJSON(t, w, out)
}))
defer srv.Close()
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
Limit: 2,
Keep: func(i *wire.Issue) bool { return i.State == "open" },
})
if err != nil {
t.Fatalf("ListIssues: %v", err)
}
if pages != 1 {
t.Errorf("read %d page(s), want 1 — the budget was full after the first", pages)
}
if len(got.Issues) != 2 || got.Warning != "" {
t.Errorf("got %d issue(s), warning %q; want 2 and no warning", len(got.Issues), got.Warning)
}
}
// A dependency endpoint the instance does not have is "no dependencies", not a
// failed pull. A dead connection still is one.
func TestDependenciesToleratesAnInstanceWithoutThem(t *testing.T) {
newProject(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not implemented", http.StatusNotImplemented)
}))
defer srv.Close()
got, err := newClient(t, srv.URL).DependencyKeys(42)
if err != nil || len(got) != 0 {
t.Errorf("DependencyKeys = %v, %v; want no keys and no error", got, err)
}
srv.Close()
if _, err := newClient(t, srv.URL).Dependencies(42); err == nil {
t.Error("a dead connection was reported as an instance without dependency support")
}
}
// A half-filled configuration is refused here rather than at the first 401,
// because a 401 names nothing an operator can act on.
func TestNewRefusesAHalfFilledConfiguration(t *testing.T) {
for _, tc := range []struct {
what string
cfg config.Resolved
want string
}{
{"no url", config.Resolved{Token: "t", Owner: "a", Repo: "b"}, config.EnvURL},
{"no token", config.Resolved{URL: "u", Owner: "a", Repo: "b"}, "kettle auth add"},
{"no repo", config.Resolved{URL: "u", Token: "t"}, "kettle init --repo"},
} {
_, err := gitea.New(&tc.cfg)
if err == nil {
t.Errorf("%s: accepted", tc.what)
continue
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: the error does not name the fix (%q): %v", tc.what, tc.want, err)
}
}
}
// The layering rule, from this side. The transport knows numbers, logins, HTTP
// and JSON; the domain knows none of those, and neither may reach the other.
//
// The bridge is out too, and for a reason of its own: it is the layer that
// translates between the two, so it sits ABOVE both. A transport that imported
// it would be a transport that knows what an issue is, one indirection later —
// and the protocol both of them share, internal/wire, exists precisely so that
// neither has to reach for the other to name a payload.
func TestTransportDoesNotImportTheDomain(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
switch {
case strings.HasSuffix(dep, "/internal/issue"):
t.Errorf("the transport imports %s — what an issue IS is not a transport concept", dep)
case strings.HasSuffix(dep, "/internal/mapping"):
t.Errorf("the transport imports %s — translating is a layer of its own, and it sits above this one", dep)
}
}
}
+332
View File
@@ -0,0 +1,332 @@
package gitea
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// GetIssue fetches one issue by number.
//
// A number is an address, not a query: this answers for a closed issue exactly
// as it does for an open one.
func (c *Client) GetIssue(number int) (*wire.Issue, error) {
var got wire.Issue
if err := c.Call(http.MethodGet, c.repoPathf("issues/%d", number), nil, &got); err != nil {
return nil, err
}
// A 200 that carries no number is not this issue. Gitea has answered that
// way for a repository whose issue tracker is disabled.
if got.Number == 0 {
return nil, fmt.Errorf("issue #%d not found in %s", number, c.repo)
}
return &got, nil
}
// CreateIssue files a new issue. name labels the request body in the
// scratchpad; the issue's slug is what makes that dump worth keeping.
func (c *Client) CreateIssue(req wire.IssueRequest, name string) (*wire.Issue, error) {
var got wire.Issue
body := &Body{Name: name, Data: req}
if err := c.Call(http.MethodPost, c.repoPath("issues"), body, &got); err != nil {
return nil, err
}
return &got, nil
}
// EditIssue patches an existing issue. Only the fields set on req are sent.
func (c *Client) EditIssue(number int, req wire.IssueRequest, name string) (*wire.Issue, error) {
var got wire.Issue
body := &Body{Name: name, Data: req}
if err := c.Call(http.MethodPatch, c.repoPathf("issues/%d", number), body, &got); err != nil {
return nil, err
}
return &got, nil
}
// SetLabels replaces an issue's labels with exactly these ids.
//
// It exists because Gitea occasionally drops labels handed to it on create, and
// the answer to that is to re-apply them rather than to trust the echo.
func (c *Client) SetLabels(number int, ids []int64, name string) ([]wire.Label, error) {
if ids == nil {
ids = []int64{}
}
var got []wire.Label
body := &Body{Name: name, Data: struct {
Labels []int64 `json:"labels"`
}{ids}}
if err := c.Call(http.MethodPut, c.repoPathf("issues/%d/labels", number), body, &got); err != nil {
return nil, err
}
return got, nil
}
// ListComments is an issue's whole thread, every page of it.
func (c *Client) ListComments(number int) ([]wire.Comment, error) {
return paginate[wire.Comment](c, c.repoPathf("issues/%d/comments", number), pageLimit)
}
// CreateComment posts a comment on an issue.
func (c *Client) CreateComment(number int, text, name string) (*wire.Comment, error) {
var got wire.Comment
body := &Body{Name: name, Data: commentBody{Body: text}}
if err := c.Call(http.MethodPost, c.repoPathf("issues/%d/comments", number), body, &got); err != nil {
return nil, err
}
return &got, nil
}
// EditComment rewrites one comment, addressed by its own id and not by the
// issue it is on — which is how Gitea addresses it.
func (c *Client) EditComment(id int64, text, name string) (*wire.Comment, error) {
var got wire.Comment
body := &Body{Name: name, Data: commentBody{Body: text}}
if err := c.Call(http.MethodPatch, c.repoPathf("issues/comments/%d", id), body, &got); err != nil {
return nil, err
}
return &got, nil
}
type commentBody struct {
Body string `json:"body"`
}
// --------------------------------------------------------------------------
// listing, and the filter the server does not honour
// --------------------------------------------------------------------------
// IssueFilter is what a listing asks for.
type IssueFilter struct {
// State is open (the default), closed, or all.
State string
// Labels are label names; an issue must carry all of them.
Labels []string
// Query is Gitea's keyword search over title and body.
Query string
// Milestone is an id or a title. It is resolved against the repository
// before it is trusted — see ResolveMilestone.
Milestone string
// Limit counts the payloads the CALLER cares about, not the ones the server
// returned. Must be 1 or more.
Limit int
// Keep says whether a payload counts against Limit. Without it every
// payload counts and a listing behaves as any other. With it, pages keep
// coming until Limit have counted, and the returned list carries the ones
// that did not count too — they were enumerated, and a caller with
// something to say about them ("11 closed, not stored") still can.
//
// What Keep means is the caller's business; this package only counts.
Keep func(*wire.Issue) bool
}
// IssueListing is what a filtered read found.
type IssueListing struct {
// Issues are every payload that passed the filter, kept or not.
Issues []wire.Issue
// Milestone is the resolved milestone title, for a receipt.
Milestone string
// Warning is set when a Keep-bounded read ran out of page budget with the
// budget unfilled. Returned rather than printed: the transport does not own
// the operator's terminal, and a caller that is rendering JSON needs it as
// data.
Warning string
}
// ListIssues reads filtered issue payloads.
//
// One request per page, and a payload already carries the issue body — a whole
// milestone costs one call per page, not one per issue.
//
// Two boundaries hold whatever Keep decides:
//
// - Stop at the limit. The page after the one that completed the budget is
// never requested.
// - Stop at the page budget. A predicate that rejects everything must not turn
// a bounded read into a walk of the whole tracker, so a filtered read scans
// at most PageSlack times the pages Limit would need if every payload
// counted. Hitting that with the budget unfilled sets Warning rather than
// answering short in silence: the caller asked for N and is told it got
// fewer.
func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
if f.Limit < 1 {
return nil, fmt.Errorf("a listing limit must be 1 or more, got %d", f.Limit)
}
out := &IssueListing{}
var milestoneID int64
if f.Milestone != "" {
ms, err := c.ResolveMilestone(f.Milestone)
if err != nil {
return nil, err
}
milestoneID, out.Milestone = ms.ID, ms.Title
}
params := url.Values{}
state := f.State
if state == "" {
state = "open"
}
params.Set("state", state)
params.Set("type", "issues")
if len(f.Labels) > 0 {
params.Set("labels", strings.Join(f.Labels, ","))
}
if f.Query != "" {
params.Set("q", f.Query)
}
if out.Milestone != "" {
params.Set("milestones", out.Milestone)
}
path := c.repoPath("issues?" + params.Encode())
perPage := min(f.Limit, pageLimit)
ideal := max(1, (f.Limit+perPage-1)/perPage)
budget := ideal
if f.Keep != nil {
budget = ideal * PageSlack
}
kept, seen, lastFull := 0, 0, false
err := pages(c, path, perPage, budget, func(batch []wire.Issue) (bool, error) {
seen++
lastFull = len(batch) == perPage
for i := range batch {
p := &batch[i]
if !matches(p, milestoneID, f.Labels) {
continue
}
out.Issues = append(out.Issues, *p)
if f.Keep == nil || f.Keep(p) {
kept++
if kept >= f.Limit {
return false, nil
}
}
}
return true, nil
})
if err != nil {
return nil, err
}
if f.Keep != nil && seen >= budget && lastFull {
out.Warning = fmt.Sprintf("scanned %d page(s) and stopped %d short of the limit of %d"+
" — there may be more; narrow the filter or raise the limit", budget, f.Limit-kept, f.Limit)
}
return out, nil
}
// matches re-checks on the client what the server was already asked for.
//
// Not paranoia: Gitea silently IGNORES a `milestones=` value it cannot resolve
// and answers with the whole backlog, which is why the milestone is resolved to
// an id first and every payload is checked against that id here. The same
// re-check on labels costs nothing, and `pull_request` is the one filter that
// matters most — a pull request rendered as a unit of work is not a bug the
// operator can see until it is in the store.
//
// A function and not a method: the payload is the protocol's, and re-checking a
// filter the server ignored is this package's business, not the protocol's.
func matches(i *wire.Issue, milestoneID int64, labels []string) bool {
if i.IsPullRequest() {
return false
}
if milestoneID != 0 && (i.Milestone == nil || i.Milestone.ID != milestoneID) {
return false
}
have := make(map[string]bool, len(i.Labels))
for _, l := range i.Labels {
have[l.Name] = true
}
for _, want := range labels {
if !have[want] {
return false
}
}
return true
}
// --------------------------------------------------------------------------
// dependencies
// --------------------------------------------------------------------------
// issueMeta is Gitea's IssueMeta: how a dependency names another issue.
type issueMeta struct {
Index int `json:"index"`
Owner string `json:"owner"`
Repo string `json:"repo"`
}
// Dependencies are the issues that block this one — Gitea's own dependency
// links, read in the direction AddDependency writes them.
//
// An instance that does not have the endpoint, or has dependencies turned off
// for this repository, answers with a status rather than a list. That is
// reported as "no dependencies" and not as a failure: a pull must still bring
// the issue itself back from a tracker whose dependency support is off.
//
// Deliberately narrower than the Python it replaces, which swallowed every
// failure here including a dead connection. "The server said no" and "there was
// no server" are different answers, and only the first one means the feature is
// missing.
func (c *Client) Dependencies(number int) ([]wire.Issue, error) {
var got []wire.Issue
err := c.Call(http.MethodGet, c.repoPathf("issues/%d/dependencies", number), nil, &got)
var apiErr *APIError
if errors.As(err, &apiErr) {
return nil, nil
}
if err != nil {
return nil, err
}
return got, nil
}
// DependencyKeys is the same links as cross-repo handles — what a repeat push
// compares against so it does not POST a link the tracker already has.
//
// A bare number is ambiguous the moment a dependency lives in another
// repository, and Gitea lets it, so the repository travels with it.
func (c *Client) DependencyKeys(number int) ([]wire.Key, error) {
deps, err := c.Dependencies(number)
if err != nil {
return nil, err
}
out := make([]wire.Key, 0, len(deps))
for i := range deps {
out = append(out, deps[i].KeyIn(c.repo))
}
return out, nil
}
// AddDependency makes issue number depend on dep.
//
// Confirmed against an instance's own swagger.v1.json (Gitea 1.26.1):
//
// POST /repos/{owner}/{repo}/issues/{index}/dependencies
// body: IssueMeta — {"index": <int>, "owner": "<owner>", "repo": "<name>"}
// "Make the issue in the url depend on the issue in the form."
//
// So the URL names the blocked issue and the body the blocker, which is the
// direction Dependencies reads back. A link that already exists answers 409, so
// callers pre-filter with DependencyKeys and treat a failure here as a note
// rather than an abort: one missing cross-link must not undo a push that has
// already created issues.
func (c *Client) AddDependency(number int, dep wire.Key) error {
if dep.Repo.Zero() {
return fmt.Errorf("dependency %s names no repository — a link needs owner/repo#number", dep)
}
if dep.Number < 1 {
return fmt.Errorf("dependency %s names no issue number", dep)
}
body := &Body{
Name: fmt.Sprintf("dep-%d-%d", number, dep.Number),
Data: issueMeta{Index: dep.Number, Owner: dep.Repo.Owner, Repo: dep.Repo.Name},
}
return c.Call(http.MethodPost, c.repoPathf("issues/%d/dependencies", number), body, nil)
}
+108
View File
@@ -0,0 +1,108 @@
package gitea
import (
"fmt"
"net/http"
"strconv"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// ListLabels is every label in the repository, every page of it.
//
// A bootstrap decides its plan against this and never against a cache: a cache
// answers "what did we create last time", and the question is "what does the
// repository have right now".
func (c *Client) ListLabels() ([]wire.Label, error) {
return paginate[wire.Label](c, c.repoPath("labels"), 100)
}
// CreateLabel adds a label to the repository.
//
// Through the API rather than through any CLI wrapper, because `exclusive` —
// the flag that makes `type/*` behave like a single choice — is not something
// the `tea` client could set.
//
// What a label MEANS is not decided here either: this creates what it is
// handed.
func (c *Client) CreateLabel(req wire.LabelRequest) (*wire.Label, error) {
var got wire.Label
body := &Body{Name: "label-" + req.Name, Data: req}
if err := c.Call(http.MethodPost, c.repoPath("labels"), body, &got); err != nil {
return nil, err
}
if got.ID == 0 {
return nil, fmt.Errorf("creating label %q: the tracker's answer carries no id", req.Name)
}
return &got, nil
}
// EditLabel patches an existing label by id.
func (c *Client) EditLabel(id int64, req wire.LabelRequest) (*wire.Label, error) {
var got wire.Label
body := &Body{Name: "label-" + req.Name, Data: req}
if err := c.Call(http.MethodPatch, c.repoPathf("labels/%d", id), body, &got); err != nil {
return nil, err
}
return &got, nil
}
// ListMilestones is every milestone in the repository, open and closed.
//
// Both states, always: a milestone is closed the moment its work is done, and a
// listing that hid those would fail to resolve exactly the filter somebody
// types when they want to see what was in it.
func (c *Client) ListMilestones() ([]wire.Milestone, error) {
return paginate[wire.Milestone](c, c.repoPath("milestones?state=all"), 100)
}
// ResolveMilestone finds a milestone by id or by title, and fails when there is
// none.
//
// It fails LOUDLY, and that is the whole point of resolving before filtering:
// Gitea silently ignores a `milestones=` filter it cannot resolve and answers
// with the entire backlog. A typo in a milestone name would otherwise read as
// "your milestone has 300 issues in it".
func (c *Client) ResolveMilestone(value string) (*wire.Milestone, error) {
got, err := c.ListMilestones()
if err != nil {
return nil, err
}
for i := range got {
if got[i].Title == value || strconv.FormatInt(got[i].ID, 10) == value {
return &got[i], nil
}
}
have := make([]string, 0, len(got))
for _, m := range got {
have = append(have, fmt.Sprintf("%s (id %d)", m.Title, m.ID))
}
if len(have) == 0 {
have = []string{"none"}
}
return nil, fmt.Errorf("no milestone %q in %s — have: %s", value, c.repo, strings.Join(have, ", "))
}
// FindMilestone is the milestone with this title, or nil when the repository
// has no such milestone.
//
// The quiet counterpart of ResolveMilestone, for a push: an issue naming a
// milestone the tracker does not have is filed without one, because refusing
// the whole push over a field the tracker will happily accept as empty helps
// nobody. "none" and "" are both "no milestone".
func (c *Client) FindMilestone(title string) (*wire.Milestone, error) {
if title == "" || title == "none" {
return nil, nil
}
got, err := c.ListMilestones()
if err != nil {
return nil, err
}
for i := range got {
if got[i].Title == title {
return &got[i], nil
}
}
return nil, nil
}
+88
View File
@@ -0,0 +1,88 @@
package gitea
import (
"encoding/json"
"os"
"path/filepath"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// RemoteMapName is the ledger's file name, beside the issues it indexes.
const RemoteMapName = ".remote.json"
// RemoteMap is the number -> slug ledger: {"owner/repo#42": "wire-sqlc-appclick"}.
//
// ITS ENTRIES OUTLIVE THE FILES THEY NAME, and that is deliberate rather than a
// leak. A push deletes an issue's file the moment the tracker confirms the
// write, and the entry left behind is what makes the next pull of that number
// land on the same slug — so every `depends:` that pointed at it still
// resolves. Nothing prunes them, because "no file" no longer means "no such
// issue"; eviction does not prune it either, for the same reason a push does
// not. A stale entry costs one line of JSON and is corrected the next time that
// number is pulled.
//
// It is a cache, not a record. The slug also travels tracker-side, in the issue
// body, so losing this file costs a re-pull and not information — which is why
// Load never fails and why a rebuild is a MERGE and never a replacement. The
// order of authority:
//
// the tracker the issue, and the marker naming its slug
// .remote.json a local number -> slug ledger, a cache of that marker
// the store whatever happens to be checked out right now
//
// The store is a subset of what the ledger knows, so a rebuild that started
// from the files alone would throw away every entry it cannot see. Start from
// Load, add what the files say, Save.
type RemoteMap map[string]string
// RemoteMapPath is where the ledger lives: inside the issue store, beside the
// issues. root is the STORE, not the payload scratchpad — this file is
// bookkeeping about issues and belongs where they are.
func RemoteMapPath(root string) string { return filepath.Join(root, RemoteMapName) }
// LoadRemoteMap reads the ledger.
//
// A missing, unreadable or malformed file is an empty ledger and never an
// error. The ledger is a cache of markers the tracker holds, so refusing to run
// because it cannot be parsed would block the very pull that would rebuild it —
// and the cost of starting empty is one re-pull, never a lost issue.
func LoadRemoteMap(root string) RemoteMap {
raw, err := os.ReadFile(RemoteMapPath(root))
if err != nil {
return RemoteMap{}
}
var got RemoteMap
if err := json.Unmarshal(raw, &got); err != nil || got == nil {
return RemoteMap{}
}
return got
}
// Save writes the ledger, creating the directory if it is not there.
//
// The one write in this package allowed to create the store, and only because
// of when it happens: the ledger is written the instant the tracker confirms a
// push and BEFORE the local file is deleted, so failing it over a missing
// directory would lose the slug at exactly the moment the local copy stops
// being the record.
//
// Indented and key-sorted — encoding/json sorts map keys for us — because this
// file is read by people and diffed by git as often as it is read by the
// binary.
func (m RemoteMap) Save(root string) error {
if err := os.MkdirAll(root, 0o755); err != nil {
return err
}
raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(RemoteMapPath(root), append(raw, '\n'), 0o644)
}
// Slug is the local name recorded for a key, or "".
func (m RemoteMap) Slug(k wire.Key) string { return m[k.String()] }
// Set records that a key is known locally under this slug.
func (m RemoteMap) Set(k wire.Key, slug string) { m[k.String()] = slug }
+80
View File
@@ -0,0 +1,80 @@
package gitea_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func TestRemoteMapRoundTrips(t *testing.T) {
root := filepath.Join(t.TempDir(), "issues")
key := wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 42}
m := gitea.RemoteMap{}
m.Set(key, "wire-sqlc-appclick")
if err := m.Save(root); err != nil {
t.Fatalf("Save: %v", err)
}
path := gitea.RemoteMapPath(root)
if want := filepath.Join(root, ".remote.json"); path != want {
t.Errorf("the ledger is at %s, want %s — beside the issues it indexes", path, want)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading the ledger: %v", err)
}
if !strings.Contains(string(raw), `"acme/widgets#42": "wire-sqlc-appclick"`) {
t.Errorf("the ledger is not readable by a human:\n%s", raw)
}
back := gitea.LoadRemoteMap(root)
if got := back.Slug(key); got != "wire-sqlc-appclick" {
t.Errorf("the key came back as %q, want wire-sqlc-appclick", got)
}
if got := back.Slug(wire.Key{Repo: key.Repo, Number: 7}); got != "" {
t.Errorf("an unrecorded key answered %q", got)
}
// A rebuild is a merge and never a replacement: what is already recorded
// survives an entry added on top of it. This is what makes a pull of a
// number whose file was deleted by a push land on the same slug.
second := wire.Key{Repo: key.Repo, Number: 43}
back.Set(second, "drop-the-wiki")
if err := back.Save(root); err != nil {
t.Fatalf("Save: %v", err)
}
again := gitea.LoadRemoteMap(root)
if again.Slug(key) != "wire-sqlc-appclick" || again.Slug(second) != "drop-the-wiki" {
t.Errorf("a second save lost an entry: %v", again)
}
}
// The ledger is a cache of markers the tracker holds, so an unreadable one must
// not stop the pull that would rebuild it.
func TestRemoteMapSurvivesAMissingOrMangledFile(t *testing.T) {
root := t.TempDir()
if got := gitea.LoadRemoteMap(filepath.Join(root, "nowhere")); len(got) != 0 {
t.Errorf("a missing ledger loaded as %v", got)
}
if err := os.WriteFile(gitea.RemoteMapPath(root), []byte("{ not json at all"), 0o644); err != nil {
t.Fatal(err)
}
got := gitea.LoadRemoteMap(root)
if len(got) != 0 {
t.Errorf("a mangled ledger loaded as %v", got)
}
// Still writable afterwards: an unreadable ledger costs a re-pull, not a run.
got.Set(wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 1}, "first")
if err := got.Save(root); err != nil {
t.Fatalf("Save over a mangled ledger: %v", err)
}
if gitea.LoadRemoteMap(root).Slug(wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 1}) != "first" {
t.Error("the ledger did not come back after being rewritten")
}
}
+185
View File
@@ -0,0 +1,185 @@
package issue
import (
"fmt"
"regexp"
"strings"
)
// A checkbox is the one part of a body that is *state* and not prose, so the
// format gives it markup of its own. It is item markup, not a property of one
// section: `## Acceptance criteria` is the usual home, but a type/feature
// keeps its children as checkboxes under `## Issues`. The scan is therefore
// over the whole text and the heading is only recorded, never required.
var (
// The trailing group stands in for a lookahead RE2 does not have: after
// the bracket there is either whitespace and then anything, or end of line.
checkboxRe = regexp.MustCompile(
`^(?P<indent>[ \t]*)(?P<marker>[-*+]|\d+[.)])[ \t]+` +
`\[(?P<box>[ xX])\](?P<text>[ \t].*|)$`)
// Any list item — a sibling ends the item above it, checkbox or not.
listItemRe = regexp.MustCompile(`^[ \t]*([-*+]|\d+[.)])([ \t]|$)`)
fenceRe = regexp.MustCompile("^[ \t]{0,3}(`{3,}|~{3,})")
)
// Checkbox is one checkbox item found in a text.
type Checkbox struct {
// Index is the 1-based position in the list — what a user types to pick it.
Index int
// Line is the 1-based line of the `- [ ]` marker, in the text given.
Line int
// EndLine is the 1-based last line of the item, continuations included.
EndLine int
// Checked is true for [x] / [X].
Checked bool
// Text is the item's text; continuation lines joined with one space.
Text string
// Section is the nearest preceding `## ` heading, "" above the first one.
Section string
}
// Checkboxes returns every checkbox item in text, in document order.
//
// A pure function of the string it is given — no I/O, no store, no tracker.
// Pass an issue body to get body-relative line numbers, or a whole file to get
// file-relative ones; nothing else changes.
//
// Rules:
//
// - Only a line matching checkboxRe opens an item. A wrapped ("continuation")
// line is part of the item above it, never an item of its own; the item runs
// to the next blank line, heading, code fence, or list marker.
// - Fenced code blocks are skipped whole: `- [ ]` inside a fence is an example
// of the markup, not a box anybody may tick.
// - `-`, `*`, `+` and `1.` markers all count, at any indentation, so nested
// lists are seen too.
func Checkboxes(text string) []Checkbox {
lines := splitLines(text)
var items []Checkbox
section, fence := "", ""
for n, line := range lines {
if m := fenceRe.FindStringSubmatch(line); m != nil {
tok := m[1]
switch {
case fence == "":
fence = tok
case tok[0] == fence[0] && len(tok) >= len(fence):
fence = ""
}
continue
}
if fence != "" {
continue
}
if strings.HasPrefix(line, "## ") {
section = strings.TrimSpace(line)
continue
}
if strings.HasPrefix(line, "# ") {
section = ""
continue
}
m := checkboxRe.FindStringSubmatch(line)
if m == nil {
continue
}
end := n + 1
parts := []string{strings.TrimSpace(m[4])}
for k := n + 1; k < len(lines); k++ {
next := lines[k]
if strings.TrimSpace(next) == "" || strings.HasPrefix(next, "#") ||
fenceRe.MatchString(next) || listItemRe.MatchString(next) {
break
}
end = k + 1
parts = append(parts, strings.TrimSpace(next))
}
var kept []string
for _, p := range parts {
if p != "" {
kept = append(kept, p)
}
}
items = append(items, Checkbox{
Index: len(items) + 1,
Line: n + 1,
EndLine: end,
Checked: m[3] != " ",
Text: strings.Join(kept, " "),
Section: section,
})
}
return items
}
// SetCheckbox returns text with the checkbox on the given 1-based line set to
// checked.
//
// Pure, and deliberately surgical: exactly one byte of the input changes — the
// one between the brackets. Everything else, including trailing whitespace and
// the item's own wording, comes back byte for byte. That is the whole point:
// ticking a box must not produce a diff wider than the state that changed.
//
// Already in the requested state is a no-op — text comes back unchanged, and
// an existing [X] keeps its capital.
func SetCheckbox(text string, line int, checked bool) (string, error) {
off := 0
for n := 1; off <= len(text); n++ {
nl := strings.IndexByte(text[off:], '\n')
var raw string
if nl == -1 {
raw = text[off:]
} else {
raw = text[off : off+nl]
}
if n == line {
m := checkboxRe.FindStringSubmatchIndex(strings.TrimRight(raw, "\r"))
if m == nil {
return "", fmt.Errorf("line %d is not a checkbox item", line)
}
box := off + m[6] // group 3: box
if (text[box] != ' ') == checked {
return text, nil
}
c := byte(' ')
if checked {
c = 'x'
}
return text[:box] + string(c) + text[box+1:], nil
}
if nl == -1 {
break
}
off += nl + 1
}
return "", fmt.Errorf("line %d is past the end of the text", line)
}
// CheckboxProgress is (done, total) over every checkbox in text; (0, 0) when
// it has none.
//
// Computed on the fly, on purpose. Progress is not a metadata field: it is the
// body read back, and the body is the only place the state lives.
func CheckboxProgress(text string) (done, total int) {
items := Checkboxes(text)
for _, c := range items {
if c.Checked {
done++
}
}
return done, len(items)
}
// splitLines is strings.Split minus the phantom final element a trailing
// newline produces, matching Python's str.splitlines().
func splitLines(text string) []string {
if text == "" {
return nil
}
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
if last := len(lines) - 1; lines[last] == "" {
lines = lines[:last]
}
return lines
}
+117
View File
@@ -0,0 +1,117 @@
package issue
import "testing"
const boxes = `## Acceptance criteria
- [ ] первый пункт
- [x] второй пункт, который
переносится на вторую строку
* [X] третий
1. [ ] четвёртый
## Notes
` + "```" + `
- [ ] это пример разметки, а не состояние
` + "```" + `
`
func TestCheckboxesReadTheWholeBody(t *testing.T) {
items := Checkboxes(boxes)
if len(items) != 4 {
t.Fatalf("found %d items, want 4: %+v", len(items), items)
}
if items[1].Text != "второй пункт, который переносится на вторую строку" {
t.Errorf("continuation not joined: %q", items[1].Text)
}
if items[1].EndLine != 4 {
t.Errorf("end line = %d, want 4", items[1].EndLine)
}
if !items[2].Checked {
t.Error("[X] must read as checked")
}
if items[3].Section != ACSection {
t.Errorf("section = %q", items[3].Section)
}
for _, c := range items {
if c.Section == "## Notes" {
t.Error("a checkbox inside a code fence was counted")
}
}
}
func TestCheckboxProgressIsCountedOffTheBody(t *testing.T) {
done, total := CheckboxProgress(boxes)
if done != 2 || total != 4 {
t.Errorf("progress = %d/%d, want 2/4", done, total)
}
}
func TestSetCheckboxChangesExactlyOneByte(t *testing.T) {
items := Checkboxes(boxes)
got, err := SetCheckbox(boxes, items[0].Line, true)
if err != nil {
t.Fatal(err)
}
if len(got) != len(boxes) {
t.Fatalf("length changed: %d -> %d", len(boxes), len(got))
}
diff := 0
for i := range got {
if got[i] != boxes[i] {
diff++
}
}
if diff != 1 {
t.Errorf("%d bytes changed, want 1", diff)
}
}
func TestSetCheckboxIsANoOpWhenAlreadyInState(t *testing.T) {
items := Checkboxes(boxes)
// [X] keeps its capital: the state already matches, so nothing is rewritten.
got, err := SetCheckbox(boxes, items[2].Line, true)
if err != nil {
t.Fatal(err)
}
if got != boxes {
t.Error("an already-checked box was rewritten")
}
}
func TestSetCheckboxRefusesALineThatIsNotOne(t *testing.T) {
if _, err := SetCheckbox(boxes, 1, true); err == nil {
t.Error("ticking a heading must fail")
}
if _, err := SetCheckbox(boxes, 9999, true); err == nil {
t.Error("ticking past the end must fail")
}
}
func TestBodyDepRefsOnlyReadTheDepSections(t *testing.T) {
body := `## Summary
смотри также some-other-issue, который не зависимость
## Depends on
- migrate-schema — нужна схема
- add-pool-cfg
## Issues
- [ ] wire-sqlc-appclick — часть
- [ ] #42
`
got := BodyDepRefs(body)
want := []DepRef{
{DependsSection, "migrate-schema"},
{DependsSection, "add-pool-cfg"},
{IssuesSection, "wire-sqlc-appclick"},
{IssuesSection, "#42"},
}
if len(got) != len(want) {
t.Fatalf("got %+v, want %+v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("ref %d = %+v, want %+v", i, got[i], want[i])
}
}
}
+59
View File
@@ -0,0 +1,59 @@
package issue
import (
"regexp"
"strings"
)
// A reference is a slug, or `#N` on an issue that came from a tracker.
var depRefRe = regexp.MustCompile(`#(\d+)|\b([a-z0-9]+(?:-[a-z0-9]+)+)\b`)
// DepRef is one dependency reference written in the body prose, carried out
// with the section it was found in.
//
// The section travels with the reference so a caller can name the one the
// reader actually has in front of them: a container's children come from
// `## Issues`, and pointing at `## Depends on` would name a section that is not
// in the file.
type DepRef struct {
Section string
Ref string
}
// BodyDepRefs returns every reference under one of DepSections, deduplicated
// on first sight, in order of first appearance.
//
// Never from prose elsewhere, or a graph walk would drag in half the backlog.
func BodyDepRefs(body string) []DepRef {
var out []DepRef
seen := map[string]bool{}
section := ""
for _, line := range splitLines(body) {
if strings.HasPrefix(line, "## ") {
head := strings.TrimSpace(line)
section = ""
for _, s := range DepSections {
if head == s {
section = head
break
}
}
continue
}
if section == "" {
continue
}
for _, tok := range depRefRe.FindAllStringSubmatch(line, -1) {
ref := tok[2]
if tok[1] != "" {
ref = "#" + tok[1]
}
if !seen[ref] {
seen[ref] = true
out = append(out, DepRef{Section: section, Ref: ref})
}
}
}
return out
}
+155
View File
@@ -0,0 +1,155 @@
package issue
import (
"os"
"sort"
)
// Closed issues leave the store. The store is a working set, not an archive.
//
// WHAT IS EVICTED, and it is two conditions, both read off the file:
//
// state: closed the work is done
// origin: <tracker> the work is somewhere else too
//
// THE SECOND CONDITION IS THE WHOLE SAFETY ARGUMENT. `origin: local` means this
// file IS the issue — there is no other copy and deleting it deletes the work.
// It is therefore never evicted, in any state, not even when named explicitly:
// a closed local issue is reported and kept. The only files that go are ones
// whose own metadata says the work can be fetched back, which is the same trade
// a push makes when it drops a file the tracker has just confirmed.
//
// That parallel is exact except for where the confirmation comes from. Push has
// to ask the tracker, because it is the tracker that just changed. Eviction asks
// the file, because state and origin are domain fields and the answer is already
// in the store — which is why this lives in the domain and needs no network, no
// login, and no tracker. The sync layer's variant refreshes state from the
// tracker first and then calls Evict, so there is exactly one implementation of
// "what may be evicted" and it is this one.
//
// NOT A ONE-OFF MIGRATION. A pull by number fetches an issue in any state — a
// number is an address, not a query — so a closed issue pulled after an eviction
// lands on disk again. That is the tracker being asked a direct question, not a
// regression; evict it again when you are done with it.
//
// `.remote.json` is deliberately NOT pruned. It is the local number -> slug
// ledger, its entries outlive the files they name, and an evicted issue is in
// exactly that state. INDEX.md is rebuilt, because it IS a view of the
// directory.
const closed = "closed"
// LocalReason is printed whether or not the issue was named, because "this
// closed thing is still here" needs an answer every time.
const LocalReason = "origin: " + Local + " — this file IS the issue"
// Evicted is one issue that left the store, with every file that went with it.
type Evicted struct {
ID string
Paths []string
}
// Kept is one issue that was considered and stayed, with the reason.
type Kept struct {
ID string
Why string
Open bool // true when it is simply not closed yet — the normal case
}
// EvictReport is what a run did, or would have done.
type EvictReport struct {
Evicted []Evicted
Kept []Kept
DryRun bool
IndexPath string
IndexCount int
}
// Classify splits the store into what may be evicted, what is protected, and
// what is still open.
//
// Pure — it reads the loaded issues and decides; nothing here touches disk.
// ids restricts the question to those issues; empty considers the whole store.
// A protected issue is returned as such even when it was named explicitly:
// naming a file does not make deleting it safe.
func Classify(issues map[string]*Issue, ids []string) (evict, protected, stillOpen []string) {
chosen := ids
if len(chosen) == 0 {
for id := range issues {
chosen = append(chosen, id)
}
sort.Strings(chosen)
}
for _, id := range chosen {
i, ok := issues[id]
if !ok {
continue
}
switch {
case i.State != closed:
stillOpen = append(stillOpen, id)
case i.IsLocal():
protected = append(protected, id)
default:
evict = append(evict, id)
}
}
return evict, protected, stillOpen
}
// Remove deletes everything the store holds under one slug and returns the
// paths that went.
//
// Deliberately dumb: it takes an id, not a decision. Whether an issue may go is
// settled by Classify before this is reached, so the dangerous half of the
// operation has no branches in it at all.
func Remove(root, id string) ([]string, error) {
var gone []string
for _, p := range SlugFiles(root, id) {
if err := os.Remove(p); err != nil {
return gone, err
}
gone = append(gone, p)
}
return gone, nil
}
// Evict classifies, removes, and rebuilds the index. The one implementation,
// called both by the offline command and by the sync layer — which does nothing
// to this decision except hand over issues whose state it has just refreshed
// from the tracker.
func Evict(root string, issues map[string]*Issue, ids []string, dryRun bool) (*EvictReport, error) {
evict, protected, stillOpen := Classify(issues, ids)
rep := &EvictReport{DryRun: dryRun}
for _, id := range evict {
var paths []string
if dryRun {
paths = SlugFiles(root, id)
} else {
var err error
if paths, err = Remove(root, id); err != nil {
return rep, err
}
}
rep.Evicted = append(rep.Evicted, Evicted{ID: id, Paths: paths})
}
for _, id := range protected {
rep.Kept = append(rep.Kept, Kept{ID: id, Why: LocalReason})
}
for _, id := range stillOpen {
rep.Kept = append(rep.Kept, Kept{ID: id, Why: "state: " + issues[id].State, Open: true})
}
// Only when something actually went: the index is a view of the directory,
// and rewriting it after a run that changed nothing is a write nobody asked
// for.
if !dryRun && len(rep.Evicted) > 0 {
path, n, err := BuildIndex(root)
if err != nil {
return rep, err
}
rep.IndexPath, rep.IndexCount = path, n
}
return rep, nil
}
+106
View File
@@ -0,0 +1,106 @@
package issue
import "sort"
// Graph is the edge list read off the `depends:` metadata — the authoritative
// one. Body prose is never walked.
func Graph(issues map[string]*Issue) map[string][]string {
out := make(map[string][]string, len(issues))
for id, i := range issues {
out[id] = append([]string{}, i.Depends...)
}
return out
}
// Dependents lists who depends on id — the upward direction.
func Dependents(issues map[string]*Issue, id string) []string {
var out []string
for other, i := range issues {
if contains(i.Depends, id) {
out = append(out, other)
}
}
sort.Strings(out)
return out
}
// TopoOrder puts dependencies first.
//
// Cycles are broken deterministically rather than raising: a cycle is a data
// problem for the caller to report, not a reason to refuse to order the rest.
func TopoOrder(ids []string, edges map[string][]string) []string {
const (
open = 1
done = 2
)
state := map[string]int{}
var order []string
var visit func(string)
visit = func(n string) {
switch state[n] {
case done, open: // open = a back edge; leave it unresolved
return
}
state[n] = open
for _, d := range edges[n] {
if _, ok := edges[d]; ok {
visit(d)
}
}
state[n] = done
order = append(order, n)
}
for _, n := range ids {
visit(n)
}
return order
}
// FindCycles returns one id list per cycle. Empty when the graph is a DAG.
func FindCycles(edges map[string][]string) [][]string {
const (
open = 1
done = 2
)
state := map[string]int{}
var stack []string
var cycles [][]string
var visit func(string)
visit = func(n string) {
state[n] = open
stack = append(stack, n)
for _, d := range edges[n] {
if _, ok := edges[d]; !ok {
continue
}
if state[d] == open {
for i, s := range stack {
if s == d {
cycles = append(cycles, append(append([]string{}, stack[i:]...), d))
break
}
}
} else if state[d] == 0 {
visit(d)
}
}
stack = stack[:len(stack)-1]
state[n] = done
}
// Sorted so the report is the same on every run; Go map order is not.
ids := make([]string, 0, len(edges))
for n := range edges {
ids = append(ids, n)
}
sort.Strings(ids)
for _, n := range ids {
if state[n] == 0 {
visit(n)
}
}
return cycles
}
+128
View File
@@ -0,0 +1,128 @@
package issue
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
var treeFileRe = regexp.MustCompile(`^tree-.+\.md$`)
const indexPreamble = "Every issue this project knows about. `origin: local` means it " +
"exists nowhere else — a complete state, not a pending one. Any other value " +
"names the tracker it also lives in; the handle is in the file. `progress` " +
"counts the body's checkboxes, ticked over total, and is blank for an issue " +
"that has none — read off the body at build time, stored nowhere. Rebuild " +
"with `kettle index`; tick a box with `kettle ac`."
// BuildIndex rewrites INDEX.md from what is on disk and returns its path and
// the number of issues in it.
//
// An index of a store that is not there is not an empty index, it is a bad
// path: failing beats writing INDEX.md into a directory nobody asked for. An
// existing store with nothing in it is a legitimate thing to index and gets an
// "_empty_" table.
func BuildIndex(root string) (string, int, error) {
if err := RequireStore(root); err != nil {
return "", 0, err
}
issues, err := LoadAll(root)
if err != nil {
return "", 0, err
}
ids := make([]string, 0, len(issues))
for id := range issues {
ids = append(ids, id)
}
sort.Strings(ids)
out := []string{"# Issue store", "", indexPreamble, ""}
if len(ids) > 0 {
out = append(out,
"| id | state | progress | type | labels | title | milestone | depends | origin |",
"|---|---|---|---|---|---|---|---|---|")
for _, id := range ids {
i := issues[id]
var rest []string
for _, l := range i.Labels {
if !strings.HasPrefix(l, "type/") {
rest = append(rest, l)
}
}
out = append(out, fmt.Sprintf("| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s | %s |",
id, id, cell(i.State), progress(i.Body), cell(i.Type()),
cellList(rest), cell(i.Title), cell(i.Milestone),
cellList(i.Depends), cell(i.Origin)))
}
} else {
out = append(out, "_empty_")
}
if trees := treeFiles(root); len(trees) > 0 {
out = append(out, "", "## Dependency trees", "")
for _, t := range trees {
out = append(out, fmt.Sprintf("- [%s](%s)", t, t))
}
}
if cycles := FindCycles(Graph(issues)); len(cycles) > 0 {
out = append(out, "", "## Dependency cycles", "")
for _, c := range cycles {
out = append(out, "- "+strings.Join(c, " -> "))
}
}
out = append(out, "")
path := filepath.Join(root, "INDEX.md")
if err := os.WriteFile(path, []byte(strings.Join(out, "\n")), 0o644); err != nil {
return "", 0, err
}
return path, len(ids), nil
}
// progress is `3/7` for a body with checkboxes, "" for one without.
//
// Counted from the body every time the index is built and stored nowhere — the
// boxes are the state, and a second copy of it in a metadata field would be
// wrong by the next edit.
func progress(body string) string {
done, total := CheckboxProgress(body)
if total == 0 {
return ""
}
return fmt.Sprintf("%d/%d", done, total)
}
func cell(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return "—"
}
return strings.ReplaceAll(v, "|", `\|`)
}
func cellList(xs []string) string {
if len(xs) == 0 {
return "—"
}
return strings.Join(xs, ", ")
}
func treeFiles(root string) []string {
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if treeFileRe.MatchString(e.Name()) {
out = append(out, e.Name())
}
}
sort.Strings(out)
return out
}
+229
View File
@@ -0,0 +1,229 @@
// Package issue is what an issue IS. The domain layer.
//
// It knows the canonical markdown format, the label taxonomy, validation, and
// the dependency graph. It knows NOTHING about any tracker: no Gitea, no
// logins, no HTTP, no issue numbers. The layering rule is mechanically checked
// — see TestDomainImportsNothing, which walks this package's transitive
// dependencies and fails on anything outside the standard library and
// internal/project.
//
// Delete the transport entirely and this layer keeps working: issues that live
// only on this machine are first-class, not drafts on their way somewhere.
//
// Identity is a slug derived from the title, and it is the only identity the
// domain has. The file name is the id:
//
// .tea/issues/wire-sqlc-appclick.md
//
// ---
// id: wire-sqlc-appclick
// state: open
// labels: [type/task, tech/sql]
// assignees: [naudachu]
// milestone: v0.2
// depends: [migrate-schema]
// origin: gitea
// gitea: owner/repo#42
// synced: 2026-08-07T18:40:00Z
// ---
// # Wire sqlc into the appclick repo layer
//
// ## Summary
// ...
//
// Keys down to origin are owned here. Everything below is written by the sync
// layer; this package carries those keys through load/save verbatim in Extra
// and never reads them. That passthrough is what lets one file represent both
// a local issue and a synced one without the domain learning a second
// vocabulary.
//
// Every metadata field is one line and lists are inline, so plain grep works
// without a parser:
//
// grep -l 'labels:.*type/bug' .tea/issues/*.md
// grep -ln 'depends:.*migrate-schema' .tea/issues/*.md # who depends on it
package issue
import (
"fmt"
"regexp"
"strings"
)
// Origin is "does this issue exist anywhere but here" — a fact about the work,
// so it is owned here. Its value is Local or a tracker's name; what that name
// means, and the handle that goes with it (gitea: owner/repo#42), stay foreign
// keys this layer carries but never reads.
const Local = "local"
// DomainKeys are the metadata fields this layer owns, in render order. Foreign
// keys render after these, sorted, so the sync layer can add fields without
// touching this list.
var DomainKeys = []string{"id", "state", "labels", "assignees", "milestone",
"depends", "origin"}
var listKeys = map[string]bool{"labels": true, "assignees": true, "depends": true}
// States an issue may be in.
var States = []string{"open", "closed"}
var slugOK = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
var slugPunct = regexp.MustCompile(`[^a-z0-9]+`)
// Issue is one unit of work. Extra holds metadata this layer does not own.
type Issue struct {
ID string
Title string
Body string
State string
Labels []string
Assignees []string
Milestone string
Depends []string
Origin string
Extra map[string]string
}
// IsLocal reports whether this issue exists nowhere but here.
//
// A complete state, not a pending one — and the state in which this file is
// the only copy of the work. An issue whose Origin names somewhere else can be
// fetched from there again; this one cannot.
func (i *Issue) IsLocal() bool { return i.Origin == Local }
// Type is the value of the mandatory, exclusive type/* label.
func (i *Issue) Type() string { return i.namespaced("type/") }
// Severity is the value of the optional, exclusive severity/* label.
func (i *Issue) Severity() string { return i.namespaced("severity/") }
func (i *Issue) namespaced(prefix string) string {
for _, l := range i.Labels {
if v, ok := strings.CutPrefix(l, prefix); ok {
return v
}
}
return ""
}
// FromText parses a stored issue. A non-empty id overrides the one in the
// metadata block, which is how the store makes the file name authoritative.
func FromText(text, id string) *Issue {
meta, title, body := ParseMeta(text)
extra := map[string]string{}
for k, v := range meta {
if !isDomainKey(k) {
extra[k] = v
}
}
if id == "" {
id = meta["id"]
}
milestone := meta["milestone"]
if milestone == "none" {
milestone = ""
}
state := meta["state"]
if state == "" {
state = "open"
}
origin := meta["origin"]
if origin == "" {
origin = Local
}
return &Issue{
ID: id,
Title: title,
Body: strings.TrimSpace(body),
State: state,
Labels: splitList(meta["labels"]),
Assignees: splitList(meta["assignees"]),
Milestone: milestone,
Depends: splitList(meta["depends"]),
Origin: origin,
Extra: extra,
}
}
// Text renders the issue back to its canonical file form.
func (i *Issue) Text() string {
meta := map[string]string{}
for k, v := range i.Extra {
meta[k] = v
}
milestone := i.Milestone
if milestone == "" {
milestone = "none"
}
meta["id"] = i.ID
meta["state"] = i.State
meta["labels"] = renderList(i.Labels)
meta["assignees"] = renderList(i.Assignees)
meta["milestone"] = milestone
meta["depends"] = renderList(i.Depends)
meta["origin"] = i.Origin
body := strings.TrimSpace(i.Body)
if body == "" {
body = "(no body)"
}
return fmt.Sprintf("%s\n# %s\n\n%s\n", RenderMeta(meta), i.Title, body)
}
// Slugify turns a title into an id. Titles are English by format rule, so
// ASCII is enough; anything else is dropped rather than transliterated.
func Slugify(text string, maxLen int) string {
if maxLen <= 0 {
maxLen = 48
}
s := strings.Trim(slugPunct.ReplaceAllString(strings.ToLower(text), "-"), "-")
if len(s) > maxLen {
cut := s[:maxLen]
if i := strings.LastIndex(cut, "-"); i > 0 {
cut = cut[:i]
}
s = cut
}
s = strings.Trim(s, "-")
if s == "" {
return "issue"
}
return s
}
// IsSlug reports whether id is a well-formed identity.
func IsSlug(id string) bool { return slugOK.MatchString(id) }
// UniqueID is base, or base-2, base-3… when the slug is already used.
func UniqueID(root, base string, taken []string) (string, error) {
used := map[string]bool{}
for _, t := range taken {
used[t] = true
}
for _, t := range AllIDs(root) {
used[t] = true
}
if !used[base] {
return base, nil
}
for n := 2; n < 1000; n++ {
cand := fmt.Sprintf("%s-%d", base, n)
if !used[cand] {
return cand, nil
}
}
return "", fmt.Errorf("cannot allocate an id for %q", base)
}
func isDomainKey(k string) bool {
for _, d := range DomainKeys {
if d == k {
return true
}
}
return false
}
+137
View File
@@ -0,0 +1,137 @@
package issue
import (
"reflect"
"strings"
"testing"
)
const sample = `---
id: wire-sqlc-appclick
state: open
labels: [type/task, tech/sql]
assignees: [naudachu]
milestone: v0.2
depends: [migrate-schema]
origin: gitea
gitea: claude-skills/tea#42
synced: 2026-08-09T18:40:00Z
---
# Wire sqlc into the appclick repo layer
## Summary
Проводка sqlc.
## Spec
none
## Motivation
Ручной SQL расходится со схемой.
## Acceptance criteria
- [x] сгенерирован код
- [ ] тесты зелёные
`
func TestFromTextReadsTheDomainAndCarriesTheRest(t *testing.T) {
i := FromText(sample, "")
if i.ID != "wire-sqlc-appclick" {
t.Errorf("id = %q", i.ID)
}
if i.Title != "Wire sqlc into the appclick repo layer" {
t.Errorf("title = %q", i.Title)
}
if want := []string{"type/task", "tech/sql"}; !reflect.DeepEqual(i.Labels, want) {
t.Errorf("labels = %v, want %v", i.Labels, want)
}
if i.Type() != "task" {
t.Errorf("type = %q", i.Type())
}
if i.IsLocal() {
t.Error("origin gitea must not read as local")
}
if i.Extra["gitea"] != "claude-skills/tea#42" {
t.Errorf("foreign key lost: %v", i.Extra)
}
// The domain carries foreign keys; it must not learn to read them.
if _, ok := i.Extra["labels"]; ok {
t.Error("a domain key leaked into Extra")
}
if strings.Contains(i.Body, "# "+i.Title) {
t.Error("the title heading was left in the body")
}
if !strings.HasPrefix(i.Body, SummarySection) {
t.Errorf("body does not start at ## Summary: %q", head(i.Body))
}
}
func TestTextRoundTripsByteForByte(t *testing.T) {
if got := FromText(sample, "").Text(); got != sample {
t.Errorf("round trip changed the file:\n--- got ---\n%s\n--- want ---\n%s", got, sample)
}
}
func TestMilestoneNoneIsTheEmptyMilestone(t *testing.T) {
i := FromText("---\nid: x\nmilestone: none\n---\n# T\n\nbody\n", "")
if i.Milestone != "" {
t.Errorf("milestone = %q, want empty", i.Milestone)
}
if !strings.Contains(i.Text(), "milestone: none") {
t.Error("an empty milestone must render back as none")
}
}
func TestBareListValueIsTheSameStatementAsABracketedOne(t *testing.T) {
i := FromText("---\nid: x\nlabels: type/bug\n---\n# T\n\nbody\n", "")
if want := []string{"type/bug"}; !reflect.DeepEqual(i.Labels, want) {
t.Errorf("labels = %v, want %v", i.Labels, want)
}
}
func TestFileNameWinsOverTheMetadataID(t *testing.T) {
// The store names the file after the slug, so a hand-edited `id:` that
// disagrees with it is the one that is wrong.
if got := FromText(sample, "renamed-by-hand").ID; got != "renamed-by-hand" {
t.Errorf("id = %q", got)
}
}
func TestSlugify(t *testing.T) {
cases := map[string]string{
// Truncation cuts back to the last dash, so a slug never ends in half
// a word — even when the limit happened to land on a boundary.
"Wire sqlc into the appclick repo layer": "wire-sqlc-into-the-appclick",
"Fix tea-guard crash": "fix-tea-guard-crash",
" Trailing --- dashes ": "trailing-dashes",
// Titles are English by format rule; anything else is dropped rather
// than transliterated, and an empty result is not an id.
"Крашится гвард": "issue",
"": "issue",
}
for in, want := range cases {
if got := Slugify(in, 32); got != want {
t.Errorf("Slugify(%q) = %q, want %q", in, got, want)
}
}
}
func head(s string) string {
if len(s) > 40 {
return s[:40]
}
return s
}
func TestSectionBodyStopsAtTheNextHeading(t *testing.T) {
body := "## Summary\nодин\nдва\n\n## Spec\nnone\n"
if got := SectionBody(body, "## Summary"); got != "один\nдва" {
t.Errorf("Summary = %q", got)
}
if got := SectionBody(body, SpecSection); got != "none" {
t.Errorf("Spec = %q", got)
}
if got := SectionBody(body, "## Missing"); got != "" {
t.Errorf("missing section = %q, want empty", got)
}
}
+55
View File
@@ -0,0 +1,55 @@
package issue
import (
"os/exec"
"strings"
"testing"
)
// The domain must depend on nothing but the standard library and the one
// package that answers "which directory is the project".
//
// In Python this rule was a grep in a document and a habit; here it is a build
// graph, and the test fails the moment a tracker concept — an HTTP client, a
// JSON payload, a login — is imported into the layer that must not know a
// tracker exists.
func TestDomainDependsOnNothing(t *testing.T) {
const allowed = "git.noodles.cam/claude-skills/marketplace/cli/internal/project"
out, err := exec.Command("go", "list", "-deps", ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
if dep == allowed || dep == "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" {
continue
}
// A standard-library import path has no dot in its first element,
// because it has no domain name in front of it.
first, _, _ := strings.Cut(dep, "/")
if strings.Contains(first, ".") {
t.Errorf("the domain imports %s — a tracker concept in the layer that must not know one exists", dep)
}
}
}
// The other half of the same rule: net/http and its friends are standard
// library, so "no third-party imports" would not catch a transport written by
// hand. Name them.
func TestDomainDoesNotReachTheNetworkOrTheShell(t *testing.T) {
forbidden := []string{"net/http", "net", "os/exec", "encoding/json"}
out, err := exec.Command("go", "list", "-deps", ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
deps := map[string]bool{}
for _, d := range strings.Fields(string(out)) {
deps[d] = true
}
for _, f := range forbidden {
if deps[f] {
t.Errorf("the domain reaches %s — that belongs in the transport", f)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package issue
import (
"regexp"
"sort"
"strings"
)
var titleRe = regexp.MustCompile(`^#[ \t]+(.+?)[ \t]*\n`)
// ParseMeta splits a file into its metadata block, title, and body.
//
// Values come back as the raw text that followed the colon. Lists are not
// unpacked here: a foreign key that happens to look like a list must round
// trip byte for byte, and the domain's own lists are unpacked by their
// accessors. The title is the first `# ` heading below the block and is
// stripped out of the body.
func ParseMeta(text string) (meta map[string]string, title, body string) {
meta = map[string]string{}
rest := text
if strings.HasPrefix(text, "---") {
if end := strings.Index(text[3:], "\n---"); end != -1 {
end += 3
for _, line := range strings.Split(strings.TrimSpace(text[3:end]), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
meta[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
rest = text[end+4:]
}
}
rest = strings.TrimLeft(rest, "\n")
if m := titleRe.FindStringSubmatchIndex(rest); m != nil {
title = strings.TrimSpace(rest[m[2]:m[3]])
rest = strings.TrimLeft(rest[m[1]:], "\n")
}
return meta, title, rest
}
// RenderMeta writes the block back: domain keys in DomainKeys order, foreign
// keys after them, sorted. Lists stay on one line so grep sees them whole.
func RenderMeta(meta map[string]string) string {
var foreign []string
for k := range meta {
if !isDomainKey(k) {
foreign = append(foreign, k)
}
}
sort.Strings(foreign)
lines := []string{"---"}
for _, k := range append(append([]string{}, DomainKeys...), foreign...) {
if v, ok := meta[k]; ok {
lines = append(lines, k+": "+v)
}
}
return strings.Join(append(lines, "---"), "\n")
}
// splitList unpacks the inline `[a, b]` form, and a bare comma-separated value
// too: a hand-written `labels: type/bug` is the same statement as
// `labels: [type/bug]` and the format does not make an operator care.
func splitList(v string) []string {
v = strings.TrimSpace(v)
if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") {
v = v[1 : len(v)-1]
}
var out []string
for _, part := range strings.Split(v, ",") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
func renderList(xs []string) string { return "[" + strings.Join(xs, ", ") + "]" }
+209
View File
@@ -0,0 +1,209 @@
package issue
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
)
// The store holds two kinds of file, and only one of them is a store.
//
// An issue whose origin is Local lives here and nowhere else — that file IS the
// issue, and losing it loses the work. Anything with a tracker origin is a
// cache: the tracker has it, this copy is a working copy, and it is deleted the
// moment a push confirms the tracker is up to date.
// Root resolves the issue store for the current project. An explicit out
// overrides it and is used exactly as typed: a relative out stays relative to
// the working directory, because that is what the operator asked for.
func Root(out string) string {
if out != "" {
return out
}
return project.StoreRoot("")
}
// ErrStoreMissing marks the "the store directory is not there" failure.
//
// Deliberately a different answer from "the store is empty". One is a path that
// does not exist, the other is a repository with no issues filed yet, and
// conflating the two is exactly what made a missed directory look like an empty
// backlog.
var ErrStoreMissing = errors.New("store missing")
// StoreExists reports whether root is a directory that can be read as a store.
func StoreExists(root string) bool {
if root == "" {
return false
}
fi, err := os.Stat(root)
return err == nil && fi.IsDir()
}
// RequireStore asserts the store is there before reading or writing it.
//
// An empty root means no project was found at all — a different failure from a
// project whose store has not been created yet, and the message says so.
func RequireStore(root string) error {
if root == "" {
return fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError(""))
}
if !StoreExists(root) {
return fmt.Errorf("%w: store %s does not exist", ErrStoreMissing, root)
}
return nil
}
// CreateStore creates the store, reporting whether it made the directory.
//
// Only the commands that legitimately bootstrap a store call this — `new` and
// `pull` — and both announce it. Nothing creates a store as a side effect of a
// write: a missing directory is something to report, not something to conjure.
// An unresolved root is never conjured either — without a marker there is no
// project to create a store IN, and guessing one is how a store once ended up
// inside the plugin.
func CreateStore(root string) (bool, error) {
if root == "" {
return false, fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError(""))
}
if StoreExists(root) {
return false, nil
}
if err := os.MkdirAll(root, 0o755); err != nil {
return false, err
}
return true, nil
}
// StoreError says why root cannot be read as a store, or nil when it holds
// issues.
//
// The three messages are distinct on purpose — no project at all, a project
// with no store, and a store with nothing in it are three different things to
// do next.
func StoreError(root string) error {
switch {
case root == "":
return project.NotFoundError("")
case !StoreExists(root):
return fmt.Errorf("store %s does not exist — nothing was created; pass --out to point elsewhere", root)
case len(AllIDs(root)) == 0:
return fmt.Errorf("store %s exists but is empty", root)
}
return nil
}
// PathOf is where the issue with this id lives.
func PathOf(root, id string) string { return filepath.Join(root, id+".md") }
// AllIDs lists every issue in the store, by slug.
//
// An issue file is named by its slug and a slug has no dot in it, so
// `<id>.comments.md` — the thread the sync layer parks beside an issue — is not
// one, and neither is anything else that grew a second extension. Without that
// rule `wire-sqlc.comments` reads as an issue called `wire-sqlc.comments`, and
// a bare push tries to file the comment thread as a unit of work.
func AllIDs(root string) []string {
if !StoreExists(root) {
return nil
}
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".md") {
continue
}
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "INDEX") ||
strings.HasPrefix(name, "tree-") {
continue
}
id := name[:len(name)-3]
if strings.Contains(id, ".") {
continue
}
out = append(out, id)
}
sort.Strings(out)
return out
}
// SlugFiles lists every file the store holds under one slug — the issue and its
// sidecars.
//
// `<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
// companion another layer parked there (`<id>.comments.md` is the one that
// exists today). AllIDs already refuses to read those as issues because a slug
// has no dot in it; this is the same rule read the other way round.
//
// Which is how the domain can remove an issue completely without learning what
// any of those companions are: it does not need to know that a comment thread
// exists to know that a file named after this issue belongs to it and goes when
// it goes. The issue's own file comes first — it is the headline of any receipt
// printed from this list.
//
// A missing store is an empty list, not an error: nothing is there to remove.
func SlugFiles(root, id string) []string {
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
prefix, own := id+".", id+".md"
var self, sidecars []string
for _, e := range entries {
name := e.Name()
if !strings.HasPrefix(name, prefix) || e.IsDir() {
continue
}
p := filepath.Join(root, name)
if name == own {
self = append(self, p)
} else {
sidecars = append(sidecars, p)
}
}
sort.Strings(sidecars)
return append(self, sidecars...)
}
// Load reads one issue. The file name wins over the id in the metadata block.
func Load(root, id string) (*Issue, error) {
raw, err := os.ReadFile(PathOf(root, id))
if err != nil {
return nil, err
}
return FromText(string(raw), id), nil
}
// LoadAll reads the whole store.
func LoadAll(root string) (map[string]*Issue, error) {
out := map[string]*Issue{}
for _, id := range AllIDs(root) {
i, err := Load(root, id)
if err != nil {
return nil, err
}
out[id] = i
}
return out, nil
}
// Save writes an issue to the store, which must already exist.
func Save(root string, i *Issue) (string, error) {
if err := RequireStore(root); err != nil {
return "", err
}
p := PathOf(root, i.ID)
if err := os.WriteFile(p, []byte(i.Text()), 0o644); err != nil {
return "", err
}
return p, nil
}
+116
View File
@@ -0,0 +1,116 @@
package issue
import "strings"
// Four namespaces classify an issue. type/* is mandatory and exclusive,
// severity/* is optional and exclusive, tech/* and comp/* are free-form.
//
// Colors are NOT here — a hex code is how a tracker paints a chip, which makes
// it the sync layer's business.
// Types are the kinds of work, and the order is the order they are offered in.
var Types = []struct{ Name, Meaning string }{
{"bug", "Something behaves incorrectly in existing code"},
{"task", "Implementation of new functionality"},
{"refactor", "Internal restructuring; behavior must not change"},
{"test", "Writing or fixing tests"},
{"feature", "Container: several issues delivering one unit of business value"},
{"draft", "Idea captured for later; not ready for work"},
}
// Severities are the business-impact levels, ascending.
var Severities = []string{"low", "medium", "high", "showstopper", "critical"}
// Section headers are fixed English literals in a fixed order; only body prose
// is Russian.
const (
SummarySection = "## Summary"
SpecSection = "## Spec"
ACSection = "## Acceptance criteria"
DependsSection = "## Depends on"
IssuesSection = "## Issues"
)
// RequiredSections must be present in every type. type/draft is exempt from
// acceptance criteria and only from that.
var RequiredSections = []string{SummarySection, SpecSection}
// DepSections both name what an issue depends on, so both are edge sources and
// both point the same way. In a type/feature that reads container -> child:
// "the container is closed when its children are closed" IS a dependency.
// "a child belongs to a feature" is membership, and membership has no place in
// a dependency graph — which is why a child never names its container back.
var DepSections = []string{DependsSection, IssuesSection}
// ExpectedSections are the per-type sections from the templates. Absence is a
// warning, not a stop.
var ExpectedSections = map[string][]string{
"bug": {"## Steps to reproduce", "## Expected", "## Actual", "## Environment"},
"task": {"## Motivation"},
"refactor": {"## Motivation", "## Invariants"},
"test": {"## Motivation", "## Test cases"},
"feature": {"## Motivation", IssuesSection},
"draft": {"## Notes"},
}
// KnownType reports whether name is one of Types.
func KnownType(name string) bool {
for _, t := range Types {
if t.Name == name {
return true
}
}
return false
}
// KnownSeverity reports whether name is one of Severities.
func KnownSeverity(name string) bool {
for _, s := range Severities {
if s == name {
return true
}
}
return false
}
// TypeNames lists the type slugs, for error messages and completion.
func TypeNames() []string {
out := make([]string, len(Types))
for i, t := range Types {
out[i] = t.Name
}
return out
}
// CanonicalLabels is the label set a tracker needs before a push can attach
// anything: the two exclusive namespaces in full. tech/* and comp/* are
// project-specific and have no preset.
func CanonicalLabels() []string {
out := make([]string, 0, len(Types)+len(Severities))
for _, t := range Types {
out = append(out, "type/"+t.Name)
}
for _, s := range Severities {
out = append(out, "severity/"+s)
}
return out
}
// SectionBody is the text under header, up to the next `## ` heading.
func SectionBody(body, header string) string {
var out []string
active := false
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(line, "## ") {
if active {
break
}
active = strings.TrimSpace(line) == header
continue
}
if active {
out = append(out, line)
}
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
+132
View File
@@ -0,0 +1,132 @@
package issue
import "strings"
// The type templates, verbatim from references/format.md.
//
// Section headers are fixed English literals in a fixed order; body prose is
// Russian. Both halves of that rule are in the strings below, and the format
// document is the source of truth for them.
const specSection = "## Spec\nnone\n"
var templates = map[string]string{
"bug": `## Summary
Что сломано и где проявляется, одно-два предложения.
` + specSection + `
## Steps to reproduce
1. …
2. …
## Expected
Что должно было произойти.
## Actual
Что происходит на самом деле: вывод команды, лог.
## Environment
Только релевантное: версии, ОС, конфигурация.
## Acceptance criteria
- [ ] баг не воспроизводится по шагам выше
- [ ] добавлена проверка на регрессию (если применимо)
`,
"task": `## Summary
Что нужно сделать, одно-два предложения.
` + specSection + `
## Motivation
Какую проблему пользователя/системы это решает.
## Acceptance criteria
- [ ] проверяемое условие
- [ ] …
`,
"refactor": `## Summary
Что перестраиваем и в каких файлах (` + "`path/file:line`" + `).
` + specSection + `
## Motivation
Чем плохо текущее состояние: дублирование, связность, читаемость.
## Invariants
Что НЕ должно измениться: поведение, публичные API, форматы данных.
## Acceptance criteria
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
`,
"test": `## Summary
Что покрываем тестами и где (` + "`path/file:line`" + `).
` + specSection + `
## Motivation
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
## Test cases
- сценарий → ожидаемый результат
- …
## Acceptance criteria
- [ ] перечисленные кейсы покрыты и зелёные
- [ ] тесты проходят в CI
`,
"feature": `## Summary
Бизнес-ценность одним-двумя предложениями.
` + specSection + `
## Motivation
Какую проблему пользователя/системы это решает.
## Issues
- [ ] slug-дочернего-issue — краткое описание части
- [ ] …
## Acceptance criteria
- [ ] все дочерние issues закрыты
- [ ] проверяемое условие уровня фичи
`,
"draft": `## Summary
Идея одним-двумя предложениями.
` + specSection + `
## Notes
Свободные заметки: что известно, открытые вопросы, варианты.
`,
}
// Template is the prefilled body for a type, with `## Depends on` inserted
// right after `## Spec` when the issue has dependencies.
func Template(typ string, depends []string) string {
return withDepends(templates[typ], depends)
}
// withDepends places the section where the format says it goes: after
// `## Spec`, before everything else. Appended at the end only when the
// template has no third section to sit in front of.
func withDepends(body string, depends []string) string {
if len(depends) == 0 {
return body
}
var b strings.Builder
b.WriteString("## Depends on\n")
for _, d := range depends {
b.WriteString("- " + d + "\n")
}
block := b.String()
var out []string
placed := false
for _, line := range strings.SplitAfter(body, "\n") {
if !placed && len(out) > 0 && strings.HasPrefix(line, "## ") &&
!strings.HasPrefix(line, SummarySection) && !strings.HasPrefix(line, SpecSection) {
out = append(out, block+"\n")
placed = true
}
out = append(out, line)
}
if !placed {
out = append(out, "\n"+block)
}
return strings.Join(out, "")
}
+131
View File
@@ -0,0 +1,131 @@
package issue
import (
"fmt"
"regexp"
"strings"
)
var (
titlePrefixRe = regexp.MustCompile(
`(?i)^\s*(\[[^\]]+\]|(fix|feat|feature|bug|task|test|chore|refactor)\s*:)`)
cyrillicRe = regexp.MustCompile(`(?i)[а-яё]`)
)
// Validate reports what is wrong with an issue.
//
// Errors mean the issue is not well-formed in the canonical format; warnings
// mean it deviates from its type template. Pass knownIDs to have dependencies
// resolved against a store; pass nil to skip that check.
func Validate(i *Issue, knownIDs map[string]bool) (errs, warns []string) {
switch {
case i.ID == "":
errs = append(errs, "no `id:` — the slug is the issue's identity")
case !IsSlug(i.ID):
errs = append(errs, fmt.Sprintf("id %q is not a slug (lowercase, digits, single dashes)", i.ID))
}
if !contains(States, i.State) {
errs = append(errs, fmt.Sprintf("state %q must be one of: %s",
i.State, strings.Join(States, ", ")))
}
var types []string
severities := 0
for _, l := range i.Labels {
if strings.HasPrefix(l, "type/") {
types = append(types, l)
}
if strings.HasPrefix(l, "severity/") {
severities++
}
}
switch {
case len(types) != 1:
found := strings.Join(types, ", ")
if found == "" {
found = "none"
}
errs = append(errs, fmt.Sprintf("need exactly one type/* label, found %d: %s",
len(types), found))
case !KnownType(i.Type()):
errs = append(errs, fmt.Sprintf("unknown type %q — known: %s",
i.Type(), strings.Join(TypeNames(), ", ")))
}
if severities > 1 {
errs = append(errs, "at most one severity/* label")
}
if s := i.Severity(); s != "" && !KnownSeverity(s) {
warns = append(warns, fmt.Sprintf("unknown severity %q", s))
}
if i.Title == "" {
errs = append(errs, "no `# Title` heading below the metadata block")
} else {
if titlePrefixRe.MatchString(i.Title) {
head := i.Title
if len(head) > 24 {
head = head[:24]
}
errs = append(errs, fmt.Sprintf(
"title carries a type prefix (%q) — the type lives in the label", head))
}
if cyrillicRe.MatchString(i.Title) {
errs = append(errs, "title must be English, imperative mood (prose stays Russian)")
}
}
for _, h := range RequiredSections {
if !strings.Contains(i.Body, h) {
errs = append(errs, "missing section "+h)
}
}
if i.Type() != "draft" && !strings.Contains(i.Body, ACSection) {
errs = append(errs, "missing section "+ACSection)
}
if strings.Contains(i.Body, SpecSection) && SectionBody(i.Body, SpecSection) == "" {
errs = append(errs, "## Spec is empty — put a repo path, a URL, or the literal `none`")
}
for _, h := range ExpectedSections[i.Type()] {
if !strings.Contains(i.Body, h) {
warns = append(warns, fmt.Sprintf("type/%s template usually has %s", i.Type(), h))
}
}
if contains(i.Depends, i.ID) {
errs = append(errs, "depends on itself")
}
if knownIDs != nil {
for _, d := range i.Depends {
if !knownIDs[d] {
warns = append(warns, fmt.Sprintf("depends on %q, which is not in the store", d))
}
}
}
// `depends:` is the machine-readable graph; the body section is prose for
// humans. They drift silently unless something says so. Name the section
// the reference actually came from — for a container that is `## Issues`.
for _, r := range BodyDepRefs(i.Body) {
if !strings.HasPrefix(r.Ref, "#") && !contains(i.Depends, r.Ref) {
warns = append(warns, fmt.Sprintf(
"%s mentions %q but `depends:` does not list it", r.Section, r.Ref))
}
}
// An unticked checkbox is never a finding — neither an error nor a warning.
// `- [ ]` is work not done yet, which is the normal state of a perfectly
// well-formed issue. Reading that state is the `ac` command's job.
return errs, warns
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
+207
View File
@@ -0,0 +1,207 @@
package mapping
import (
"slices"
"strconv"
"strings"
"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 *wire.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: p.Number}.String(),
URLKey: p.HTMLURL,
SyncedKey: opt.Synced,
}
if p.Ref != "" {
extra[BranchKey] = p.Ref
}
if p.UpdatedAt != "" {
extra[RemoteUpdatedKey] = p.UpdatedAt
}
// 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 := p.State
if state == "" {
state = "open"
}
// Appended into nil slices, 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.
var labels []string
for _, l := range p.Labels {
labels = append(labels, l.Name)
}
var assignees []string
for _, a := range p.Assignees {
assignees = append(assignees, a.Login)
}
milestone := ""
if p.Milestone != nil {
milestone = p.Milestone.Title
}
return &issue.Issue{
ID: id,
Title: p.Title,
Body: body,
State: state,
Labels: labels,
Assignees: assignees,
Milestone: milestone,
Depends: deps,
Origin: Origin,
Extra: extra,
}, unresolved
}
// 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 []wire.Comment) string {
var out []string
for _, c := range comments {
day := c.CreatedAt
if len(day) > 10 {
day = day[:10]
}
body := strings.TrimSpace(c.Body)
if body == "" {
body = "(empty)"
}
out = append(out,
"## comment "+strconv.FormatInt(c.ID, 10)+" — "+c.User.Login+" — "+day,
"", body, "")
}
return strings.Join(out, "\n")
}
+123
View File
@@ -0,0 +1,123 @@
package mapping
import (
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
// an issue IS, which is exactly why the table lives here and not in the domain
// — internal/issue/taxonomy.go says as much where the labels themselves are.
//
// The keys are the canonical set and nothing else. TestEveryCanonicalLabelHasA
// Color walks issue.CanonicalLabels() and fails on a gap, so a type or a
// severity added over there cannot quietly arrive here as grey.
var labelColors = map[string]string{
"type/bug": "#ee0701",
"type/task": "#0e8a16",
"type/refactor": "#1d76db",
"type/test": "#fbca04",
"type/feature": "#5319e7",
"type/draft": "#cccccc",
"severity/low": "#c2e0c6",
"severity/medium": "#fbca04",
"severity/high": "#eb6420",
"severity/showstopper": "#ee0701",
"severity/critical": "#b60205",
}
// DefaultColor paints everything outside the canonical set. `tech/*` and
// `comp/*` are project-specific and have no preset, so guessing a color for one
// would be inventing a meaning it does not have.
const DefaultColor = "#ededed"
// LabelColor is the hex code a label is painted with in the tracker.
func LabelColor(name string) string {
if c, ok := labelColors[name]; ok {
return c
}
return DefaultColor
}
// LabelSpecs is the request body for each name, in the order given.
//
// A wire.LabelRequest and not a shape of this package's own: it is field for
// field what a label create takes, and a second spelling of it would mean the
// bootstrap command copying four fields across on its way to the transport.
// Exclusivity and meaning come from the domain taxonomy; only the color is
// decided here.
//
// A slice and not a map: the order is the taxonomy's, and a bootstrap prints
// its plan in that order — a map would shuffle the plan on every run and make
// two identical runs look like different ones.
func LabelSpecs(names []string) []wire.LabelRequest {
ns := exclusiveNamespaces()
out := make([]wire.LabelRequest, 0, len(names))
for _, name := range names {
out = append(out, wire.LabelRequest{
Name: name,
Color: LabelColor(name),
Description: typeMeaning(name),
Exclusive: hasAnyPrefix(name, ns),
})
}
return out
}
// CanonicalLabelSpecs is the set a repository needs before a push can attach
// anything.
//
// Derived from the domain's own list rather than restated: add a type over in
// the taxonomy and the next bootstrap creates it, with no line changing here
// except the color it is painted with.
func CanonicalLabelSpecs() []wire.LabelRequest { return LabelSpecs(issue.CanonicalLabels()) }
// exclusiveNamespaces are the namespaces at most one label may come from, read
// off the canonical set rather than listed again — the domain publishes exactly
// the exclusive namespaces there, in full, and that is what makes the set
// canonical.
//
// A prefix test and not a membership test, on purpose: a project's own
// `type/spike` is still exclusive. Being one of a set of alternatives is a
// property of the namespace, not of the members the taxonomy happens to know.
func exclusiveNamespaces() []string {
var out []string
seen := map[string]bool{}
for _, name := range issue.CanonicalLabels() {
ns, _, ok := strings.Cut(name, "/")
if !ok || seen[ns] {
continue
}
seen[ns] = true
out = append(out, ns+"/")
}
return out
}
// typeMeaning is the description a `type/*` label carries into the tracker, so
// the meaning a reader needs is on the chip rather than in this repository.
// Nothing else gets one: a severity explains itself, and a project's own
// namespaces are not ours to describe.
func typeMeaning(name string) string {
tail, ok := strings.CutPrefix(name, "type/")
if !ok {
return ""
}
for _, t := range issue.Types {
if t.Name == tail {
return t.Meaning
}
}
return ""
}
func hasAnyPrefix(s string, prefixes []string) bool {
for _, p := range prefixes {
if strings.HasPrefix(s, p) {
return true
}
}
return false
}
+95
View File
@@ -0,0 +1,95 @@
package mapping
import (
"regexp"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
var hexColor = regexp.MustCompile(`^#[0-9a-f]{6}$`)
// The canonical set is the domain's, and every member of it must have a color
// here. A type added over in the taxonomy that arrived as grey would look like
// a label somebody created by hand.
func TestEveryCanonicalLabelHasAColor(t *testing.T) {
for _, name := range issue.CanonicalLabels() {
color := LabelColor(name)
switch {
case color == DefaultColor:
t.Errorf("%s has no color of its own", name)
case !hexColor.MatchString(color):
t.Errorf("%s = %q, want #rrggbb in lower case", name, color)
}
}
// And the other direction: a color left behind after a label was retired
// paints nothing and is a lie about what the taxonomy holds.
if len(labelColors) != len(issue.CanonicalLabels()) {
t.Errorf("%d colors for %d canonical labels — one of the two lists moved without the other",
len(labelColors), len(issue.CanonicalLabels()))
}
// Anything outside the set is project-specific and nobody here can guess
// what it means.
if got := LabelColor("tech/sql"); got != DefaultColor {
t.Errorf("LabelColor(tech/sql) = %q, want the default", got)
}
}
func TestLabelSpecs(t *testing.T) {
cases := []struct {
name string
description string
exclusive bool
}{
{"type/bug", "Something behaves incorrectly in existing code", true},
{"type/draft", "Idea captured for later; not ready for work", true},
{"severity/critical", "", true},
// Exclusivity is a property of the namespace, not of the members the
// taxonomy happens to know.
{"type/spike", "", true},
{"tech/sql", "", false},
{"comp/appclick", "", false},
}
names := make([]string, len(cases))
for i, c := range cases {
names[i] = c.name
}
specs := LabelSpecs(names)
if len(specs) != len(cases) {
t.Fatalf("%d specs for %d names", len(specs), len(cases))
}
for i, c := range cases {
got := specs[i]
// The order is the taxonomy's: a bootstrap prints its plan in it, and
// two identical runs must not look like different ones.
if got.Name != c.name {
t.Fatalf("spec %d is %s, want %s", i, got.Name, c.name)
}
if got.Description != c.description {
t.Errorf("%s description = %q, want %q", c.name, got.Description, c.description)
}
if got.Exclusive != c.exclusive {
t.Errorf("%s exclusive = %v, want %v", c.name, got.Exclusive, c.exclusive)
}
if got.Color != LabelColor(c.name) {
t.Errorf("%s color = %q", c.name, got.Color)
}
}
}
func TestCanonicalLabelSpecsAreTheDomainsList(t *testing.T) {
specs := CanonicalLabelSpecs()
want := issue.CanonicalLabels()
if len(specs) != len(want) {
t.Fatalf("%d specs, want %d", len(specs), len(want))
}
for i, name := range want {
if specs[i].Name != name {
t.Errorf("spec %d = %s, want %s", i, specs[i].Name, name)
}
if !specs[i].Exclusive {
t.Errorf("%s must be exclusive — the canonical set IS the exclusive namespaces", name)
}
}
}
+46
View File
@@ -0,0 +1,46 @@
package mapping
import (
"os/exec"
"strings"
"testing"
)
// The bridge translates values and nothing else: no network, no filesystem, no
// clock, no configuration. Every one of those is a caller's to supply, which is
// what lets this package be reasoned about and tested without a Gitea anywhere.
//
// Two imports and no more: internal/issue for what an issue is, and
// internal/wire for the shapes on the other side. wire is allowed precisely
// because it is inert — shapes and identifiers over the standard library, with
// a layering test of its own — so naming a payload here costs nothing and
// reaches nowhere.
//
// DIRECT imports, not the dependency walk internal/issue does. The domain
// reaches os through internal/project and that is the domain's business; what
// this test is about is what this package itself reaches for. A transport that
// grew a helper here — or a lookup that quietly opened a config file — is what
// it catches.
func TestTheBridgeTranslatesAndNothingElse(t *testing.T) {
forbidden := map[string]string{
"net/http": "an HTTP call belongs in the transport",
"net": "an HTTP call belongs in the transport",
"os": "a pure function reads no file and no environment",
"os/exec": "nothing here shells out",
"io/ioutil": "a pure function reads no file",
"time": "the clock is the caller's; a timestamp arrives as a string",
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea": "the transport imports this package, never the reverse",
"git.noodles.cam/claude-skills/marketplace/cli/internal/config": "credentials and repositories are the transport's",
"git.noodles.cam/claude-skills/marketplace/cli/internal/project": "nothing here resolves a path",
}
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
if why, bad := forbidden[dep]; bad {
t.Errorf("mapping imports %s — %s", dep, why)
}
}
}
+106
View File
@@ -0,0 +1,106 @@
// 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 <!-- kettle:id … -->, 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
}
+381
View File
@@ -0,0 +1,381 @@
package mapping
import (
"encoding/json"
"reflect"
"strings"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// The repository the fixtures are pushed to. A wire.Repo and not a string: the
// handle in `gitea:` is a key, and a key is a repository and a number.
var tea = wire.Repo{Owner: "claude-skills", Name: "tea"}
// A file exactly as the store holds it: domain fields, then the sync fields the
// domain carries and never reads.
const stored = `---
id: wire-sqlc-appclick
state: open
labels: [type/task, tech/sql]
assignees: [naudachu]
milestone: v0.2
depends: [migrate-schema]
origin: gitea
branch: feat/wire-sqlc
gitea: claude-skills/tea#42
synced: 2026-08-09T18:40:00Z
---
# Wire sqlc into the appclick repo layer
## Summary
Проводка sqlc в слой репозиториев.
## Spec
none
## Depends on
- #7 — нужна схема БД из этого issue
## Acceptance criteria
- [x] сгенерирован код
- [ ] тесты зелёные
`
func ptr[T any](v T) *T { return &v }
func roundTripOptions() RequestOptions {
return RequestOptions{
LabelIDs: map[string]int64{"type/task": 11, "tech/sql": 12},
MilestoneID: ptr(int64(5)),
IncludeState: true,
}
}
// The whole point of the package in one test: everything the format says is
// preserved comes back, and the body comes back byte for byte.
func TestRoundTripPreservesEveryFieldTheFormatKeeps(t *testing.T) {
local := issue.FromText(stored, "wire-sqlc-appclick")
req := ToRequest(local, roundTripOptions())
if req.Title == nil || *req.Title != local.Title {
t.Errorf("title = %v, want %q", req.Title, local.Title)
}
if req.Body == nil {
t.Fatal("the request carries no body — a create would file an empty issue")
}
if got := IDInBody(*req.Body); got != local.ID {
t.Errorf("the request body does not claim the slug: %q", got)
}
if got := StripIDMarker(*req.Body); got != strings.TrimSpace(local.Body) {
t.Errorf("the prose was rewritten on the way up:\n--- got ---\n%s\n--- want ---\n%s",
got, strings.TrimSpace(local.Body))
}
if want := []int64{11, 12}; req.Labels == nil || !reflect.DeepEqual(*req.Labels, want) {
t.Errorf("labels = %v, want %v", req.Labels, want)
}
if want := []string{"naudachu"}; req.Assignees == nil || !reflect.DeepEqual(*req.Assignees, want) {
t.Errorf("assignees = %v, want %v", req.Assignees, want)
}
if req.Milestone == nil || *req.Milestone != 5 {
t.Errorf("milestone = %v, want 5", req.Milestone)
}
if req.State == nil || *req.State != "open" {
t.Errorf("state = %v", req.State)
}
if req.Ref == nil || *req.Ref != "feat/wire-sqlc" {
t.Errorf("ref = %v — branch: is a sync field and must ride along", req.Ref)
}
// What the tracker hands back is the body it was given, plus its own
// bookkeeping.
echo := &wire.Issue{
Number: 42,
Title: *req.Title,
Body: *req.Body,
State: "open",
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/42",
UpdatedAt: "2026-08-09T18:24:01Z",
Ref: *req.Ref,
Comments: 3,
Labels: []wire.Label{{Name: "type/task"}, {Name: "tech/sql"}},
Assignees: []wire.User{{Login: "naudachu"}},
Milestone: &wire.Milestone{ID: 5, Title: "v0.2"},
}
back, unresolved := FromPayload(echo, local.ID, tea, PayloadOptions{
IDForNumber: map[int]string{7: "migrate-schema"},
Synced: "2026-08-09T18:40:00Z",
})
if len(unresolved) != 0 {
t.Errorf("unresolved = %v, want none", unresolved)
}
if back.Body != strings.TrimSpace(local.Body) {
t.Errorf("the body did not survive the trip:\n--- got ---\n%s\n--- want ---\n%s",
back.Body, strings.TrimSpace(local.Body))
}
if strings.Contains(back.Body, "kettle:id") || strings.Contains(back.Body, "tea:id") {
t.Error("the marker reached the local copy — it is transport bookkeeping and belongs nowhere near disk")
}
for _, c := range []struct{ name, got, want string }{
{"id", back.ID, local.ID},
{"title", back.Title, local.Title},
{"state", back.State, local.State},
{"milestone", back.Milestone, local.Milestone},
{"origin", back.Origin, local.Origin},
{"gitea", back.Extra[GiteaKey], "claude-skills/tea#42"},
{"branch", back.Extra[BranchKey], "feat/wire-sqlc"},
{"synced", back.Extra[SyncedKey], "2026-08-09T18:40:00Z"},
{"url", back.Extra[URLKey], "https://git.noodles.cam/claude-skills/tea/issues/42"},
{"remote-updated", back.Extra[RemoteUpdatedKey], "2026-08-09T18:24:01Z"},
{"comments", back.Extra[CommentsKey], "3"},
} {
if c.got != c.want {
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
}
}
if !reflect.DeepEqual(back.Labels, local.Labels) {
t.Errorf("labels = %v, want %v", back.Labels, local.Labels)
}
if !reflect.DeepEqual(back.Assignees, local.Assignees) {
t.Errorf("assignees = %v, want %v", back.Assignees, local.Assignees)
}
// `depends:` is slugs; the `#7` the prose names is translated at this edge
// and the prose itself is left alone.
if !reflect.DeepEqual(back.Depends, local.Depends) {
t.Errorf("depends = %v, want %v", back.Depends, local.Depends)
}
if !strings.Contains(back.Body, "- #7 — нужна схема БД из этого issue") {
t.Error("the ## Depends on prose was rewritten; it is the author's text and passes through unchanged")
}
// And the strongest form of "no churn": pushing what came back sends
// exactly what was sent the first time.
if again := ToRequest(back, roundTripOptions()); !reflect.DeepEqual(again, req) {
t.Errorf("a second push differs from the first:\n--- again ---\n%+v\n--- first ---\n%+v", again, req)
}
}
// The other shape an issue comes in: nothing scheduled, nobody assigned.
func TestNoMilestoneAndNoAssignees(t *testing.T) {
local := issue.FromText("---\nid: lone\nstate: open\nlabels: [type/task]\n"+
"assignees: []\nmilestone: none\ndepends: []\norigin: local\n---\n"+
"# A lone issue\n\n## Summary\nОдин.\n", "lone")
req := ToRequest(local, RequestOptions{LabelIDs: map[string]int64{"type/task": 11}})
if req.Assignees != nil {
t.Errorf("assignees = %v — an empty list would clear whoever the tracker has", req.Assignees)
}
if req.Milestone != nil {
t.Errorf("milestone = %v — a missing milestone is no opinion, not a detach", req.Milestone)
}
raw, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal: %v", err)
}
body := string(raw)
for _, key := range []string{`"assignees"`, `"milestone"`, `"state"`, `"ref"`} {
if strings.Contains(body, key) {
t.Errorf("%s is in the request body; on a PATCH that overwrites what the tracker holds: %s", key, body)
}
}
// A resolved-but-empty label set is the opposite statement and must be sent.
if !strings.Contains(body, `"labels":[11]`) {
t.Errorf("labels missing from %s", body)
}
empty, err := json.Marshal(ToRequest(local, RequestOptions{LabelIDs: map[string]int64{}}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(empty), `"labels":[]`) {
t.Errorf("a resolved label set that matched nothing must still be sent as []: %s", empty)
}
silent, err := json.Marshal(ToRequest(local, RequestOptions{}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(silent), `"labels"`) {
t.Errorf("a caller that resolved no ids must not clear the tracker's labels: %s", silent)
}
back, unresolved := FromPayload(&wire.Issue{
Number: 9,
Title: "A lone issue",
Body: WithIDMarker("## Summary\nОдин.", "lone"),
State: "open",
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/9",
}, "lone", tea, PayloadOptions{Synced: "2026-08-11T10:00:00Z"})
if len(unresolved) != 0 {
t.Errorf("unresolved = %v", unresolved)
}
if back.Milestone != "" || back.Assignees != nil || back.Labels != nil {
t.Errorf("empty came back as something: milestone=%q assignees=%v labels=%v",
back.Milestone, back.Assignees, back.Labels)
}
if _, ok := back.Extra[BranchKey]; ok {
t.Error("an absent ref must not write an empty branch: field")
}
if _, ok := back.Extra[CommentsKey]; ok {
t.Error("zero comments is not a fact worth a line in the file")
}
if !strings.Contains(back.Text(), "milestone: none") {
t.Error("an empty milestone must render back as none")
}
}
// A dependency whose target is not in the store yet is reported, never invented:
// a made-up slug is an edge to a file that does not exist.
func TestUnresolvedNumbersAreReportedNotInvented(t *testing.T) {
body := "## Summary\nx\n\n## Depends on\n- #7\n- #8\n"
back, unresolved := FromPayload(&wire.Issue{Number: 1, Title: "T", Body: body},
"here", wire.Repo{Owner: "o", Name: "r"}, PayloadOptions{IDForNumber: map[int]string{7: "known"}})
if want := []string{"known"}; !reflect.DeepEqual(back.Depends, want) {
t.Errorf("depends = %v, want %v", back.Depends, want)
}
if want := []int{8}; !reflect.DeepEqual(unresolved, want) {
t.Errorf("unresolved = %v, want %v", unresolved, want)
}
if !strings.Contains(back.Body, "- #8") {
t.Error("the body still names it, which is why dropping it from depends: loses nothing")
}
}
func TestExtraNumbersJoinTheOnesTheBodyNames(t *testing.T) {
body := "## Summary\nx\n\n## Depends on\n- #7\n"
back, _ := FromPayload(&wire.Issue{Number: 1, Title: "T", Body: body}, "here", wire.Repo{Owner: "o", Name: "r"},
PayloadOptions{
IDForNumber: map[int]string{7: "seven", 9: "nine"},
ExtraNumbers: []int{7, 9},
})
if want := []string{"seven", "nine"}; !reflect.DeepEqual(back.Depends, want) {
t.Errorf("depends = %v, want %v", back.Depends, want)
}
}
// The one exception to "a pull overwrites the body", and the narrowest one that
// works: a tick only ever travels one way, so the two sides are a set union.
func TestMergeCheckboxState(t *testing.T) {
cases := []struct {
name string
remote, local string
want string
wantUnchangedRef bool
}{
{
name: "a local tick survives the overwrite",
remote: "- [ ] один\n- [ ] два\n",
local: "- [x] два\n",
want: "- [ ] один\n- [x] два\n",
},
{
name: "rewrapping an item does not cost it its tick",
remote: "- [ ] очень длинный\n пункт\n",
local: "- [x] очень длинный пункт\n",
want: "- [x] очень длинный\n пункт\n",
},
{
name: "the same text twice is the same item to whoever reads it",
remote: "- [ ] дубль\n- [ ] дубль\n",
local: "- [ ] дубль\n- [x] дубль\n",
want: "- [x] дубль\n- [x] дубль\n",
},
{
name: "a first pull has nothing to merge",
remote: "- [ ] один\n",
local: "",
want: "- [ ] один\n",
},
{
name: "unticking is not monotone, so it does not travel",
remote: "- [x] один\n",
local: "- [ ] один\n",
want: "- [x] один\n",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := MergeCheckboxState(c.remote, c.local); got != c.want {
t.Errorf("got:\n%q\nwant:\n%q", got, c.want)
}
})
}
}
// What `gitea:` holds is a key, and it round-trips through the one parser.
// Anything that is not a key reads as "not synced" — never as issue #0, and
// never as the issue -3 a bare strconv.Atoi would have handed back.
func TestRemoteKeyRoundTrip(t *testing.T) {
cases := []struct {
key string
repo string
number int
ok bool
}{
{"claude-skills/tea#42", "claude-skills/tea", 42, true},
{"o/r#1", "o/r", 1, true},
// Never pushed, hand-edited, or written by a format that predates this
// one — all the same answer, and none of them is issue #0. `#42` is in
// the list because a handle without a repository addresses nothing.
{"", "", 0, false},
{"claude-skills/tea", "", 0, false},
{"#42", "", 0, false},
{"o/r#", "", 0, false},
{"o/r#-3", "", 0, false},
{"o/r#4x", "", 0, false},
}
for _, c := range cases {
got, ok := RemoteKeyOf(&issue.Issue{Extra: map[string]string{GiteaKey: c.key}})
if got.Repo.String() != c.repo || got.Number != c.number || ok != c.ok {
t.Errorf("RemoteKeyOf(%q) = (%v, %v), want (%q, %d, %v)",
c.key, got, ok, c.repo, c.number, c.ok)
}
if c.ok && got.String() != c.key {
t.Errorf("the key formatted back as %q, want %q", got, c.key)
}
}
}
func TestNumberOf(t *testing.T) {
synced := &issue.Issue{Extra: map[string]string{GiteaKey: "o/r#42"}}
if n, ok := NumberOf(synced); n != 42 || !ok {
t.Errorf("NumberOf = (%d, %v), want (42, true)", n, ok)
}
if _, ok := NumberOf(&issue.Issue{}); ok {
t.Error("an issue that has never been pushed has no number")
}
}
func TestApplyRemoteStampsTheSyncFields(t *testing.T) {
local := &issue.Issue{ID: "x", Origin: issue.Local}
ApplyRemote(local, &wire.Issue{
Number: 42,
HTMLURL: "https://git.noodles.cam/o/r/issues/42",
UpdatedAt: "2026-08-09T18:24:01Z",
}, wire.Repo{Owner: "o", Name: "r"}, "2026-08-11T10:00:00Z")
if local.IsLocal() {
t.Error("origin must move: the work exists somewhere else now")
}
if local.Extra[GiteaKey] != "o/r#42" || local.Extra[URLKey] == "" ||
local.Extra[SyncedKey] != "2026-08-11T10:00:00Z" ||
local.Extra[RemoteUpdatedKey] != "2026-08-09T18:24:01Z" {
t.Errorf("extra = %v", local.Extra)
}
}
func TestRenderComments(t *testing.T) {
got := RenderComments([]wire.Comment{
{ID: 1, User: wire.User{Login: "naudachu"}, CreatedAt: "2026-08-09T18:24:01Z", Body: " привет "},
{ID: 2, User: wire.User{Login: "bot"}, CreatedAt: "", Body: ""},
})
want := "## comment 1 — naudachu — 2026-08-09\n\nпривет\n\n" +
"## comment 2 — bot — \n\n(empty)\n"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
}
+110
View File
@@ -0,0 +1,110 @@
package mapping
import (
"regexp"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
// The id marker: the slug, kept tracker-side.
//
// Push deletes the local file once the tracker has confirmed the write, so the
// slug — the issue's ONLY identity in the domain — cannot live only on this
// machine any more. It rides up in the body as an HTML comment:
//
// <!-- kettle:id wire-sqlc-appclick -->
//
// Why the body and not a local number -> slug ledger: the ledger is a local
// file, and "the local copy is not the record" is the whole point of deleting
// one. A marker in the body survives a rename in the web UI, a lost ledger, a
// fresh clone, and a second machine — none of which the ledger does. Why an
// HTML comment: Gitea renders markdown, so it is invisible to a human reader,
// and it comes back verbatim on every API read.
//
// WHERE: the first line of the tracker-side body, followed by one blank line.
// First because it is the one position that does not depend on what sections
// the issue happens to have, and because a human who does look at the raw
// markdown finds it before the prose rather than buried in it.
//
// WHAT THE LOCAL FILE SEES: nothing. FromPayload strips every marker before the
// body reaches the store, so `.kettle/issues/<id>.md` holds exactly what the
// author wrote — checkbox line numbers, `kettle check`, and diffs are all
// unaffected, and the slug is already the file's name, so a copy of it in the
// body would be duplicated state.
//
// WHY IT CANNOT ACCUMULATE: the two operations are strip-all and
// strip-all-then-prepend-one. WithIDMarker never appends to what is there, and
// StripIDMarker removes EVERY marker line, not the first. So a body that
// somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on
// the next pull and goes back up with exactly one. There is no code path that
// adds a marker to a body that has not just been stripped.
//
// WHY TWO SPELLINGS ARE READ AND ONE IS WRITTEN: this tool was called `tea`
// and wrote `<!-- tea:id … -->`. Issues pushed under that name are sitting in
// the tracker right now, and their local files are gone — the marker is the
// only copy of their slug there is. A rename that stopped reading the old
// spelling would orphan every one of them: the pull would fall back to the
// title, allocate a fresh slug, and every `depends:` pointing at the old one
// would dangle. So the writer moved and the reader did not.
var markerRe = regexp.MustCompile(
`^[ \t]*<!--[ \t]*(?:kettle|tea):id[ \t]+(\S+)[ \t]*-->[ \t]*$`)
// IDMarker is the marker line for a slug. One place formats it, one regex
// reads it — and what that regex accepts is deliberately wider than this.
func IDMarker(id string) string { return "<!-- kettle:id " + id + " -->" }
// IDInBody is the slug a tracker-side body claims, or "" when it claims none.
//
// The FIRST valid marker wins; a second one is ignored here and removed by
// StripIDMarker on the way in. The captured text must be a slug by the domain's
// own rule — a marker holding anything else is not a slug and is treated as if
// it were not there, so a mangled comment falls back to the title instead of
// naming a file after garbage.
func IDInBody(body string) string {
for _, line := range strings.Split(body, "\n") {
if m := markerRe.FindStringSubmatch(strings.TrimSuffix(line, "\r")); m != nil {
if issue.IsSlug(m[1]) {
return m[1]
}
}
}
return ""
}
// StripIDMarker is body with every marker line removed, in either spelling.
// Idempotent.
//
// A body that carries no marker is returned byte for byte — the common case (an
// issue filed in the web UI) costs nothing and is not reformatted. When a marker
// is removed from the top, the blank line it was written with goes with it, so
// the round trip is exact: StripIDMarker(WithIDMarker(b, id)) == b.
func StripIDMarker(body string) string {
lines := strings.Split(body, "\n")
found := false
for _, line := range lines {
if markerRe.MatchString(strings.TrimSuffix(line, "\r")) {
found = true
break
}
}
if !found {
return body
}
kept := make([]string, 0, len(lines))
for _, line := range lines {
if !markerRe.MatchString(strings.TrimSuffix(line, "\r")) {
kept = append(kept, line)
}
}
return strings.TrimLeft(strings.Join(kept, "\n"), "\n")
}
// WithIDMarker is body with exactly one marker, as its first line.
//
// Strip-then-prepend, always — that is the guarantee that a body can never end
// up with two, however many it arrived with, and it is what quietly rewrites a
// `tea:id` marker into the current spelling the next time the issue is pushed.
func WithIDMarker(body, id string) string {
return IDMarker(id) + "\n\n" + StripIDMarker(body)
}
+104
View File
@@ -0,0 +1,104 @@
package mapping
import (
"strings"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func TestIDInBodyReadsBothSpellings(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"the spelling this tool writes", "<!-- kettle:id wire-sqlc -->\n\n## Summary\nx", "wire-sqlc"},
// The whole reason the reader is wider than the writer.
{"the spelling already in the tracker", "<!-- tea:id wire-sqlc -->\n\n## Summary\nx", "wire-sqlc"},
{"indented and loosely spaced", " <!-- tea:id wire-sqlc --> \n", "wire-sqlc"},
{"no marker at all", "## Summary\nx", ""},
// A mangled comment falls back to the title rather than naming a file
// after garbage.
{"not a slug", "<!-- kettle:id Wire_SQLC -->\n", ""},
{"not on a line of its own", "text <!-- kettle:id wire-sqlc -->\n", ""},
{"the first valid marker wins", "<!-- tea:id first-one -->\n<!-- kettle:id second-one -->\n", "first-one"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := IDInBody(c.body); got != c.want {
t.Errorf("IDInBody = %q, want %q", got, c.want)
}
})
}
}
// An issue pushed under the old name is sitting in the tracker with its local
// file long since deleted — the marker is the only copy of its slug there is.
// It has to keep resolving, and it has to come back up in the new spelling.
func TestAnIssuePushedByTheOldNameStillResolves(t *testing.T) {
const inTracker = "<!-- tea:id wire-sqlc-appclick -->\n\n## Summary\nПроводка sqlc.\n"
id := IDInBody(inTracker)
if id != "wire-sqlc-appclick" {
t.Fatalf("IDInBody = %q — every issue pushed under the old name would be orphaned", id)
}
iss, _ := FromPayload(&wire.Issue{Number: 42, Title: "Wire sqlc", Body: inTracker},
id, tea, PayloadOptions{})
if strings.Contains(iss.Body, "tea:id") {
t.Errorf("the old marker reached the local copy: %q", iss.Body)
}
if iss.Body != "## Summary\nПроводка sqlc." {
t.Errorf("body = %q", iss.Body)
}
// And the next push rewrites it into the current spelling, without ever
// having two.
up := *ToRequest(iss, RequestOptions{}).Body
if !strings.HasPrefix(up, "<!-- kettle:id wire-sqlc-appclick -->\n\n") {
t.Errorf("the marker was not rewritten: %q", up)
}
if strings.Contains(up, "tea:id") {
t.Errorf("both spellings went up: %q", up)
}
if n := strings.Count(up, ":id "); n != 1 {
t.Errorf("%d markers in the body, want 1", n)
}
}
func TestMarkersCannotAccumulate(t *testing.T) {
body := "## Summary\nx"
// Whatever it arrived with — one, the other, several — it goes up with one.
messy := "<!-- tea:id old-one -->\n\n<!-- kettle:id other-one -->\n\n" + body
got := WithIDMarker(messy, "real-one")
if want := IDMarker("real-one") + "\n\n" + body; got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
if got := WithIDMarker(got, "real-one"); strings.Count(got, "<!--") != 1 {
t.Errorf("a second pass added one: %q", got)
}
}
func TestStripIsTheExactInverseOfWith(t *testing.T) {
bodies := []string{
"## Summary\nx",
"## Summary\nx\n\n## Acceptance criteria\n- [ ] один\n",
"",
}
for _, b := range bodies {
if got := StripIDMarker(WithIDMarker(b, "an-id")); got != b {
t.Errorf("StripIDMarker(WithIDMarker(%q)) = %q", b, got)
}
}
}
// The common case — an issue filed in the web UI — costs nothing and is not
// reformatted.
func TestStripLeavesAnUnmarkedBodyByteForByte(t *testing.T) {
body := "\n\n## Summary\nx\n\n\n"
if got := StripIDMarker(body); got != body {
t.Errorf("StripIDMarker rewrote a body with no marker in it: %q", got)
}
}
+97
View File
@@ -0,0 +1,97 @@
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
}
+206
View File
@@ -0,0 +1,206 @@
package project
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Initializing is a statement, and the only one that matters here: *this*
// directory is the project whose issues live in it. It is answered once, by a
// person, and everything downstream reads the answer instead of guessing.
//
// The marker is deliberately something an operator makes, not something
// inferred from the tree: `.git` is in every clone including this repository's
// own, so a plugin that inferred its root from one wrote issues into itself.
// Layouts this has been through, migrated in on init in the order listed —
// oldest first, so a tree that skipped a generation still lands in one place.
//
// Each is a move, never a copy: two stores is the state the marker exists to
// prevent, and a store left behind at an old path is a store somebody will edit
// by accident months later.
var legacy = map[string][]string{
"issues": {
filepath.Join("tmp", "issues"),
filepath.Join(".tea", "issues"),
},
"payload": {
filepath.Join("tmp", "payload"),
filepath.Join(".tea", "payload"),
},
}
// ClashError reports that a migration found the same name on both sides.
//
// Two versions of one issue, and which one survives is not a decision a
// migration gets to make quietly.
type ClashError struct {
Src, Dst string
Names []string
}
func (e *ClashError) Error() string {
names := e.Names
suffix := ""
if len(names) > 5 {
suffix = fmt.Sprintf(" (+%d more)", len(names)-5)
names = names[:5]
}
return fmt.Sprintf("%s and %s both hold %s%s — move or delete one side first; nothing was changed",
e.Src, e.Dst, strings.Join(names, ", "), suffix)
}
// Init makes root a project. Everything it does is idempotent:
//
// - creates .tea/issues/ and .tea/payload/
// - moves an existing tmp/issues/ and tmp/payload/ in, if it finds them
// - adds .tea/ to .gitignore
//
// The move is the migration off the old layout and it is a move, not a copy:
// two stores is the state the marker exists to prevent, and a store left behind
// at the old path is a store somebody will edit by accident.
//
// .tea/ is gitignored because an `origin: local` issue is the only copy of that
// work and the operator, not this command, decides what goes in a shared
// history. Committing the store is a legitimate choice — drop the line if you
// make it.
//
// Returns one line per thing done, for the receipt.
func Init(root string, dryRun bool) ([]string, error) {
var done []string
marker := filepath.Join(root, Marker)
fresh := !isDir(marker)
for _, name := range []string{"issues", "payload"} {
d := filepath.Join(marker, name)
if isDir(d) {
continue
}
if !dryRun {
if err := os.MkdirAll(d, 0o755); err != nil {
return done, err
}
}
done = append(done, "created "+filepath.Join(Marker, name))
}
for _, name := range []string{"issues", "payload"} {
for _, old := range legacy[name] {
src := filepath.Join(root, old)
moved, err := migrate(src, filepath.Join(marker, name), dryRun)
if err != nil {
return done, err
}
switch {
case moved == nil:
// nothing there to migrate
case len(moved) == 0:
done = append(done, old+" was empty — nothing to move")
default:
done = append(done, fmt.Sprintf("moved %d file(s) from %s to %s",
len(moved), old, filepath.Join(Marker, name)))
}
}
}
// The old marker goes only when the migration emptied it — anything else
// parked in there is somebody's, and this is not the command that decides
// what.
if !dryRun {
os.Remove(filepath.Join(root, ".tea"))
}
added, err := addToGitignore(filepath.Join(root, ".gitignore"), Marker+"/", dryRun)
if err != nil {
return done, err
}
if added {
done = append(done, "added "+Marker+"/ to .gitignore")
}
switch {
case len(done) == 0:
done = append(done, "already initialized — nothing to do")
case fresh:
done = append(done, fmt.Sprintf("%s now tracks issues in %s/issues", root, Marker))
}
return done, nil
}
// migrate moves the CONTENTS of src into dst — contents, not the directory, so
// an already-created destination is not a reason to refuse. Returns the names
// moved, or nil when there was nothing to migrate.
func migrate(src, dst string, dryRun bool) ([]string, error) {
if !isDir(src) {
return nil, nil
}
entries, err := os.ReadDir(src)
if err != nil {
return nil, err
}
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
sort.Strings(names)
if len(names) == 0 {
return []string{}, nil
}
var clashes []string
for _, n := range names {
if _, err := os.Lstat(filepath.Join(dst, n)); err == nil {
clashes = append(clashes, n)
}
}
if len(clashes) > 0 {
return nil, &ClashError{Src: src, Dst: dst, Names: clashes}
}
if dryRun {
return names, nil
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return nil, err
}
for _, n := range names {
if err := os.Rename(filepath.Join(src, n), filepath.Join(dst, n)); err != nil {
return nil, err
}
}
os.Remove(src) // only succeeds when we emptied it, which is the intent
return names, nil
}
// addToGitignore appends entry unless some line already ignores it.
func addToGitignore(path, entry string, dryRun bool) (bool, error) {
var lines []string
if raw, err := os.ReadFile(path); err == nil {
lines = strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
} else if !os.IsNotExist(err) {
return false, err
}
want := strings.TrimSuffix(entry, "/")
for _, line := range lines {
if strings.TrimSuffix(strings.TrimSpace(line), "/") == want {
return false, nil
}
}
if dryRun {
return true, nil
}
trailer := "\n"
if len(lines) == 0 || lines[len(lines)-1] == "" {
trailer = ""
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return false, err
}
defer f.Close()
if _, err := f.WriteString(trailer + entry + "\n"); err != nil {
return false, err
}
return true, nil
}
+245
View File
@@ -0,0 +1,245 @@
// Package project answers one question: which directory is the project.
//
// Everything that is a fact about the project — the issue store, the request
// payload scratchpad, the tracker the issues belong to — is resolved from the
// answer, and the answer is found by one walk written once. The guard, the
// transport and the store used to each have their own copy of that walk in
// Python, and they disagreed: in a linked worktree `tea` worked while every
// script reported no login pinned.
//
// This package depends on nothing but the standard library, and nothing in it
// resolves from the binary's own location. Where an installation keeps its
// files is a fact about the installation; which issues a tree has is a fact
// about the tree, and a binary installed in one place and pointed at another
// must answer from the one it was pointed at.
package project
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Marker is the directory an operator creates to state "this is a project".
// It is never inferred. `.git` was tried and is in every clone, including this
// repository's own, so a plugin resolved its store inside itself.
const Marker = ".kettle"
// Everything under the marker, each resolved by the same walk so that which
// command wrote a file cannot change where it landed.
var (
storeParts = []string{Marker, "issues"}
payloadParts = []string{Marker, "payload"}
configParts = []string{Marker, "config.yaml"}
)
// Anchors are the directories a root search starts from, in order, first hit
// wins: the project the agent harness was opened on, then the working
// directory. A non-empty start overrides both and exists so resolution can be
// exercised against a scratch tree.
func Anchors(start string) []string {
if start != "" {
abs, err := filepath.Abs(start)
if err != nil {
return nil
}
return []string{abs}
}
var out []string
for _, d := range []string{os.Getenv("CLAUDE_PROJECT_DIR"), cwd()} {
if d == "" || !isDir(d) {
continue
}
abs, err := filepath.Abs(d)
if err != nil {
continue
}
if !contains(out, abs) {
out = append(out, abs)
}
}
return out
}
// Parents yields start and every ancestor of it, up to the filesystem root.
func Parents(start string) []string {
d, err := filepath.Abs(start)
if err != nil {
return nil
}
var out []string
for {
out = append(out, d)
parent := filepath.Dir(d)
if parent == d {
return out
}
d = parent
}
}
// GitDirOf is the private git directory `d/.git` points at, or "".
//
// Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a directory
// and there is nothing to follow.
func GitDirOf(d string) string {
p := filepath.Join(d, ".git")
fi, err := os.Stat(p)
if err != nil || fi.IsDir() {
return ""
}
head, err := os.ReadFile(p)
if err != nil {
return ""
}
for _, line := range strings.Split(string(head), "\n") {
line = strings.TrimSpace(line)
target, ok := strings.CutPrefix(line, "gitdir:")
if !ok {
continue
}
target = strings.TrimSpace(target)
if target == "" {
return ""
}
if !filepath.IsAbs(target) {
target = filepath.Join(d, target)
}
abs, err := filepath.Abs(target)
if err != nil {
return ""
}
return abs
}
return ""
}
// MainWorktree is the main working tree of d's repository when d is a linked
// worktree, or "".
//
// `<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir` file
// holds a path to `<main>/.git`; the main working tree is its parent. The
// `.git` basename check keeps this to worktrees: a submodule's `.git` is a
// pointer too, but it points into `<super>/.git/modules/…`, and the tree it
// belongs to is already on the parent chain.
func MainWorktree(d string) string {
gitdir := GitDirOf(d)
if gitdir == "" || !isDir(gitdir) {
return ""
}
common := gitdir
if raw, err := os.ReadFile(filepath.Join(gitdir, "commondir")); err == nil {
if rel := strings.TrimSpace(string(raw)); rel != "" {
if abs, err := filepath.Abs(filepath.Join(gitdir, rel)); err == nil {
common = abs
}
}
}
if filepath.Base(common) != ".git" {
return ""
}
root := filepath.Dir(common)
abs, err := filepath.Abs(d)
if err != nil {
return ""
}
if root != "" && isDir(root) && root != abs {
return root
}
return ""
}
// Root is the nearest ancestor of an anchor (inclusive) holding the marker, or
// "" when there is no project.
//
// A marker, not a fixed number of `..` hops: how deep a caller sits below the
// root is an implementation detail of the layout, and the layout is not a
// promise. Walking up means every command sees one store from anywhere inside
// the project — including from inside the store itself — while a cd into a
// DIFFERENT project correctly answers with that project's store.
//
// A linked worktree is the same project on another branch, and the marker is
// gitignored, so it is only ever in the main checkout: the chain is searched
// first and always wins, then the main working tree of any worktree met on it.
func Root(start string) string {
for _, anchor := range Anchors(start) {
var hops []string
for _, d := range Parents(anchor) {
if isDir(filepath.Join(d, Marker)) {
return d
}
if main := MainWorktree(d); main != "" && !contains(hops, main) {
hops = append(hops, main)
}
}
// One level of indirection, never two: a main checkout is not itself a
// linked worktree, so this cannot chain and cannot cycle.
for _, hop := range hops {
for _, d := range Parents(hop) {
if isDir(filepath.Join(d, Marker)) {
return d
}
}
}
}
return ""
}
// StoreRoot is the absolute path of the issue store, or "" with no project.
func StoreRoot(start string) string { return under(start, storeParts) }
// PayloadRoot is the absolute path of the request-payload scratchpad, or "".
//
// A sibling of the store under the same marker, resolved by the same walk, so
// the scratchpad and the store can never end up in two different projects —
// and so a scratchpad can never sit INSIDE a store, where a call that touched
// no issue would still materialize the issue directory.
func PayloadRoot(start string) string { return under(start, payloadParts) }
// ConfigPath is the absolute path of the project's tracker config, or "".
func ConfigPath(start string) string { return under(start, configParts) }
func under(start string, parts []string) string {
root := Root(start)
if root == "" {
return ""
}
return filepath.Join(append([]string{root}, parts...)...)
}
// NotFoundError explains why no project could be resolved, naming every
// directory the search began from.
//
// The anchors, not the whole chain above them: an operator who sees the two
// places the search started knows immediately whether it started where they
// meant it to.
func NotFoundError(start string) error {
dirs := strings.Join(Anchors(start), " and ")
if dirs == "" {
dirs = "nowhere"
}
return fmt.Errorf("no %s/ found — searched up from %s. Run `kettle init` in the project you mean to track issues in", Marker, dirs)
}
func isDir(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
func cwd() string {
d, err := os.Getwd()
if err != nil {
return ""
}
return d
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
+160
View File
@@ -0,0 +1,160 @@
package project
import (
"os"
"path/filepath"
"testing"
)
// Every test here strips CLAUDE_PROJECT_DIR: it is the first anchor of the
// walk, so the harness's own value would point every fixture at whatever
// repository the suite happens to run in.
func fixture(t *testing.T) string {
t.Helper()
t.Setenv("CLAUDE_PROJECT_DIR", "")
dir := t.TempDir()
// macOS hands out /var/… , a symlink to /private/var, and the walk works
// in resolved paths.
real, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
return real
}
func chdir(t *testing.T, dir string) {
t.Helper()
t.Chdir(dir)
}
func TestRootFindsTheNearestMarkerUpFromTheWorkingDirectory(t *testing.T) {
root := fixture(t)
deep := filepath.Join(root, "a", "b", "c")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, Marker), 0o755); err != nil {
t.Fatal(err)
}
chdir(t, deep)
if got := Root(""); got != root {
t.Errorf("Root() = %q, want %q", got, root)
}
if got := StoreRoot(""); got != filepath.Join(root, Marker, "issues") {
t.Errorf("StoreRoot() = %q", got)
}
// The scratchpad is a sibling of the store, never inside it: a call that
// touches no issue must not materialize the issue directory.
if got := PayloadRoot(""); got != filepath.Join(root, Marker, "payload") {
t.Errorf("PayloadRoot() = %q", got)
}
}
func TestNoMarkerIsAnAnswerNotAFallback(t *testing.T) {
dir := fixture(t)
chdir(t, dir)
if got := Root(""); got != "" {
t.Errorf("Root() = %q, want empty — a plausible-looking directory is the failure this replaces", got)
}
if got := StoreRoot(""); got != "" {
t.Errorf("StoreRoot() = %q, want empty", got)
}
if err := NotFoundError(""); err == nil {
t.Fatal("NotFoundError must explain itself")
}
}
func TestTheNearestMarkerWinsOverAnAncestorOne(t *testing.T) {
outer := fixture(t)
inner := filepath.Join(outer, "vendored")
for _, d := range []string{filepath.Join(outer, Marker), filepath.Join(inner, Marker)} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
chdir(t, inner)
if got := Root(""); got != inner {
t.Errorf("Root() = %q, want the nearest marker %q", got, inner)
}
}
// A linked worktree is a SIBLING of the main checkout, so the gitignored
// marker is never on its parent chain. The whole sync layer once died there
// while the tracker CLI in the same directory worked.
func TestALinkedWorktreeResolvesToTheMainCheckout(t *testing.T) {
base := fixture(t)
main := filepath.Join(base, "repo")
wt := filepath.Join(base, "repo-feat")
gitdir := filepath.Join(main, ".git", "worktrees", "feat")
for _, d := range []string{filepath.Join(main, Marker), gitdir, wt} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
// <worktree>/.git points at the private dir; commondir points back at
// <main>/.git, whose parent is the main working tree.
write(t, filepath.Join(wt, ".git"), "gitdir: "+gitdir+"\n")
write(t, filepath.Join(gitdir, "commondir"), "../..\n")
chdir(t, wt)
if got := Root(""); got != main {
t.Errorf("Root() = %q, want the main checkout %q", got, main)
}
}
// A submodule's .git is a pointer too, but it points into
// <super>/.git/modules/…, and the tree it belongs to is already on the parent
// chain. Following it would be a hop to nowhere.
func TestASubmoduleIsNotAWorktree(t *testing.T) {
base := fixture(t)
sub := filepath.Join(base, "super", "sub")
gitdir := filepath.Join(base, "super", ".git", "modules", "sub")
for _, d := range []string{sub, gitdir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
write(t, filepath.Join(sub, ".git"), "gitdir: "+gitdir+"\n")
if got := MainWorktree(sub); got != "" {
t.Errorf("MainWorktree() = %q, want empty", got)
}
}
func TestAnOrdinaryCloneHasNothingToFollow(t *testing.T) {
dir := fixture(t)
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if got := GitDirOf(dir); got != "" {
t.Errorf("GitDirOf() = %q — only a .git FILE is a pointer", got)
}
}
func TestClaudeProjectDirIsTheFirstAnchor(t *testing.T) {
base := fixture(t)
opened := filepath.Join(base, "opened")
elsewhere := filepath.Join(base, "elsewhere")
for _, d := range []string{filepath.Join(opened, Marker), filepath.Join(elsewhere, Marker)} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("CLAUDE_PROJECT_DIR", opened)
chdir(t, elsewhere)
if got := Root(""); got != opened {
t.Errorf("Root() = %q, want the opened project %q", got, opened)
}
}
func write(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
+103
View File
@@ -0,0 +1,103 @@
package wire
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// Repo is one repository, spelled the way a tracker spells it.
type Repo struct {
Owner string
Name string
}
func (r Repo) String() string {
if r.Zero() {
return ""
}
return r.Owner + "/" + r.Name
}
// Zero reports whether this names no repository. Both halves are required:
// half a name addresses nothing.
func (r Repo) Zero() bool { return r.Owner == "" || r.Name == "" }
// ParseRepo reads owner/name.
func ParseRepo(s string) (Repo, error) {
owner, name, ok := strings.Cut(strings.TrimSpace(s), "/")
if !ok || owner == "" || name == "" {
return Repo{}, fmt.Errorf("repo %q is not owner/name", s)
}
return Repo{Owner: owner, Name: name}, nil
}
// Key is a stable cross-repo handle for one issue: owner/repo#42.
//
// It is what the ledger is keyed by and what the `gitea:` metadata field holds,
// so it has to survive being written to a file and read back — which is why it
// is a repository and a number and not a bare number. A number is ambiguous the
// moment a dependency lives in another repository, and dependencies are allowed
// to.
type Key struct {
// Repo is zero when the caller named a number and nothing else, which is
// the common case on a command line: "42" means "42 in this project's
// repository", and which repository that is, is the client's business.
Repo Repo
Number int
}
func (k Key) String() string {
if k.Repo.Zero() {
return "#" + strconv.Itoa(k.Number)
}
return fmt.Sprintf("%s#%d", k.Repo, k.Number)
}
// In returns this key with r filled in when it names no repository of its own.
func (k Key) In(r Repo) Key {
if k.Repo.Zero() {
k.Repo = r
}
return k
}
// The four spellings, as patterns.
//
// Digits and only digits after the `#`, which is the test strconv.Atoi is too
// generous to make on its own: it accepts a sign, and `owner/repo#-3` is not a
// handle anybody ever wrote. Anything that is not a key has to be recognizable
// as not a key — a hand-edited metadata line and a number are told apart here
// and nowhere else.
var (
keyURL = regexp.MustCompile(`^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$`)
keyQualified = regexp.MustCompile(`^([\w.-]+/[\w.-]+)#(\d+)$`)
keyNumber = regexp.MustCompile(`^#?(\d+)$`)
)
// ParseKey reads an issue key: 42, #42, owner/repo#42, or the issue's URL.
//
// All four spellings because all four are what somebody has in hand — a number
// from a receipt, a `#42` copied out of a body, a qualified key out of the
// ledger, a URL pasted from a browser. Refusing three of them buys nothing.
func ParseKey(s string) (Key, error) {
s = strings.TrimSpace(s)
if m := keyURL.FindStringSubmatch(s); m != nil {
n, _ := strconv.Atoi(m[3])
return Key{Repo: Repo{Owner: m[1], Name: m[2]}, Number: n}, nil
}
if m := keyQualified.FindStringSubmatch(s); m != nil {
repo, err := ParseRepo(m[1])
if err != nil {
return Key{}, err
}
n, _ := strconv.Atoi(m[2])
return Key{Repo: repo, Number: n}, nil
}
if m := keyNumber.FindStringSubmatch(s); m != nil {
n, _ := strconv.Atoi(m[1])
return Key{Number: n}, nil
}
return Key{}, fmt.Errorf("cannot parse issue key %q — want 42, #42, owner/repo#42, or an issue URL", s)
}
+47
View File
@@ -0,0 +1,47 @@
package wire_test
import (
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func TestParseKey(t *testing.T) {
acme := wire.Repo{Owner: "acme", Name: "widgets"}
for _, tc := range []struct {
in string
want wire.Key
}{
{"42", wire.Key{Number: 42}},
{"#42", wire.Key{Number: 42}},
{" acme/widgets#42 ", wire.Key{Repo: acme, Number: 42}},
{"https://git.example.test/acme/widgets/issues/42", wire.Key{Repo: acme, Number: 42}},
{"https://git.example.test/acme/widgets/issues/42/", wire.Key{Repo: acme, Number: 42}},
} {
got, err := wire.ParseKey(tc.in)
if err != nil {
t.Errorf("ParseKey(%q): %v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("ParseKey(%q) = %v, want %v", tc.in, got, tc.want)
}
}
// What is not a key has to be refused as one. `o/r#-3` is the case a bare
// strconv.Atoi accepts and nobody ever wrote: a key read back out of a
// metadata line somebody hand-edited must come back as "not a key", never
// as issue -3.
for _, bad := range []string{"not an issue", "o/r#-3", "o/r#4x", "o/r#", "o/r", ""} {
if got, err := wire.ParseKey(bad); err == nil {
t.Errorf("ParseKey(%q) = %v, want a refusal", bad, got)
}
}
if got := (wire.Key{Repo: acme, Number: 42}).String(); got != "acme/widgets#42" {
t.Errorf("a qualified key formatted as %q", got)
}
if got := (wire.Key{Number: 42}).In(acme).String(); got != "acme/widgets#42" {
t.Errorf("an unqualified key filled in as %q", got)
}
}
+61
View File
@@ -0,0 +1,61 @@
package wire
import (
"os/exec"
"strings"
"testing"
)
// The protocol is shared by two layers that may not import each other, and it
// can only be shared because it reaches for nothing itself: no domain, no
// configuration, no path resolution, no third party. One import from any of
// those would drag every user of this package into that layer — which is the
// whole reason these shapes were lifted out of the transport rather than left
// there for the bridge to reimplement.
//
// The dependency walk, so a helper pulled in three packages deep is caught as
// the same violation as one written at the top of a file.
func TestWireDependsOnNothing(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
if dep == "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" {
continue
}
// A standard-library import path has no dot in its first element,
// because it has no domain name in front of it.
first, _, _ := strings.Cut(dep, "/")
if strings.Contains(first, ".") {
t.Errorf("the protocol imports %s — these are shapes and identifiers, and nothing else belongs here", dep)
}
}
}
// The other half: net/http and os are standard library, so "no third-party
// imports" would not catch a transport or a file read written by hand here.
// Name them.
//
// DIRECT imports, not the dependency walk — fmt reaches os on its own, and the
// question this asks is what THIS package reaches for.
func TestWireReachesNeitherTheNetworkNorTheDisk(t *testing.T) {
forbidden := map[string]string{
"net/http": "an HTTP call belongs in the transport",
"net": "an HTTP call belongs in the transport",
"os": "a shape reads no file and no environment",
"os/exec": "nothing here shells out",
"io": "nothing here is a stream",
"time": "a timestamp crosses as the string the tracker sent",
}
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
if why, bad := forbidden[dep]; bad {
t.Errorf("wire imports %s — %s", dep, why)
}
}
}
+59
View File
@@ -0,0 +1,59 @@
package wire
// The bodies that go up, and the shorthand that fills them.
//
// The omitted keys carry meaning of their own on a PATCH: a key that is absent
// leaves the tracker's value alone, and a key that is present overwrites it. So
// "no opinion" and "empty" must not marshal the same way, which is what every
// pointer and every omitempty below is for.
// IssueRequest is the body of a create or an edit.
//
// Every field is a pointer because Gitea reads an absent key 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 `labels`
// clears its labels. A caller meaning to change only the state would do both by
// accident with plain zero values. Set fills a field; leaving it nil leaves the
// tracker's copy alone.
type IssueRequest struct {
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
// Labels is a pointer because `[]` is a statement — it clears every label
// on the issue — while a caller that has not resolved label ids at all has
// no business making it. A plain slice with omitempty cannot say both.
Labels *[]int64 `json:"labels,omitempty"`
Assignees *[]string `json:"assignees,omitempty"`
// Milestone is a pointer for the same reason, and because 0 is Gitea's
// "detach from its milestone" — a value somebody may well mean.
Milestone *int64 `json:"milestone,omitempty"`
State *string `json:"state,omitempty"`
Ref *string `json:"ref,omitempty"`
}
// LabelRequest is the body of a label create or edit — everything a repository
// needs to make one label.
//
// Value fields, not pointers, and every one of them is sent: Gitea 1.26 patches
// only what it is given, but an older server reads an absent field as empty and
// blanks it. A label edit is rare enough that sending the unchanged name and
// description along costs nothing and removes a way to lose them.
//
// It goes up as a request body of its own because `tea labels create` could not
// set `exclusive` — the flag that makes `type/*` behave like a single choice —
// which is the whole reason label creation went through the API rather than a
// CLI wrapper.
//
// What a label MEANS — which namespaces are exclusive, what colour a severity
// is — is not decided here. This is the shape; the taxonomy is the domain's and
// the palette is the bridge's.
type LabelRequest struct {
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Exclusive bool `json:"exclusive"`
}
// Set is a pointer to v, for filling the optional fields of a request. Gitea
// reads an absent key as "no opinion" and a present one as "make it this", so
// those fields are pointers and this is the shorthand that fills them.
func Set[T any](v T) *T { return &v }
+155
View File
@@ -0,0 +1,155 @@
// Package wire is the protocol: the JSON shapes a Gitea instance sends and
// takes, the identifiers that address them, and nothing else.
//
// It is a package because two layers need the same vocabulary and neither may
// import the other. internal/gitea is the transport — HTTP verbs, pagination,
// status codes, credentials — and internal/mapping is the bridge — md <-> JSON,
// pure functions, no network. Both have to name a Gitea issue, and when each
// named it with a struct of its own, every command written on top of the two
// would have had to copy a payload field by field from one spelling into the
// other. Two copies of a shape also drift: the first field only one of them
// learns is a field the other silently drops.
//
// THIS PACKAGE IMPORTS THE STANDARD LIBRARY AND NOTHING ELSE — no HTTP, no
// filesystem, no configuration, and above all not internal/issue. That is what
// lets the transport and the bridge share it without either one landing inside
// the other's layer, and layering_test.go fails the moment it stops being true.
//
// Structs and not map[string]any, because the two representations disagreeing
// is the failure this vocabulary exists to make debuggable: a typo in a key is
// a compile error here and a silently dropped field there. Anything Gitea sends
// that is not named below is not read by anybody — decoding is lossy on
// purpose, since the tracker is not the record for anything the domain owns.
package wire
// User is whoever wrote or was assigned something.
//
// 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. A transport
// that carries the rest invites somebody to use it.
type User struct {
Login string `json:"login"`
}
// Label as the tracker holds it.
//
// Color is hex. Gitea returns it without the leading `#` (`ee0701`) and accepts
// it either way; both spellings are the same color, so a comparison has to
// strip before it compares.
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Exclusive bool `json:"exclusive"`
}
// Milestone as the tracker holds it. The domain carries its title; the id
// exists only long enough to be sent back.
type Milestone struct {
ID int64 `json:"id"`
Title string `json:"title"`
State string `json:"state"`
Description string `json:"description"`
}
// RepoRef is the repository an issue payload says it belongs to. Present on a
// dependency listing, where the answer may well be another repository.
type RepoRef struct {
Owner string `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"`
}
// PullRequest is non-nil on a row that is a pull request rather than an issue.
// Gitea's issue endpoints return both, and `type=issues` is a filter the server
// has been known to ignore — which is why every listing re-checks it.
type PullRequest struct {
Merged bool `json:"merged"`
HTMLURL string `json:"html_url"`
}
// Issue is a tracker row: a Gitea issue as the API reports it.
//
// Timestamps stay strings. They are written into an issue's metadata verbatim
// and compared as opaque values; parsing them here would mean formatting them
// back, and a round trip through a time package is a chance to hand the store a
// different string than the tracker sent.
type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
// Ref is the branch the issue is pinned to.
Ref string `json:"ref"`
// HTMLURL and UpdatedAt are the tracker's own bookkeeping and land in the
// domain's Extra untouched.
HTMLURL string `json:"html_url"`
// Comments is a count, not a thread: the thread is fetched separately and
// parked beside the issue as a sidecar.
Comments int `json:"comments"`
Labels []Label `json:"labels"`
Assignees []User `json:"assignees"`
Milestone *Milestone `json:"milestone"`
Repository *RepoRef `json:"repository"`
PullRequest *PullRequest `json:"pull_request"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// IsPullRequest reports whether this row is a pull request.
func (i *Issue) IsPullRequest() bool { return i.PullRequest != nil }
// LabelNames are the label names, in the order the tracker listed them.
func (i *Issue) LabelNames() []string {
out := make([]string, 0, len(i.Labels))
for _, l := range i.Labels {
out = append(out, l.Name)
}
return out
}
// AssigneeLogins are the assignees, as logins.
func (i *Issue) AssigneeLogins() []string {
out := make([]string, 0, len(i.Assignees))
for _, a := range i.Assignees {
out = append(out, a.Login)
}
return out
}
// MilestoneTitle is the milestone's title, or "" when there is none.
func (i *Issue) MilestoneTitle() string {
if i.Milestone == nil {
return ""
}
return i.Milestone.Title
}
// KeyIn is this issue's cross-repo handle. The payload's own repository wins
// when it carries one — a dependency listing answers with issues from other
// repositories — and fallback is the repository that was asked.
func (i *Issue) KeyIn(fallback Repo) Key {
repo := fallback
if i.Repository != nil {
if r, err := ParseRepo(i.Repository.FullName); err == nil {
repo = r
}
}
return Key{Repo: repo, Number: i.Number}
}
// Comment is one entry in an issue's thread.
//
// Read only, in practice: a thread is flattened to markdown for a reader and
// nothing writes that markdown back, which is why the rendering may be as lossy
// as a reader needs.
type Comment struct {
ID int64 `json:"id"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
User User `json:"user"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
+50
View File
@@ -0,0 +1,50 @@
This project is covered by two different licenses: MIT and Apache.
#### MIT License ####
The following files were ported to Go from C files of libyaml, and thus
are still covered by their original MIT license, with the additional
copyright staring in 2011 when the project was ported over:
apic.go emitterc.go parserc.go readerc.go scannerc.go
writerc.go yamlh.go yamlprivateh.go
Copyright (c) 2006-2010 Kirill Simonov
Copyright (c) 2006-2011 Kirill Simonov
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
### Apache License ###
All the remaining project files are covered by the Apache license:
Copyright (c) 2011-2019 Canonical Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+13
View File
@@ -0,0 +1,13 @@
Copyright 2011-2016 Canonical Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+150
View File
@@ -0,0 +1,150 @@
# YAML support for the Go language
Introduction
------------
The yaml package enables Go programs to comfortably encode and decode YAML
values. It was developed within [Canonical](https://www.canonical.com) as
part of the [juju](https://juju.ubuntu.com) project, and is based on a
pure Go port of the well-known [libyaml](http://pyyaml.org/wiki/LibYAML)
C library to parse and generate YAML data quickly and reliably.
Compatibility
-------------
The yaml package supports most of YAML 1.2, but preserves some behavior
from 1.1 for backwards compatibility.
Specifically, as of v3 of the yaml package:
- YAML 1.1 bools (_yes/no, on/off_) are supported as long as they are being
decoded into a typed bool value. Otherwise they behave as a string. Booleans
in YAML 1.2 are _true/false_ only.
- Octals encode and decode as _0777_ per YAML 1.1, rather than _0o777_
as specified in YAML 1.2, because most parsers still use the old format.
Octals in the _0o777_ format are supported though, so new files work.
- Does not support base-60 floats. These are gone from YAML 1.2, and were
actually never supported by this package as it's clearly a poor choice.
and offers backwards
compatibility with YAML 1.1 in some cases.
1.2, including support for
anchors, tags, map merging, etc. Multi-document unmarshalling is not yet
implemented, and base-60 floats from YAML 1.1 are purposefully not
supported since they're a poor design and are gone in YAML 1.2.
Installation and usage
----------------------
The import path for the package is *gopkg.in/yaml.v3*.
To install it, run:
go get gopkg.in/yaml.v3
API documentation
-----------------
If opened in a browser, the import path itself leads to the API documentation:
- [https://gopkg.in/yaml.v3](https://gopkg.in/yaml.v3)
API stability
-------------
The package API for yaml v3 will remain stable as described in [gopkg.in](https://gopkg.in).
License
-------
The yaml package is licensed under the MIT and Apache License 2.0 licenses.
Please see the LICENSE file for details.
Example
-------
```Go
package main
import (
"fmt"
"log"
"gopkg.in/yaml.v3"
)
var data = `
a: Easy!
b:
c: 2
d: [3, 4]
`
// Note: struct fields must be public in order for unmarshal to
// correctly populate the data.
type T struct {
A string
B struct {
RenamedC int `yaml:"c"`
D []int `yaml:",flow"`
}
}
func main() {
t := T{}
err := yaml.Unmarshal([]byte(data), &t)
if err != nil {
log.Fatalf("error: %v", err)
}
fmt.Printf("--- t:\n%v\n\n", t)
d, err := yaml.Marshal(&t)
if err != nil {
log.Fatalf("error: %v", err)
}
fmt.Printf("--- t dump:\n%s\n\n", string(d))
m := make(map[interface{}]interface{})
err = yaml.Unmarshal([]byte(data), &m)
if err != nil {
log.Fatalf("error: %v", err)
}
fmt.Printf("--- m:\n%v\n\n", m)
d, err = yaml.Marshal(&m)
if err != nil {
log.Fatalf("error: %v", err)
}
fmt.Printf("--- m dump:\n%s\n\n", string(d))
}
```
This example will generate the following output:
```
--- t:
{Easy! {2 [3 4]}}
--- t dump:
a: Easy!
b:
c: 2
d: [3, 4]
--- m:
map[a:Easy! b:map[c:2 d:[3 4]]]
--- m dump:
a: Easy!
b:
c: 2
d:
- 3
- 4
```
+747
View File
@@ -0,0 +1,747 @@
//
// Copyright (c) 2011-2019 Canonical Ltd
// Copyright (c) 2006-2010 Kirill Simonov
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package yaml
import (
"io"
)
func yaml_insert_token(parser *yaml_parser_t, pos int, token *yaml_token_t) {
//fmt.Println("yaml_insert_token", "pos:", pos, "typ:", token.typ, "head:", parser.tokens_head, "len:", len(parser.tokens))
// Check if we can move the queue at the beginning of the buffer.
if parser.tokens_head > 0 && len(parser.tokens) == cap(parser.tokens) {
if parser.tokens_head != len(parser.tokens) {
copy(parser.tokens, parser.tokens[parser.tokens_head:])
}
parser.tokens = parser.tokens[:len(parser.tokens)-parser.tokens_head]
parser.tokens_head = 0
}
parser.tokens = append(parser.tokens, *token)
if pos < 0 {
return
}
copy(parser.tokens[parser.tokens_head+pos+1:], parser.tokens[parser.tokens_head+pos:])
parser.tokens[parser.tokens_head+pos] = *token
}
// Create a new parser object.
func yaml_parser_initialize(parser *yaml_parser_t) bool {
*parser = yaml_parser_t{
raw_buffer: make([]byte, 0, input_raw_buffer_size),
buffer: make([]byte, 0, input_buffer_size),
}
return true
}
// Destroy a parser object.
func yaml_parser_delete(parser *yaml_parser_t) {
*parser = yaml_parser_t{}
}
// String read handler.
func yaml_string_read_handler(parser *yaml_parser_t, buffer []byte) (n int, err error) {
if parser.input_pos == len(parser.input) {
return 0, io.EOF
}
n = copy(buffer, parser.input[parser.input_pos:])
parser.input_pos += n
return n, nil
}
// Reader read handler.
func yaml_reader_read_handler(parser *yaml_parser_t, buffer []byte) (n int, err error) {
return parser.input_reader.Read(buffer)
}
// Set a string input.
func yaml_parser_set_input_string(parser *yaml_parser_t, input []byte) {
if parser.read_handler != nil {
panic("must set the input source only once")
}
parser.read_handler = yaml_string_read_handler
parser.input = input
parser.input_pos = 0
}
// Set a file input.
func yaml_parser_set_input_reader(parser *yaml_parser_t, r io.Reader) {
if parser.read_handler != nil {
panic("must set the input source only once")
}
parser.read_handler = yaml_reader_read_handler
parser.input_reader = r
}
// Set the source encoding.
func yaml_parser_set_encoding(parser *yaml_parser_t, encoding yaml_encoding_t) {
if parser.encoding != yaml_ANY_ENCODING {
panic("must set the encoding only once")
}
parser.encoding = encoding
}
// Create a new emitter object.
func yaml_emitter_initialize(emitter *yaml_emitter_t) {
*emitter = yaml_emitter_t{
buffer: make([]byte, output_buffer_size),
raw_buffer: make([]byte, 0, output_raw_buffer_size),
states: make([]yaml_emitter_state_t, 0, initial_stack_size),
events: make([]yaml_event_t, 0, initial_queue_size),
best_width: -1,
}
}
// Destroy an emitter object.
func yaml_emitter_delete(emitter *yaml_emitter_t) {
*emitter = yaml_emitter_t{}
}
// String write handler.
func yaml_string_write_handler(emitter *yaml_emitter_t, buffer []byte) error {
*emitter.output_buffer = append(*emitter.output_buffer, buffer...)
return nil
}
// yaml_writer_write_handler uses emitter.output_writer to write the
// emitted text.
func yaml_writer_write_handler(emitter *yaml_emitter_t, buffer []byte) error {
_, err := emitter.output_writer.Write(buffer)
return err
}
// Set a string output.
func yaml_emitter_set_output_string(emitter *yaml_emitter_t, output_buffer *[]byte) {
if emitter.write_handler != nil {
panic("must set the output target only once")
}
emitter.write_handler = yaml_string_write_handler
emitter.output_buffer = output_buffer
}
// Set a file output.
func yaml_emitter_set_output_writer(emitter *yaml_emitter_t, w io.Writer) {
if emitter.write_handler != nil {
panic("must set the output target only once")
}
emitter.write_handler = yaml_writer_write_handler
emitter.output_writer = w
}
// Set the output encoding.
func yaml_emitter_set_encoding(emitter *yaml_emitter_t, encoding yaml_encoding_t) {
if emitter.encoding != yaml_ANY_ENCODING {
panic("must set the output encoding only once")
}
emitter.encoding = encoding
}
// Set the canonical output style.
func yaml_emitter_set_canonical(emitter *yaml_emitter_t, canonical bool) {
emitter.canonical = canonical
}
// Set the indentation increment.
func yaml_emitter_set_indent(emitter *yaml_emitter_t, indent int) {
if indent < 2 || indent > 9 {
indent = 2
}
emitter.best_indent = indent
}
// Set the preferred line width.
func yaml_emitter_set_width(emitter *yaml_emitter_t, width int) {
if width < 0 {
width = -1
}
emitter.best_width = width
}
// Set if unescaped non-ASCII characters are allowed.
func yaml_emitter_set_unicode(emitter *yaml_emitter_t, unicode bool) {
emitter.unicode = unicode
}
// Set the preferred line break character.
func yaml_emitter_set_break(emitter *yaml_emitter_t, line_break yaml_break_t) {
emitter.line_break = line_break
}
///*
// * Destroy a token object.
// */
//
//YAML_DECLARE(void)
//yaml_token_delete(yaml_token_t *token)
//{
// assert(token); // Non-NULL token object expected.
//
// switch (token.type)
// {
// case YAML_TAG_DIRECTIVE_TOKEN:
// yaml_free(token.data.tag_directive.handle);
// yaml_free(token.data.tag_directive.prefix);
// break;
//
// case YAML_ALIAS_TOKEN:
// yaml_free(token.data.alias.value);
// break;
//
// case YAML_ANCHOR_TOKEN:
// yaml_free(token.data.anchor.value);
// break;
//
// case YAML_TAG_TOKEN:
// yaml_free(token.data.tag.handle);
// yaml_free(token.data.tag.suffix);
// break;
//
// case YAML_SCALAR_TOKEN:
// yaml_free(token.data.scalar.value);
// break;
//
// default:
// break;
// }
//
// memset(token, 0, sizeof(yaml_token_t));
//}
//
///*
// * Check if a string is a valid UTF-8 sequence.
// *
// * Check 'reader.c' for more details on UTF-8 encoding.
// */
//
//static int
//yaml_check_utf8(yaml_char_t *start, size_t length)
//{
// yaml_char_t *end = start+length;
// yaml_char_t *pointer = start;
//
// while (pointer < end) {
// unsigned char octet;
// unsigned int width;
// unsigned int value;
// size_t k;
//
// octet = pointer[0];
// width = (octet & 0x80) == 0x00 ? 1 :
// (octet & 0xE0) == 0xC0 ? 2 :
// (octet & 0xF0) == 0xE0 ? 3 :
// (octet & 0xF8) == 0xF0 ? 4 : 0;
// value = (octet & 0x80) == 0x00 ? octet & 0x7F :
// (octet & 0xE0) == 0xC0 ? octet & 0x1F :
// (octet & 0xF0) == 0xE0 ? octet & 0x0F :
// (octet & 0xF8) == 0xF0 ? octet & 0x07 : 0;
// if (!width) return 0;
// if (pointer+width > end) return 0;
// for (k = 1; k < width; k ++) {
// octet = pointer[k];
// if ((octet & 0xC0) != 0x80) return 0;
// value = (value << 6) + (octet & 0x3F);
// }
// if (!((width == 1) ||
// (width == 2 && value >= 0x80) ||
// (width == 3 && value >= 0x800) ||
// (width == 4 && value >= 0x10000))) return 0;
//
// pointer += width;
// }
//
// return 1;
//}
//
// Create STREAM-START.
func yaml_stream_start_event_initialize(event *yaml_event_t, encoding yaml_encoding_t) {
*event = yaml_event_t{
typ: yaml_STREAM_START_EVENT,
encoding: encoding,
}
}
// Create STREAM-END.
func yaml_stream_end_event_initialize(event *yaml_event_t) {
*event = yaml_event_t{
typ: yaml_STREAM_END_EVENT,
}
}
// Create DOCUMENT-START.
func yaml_document_start_event_initialize(
event *yaml_event_t,
version_directive *yaml_version_directive_t,
tag_directives []yaml_tag_directive_t,
implicit bool,
) {
*event = yaml_event_t{
typ: yaml_DOCUMENT_START_EVENT,
version_directive: version_directive,
tag_directives: tag_directives,
implicit: implicit,
}
}
// Create DOCUMENT-END.
func yaml_document_end_event_initialize(event *yaml_event_t, implicit bool) {
*event = yaml_event_t{
typ: yaml_DOCUMENT_END_EVENT,
implicit: implicit,
}
}
// Create ALIAS.
func yaml_alias_event_initialize(event *yaml_event_t, anchor []byte) bool {
*event = yaml_event_t{
typ: yaml_ALIAS_EVENT,
anchor: anchor,
}
return true
}
// Create SCALAR.
func yaml_scalar_event_initialize(event *yaml_event_t, anchor, tag, value []byte, plain_implicit, quoted_implicit bool, style yaml_scalar_style_t) bool {
*event = yaml_event_t{
typ: yaml_SCALAR_EVENT,
anchor: anchor,
tag: tag,
value: value,
implicit: plain_implicit,
quoted_implicit: quoted_implicit,
style: yaml_style_t(style),
}
return true
}
// Create SEQUENCE-START.
func yaml_sequence_start_event_initialize(event *yaml_event_t, anchor, tag []byte, implicit bool, style yaml_sequence_style_t) bool {
*event = yaml_event_t{
typ: yaml_SEQUENCE_START_EVENT,
anchor: anchor,
tag: tag,
implicit: implicit,
style: yaml_style_t(style),
}
return true
}
// Create SEQUENCE-END.
func yaml_sequence_end_event_initialize(event *yaml_event_t) bool {
*event = yaml_event_t{
typ: yaml_SEQUENCE_END_EVENT,
}
return true
}
// Create MAPPING-START.
func yaml_mapping_start_event_initialize(event *yaml_event_t, anchor, tag []byte, implicit bool, style yaml_mapping_style_t) {
*event = yaml_event_t{
typ: yaml_MAPPING_START_EVENT,
anchor: anchor,
tag: tag,
implicit: implicit,
style: yaml_style_t(style),
}
}
// Create MAPPING-END.
func yaml_mapping_end_event_initialize(event *yaml_event_t) {
*event = yaml_event_t{
typ: yaml_MAPPING_END_EVENT,
}
}
// Destroy an event object.
func yaml_event_delete(event *yaml_event_t) {
*event = yaml_event_t{}
}
///*
// * Create a document object.
// */
//
//YAML_DECLARE(int)
//yaml_document_initialize(document *yaml_document_t,
// version_directive *yaml_version_directive_t,
// tag_directives_start *yaml_tag_directive_t,
// tag_directives_end *yaml_tag_directive_t,
// start_implicit int, end_implicit int)
//{
// struct {
// error yaml_error_type_t
// } context
// struct {
// start *yaml_node_t
// end *yaml_node_t
// top *yaml_node_t
// } nodes = { NULL, NULL, NULL }
// version_directive_copy *yaml_version_directive_t = NULL
// struct {
// start *yaml_tag_directive_t
// end *yaml_tag_directive_t
// top *yaml_tag_directive_t
// } tag_directives_copy = { NULL, NULL, NULL }
// value yaml_tag_directive_t = { NULL, NULL }
// mark yaml_mark_t = { 0, 0, 0 }
//
// assert(document) // Non-NULL document object is expected.
// assert((tag_directives_start && tag_directives_end) ||
// (tag_directives_start == tag_directives_end))
// // Valid tag directives are expected.
//
// if (!STACK_INIT(&context, nodes, INITIAL_STACK_SIZE)) goto error
//
// if (version_directive) {
// version_directive_copy = yaml_malloc(sizeof(yaml_version_directive_t))
// if (!version_directive_copy) goto error
// version_directive_copy.major = version_directive.major
// version_directive_copy.minor = version_directive.minor
// }
//
// if (tag_directives_start != tag_directives_end) {
// tag_directive *yaml_tag_directive_t
// if (!STACK_INIT(&context, tag_directives_copy, INITIAL_STACK_SIZE))
// goto error
// for (tag_directive = tag_directives_start
// tag_directive != tag_directives_end; tag_directive ++) {
// assert(tag_directive.handle)
// assert(tag_directive.prefix)
// if (!yaml_check_utf8(tag_directive.handle,
// strlen((char *)tag_directive.handle)))
// goto error
// if (!yaml_check_utf8(tag_directive.prefix,
// strlen((char *)tag_directive.prefix)))
// goto error
// value.handle = yaml_strdup(tag_directive.handle)
// value.prefix = yaml_strdup(tag_directive.prefix)
// if (!value.handle || !value.prefix) goto error
// if (!PUSH(&context, tag_directives_copy, value))
// goto error
// value.handle = NULL
// value.prefix = NULL
// }
// }
//
// DOCUMENT_INIT(*document, nodes.start, nodes.end, version_directive_copy,
// tag_directives_copy.start, tag_directives_copy.top,
// start_implicit, end_implicit, mark, mark)
//
// return 1
//
//error:
// STACK_DEL(&context, nodes)
// yaml_free(version_directive_copy)
// while (!STACK_EMPTY(&context, tag_directives_copy)) {
// value yaml_tag_directive_t = POP(&context, tag_directives_copy)
// yaml_free(value.handle)
// yaml_free(value.prefix)
// }
// STACK_DEL(&context, tag_directives_copy)
// yaml_free(value.handle)
// yaml_free(value.prefix)
//
// return 0
//}
//
///*
// * Destroy a document object.
// */
//
//YAML_DECLARE(void)
//yaml_document_delete(document *yaml_document_t)
//{
// struct {
// error yaml_error_type_t
// } context
// tag_directive *yaml_tag_directive_t
//
// context.error = YAML_NO_ERROR // Eliminate a compiler warning.
//
// assert(document) // Non-NULL document object is expected.
//
// while (!STACK_EMPTY(&context, document.nodes)) {
// node yaml_node_t = POP(&context, document.nodes)
// yaml_free(node.tag)
// switch (node.type) {
// case YAML_SCALAR_NODE:
// yaml_free(node.data.scalar.value)
// break
// case YAML_SEQUENCE_NODE:
// STACK_DEL(&context, node.data.sequence.items)
// break
// case YAML_MAPPING_NODE:
// STACK_DEL(&context, node.data.mapping.pairs)
// break
// default:
// assert(0) // Should not happen.
// }
// }
// STACK_DEL(&context, document.nodes)
//
// yaml_free(document.version_directive)
// for (tag_directive = document.tag_directives.start
// tag_directive != document.tag_directives.end
// tag_directive++) {
// yaml_free(tag_directive.handle)
// yaml_free(tag_directive.prefix)
// }
// yaml_free(document.tag_directives.start)
//
// memset(document, 0, sizeof(yaml_document_t))
//}
//
///**
// * Get a document node.
// */
//
//YAML_DECLARE(yaml_node_t *)
//yaml_document_get_node(document *yaml_document_t, index int)
//{
// assert(document) // Non-NULL document object is expected.
//
// if (index > 0 && document.nodes.start + index <= document.nodes.top) {
// return document.nodes.start + index - 1
// }
// return NULL
//}
//
///**
// * Get the root object.
// */
//
//YAML_DECLARE(yaml_node_t *)
//yaml_document_get_root_node(document *yaml_document_t)
//{
// assert(document) // Non-NULL document object is expected.
//
// if (document.nodes.top != document.nodes.start) {
// return document.nodes.start
// }
// return NULL
//}
//
///*
// * Add a scalar node to a document.
// */
//
//YAML_DECLARE(int)
//yaml_document_add_scalar(document *yaml_document_t,
// tag *yaml_char_t, value *yaml_char_t, length int,
// style yaml_scalar_style_t)
//{
// struct {
// error yaml_error_type_t
// } context
// mark yaml_mark_t = { 0, 0, 0 }
// tag_copy *yaml_char_t = NULL
// value_copy *yaml_char_t = NULL
// node yaml_node_t
//
// assert(document) // Non-NULL document object is expected.
// assert(value) // Non-NULL value is expected.
//
// if (!tag) {
// tag = (yaml_char_t *)YAML_DEFAULT_SCALAR_TAG
// }
//
// if (!yaml_check_utf8(tag, strlen((char *)tag))) goto error
// tag_copy = yaml_strdup(tag)
// if (!tag_copy) goto error
//
// if (length < 0) {
// length = strlen((char *)value)
// }
//
// if (!yaml_check_utf8(value, length)) goto error
// value_copy = yaml_malloc(length+1)
// if (!value_copy) goto error
// memcpy(value_copy, value, length)
// value_copy[length] = '\0'
//
// SCALAR_NODE_INIT(node, tag_copy, value_copy, length, style, mark, mark)
// if (!PUSH(&context, document.nodes, node)) goto error
//
// return document.nodes.top - document.nodes.start
//
//error:
// yaml_free(tag_copy)
// yaml_free(value_copy)
//
// return 0
//}
//
///*
// * Add a sequence node to a document.
// */
//
//YAML_DECLARE(int)
//yaml_document_add_sequence(document *yaml_document_t,
// tag *yaml_char_t, style yaml_sequence_style_t)
//{
// struct {
// error yaml_error_type_t
// } context
// mark yaml_mark_t = { 0, 0, 0 }
// tag_copy *yaml_char_t = NULL
// struct {
// start *yaml_node_item_t
// end *yaml_node_item_t
// top *yaml_node_item_t
// } items = { NULL, NULL, NULL }
// node yaml_node_t
//
// assert(document) // Non-NULL document object is expected.
//
// if (!tag) {
// tag = (yaml_char_t *)YAML_DEFAULT_SEQUENCE_TAG
// }
//
// if (!yaml_check_utf8(tag, strlen((char *)tag))) goto error
// tag_copy = yaml_strdup(tag)
// if (!tag_copy) goto error
//
// if (!STACK_INIT(&context, items, INITIAL_STACK_SIZE)) goto error
//
// SEQUENCE_NODE_INIT(node, tag_copy, items.start, items.end,
// style, mark, mark)
// if (!PUSH(&context, document.nodes, node)) goto error
//
// return document.nodes.top - document.nodes.start
//
//error:
// STACK_DEL(&context, items)
// yaml_free(tag_copy)
//
// return 0
//}
//
///*
// * Add a mapping node to a document.
// */
//
//YAML_DECLARE(int)
//yaml_document_add_mapping(document *yaml_document_t,
// tag *yaml_char_t, style yaml_mapping_style_t)
//{
// struct {
// error yaml_error_type_t
// } context
// mark yaml_mark_t = { 0, 0, 0 }
// tag_copy *yaml_char_t = NULL
// struct {
// start *yaml_node_pair_t
// end *yaml_node_pair_t
// top *yaml_node_pair_t
// } pairs = { NULL, NULL, NULL }
// node yaml_node_t
//
// assert(document) // Non-NULL document object is expected.
//
// if (!tag) {
// tag = (yaml_char_t *)YAML_DEFAULT_MAPPING_TAG
// }
//
// if (!yaml_check_utf8(tag, strlen((char *)tag))) goto error
// tag_copy = yaml_strdup(tag)
// if (!tag_copy) goto error
//
// if (!STACK_INIT(&context, pairs, INITIAL_STACK_SIZE)) goto error
//
// MAPPING_NODE_INIT(node, tag_copy, pairs.start, pairs.end,
// style, mark, mark)
// if (!PUSH(&context, document.nodes, node)) goto error
//
// return document.nodes.top - document.nodes.start
//
//error:
// STACK_DEL(&context, pairs)
// yaml_free(tag_copy)
//
// return 0
//}
//
///*
// * Append an item to a sequence node.
// */
//
//YAML_DECLARE(int)
//yaml_document_append_sequence_item(document *yaml_document_t,
// sequence int, item int)
//{
// struct {
// error yaml_error_type_t
// } context
//
// assert(document) // Non-NULL document is required.
// assert(sequence > 0
// && document.nodes.start + sequence <= document.nodes.top)
// // Valid sequence id is required.
// assert(document.nodes.start[sequence-1].type == YAML_SEQUENCE_NODE)
// // A sequence node is required.
// assert(item > 0 && document.nodes.start + item <= document.nodes.top)
// // Valid item id is required.
//
// if (!PUSH(&context,
// document.nodes.start[sequence-1].data.sequence.items, item))
// return 0
//
// return 1
//}
//
///*
// * Append a pair of a key and a value to a mapping node.
// */
//
//YAML_DECLARE(int)
//yaml_document_append_mapping_pair(document *yaml_document_t,
// mapping int, key int, value int)
//{
// struct {
// error yaml_error_type_t
// } context
//
// pair yaml_node_pair_t
//
// assert(document) // Non-NULL document is required.
// assert(mapping > 0
// && document.nodes.start + mapping <= document.nodes.top)
// // Valid mapping id is required.
// assert(document.nodes.start[mapping-1].type == YAML_MAPPING_NODE)
// // A mapping node is required.
// assert(key > 0 && document.nodes.start + key <= document.nodes.top)
// // Valid key id is required.
// assert(value > 0 && document.nodes.start + value <= document.nodes.top)
// // Valid value id is required.
//
// pair.key = key
// pair.value = value
//
// if (!PUSH(&context,
// document.nodes.start[mapping-1].data.mapping.pairs, pair))
// return 0
//
// return 1
//}
//
//
+1000
View File
File diff suppressed because it is too large Load Diff
+2020
View File
File diff suppressed because it is too large Load Diff
+577
View File
@@ -0,0 +1,577 @@
//
// Copyright (c) 2011-2019 Canonical Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package yaml
import (
"encoding"
"fmt"
"io"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
)
type encoder struct {
emitter yaml_emitter_t
event yaml_event_t
out []byte
flow bool
indent int
doneInit bool
}
func newEncoder() *encoder {
e := &encoder{}
yaml_emitter_initialize(&e.emitter)
yaml_emitter_set_output_string(&e.emitter, &e.out)
yaml_emitter_set_unicode(&e.emitter, true)
return e
}
func newEncoderWithWriter(w io.Writer) *encoder {
e := &encoder{}
yaml_emitter_initialize(&e.emitter)
yaml_emitter_set_output_writer(&e.emitter, w)
yaml_emitter_set_unicode(&e.emitter, true)
return e
}
func (e *encoder) init() {
if e.doneInit {
return
}
if e.indent == 0 {
e.indent = 4
}
e.emitter.best_indent = e.indent
yaml_stream_start_event_initialize(&e.event, yaml_UTF8_ENCODING)
e.emit()
e.doneInit = true
}
func (e *encoder) finish() {
e.emitter.open_ended = false
yaml_stream_end_event_initialize(&e.event)
e.emit()
}
func (e *encoder) destroy() {
yaml_emitter_delete(&e.emitter)
}
func (e *encoder) emit() {
// This will internally delete the e.event value.
e.must(yaml_emitter_emit(&e.emitter, &e.event))
}
func (e *encoder) must(ok bool) {
if !ok {
msg := e.emitter.problem
if msg == "" {
msg = "unknown problem generating YAML content"
}
failf("%s", msg)
}
}
func (e *encoder) marshalDoc(tag string, in reflect.Value) {
e.init()
var node *Node
if in.IsValid() {
node, _ = in.Interface().(*Node)
}
if node != nil && node.Kind == DocumentNode {
e.nodev(in)
} else {
yaml_document_start_event_initialize(&e.event, nil, nil, true)
e.emit()
e.marshal(tag, in)
yaml_document_end_event_initialize(&e.event, true)
e.emit()
}
}
func (e *encoder) marshal(tag string, in reflect.Value) {
tag = shortTag(tag)
if !in.IsValid() || in.Kind() == reflect.Ptr && in.IsNil() {
e.nilv()
return
}
iface := in.Interface()
switch value := iface.(type) {
case *Node:
e.nodev(in)
return
case Node:
if !in.CanAddr() {
var n = reflect.New(in.Type()).Elem()
n.Set(in)
in = n
}
e.nodev(in.Addr())
return
case time.Time:
e.timev(tag, in)
return
case *time.Time:
e.timev(tag, in.Elem())
return
case time.Duration:
e.stringv(tag, reflect.ValueOf(value.String()))
return
case Marshaler:
v, err := value.MarshalYAML()
if err != nil {
fail(err)
}
if v == nil {
e.nilv()
return
}
e.marshal(tag, reflect.ValueOf(v))
return
case encoding.TextMarshaler:
text, err := value.MarshalText()
if err != nil {
fail(err)
}
in = reflect.ValueOf(string(text))
case nil:
e.nilv()
return
}
switch in.Kind() {
case reflect.Interface:
e.marshal(tag, in.Elem())
case reflect.Map:
e.mapv(tag, in)
case reflect.Ptr:
e.marshal(tag, in.Elem())
case reflect.Struct:
e.structv(tag, in)
case reflect.Slice, reflect.Array:
e.slicev(tag, in)
case reflect.String:
e.stringv(tag, in)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
e.intv(tag, in)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
e.uintv(tag, in)
case reflect.Float32, reflect.Float64:
e.floatv(tag, in)
case reflect.Bool:
e.boolv(tag, in)
default:
panic("cannot marshal type: " + in.Type().String())
}
}
func (e *encoder) mapv(tag string, in reflect.Value) {
e.mappingv(tag, func() {
keys := keyList(in.MapKeys())
sort.Sort(keys)
for _, k := range keys {
e.marshal("", k)
e.marshal("", in.MapIndex(k))
}
})
}
func (e *encoder) fieldByIndex(v reflect.Value, index []int) (field reflect.Value) {
for _, num := range index {
for {
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return reflect.Value{}
}
v = v.Elem()
continue
}
break
}
v = v.Field(num)
}
return v
}
func (e *encoder) structv(tag string, in reflect.Value) {
sinfo, err := getStructInfo(in.Type())
if err != nil {
panic(err)
}
e.mappingv(tag, func() {
for _, info := range sinfo.FieldsList {
var value reflect.Value
if info.Inline == nil {
value = in.Field(info.Num)
} else {
value = e.fieldByIndex(in, info.Inline)
if !value.IsValid() {
continue
}
}
if info.OmitEmpty && isZero(value) {
continue
}
e.marshal("", reflect.ValueOf(info.Key))
e.flow = info.Flow
e.marshal("", value)
}
if sinfo.InlineMap >= 0 {
m := in.Field(sinfo.InlineMap)
if m.Len() > 0 {
e.flow = false
keys := keyList(m.MapKeys())
sort.Sort(keys)
for _, k := range keys {
if _, found := sinfo.FieldsMap[k.String()]; found {
panic(fmt.Sprintf("cannot have key %q in inlined map: conflicts with struct field", k.String()))
}
e.marshal("", k)
e.flow = false
e.marshal("", m.MapIndex(k))
}
}
}
})
}
func (e *encoder) mappingv(tag string, f func()) {
implicit := tag == ""
style := yaml_BLOCK_MAPPING_STYLE
if e.flow {
e.flow = false
style = yaml_FLOW_MAPPING_STYLE
}
yaml_mapping_start_event_initialize(&e.event, nil, []byte(tag), implicit, style)
e.emit()
f()
yaml_mapping_end_event_initialize(&e.event)
e.emit()
}
func (e *encoder) slicev(tag string, in reflect.Value) {
implicit := tag == ""
style := yaml_BLOCK_SEQUENCE_STYLE
if e.flow {
e.flow = false
style = yaml_FLOW_SEQUENCE_STYLE
}
e.must(yaml_sequence_start_event_initialize(&e.event, nil, []byte(tag), implicit, style))
e.emit()
n := in.Len()
for i := 0; i < n; i++ {
e.marshal("", in.Index(i))
}
e.must(yaml_sequence_end_event_initialize(&e.event))
e.emit()
}
// isBase60 returns whether s is in base 60 notation as defined in YAML 1.1.
//
// The base 60 float notation in YAML 1.1 is a terrible idea and is unsupported
// in YAML 1.2 and by this package, but these should be marshalled quoted for
// the time being for compatibility with other parsers.
func isBase60Float(s string) (result bool) {
// Fast path.
if s == "" {
return false
}
c := s[0]
if !(c == '+' || c == '-' || c >= '0' && c <= '9') || strings.IndexByte(s, ':') < 0 {
return false
}
// Do the full match.
return base60float.MatchString(s)
}
// From http://yaml.org/type/float.html, except the regular expression there
// is bogus. In practice parsers do not enforce the "\.[0-9_]*" suffix.
var base60float = regexp.MustCompile(`^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+(?:\.[0-9_]*)?$`)
// isOldBool returns whether s is bool notation as defined in YAML 1.1.
//
// We continue to force strings that YAML 1.1 would interpret as booleans to be
// rendered as quotes strings so that the marshalled output valid for YAML 1.1
// parsing.
func isOldBool(s string) (result bool) {
switch s {
case "y", "Y", "yes", "Yes", "YES", "on", "On", "ON",
"n", "N", "no", "No", "NO", "off", "Off", "OFF":
return true
default:
return false
}
}
func (e *encoder) stringv(tag string, in reflect.Value) {
var style yaml_scalar_style_t
s := in.String()
canUsePlain := true
switch {
case !utf8.ValidString(s):
if tag == binaryTag {
failf("explicitly tagged !!binary data must be base64-encoded")
}
if tag != "" {
failf("cannot marshal invalid UTF-8 data as %s", shortTag(tag))
}
// It can't be encoded directly as YAML so use a binary tag
// and encode it as base64.
tag = binaryTag
s = encodeBase64(s)
case tag == "":
// Check to see if it would resolve to a specific
// tag when encoded unquoted. If it doesn't,
// there's no need to quote it.
rtag, _ := resolve("", s)
canUsePlain = rtag == strTag && !(isBase60Float(s) || isOldBool(s))
}
// Note: it's possible for user code to emit invalid YAML
// if they explicitly specify a tag and a string containing
// text that's incompatible with that tag.
switch {
case strings.Contains(s, "\n"):
if e.flow {
style = yaml_DOUBLE_QUOTED_SCALAR_STYLE
} else {
style = yaml_LITERAL_SCALAR_STYLE
}
case canUsePlain:
style = yaml_PLAIN_SCALAR_STYLE
default:
style = yaml_DOUBLE_QUOTED_SCALAR_STYLE
}
e.emitScalar(s, "", tag, style, nil, nil, nil, nil)
}
func (e *encoder) boolv(tag string, in reflect.Value) {
var s string
if in.Bool() {
s = "true"
} else {
s = "false"
}
e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil)
}
func (e *encoder) intv(tag string, in reflect.Value) {
s := strconv.FormatInt(in.Int(), 10)
e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil)
}
func (e *encoder) uintv(tag string, in reflect.Value) {
s := strconv.FormatUint(in.Uint(), 10)
e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil)
}
func (e *encoder) timev(tag string, in reflect.Value) {
t := in.Interface().(time.Time)
s := t.Format(time.RFC3339Nano)
e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil)
}
func (e *encoder) floatv(tag string, in reflect.Value) {
// Issue #352: When formatting, use the precision of the underlying value
precision := 64
if in.Kind() == reflect.Float32 {
precision = 32
}
s := strconv.FormatFloat(in.Float(), 'g', -1, precision)
switch s {
case "+Inf":
s = ".inf"
case "-Inf":
s = "-.inf"
case "NaN":
s = ".nan"
}
e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil)
}
func (e *encoder) nilv() {
e.emitScalar("null", "", "", yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil)
}
func (e *encoder) emitScalar(value, anchor, tag string, style yaml_scalar_style_t, head, line, foot, tail []byte) {
// TODO Kill this function. Replace all initialize calls by their underlining Go literals.
implicit := tag == ""
if !implicit {
tag = longTag(tag)
}
e.must(yaml_scalar_event_initialize(&e.event, []byte(anchor), []byte(tag), []byte(value), implicit, implicit, style))
e.event.head_comment = head
e.event.line_comment = line
e.event.foot_comment = foot
e.event.tail_comment = tail
e.emit()
}
func (e *encoder) nodev(in reflect.Value) {
e.node(in.Interface().(*Node), "")
}
func (e *encoder) node(node *Node, tail string) {
// Zero nodes behave as nil.
if node.Kind == 0 && node.IsZero() {
e.nilv()
return
}
// If the tag was not explicitly requested, and dropping it won't change the
// implicit tag of the value, don't include it in the presentation.
var tag = node.Tag
var stag = shortTag(tag)
var forceQuoting bool
if tag != "" && node.Style&TaggedStyle == 0 {
if node.Kind == ScalarNode {
if stag == strTag && node.Style&(SingleQuotedStyle|DoubleQuotedStyle|LiteralStyle|FoldedStyle) != 0 {
tag = ""
} else {
rtag, _ := resolve("", node.Value)
if rtag == stag {
tag = ""
} else if stag == strTag {
tag = ""
forceQuoting = true
}
}
} else {
var rtag string
switch node.Kind {
case MappingNode:
rtag = mapTag
case SequenceNode:
rtag = seqTag
}
if rtag == stag {
tag = ""
}
}
}
switch node.Kind {
case DocumentNode:
yaml_document_start_event_initialize(&e.event, nil, nil, true)
e.event.head_comment = []byte(node.HeadComment)
e.emit()
for _, node := range node.Content {
e.node(node, "")
}
yaml_document_end_event_initialize(&e.event, true)
e.event.foot_comment = []byte(node.FootComment)
e.emit()
case SequenceNode:
style := yaml_BLOCK_SEQUENCE_STYLE
if node.Style&FlowStyle != 0 {
style = yaml_FLOW_SEQUENCE_STYLE
}
e.must(yaml_sequence_start_event_initialize(&e.event, []byte(node.Anchor), []byte(longTag(tag)), tag == "", style))
e.event.head_comment = []byte(node.HeadComment)
e.emit()
for _, node := range node.Content {
e.node(node, "")
}
e.must(yaml_sequence_end_event_initialize(&e.event))
e.event.line_comment = []byte(node.LineComment)
e.event.foot_comment = []byte(node.FootComment)
e.emit()
case MappingNode:
style := yaml_BLOCK_MAPPING_STYLE
if node.Style&FlowStyle != 0 {
style = yaml_FLOW_MAPPING_STYLE
}
yaml_mapping_start_event_initialize(&e.event, []byte(node.Anchor), []byte(longTag(tag)), tag == "", style)
e.event.tail_comment = []byte(tail)
e.event.head_comment = []byte(node.HeadComment)
e.emit()
// The tail logic below moves the foot comment of prior keys to the following key,
// since the value for each key may be a nested structure and the foot needs to be
// processed only the entirety of the value is streamed. The last tail is processed
// with the mapping end event.
var tail string
for i := 0; i+1 < len(node.Content); i += 2 {
k := node.Content[i]
foot := k.FootComment
if foot != "" {
kopy := *k
kopy.FootComment = ""
k = &kopy
}
e.node(k, tail)
tail = foot
v := node.Content[i+1]
e.node(v, "")
}
yaml_mapping_end_event_initialize(&e.event)
e.event.tail_comment = []byte(tail)
e.event.line_comment = []byte(node.LineComment)
e.event.foot_comment = []byte(node.FootComment)
e.emit()
case AliasNode:
yaml_alias_event_initialize(&e.event, []byte(node.Value))
e.event.head_comment = []byte(node.HeadComment)
e.event.line_comment = []byte(node.LineComment)
e.event.foot_comment = []byte(node.FootComment)
e.emit()
case ScalarNode:
value := node.Value
if !utf8.ValidString(value) {
if stag == binaryTag {
failf("explicitly tagged !!binary data must be base64-encoded")
}
if stag != "" {
failf("cannot marshal invalid UTF-8 data as %s", stag)
}
// It can't be encoded directly as YAML so use a binary tag
// and encode it as base64.
tag = binaryTag
value = encodeBase64(value)
}
style := yaml_PLAIN_SCALAR_STYLE
switch {
case node.Style&DoubleQuotedStyle != 0:
style = yaml_DOUBLE_QUOTED_SCALAR_STYLE
case node.Style&SingleQuotedStyle != 0:
style = yaml_SINGLE_QUOTED_SCALAR_STYLE
case node.Style&LiteralStyle != 0:
style = yaml_LITERAL_SCALAR_STYLE
case node.Style&FoldedStyle != 0:
style = yaml_FOLDED_SCALAR_STYLE
case strings.Contains(value, "\n"):
style = yaml_LITERAL_SCALAR_STYLE
case forceQuoting:
style = yaml_DOUBLE_QUOTED_SCALAR_STYLE
}
e.emitScalar(value, node.Anchor, tag, style, []byte(node.HeadComment), []byte(node.LineComment), []byte(node.FootComment), []byte(tail))
default:
failf("cannot encode node with unknown kind %d", node.Kind)
}
}
+1258
View File
File diff suppressed because it is too large Load Diff
+434
View File
@@ -0,0 +1,434 @@
//
// Copyright (c) 2011-2019 Canonical Ltd
// Copyright (c) 2006-2010 Kirill Simonov
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package yaml
import (
"io"
)
// Set the reader error and return 0.
func yaml_parser_set_reader_error(parser *yaml_parser_t, problem string, offset int, value int) bool {
parser.error = yaml_READER_ERROR
parser.problem = problem
parser.problem_offset = offset
parser.problem_value = value
return false
}
// Byte order marks.
const (
bom_UTF8 = "\xef\xbb\xbf"
bom_UTF16LE = "\xff\xfe"
bom_UTF16BE = "\xfe\xff"
)
// Determine the input stream encoding by checking the BOM symbol. If no BOM is
// found, the UTF-8 encoding is assumed. Return 1 on success, 0 on failure.
func yaml_parser_determine_encoding(parser *yaml_parser_t) bool {
// Ensure that we had enough bytes in the raw buffer.
for !parser.eof && len(parser.raw_buffer)-parser.raw_buffer_pos < 3 {
if !yaml_parser_update_raw_buffer(parser) {
return false
}
}
// Determine the encoding.
buf := parser.raw_buffer
pos := parser.raw_buffer_pos
avail := len(buf) - pos
if avail >= 2 && buf[pos] == bom_UTF16LE[0] && buf[pos+1] == bom_UTF16LE[1] {
parser.encoding = yaml_UTF16LE_ENCODING
parser.raw_buffer_pos += 2
parser.offset += 2
} else if avail >= 2 && buf[pos] == bom_UTF16BE[0] && buf[pos+1] == bom_UTF16BE[1] {
parser.encoding = yaml_UTF16BE_ENCODING
parser.raw_buffer_pos += 2
parser.offset += 2
} else if avail >= 3 && buf[pos] == bom_UTF8[0] && buf[pos+1] == bom_UTF8[1] && buf[pos+2] == bom_UTF8[2] {
parser.encoding = yaml_UTF8_ENCODING
parser.raw_buffer_pos += 3
parser.offset += 3
} else {
parser.encoding = yaml_UTF8_ENCODING
}
return true
}
// Update the raw buffer.
func yaml_parser_update_raw_buffer(parser *yaml_parser_t) bool {
size_read := 0
// Return if the raw buffer is full.
if parser.raw_buffer_pos == 0 && len(parser.raw_buffer) == cap(parser.raw_buffer) {
return true
}
// Return on EOF.
if parser.eof {
return true
}
// Move the remaining bytes in the raw buffer to the beginning.
if parser.raw_buffer_pos > 0 && parser.raw_buffer_pos < len(parser.raw_buffer) {
copy(parser.raw_buffer, parser.raw_buffer[parser.raw_buffer_pos:])
}
parser.raw_buffer = parser.raw_buffer[:len(parser.raw_buffer)-parser.raw_buffer_pos]
parser.raw_buffer_pos = 0
// Call the read handler to fill the buffer.
size_read, err := parser.read_handler(parser, parser.raw_buffer[len(parser.raw_buffer):cap(parser.raw_buffer)])
parser.raw_buffer = parser.raw_buffer[:len(parser.raw_buffer)+size_read]
if err == io.EOF {
parser.eof = true
} else if err != nil {
return yaml_parser_set_reader_error(parser, "input error: "+err.Error(), parser.offset, -1)
}
return true
}
// Ensure that the buffer contains at least `length` characters.
// Return true on success, false on failure.
//
// The length is supposed to be significantly less that the buffer size.
func yaml_parser_update_buffer(parser *yaml_parser_t, length int) bool {
if parser.read_handler == nil {
panic("read handler must be set")
}
// [Go] This function was changed to guarantee the requested length size at EOF.
// The fact we need to do this is pretty awful, but the description above implies
// for that to be the case, and there are tests
// If the EOF flag is set and the raw buffer is empty, do nothing.
if parser.eof && parser.raw_buffer_pos == len(parser.raw_buffer) {
// [Go] ACTUALLY! Read the documentation of this function above.
// This is just broken. To return true, we need to have the
// given length in the buffer. Not doing that means every single
// check that calls this function to make sure the buffer has a
// given length is Go) panicking; or C) accessing invalid memory.
//return true
}
// Return if the buffer contains enough characters.
if parser.unread >= length {
return true
}
// Determine the input encoding if it is not known yet.
if parser.encoding == yaml_ANY_ENCODING {
if !yaml_parser_determine_encoding(parser) {
return false
}
}
// Move the unread characters to the beginning of the buffer.
buffer_len := len(parser.buffer)
if parser.buffer_pos > 0 && parser.buffer_pos < buffer_len {
copy(parser.buffer, parser.buffer[parser.buffer_pos:])
buffer_len -= parser.buffer_pos
parser.buffer_pos = 0
} else if parser.buffer_pos == buffer_len {
buffer_len = 0
parser.buffer_pos = 0
}
// Open the whole buffer for writing, and cut it before returning.
parser.buffer = parser.buffer[:cap(parser.buffer)]
// Fill the buffer until it has enough characters.
first := true
for parser.unread < length {
// Fill the raw buffer if necessary.
if !first || parser.raw_buffer_pos == len(parser.raw_buffer) {
if !yaml_parser_update_raw_buffer(parser) {
parser.buffer = parser.buffer[:buffer_len]
return false
}
}
first = false
// Decode the raw buffer.
inner:
for parser.raw_buffer_pos != len(parser.raw_buffer) {
var value rune
var width int
raw_unread := len(parser.raw_buffer) - parser.raw_buffer_pos
// Decode the next character.
switch parser.encoding {
case yaml_UTF8_ENCODING:
// Decode a UTF-8 character. Check RFC 3629
// (http://www.ietf.org/rfc/rfc3629.txt) for more details.
//
// The following table (taken from the RFC) is used for
// decoding.
//
// Char. number range | UTF-8 octet sequence
// (hexadecimal) | (binary)
// --------------------+------------------------------------
// 0000 0000-0000 007F | 0xxxxxxx
// 0000 0080-0000 07FF | 110xxxxx 10xxxxxx
// 0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx
// 0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
//
// Additionally, the characters in the range 0xD800-0xDFFF
// are prohibited as they are reserved for use with UTF-16
// surrogate pairs.
// Determine the length of the UTF-8 sequence.
octet := parser.raw_buffer[parser.raw_buffer_pos]
switch {
case octet&0x80 == 0x00:
width = 1
case octet&0xE0 == 0xC0:
width = 2
case octet&0xF0 == 0xE0:
width = 3
case octet&0xF8 == 0xF0:
width = 4
default:
// The leading octet is invalid.
return yaml_parser_set_reader_error(parser,
"invalid leading UTF-8 octet",
parser.offset, int(octet))
}
// Check if the raw buffer contains an incomplete character.
if width > raw_unread {
if parser.eof {
return yaml_parser_set_reader_error(parser,
"incomplete UTF-8 octet sequence",
parser.offset, -1)
}
break inner
}
// Decode the leading octet.
switch {
case octet&0x80 == 0x00:
value = rune(octet & 0x7F)
case octet&0xE0 == 0xC0:
value = rune(octet & 0x1F)
case octet&0xF0 == 0xE0:
value = rune(octet & 0x0F)
case octet&0xF8 == 0xF0:
value = rune(octet & 0x07)
default:
value = 0
}
// Check and decode the trailing octets.
for k := 1; k < width; k++ {
octet = parser.raw_buffer[parser.raw_buffer_pos+k]
// Check if the octet is valid.
if (octet & 0xC0) != 0x80 {
return yaml_parser_set_reader_error(parser,
"invalid trailing UTF-8 octet",
parser.offset+k, int(octet))
}
// Decode the octet.
value = (value << 6) + rune(octet&0x3F)
}
// Check the length of the sequence against the value.
switch {
case width == 1:
case width == 2 && value >= 0x80:
case width == 3 && value >= 0x800:
case width == 4 && value >= 0x10000:
default:
return yaml_parser_set_reader_error(parser,
"invalid length of a UTF-8 sequence",
parser.offset, -1)
}
// Check the range of the value.
if value >= 0xD800 && value <= 0xDFFF || value > 0x10FFFF {
return yaml_parser_set_reader_error(parser,
"invalid Unicode character",
parser.offset, int(value))
}
case yaml_UTF16LE_ENCODING, yaml_UTF16BE_ENCODING:
var low, high int
if parser.encoding == yaml_UTF16LE_ENCODING {
low, high = 0, 1
} else {
low, high = 1, 0
}
// The UTF-16 encoding is not as simple as one might
// naively think. Check RFC 2781
// (http://www.ietf.org/rfc/rfc2781.txt).
//
// Normally, two subsequent bytes describe a Unicode
// character. However a special technique (called a
// surrogate pair) is used for specifying character
// values larger than 0xFFFF.
//
// A surrogate pair consists of two pseudo-characters:
// high surrogate area (0xD800-0xDBFF)
// low surrogate area (0xDC00-0xDFFF)
//
// The following formulas are used for decoding
// and encoding characters using surrogate pairs:
//
// U = U' + 0x10000 (0x01 00 00 <= U <= 0x10 FF FF)
// U' = yyyyyyyyyyxxxxxxxxxx (0 <= U' <= 0x0F FF FF)
// W1 = 110110yyyyyyyyyy
// W2 = 110111xxxxxxxxxx
//
// where U is the character value, W1 is the high surrogate
// area, W2 is the low surrogate area.
// Check for incomplete UTF-16 character.
if raw_unread < 2 {
if parser.eof {
return yaml_parser_set_reader_error(parser,
"incomplete UTF-16 character",
parser.offset, -1)
}
break inner
}
// Get the character.
value = rune(parser.raw_buffer[parser.raw_buffer_pos+low]) +
(rune(parser.raw_buffer[parser.raw_buffer_pos+high]) << 8)
// Check for unexpected low surrogate area.
if value&0xFC00 == 0xDC00 {
return yaml_parser_set_reader_error(parser,
"unexpected low surrogate area",
parser.offset, int(value))
}
// Check for a high surrogate area.
if value&0xFC00 == 0xD800 {
width = 4
// Check for incomplete surrogate pair.
if raw_unread < 4 {
if parser.eof {
return yaml_parser_set_reader_error(parser,
"incomplete UTF-16 surrogate pair",
parser.offset, -1)
}
break inner
}
// Get the next character.
value2 := rune(parser.raw_buffer[parser.raw_buffer_pos+low+2]) +
(rune(parser.raw_buffer[parser.raw_buffer_pos+high+2]) << 8)
// Check for a low surrogate area.
if value2&0xFC00 != 0xDC00 {
return yaml_parser_set_reader_error(parser,
"expected low surrogate area",
parser.offset+2, int(value2))
}
// Generate the value of the surrogate pair.
value = 0x10000 + ((value & 0x3FF) << 10) + (value2 & 0x3FF)
} else {
width = 2
}
default:
panic("impossible")
}
// Check if the character is in the allowed range:
// #x9 | #xA | #xD | [#x20-#x7E] (8 bit)
// | #x85 | [#xA0-#xD7FF] | [#xE000-#xFFFD] (16 bit)
// | [#x10000-#x10FFFF] (32 bit)
switch {
case value == 0x09:
case value == 0x0A:
case value == 0x0D:
case value >= 0x20 && value <= 0x7E:
case value == 0x85:
case value >= 0xA0 && value <= 0xD7FF:
case value >= 0xE000 && value <= 0xFFFD:
case value >= 0x10000 && value <= 0x10FFFF:
default:
return yaml_parser_set_reader_error(parser,
"control characters are not allowed",
parser.offset, int(value))
}
// Move the raw pointers.
parser.raw_buffer_pos += width
parser.offset += width
// Finally put the character into the buffer.
if value <= 0x7F {
// 0000 0000-0000 007F . 0xxxxxxx
parser.buffer[buffer_len+0] = byte(value)
buffer_len += 1
} else if value <= 0x7FF {
// 0000 0080-0000 07FF . 110xxxxx 10xxxxxx
parser.buffer[buffer_len+0] = byte(0xC0 + (value >> 6))
parser.buffer[buffer_len+1] = byte(0x80 + (value & 0x3F))
buffer_len += 2
} else if value <= 0xFFFF {
// 0000 0800-0000 FFFF . 1110xxxx 10xxxxxx 10xxxxxx
parser.buffer[buffer_len+0] = byte(0xE0 + (value >> 12))
parser.buffer[buffer_len+1] = byte(0x80 + ((value >> 6) & 0x3F))
parser.buffer[buffer_len+2] = byte(0x80 + (value & 0x3F))
buffer_len += 3
} else {
// 0001 0000-0010 FFFF . 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
parser.buffer[buffer_len+0] = byte(0xF0 + (value >> 18))
parser.buffer[buffer_len+1] = byte(0x80 + ((value >> 12) & 0x3F))
parser.buffer[buffer_len+2] = byte(0x80 + ((value >> 6) & 0x3F))
parser.buffer[buffer_len+3] = byte(0x80 + (value & 0x3F))
buffer_len += 4
}
parser.unread++
}
// On EOF, put NUL into the buffer and return.
if parser.eof {
parser.buffer[buffer_len] = 0
buffer_len++
parser.unread++
break
}
}
// [Go] Read the documentation of this function above. To return true,
// we need to have the given length in the buffer. Not doing that means
// every single check that calls this function to make sure the buffer
// has a given length is Go) panicking; or C) accessing invalid memory.
// This happens here due to the EOF above breaking early.
for buffer_len < length {
parser.buffer[buffer_len] = 0
buffer_len++
}
parser.buffer = parser.buffer[:buffer_len]
return true
}
+326
View File
@@ -0,0 +1,326 @@
//
// Copyright (c) 2011-2019 Canonical Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package yaml
import (
"encoding/base64"
"math"
"regexp"
"strconv"
"strings"
"time"
)
type resolveMapItem struct {
value interface{}
tag string
}
var resolveTable = make([]byte, 256)
var resolveMap = make(map[string]resolveMapItem)
func init() {
t := resolveTable
t[int('+')] = 'S' // Sign
t[int('-')] = 'S'
for _, c := range "0123456789" {
t[int(c)] = 'D' // Digit
}
for _, c := range "yYnNtTfFoO~" {
t[int(c)] = 'M' // In map
}
t[int('.')] = '.' // Float (potentially in map)
var resolveMapList = []struct {
v interface{}
tag string
l []string
}{
{true, boolTag, []string{"true", "True", "TRUE"}},
{false, boolTag, []string{"false", "False", "FALSE"}},
{nil, nullTag, []string{"", "~", "null", "Null", "NULL"}},
{math.NaN(), floatTag, []string{".nan", ".NaN", ".NAN"}},
{math.Inf(+1), floatTag, []string{".inf", ".Inf", ".INF"}},
{math.Inf(+1), floatTag, []string{"+.inf", "+.Inf", "+.INF"}},
{math.Inf(-1), floatTag, []string{"-.inf", "-.Inf", "-.INF"}},
{"<<", mergeTag, []string{"<<"}},
}
m := resolveMap
for _, item := range resolveMapList {
for _, s := range item.l {
m[s] = resolveMapItem{item.v, item.tag}
}
}
}
const (
nullTag = "!!null"
boolTag = "!!bool"
strTag = "!!str"
intTag = "!!int"
floatTag = "!!float"
timestampTag = "!!timestamp"
seqTag = "!!seq"
mapTag = "!!map"
binaryTag = "!!binary"
mergeTag = "!!merge"
)
var longTags = make(map[string]string)
var shortTags = make(map[string]string)
func init() {
for _, stag := range []string{nullTag, boolTag, strTag, intTag, floatTag, timestampTag, seqTag, mapTag, binaryTag, mergeTag} {
ltag := longTag(stag)
longTags[stag] = ltag
shortTags[ltag] = stag
}
}
const longTagPrefix = "tag:yaml.org,2002:"
func shortTag(tag string) string {
if strings.HasPrefix(tag, longTagPrefix) {
if stag, ok := shortTags[tag]; ok {
return stag
}
return "!!" + tag[len(longTagPrefix):]
}
return tag
}
func longTag(tag string) string {
if strings.HasPrefix(tag, "!!") {
if ltag, ok := longTags[tag]; ok {
return ltag
}
return longTagPrefix + tag[2:]
}
return tag
}
func resolvableTag(tag string) bool {
switch tag {
case "", strTag, boolTag, intTag, floatTag, nullTag, timestampTag:
return true
}
return false
}
var yamlStyleFloat = regexp.MustCompile(`^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$`)
func resolve(tag string, in string) (rtag string, out interface{}) {
tag = shortTag(tag)
if !resolvableTag(tag) {
return tag, in
}
defer func() {
switch tag {
case "", rtag, strTag, binaryTag:
return
case floatTag:
if rtag == intTag {
switch v := out.(type) {
case int64:
rtag = floatTag
out = float64(v)
return
case int:
rtag = floatTag
out = float64(v)
return
}
}
}
failf("cannot decode %s `%s` as a %s", shortTag(rtag), in, shortTag(tag))
}()
// Any data is accepted as a !!str or !!binary.
// Otherwise, the prefix is enough of a hint about what it might be.
hint := byte('N')
if in != "" {
hint = resolveTable[in[0]]
}
if hint != 0 && tag != strTag && tag != binaryTag {
// Handle things we can lookup in a map.
if item, ok := resolveMap[in]; ok {
return item.tag, item.value
}
// Base 60 floats are a bad idea, were dropped in YAML 1.2, and
// are purposefully unsupported here. They're still quoted on
// the way out for compatibility with other parser, though.
switch hint {
case 'M':
// We've already checked the map above.
case '.':
// Not in the map, so maybe a normal float.
floatv, err := strconv.ParseFloat(in, 64)
if err == nil {
return floatTag, floatv
}
case 'D', 'S':
// Int, float, or timestamp.
// Only try values as a timestamp if the value is unquoted or there's an explicit
// !!timestamp tag.
if tag == "" || tag == timestampTag {
t, ok := parseTimestamp(in)
if ok {
return timestampTag, t
}
}
plain := strings.Replace(in, "_", "", -1)
intv, err := strconv.ParseInt(plain, 0, 64)
if err == nil {
if intv == int64(int(intv)) {
return intTag, int(intv)
} else {
return intTag, intv
}
}
uintv, err := strconv.ParseUint(plain, 0, 64)
if err == nil {
return intTag, uintv
}
if yamlStyleFloat.MatchString(plain) {
floatv, err := strconv.ParseFloat(plain, 64)
if err == nil {
return floatTag, floatv
}
}
if strings.HasPrefix(plain, "0b") {
intv, err := strconv.ParseInt(plain[2:], 2, 64)
if err == nil {
if intv == int64(int(intv)) {
return intTag, int(intv)
} else {
return intTag, intv
}
}
uintv, err := strconv.ParseUint(plain[2:], 2, 64)
if err == nil {
return intTag, uintv
}
} else if strings.HasPrefix(plain, "-0b") {
intv, err := strconv.ParseInt("-"+plain[3:], 2, 64)
if err == nil {
if true || intv == int64(int(intv)) {
return intTag, int(intv)
} else {
return intTag, intv
}
}
}
// Octals as introduced in version 1.2 of the spec.
// Octals from the 1.1 spec, spelled as 0777, are still
// decoded by default in v3 as well for compatibility.
// May be dropped in v4 depending on how usage evolves.
if strings.HasPrefix(plain, "0o") {
intv, err := strconv.ParseInt(plain[2:], 8, 64)
if err == nil {
if intv == int64(int(intv)) {
return intTag, int(intv)
} else {
return intTag, intv
}
}
uintv, err := strconv.ParseUint(plain[2:], 8, 64)
if err == nil {
return intTag, uintv
}
} else if strings.HasPrefix(plain, "-0o") {
intv, err := strconv.ParseInt("-"+plain[3:], 8, 64)
if err == nil {
if true || intv == int64(int(intv)) {
return intTag, int(intv)
} else {
return intTag, intv
}
}
}
default:
panic("internal error: missing handler for resolver table: " + string(rune(hint)) + " (with " + in + ")")
}
}
return strTag, in
}
// encodeBase64 encodes s as base64 that is broken up into multiple lines
// as appropriate for the resulting length.
func encodeBase64(s string) string {
const lineLen = 70
encLen := base64.StdEncoding.EncodedLen(len(s))
lines := encLen/lineLen + 1
buf := make([]byte, encLen*2+lines)
in := buf[0:encLen]
out := buf[encLen:]
base64.StdEncoding.Encode(in, []byte(s))
k := 0
for i := 0; i < len(in); i += lineLen {
j := i + lineLen
if j > len(in) {
j = len(in)
}
k += copy(out[k:], in[i:j])
if lines > 1 {
out[k] = '\n'
k++
}
}
return string(out[:k])
}
// This is a subset of the formats allowed by the regular expression
// defined at http://yaml.org/type/timestamp.html.
var allowedTimestampFormats = []string{
"2006-1-2T15:4:5.999999999Z07:00", // RCF3339Nano with short date fields.
"2006-1-2t15:4:5.999999999Z07:00", // RFC3339Nano with short date fields and lower-case "t".
"2006-1-2 15:4:5.999999999", // space separated with no time zone
"2006-1-2", // date only
// Notable exception: time.Parse cannot handle: "2001-12-14 21:59:43.10 -5"
// from the set of examples.
}
// parseTimestamp parses s as a timestamp string and
// returns the timestamp and reports whether it succeeded.
// Timestamp formats are defined at http://yaml.org/type/timestamp.html
func parseTimestamp(s string) (time.Time, bool) {
// TODO write code to check all the formats supported by
// http://yaml.org/type/timestamp.html instead of using time.Parse.
// Quick check: all date formats start with YYYY-.
i := 0
for ; i < len(s); i++ {
if c := s[i]; c < '0' || c > '9' {
break
}
}
if i != 4 || i == len(s) || s[i] != '-' {
return time.Time{}, false
}
for _, format := range allowedTimestampFormats {
if t, err := time.Parse(format, s); err == nil {
return t, true
}
}
return time.Time{}, false
}
+3038
View File
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
//
// Copyright (c) 2011-2019 Canonical Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package yaml
import (
"reflect"
"unicode"
)
type keyList []reflect.Value
func (l keyList) Len() int { return len(l) }
func (l keyList) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
func (l keyList) Less(i, j int) bool {
a := l[i]
b := l[j]
ak := a.Kind()
bk := b.Kind()
for (ak == reflect.Interface || ak == reflect.Ptr) && !a.IsNil() {
a = a.Elem()
ak = a.Kind()
}
for (bk == reflect.Interface || bk == reflect.Ptr) && !b.IsNil() {
b = b.Elem()
bk = b.Kind()
}
af, aok := keyFloat(a)
bf, bok := keyFloat(b)
if aok && bok {
if af != bf {
return af < bf
}
if ak != bk {
return ak < bk
}
return numLess(a, b)
}
if ak != reflect.String || bk != reflect.String {
return ak < bk
}
ar, br := []rune(a.String()), []rune(b.String())
digits := false
for i := 0; i < len(ar) && i < len(br); i++ {
if ar[i] == br[i] {
digits = unicode.IsDigit(ar[i])
continue
}
al := unicode.IsLetter(ar[i])
bl := unicode.IsLetter(br[i])
if al && bl {
return ar[i] < br[i]
}
if al || bl {
if digits {
return al
} else {
return bl
}
}
var ai, bi int
var an, bn int64
if ar[i] == '0' || br[i] == '0' {
for j := i - 1; j >= 0 && unicode.IsDigit(ar[j]); j-- {
if ar[j] != '0' {
an = 1
bn = 1
break
}
}
}
for ai = i; ai < len(ar) && unicode.IsDigit(ar[ai]); ai++ {
an = an*10 + int64(ar[ai]-'0')
}
for bi = i; bi < len(br) && unicode.IsDigit(br[bi]); bi++ {
bn = bn*10 + int64(br[bi]-'0')
}
if an != bn {
return an < bn
}
if ai != bi {
return ai < bi
}
return ar[i] < br[i]
}
return len(ar) < len(br)
}
// keyFloat returns a float value for v if it is a number/bool
// and whether it is a number/bool or not.
func keyFloat(v reflect.Value) (f float64, ok bool) {
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return float64(v.Int()), true
case reflect.Float32, reflect.Float64:
return v.Float(), true
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return float64(v.Uint()), true
case reflect.Bool:
if v.Bool() {
return 1, true
}
return 0, true
}
return 0, false
}
// numLess returns whether a < b.
// a and b must necessarily have the same kind.
func numLess(a, b reflect.Value) bool {
switch a.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return a.Int() < b.Int()
case reflect.Float32, reflect.Float64:
return a.Float() < b.Float()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return a.Uint() < b.Uint()
case reflect.Bool:
return !a.Bool() && b.Bool()
}
panic("not a number")
}
+48
View File
@@ -0,0 +1,48 @@
//
// Copyright (c) 2011-2019 Canonical Ltd
// Copyright (c) 2006-2010 Kirill Simonov
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package yaml
// Set the writer error and return false.
func yaml_emitter_set_writer_error(emitter *yaml_emitter_t, problem string) bool {
emitter.error = yaml_WRITER_ERROR
emitter.problem = problem
return false
}
// Flush the output buffer.
func yaml_emitter_flush(emitter *yaml_emitter_t) bool {
if emitter.write_handler == nil {
panic("write handler not set")
}
// Check if the buffer is empty.
if emitter.buffer_pos == 0 {
return true
}
if err := emitter.write_handler(emitter, emitter.buffer[:emitter.buffer_pos]); err != nil {
return yaml_emitter_set_writer_error(emitter, "write error: "+err.Error())
}
emitter.buffer_pos = 0
return true
}
+698
View File
@@ -0,0 +1,698 @@
//
// Copyright (c) 2011-2019 Canonical Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package yaml implements YAML support for the Go language.
//
// Source code and other details for the project are available at GitHub:
//
// https://github.com/go-yaml/yaml
//
package yaml
import (
"errors"
"fmt"
"io"
"reflect"
"strings"
"sync"
"unicode/utf8"
)
// The Unmarshaler interface may be implemented by types to customize their
// behavior when being unmarshaled from a YAML document.
type Unmarshaler interface {
UnmarshalYAML(value *Node) error
}
type obsoleteUnmarshaler interface {
UnmarshalYAML(unmarshal func(interface{}) error) error
}
// The Marshaler interface may be implemented by types to customize their
// behavior when being marshaled into a YAML document. The returned value
// is marshaled in place of the original value implementing Marshaler.
//
// If an error is returned by MarshalYAML, the marshaling procedure stops
// and returns with the provided error.
type Marshaler interface {
MarshalYAML() (interface{}, error)
}
// Unmarshal decodes the first document found within the in byte slice
// and assigns decoded values into the out value.
//
// Maps and pointers (to a struct, string, int, etc) are accepted as out
// values. If an internal pointer within a struct is not initialized,
// the yaml package will initialize it if necessary for unmarshalling
// the provided data. The out parameter must not be nil.
//
// The type of the decoded values should be compatible with the respective
// values in out. If one or more values cannot be decoded due to a type
// mismatches, decoding continues partially until the end of the YAML
// content, and a *yaml.TypeError is returned with details for all
// missed values.
//
// Struct fields are only unmarshalled if they are exported (have an
// upper case first letter), and are unmarshalled using the field name
// lowercased as the default key. Custom keys may be defined via the
// "yaml" name in the field tag: the content preceding the first comma
// is used as the key, and the following comma-separated options are
// used to tweak the marshalling process (see Marshal).
// Conflicting names result in a runtime error.
//
// For example:
//
// type T struct {
// F int `yaml:"a,omitempty"`
// B int
// }
// var t T
// yaml.Unmarshal([]byte("a: 1\nb: 2"), &t)
//
// See the documentation of Marshal for the format of tags and a list of
// supported tag options.
//
func Unmarshal(in []byte, out interface{}) (err error) {
return unmarshal(in, out, false)
}
// A Decoder reads and decodes YAML values from an input stream.
type Decoder struct {
parser *parser
knownFields bool
}
// NewDecoder returns a new decoder that reads from r.
//
// The decoder introduces its own buffering and may read
// data from r beyond the YAML values requested.
func NewDecoder(r io.Reader) *Decoder {
return &Decoder{
parser: newParserFromReader(r),
}
}
// KnownFields ensures that the keys in decoded mappings to
// exist as fields in the struct being decoded into.
func (dec *Decoder) KnownFields(enable bool) {
dec.knownFields = enable
}
// Decode reads the next YAML-encoded value from its input
// and stores it in the value pointed to by v.
//
// See the documentation for Unmarshal for details about the
// conversion of YAML into a Go value.
func (dec *Decoder) Decode(v interface{}) (err error) {
d := newDecoder()
d.knownFields = dec.knownFields
defer handleErr(&err)
node := dec.parser.parse()
if node == nil {
return io.EOF
}
out := reflect.ValueOf(v)
if out.Kind() == reflect.Ptr && !out.IsNil() {
out = out.Elem()
}
d.unmarshal(node, out)
if len(d.terrors) > 0 {
return &TypeError{d.terrors}
}
return nil
}
// Decode decodes the node and stores its data into the value pointed to by v.
//
// See the documentation for Unmarshal for details about the
// conversion of YAML into a Go value.
func (n *Node) Decode(v interface{}) (err error) {
d := newDecoder()
defer handleErr(&err)
out := reflect.ValueOf(v)
if out.Kind() == reflect.Ptr && !out.IsNil() {
out = out.Elem()
}
d.unmarshal(n, out)
if len(d.terrors) > 0 {
return &TypeError{d.terrors}
}
return nil
}
func unmarshal(in []byte, out interface{}, strict bool) (err error) {
defer handleErr(&err)
d := newDecoder()
p := newParser(in)
defer p.destroy()
node := p.parse()
if node != nil {
v := reflect.ValueOf(out)
if v.Kind() == reflect.Ptr && !v.IsNil() {
v = v.Elem()
}
d.unmarshal(node, v)
}
if len(d.terrors) > 0 {
return &TypeError{d.terrors}
}
return nil
}
// Marshal serializes the value provided into a YAML document. The structure
// of the generated document will reflect the structure of the value itself.
// Maps and pointers (to struct, string, int, etc) are accepted as the in value.
//
// Struct fields are only marshalled if they are exported (have an upper case
// first letter), and are marshalled using the field name lowercased as the
// default key. Custom keys may be defined via the "yaml" name in the field
// tag: the content preceding the first comma is used as the key, and the
// following comma-separated options are used to tweak the marshalling process.
// Conflicting names result in a runtime error.
//
// The field tag format accepted is:
//
// `(...) yaml:"[<key>][,<flag1>[,<flag2>]]" (...)`
//
// The following flags are currently supported:
//
// omitempty Only include the field if it's not set to the zero
// value for the type or to empty slices or maps.
// Zero valued structs will be omitted if all their public
// fields are zero, unless they implement an IsZero
// method (see the IsZeroer interface type), in which
// case the field will be excluded if IsZero returns true.
//
// flow Marshal using a flow style (useful for structs,
// sequences and maps).
//
// inline Inline the field, which must be a struct or a map,
// causing all of its fields or keys to be processed as if
// they were part of the outer struct. For maps, keys must
// not conflict with the yaml keys of other struct fields.
//
// In addition, if the key is "-", the field is ignored.
//
// For example:
//
// type T struct {
// F int `yaml:"a,omitempty"`
// B int
// }
// yaml.Marshal(&T{B: 2}) // Returns "b: 2\n"
// yaml.Marshal(&T{F: 1}} // Returns "a: 1\nb: 0\n"
//
func Marshal(in interface{}) (out []byte, err error) {
defer handleErr(&err)
e := newEncoder()
defer e.destroy()
e.marshalDoc("", reflect.ValueOf(in))
e.finish()
out = e.out
return
}
// An Encoder writes YAML values to an output stream.
type Encoder struct {
encoder *encoder
}
// NewEncoder returns a new encoder that writes to w.
// The Encoder should be closed after use to flush all data
// to w.
func NewEncoder(w io.Writer) *Encoder {
return &Encoder{
encoder: newEncoderWithWriter(w),
}
}
// Encode writes the YAML encoding of v to the stream.
// If multiple items are encoded to the stream, the
// second and subsequent document will be preceded
// with a "---" document separator, but the first will not.
//
// See the documentation for Marshal for details about the conversion of Go
// values to YAML.
func (e *Encoder) Encode(v interface{}) (err error) {
defer handleErr(&err)
e.encoder.marshalDoc("", reflect.ValueOf(v))
return nil
}
// Encode encodes value v and stores its representation in n.
//
// See the documentation for Marshal for details about the
// conversion of Go values into YAML.
func (n *Node) Encode(v interface{}) (err error) {
defer handleErr(&err)
e := newEncoder()
defer e.destroy()
e.marshalDoc("", reflect.ValueOf(v))
e.finish()
p := newParser(e.out)
p.textless = true
defer p.destroy()
doc := p.parse()
*n = *doc.Content[0]
return nil
}
// SetIndent changes the used indentation used when encoding.
func (e *Encoder) SetIndent(spaces int) {
if spaces < 0 {
panic("yaml: cannot indent to a negative number of spaces")
}
e.encoder.indent = spaces
}
// Close closes the encoder by writing any remaining data.
// It does not write a stream terminating string "...".
func (e *Encoder) Close() (err error) {
defer handleErr(&err)
e.encoder.finish()
return nil
}
func handleErr(err *error) {
if v := recover(); v != nil {
if e, ok := v.(yamlError); ok {
*err = e.err
} else {
panic(v)
}
}
}
type yamlError struct {
err error
}
func fail(err error) {
panic(yamlError{err})
}
func failf(format string, args ...interface{}) {
panic(yamlError{fmt.Errorf("yaml: "+format, args...)})
}
// A TypeError is returned by Unmarshal when one or more fields in
// the YAML document cannot be properly decoded into the requested
// types. When this error is returned, the value is still
// unmarshaled partially.
type TypeError struct {
Errors []string
}
func (e *TypeError) Error() string {
return fmt.Sprintf("yaml: unmarshal errors:\n %s", strings.Join(e.Errors, "\n "))
}
type Kind uint32
const (
DocumentNode Kind = 1 << iota
SequenceNode
MappingNode
ScalarNode
AliasNode
)
type Style uint32
const (
TaggedStyle Style = 1 << iota
DoubleQuotedStyle
SingleQuotedStyle
LiteralStyle
FoldedStyle
FlowStyle
)
// Node represents an element in the YAML document hierarchy. While documents
// are typically encoded and decoded into higher level types, such as structs
// and maps, Node is an intermediate representation that allows detailed
// control over the content being decoded or encoded.
//
// It's worth noting that although Node offers access into details such as
// line numbers, colums, and comments, the content when re-encoded will not
// have its original textual representation preserved. An effort is made to
// render the data plesantly, and to preserve comments near the data they
// describe, though.
//
// Values that make use of the Node type interact with the yaml package in the
// same way any other type would do, by encoding and decoding yaml data
// directly or indirectly into them.
//
// For example:
//
// var person struct {
// Name string
// Address yaml.Node
// }
// err := yaml.Unmarshal(data, &person)
//
// Or by itself:
//
// var person Node
// err := yaml.Unmarshal(data, &person)
//
type Node struct {
// Kind defines whether the node is a document, a mapping, a sequence,
// a scalar value, or an alias to another node. The specific data type of
// scalar nodes may be obtained via the ShortTag and LongTag methods.
Kind Kind
// Style allows customizing the apperance of the node in the tree.
Style Style
// Tag holds the YAML tag defining the data type for the value.
// When decoding, this field will always be set to the resolved tag,
// even when it wasn't explicitly provided in the YAML content.
// When encoding, if this field is unset the value type will be
// implied from the node properties, and if it is set, it will only
// be serialized into the representation if TaggedStyle is used or
// the implicit tag diverges from the provided one.
Tag string
// Value holds the unescaped and unquoted represenation of the value.
Value string
// Anchor holds the anchor name for this node, which allows aliases to point to it.
Anchor string
// Alias holds the node that this alias points to. Only valid when Kind is AliasNode.
Alias *Node
// Content holds contained nodes for documents, mappings, and sequences.
Content []*Node
// HeadComment holds any comments in the lines preceding the node and
// not separated by an empty line.
HeadComment string
// LineComment holds any comments at the end of the line where the node is in.
LineComment string
// FootComment holds any comments following the node and before empty lines.
FootComment string
// Line and Column hold the node position in the decoded YAML text.
// These fields are not respected when encoding the node.
Line int
Column int
}
// IsZero returns whether the node has all of its fields unset.
func (n *Node) IsZero() bool {
return n.Kind == 0 && n.Style == 0 && n.Tag == "" && n.Value == "" && n.Anchor == "" && n.Alias == nil && n.Content == nil &&
n.HeadComment == "" && n.LineComment == "" && n.FootComment == "" && n.Line == 0 && n.Column == 0
}
// LongTag returns the long form of the tag that indicates the data type for
// the node. If the Tag field isn't explicitly defined, one will be computed
// based on the node properties.
func (n *Node) LongTag() string {
return longTag(n.ShortTag())
}
// ShortTag returns the short form of the YAML tag that indicates data type for
// the node. If the Tag field isn't explicitly defined, one will be computed
// based on the node properties.
func (n *Node) ShortTag() string {
if n.indicatedString() {
return strTag
}
if n.Tag == "" || n.Tag == "!" {
switch n.Kind {
case MappingNode:
return mapTag
case SequenceNode:
return seqTag
case AliasNode:
if n.Alias != nil {
return n.Alias.ShortTag()
}
case ScalarNode:
tag, _ := resolve("", n.Value)
return tag
case 0:
// Special case to make the zero value convenient.
if n.IsZero() {
return nullTag
}
}
return ""
}
return shortTag(n.Tag)
}
func (n *Node) indicatedString() bool {
return n.Kind == ScalarNode &&
(shortTag(n.Tag) == strTag ||
(n.Tag == "" || n.Tag == "!") && n.Style&(SingleQuotedStyle|DoubleQuotedStyle|LiteralStyle|FoldedStyle) != 0)
}
// SetString is a convenience function that sets the node to a string value
// and defines its style in a pleasant way depending on its content.
func (n *Node) SetString(s string) {
n.Kind = ScalarNode
if utf8.ValidString(s) {
n.Value = s
n.Tag = strTag
} else {
n.Value = encodeBase64(s)
n.Tag = binaryTag
}
if strings.Contains(n.Value, "\n") {
n.Style = LiteralStyle
}
}
// --------------------------------------------------------------------------
// Maintain a mapping of keys to structure field indexes
// The code in this section was copied from mgo/bson.
// structInfo holds details for the serialization of fields of
// a given struct.
type structInfo struct {
FieldsMap map[string]fieldInfo
FieldsList []fieldInfo
// InlineMap is the number of the field in the struct that
// contains an ,inline map, or -1 if there's none.
InlineMap int
// InlineUnmarshalers holds indexes to inlined fields that
// contain unmarshaler values.
InlineUnmarshalers [][]int
}
type fieldInfo struct {
Key string
Num int
OmitEmpty bool
Flow bool
// Id holds the unique field identifier, so we can cheaply
// check for field duplicates without maintaining an extra map.
Id int
// Inline holds the field index if the field is part of an inlined struct.
Inline []int
}
var structMap = make(map[reflect.Type]*structInfo)
var fieldMapMutex sync.RWMutex
var unmarshalerType reflect.Type
func init() {
var v Unmarshaler
unmarshalerType = reflect.ValueOf(&v).Elem().Type()
}
func getStructInfo(st reflect.Type) (*structInfo, error) {
fieldMapMutex.RLock()
sinfo, found := structMap[st]
fieldMapMutex.RUnlock()
if found {
return sinfo, nil
}
n := st.NumField()
fieldsMap := make(map[string]fieldInfo)
fieldsList := make([]fieldInfo, 0, n)
inlineMap := -1
inlineUnmarshalers := [][]int(nil)
for i := 0; i != n; i++ {
field := st.Field(i)
if field.PkgPath != "" && !field.Anonymous {
continue // Private field
}
info := fieldInfo{Num: i}
tag := field.Tag.Get("yaml")
if tag == "" && strings.Index(string(field.Tag), ":") < 0 {
tag = string(field.Tag)
}
if tag == "-" {
continue
}
inline := false
fields := strings.Split(tag, ",")
if len(fields) > 1 {
for _, flag := range fields[1:] {
switch flag {
case "omitempty":
info.OmitEmpty = true
case "flow":
info.Flow = true
case "inline":
inline = true
default:
return nil, errors.New(fmt.Sprintf("unsupported flag %q in tag %q of type %s", flag, tag, st))
}
}
tag = fields[0]
}
if inline {
switch field.Type.Kind() {
case reflect.Map:
if inlineMap >= 0 {
return nil, errors.New("multiple ,inline maps in struct " + st.String())
}
if field.Type.Key() != reflect.TypeOf("") {
return nil, errors.New("option ,inline needs a map with string keys in struct " + st.String())
}
inlineMap = info.Num
case reflect.Struct, reflect.Ptr:
ftype := field.Type
for ftype.Kind() == reflect.Ptr {
ftype = ftype.Elem()
}
if ftype.Kind() != reflect.Struct {
return nil, errors.New("option ,inline may only be used on a struct or map field")
}
if reflect.PtrTo(ftype).Implements(unmarshalerType) {
inlineUnmarshalers = append(inlineUnmarshalers, []int{i})
} else {
sinfo, err := getStructInfo(ftype)
if err != nil {
return nil, err
}
for _, index := range sinfo.InlineUnmarshalers {
inlineUnmarshalers = append(inlineUnmarshalers, append([]int{i}, index...))
}
for _, finfo := range sinfo.FieldsList {
if _, found := fieldsMap[finfo.Key]; found {
msg := "duplicated key '" + finfo.Key + "' in struct " + st.String()
return nil, errors.New(msg)
}
if finfo.Inline == nil {
finfo.Inline = []int{i, finfo.Num}
} else {
finfo.Inline = append([]int{i}, finfo.Inline...)
}
finfo.Id = len(fieldsList)
fieldsMap[finfo.Key] = finfo
fieldsList = append(fieldsList, finfo)
}
}
default:
return nil, errors.New("option ,inline may only be used on a struct or map field")
}
continue
}
if tag != "" {
info.Key = tag
} else {
info.Key = strings.ToLower(field.Name)
}
if _, found = fieldsMap[info.Key]; found {
msg := "duplicated key '" + info.Key + "' in struct " + st.String()
return nil, errors.New(msg)
}
info.Id = len(fieldsList)
fieldsList = append(fieldsList, info)
fieldsMap[info.Key] = info
}
sinfo = &structInfo{
FieldsMap: fieldsMap,
FieldsList: fieldsList,
InlineMap: inlineMap,
InlineUnmarshalers: inlineUnmarshalers,
}
fieldMapMutex.Lock()
structMap[st] = sinfo
fieldMapMutex.Unlock()
return sinfo, nil
}
// IsZeroer is used to check whether an object is zero to
// determine whether it should be omitted when marshaling
// with the omitempty flag. One notable implementation
// is time.Time.
type IsZeroer interface {
IsZero() bool
}
func isZero(v reflect.Value) bool {
kind := v.Kind()
if z, ok := v.Interface().(IsZeroer); ok {
if (kind == reflect.Ptr || kind == reflect.Interface) && v.IsNil() {
return true
}
return z.IsZero()
}
switch kind {
case reflect.String:
return len(v.String()) == 0
case reflect.Interface, reflect.Ptr:
return v.IsNil()
case reflect.Slice:
return v.Len() == 0
case reflect.Map:
return v.Len() == 0
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() == 0
case reflect.Float32, reflect.Float64:
return v.Float() == 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return v.Uint() == 0
case reflect.Bool:
return !v.Bool()
case reflect.Struct:
vt := v.Type()
for i := v.NumField() - 1; i >= 0; i-- {
if vt.Field(i).PkgPath != "" {
continue // Private field
}
if !isZero(v.Field(i)) {
return false
}
}
return true
}
return false
}
+807
View File
@@ -0,0 +1,807 @@
//
// Copyright (c) 2011-2019 Canonical Ltd
// Copyright (c) 2006-2010 Kirill Simonov
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package yaml
import (
"fmt"
"io"
)
// The version directive data.
type yaml_version_directive_t struct {
major int8 // The major version number.
minor int8 // The minor version number.
}
// The tag directive data.
type yaml_tag_directive_t struct {
handle []byte // The tag handle.
prefix []byte // The tag prefix.
}
type yaml_encoding_t int
// The stream encoding.
const (
// Let the parser choose the encoding.
yaml_ANY_ENCODING yaml_encoding_t = iota
yaml_UTF8_ENCODING // The default UTF-8 encoding.
yaml_UTF16LE_ENCODING // The UTF-16-LE encoding with BOM.
yaml_UTF16BE_ENCODING // The UTF-16-BE encoding with BOM.
)
type yaml_break_t int
// Line break types.
const (
// Let the parser choose the break type.
yaml_ANY_BREAK yaml_break_t = iota
yaml_CR_BREAK // Use CR for line breaks (Mac style).
yaml_LN_BREAK // Use LN for line breaks (Unix style).
yaml_CRLN_BREAK // Use CR LN for line breaks (DOS style).
)
type yaml_error_type_t int
// Many bad things could happen with the parser and emitter.
const (
// No error is produced.
yaml_NO_ERROR yaml_error_type_t = iota
yaml_MEMORY_ERROR // Cannot allocate or reallocate a block of memory.
yaml_READER_ERROR // Cannot read or decode the input stream.
yaml_SCANNER_ERROR // Cannot scan the input stream.
yaml_PARSER_ERROR // Cannot parse the input stream.
yaml_COMPOSER_ERROR // Cannot compose a YAML document.
yaml_WRITER_ERROR // Cannot write to the output stream.
yaml_EMITTER_ERROR // Cannot emit a YAML stream.
)
// The pointer position.
type yaml_mark_t struct {
index int // The position index.
line int // The position line.
column int // The position column.
}
// Node Styles
type yaml_style_t int8
type yaml_scalar_style_t yaml_style_t
// Scalar styles.
const (
// Let the emitter choose the style.
yaml_ANY_SCALAR_STYLE yaml_scalar_style_t = 0
yaml_PLAIN_SCALAR_STYLE yaml_scalar_style_t = 1 << iota // The plain scalar style.
yaml_SINGLE_QUOTED_SCALAR_STYLE // The single-quoted scalar style.
yaml_DOUBLE_QUOTED_SCALAR_STYLE // The double-quoted scalar style.
yaml_LITERAL_SCALAR_STYLE // The literal scalar style.
yaml_FOLDED_SCALAR_STYLE // The folded scalar style.
)
type yaml_sequence_style_t yaml_style_t
// Sequence styles.
const (
// Let the emitter choose the style.
yaml_ANY_SEQUENCE_STYLE yaml_sequence_style_t = iota
yaml_BLOCK_SEQUENCE_STYLE // The block sequence style.
yaml_FLOW_SEQUENCE_STYLE // The flow sequence style.
)
type yaml_mapping_style_t yaml_style_t
// Mapping styles.
const (
// Let the emitter choose the style.
yaml_ANY_MAPPING_STYLE yaml_mapping_style_t = iota
yaml_BLOCK_MAPPING_STYLE // The block mapping style.
yaml_FLOW_MAPPING_STYLE // The flow mapping style.
)
// Tokens
type yaml_token_type_t int
// Token types.
const (
// An empty token.
yaml_NO_TOKEN yaml_token_type_t = iota
yaml_STREAM_START_TOKEN // A STREAM-START token.
yaml_STREAM_END_TOKEN // A STREAM-END token.
yaml_VERSION_DIRECTIVE_TOKEN // A VERSION-DIRECTIVE token.
yaml_TAG_DIRECTIVE_TOKEN // A TAG-DIRECTIVE token.
yaml_DOCUMENT_START_TOKEN // A DOCUMENT-START token.
yaml_DOCUMENT_END_TOKEN // A DOCUMENT-END token.
yaml_BLOCK_SEQUENCE_START_TOKEN // A BLOCK-SEQUENCE-START token.
yaml_BLOCK_MAPPING_START_TOKEN // A BLOCK-SEQUENCE-END token.
yaml_BLOCK_END_TOKEN // A BLOCK-END token.
yaml_FLOW_SEQUENCE_START_TOKEN // A FLOW-SEQUENCE-START token.
yaml_FLOW_SEQUENCE_END_TOKEN // A FLOW-SEQUENCE-END token.
yaml_FLOW_MAPPING_START_TOKEN // A FLOW-MAPPING-START token.
yaml_FLOW_MAPPING_END_TOKEN // A FLOW-MAPPING-END token.
yaml_BLOCK_ENTRY_TOKEN // A BLOCK-ENTRY token.
yaml_FLOW_ENTRY_TOKEN // A FLOW-ENTRY token.
yaml_KEY_TOKEN // A KEY token.
yaml_VALUE_TOKEN // A VALUE token.
yaml_ALIAS_TOKEN // An ALIAS token.
yaml_ANCHOR_TOKEN // An ANCHOR token.
yaml_TAG_TOKEN // A TAG token.
yaml_SCALAR_TOKEN // A SCALAR token.
)
func (tt yaml_token_type_t) String() string {
switch tt {
case yaml_NO_TOKEN:
return "yaml_NO_TOKEN"
case yaml_STREAM_START_TOKEN:
return "yaml_STREAM_START_TOKEN"
case yaml_STREAM_END_TOKEN:
return "yaml_STREAM_END_TOKEN"
case yaml_VERSION_DIRECTIVE_TOKEN:
return "yaml_VERSION_DIRECTIVE_TOKEN"
case yaml_TAG_DIRECTIVE_TOKEN:
return "yaml_TAG_DIRECTIVE_TOKEN"
case yaml_DOCUMENT_START_TOKEN:
return "yaml_DOCUMENT_START_TOKEN"
case yaml_DOCUMENT_END_TOKEN:
return "yaml_DOCUMENT_END_TOKEN"
case yaml_BLOCK_SEQUENCE_START_TOKEN:
return "yaml_BLOCK_SEQUENCE_START_TOKEN"
case yaml_BLOCK_MAPPING_START_TOKEN:
return "yaml_BLOCK_MAPPING_START_TOKEN"
case yaml_BLOCK_END_TOKEN:
return "yaml_BLOCK_END_TOKEN"
case yaml_FLOW_SEQUENCE_START_TOKEN:
return "yaml_FLOW_SEQUENCE_START_TOKEN"
case yaml_FLOW_SEQUENCE_END_TOKEN:
return "yaml_FLOW_SEQUENCE_END_TOKEN"
case yaml_FLOW_MAPPING_START_TOKEN:
return "yaml_FLOW_MAPPING_START_TOKEN"
case yaml_FLOW_MAPPING_END_TOKEN:
return "yaml_FLOW_MAPPING_END_TOKEN"
case yaml_BLOCK_ENTRY_TOKEN:
return "yaml_BLOCK_ENTRY_TOKEN"
case yaml_FLOW_ENTRY_TOKEN:
return "yaml_FLOW_ENTRY_TOKEN"
case yaml_KEY_TOKEN:
return "yaml_KEY_TOKEN"
case yaml_VALUE_TOKEN:
return "yaml_VALUE_TOKEN"
case yaml_ALIAS_TOKEN:
return "yaml_ALIAS_TOKEN"
case yaml_ANCHOR_TOKEN:
return "yaml_ANCHOR_TOKEN"
case yaml_TAG_TOKEN:
return "yaml_TAG_TOKEN"
case yaml_SCALAR_TOKEN:
return "yaml_SCALAR_TOKEN"
}
return "<unknown token>"
}
// The token structure.
type yaml_token_t struct {
// The token type.
typ yaml_token_type_t
// The start/end of the token.
start_mark, end_mark yaml_mark_t
// The stream encoding (for yaml_STREAM_START_TOKEN).
encoding yaml_encoding_t
// The alias/anchor/scalar value or tag/tag directive handle
// (for yaml_ALIAS_TOKEN, yaml_ANCHOR_TOKEN, yaml_SCALAR_TOKEN, yaml_TAG_TOKEN, yaml_TAG_DIRECTIVE_TOKEN).
value []byte
// The tag suffix (for yaml_TAG_TOKEN).
suffix []byte
// The tag directive prefix (for yaml_TAG_DIRECTIVE_TOKEN).
prefix []byte
// The scalar style (for yaml_SCALAR_TOKEN).
style yaml_scalar_style_t
// The version directive major/minor (for yaml_VERSION_DIRECTIVE_TOKEN).
major, minor int8
}
// Events
type yaml_event_type_t int8
// Event types.
const (
// An empty event.
yaml_NO_EVENT yaml_event_type_t = iota
yaml_STREAM_START_EVENT // A STREAM-START event.
yaml_STREAM_END_EVENT // A STREAM-END event.
yaml_DOCUMENT_START_EVENT // A DOCUMENT-START event.
yaml_DOCUMENT_END_EVENT // A DOCUMENT-END event.
yaml_ALIAS_EVENT // An ALIAS event.
yaml_SCALAR_EVENT // A SCALAR event.
yaml_SEQUENCE_START_EVENT // A SEQUENCE-START event.
yaml_SEQUENCE_END_EVENT // A SEQUENCE-END event.
yaml_MAPPING_START_EVENT // A MAPPING-START event.
yaml_MAPPING_END_EVENT // A MAPPING-END event.
yaml_TAIL_COMMENT_EVENT
)
var eventStrings = []string{
yaml_NO_EVENT: "none",
yaml_STREAM_START_EVENT: "stream start",
yaml_STREAM_END_EVENT: "stream end",
yaml_DOCUMENT_START_EVENT: "document start",
yaml_DOCUMENT_END_EVENT: "document end",
yaml_ALIAS_EVENT: "alias",
yaml_SCALAR_EVENT: "scalar",
yaml_SEQUENCE_START_EVENT: "sequence start",
yaml_SEQUENCE_END_EVENT: "sequence end",
yaml_MAPPING_START_EVENT: "mapping start",
yaml_MAPPING_END_EVENT: "mapping end",
yaml_TAIL_COMMENT_EVENT: "tail comment",
}
func (e yaml_event_type_t) String() string {
if e < 0 || int(e) >= len(eventStrings) {
return fmt.Sprintf("unknown event %d", e)
}
return eventStrings[e]
}
// The event structure.
type yaml_event_t struct {
// The event type.
typ yaml_event_type_t
// The start and end of the event.
start_mark, end_mark yaml_mark_t
// The document encoding (for yaml_STREAM_START_EVENT).
encoding yaml_encoding_t
// The version directive (for yaml_DOCUMENT_START_EVENT).
version_directive *yaml_version_directive_t
// The list of tag directives (for yaml_DOCUMENT_START_EVENT).
tag_directives []yaml_tag_directive_t
// The comments
head_comment []byte
line_comment []byte
foot_comment []byte
tail_comment []byte
// The anchor (for yaml_SCALAR_EVENT, yaml_SEQUENCE_START_EVENT, yaml_MAPPING_START_EVENT, yaml_ALIAS_EVENT).
anchor []byte
// The tag (for yaml_SCALAR_EVENT, yaml_SEQUENCE_START_EVENT, yaml_MAPPING_START_EVENT).
tag []byte
// The scalar value (for yaml_SCALAR_EVENT).
value []byte
// Is the document start/end indicator implicit, or the tag optional?
// (for yaml_DOCUMENT_START_EVENT, yaml_DOCUMENT_END_EVENT, yaml_SEQUENCE_START_EVENT, yaml_MAPPING_START_EVENT, yaml_SCALAR_EVENT).
implicit bool
// Is the tag optional for any non-plain style? (for yaml_SCALAR_EVENT).
quoted_implicit bool
// The style (for yaml_SCALAR_EVENT, yaml_SEQUENCE_START_EVENT, yaml_MAPPING_START_EVENT).
style yaml_style_t
}
func (e *yaml_event_t) scalar_style() yaml_scalar_style_t { return yaml_scalar_style_t(e.style) }
func (e *yaml_event_t) sequence_style() yaml_sequence_style_t { return yaml_sequence_style_t(e.style) }
func (e *yaml_event_t) mapping_style() yaml_mapping_style_t { return yaml_mapping_style_t(e.style) }
// Nodes
const (
yaml_NULL_TAG = "tag:yaml.org,2002:null" // The tag !!null with the only possible value: null.
yaml_BOOL_TAG = "tag:yaml.org,2002:bool" // The tag !!bool with the values: true and false.
yaml_STR_TAG = "tag:yaml.org,2002:str" // The tag !!str for string values.
yaml_INT_TAG = "tag:yaml.org,2002:int" // The tag !!int for integer values.
yaml_FLOAT_TAG = "tag:yaml.org,2002:float" // The tag !!float for float values.
yaml_TIMESTAMP_TAG = "tag:yaml.org,2002:timestamp" // The tag !!timestamp for date and time values.
yaml_SEQ_TAG = "tag:yaml.org,2002:seq" // The tag !!seq is used to denote sequences.
yaml_MAP_TAG = "tag:yaml.org,2002:map" // The tag !!map is used to denote mapping.
// Not in original libyaml.
yaml_BINARY_TAG = "tag:yaml.org,2002:binary"
yaml_MERGE_TAG = "tag:yaml.org,2002:merge"
yaml_DEFAULT_SCALAR_TAG = yaml_STR_TAG // The default scalar tag is !!str.
yaml_DEFAULT_SEQUENCE_TAG = yaml_SEQ_TAG // The default sequence tag is !!seq.
yaml_DEFAULT_MAPPING_TAG = yaml_MAP_TAG // The default mapping tag is !!map.
)
type yaml_node_type_t int
// Node types.
const (
// An empty node.
yaml_NO_NODE yaml_node_type_t = iota
yaml_SCALAR_NODE // A scalar node.
yaml_SEQUENCE_NODE // A sequence node.
yaml_MAPPING_NODE // A mapping node.
)
// An element of a sequence node.
type yaml_node_item_t int
// An element of a mapping node.
type yaml_node_pair_t struct {
key int // The key of the element.
value int // The value of the element.
}
// The node structure.
type yaml_node_t struct {
typ yaml_node_type_t // The node type.
tag []byte // The node tag.
// The node data.
// The scalar parameters (for yaml_SCALAR_NODE).
scalar struct {
value []byte // The scalar value.
length int // The length of the scalar value.
style yaml_scalar_style_t // The scalar style.
}
// The sequence parameters (for YAML_SEQUENCE_NODE).
sequence struct {
items_data []yaml_node_item_t // The stack of sequence items.
style yaml_sequence_style_t // The sequence style.
}
// The mapping parameters (for yaml_MAPPING_NODE).
mapping struct {
pairs_data []yaml_node_pair_t // The stack of mapping pairs (key, value).
pairs_start *yaml_node_pair_t // The beginning of the stack.
pairs_end *yaml_node_pair_t // The end of the stack.
pairs_top *yaml_node_pair_t // The top of the stack.
style yaml_mapping_style_t // The mapping style.
}
start_mark yaml_mark_t // The beginning of the node.
end_mark yaml_mark_t // The end of the node.
}
// The document structure.
type yaml_document_t struct {
// The document nodes.
nodes []yaml_node_t
// The version directive.
version_directive *yaml_version_directive_t
// The list of tag directives.
tag_directives_data []yaml_tag_directive_t
tag_directives_start int // The beginning of the tag directives list.
tag_directives_end int // The end of the tag directives list.
start_implicit int // Is the document start indicator implicit?
end_implicit int // Is the document end indicator implicit?
// The start/end of the document.
start_mark, end_mark yaml_mark_t
}
// The prototype of a read handler.
//
// The read handler is called when the parser needs to read more bytes from the
// source. The handler should write not more than size bytes to the buffer.
// The number of written bytes should be set to the size_read variable.
//
// [in,out] data A pointer to an application data specified by
// yaml_parser_set_input().
// [out] buffer The buffer to write the data from the source.
// [in] size The size of the buffer.
// [out] size_read The actual number of bytes read from the source.
//
// On success, the handler should return 1. If the handler failed,
// the returned value should be 0. On EOF, the handler should set the
// size_read to 0 and return 1.
type yaml_read_handler_t func(parser *yaml_parser_t, buffer []byte) (n int, err error)
// This structure holds information about a potential simple key.
type yaml_simple_key_t struct {
possible bool // Is a simple key possible?
required bool // Is a simple key required?
token_number int // The number of the token.
mark yaml_mark_t // The position mark.
}
// The states of the parser.
type yaml_parser_state_t int
const (
yaml_PARSE_STREAM_START_STATE yaml_parser_state_t = iota
yaml_PARSE_IMPLICIT_DOCUMENT_START_STATE // Expect the beginning of an implicit document.
yaml_PARSE_DOCUMENT_START_STATE // Expect DOCUMENT-START.
yaml_PARSE_DOCUMENT_CONTENT_STATE // Expect the content of a document.
yaml_PARSE_DOCUMENT_END_STATE // Expect DOCUMENT-END.
yaml_PARSE_BLOCK_NODE_STATE // Expect a block node.
yaml_PARSE_BLOCK_NODE_OR_INDENTLESS_SEQUENCE_STATE // Expect a block node or indentless sequence.
yaml_PARSE_FLOW_NODE_STATE // Expect a flow node.
yaml_PARSE_BLOCK_SEQUENCE_FIRST_ENTRY_STATE // Expect the first entry of a block sequence.
yaml_PARSE_BLOCK_SEQUENCE_ENTRY_STATE // Expect an entry of a block sequence.
yaml_PARSE_INDENTLESS_SEQUENCE_ENTRY_STATE // Expect an entry of an indentless sequence.
yaml_PARSE_BLOCK_MAPPING_FIRST_KEY_STATE // Expect the first key of a block mapping.
yaml_PARSE_BLOCK_MAPPING_KEY_STATE // Expect a block mapping key.
yaml_PARSE_BLOCK_MAPPING_VALUE_STATE // Expect a block mapping value.
yaml_PARSE_FLOW_SEQUENCE_FIRST_ENTRY_STATE // Expect the first entry of a flow sequence.
yaml_PARSE_FLOW_SEQUENCE_ENTRY_STATE // Expect an entry of a flow sequence.
yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_KEY_STATE // Expect a key of an ordered mapping.
yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_VALUE_STATE // Expect a value of an ordered mapping.
yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_END_STATE // Expect the and of an ordered mapping entry.
yaml_PARSE_FLOW_MAPPING_FIRST_KEY_STATE // Expect the first key of a flow mapping.
yaml_PARSE_FLOW_MAPPING_KEY_STATE // Expect a key of a flow mapping.
yaml_PARSE_FLOW_MAPPING_VALUE_STATE // Expect a value of a flow mapping.
yaml_PARSE_FLOW_MAPPING_EMPTY_VALUE_STATE // Expect an empty value of a flow mapping.
yaml_PARSE_END_STATE // Expect nothing.
)
func (ps yaml_parser_state_t) String() string {
switch ps {
case yaml_PARSE_STREAM_START_STATE:
return "yaml_PARSE_STREAM_START_STATE"
case yaml_PARSE_IMPLICIT_DOCUMENT_START_STATE:
return "yaml_PARSE_IMPLICIT_DOCUMENT_START_STATE"
case yaml_PARSE_DOCUMENT_START_STATE:
return "yaml_PARSE_DOCUMENT_START_STATE"
case yaml_PARSE_DOCUMENT_CONTENT_STATE:
return "yaml_PARSE_DOCUMENT_CONTENT_STATE"
case yaml_PARSE_DOCUMENT_END_STATE:
return "yaml_PARSE_DOCUMENT_END_STATE"
case yaml_PARSE_BLOCK_NODE_STATE:
return "yaml_PARSE_BLOCK_NODE_STATE"
case yaml_PARSE_BLOCK_NODE_OR_INDENTLESS_SEQUENCE_STATE:
return "yaml_PARSE_BLOCK_NODE_OR_INDENTLESS_SEQUENCE_STATE"
case yaml_PARSE_FLOW_NODE_STATE:
return "yaml_PARSE_FLOW_NODE_STATE"
case yaml_PARSE_BLOCK_SEQUENCE_FIRST_ENTRY_STATE:
return "yaml_PARSE_BLOCK_SEQUENCE_FIRST_ENTRY_STATE"
case yaml_PARSE_BLOCK_SEQUENCE_ENTRY_STATE:
return "yaml_PARSE_BLOCK_SEQUENCE_ENTRY_STATE"
case yaml_PARSE_INDENTLESS_SEQUENCE_ENTRY_STATE:
return "yaml_PARSE_INDENTLESS_SEQUENCE_ENTRY_STATE"
case yaml_PARSE_BLOCK_MAPPING_FIRST_KEY_STATE:
return "yaml_PARSE_BLOCK_MAPPING_FIRST_KEY_STATE"
case yaml_PARSE_BLOCK_MAPPING_KEY_STATE:
return "yaml_PARSE_BLOCK_MAPPING_KEY_STATE"
case yaml_PARSE_BLOCK_MAPPING_VALUE_STATE:
return "yaml_PARSE_BLOCK_MAPPING_VALUE_STATE"
case yaml_PARSE_FLOW_SEQUENCE_FIRST_ENTRY_STATE:
return "yaml_PARSE_FLOW_SEQUENCE_FIRST_ENTRY_STATE"
case yaml_PARSE_FLOW_SEQUENCE_ENTRY_STATE:
return "yaml_PARSE_FLOW_SEQUENCE_ENTRY_STATE"
case yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_KEY_STATE:
return "yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_KEY_STATE"
case yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_VALUE_STATE:
return "yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_VALUE_STATE"
case yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_END_STATE:
return "yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_END_STATE"
case yaml_PARSE_FLOW_MAPPING_FIRST_KEY_STATE:
return "yaml_PARSE_FLOW_MAPPING_FIRST_KEY_STATE"
case yaml_PARSE_FLOW_MAPPING_KEY_STATE:
return "yaml_PARSE_FLOW_MAPPING_KEY_STATE"
case yaml_PARSE_FLOW_MAPPING_VALUE_STATE:
return "yaml_PARSE_FLOW_MAPPING_VALUE_STATE"
case yaml_PARSE_FLOW_MAPPING_EMPTY_VALUE_STATE:
return "yaml_PARSE_FLOW_MAPPING_EMPTY_VALUE_STATE"
case yaml_PARSE_END_STATE:
return "yaml_PARSE_END_STATE"
}
return "<unknown parser state>"
}
// This structure holds aliases data.
type yaml_alias_data_t struct {
anchor []byte // The anchor.
index int // The node id.
mark yaml_mark_t // The anchor mark.
}
// The parser structure.
//
// All members are internal. Manage the structure using the
// yaml_parser_ family of functions.
type yaml_parser_t struct {
// Error handling
error yaml_error_type_t // Error type.
problem string // Error description.
// The byte about which the problem occurred.
problem_offset int
problem_value int
problem_mark yaml_mark_t
// The error context.
context string
context_mark yaml_mark_t
// Reader stuff
read_handler yaml_read_handler_t // Read handler.
input_reader io.Reader // File input data.
input []byte // String input data.
input_pos int
eof bool // EOF flag
buffer []byte // The working buffer.
buffer_pos int // The current position of the buffer.
unread int // The number of unread characters in the buffer.
newlines int // The number of line breaks since last non-break/non-blank character
raw_buffer []byte // The raw buffer.
raw_buffer_pos int // The current position of the buffer.
encoding yaml_encoding_t // The input encoding.
offset int // The offset of the current position (in bytes).
mark yaml_mark_t // The mark of the current position.
// Comments
head_comment []byte // The current head comments
line_comment []byte // The current line comments
foot_comment []byte // The current foot comments
tail_comment []byte // Foot comment that happens at the end of a block.
stem_comment []byte // Comment in item preceding a nested structure (list inside list item, etc)
comments []yaml_comment_t // The folded comments for all parsed tokens
comments_head int
// Scanner stuff
stream_start_produced bool // Have we started to scan the input stream?
stream_end_produced bool // Have we reached the end of the input stream?
flow_level int // The number of unclosed '[' and '{' indicators.
tokens []yaml_token_t // The tokens queue.
tokens_head int // The head of the tokens queue.
tokens_parsed int // The number of tokens fetched from the queue.
token_available bool // Does the tokens queue contain a token ready for dequeueing.
indent int // The current indentation level.
indents []int // The indentation levels stack.
simple_key_allowed bool // May a simple key occur at the current position?
simple_keys []yaml_simple_key_t // The stack of simple keys.
simple_keys_by_tok map[int]int // possible simple_key indexes indexed by token_number
// Parser stuff
state yaml_parser_state_t // The current parser state.
states []yaml_parser_state_t // The parser states stack.
marks []yaml_mark_t // The stack of marks.
tag_directives []yaml_tag_directive_t // The list of TAG directives.
// Dumper stuff
aliases []yaml_alias_data_t // The alias data.
document *yaml_document_t // The currently parsed document.
}
type yaml_comment_t struct {
scan_mark yaml_mark_t // Position where scanning for comments started
token_mark yaml_mark_t // Position after which tokens will be associated with this comment
start_mark yaml_mark_t // Position of '#' comment mark
end_mark yaml_mark_t // Position where comment terminated
head []byte
line []byte
foot []byte
}
// Emitter Definitions
// The prototype of a write handler.
//
// The write handler is called when the emitter needs to flush the accumulated
// characters to the output. The handler should write @a size bytes of the
// @a buffer to the output.
//
// @param[in,out] data A pointer to an application data specified by
// yaml_emitter_set_output().
// @param[in] buffer The buffer with bytes to be written.
// @param[in] size The size of the buffer.
//
// @returns On success, the handler should return @c 1. If the handler failed,
// the returned value should be @c 0.
//
type yaml_write_handler_t func(emitter *yaml_emitter_t, buffer []byte) error
type yaml_emitter_state_t int
// The emitter states.
const (
// Expect STREAM-START.
yaml_EMIT_STREAM_START_STATE yaml_emitter_state_t = iota
yaml_EMIT_FIRST_DOCUMENT_START_STATE // Expect the first DOCUMENT-START or STREAM-END.
yaml_EMIT_DOCUMENT_START_STATE // Expect DOCUMENT-START or STREAM-END.
yaml_EMIT_DOCUMENT_CONTENT_STATE // Expect the content of a document.
yaml_EMIT_DOCUMENT_END_STATE // Expect DOCUMENT-END.
yaml_EMIT_FLOW_SEQUENCE_FIRST_ITEM_STATE // Expect the first item of a flow sequence.
yaml_EMIT_FLOW_SEQUENCE_TRAIL_ITEM_STATE // Expect the next item of a flow sequence, with the comma already written out
yaml_EMIT_FLOW_SEQUENCE_ITEM_STATE // Expect an item of a flow sequence.
yaml_EMIT_FLOW_MAPPING_FIRST_KEY_STATE // Expect the first key of a flow mapping.
yaml_EMIT_FLOW_MAPPING_TRAIL_KEY_STATE // Expect the next key of a flow mapping, with the comma already written out
yaml_EMIT_FLOW_MAPPING_KEY_STATE // Expect a key of a flow mapping.
yaml_EMIT_FLOW_MAPPING_SIMPLE_VALUE_STATE // Expect a value for a simple key of a flow mapping.
yaml_EMIT_FLOW_MAPPING_VALUE_STATE // Expect a value of a flow mapping.
yaml_EMIT_BLOCK_SEQUENCE_FIRST_ITEM_STATE // Expect the first item of a block sequence.
yaml_EMIT_BLOCK_SEQUENCE_ITEM_STATE // Expect an item of a block sequence.
yaml_EMIT_BLOCK_MAPPING_FIRST_KEY_STATE // Expect the first key of a block mapping.
yaml_EMIT_BLOCK_MAPPING_KEY_STATE // Expect the key of a block mapping.
yaml_EMIT_BLOCK_MAPPING_SIMPLE_VALUE_STATE // Expect a value for a simple key of a block mapping.
yaml_EMIT_BLOCK_MAPPING_VALUE_STATE // Expect a value of a block mapping.
yaml_EMIT_END_STATE // Expect nothing.
)
// The emitter structure.
//
// All members are internal. Manage the structure using the @c yaml_emitter_
// family of functions.
type yaml_emitter_t struct {
// Error handling
error yaml_error_type_t // Error type.
problem string // Error description.
// Writer stuff
write_handler yaml_write_handler_t // Write handler.
output_buffer *[]byte // String output data.
output_writer io.Writer // File output data.
buffer []byte // The working buffer.
buffer_pos int // The current position of the buffer.
raw_buffer []byte // The raw buffer.
raw_buffer_pos int // The current position of the buffer.
encoding yaml_encoding_t // The stream encoding.
// Emitter stuff
canonical bool // If the output is in the canonical style?
best_indent int // The number of indentation spaces.
best_width int // The preferred width of the output lines.
unicode bool // Allow unescaped non-ASCII characters?
line_break yaml_break_t // The preferred line break.
state yaml_emitter_state_t // The current emitter state.
states []yaml_emitter_state_t // The stack of states.
events []yaml_event_t // The event queue.
events_head int // The head of the event queue.
indents []int // The stack of indentation levels.
tag_directives []yaml_tag_directive_t // The list of tag directives.
indent int // The current indentation level.
flow_level int // The current flow level.
root_context bool // Is it the document root context?
sequence_context bool // Is it a sequence context?
mapping_context bool // Is it a mapping context?
simple_key_context bool // Is it a simple mapping key context?
line int // The current line.
column int // The current column.
whitespace bool // If the last character was a whitespace?
indention bool // If the last character was an indentation character (' ', '-', '?', ':')?
open_ended bool // If an explicit document end is required?
space_above bool // Is there's an empty line above?
foot_indent int // The indent used to write the foot comment above, or -1 if none.
// Anchor analysis.
anchor_data struct {
anchor []byte // The anchor value.
alias bool // Is it an alias?
}
// Tag analysis.
tag_data struct {
handle []byte // The tag handle.
suffix []byte // The tag suffix.
}
// Scalar analysis.
scalar_data struct {
value []byte // The scalar value.
multiline bool // Does the scalar contain line breaks?
flow_plain_allowed bool // Can the scalar be expessed in the flow plain style?
block_plain_allowed bool // Can the scalar be expressed in the block plain style?
single_quoted_allowed bool // Can the scalar be expressed in the single quoted style?
block_allowed bool // Can the scalar be expressed in the literal or folded styles?
style yaml_scalar_style_t // The output style.
}
// Comments
head_comment []byte
line_comment []byte
foot_comment []byte
tail_comment []byte
key_line_comment []byte
// Dumper stuff
opened bool // If the stream was already opened?
closed bool // If the stream was already closed?
// The information associated with the document nodes.
anchors *struct {
references int // The number of references.
anchor int // The anchor id.
serialized bool // If the node has been emitted?
}
last_anchor_id int // The last assigned anchor id.
document *yaml_document_t // The currently emitted document.
}
+198
View File
@@ -0,0 +1,198 @@
//
// Copyright (c) 2011-2019 Canonical Ltd
// Copyright (c) 2006-2010 Kirill Simonov
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package yaml
const (
// The size of the input raw buffer.
input_raw_buffer_size = 512
// The size of the input buffer.
// It should be possible to decode the whole raw buffer.
input_buffer_size = input_raw_buffer_size * 3
// The size of the output buffer.
output_buffer_size = 128
// The size of the output raw buffer.
// It should be possible to encode the whole output buffer.
output_raw_buffer_size = (output_buffer_size*2 + 2)
// The size of other stacks and queues.
initial_stack_size = 16
initial_queue_size = 16
initial_string_size = 16
)
// Check if the character at the specified position is an alphabetical
// character, a digit, '_', or '-'.
func is_alpha(b []byte, i int) bool {
return b[i] >= '0' && b[i] <= '9' || b[i] >= 'A' && b[i] <= 'Z' || b[i] >= 'a' && b[i] <= 'z' || b[i] == '_' || b[i] == '-'
}
// Check if the character at the specified position is a digit.
func is_digit(b []byte, i int) bool {
return b[i] >= '0' && b[i] <= '9'
}
// Get the value of a digit.
func as_digit(b []byte, i int) int {
return int(b[i]) - '0'
}
// Check if the character at the specified position is a hex-digit.
func is_hex(b []byte, i int) bool {
return b[i] >= '0' && b[i] <= '9' || b[i] >= 'A' && b[i] <= 'F' || b[i] >= 'a' && b[i] <= 'f'
}
// Get the value of a hex-digit.
func as_hex(b []byte, i int) int {
bi := b[i]
if bi >= 'A' && bi <= 'F' {
return int(bi) - 'A' + 10
}
if bi >= 'a' && bi <= 'f' {
return int(bi) - 'a' + 10
}
return int(bi) - '0'
}
// Check if the character is ASCII.
func is_ascii(b []byte, i int) bool {
return b[i] <= 0x7F
}
// Check if the character at the start of the buffer can be printed unescaped.
func is_printable(b []byte, i int) bool {
return ((b[i] == 0x0A) || // . == #x0A
(b[i] >= 0x20 && b[i] <= 0x7E) || // #x20 <= . <= #x7E
(b[i] == 0xC2 && b[i+1] >= 0xA0) || // #0xA0 <= . <= #xD7FF
(b[i] > 0xC2 && b[i] < 0xED) ||
(b[i] == 0xED && b[i+1] < 0xA0) ||
(b[i] == 0xEE) ||
(b[i] == 0xEF && // #xE000 <= . <= #xFFFD
!(b[i+1] == 0xBB && b[i+2] == 0xBF) && // && . != #xFEFF
!(b[i+1] == 0xBF && (b[i+2] == 0xBE || b[i+2] == 0xBF))))
}
// Check if the character at the specified position is NUL.
func is_z(b []byte, i int) bool {
return b[i] == 0x00
}
// Check if the beginning of the buffer is a BOM.
func is_bom(b []byte, i int) bool {
return b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF
}
// Check if the character at the specified position is space.
func is_space(b []byte, i int) bool {
return b[i] == ' '
}
// Check if the character at the specified position is tab.
func is_tab(b []byte, i int) bool {
return b[i] == '\t'
}
// Check if the character at the specified position is blank (space or tab).
func is_blank(b []byte, i int) bool {
//return is_space(b, i) || is_tab(b, i)
return b[i] == ' ' || b[i] == '\t'
}
// Check if the character at the specified position is a line break.
func is_break(b []byte, i int) bool {
return (b[i] == '\r' || // CR (#xD)
b[i] == '\n' || // LF (#xA)
b[i] == 0xC2 && b[i+1] == 0x85 || // NEL (#x85)
b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA8 || // LS (#x2028)
b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA9) // PS (#x2029)
}
func is_crlf(b []byte, i int) bool {
return b[i] == '\r' && b[i+1] == '\n'
}
// Check if the character is a line break or NUL.
func is_breakz(b []byte, i int) bool {
//return is_break(b, i) || is_z(b, i)
return (
// is_break:
b[i] == '\r' || // CR (#xD)
b[i] == '\n' || // LF (#xA)
b[i] == 0xC2 && b[i+1] == 0x85 || // NEL (#x85)
b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA8 || // LS (#x2028)
b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA9 || // PS (#x2029)
// is_z:
b[i] == 0)
}
// Check if the character is a line break, space, or NUL.
func is_spacez(b []byte, i int) bool {
//return is_space(b, i) || is_breakz(b, i)
return (
// is_space:
b[i] == ' ' ||
// is_breakz:
b[i] == '\r' || // CR (#xD)
b[i] == '\n' || // LF (#xA)
b[i] == 0xC2 && b[i+1] == 0x85 || // NEL (#x85)
b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA8 || // LS (#x2028)
b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA9 || // PS (#x2029)
b[i] == 0)
}
// Check if the character is a line break, space, tab, or NUL.
func is_blankz(b []byte, i int) bool {
//return is_blank(b, i) || is_breakz(b, i)
return (
// is_blank:
b[i] == ' ' || b[i] == '\t' ||
// is_breakz:
b[i] == '\r' || // CR (#xD)
b[i] == '\n' || // LF (#xA)
b[i] == 0xC2 && b[i+1] == 0x85 || // NEL (#x85)
b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA8 || // LS (#x2028)
b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA9 || // PS (#x2029)
b[i] == 0)
}
// Determine the width of the character.
func width(b byte) int {
// Don't replace these by a switch without first
// confirming that it is being inlined.
if b&0x80 == 0x00 {
return 1
}
if b&0xE0 == 0xC0 {
return 2
}
if b&0xF0 == 0xE0 {
return 3
}
if b&0xF8 == 0xF0 {
return 4
}
return 0
}
+3
View File
@@ -0,0 +1,3 @@
# gopkg.in/yaml.v3 v3.0.1
## explicit
gopkg.in/yaml.v3