Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions tools/sync/chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
}
}
}

Expand All @@ -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.
Expand Down
42 changes: 30 additions & 12 deletions tools/sync/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
Expand Down
37 changes: 34 additions & 3 deletions tools/sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"fmt"
"os"
"os/signal"
"strings"
"time"

"github.com/spin-stack/go-tools/versions"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) }
63 changes: 58 additions & 5 deletions tools/sync/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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**",
Expand Down Expand Up @@ -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"}}
Expand All @@ -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/"):
Expand All @@ -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())
Expand All @@ -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)
}
Expand All @@ -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
Expand Down
Loading