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:
+233
@@ -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.
|
||||||
@@ -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:])) }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module git.noodles.cam/claude-skills/marketplace/cli
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
@@ -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=
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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, ", ")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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...))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ", ") + "]" }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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, "")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+2020
File diff suppressed because it is too large
Load Diff
+577
@@ -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
File diff suppressed because it is too large
Load Diff
+434
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+134
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
# gopkg.in/yaml.v3 v3.0.1
|
||||||
|
## explicit
|
||||||
|
gopkg.in/yaml.v3
|
||||||
Reference in New Issue
Block a user