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:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user