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