blob: 254482ed596b17443b90a2f96ee748e6169aea6d [file] [log] [blame] [edit]
// 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
}
}
}