1239fdee70
The transport was hand-rolled net/http against the REST API. The payload shapes were ours, in internal/wire, which meant every field Gitea learned was a field somebody here had to notice; and "does this instance have issue dependencies?" had to be guessed from a status code, because a 404 from a missing route and a 404 from a missing issue look alike. The SDK settles both. The shapes are maintained by the people who maintain the server, and the client negotiates the server's version when it is built, so the dependency endpoint is now gated on `>= 1.20.0` — verified against the release where the route appears, not assumed. Below the gate nothing is requested at all. internal/wire keeps what the SDK has no answer for: addressing. The SDK takes an owner, a name and an int64 and never parses, while `42`, `#42`, `owner/repo#42` and an issue URL are four spellings of one address, all four are what somebody has in hand, and Key is what the ledger is keyed by. The payload structs go. Four things that had to survive the move, and did: - request bodies still land in .kettle/payload/, now via an http.RoundTripper on the client the SDK is given — which is better than before, because it files every request rather than the ones a call site remembered to name; - errors still carry the HTTP status AND the response body, and a decode failure on a 2xx is deliberately not an APIError, so the dependency probe cannot read a bad decode as "feature missing"; - the number -> slug ledger is untouched, entries still outlive the files they name; - Client.For(repo) still re-points at another repository for one call. What it cost, written down in AGENTS.md where it happened. internal/mapping's layering test was a fact about the import graph — nothing in its closure could open a socket — and the SDK ships its types and its client in one package, so the test now asserts what is still true: the bridge performs no I/O, checked on direct imports plus a grep for time.Now. A run makes one extra request before it does anything. Gitea's issue edit endpoint carries no labels, so a push whose labels changed needs a second call; push makes it and says so. go.mod requires go 1.26, which the SDK sets and which is now the floor for building this binary. internal/issue and internal/project are byte-identical. The domain did not notice, which is the whole argument for the layering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
685 lines
22 KiB
Go
685 lines
22 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.
|
|
//
|
|
// The fake answers /api/v1/version before anything else: the SDK asks an
|
|
// instance what it is before it hands back a client, so a fake that did not
|
|
// answer would fail every command at startup — and it is that answer the
|
|
// dependency gate is decided on, which is why it says a version new enough to
|
|
// have them.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
sdk "code.gitea.io/sdk/gitea"
|
|
|
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
|
)
|
|
|
|
// pullGiteaVersion is what both fakes in this package claim to be: new enough
|
|
// for the issue-dependency endpoints, which is what the transport gates on.
|
|
const pullGiteaVersion = "1.26.1"
|
|
|
|
// pullVersionRoute answers the version handshake and reports whether it did.
|
|
func pullVersionRoute(w http.ResponseWriter, r *http.Request) bool {
|
|
if r.URL.Path != "/api/v1/version" {
|
|
return false
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"version":"`+pullGiteaVersion+`"}`)
|
|
return true
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// the fake tracker
|
|
// --------------------------------------------------------------------------
|
|
|
|
// pullFakeGitea is a Gitea instance with one repository in it, owner/repo.
|
|
type pullFakeGitea struct {
|
|
mu sync.Mutex
|
|
issues map[int]*sdk.Issue
|
|
deps map[int][]int
|
|
comments map[int][]sdk.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]*sdk.Issue{},
|
|
deps: map[int][]int{},
|
|
comments: map[int][]sdk.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 sdk.Issue) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
if p.State == "" {
|
|
p.State = sdk.StateOpen
|
|
}
|
|
p.HTMLURL = pullURL(int(p.Index))
|
|
g.issues[int(p.Index)] = &p
|
|
if int(p.Index) > g.next {
|
|
g.next = int(p.Index)
|
|
}
|
|
}
|
|
|
|
func (g *pullFakeGitea) pullIssue(n int) sdk.Issue {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
if p := g.issues[n]; p != nil {
|
|
return *p
|
|
}
|
|
return sdk.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()
|
|
|
|
if pullVersionRoute(w, r) {
|
|
return
|
|
}
|
|
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 := []sdk.Label{}
|
|
for name, id := range g.labels {
|
|
out = append(out, sdk.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 sdk.CreateLabelOption
|
|
pullDecode(r, &req)
|
|
id := int64(1000 + len(g.labels))
|
|
g.labels[req.Name] = id
|
|
pullJSON(w, sdk.Label{ID: id, Name: req.Name, Color: req.Color, Exclusive: req.Exclusive})
|
|
|
|
case path == "milestones" && r.Method == http.MethodGet:
|
|
pullJSON(w, []sdk.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 sdk.CreateIssueOption
|
|
pullDecode(r, &req)
|
|
g.next++
|
|
p := &sdk.Issue{
|
|
Index: int64(g.next), Title: req.Title, Body: req.Body,
|
|
State: sdk.StateOpen, HTMLURL: pullURL(g.next), Labels: g.pullLabelsFor(req.Labels),
|
|
}
|
|
g.issues[g.next] = 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 sdk.EditIssueOption
|
|
pullDecode(r, &req)
|
|
// An empty title is Gitea's "leave it alone" — the one field of an
|
|
// edit that says so with a zero value rather than with null.
|
|
if req.Title != "" {
|
|
p.Title = req.Title
|
|
}
|
|
if req.Body != nil {
|
|
p.Body = *req.Body
|
|
}
|
|
if req.State != nil {
|
|
p.State = *req.State
|
|
}
|
|
// No labels here on purpose: Gitea's edit endpoint takes none, so
|
|
// an issue whose labels changed gets them through PUT ./labels
|
|
// below, and a fake that quietly accepted them would hide a push
|
|
// that never sent them.
|
|
}
|
|
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 := []sdk.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 = []sdk.Comment{}
|
|
}
|
|
pullJSON(w, out)
|
|
case m[2] == "labels" && r.Method == http.MethodPut:
|
|
var req sdk.IssueLabelsOption
|
|
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 := []sdk.Issue{}
|
|
for _, n := range numbers {
|
|
p := g.issues[n]
|
|
if state != "" && state != "all" && string(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) []*sdk.Label {
|
|
if ids == nil {
|
|
return nil
|
|
}
|
|
byID := map[int64]string{}
|
|
for name, id := range g.labels {
|
|
byID[id] = name
|
|
}
|
|
var out []*sdk.Label
|
|
for _, id := range ids {
|
|
if name, ok := byID[id]; ok {
|
|
out = append(out, &sdk.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)
|
|
}
|
|
|
|
// 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(sdk.Issue{
|
|
Index: 7, Title: "Closed but addressable", State: sdk.StateClosed,
|
|
Body: "## Summary\nДело сделано.\n", Updated: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
|
})
|
|
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(sdk.Issue{Index: 1, Title: "Migrate the schema", Body: "## Summary\nx\n"})
|
|
g.pullAdd(sdk.Issue{Index: 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 := []*sdk.Label{{ID: 1, Name: "type/bug"}}
|
|
g.pullAdd(sdk.Issue{Index: 1, Title: "Still broken", Body: "## Summary\nx\n", Labels: bug})
|
|
g.pullAdd(sdk.Issue{Index: 2, Title: "Fixed last week", State: sdk.StateClosed,
|
|
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(sdk.Issue{
|
|
Index: 3, Title: "Came down and went back up",
|
|
Body: "## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n",
|
|
Labels: []*sdk.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(sdk.Issue{Index: 4, Title: "Something open", Body: "x"})
|
|
g.pullAdd(sdk.Issue{Index: 5, Title: "Something closed", State: sdk.StateClosed, 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)
|
|
}
|
|
}
|