| // Copyright 2024 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| // Package repro tries to extract a reproduction case for a bug. |
| // If it finds one, it tries to bisect to determine what caused the bug. |
| package repro |
| |
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "iter" |
| "log/slog" |
| "os" |
| "reflect" |
| "regexp" |
| "strings" |
| "text/template" |
| |
| "golang.org/x/oscar/internal/github" |
| "golang.org/x/oscar/internal/labels" |
| "golang.org/x/oscar/internal/llm" |
| "golang.org/x/oscar/internal/storage" |
| "rsc.io/markdown" |
| ) |
| |
| // CaseTester is an interface that knows how to run |
| // test cases for the project. This is supplied by |
| // clients of this package. |
| type CaseTester interface { |
| // Clean takes a test case extracted from an issue |
| // and tries to turn it into a runnable test case. |
| // For Go this might do things like add a missing package declaration. |
| // If the test case is incomprehensible this should return |
| // an error, in which case no bisection will be attempted. |
| Clean(ctx context.Context, body string) (string, error) |
| |
| // CleanVersions takes the pass/fail versions guessed by the LLM, |
| // and returns new versions that match the repo for the project |
| // being tested. |
| CleanVersions(ctx context.Context, passVersion, failVersion string) (string, string) |
| |
| // Try runs a cleaned test case at the suggested version. |
| // It reports whether the test case passed or failed. |
| Try(ctx context.Context, body, version string) (bool, error) |
| |
| // Bisect starts a bisection of a cleaned test case. |
| // If the Bisect method is able to determine the failing commit, |
| // it is responsible for updating the issue. |
| // We do this because bisection is done asynchronously. |
| // |
| // The string result is an arbitrary identifier that |
| // will be returned to the caller of [CheckReproduction], |
| // and may be used to report progress or cancel the operation. |
| Bisect(ctx context.Context, issue *github.Issue, body, pass, fail string) (string, error) |
| } |
| |
| // CheckReproduction looks at an issue body and tries to extract |
| // a test case. If it is able to find a case that has a problem, |
| // it tries to bisect to the commit that caused the issue. |
| // |
| // On success this returns an empty string if there is nothing to do, |
| // or the result of a call to [CaseTester.Bisect]. |
| func CheckReproduction(ctx context.Context, lg *slog.Logger, db storage.DB, cgen llm.ContentGenerator, tester CaseTester, i *github.Issue) (string, error) { |
| // See if this is a bug report. |
| if i.PullRequest != nil { |
| lg.Debug("no reproduction case", "issue", i.Number, "reason", "pull request") |
| return "", nil |
| } |
| |
| // TODO(iant): We shouldn't look up the label again, |
| // it should be written down somewhere. |
| cat, _, err := labels.IssueCategory(ctx, db, cgen, i) |
| if err != nil { |
| return "", err |
| } |
| if cat.Name != "bug" { |
| lg.Debug("no reproduction case", "issue", i.Number, "reason", "not a bug report", "category", cat.Name) |
| return "", nil |
| } |
| |
| bodyDoc := github.ParseMarkdown(i.Body) |
| body := cleanIssueBody(bodyDoc) |
| args := bodyArgs{ |
| Title: i.Title, |
| Body: body, |
| } |
| |
| var sb strings.Builder |
| err = template.Must(template.New("repro").Parse(reproPromptTemplate)).Execute(&sb, args) |
| if err != nil { |
| return "", err |
| } |
| |
| jsonRes, err := cgen.GenerateContent(ctx, reproSchema, []llm.Part{llm.Text(sb.String())}) |
| if err != nil { |
| return "", err |
| } |
| |
| var res reproResponse |
| if err := json.Unmarshal([]byte(jsonRes), &res); err != nil { |
| return "", fmt.Errorf("unmarshaling %q: %w", jsonRes, err) |
| } |
| |
| if res.Repro == "" || res.Repro == "unknown" { |
| lg.Debug("no reproduction case", "issue", i.Number, "reason", "LLM found nothing") |
| return "", nil |
| } |
| |
| // The LLM sometimes introduces Markdown syntax. Remove it. |
| repro := strings.ReplaceAll(res.Repro, "```", "") |
| |
| testcase, err := tester.Clean(ctx, repro) |
| if err != nil { |
| lg.Debug("no reproduction case", "issue", i.Number, "reason", "failed to clean test case", "err", err) |
| return "", nil |
| } |
| |
| passRelease, failRelease := tester.CleanVersions(ctx, res.PassRelease, res.FailRelease) |
| |
| failOK, err := tester.Try(ctx, testcase, failRelease) |
| if err != nil { |
| lg.Debug("no reproduction case", "issue", i.Number, "reason", "failed to run test", "version", failRelease, "err", err) |
| return "", nil |
| } |
| |
| passOK, err := tester.Try(ctx, testcase, passRelease) |
| if err != nil { |
| lg.Debug("no reproduction case", "issue", i.Number, "reason", "failed to run test", "version", passRelease, "err", err) |
| return "", nil |
| } |
| |
| switch { |
| case !failOK && passOK: |
| case failOK && !passOK: |
| failOK, passOK = passOK, failOK |
| failRelease, passRelease = passRelease, failRelease |
| case failOK && passOK: |
| // TODO: try earlier and later revisions. |
| lg.Debug("no reproduction case", "issue", i.Number, "reason", "test case always passes", "failVersion", failRelease, "passVersion", passRelease) |
| return "", nil |
| case !failOK && !passOK: |
| // TODO: try earlier and later revisions. |
| lg.Debug("no reproduction case", "issue", i.Number, "reason", "test case always fails", "failVersion", failRelease, "passVersion", passRelease) |
| return "", nil |
| default: |
| panic("can't happen") |
| } |
| |
| // Start the bisection. The bisection code runs asynchronously, |
| // and is responsible for updating the issue if it finds the |
| // failing commit. |
| return tester.Bisect(ctx, i, testcase, passRelease, failRelease) |
| } |
| |
| // reproPromptTemplate is the prompt we send to the LLM to ask it to |
| // pull a reproduction case from an issue. |
| const reproPromptTemplate = ` |
| Your job is to take an issue reported against the Go language |
| or standard library and look for a test case, |
| written in Go, that will reproduce the problem. |
| |
| If you find a test case, please report the test case, |
| the Go version where the test case passed, |
| and the Go version where the case failed. |
| |
| Only use test cases that actually appear in the issue. |
| If there is no test case, say that the test case is unknown. |
| Do not try to write a test case that does not appear in the issue. |
| |
| Return the test case as a Go program. |
| Do not use markdown syntax. |
| |
| Please act as an experienced Go developer and maintainer. |
| |
| The title of the issue is: {{.Title}} |
| The body of the issue is: {{.Body}} |
| ` |
| |
| type bodyArgs struct { |
| Title string |
| Body string |
| } |
| |
| // reproResponse is the response we expect from the LLM. |
| // It must match [reproSchema]. |
| type reproResponse struct { |
| Repro string |
| FailRelease string |
| PassRelease string |
| } |
| |
| // reproSchema describes the data the LLM should reply with. |
| var reproSchema = &llm.Schema{ |
| Type: llm.TypeObject, |
| Properties: map[string]*llm.Schema{ |
| "Repro": { |
| Type: llm.TypeString, |
| Description: `The test case, or "unknown" if no test case is found`, |
| }, |
| "FailRelease": { |
| Type: llm.TypeString, |
| Description: `A Go release in which the test fails, or "unknown" if not known`, |
| }, |
| "PassRelease": { |
| Type: llm.TypeString, |
| Description: `A Go release in which the test passes, or "unknown" if not known`, |
| }, |
| }, |
| } |
| |
| // TODO(iant): copied from ../labels/labels.go. |
| var htmlCommentRegexp = regexp.MustCompile(`<!--(\n|.)*?-->`) |
| |
| // TODO(iant): copied from ../labels/labels.go. |
| func cleanIssueBody(doc *markdown.Document) string { |
| for b, entry := range blocks(doc) { |
| if h, ok := b.(*markdown.HTMLBlock); ok && entry { |
| // Delete comments. |
| // Each Text is a line. |
| t := strings.Join(h.Text, "\n") |
| t = htmlCommentRegexp.ReplaceAllString(t, "") |
| h.Text = strings.Split(t, "\n") |
| } |
| } |
| return markdown.Format(doc) |
| } |
| |
| // TODO(iant): copied from ../labels/labels.go. |
| var blockType = reflect.TypeFor[markdown.Block]() |
| |
| // TODO(iant): copied from ../labels/labels.go. |
| func blocks(b markdown.Block) iter.Seq2[markdown.Block, bool] { |
| return func(yield func(markdown.Block, bool) bool) { |
| if !yield(b, true) { |
| return |
| } |
| |
| // Using reflection makes this code resilient to additions |
| // to the markdown package. |
| |
| // All implementations of Block are struct pointers. |
| v := reflect.ValueOf(b).Elem() |
| if v.Kind() != reflect.Struct { |
| fmt.Fprintf(os.Stderr, "internal/labels.blocks: expected struct, got %s", v.Type()) |
| return |
| } |
| // Each Block holds its sub-Blocks directly, or in a slice. |
| for _, sf := range reflect.VisibleFields(v.Type()) { |
| if sf.Type.Implements(blockType) { |
| sv := v.FieldByIndex(sf.Index) |
| mb := sv.Interface().(markdown.Block) |
| for b, e := range blocks(mb) { |
| if !yield(b, e) { |
| return |
| } |
| } |
| } else if sf.Type.Kind() == reflect.Slice && sf.Type.Elem().Implements(blockType) { |
| sv := v.FieldByIndex(sf.Index) |
| for i := range sv.Len() { |
| mb := sv.Index(i).Interface().(markdown.Block) |
| for b, e := range blocks(mb) { |
| if !yield(b, e) { |
| return |
| } |
| } |
| } |
| } |
| } |
| if !yield(b, false) { |
| return |
| } |
| } |
| } |