blob: 946fce1cf48a2eb577735d10d8eee4d2511498da [file] [log] [blame]
// 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 related
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"strings"
"testing"
"time"
"golang.org/x/oscar/internal/actions"
"golang.org/x/oscar/internal/diff"
"golang.org/x/oscar/internal/docs"
"golang.org/x/oscar/internal/embeddocs"
"golang.org/x/oscar/internal/github"
"golang.org/x/oscar/internal/llm"
"golang.org/x/oscar/internal/storage"
"golang.org/x/oscar/internal/testutil"
)
var ctx = context.Background()
func TestRun(t *testing.T) {
check := testutil.Checker(t)
lg := testutil.Slogger(t)
db := storage.MemDB()
gh := github.New(lg, db, nil, nil)
gh.Testing().LoadTxtar("../testdata/markdown.txt")
gh.Testing().LoadTxtar("../testdata/rsctmp.txt")
run := func(p *Poster) {
t.Helper()
check(p.Run(ctx))
check(actions.Run(ctx, lg, db))
}
dc := docs.New(lg, db)
docs.Sync(dc, gh)
vdb := storage.MemVectorDB(db, lg, "vecs")
embeddocs.Sync(ctx, lg, vdb, llm.QuoteEmbedder(), dc)
vdb = storage.MemVectorDB(db, lg, "vecs")
p := New(lg, db, gh, vdb, dc, "postname")
p.EnableProject("rsc/markdown")
p.SetTimeLimit(time.Time{})
run(p)
checkActionLog(t, db, nil)
actions.ClearLogForTesting(t, db)
p.EnablePosts()
run(p)
checkActionLog(t, db, map[int64]string{13: post13, 19: post19})
actions.ClearLogForTesting(t, db)
p.EnableProject("rsc/markdown")
p.SetTimeLimit(time.Time{})
p.EnablePosts()
run(p)
checkActionLog(t, db, nil)
actions.ClearLogForTesting(t, db)
for i := range 4 {
p := New(lg, db, gh, vdb, dc, "postnameloop."+fmt.Sprint(i))
p.EnableProject("rsc/markdown")
p.SetTimeLimit(time.Time{})
switch i {
case 0:
p.SkipTitlePrefix("feature: ")
case 1:
p.SkipTitleSuffix("for heading")
case 2:
p.SkipBodyContains("For example, this heading")
case 3:
p.SkipBodyContains("For example, this heading")
p.SkipBodyContains("ZZZ")
}
p.EnablePosts()
run(p)
checkActionLog(t, db, map[int64]string{13: post13})
actions.ClearLogForTesting(t, db)
}
p = New(lg, db, gh, vdb, dc, "postname2")
p = New(lg, db, gh, vdb, dc, "postname3")
p.EnableProject("rsc/markdown")
p.SetMinScore(2.0) // impossible
p.SetTimeLimit(time.Time{})
p.EnablePosts()
run(p)
checkActionLog(t, db, nil)
actions.ClearLogForTesting(t, db)
p = New(lg, db, gh, vdb, dc, "postname4")
p.EnableProject("rsc/markdown")
p.SetMinScore(2.0) // impossible
p.SetTimeLimit(time.Date(2222, 1, 1, 1, 1, 1, 1, time.UTC))
p.EnablePosts()
run(p)
checkActionLog(t, db, nil)
actions.ClearLogForTesting(t, db)
p = New(lg, db, gh, vdb, dc, "postname5")
p.EnableProject("rsc/markdown")
p.SetMinScore(0) // everything
p.SetMaxResults(0) // except none
p.SetTimeLimit(time.Time{})
p.EnablePosts()
run(p)
checkActionLog(t, db, nil)
actions.ClearLogForTesting(t, db)
}
func TestPost(t *testing.T) {
check := testutil.Checker(t)
post := func(p *Poster, project string, issues ...int64) {
t.Helper()
for _, iss := range issues {
check(p.Post(ctx, project, iss))
}
check(actions.Run(ctx, p.slog, p.db))
}
run := func(p *Poster) {
check(p.Run(ctx))
check(actions.Run(ctx, p.slog, p.db))
}
t.Run("basic", func(t *testing.T) {
p, _, project, _ := newTestPoster(t)
post(p, project, 19, 13)
checkActionLog(t, p.db, map[int64]string{13: post13, 19: post19})
})
t.Run("double-post", func(t *testing.T) {
p, _, project, _ := newTestPoster(t)
post(p, project, 13, 13)
checkActionLog(t, p.db, map[int64]string{13: post13})
})
t.Run("post-run", func(t *testing.T) {
p, buf, project, _ := newTestPoster(t)
post(p, project, 19)
latestDone := checkActionLog(t, p.db, map[int64]string{19: post19})
testutil.ExpectLog(t, buf, "advanced watcher", 0)
// Post does not advance Run's watcher, so it operates on all unhandled issues.
run(p)
latestDone = checkActionLogAfter(t, p.db, map[int64]string{13: post13}, latestDone)
testutil.ExpectLog(t, buf, "advanced watcher", 2) // issue 13 and 19 both advance watcher
// Run is a no-op because previous call to run advanced watcher past issue 19.
run(p)
checkActionLogAfter(t, p.db, nil, latestDone)
testutil.ExpectLog(t, buf, "advanced watcher", 2) // no change
})
t.Run("post-run-async", func(t *testing.T) {
p, _, project, _ := newTestPoster(t)
// OK to run Post in the middle of a Run.
done := make(chan struct{})
go func() {
run(p)
done <- struct{}{}
}()
post(p, project, 19)
<-done
checkActionLog(t, p.db, map[int64]string{13: post13, 19: post19})
})
}
func TestPostError(t *testing.T) {
t.Run("event not in DB", func(t *testing.T) {
p, _, project, _ := newTestPoster(t)
wantErr := errEventNotFound
// issue 42 is not in the project
if err := p.Post(ctx, project, 42); !errors.Is(err, wantErr) {
t.Fatalf("Post err = %v, want %v", err, wantErr)
}
})
t.Run("issue not in Vector DB", func(t *testing.T) {
p, _, project, _ := newTestPoster(t)
// Vector search will fail if there is no embedding
// for the issue.
id := int64(19)
p.vdb.Delete(issueURL(project, id))
wantErr := errVectorSearchFailed
if err := p.Post(ctx, project, id); !errors.Is(err, wantErr) {
t.Fatalf("Post err = %v, want %v", err, wantErr)
}
})
}
func newTestPoster(t *testing.T) (_ *Poster, out *bytes.Buffer, project string, check func(err error)) {
t.Helper()
lg, out := testutil.SlogBuffer()
db := storage.MemDB()
gh := github.New(lg, db, nil, nil)
gh.Testing().LoadTxtar("../testdata/markdown.txt")
gh.Testing().LoadTxtar("../testdata/rsctmp.txt")
dc := docs.New(lg, db)
docs.Sync(dc, gh)
vdb := storage.MemVectorDB(db, lg, "vecs")
embeddocs.Sync(ctx, lg, vdb, llm.QuoteEmbedder(), dc)
p := New(lg, db, gh, vdb, dc, t.Name())
project = "rsc/markdown"
p.EnableProject(project)
p.SetTimeLimit(time.Time{})
p.EnablePosts()
return p, out, project, testutil.Checker(t)
}
// checkActionLog calls checkActionLogAfter with the zero time.
func checkActionLog(t *testing.T, db storage.DB, want map[int64]string) time.Time {
return checkActionLogAfter(t, db, want, time.Time{})
}
// checkActionLogAfter compares the contents of the action log after start with the values in want.
// The actions in the log must all be of type [action].
// Each key in want is the issue number of a completed action, and each value must match the action's
// comment body.
// checkActionLogAfter returns the done time of the latest matched action.
func checkActionLogAfter(t *testing.T, db storage.DB, want map[int64]string, start time.Time) time.Time {
t.Helper()
entries := slices.Collect(actions.ScanAfter(testutil.Slogger(t), db, start, nil))
for _, e := range entries {
if !e.IsDone() {
continue
}
var a action
if err := json.Unmarshal(e.Action, &a); err != nil {
t.Fatal(err)
}
if a.Issue.Project() != "rsc/markdown" {
t.Errorf("posted to unexpected project: %v", e)
continue
}
w, ok := want[a.Issue.Number]
if !ok {
t.Errorf("post to unexpected issue: %v", e)
continue
}
delete(want, a.Issue.Number)
if strings.TrimSpace(a.Changes.Body) != strings.TrimSpace(w) {
t.Errorf("rsc/markdown#%d: wrong post:\n%s", a.Issue.Number,
string(diff.Diff("want", []byte(w), "have", []byte(a.Changes.Body))))
}
}
for _, issue := range slices.Sorted(maps.Keys(want)) {
t.Errorf("did not see post on rsc/markdown#%d", issue)
}
if t.Failed() {
t.FailNow()
}
if len(entries) > 0 {
return entries[len(entries)-1].Done
}
return time.Time{}
}
var post13 = unQUOT(`**Related Issues and Documentation**
- [goldmark and markdown diff with h1 inside p #6 (closed)](https://github.com/rsc/markdown/issues/6) <!-- score=0.92657 -->
- [Support escaped \QUOT|\QUOT in table cells #9 (closed)](https://github.com/rsc/markdown/issues/9) <!-- score=0.91858 -->
- [markdown: fix markdown printing for inline code #12 (closed)](https://github.com/rsc/markdown/issues/12) <!-- score=0.91325 -->
- [markdown: emit Info in CodeBlock markdown #18 (closed)](https://github.com/rsc/markdown/issues/18) <!-- score=0.91129 -->
- [feature: synthesize lowercase anchors for heading #19](https://github.com/rsc/markdown/issues/19) <!-- score=0.90867 -->
- [Replace newlines with spaces in alt text #4 (closed)](https://github.com/rsc/markdown/issues/4) <!-- score=0.90859 -->
- [allow capital X in task list items #2 (closed)](https://github.com/rsc/markdown/issues/2) <!-- score=0.90850 -->
- [build(deps): bump golang.org/x/text from 0.3.6 to 0.3.8 in /rmplay #10](https://github.com/rsc/tmp/issues/10) <!-- score=0.90453 -->
- [Render reference links in Markdown #14 (closed)](https://github.com/rsc/markdown/issues/14) <!-- score=0.90175 -->
- [Render reference links in Markdown #15 (closed)](https://github.com/rsc/markdown/issues/15) <!-- score=0.90103 -->
<sub>(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in [this discussion](https://github.com/golang/go/discussions/67901).)</sub>
`)
var post19 = unQUOT(`**Related Issues and Documentation**
- [allow capital X in task list items #2 (closed)](https://github.com/rsc/markdown/issues/2) <!-- score=0.92943 -->
- [Support escaped \QUOT|\QUOT in table cells #9 (closed)](https://github.com/rsc/markdown/issues/9) <!-- score=0.91994 -->
- [goldmark and markdown diff with h1 inside p #6 (closed)](https://github.com/rsc/markdown/issues/6) <!-- score=0.91813 -->
- [Render reference links in Markdown #14 (closed)](https://github.com/rsc/markdown/issues/14) <!-- score=0.91513 -->
- [Render reference links in Markdown #15 (closed)](https://github.com/rsc/markdown/issues/15) <!-- score=0.91487 -->
- [Empty column heading not recognized in table #7 (closed)](https://github.com/rsc/markdown/issues/7) <!-- score=0.90874 -->
- [Correctly render reference links in Markdown #13](https://github.com/rsc/markdown/issues/13) <!-- score=0.90867 -->
- [markdown: fix markdown printing for inline code #12 (closed)](https://github.com/rsc/markdown/issues/12) <!-- score=0.90795 -->
- [Replace newlines with spaces in alt text #4 (closed)](https://github.com/rsc/markdown/issues/4) <!-- score=0.90278 -->
- [build(deps): bump golang.org/x/text from 0.3.6 to 0.3.8 in /rmplay #10](https://github.com/rsc/tmp/issues/10) <!-- score=0.90259 -->
<sub>(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in [this discussion](https://github.com/golang/go/discussions/67901).)</sub>
`)
func unQUOT(s string) string { return strings.ReplaceAll(s, "QUOT", "`") }