diff --git a/docs/architecture.md b/docs/architecture.md index bb823bc..e4724fc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,8 +42,21 @@ 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, 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, or +whose coverage slips below its floor, gets an issue (`issue.go`, written from +`issue_tests.md.tmpl` or `issue_coverage.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. Every such issue carries the label +`devtool`, and one of `coverage`, `failing on main`, `dependency update breaks +build` or `tests`, or `devtool breaks build` or `tests`, which the run creates +where a repository lacks it. A comment at its top identifies the problem (for +failing tests, by task and failing tests), and a later run that finds an open +issue with it refreshes that issue rather than filing another. `GITHUB_TOKEN` +needs permission to write issues and labels 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/issue.go b/internal/run/issue.go new file mode 100644 index 0000000..7094cd1 --- /dev/null +++ b/internal/run/issue.go @@ -0,0 +1,375 @@ +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" + engine "github.com/MarkRosemaker/devtool-engine/maintain" + "github.com/MarkRosemaker/ghrepo" +) + +// label is a label devtool puts on the issues it files, created in a +// repository the first time an issue there needs it. +type label struct{ name, color, description string } + +// reported is on every issue devtool files, so a person can list them all. +var reported = label{"devtool", "5319e7", "Reported by a maintained devtool run"} + +// The labels that say what an issue is about, one to each. +var ( + coverageLabel = label{"coverage", "fbca04", "Test coverage is below the floor"} + failingOnMain = label{"failing on main", "b60205", "The build or the tests fail on main, before any change"} + depsBreakBuild = label{"dependency update breaks build", "d93f0b", "Updating dependencies breaks the build"} + depsBreakTests = label{"dependency update breaks tests", "d93f0b", "Updating dependencies breaks the tests"} + toolBreaksBuild = label{"devtool breaks build", "1d76db", "A devtool task breaks the build"} + toolBreaksTests = label{"devtool breaks tests", "1d76db", "A devtool task breaks the tests"} +) + +// updateDependencies is the task that moves dependencies; every other task is +// devtool's own. +const updateDependencies = "update dependencies" + +// report is an issue a maintained run files on a repository: what it is about, +// the comment that identifies it, and what it says. +type report struct { + label label + marker string + title, body string +} + +// facts is what an issue says about the run that found the problem. +type facts struct { + Owner, Name string + Commit, GoVersion, Version, Time string +} + +// ShortCommit is the commit as GitHub abbreviates it. +func (f facts) ShortCommit() string { return f.Commit[:min(len(f.Commit), 12)] } + +// testFailure is a repository's tests failing in a maintained run. +type testFailure struct { + facts + + // 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 + + Marker 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) { + exec, ok := errors.AsType[ghrepo.ExecError](err) + if !ok || !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+)`) + 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; 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, brokenPackage, 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 +} + +// Build reports whether a package failed to build, rather than its tests. +func (f testFailure) Build() bool { return failedBuild.MatchString(f.Output) } + +// label is what the failure is about: main itself, or the task that broke +// the build or the tests. +func (f testFailure) label() label { + build := f.Build() + + switch { + case f.Task == "": + return failingOnMain + case f.Task == updateDependencies && build: + return depsBreakBuild + case f.Task == updateDependencies: + return depsBreakTests + case build: + return toolBreaksBuild + default: + return toolBreaksTests + } +} + +// marker identifies the failure in its issue, so a later run finds the issue +// rather than filing another: where the tests failed, and which, 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{digits.ReplaceAllString(f.Output, "")} + } + + sum := sha256.Sum256([]byte(strings.Join(append([]string{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 { + 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) + } + + if names == "" { + names = "the suite" + } + + what := "Tests fail" + if f.Build() { + what = "Build fails" + } + + if f.Task == "" { + return what + " on main: " + names + } + + return fmt.Sprintf("%s after %q: %s", what, f.Task, names) +} + +// coverageSlip is a repository's coverage below the floor a run holds it to: +// fallen below it in this run, or below what a public repository needs. +type coverageSlip struct { + facts + + Floor, Prev, Coverage float64 + Public bool + + Marker string +} + +// parseCoverageSlip reads err as the engine reports coverage below a floor. +func parseCoverageSlip(err error, spec engine.Spec, res event.Result) (coverageSlip, bool) { + c := coverageSlip{Prev: res.PrevCoverage, Coverage: res.Coverage} + + switch { + case errors.Is(err, engine.ErrPublicCoverage): + c.Public, c.Floor = true, spec.PublicCoverage + case errors.Is(err, engine.ErrCoverageFell): + c.Floor = spec.CoverageFloor + default: + return coverageSlip{}, false + } + + return c, true +} + +func (c coverageSlip) marker() string { + if c.Public { + return "devtool:coverage public" + } + + return "devtool:coverage fell" +} + +func (c coverageSlip) title() string { + if c.Public { + return fmt.Sprintf("Coverage is below the %g%% a public repository needs: %.1f%%", c.Floor, c.Coverage) + } + + return fmt.Sprintf("Coverage fell below %g%%: %.1f%% → %.1f%%", c.Floor, c.Prev, c.Coverage) +} + +var ( + //go:embed issue_tests.md.tmpl + testsIssueText string + //go:embed issue_coverage.md.tmpl + coverageIssueText string + + testsIssue = template.Must(template.New("tests").Parse(testsIssueText)) + coverageIssue = template.Must(template.New("coverage").Parse(coverageIssueText)) +) + +// reportOf is the issue a repository's result calls for, if any: its tests +// failed, or its coverage is below a floor. Any other failure is told in the +// run's report alone. +func reportOf(res event.Result, spec engine.Spec, run facts) (report, bool, error) { + if res.Err == nil { + return report{}, false, nil + } + + var ( + r report + tmpl *template.Template + data any + ) + + if f, ok := parseTestFailure(res.Err); ok { + f.facts, f.Unpushed = run, res.Commits + f.Marker = f.marker() + r, tmpl, data = report{label: f.label(), marker: f.Marker, title: f.title()}, testsIssue, f + } else if c, ok := parseCoverageSlip(res.Err, spec, res); ok { + c.facts = run + c.Marker = c.marker() + r, tmpl, data = report{label: coverageLabel, marker: c.Marker, title: c.title()}, coverageIssue, c + } else { + return report{}, false, nil + } + + var b bytes.Buffer + if err := tmpl.Execute(&b, data); err != nil { + return report{}, false, err + } + + r.body = b.String() + + return r, true, nil +} + +// fileIssue reports a repository's failing tests or slipping coverage as an +// issue on it, refreshing the open one where the same problem was reported +// before. Reporting is not the run's work, so a failure to report is logged +// rather than returned. +func (s *Service) fileIssue(ctx context.Context, u *unit, spec engine.Spec, res event.Result) { + if res.Err == nil { + return + } + + log := slog.With("repo", u.key()) + + r, ok, err := reportOf(res, spec, facts{ + Owner: u.owner, Name: u.name, Version: s.version, + Commit: commandOutput(ctx, u.repo, "git", "rev-parse", "@{upstream}"), + GoVersion: commandOutput(ctx, u.repo, "go", "version"), + Time: time.Now().UTC().Format("2006-01-02 15:04 MST"), + }) + if err != nil { + log.WarnContext(ctx, "could not write an issue", "error", err) + + return + } else if !ok { + return + } + + if err := s.fileReport(ctx, u.repo, r); err != nil { + log.WarnContext(ctx, "could not file an issue", "title", r.title, "error", err) + } +} + +// fileReport files r on repo, or refreshes the open issue it was filed as. +func (s *Service) fileReport(ctx context.Context, repo maintainedRepo, r report) error { + for _, l := range []label{reported, r.label} { + if err := repo.EnsureLabel(ctx, l.name, l.color, l.description); err != nil { + return fmt.Errorf("creating label %q: %w", l.name, err) + } + } + + open, err := repo.FindOpenIssue(ctx, r.marker, reported.name) + if err != nil { + return fmt.Errorf("looking for the issue: %w", err) + } + + if open != nil { + if _, err := repo.EditIssue(ctx, open.GetNumber(), r.title, r.body); err != nil { + return fmt.Errorf("refreshing %s: %w", open.GetHTMLURL(), err) + } + + slog.InfoContext(ctx, "refreshed the issue", "repo", repo.String(), "issue", open.GetHTMLURL()) + + return nil + } + + issue, err := repo.CreateIssue(ctx, r.title, r.body, reported.name, r.label.name) + if err != nil { + return err + } + + slog.InfoContext(ctx, "filed an issue", "repo", repo.String(), "issue", issue.GetHTMLURL()) + + return nil +} + +// 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)) +} diff --git a/internal/run/issue_coverage.md.tmpl b/internal/run/issue_coverage.md.tmpl new file mode 100644 index 0000000..18d1c98 --- /dev/null +++ b/internal/run/issue_coverage.md.tmpl @@ -0,0 +1,30 @@ + +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..d1d3b41 --- /dev/null +++ b/internal/run/issue_test.go @@ -0,0 +1,237 @@ +package run + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "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/issue_tests.md.tmpl b/internal/run/issue_tests.md.tmpl new file mode 100644 index 0000000..26ac068 --- /dev/null +++ b/internal/run/issue_tests.md.tmpl @@ -0,0 +1,66 @@ + +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. +{{- 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 it + +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, and brings it up to date each + time the failure comes back. Leave that comment as it is. + +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 662b6b0..9432ab5 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -402,6 +402,8 @@ func (s *Service) maintain( res := s.runner.Update(ctx, u.repo, spec, u.repo.sequence(s.version), events) + s.fileIssue(ctx, u, spec, 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..68c5a5e 100644 --- a/internal/run/service_test.go +++ b/internal/run/service_test.go @@ -33,6 +33,9 @@ type fakeSource struct { prefetchErr map[string]error 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 @@ -54,6 +57,9 @@ func newFakeSource(t *testing.T) *fakeSource { prefetchErr: map[string]error{}, 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{}, @@ -180,12 +186,97 @@ 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 +// 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/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