9480e48312
The plugin resolved its issue store from `__file__`, which put it inside a versioned plugin cache: issues written from one project were invisible from the next, and `origin: local` files — the only copy of that work by definition — were stranded a version bump at a time. The walk that answers "which directory is the project" was written three times over, and in a linked worktree the three disagreed. Both are runtime failures rather than logic ones, so the fix is a compiled binary: one walk, imported rather than re-derived, and a layering rule the build graph enforces instead of a grep. Seven packages, knowledge flowing one way. `project` answers which directory is the project and depends on nothing. `issue` is the domain — format, taxonomy, validation, checkboxes, dependency graph, the store, eviction — offline, with no tracker in it. `wire` holds the protocol shapes. `gitea` is the transport, `mapping` the bridge, `config` the credentials, `cmd` the command tree. Four tests hold the boundaries, each failing on a real mistake rather than a naming convention. The marker moves to `.kettle/` and the login pin moves out of the harness's settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens live in one file per machine, mode 0600, outside every working tree. That retires the PreToolUse guard hook entirely — the binary holds its own credentials, so a command running under a login nobody chose is not expressible rather than caught. `kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a move: a store left behind at an old path is one somebody edits by accident months later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
665 lines
21 KiB
Go
665 lines
21 KiB
Go
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)
|
|
}
|
|
}
|