From ab034c7c6e10434c6c6a704c6e7ae4fc228fc9d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 14:23:06 +0000 Subject: [PATCH 1/3] File an issue when a repository's tests fail A maintained run now opens an issue on a repository whose tests fail, on main or after one of its tasks: what failed, the commit, Go version, command and shuffle seed to reproduce it, the change that broke it, the commits left unpushed, and how to resolve it, for an agent to pick up. A fingerprint of repository, task and failing tests, kept in a comment at the top, stops a later run from filing the same failure twice while the issue is open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M6xJuQgmRd19QozaQDD3tk --- docs/architecture.md | 13 +- internal/run/run.go | 7 + internal/run/service_test.go | 11 ++ internal/run/testissue.go | 254 +++++++++++++++++++++++++++++++++ internal/run/testissue.md.tmpl | 64 +++++++++ internal/run/testissue_test.go | 159 +++++++++++++++++++++ 6 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 internal/run/testissue.go create mode 100644 internal/run/testissue.md.tmpl create mode 100644 internal/run/testissue_test.go diff --git a/docs/architecture.md b/docs/architecture.md index bb823bc..80ce6ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,8 +42,17 @@ others, orders them with the engine's dependency graph, and maintains them as parallel as that order allows. A required repository of an owner in `config.json` that is not in it is a gap, reported as an `unmaintained` event on every run. It reaches GitHub only through `repoSource`, which opens a -repository and lists an owner's, so its tests stand in local repositories -for GitHub's. +repository and lists an owner's, and `issueTracker`, so its tests stand in +local repositories for GitHub's. + +A repository whose tests fail, on `main` or after one of the run's tasks, gets +an issue (`testissue.go`, written from `testissue.md.tmpl`): what failed, the +commit, Go version and command to reproduce it, the change that broke it, and +how to resolve it, for an agent to pick up. A comment at its top fingerprints +the failure by repository, task and failing tests, and a later run that finds +an open issue with the same fingerprint files no other. `GITHUB_TOKEN` needs +permission to write issues for this; a failure to file is logged, never the +run's. Both emit the same event stream, so one repository reports exactly like one row of a full run. Who reads it is the caller's choice. By default diff --git a/internal/run/run.go b/internal/run/run.go index 662b6b0..1c1ee2d 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -26,6 +26,7 @@ import ( "github.com/MarkRosemaker/devtool/maintain" "github.com/MarkRosemaker/ghrepo" "github.com/MarkRosemaker/gorepo" + "github.com/google/go-github/v80/github" "golang.org/x/mod/modfile" ) @@ -47,6 +48,9 @@ type Service struct { // a run measures is written back there, so the next run on any machine // starts from it. cfg ConfigFile + + // issues is where a repository's failing tests are reported, or nil. + issues issueTracker } // New builds a service that emits its progress to events. @@ -79,6 +83,7 @@ func New( verbose: verbose, version: version, cfg: cfg, + issues: gitHubIssues{github.NewClient(nil).WithAuthToken(token)}, }, nil } @@ -402,6 +407,8 @@ func (s *Service) maintain( res := s.runner.Update(ctx, u.repo, spec, u.repo.sequence(s.version), events) + s.reportTestFailure(ctx, u, res) + recordCoverage(ctx, u, res) s.pruneOccasionally(ctx, u.repo) diff --git a/internal/run/service_test.go b/internal/run/service_test.go index a14296b..2d35a7c 100644 --- a/internal/run/service_test.go +++ b/internal/run/service_test.go @@ -33,6 +33,7 @@ type fakeSource struct { prefetchErr map[string]error openErr map[string]error coverage map[string]float64 + coverErrs map[string][]error public map[string]bool tasks map[string]func(r *fakeRepo) []engine.Task opened map[string]*fakeRepo @@ -54,6 +55,7 @@ func newFakeSource(t *testing.T) *fakeSource { prefetchErr: map[string]error{}, openErr: map[string]error{}, coverage: map[string]float64{}, + coverErrs: map[string][]error{}, public: map[string]bool{}, tasks: map[string]func(r *fakeRepo) []engine.Task{}, opened: map[string]*fakeRepo{}, @@ -182,10 +184,19 @@ func (r *fakeRepo) SetTopics(context.Context, []string) error { return nil } func (r *fakeRepo) Private() bool { return !r.src.public[r.key] } +// GoTestCover fails with each of the source's errors for the repository in +// turn, nil among them passing, and passes once they are used up. func (r *fakeRepo) GoTestCover(context.Context) (float64, error) { r.src.mu.Lock() defer r.src.mu.Unlock() + if errs := r.src.coverErrs[r.key]; len(errs) > 0 { + r.src.coverErrs[r.key] = errs[1:] + if errs[0] != nil { + return 0, errs[0] + } + } + return r.src.coverage[r.key], nil } diff --git a/internal/run/testissue.go b/internal/run/testissue.go new file mode 100644 index 0000000..2b8b102 --- /dev/null +++ b/internal/run/testissue.go @@ -0,0 +1,254 @@ +package run + +import ( + "bytes" + "context" + "crypto/sha256" + _ "embed" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "regexp" + "slices" + "strings" + "text/template" + "time" + + "github.com/MarkRosemaker/devtool-engine/event" + "github.com/MarkRosemaker/ghrepo" + "github.com/google/go-github/v80/github" +) + +// testFailure is a repository's tests failing in a maintained run, told as an +// issue on that repository for whoever fixes it. +type testFailure struct { + Owner, Name string + + // Task is the task whose change broke the tests, and Changes what it + // changed. Task is empty where the tests failed on main, before any change. + Task, Changes string + + // Command is the go test command, Output what it said about what failed, + // Failing the tests that failed (their packages where none is named), and + // Seed the shuffle seed that ordered them. + Command, Output string + Failing []string + Seed string + + // Unpushed are the earlier tasks of the run that committed, and were not + // pushed because the run stopped. + Unpushed []string + + Commit, GoVersion, Version, Time string +} + +// The engine's words for the two places a test run fails: on main, before any +// task, and after a task changed something. +const ( + failedBeforeChanges = "testing before any changes: " + failedAfterChanges = ": testing after changes:\n" +) + +// parseTestFailure reads err as the engine reports a failing test run; ok is +// false for any other failure. +func parseTestFailure(err error) (testFailure, bool) { + var exec ghrepo.ExecError + if !errors.As(err, &exec) || !strings.HasPrefix(exec.Cmd, "go test") { + return testFailure{}, false + } + + f := testFailure{Command: exec.Cmd, Output: strings.TrimSpace(exec.Out)} + + if msg := err.Error(); !strings.HasPrefix(msg, failedBeforeChanges) { + before, after, ok := strings.Cut(msg, failedAfterChanges) + if !ok { + return testFailure{}, false + } + + f.Task = before + f.Changes, _, _ = strings.Cut(after, "\n"+exec.Error()) + } + + f.Failing = failingTests(f.Output) + if m := shuffleSeed.FindStringSubmatch(f.Output); m != nil { + f.Seed = m[1] + } + + return f, true +} + +var ( + failedTest = regexp.MustCompile(`(?m)^\s*--- FAIL: (\S+)`) + failedPackage = regexp.MustCompile(`(?m)^FAIL\t(\S+)`) + shuffleSeed = regexp.MustCompile(`(?m)^-test\.shuffle (\d+)`) +) + +// failingTests names what failed: the tests, or the packages where no test is +// named, as when one does not build. +func failingTests(out string) []string { + var names []string + + for _, re := range []*regexp.Regexp{failedTest, failedPackage} { + for _, m := range re.FindAllStringSubmatch(out, -1) { + if !slices.Contains(names, m[1]) { + names = append(names, m[1]) + } + } + + if len(names) > 0 { + return names + } + } + + return nil +} + +// marker identifies the failure in its issue, so a later run finds the issue +// rather than filing another: the repository, where the tests failed, and +// which tests, none of which a timing or a line number changes. +func (f testFailure) marker() string { + what := slices.Sorted(slices.Values(f.Failing)) + if len(what) == 0 { + what = []string{regexp.MustCompile(`\d+`).ReplaceAllString(f.Output, "")} + } + + sum := sha256.Sum256([]byte(strings.Join(append([]string{f.Owner, f.Name, f.Task}, what...), "\n"))) + + return "devtool:test-failure " + hex.EncodeToString(sum[:8]) +} + +// maxTitled is how many failing tests an issue's title names. +const maxTitled = 3 + +func (f testFailure) title() string { + names := strings.Join(f.Failing[:min(len(f.Failing), maxTitled)], ", ") + if more := len(f.Failing) - maxTitled; more > 0 { + names += fmt.Sprintf(" and %d more", more) + } + + if names == "" { + names = "the suite" + } + + if f.Task == "" { + return "Tests fail on main: " + names + } + + return fmt.Sprintf("Tests fail after %q: %s", f.Task, names) +} + +//go:embed testissue.md.tmpl +var testIssueText string + +var testIssueTmpl = template.Must(template.New("testissue").Parse(testIssueText)) + +func (f testFailure) body() (string, error) { + var b bytes.Buffer + + err := testIssueTmpl.Execute(&b, struct { + testFailure + Marker, ShortCommit string + }{f, f.marker(), f.Commit[:min(len(f.Commit), 12)]}) + + return b.String(), err +} + +// issueTracker is where a test failure is reported: a test replaces it. +type issueTracker interface { + // findOpen is the address of the open issue holding marker, or "". + findOpen(ctx context.Context, owner, name, marker string) (string, error) + create(ctx context.Context, owner, name, title, body string) (string, error) +} + +// reportTestFailure files an issue for a repository whose tests failed, unless +// one for the same failure is open already. Reporting is not the run's work, +// so a failure to report is logged rather than returned. +func (s *Service) reportTestFailure(ctx context.Context, u *unit, res event.Result) { + if s.issues == nil || res.Err == nil { + return + } + + f, ok := parseTestFailure(res.Err) + if !ok { + return + } + + f.Owner, f.Name, f.Unpushed, f.Version = u.owner, u.name, res.Commits, s.version + f.Commit = commandOutput(ctx, u.repo, "git", "rev-parse", "@{upstream}") + f.GoVersion = commandOutput(ctx, u.repo, "go", "version") + f.Time = time.Now().UTC().Format("2006-01-02 15:04 MST") + + log := slog.With("repo", u.key()) + + open, err := s.issues.findOpen(ctx, f.Owner, f.Name, f.marker()) + if err != nil { + log.WarnContext(ctx, "could not look for an issue about the failing tests", "error", err) + + return + } + + if open != "" { + log.InfoContext(ctx, "the failing tests are reported already", "issue", open) + + return + } + + body, err := f.body() + if err != nil { + log.WarnContext(ctx, "could not write the issue about the failing tests", "error", err) + + return + } + + url, err := s.issues.create(ctx, f.Owner, f.Name, f.title(), body) + if err != nil { + log.WarnContext(ctx, "could not file an issue about the failing tests", "error", err) + + return + } + + log.InfoContext(ctx, "filed an issue about the failing tests", "issue", url) +} + +// commandOutput is what a command says in the repository, or "unknown". +func commandOutput(ctx context.Context, repo maintainedRepo, name string, args ...string) string { + out, err := repo.ExecCommand(ctx, name, args...) + if err != nil { + return "unknown" + } + + return strings.TrimSpace(string(out)) +} + +// gitHubIssues files issues through the GitHub API. +type gitHubIssues struct{ client *github.Client } + +func (g gitHubIssues) findOpen(ctx context.Context, owner, name, marker string) (string, error) { + opts := &github.IssueListByRepoOptions{State: "open", ListOptions: github.ListOptions{PerPage: 100}} + + for { + issues, resp, err := g.client.Issues.ListByRepo(ctx, owner, name, opts) + if err != nil { + return "", err + } + + for _, issue := range issues { + if !issue.IsPullRequest() && strings.Contains(issue.GetBody(), marker) { + return issue.GetHTMLURL(), nil + } + } + + if resp.NextPage == 0 { + return "", nil + } + + opts.ListOptions.Page = resp.NextPage + } +} + +func (g gitHubIssues) create(ctx context.Context, owner, name, title, body string) (string, error) { + issue, _, err := g.client.Issues.Create(ctx, owner, name, &github.IssueRequest{Title: &title, Body: &body}) + + return issue.GetHTMLURL(), err +} diff --git a/internal/run/testissue.md.tmpl b/internal/run/testissue.md.tmpl new file mode 100644 index 0000000..0003a94 --- /dev/null +++ b/internal/run/testissue.md.tmpl @@ -0,0 +1,64 @@ + +A maintained run of devtool found this repository's tests failing +{{- if .Task }} after its task **{{ .Task }}** changed the repository. The +change was not committed, and the run discarded it: `main` is as it was, and +the work is to make it take the change. +{{- else }} on `main`, before the run changed anything: `main` itself is broken. +{{- end }} + +## What failed + +{{ range .Failing }}- `{{ . }}` +{{ end }} +Only what failed is shown; the packages that passed are left out. + +```text +{{ .Output }} +``` + +## Reproducing it + +- **Commit:** [`{{ .ShortCommit }}`](https://github.com/{{ .Owner }}/{{ .Name }}/commit/{{ .Commit }}), `main` as the run found it. +- **Go:** {{ .GoVersion }} +- **Command**, run at the repository root: + + ```sh + {{ .Command }} + ``` +{{- if .Seed }} + + The tests ran in a shuffled order. `-shuffle={{ .Seed }}` in place of + `-shuffle=on` runs them in the same order again. +{{- end }} +- `make test-race` runs the same suite, without the coverage profile. +{{- if .Task }} + +## The change that broke them + +The task **{{ .Task }}** made this change, which the run tested before +committing it: + +```text +{{ .Changes }} +``` + +Making the same change on a branch brings the failure back. +{{- end }} +{{- if .Unpushed }} + +Earlier tasks in the same run committed, but nothing was pushed, because the +run stopped here: {{ range $i, $c := .Unpushed }}{{ if $i }}, {{ end }}**{{ $c }}**{{ end }}. +The next run makes those commits again. +{{- end }} + +## Resolving it + +- `AGENTS.md`, at the repository root, says how to work here. `make ready` + passes before every commit. +- Fix the cause. Do not skip, disable or loosen a test to make it pass. +- Open a pull request that says `Fixes` and this issue's number, so that + merging it closes this issue. +- devtool files no second issue for this failure while this one is open: it + finds this one by the comment at the top. Leave that comment as it is. + +Reported by devtool {{ .Version }} at {{ .Time }}. diff --git a/internal/run/testissue_test.go b/internal/run/testissue_test.go new file mode 100644 index 0000000..d332eae --- /dev/null +++ b/internal/run/testissue_test.go @@ -0,0 +1,159 @@ +package run + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + + engine "github.com/MarkRosemaker/devtool-engine/maintain" + "github.com/MarkRosemaker/ghrepo" +) + +// fakeIssues files issues in memory. +type fakeIssues struct { + mu sync.Mutex + filed []fakeIssue + reject error +} + +type fakeIssue struct{ repo, title, body string } + +func (f *fakeIssues) findOpen(_ context.Context, owner, name, marker string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + + for i, issue := range f.filed { + if issue.repo == owner+"/"+name && strings.Contains(issue.body, marker) { + return fmt.Sprintf("https://github.com/%s/%s/issues/%d", owner, name, i+1), nil + } + } + + return "", nil +} + +func (f *fakeIssues) create(_ context.Context, owner, name, title, body string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + + if f.reject != nil { + return "", f.reject + } + + f.filed = append(f.filed, fakeIssue{owner + "/" + name, title, body}) + + return fmt.Sprintf("https://github.com/%s/%s/issues/%d", owner, name, len(f.filed)), nil +} + +// testCommand is the command gorepo runs, and failingOutput what it says about +// a failure once gorepo has left out what passed. +const ( + testCommand = "go test ./... -race -cover -covermode=atomic -coverpkg=./... -coverprofile=cover.out -shuffle=on" + failingOutput = "-test.shuffle 1794492933930643719\n" + + "--- FAIL: TestBad (0.00s)\n" + + " b_test.go:4: boom\n" + + "FAIL\n" + + "FAIL\texample.com/app/b\t0.011s" +) + +func testsFail() error { + return ghrepo.ExecError{Cmd: testCommand, Out: failingOutput, Err: errors.New("exit status 1")} +} + +// Tests failing on main are filed once, however many runs find them. +func TestFailingTestsOnMainAreFiledOnce(t *testing.T) { + svc, src, _ := portfolio(t, `{"alice": {"repositories": {"app": {}}}}`) + src.add("alice", "app", map[string]string{"go.mod": goMod("github.com/alice/app")}) + + issues := &fakeIssues{} + svc.issues = issues + + for range 2 { + src.coverErrs["alice/app"] = []error{testsFail()} + + if err := svc.Run(t.Context()); err != nil { + t.Fatal(err) + } + } + + if len(issues.filed) != 1 { + t.Fatalf("filed %d issues over two runs, want 1", len(issues.filed)) + } + + issue := issues.filed[0] + if issue.repo != "alice/app" || issue.title != "Tests fail on main: TestBad" { + t.Errorf("filed %q on %s", issue.title, issue.repo) + } + + for _, want := range []string{ + " +A maintained run of devtool found this repository's test coverage +{{- if .Public }} at {{ printf "%.1f" .Coverage }}%, below the {{ .Floor }}% a public repository needs. +{{- else }} fallen below the floor of {{ .Floor }}%, from {{ printf "%.1f" .Prev }}% to {{ printf "%.1f" .Coverage }}%. +{{- end }} +The run's other work was done and pushed: coverage is what is left. + +## Measuring it + +- **Commit:** [`{{ .ShortCommit }}`](https://github.com/{{ .Owner }}/{{ .Name }}/commit/{{ .Commit }}), `main` as the run found it. +- **Go:** {{ .GoVersion }} +- `make cover` prints each function's coverage and the total, measured as the + run measures it: `-coverpkg=./...` counts code a test covers in any package of + the module, not only in its own. +- `go tool cover -func=cover.out | sort -k3 -n | head -30`, after `make cover`, + lists the least covered functions first. + +## Resolving it + +- `AGENTS.md`, at the repository root, says how to work here. `make ready` + passes before every commit. +- Test what the code is for: a test that runs lines without checking what they + do raises the figure and catches nothing. +- Open a pull request that says `Fixes` and this issue's number, so that + merging it closes this issue. +- devtool files no second issue about coverage while this one is open: it + finds this one by the comment at the top, and brings it up to date each + time it measures again. Leave that comment as it is. + +Last seen by devtool {{ .Version }} at {{ .Time }}. diff --git a/internal/run/issue_test.go b/internal/run/issue_test.go new file mode 100644 index 0000000..a03424e --- /dev/null +++ b/internal/run/issue_test.go @@ -0,0 +1,194 @@ +package run + +import ( + "errors" + "fmt" + "slices" + "strings" + "testing" + + "github.com/MarkRosemaker/devtool-engine/event" + engine "github.com/MarkRosemaker/devtool-engine/maintain" + "github.com/MarkRosemaker/ghrepo" + "github.com/google/go-github/v80/github" +) + +// testCommand is the command gorepo runs, and failingOutput what it says about +// a failure once gorepo has left out what passed. +const ( + testCommand = "go test ./... -race -cover -covermode=atomic -coverpkg=./... -coverprofile=cover.out -shuffle=on" + failingOutput = "-test.shuffle 1794492933930643719\n" + + "--- FAIL: TestBad (0.00s)\n" + + " b_test.go:4: boom\n" + + "FAIL\n" + + "FAIL\texample.com/app/b\t0.011s" + brokenOutput = "# example.com/app/b\n" + + "b/b.go:3:2: undefined: gone\n" + + "FAIL\texample.com/app/b [build failed]" +) + +func testsFail(out string) error { + return ghrepo.ExecError{Cmd: testCommand, Out: out, Err: errors.New("exit status 1")} +} + +// app is a portfolio of one repository, alice/app. +func app(t *testing.T, cfg string) (*Service, *fakeSource) { + t.Helper() + + svc, src, _ := portfolio(t, `{"alice": {"repositories": {"app": {`+cfg+`}}}}`) + src.add("alice", "app", map[string]string{"go.mod": goMod("github.com/alice/app")}) + + return svc, src +} + +func labelNames(issue *github.Issue) []string { + var names []string + for _, l := range issue.Labels { + names = append(names, l.GetName()) + } + + return names +} + +// Tests failing on main are filed once, and the open issue is brought up to +// date by every run that finds them still failing. +func TestFailingTestsOnMainAreFiledOnce(t *testing.T) { + svc, src := app(t, "") + + for _, out := range []string{failingOutput, strings.Replace(failingOutput, "boom", "boom again", 1)} { + src.coverErrs["alice/app"] = []error{testsFail(out)} + + if err := svc.Run(t.Context()); err != nil { + t.Fatal(err) + } + } + + issues := src.issues["alice/app"] + if len(issues) != 1 { + t.Fatalf("filed %d issues over two runs, want 1", len(issues)) + } + + issue := issues[0] + if got := issue.GetTitle(); got != "Tests fail on main: TestBad" { + t.Errorf("title = %q", got) + } + + if got, want := labelNames(issue), []string{"devtool", "failing on main"}; !slices.Equal(got, want) { + t.Errorf("labels = %v, want %v", got, want) + } + + for _, want := range []string{ + "", "from 85.0% to 70.0%", "make cover", "-coverpkg=./..."} { + if !strings.Contains(issue.GetBody(), want) { + t.Errorf("the issue lacks %q:\n%s", want, issue.GetBody()) + } + } +} + +// Only failing tests and coverage are filed: a run that fails otherwise, or +// does not fail, files nothing. +func TestOtherOutcomesAreNotFiled(t *testing.T) { + for _, err := range []error{ + nil, + errors.New("pushing: rejected"), + fmt.Errorf("testing before any changes: %w", errors.New("no go.mod")), + ghrepo.ExecError{Cmd: "go mod tidy", Err: errors.New("exit status 1")}, + } { + if _, ok, _ := reportOf(event.Result{Err: err}, engine.Spec{}, facts{}); ok { + t.Errorf("%v was filed", err) + } + } +} + +// The marker names the failure, not the run: timings and seeds do not change it. +func TestTheMarkerIgnoresWhatVariesBetweenRuns(t *testing.T) { + again := strings.NewReplacer("0.011s", "0.020s", "1794492933930643719", "42").Replace(failingOutput) + + a, _ := parseTestFailure(fmt.Errorf("testing before any changes: %w", testsFail(failingOutput))) + b, _ := parseTestFailure(fmt.Errorf("testing before any changes: %w", testsFail(again))) + + if a.marker() != b.marker() { + t.Errorf("the same failure reads as two: %s and %s", a.marker(), b.marker()) + } +} diff --git a/internal/run/testissue.md.tmpl b/internal/run/issue_tests.md.tmpl similarity index 84% rename from internal/run/testissue.md.tmpl rename to internal/run/issue_tests.md.tmpl index 0003a94..26ac068 100644 --- a/internal/run/testissue.md.tmpl +++ b/internal/run/issue_tests.md.tmpl @@ -1,5 +1,6 @@ -A maintained run of devtool found this repository's tests failing +A maintained run of devtool found this repository +{{- if .Build }} failing to build{{ else }}'s tests failing{{ end }} {{- if .Task }} after its task **{{ .Task }}** changed the repository. The change was not committed, and the run discarded it: `main` is as it was, and the work is to make it take the change. @@ -33,7 +34,7 @@ Only what failed is shown; the packages that passed are left out. - `make test-race` runs the same suite, without the coverage profile. {{- if .Task }} -## The change that broke them +## The change that broke it The task **{{ .Task }}** made this change, which the run tested before committing it: @@ -59,6 +60,7 @@ The next run makes those commits again. - Open a pull request that says `Fixes` and this issue's number, so that merging it closes this issue. - devtool files no second issue for this failure while this one is open: it - finds this one by the comment at the top. Leave that comment as it is. + finds this one by the comment at the top, and brings it up to date each + time the failure comes back. Leave that comment as it is. -Reported by devtool {{ .Version }} at {{ .Time }}. +Last seen by devtool {{ .Version }} at {{ .Time }}. diff --git a/internal/run/repository.go b/internal/run/repository.go index 705dd33..11a876b 100644 --- a/internal/run/repository.go +++ b/internal/run/repository.go @@ -7,6 +7,7 @@ import ( engine "github.com/MarkRosemaker/devtool-engine/maintain" "github.com/MarkRosemaker/gorepo" "github.com/go-git/go-git/v6" + "github.com/google/go-github/v80/github" "github.com/spf13/afero" "golang.org/x/mod/modfile" ) @@ -45,6 +46,12 @@ type maintainedRepo interface { Dependencies() ([]*modfile.Require, error) Commit(paths []string, message string) error sequence(version string) engine.Sequence + + // The issues a run files about the repository, and their labels. + FindOpenIssue(ctx context.Context, text string, labels ...string) (*github.Issue, error) + CreateIssue(ctx context.Context, title, body string, labels ...string) (*github.Issue, error) + EditIssue(ctx context.Context, number int, title, body string) (*github.Issue, error) + EnsureLabel(ctx context.Context, name, color, description string) error } // repoSource opens repositories and loads their owners' metadata. diff --git a/internal/run/run.go b/internal/run/run.go index 1c1ee2d..9432ab5 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -26,7 +26,6 @@ import ( "github.com/MarkRosemaker/devtool/maintain" "github.com/MarkRosemaker/ghrepo" "github.com/MarkRosemaker/gorepo" - "github.com/google/go-github/v80/github" "golang.org/x/mod/modfile" ) @@ -48,9 +47,6 @@ type Service struct { // a run measures is written back there, so the next run on any machine // starts from it. cfg ConfigFile - - // issues is where a repository's failing tests are reported, or nil. - issues issueTracker } // New builds a service that emits its progress to events. @@ -83,7 +79,6 @@ func New( verbose: verbose, version: version, cfg: cfg, - issues: gitHubIssues{github.NewClient(nil).WithAuthToken(token)}, }, nil } @@ -407,7 +402,7 @@ func (s *Service) maintain( res := s.runner.Update(ctx, u.repo, spec, u.repo.sequence(s.version), events) - s.reportTestFailure(ctx, u, res) + s.fileIssue(ctx, u, spec, res) recordCoverage(ctx, u, res) diff --git a/internal/run/service_test.go b/internal/run/service_test.go index 2d35a7c..68c5a5e 100644 --- a/internal/run/service_test.go +++ b/internal/run/service_test.go @@ -34,6 +34,8 @@ type fakeSource struct { openErr map[string]error coverage map[string]float64 coverErrs map[string][]error + issues map[string][]*github.Issue + labels map[string][]string public map[string]bool tasks map[string]func(r *fakeRepo) []engine.Task opened map[string]*fakeRepo @@ -56,6 +58,8 @@ func newFakeSource(t *testing.T) *fakeSource { openErr: map[string]error{}, coverage: map[string]float64{}, coverErrs: map[string][]error{}, + issues: map[string][]*github.Issue{}, + labels: map[string][]string{}, public: map[string]bool{}, tasks: map[string]func(r *fakeRepo) []engine.Task{}, opened: map[string]*fakeRepo{}, @@ -182,6 +186,82 @@ func (r *fakeRepo) SetDescription(_ context.Context, descr string) error { func (r *fakeRepo) SetTopics(context.Context, []string) error { return nil } +// ExecCommand answers go version itself: run for real, with HOME a temporary +// directory, it fetches a toolchain and records telemetry into it after the +// test has ended. +func (r *fakeRepo) ExecCommand(ctx context.Context, name string, args ...string) ([]byte, error) { + if name == "go" && slices.Equal(args, []string{"version"}) { + return []byte("go version go1.27.0 linux/amd64\n"), nil + } + + return r.repository.ExecCommand(ctx, name, args...) +} + +func (r *fakeRepo) EnsureLabel(_ context.Context, name, _, _ string) error { + r.src.mu.Lock() + defer r.src.mu.Unlock() + + if !slices.Contains(r.src.labels[r.key], name) { + r.src.labels[r.key] = append(r.src.labels[r.key], name) + } + + return nil +} + +func (r *fakeRepo) FindOpenIssue(_ context.Context, text string, labels ...string) (*github.Issue, error) { + r.src.mu.Lock() + defer r.src.mu.Unlock() + + for _, issue := range r.src.issues[r.key] { + if issue.GetState() == "open" && strings.Contains(issue.GetBody(), text) && hasLabels(issue, labels) { + return issue, nil + } + } + + return nil, nil +} + +func (r *fakeRepo) CreateIssue(_ context.Context, title, body string, labels ...string) (*github.Issue, error) { + r.src.mu.Lock() + defer r.src.mu.Unlock() + + issue := &github.Issue{ + Number: new(len(r.src.issues[r.key]) + 1), State: new("open"), Title: &title, Body: &body, + } + + for _, name := range labels { + if !slices.Contains(r.src.labels[r.key], name) { + return nil, errors.New("no label " + name) + } + + issue.Labels = append(issue.Labels, &github.Label{Name: new(name)}) + } + + r.src.issues[r.key] = append(r.src.issues[r.key], issue) + + return issue, nil +} + +func (r *fakeRepo) EditIssue(_ context.Context, number int, title, body string) (*github.Issue, error) { + r.src.mu.Lock() + defer r.src.mu.Unlock() + + issue := r.src.issues[r.key][number-1] + issue.Title, issue.Body = &title, &body + + return issue, nil +} + +func hasLabels(issue *github.Issue, names []string) bool { + for _, name := range names { + if !slices.ContainsFunc(issue.Labels, func(l *github.Label) bool { return l.GetName() == name }) { + return false + } + } + + return true +} + func (r *fakeRepo) Private() bool { return !r.src.public[r.key] } // GoTestCover fails with each of the source's errors for the repository in diff --git a/internal/run/testissue.go b/internal/run/testissue.go deleted file mode 100644 index 2b8b102..0000000 --- a/internal/run/testissue.go +++ /dev/null @@ -1,254 +0,0 @@ -package run - -import ( - "bytes" - "context" - "crypto/sha256" - _ "embed" - "encoding/hex" - "errors" - "fmt" - "log/slog" - "regexp" - "slices" - "strings" - "text/template" - "time" - - "github.com/MarkRosemaker/devtool-engine/event" - "github.com/MarkRosemaker/ghrepo" - "github.com/google/go-github/v80/github" -) - -// testFailure is a repository's tests failing in a maintained run, told as an -// issue on that repository for whoever fixes it. -type testFailure struct { - Owner, Name string - - // Task is the task whose change broke the tests, and Changes what it - // changed. Task is empty where the tests failed on main, before any change. - Task, Changes string - - // Command is the go test command, Output what it said about what failed, - // Failing the tests that failed (their packages where none is named), and - // Seed the shuffle seed that ordered them. - Command, Output string - Failing []string - Seed string - - // Unpushed are the earlier tasks of the run that committed, and were not - // pushed because the run stopped. - Unpushed []string - - Commit, GoVersion, Version, Time string -} - -// The engine's words for the two places a test run fails: on main, before any -// task, and after a task changed something. -const ( - failedBeforeChanges = "testing before any changes: " - failedAfterChanges = ": testing after changes:\n" -) - -// parseTestFailure reads err as the engine reports a failing test run; ok is -// false for any other failure. -func parseTestFailure(err error) (testFailure, bool) { - var exec ghrepo.ExecError - if !errors.As(err, &exec) || !strings.HasPrefix(exec.Cmd, "go test") { - return testFailure{}, false - } - - f := testFailure{Command: exec.Cmd, Output: strings.TrimSpace(exec.Out)} - - if msg := err.Error(); !strings.HasPrefix(msg, failedBeforeChanges) { - before, after, ok := strings.Cut(msg, failedAfterChanges) - if !ok { - return testFailure{}, false - } - - f.Task = before - f.Changes, _, _ = strings.Cut(after, "\n"+exec.Error()) - } - - f.Failing = failingTests(f.Output) - if m := shuffleSeed.FindStringSubmatch(f.Output); m != nil { - f.Seed = m[1] - } - - return f, true -} - -var ( - failedTest = regexp.MustCompile(`(?m)^\s*--- FAIL: (\S+)`) - failedPackage = regexp.MustCompile(`(?m)^FAIL\t(\S+)`) - shuffleSeed = regexp.MustCompile(`(?m)^-test\.shuffle (\d+)`) -) - -// failingTests names what failed: the tests, or the packages where no test is -// named, as when one does not build. -func failingTests(out string) []string { - var names []string - - for _, re := range []*regexp.Regexp{failedTest, failedPackage} { - for _, m := range re.FindAllStringSubmatch(out, -1) { - if !slices.Contains(names, m[1]) { - names = append(names, m[1]) - } - } - - if len(names) > 0 { - return names - } - } - - return nil -} - -// marker identifies the failure in its issue, so a later run finds the issue -// rather than filing another: the repository, where the tests failed, and -// which tests, none of which a timing or a line number changes. -func (f testFailure) marker() string { - what := slices.Sorted(slices.Values(f.Failing)) - if len(what) == 0 { - what = []string{regexp.MustCompile(`\d+`).ReplaceAllString(f.Output, "")} - } - - sum := sha256.Sum256([]byte(strings.Join(append([]string{f.Owner, f.Name, f.Task}, what...), "\n"))) - - return "devtool:test-failure " + hex.EncodeToString(sum[:8]) -} - -// maxTitled is how many failing tests an issue's title names. -const maxTitled = 3 - -func (f testFailure) title() string { - names := strings.Join(f.Failing[:min(len(f.Failing), maxTitled)], ", ") - if more := len(f.Failing) - maxTitled; more > 0 { - names += fmt.Sprintf(" and %d more", more) - } - - if names == "" { - names = "the suite" - } - - if f.Task == "" { - return "Tests fail on main: " + names - } - - return fmt.Sprintf("Tests fail after %q: %s", f.Task, names) -} - -//go:embed testissue.md.tmpl -var testIssueText string - -var testIssueTmpl = template.Must(template.New("testissue").Parse(testIssueText)) - -func (f testFailure) body() (string, error) { - var b bytes.Buffer - - err := testIssueTmpl.Execute(&b, struct { - testFailure - Marker, ShortCommit string - }{f, f.marker(), f.Commit[:min(len(f.Commit), 12)]}) - - return b.String(), err -} - -// issueTracker is where a test failure is reported: a test replaces it. -type issueTracker interface { - // findOpen is the address of the open issue holding marker, or "". - findOpen(ctx context.Context, owner, name, marker string) (string, error) - create(ctx context.Context, owner, name, title, body string) (string, error) -} - -// reportTestFailure files an issue for a repository whose tests failed, unless -// one for the same failure is open already. Reporting is not the run's work, -// so a failure to report is logged rather than returned. -func (s *Service) reportTestFailure(ctx context.Context, u *unit, res event.Result) { - if s.issues == nil || res.Err == nil { - return - } - - f, ok := parseTestFailure(res.Err) - if !ok { - return - } - - f.Owner, f.Name, f.Unpushed, f.Version = u.owner, u.name, res.Commits, s.version - f.Commit = commandOutput(ctx, u.repo, "git", "rev-parse", "@{upstream}") - f.GoVersion = commandOutput(ctx, u.repo, "go", "version") - f.Time = time.Now().UTC().Format("2006-01-02 15:04 MST") - - log := slog.With("repo", u.key()) - - open, err := s.issues.findOpen(ctx, f.Owner, f.Name, f.marker()) - if err != nil { - log.WarnContext(ctx, "could not look for an issue about the failing tests", "error", err) - - return - } - - if open != "" { - log.InfoContext(ctx, "the failing tests are reported already", "issue", open) - - return - } - - body, err := f.body() - if err != nil { - log.WarnContext(ctx, "could not write the issue about the failing tests", "error", err) - - return - } - - url, err := s.issues.create(ctx, f.Owner, f.Name, f.title(), body) - if err != nil { - log.WarnContext(ctx, "could not file an issue about the failing tests", "error", err) - - return - } - - log.InfoContext(ctx, "filed an issue about the failing tests", "issue", url) -} - -// commandOutput is what a command says in the repository, or "unknown". -func commandOutput(ctx context.Context, repo maintainedRepo, name string, args ...string) string { - out, err := repo.ExecCommand(ctx, name, args...) - if err != nil { - return "unknown" - } - - return strings.TrimSpace(string(out)) -} - -// gitHubIssues files issues through the GitHub API. -type gitHubIssues struct{ client *github.Client } - -func (g gitHubIssues) findOpen(ctx context.Context, owner, name, marker string) (string, error) { - opts := &github.IssueListByRepoOptions{State: "open", ListOptions: github.ListOptions{PerPage: 100}} - - for { - issues, resp, err := g.client.Issues.ListByRepo(ctx, owner, name, opts) - if err != nil { - return "", err - } - - for _, issue := range issues { - if !issue.IsPullRequest() && strings.Contains(issue.GetBody(), marker) { - return issue.GetHTMLURL(), nil - } - } - - if resp.NextPage == 0 { - return "", nil - } - - opts.ListOptions.Page = resp.NextPage - } -} - -func (g gitHubIssues) create(ctx context.Context, owner, name, title, body string) (string, error) { - issue, _, err := g.client.Issues.Create(ctx, owner, name, &github.IssueRequest{Title: &title, Body: &body}) - - return issue.GetHTMLURL(), err -} diff --git a/internal/run/testissue_test.go b/internal/run/testissue_test.go deleted file mode 100644 index d332eae..0000000 --- a/internal/run/testissue_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package run - -import ( - "context" - "errors" - "fmt" - "strings" - "sync" - "testing" - - engine "github.com/MarkRosemaker/devtool-engine/maintain" - "github.com/MarkRosemaker/ghrepo" -) - -// fakeIssues files issues in memory. -type fakeIssues struct { - mu sync.Mutex - filed []fakeIssue - reject error -} - -type fakeIssue struct{ repo, title, body string } - -func (f *fakeIssues) findOpen(_ context.Context, owner, name, marker string) (string, error) { - f.mu.Lock() - defer f.mu.Unlock() - - for i, issue := range f.filed { - if issue.repo == owner+"/"+name && strings.Contains(issue.body, marker) { - return fmt.Sprintf("https://github.com/%s/%s/issues/%d", owner, name, i+1), nil - } - } - - return "", nil -} - -func (f *fakeIssues) create(_ context.Context, owner, name, title, body string) (string, error) { - f.mu.Lock() - defer f.mu.Unlock() - - if f.reject != nil { - return "", f.reject - } - - f.filed = append(f.filed, fakeIssue{owner + "/" + name, title, body}) - - return fmt.Sprintf("https://github.com/%s/%s/issues/%d", owner, name, len(f.filed)), nil -} - -// testCommand is the command gorepo runs, and failingOutput what it says about -// a failure once gorepo has left out what passed. -const ( - testCommand = "go test ./... -race -cover -covermode=atomic -coverpkg=./... -coverprofile=cover.out -shuffle=on" - failingOutput = "-test.shuffle 1794492933930643719\n" + - "--- FAIL: TestBad (0.00s)\n" + - " b_test.go:4: boom\n" + - "FAIL\n" + - "FAIL\texample.com/app/b\t0.011s" -) - -func testsFail() error { - return ghrepo.ExecError{Cmd: testCommand, Out: failingOutput, Err: errors.New("exit status 1")} -} - -// Tests failing on main are filed once, however many runs find them. -func TestFailingTestsOnMainAreFiledOnce(t *testing.T) { - svc, src, _ := portfolio(t, `{"alice": {"repositories": {"app": {}}}}`) - src.add("alice", "app", map[string]string{"go.mod": goMod("github.com/alice/app")}) - - issues := &fakeIssues{} - svc.issues = issues - - for range 2 { - src.coverErrs["alice/app"] = []error{testsFail()} - - if err := svc.Run(t.Context()); err != nil { - t.Fatal(err) - } - } - - if len(issues.filed) != 1 { - t.Fatalf("filed %d issues over two runs, want 1", len(issues.filed)) - } - - issue := issues.filed[0] - if issue.repo != "alice/app" || issue.title != "Tests fail on main: TestBad" { - t.Errorf("filed %q on %s", issue.title, issue.repo) - } - - for _, want := range []string{ - " [![Go Reference](https://pkg.go.dev/badge/github.com/MarkRosemaker/ghrepo.svg)](https://pkg.go.dev/github.com/MarkRosemaker/ghrepo) ![Code Coverage](https://img.shields.io/badge/coverage-87%25-green) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) - -![Code Coverage](https://img.shields.io/badge/coverage-16%25-red) +

A GitHub repository as one Go value, its clone and its settings on GitHub.

+ +`NewRepository` opens a GitHub repository as a `Repository`: a clone on this +machine, made or reused, with the git operations a tool needs on it (pull, +commit, push, status, reset and clean), and the repository on GitHub: its +description and topics, its releases and their binaries, and its issues, found +by their text and labels, opened and edited, with the labels they need created. +`ExecCommand` runs a command in the clone. ## Additional Information diff --git a/vendor/github.com/MarkRosemaker/ghrepo/devtool.json b/vendor/github.com/MarkRosemaker/ghrepo/devtool.json index a70da13..9cab3ad 100644 --- a/vendor/github.com/MarkRosemaker/ghrepo/devtool.json +++ b/vendor/github.com/MarkRosemaker/ghrepo/devtool.json @@ -1,6 +1,6 @@ { "coverage": 87.6, - "devtoolVersion": "v0.0.0-20261109135438-ca7ef29cb255", + "devtoolVersion": "v0.0.0-20261112110546-fc8216e5bd0b", "kind": "library", "tools": { "golangci-lint": "v2.15.0", diff --git a/vendor/github.com/MarkRosemaker/ghrepo/issue.go b/vendor/github.com/MarkRosemaker/ghrepo/issue.go new file mode 100644 index 0000000..c76ccb0 --- /dev/null +++ b/vendor/github.com/MarkRosemaker/ghrepo/issue.go @@ -0,0 +1,74 @@ +package ghrepo + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/google/go-github/v80/github" +) + +// FindOpenIssue returns the first open issue whose body contains text, or nil +// where there is none. With labels, only issues carrying all of them are +// read. Pull requests, which GitHub lists as issues too, are passed over. +func (r *Repository) FindOpenIssue(ctx context.Context, text string, labels ...string) (*github.Issue, error) { + opts := &github.IssueListByRepoOptions{ + State: "open", Labels: labels, ListOptions: github.ListOptions{PerPage: 100}, + } + + for { + issues, resp, err := r.s.github.Issues.ListByRepo(ctx, r.owner, r.name, opts) + if err != nil { + return nil, err + } + + for _, issue := range issues { + if !issue.IsPullRequest() && strings.Contains(issue.GetBody(), text) { + return issue, nil + } + } + + if resp.NextPage == 0 { + return nil, nil + } + + opts.ListOptions.Page = resp.NextPage + } +} + +// CreateIssue opens an issue with the labels given, which must exist (see +// [Repository.EnsureLabel]). +func (r *Repository) CreateIssue(ctx context.Context, title, body string, labels ...string) (*github.Issue, error) { + req := &github.IssueRequest{Title: &title, Body: &body} + if len(labels) > 0 { + req.Labels = &labels + } + + issue, _, err := r.s.github.Issues.Create(ctx, r.owner, r.name, req) + + return issue, err +} + +// EditIssue replaces an issue's title and body. +func (r *Repository) EditIssue(ctx context.Context, number int, title, body string) (*github.Issue, error) { + issue, _, err := r.s.github.Issues.Edit(ctx, r.owner, r.name, number, + &github.IssueRequest{Title: &title, Body: &body}) + + return issue, err +} + +// EnsureLabel creates the label where the repository does not have it yet, +// with color (six hex digits, no #) and description. One it has is left as it +// is, so a colour somebody chose stays. +func (r *Repository) EnsureLabel(ctx context.Context, name, color, description string) error { + _, _, err := r.s.github.Issues.GetLabel(ctx, r.owner, r.name, name) + if errResp, ok := errors.AsType[*github.ErrorResponse](err); !ok || errResp.Response.StatusCode != http.StatusNotFound { + return err + } + + _, _, err = r.s.github.Issues.CreateLabel(ctx, r.owner, r.name, + &github.Label{Name: &name, Color: &color, Description: &description}) + + return err +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 4ca41c9..d78abb5 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -10,7 +10,7 @@ github.com/MarkRosemaker/devtool-engine/selfupdate # github.com/MarkRosemaker/errpath v0.0.0-20261109120451-91cd29644c73 ## explicit; go 1.23.2 github.com/MarkRosemaker/errpath -# github.com/MarkRosemaker/ghrepo v0.0.0-20261111155235-6292abd5e761 +# github.com/MarkRosemaker/ghrepo v0.0.0-20261112163900-8fb453642f10 ## explicit; go 1.26.0 github.com/MarkRosemaker/ghrepo # github.com/MarkRosemaker/golangci-json v0.0.0-20261109170355-1ad201ecdb14 From 2de2e84dea765554cad282d737e4ba3d2749c222 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 16:47:20 +0000 Subject: [PATCH 3/3] Name the package that does not compile, from a real failure A broken build fails every package that imports the broken one, so the issue now names the packages go test reports compile errors for, under their "# package" header, and its title gives them by their path in the module. lso's build failure, as Telegram reported it, is the test, and a FAIL line's separator may be a space as well as a tab. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M6xJuQgmRd19QozaQDD3tk --- internal/run/issue.go | 17 ++++--- internal/run/issue_test.go | 43 ++++++++++++++++++ internal/run/testdata/lso-build-failure.txt | 49 +++++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 internal/run/testdata/lso-build-failure.txt diff --git a/internal/run/issue.go b/internal/run/issue.go index 4b173d1..7094cd1 100644 --- a/internal/run/issue.go +++ b/internal/run/issue.go @@ -118,18 +118,20 @@ func parseTestFailure(err error) (testFailure, bool) { var ( failedTest = regexp.MustCompile(`(?m)^\s*--- FAIL: (\S+)`) - failedPackage = regexp.MustCompile(`(?m)^FAIL\t(\S+)`) + brokenPackage = regexp.MustCompile(`(?m)^# (\S+)`) + failedPackage = regexp.MustCompile(`(?m)^FAIL[ \t]+(\S+)`) failedBuild = regexp.MustCompile(`\[(build|setup) failed\]`) shuffleSeed = regexp.MustCompile(`(?m)^-test\.shuffle (\d+)`) digits = regexp.MustCompile(`\d+`) ) -// failingTests names what failed: the tests, or the packages where no test is -// named, as when one does not build. +// failingTests names what failed: the tests; where none is named, the +// packages that do not compile, rather than every package that imports them; +// failing those, the packages that failed. func failingTests(out string) []string { var names []string - for _, re := range []*regexp.Regexp{failedTest, failedPackage} { + for _, re := range []*regexp.Regexp{failedTest, brokenPackage, failedPackage} { for _, m := range re.FindAllStringSubmatch(out, -1) { if !slices.Contains(names, m[1]) { names = append(names, m[1]) @@ -184,7 +186,12 @@ func (f testFailure) marker() string { const maxTitled = 3 func (f testFailure) title() string { - names := strings.Join(f.Failing[:min(len(f.Failing), maxTitled)], ", ") + short := make([]string, 0, min(len(f.Failing), maxTitled)) + for _, name := range f.Failing[:cap(short)] { + short = append(short, strings.TrimPrefix(name, "github.com/"+f.Owner+"/"+f.Name+"/")) + } + + names := strings.Join(short, ", ") if more := len(f.Failing) - maxTitled; more > 0 { names += fmt.Sprintf(" and %d more", more) } diff --git a/internal/run/issue_test.go b/internal/run/issue_test.go index a03424e..d1d3b41 100644 --- a/internal/run/issue_test.go +++ b/internal/run/issue_test.go @@ -3,6 +3,8 @@ package run import ( "errors" "fmt" + "os" + "path/filepath" "slices" "strings" "testing" @@ -136,6 +138,47 @@ func TestATaskThatBreaksTheTestsIsFiledWithItsChange(t *testing.T) { } } +// A build broken by a dependency update, as a maintained run of lso reported +// it in Telegram, names the package that does not compile, not the nineteen +// that import it. +func TestABrokenBuildNamesThePackageThatDoesNotCompile(t *testing.T) { + text, err := os.ReadFile(filepath.Join("testdata", "lso-build-failure.txt")) + if err != nil { + t.Fatal(err) + } + + head, rest, _ := strings.Cut(strings.TrimSuffix(string(text), "\n"), "\ncommand ") + task, changes, _ := strings.Cut(head, failedAfterChanges) + out := strings.TrimSuffix(rest[strings.Index(rest, "\n")+1:], "\nexit status 1") + + failure := fmt.Errorf("%s%s%s\n%w", task, failedAfterChanges, changes, testsFail(out)) + if failure.Error() != strings.TrimSuffix(string(text), "\n") { + t.Fatalf("the error rebuilt from the message reads differently:\n%s", failure) + } + + r, ok, err := reportOf(event.Result{Err: failure}, engine.Spec{}, facts{Owner: "MarkRosemaker", Name: "lso"}) + if err != nil || !ok { + t.Fatalf("reportOf = %v, %v", ok, err) + } + + if want := `Build fails after "update dependencies": internal/handler`; r.title != want { + t.Errorf("title = %q, want %q", r.title, want) + } + + if r.label != depsBreakBuild { + t.Errorf("label = %q, want %q", r.label.name, depsBreakBuild.name) + } + + for _, want := range []string{ + "failing to build", "- `github.com/MarkRosemaker/lso/internal/handler`\n\nOnly", + "mismatched types civil.Date and untyped nil", "golang.org/x/crypto v0.58.0 => v0.59.0", + } { + if !strings.Contains(r.body, want) { + t.Errorf("the issue lacks %q:\n%s", want, r.body) + } + } +} + // Coverage fallen below the floor is filed, with the figures. func TestCoverageBelowTheFloorIsFiled(t *testing.T) { svc, src := app(t, `"coverage": 85`) diff --git a/internal/run/testdata/lso-build-failure.txt b/internal/run/testdata/lso-build-failure.txt new file mode 100644 index 0000000..892d4cc --- /dev/null +++ b/internal/run/testdata/lso-build-failure.txt @@ -0,0 +1,49 @@ +update dependencies: testing after changes: +changed: go.mod, go.sum, vendor/ +github.com/MarkRosemaker/cassette v0.0.0-20261111150821-c1aacdd69c8d => v0.0.0-20261111155207-3bee9a17aeeb +github.com/MarkRosemaker/jsonutil v0.0.0-20261111150633-f9633ee2a58b => v0.0.0-20261111155129-3eb35e1f56f9 +github.com/MarkRosemaker/openapi v0.0.0-20261111150840-c64c59206c8a => v0.0.0-20261112162126-f853e838175d +github.com/MarkRosemaker/openapi-codegen v0.0.0-20261111151843-e4e809c004cc => v0.0.0-20261112163116-42dc918ba3d5 +github.com/MarkRosemaker/openapi-compare v0.0.0-20261111150954-d52dfcd17894 => v0.0.0-20261112162234-45b138a1d5ab +github.com/MarkRosemaker/openapi-compress v0.0.0-20261111151250-d4b10689e566 => v0.0.0-20261112162518-e7e0b82d9c2e +github.com/MarkRosemaker/openapi-edit v0.0.0-20261111150956-e8fa1f9acda8 => v0.0.0-20261112162236-a2824d5b95e1 +github.com/MarkRosemaker/openapi-enrich v0.0.0-20261111151153-3931c01307b5 => v0.0.0-20261112162415-528831d94645 +github.com/MarkRosemaker/openapi-flatten v0.0.0-20261111150957-1cfa86f6217b => v0.0.0-20261112162235-3adfaf5c7b08 +github.com/MarkRosemaker/openapi-merge v0.0.0-20261111150953-481b88fd1c12 => v0.0.0-20261112162234-a5ec8a48dfe6 +github.com/MarkRosemaker/ordmap v0.0.0-20261109211921-475c54164ef0 => v0.0.0-20261112162023-d7f4218a519a +github.com/MarkRosemaker/stockdata v0.0.0-20261111152904-54e772cb0340 => v0.0.0-20261112163457-b2d6519a53d7 +github.com/MarkRosemaker/webapp v0.0.0-20261111150935-4a637f21a357 => v0.0.0-20261111170225-45bca20ad4e8 +github.com/go-api-libs/api v0.0.0-20261109120454-35fbf0e61179 => v0.0.0-20261111181121-bc153be31ced +github.com/go-api-libs/finnhub v0.0.0-20261111152514-3854356ac15e => v0.0.0-20261112163311-b8c96b231ca9 +github.com/go-api-libs/ibkr v0.0.0-20261111142534-6f388a966958 => v0.0.0-20261112163243-27c78a411324 +github.com/go-api-libs/sec-edgar v0.0.0-20261111152338-02c37ef0afbf => v0.0.0-20261112163343-f0b108f1bf45 +github.com/go-api-libs/yahoo-finance v0.0.0-20261111152254-fa6574dc3d1a => v0.0.0-20261112163257-dbd7ed4d81bb +golang.org/x/crypto v0.58.0 => v0.59.0 +golang.org/x/exp v0.0.0-20261109185158-a52e3aef5920 => v0.0.0-20261112162710-dab199d8bf38 +golang.org/x/exp/shiny v0.0.0-20261109185158-a52e3aef5920 => v0.0.0-20261112162710-dab199d8bf38 +google.golang.org/api v0.312.0 => v0.313.0 +command "go test ./... -race -cover -covermode=atomic -coverpkg=./... -coverprofile=cover.out -shuffle=on" failed: +# github.com/MarkRosemaker/lso/internal/handler +internal/handler/accounts.go:165:77: invalid operation: p.Expiry == nil (mismatched types civil.Date and untyped nil) +internal/handler/accounts.go:180:20: invalid operation: cannot indirect p.Expiry (variable of struct type civil.Date) +FAIL github.com/MarkRosemaker/lso [build failed] +FAIL github.com/MarkRosemaker/lso/cmd/lso [build failed] +FAIL github.com/MarkRosemaker/lso/databases/data [build failed] +FAIL github.com/MarkRosemaker/lso/internal/cmd [build failed] +FAIL github.com/MarkRosemaker/lso/internal/data [build failed] +FAIL github.com/MarkRosemaker/lso/internal/date [build failed] +FAIL github.com/MarkRosemaker/lso/internal/events [build failed] +FAIL github.com/MarkRosemaker/lso/internal/fundamentals [build failed] +FAIL github.com/MarkRosemaker/lso/internal/handler [build failed] +FAIL github.com/MarkRosemaker/lso/internal/hub [build failed] +FAIL github.com/MarkRosemaker/lso/internal/ibkr [build failed] +FAIL github.com/MarkRosemaker/lso/internal/livecache [build failed] +FAIL github.com/MarkRosemaker/lso/internal/log [build failed] +FAIL github.com/MarkRosemaker/lso/internal/service [build failed] +FAIL github.com/MarkRosemaker/lso/internal/setup [build failed] +FAIL github.com/MarkRosemaker/lso/internal/sheets [build failed] +FAIL github.com/MarkRosemaker/lso/internal/yahoo [build failed] +FAIL github.com/MarkRosemaker/lso/pkg/api [build failed] +FAIL github.com/MarkRosemaker/lso/pkg/scanner [build failed] +FAIL +exit status 1