From 91eb04df2e0144b0fa6b58246d000efaedf37f5d Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 23:37:37 -0300 Subject: [PATCH] sync: the issue is closed while nothing is behind, and the same one reopened when something is "Site vs spin main" was a dashboard kept open for ever. It now says what keeps it open - the site not on spin's newest release, a spin-machine pin short of the newest, a release with no image, a chain workflow whose last run did not succeed - and is closed when nothing does, the same issue reopened when something does again. What spin main has past the pin and what spinbox.dev runs are reported and keep nothing open: main is past every release between two of them, and spinbox.dev is reinstalled when its owner decides. Co-Authored-By: Claude Opus 5.5 --- tools/sync/chain.go | 34 ++++++++++++++++------ tools/sync/issue.go | 42 +++++++++++++++++++-------- tools/sync/main.go | 37 ++++++++++++++++++++++-- tools/sync/sync_test.go | 63 +++++++++++++++++++++++++++++++++++++---- 4 files changed, 147 insertions(+), 29 deletions(-) diff --git a/tools/sync/chain.go b/tools/sync/chain.go index 49c3c4a..4ab8098 100644 --- a/tools/sync/chain.go +++ b/tools/sync/chain.go @@ -31,10 +31,19 @@ var ( // release, the newest spin release images.json has an image of against spin's newest, when each // link's workflow last ran and how, and what spinbox.dev runs. Each line says // what it could not read rather than stopping the report: it is advice, and the rest of it holds. -func chain(ctx context.Context, gh *github.Client) string { +// +// behind is each link that is not where it should be - a pin short of the newest release, a +// release with no image, a workflow whose last run did not succeed or could not be read - which +// keeps the issue open. What spinbox.dev runs is not among them: it is reinstalled when its owner +// decides, and a deployment that waits for that is not a chain that stalled. +func chain(ctx context.Context, gh *github.Client) (report string, behind []string) { var b strings.Builder b.WriteString("| | |\n|---|---|\n") row := func(k, v string) { fmt.Fprintf(&b, "| %s | %s |\n", k, v) } + lags := func(k, v string) { + row(k, v) + behind = append(behind, k) + } pinned, err := machinePin(ctx, gh) var latest struct { @@ -45,22 +54,22 @@ func chain(ctx context.Context, gh *github.Client) string { } switch { case err != nil: - row("spin-machine", "could not read: "+err.Error()) + lags("spin-machine", "could not read: "+err.Error()) case pinned == latest.Tag: row("spin-machine", fmt.Sprintf("spin main pins %s, the newest release", pinned)) default: - row("spin-machine", fmt.Sprintf("spin main pins %s; the newest release is **%s**", pinned, latest.Tag)) + lags("spin-machine", fmt.Sprintf("spin main pins %s; the newest release is **%s**", pinned, latest.Tag)) } switch imaged, newest, err := images(ctx, gh); { case err != nil: - row("images.json", "could not read: "+err.Error()) + lags("images.json", "could not read: "+err.Error()) case imaged == newest: row("images.json", fmt.Sprintf("has an image of spin's newest release, %s", newest)) default: // The installation pages name the newest release with an image: until this one has its // own, the site documents a release nobody can install. - row("images.json", fmt.Sprintf("the newest release with an image is %s; spin's newest is **%s**, which spin-stack/ami's `release.yml` builds one of", imaged, newest)) + lags("images.json", fmt.Sprintf("the newest release with an image is %s; spin's newest is **%s**, which spin-stack/ami's `release.yml` builds one of", imaged, newest)) } for _, r := range runs { @@ -75,16 +84,23 @@ func chain(ctx context.Context, gh *github.Client) string { key := fmt.Sprintf("`%s` %s", r.workflow, r.what) switch err := gh.Get(ctx, "/repos/"+r.repo+"/actions/workflows/"+r.workflow+"/runs?per_page=1", &list); { case err != nil: - row(key, "could not read: "+err.Error()) + lags(key, "could not read: "+err.Error()) case len(list.Runs) == 0: - row(key, "never ran") + lags(key, "never ran") default: last := list.Runs[0] how := last.Conclusion if how == "" { how = last.Status } - row(key, fmt.Sprintf("last ran %s, [%s](%s)", day(last.Created), how, last.URL)) + line := fmt.Sprintf("last ran %s, [%s](%s)", day(last.Created), how, last.URL) + // A run still going has said nothing yet; one that ended any other way than success + // is the link that stalled. + if last.Conclusion == "" || last.Conclusion == "success" { + row(key, line) + } else { + lags(key, line) + } } } @@ -94,7 +110,7 @@ func chain(ctx context.Context, gh *github.Client) string { } else { row("spinbox.dev", fmt.Sprintf("runs %s, with the module at `%s`", version, ref)) } - return b.String() + return b.String(), behind } // machinePin is the spin-machine release spin's main pins in its versions.yaml. diff --git a/tools/sync/issue.go b/tools/sync/issue.go index fa88355..50437b6 100644 --- a/tools/sync/issue.go +++ b/tools/sync/issue.go @@ -21,6 +21,7 @@ const issueHead = "What the site at https://spin-stack.github.io/website/ docume // sections, in the order the body has them: each job writes its own and leaves the others. var sections = []struct{ key, title string }{ + {"behind", "What keeps it open"}, {"release", "The last release sync"}, {"main", "spin main past the pin"}, {"chain", "The release chain"}, @@ -51,25 +52,28 @@ func withSection(body, key, text string) string { return b.String() } -// writeIssue writes the sections given into the open issue issueTitle in repo, which bot opened, -// and opens it when there is none. It is found by its author, not searched for: GitHub's search -// is behind its writes, and a second issue is what that would make. -func writeIssue(ctx context.Context, gh *github.Client, repo, bot string, texts map[string]string) (string, error) { - var open []struct { +// writeIssue writes the sections given into the issue issueTitle in repo, which bot opened, and +// has it open while something is behind and closed once settled says nothing is: the same issue, +// reopened, never a second. With none yet, it is opened only for something to say. It is found by +// its author, not searched for: GitHub's search is behind its writes, and a second issue is what +// that would make. Newest first, as GitHub lists them, so an older one closed by hand is passed by. +func writeIssue(ctx context.Context, gh *github.Client, repo, bot string, texts map[string]string, settled bool) (string, error) { + var mine []struct { Number int `json:"number"` Title string `json:"title"` Body string `json:"body"` + State string `json:"state"` URL string `json:"html_url"` PR any `json:"pull_request"` } - q := url.Values{"state": {"open"}, "creator": {bot}, "per_page": {"100"}} - if err := gh.Get(ctx, "/repos/"+repo+"/issues?"+q.Encode(), &open); err != nil { + q := url.Values{"state": {"all"}, "creator": {bot}, "per_page": {"100"}} + if err := gh.Get(ctx, "/repos/"+repo+"/issues?"+q.Encode(), &mine); err != nil { return "", err } - body, number, link := "", 0, "" - for _, i := range open { + body, number, state, link := "", 0, "", "" + for _, i := range mine { if i.Title == issueTitle && i.PR == nil { - body, number, link = i.Body, i.Number, i.URL + body, number, state, link = i.Body, i.Number, i.State, i.URL break } } @@ -81,11 +85,25 @@ func writeIssue(ctx context.Context, gh *github.Client, repo, bot string, texts var out struct { URL string `json:"html_url"` } - if number == 0 { + want := "open" + if settled { + want = "closed" + } + switch { + case number == 0 && settled: + return "", nil + case number == 0: err := gh.Do(ctx, http.MethodPost, "/repos/"+repo+"/issues", map[string]string{"title": issueTitle, "body": body}, &out) return out.URL, err } - err := gh.Do(ctx, http.MethodPatch, fmt.Sprintf("/repos/%s/issues/%d", repo, number), map[string]string{"body": body}, &out) + patch := map[string]string{"body": body} + if state != want { + patch["state"] = want + if settled { + patch["state_reason"] = "completed" + } + } + err := gh.Do(ctx, http.MethodPatch, fmt.Sprintf("/repos/%s/issues/%d", repo, number), patch, &out) if out.URL == "" { out.URL = link } diff --git a/tools/sync/main.go b/tools/sync/main.go index c5fc228..f8ada85 100644 --- a/tools/sync/main.go +++ b/tools/sync/main.go @@ -15,6 +15,7 @@ import ( "fmt" "os" "os/signal" + "strings" "time" "github.com/spin-stack/go-tools/versions" @@ -90,12 +91,14 @@ func run(ctx context.Context, argv []string) error { if err != nil { return err } - texts := map[string]string{*mode: text, "chain": chain(ctx, gh)} + report, behind := chain(ctx, gh) + behind = append(siteBehind(ctx, gh, now), behind...) + texts := map[string]string{*mode: text, "chain": report, "behind": behindText(behind)} if *dry { - fmt.Print(withSection(withSection("", *mode, text), "chain", texts["chain"])) + fmt.Print(withSection(withSection(withSection("", "behind", texts["behind"]), *mode, text), "chain", report)) return nil } - link, err := writeIssue(ctx, gh, "spin-stack/spin", *bot, texts) + link, err := writeIssue(ctx, gh, "spin-stack/spin", *bot, texts, len(behind) == 0) if err != nil { return err } @@ -137,4 +140,32 @@ func issueText(ctx context.Context, gh *github.Client, mode string, was, now *ve return intro + "\n\n" + digest(cs, total, fds), nil } +// siteBehind is the site, when the release it documents is not spin's newest: the one thing of +// the issue's own that keeps it open. What spin main has past the pin does not - main is past +// every release between two of them, and that is for the next release to bring. +func siteBehind(ctx context.Context, gh *github.Client, now *versions.Versions) []string { + pinned, err := now.Get("spin") + if err != nil { + return []string{"the site's pin: " + err.Error()} + } + var latest struct { + Tag string `json:"tag_name"` + } + if err := gh.Get(ctx, "/repos/spin-stack/spin/releases/latest", &latest); err != nil { + return []string{"spin's newest release: could not read: " + err.Error()} + } + if pinned.Version != latest.Tag { + return []string{fmt.Sprintf("the site documents %s; spin's newest release is %s", pinned.Version, latest.Tag)} + } + return nil +} + +// behindText is what keeps the issue open, a line each, or that nothing does. +func behindText(behind []string) string { + if len(behind) == 0 { + return today() + ": nothing. The site documents spin's newest release and every link of the chain is where it should be, so the issue is closed until one is not." + } + return today() + ":\n\n- " + strings.Join(behind, "\n- ") +} + func today() string { return time.Now().UTC().Format(time.DateOnly) } diff --git a/tools/sync/sync_test.go b/tools/sync/sync_test.go index cfa94e9..2b19583 100644 --- a/tools/sync/sync_test.go +++ b/tools/sync/sync_test.go @@ -130,7 +130,15 @@ func TestChainReportsEachLinkAndWhatItCouldNotRead(t *testing.T) { "/repos/spin-stack/spinbox.dev-deploy/contents/terraform.tfvars?ref=main": "spin_version = \"v20260928.07\"\ndomain = \"spinbox.dev\"\n", "/repos/spin-stack/spinbox.dev-deploy/contents/main.tf?ref=main": "module \"spin\" {\n source = \"github.com/spin-stack/spin-terraform-aws?ref=1d7e2a2\"\n}\n", }) - got := chain(context.Background(), gh) + got, behind := chain(context.Background(), gh) + wantBehind := []string{ + "spin-machine", "images.json", + "`propose.yml` the module proposed to the deployments", + "`fresh-install.yml` an installation from nothing, weekly", + } + if !slices.Equal(behind, wantBehind) { + t.Errorf("behind = %q, want %q: a link not where it should be keeps the issue open, a run that succeeded and spinbox.dev do not", behind, wantBehind) + } for _, want := range []string{ "| spin-machine | spin main pins v20261014.01; the newest release is **v20261023.01** |", "| images.json | the newest release with an image is v20261019.10; spin's newest is **v20261025.01**", @@ -158,7 +166,7 @@ func (s *issues) handler(t *testing.T) http.HandlerFunc { defer s.mu.Unlock() switch { case r.Method == http.MethodGet && r.URL.Path == "/repos/spin-stack/spin/issues": - if r.URL.Query().Get("creator") != "sources[bot]" || r.URL.Query().Get("state") != "open" { + if r.URL.Query().Get("creator") != "sources[bot]" || r.URL.Query().Get("state") != "all" { t.Errorf("issues listed by %q", r.URL.RawQuery) } list := []map[string]any{{"number": 7, "title": "Something else", "body": "x"}} @@ -171,7 +179,7 @@ func (s *issues) handler(t *testing.T) http.HandlerFunc { _ = json.NewDecoder(r.Body).Decode(&in) s.created++ n := 100 + s.created - in["number"], in["html_url"] = n, fmt.Sprintf("https://github.com/spin-stack/spin/issues/%d", n) + in["number"], in["html_url"], in["state"] = n, fmt.Sprintf("https://github.com/spin-stack/spin/issues/%d", n), "open" s.byNum[n] = in _ = json.NewEncoder(w).Encode(in) case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/repos/spin-stack/spin/issues/"): @@ -180,6 +188,9 @@ func (s *issues) handler(t *testing.T) http.HandlerFunc { var in map[string]any _ = json.NewDecoder(r.Body).Decode(&in) s.byNum[n]["body"] = in["body"] + if st, ok := in["state"]; ok { + s.byNum[n]["state"] = st + } _ = json.NewEncoder(w).Encode(s.byNum[n]) default: t.Errorf("unexpected request %s %s", r.Method, r.URL.RequestURI()) @@ -197,10 +208,10 @@ func TestTheIssueIsOneAndEachWriteKeepsTheOtherSections(t *testing.T) { gh := &github.Client{Base: srv.URL, HTTP: srv.Client()} ctx := context.Background() - if _, err := writeIssue(ctx, gh, "spin-stack/spin", "sources[bot]", map[string]string{"release": "moved to v20261025.01", "chain": "chain one"}); err != nil { + if _, err := writeIssue(ctx, gh, "spin-stack/spin", "sources[bot]", map[string]string{"release": "moved to v20261025.01", "chain": "chain one"}, false); err != nil { t.Fatal(err) } - link, err := writeIssue(ctx, gh, "spin-stack/spin", "sources[bot]", map[string]string{"main": "main is 40 past", "chain": "chain two"}) + link, err := writeIssue(ctx, gh, "spin-stack/spin", "sources[bot]", map[string]string{"main": "main is 40 past", "chain": "chain two"}, false) if err != nil { t.Fatal(err) } @@ -224,6 +235,48 @@ func TestTheIssueIsOneAndEachWriteKeepsTheOtherSections(t *testing.T) { } } +// The issue is open while something is behind and closed once nothing is - the same issue, +// reopened, never a second - and with none yet, nothing settled opens one. +func TestTheIssueIsClosedWhileSettledAndTheSameOneReopened(t *testing.T) { + st := &issues{byNum: map[int]map[string]any{}} + srv := httptest.NewServer(st.handler(t)) + defer srv.Close() + gh := &github.Client{Base: srv.URL, HTTP: srv.Client()} + ctx := context.Background() + sync := func(settled bool, behind string) { + t.Helper() + if _, err := writeIssue(ctx, gh, "spin-stack/spin", "sources[bot]", map[string]string{"behind": behind, "chain": "chain"}, settled); err != nil { + t.Fatal(err) + } + } + + sync(true, "nothing") + if st.created != 0 { + t.Fatalf("a settled chain with no issue opened one") + } + for _, step := range []struct { + settled bool + behind string + state string + }{ + {false, "images.json", "open"}, + {true, "nothing", "closed"}, + {true, "nothing", "closed"}, + {false, "the site documents an old release", "open"}, + } { + sync(step.settled, step.behind) + if st.created != 1 { + t.Fatalf("%d issues opened; want the one, reopened", st.created) + } + if got := st.byNum[101]["state"]; got != step.state { + t.Errorf("with %q behind the issue is %v, want %s", step.behind, got, step.state) + } + if body := st.byNum[101]["body"].(string); !strings.Contains(body, step.behind) { + t.Errorf("the body does not say what keeps it open, %q:\n%s", step.behind, body) + } + } +} + func pins(t *testing.T, spin, brand string) *versions.Versions { t.Helper() v, err := versions.Parse([]byte(fmt.Sprintf(`- name: spin