|
|
|
@ -4,6 +4,7 @@ import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/url"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"regexp"
|
|
|
|
|
"slices"
|
|
|
|
@ -23,28 +24,51 @@ var (
|
|
|
|
|
ErrForgeNotAvailable = errors.New("forge not available")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//go:embed issue_system_prompt.gotmpl
|
|
|
|
|
//go:embed prompts/issue_system_prompt.gotmpl
|
|
|
|
|
var issueSystemPromptRawTemplate string
|
|
|
|
|
|
|
|
|
|
//go:embed issue_user_prompt.gotmpl
|
|
|
|
|
//go:embed prompts/issue_user_prompt.gotmpl
|
|
|
|
|
var issueUserPromptRawTemplate string
|
|
|
|
|
|
|
|
|
|
//go:embed issue_default_template.txt
|
|
|
|
|
//go:embed prompts/issue_default_template.txt
|
|
|
|
|
var issueDefaultTemplate string
|
|
|
|
|
|
|
|
|
|
//go:embed prompts/pull_request_system_prompt.gotmpl
|
|
|
|
|
var pullRequestSystemPromptRawTemplate string
|
|
|
|
|
|
|
|
|
|
//go:embed prompts/pull_request_user_prompt.gotmpl
|
|
|
|
|
var pullRequestUserPromptRawTemplate string
|
|
|
|
|
|
|
|
|
|
//go:embed prompts/pull_request_default_template.txt
|
|
|
|
|
var pullRequestDefaultTemplate string
|
|
|
|
|
|
|
|
|
|
type ForgeFactory interface {
|
|
|
|
|
Match(user *model.User) bool
|
|
|
|
|
Create(ctx context.Context, user *model.User) (port.Forge, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type IssueManager struct {
|
|
|
|
|
type ForgeManager struct {
|
|
|
|
|
forgeFactories []ForgeFactory
|
|
|
|
|
llmClient llm.Client
|
|
|
|
|
projectCache *cache.Cache[[]*model.Project]
|
|
|
|
|
forgeCache *cache.Cache[port.Forge]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *IssueManager) CreateIssue(ctx context.Context, user *model.User, projectID string, title string, body string) (string, error) {
|
|
|
|
|
func (m *ForgeManager) UpdatePullRequest(ctx context.Context, user *model.User, projectID string, pullRequestID string, title string, body string) (string, error) {
|
|
|
|
|
forge, err := m.getUserForge(ctx, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pullRequestURL, err := forge.UpdatePullRequest(ctx, projectID, pullRequestID, title, body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pullRequestURL, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *ForgeManager) CreateIssue(ctx context.Context, user *model.User, projectID string, title string, body string) (string, error) {
|
|
|
|
|
forge, err := m.getUserForge(ctx, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
@ -58,7 +82,21 @@ func (m *IssueManager) CreateIssue(ctx context.Context, user *model.User, projec
|
|
|
|
|
return issueURL, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *IssueManager) GetUserProjects(ctx context.Context, user *model.User) ([]*model.Project, error) {
|
|
|
|
|
func (m *ForgeManager) GetUserProjectOpenedPullRequests(ctx context.Context, user *model.User, projectID string) ([]*model.PullRequest, error) {
|
|
|
|
|
forge, err := m.getUserForge(ctx, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pullRequests, err := forge.ListOpenedPullRequests(ctx, projectID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pullRequests, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *ForgeManager) GetUserProjects(ctx context.Context, user *model.User) ([]*model.Project, error) {
|
|
|
|
|
cacheKey := fmt.Sprintf("%s/%s", user.Provider, user.ID)
|
|
|
|
|
|
|
|
|
|
projects, exists := m.projectCache.Get(cacheKey)
|
|
|
|
@ -68,7 +106,7 @@ func (m *IssueManager) GetUserProjects(ctx context.Context, user *model.User) ([
|
|
|
|
|
return nil, errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
refreshedProjects, err := forge.GetAllProjects(ctx)
|
|
|
|
|
refreshedProjects, err := forge.ListProjects(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
@ -85,12 +123,157 @@ func (m *IssueManager) GetUserProjects(ctx context.Context, user *model.User) ([
|
|
|
|
|
return projects, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *ForgeManager) GetUserProjectPullRequest(ctx context.Context, user *model.User, projectID string, pullRequestID string) (*model.PullRequest, error) {
|
|
|
|
|
forge, err := m.getUserForge(ctx, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pullRequests, err := forge.GetPullRequests(ctx, projectID, pullRequestID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(pullRequests) == 0 {
|
|
|
|
|
return nil, errors.WithStack(port.ErrPullRequestNotFound)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pullRequests[0], nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type GeneratIssueOptions struct {
|
|
|
|
|
IssueSummary string
|
|
|
|
|
IssueTemplate string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *IssueManager) GenerateIssue(ctx context.Context, user *model.User, projectID string, issueSummary string, overwrittenIssueTemplate string) (string, string, string, error) {
|
|
|
|
|
func (m *ForgeManager) GeneratePullRequest(ctx context.Context, user *model.User, projectID string, pullRequestID string, summary string, overwrittenTemplate string) (string, string, string, error) {
|
|
|
|
|
systemPrompt, err := m.getPullRequestSystemPrompt(ctx, user, projectID, pullRequestID, overwrittenTemplate)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", "", "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
slog.DebugContext(ctx, "using system prompt", slog.String("systemPrompt", systemPrompt))
|
|
|
|
|
|
|
|
|
|
userPrompt, err := m.getPullRequestUserPrompt(ctx, user, projectID, pullRequestID, summary)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", "", "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
slog.DebugContext(ctx, "using user prompt", slog.String("userPrompt", userPrompt))
|
|
|
|
|
|
|
|
|
|
messages := []llm.Message{
|
|
|
|
|
llm.NewMessage(llm.RoleSystem, systemPrompt),
|
|
|
|
|
llm.NewMessage(llm.RoleUser, userPrompt),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res, err := m.llmClient.ChatCompletion(ctx, llm.WithMessages(messages...))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", "", "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body := res.Message().Content()
|
|
|
|
|
|
|
|
|
|
messages = append(messages, res.Message())
|
|
|
|
|
messages = append(messages, llm.NewMessage(llm.RoleUser, "Generate a title for this issue. Keep it descriptive, simple and short. Do not write anything else."))
|
|
|
|
|
|
|
|
|
|
res, err = m.llmClient.ChatCompletion(ctx, llm.WithMessages(messages...))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", "", "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
title := toTitle(res.Message().Content())
|
|
|
|
|
|
|
|
|
|
messages = append(messages, res.Message())
|
|
|
|
|
messages = append(messages, llm.NewMessage(llm.RoleUser, "Give me a list of questions as Markdown that could help clarify the request. Only write the list without additional headings."))
|
|
|
|
|
|
|
|
|
|
res, err = m.llmClient.ChatCompletion(ctx, llm.WithMessages(messages...))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", "", "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tips := res.Message().Content()
|
|
|
|
|
|
|
|
|
|
return title, body, tips, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *ForgeManager) getPullRequestSystemPrompt(ctx context.Context, user *model.User, projectID string, pullRequestID string, prTemplate string) (string, error) {
|
|
|
|
|
if prTemplate == "" {
|
|
|
|
|
forge, err := m.getUserForge(ctx, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
repoPullRequestTemplate, err := forge.GetPullRequestTemplate(ctx, projectID)
|
|
|
|
|
if err != nil && !errors.Is(err, port.ErrFileNotFound) {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
prTemplate = repoPullRequestTemplate
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if prTemplate == "" {
|
|
|
|
|
prTemplate = pullRequestDefaultTemplate
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
systemPrompt, err := llm.PromptTemplate(pullRequestSystemPromptRawTemplate, struct {
|
|
|
|
|
PullRequestTemplate string
|
|
|
|
|
}{
|
|
|
|
|
PullRequestTemplate: prTemplate,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return systemPrompt, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *ForgeManager) getPullRequestUserPrompt(ctx context.Context, user *model.User, projectID string, pullRequestID string, summary string) (string, error) {
|
|
|
|
|
forge, err := m.getUserForge(ctx, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
project, err := forge.GetProject(ctx, projectID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
projectLanguages, err := forge.GetProjectLanguages(ctx, projectID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
diff, err := forge.GetPullRequestDiff(ctx, projectID, pullRequestID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resources, err := m.extractResources(ctx, forge, projectID, summary)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
userPrompt, err := llm.PromptTemplate(pullRequestUserPromptRawTemplate, struct {
|
|
|
|
|
Summary string
|
|
|
|
|
Project *model.Project
|
|
|
|
|
ProjectLanguages []string
|
|
|
|
|
Resources []*model.Resource
|
|
|
|
|
Diff string
|
|
|
|
|
}{
|
|
|
|
|
Summary: summary,
|
|
|
|
|
Project: project,
|
|
|
|
|
ProjectLanguages: projectLanguages,
|
|
|
|
|
Resources: resources,
|
|
|
|
|
Diff: diff,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return userPrompt, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *ForgeManager) GenerateIssue(ctx context.Context, user *model.User, projectID string, issueSummary string, overwrittenIssueTemplate string) (string, string, string, error) {
|
|
|
|
|
systemPrompt, err := m.getIssueSystemPrompt(ctx, user, projectID, overwrittenIssueTemplate)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", "", "", errors.WithStack(err)
|
|
|
|
@ -140,7 +323,7 @@ func (m *IssueManager) GenerateIssue(ctx context.Context, user *model.User, proj
|
|
|
|
|
return title, body, tips, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *IssueManager) getIssueSystemPrompt(ctx context.Context, user *model.User, projectID string, issueTemplate string) (string, error) {
|
|
|
|
|
func (m *ForgeManager) getIssueSystemPrompt(ctx context.Context, user *model.User, projectID string, issueTemplate string) (string, error) {
|
|
|
|
|
if issueTemplate == "" {
|
|
|
|
|
forge, err := m.getUserForge(ctx, user)
|
|
|
|
|
if err != nil {
|
|
|
|
@ -171,7 +354,7 @@ func (m *IssueManager) getIssueSystemPrompt(ctx context.Context, user *model.Use
|
|
|
|
|
return systemPrompt, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *IssueManager) getIssueUserPrompt(ctx context.Context, user *model.User, projectID string, issueSummary string) (string, error) {
|
|
|
|
|
func (m *ForgeManager) getIssueUserPrompt(ctx context.Context, user *model.User, projectID string, issueSummary string) (string, error) {
|
|
|
|
|
forge, err := m.getUserForge(ctx, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", errors.WithStack(err)
|
|
|
|
@ -210,7 +393,7 @@ func (m *IssueManager) getIssueUserPrompt(ctx context.Context, user *model.User,
|
|
|
|
|
return userPrompt, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *IssueManager) getUserForge(ctx context.Context, user *model.User) (port.Forge, error) {
|
|
|
|
|
func (m *ForgeManager) getUserForge(ctx context.Context, user *model.User) (port.Forge, error) {
|
|
|
|
|
forge, exists := m.forgeCache.Get(user.AccessToken)
|
|
|
|
|
if exists {
|
|
|
|
|
return forge, nil
|
|
|
|
@ -235,7 +418,7 @@ func (m *IssueManager) getUserForge(ctx context.Context, user *model.User) (port
|
|
|
|
|
return nil, errors.New("no forge matching user found")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *IssueManager) extractResources(ctx context.Context, forge port.Forge, projectID string, issueSummary string) ([]*model.Resource, error) {
|
|
|
|
|
func (m *ForgeManager) extractResources(ctx context.Context, forge port.Forge, projectID string, issueSummary string) ([]*model.Resource, error) {
|
|
|
|
|
resources := make([]*model.Resource, 0)
|
|
|
|
|
|
|
|
|
|
issues, err := m.extractIssues(ctx, forge, projectID, issueSummary)
|
|
|
|
@ -257,7 +440,7 @@ func (m *IssueManager) extractResources(ctx context.Context, forge port.Forge, p
|
|
|
|
|
|
|
|
|
|
var issueRefRegExp = regexp.MustCompile(`#([0-9]+)`)
|
|
|
|
|
|
|
|
|
|
func (m *IssueManager) extractIssues(ctx context.Context, forge port.Forge, projectID string, issueSummary string) ([]*model.Resource, error) {
|
|
|
|
|
func (m *ForgeManager) extractIssues(ctx context.Context, forge port.Forge, projectID string, issueSummary string) ([]*model.Resource, error) {
|
|
|
|
|
issueRefMatches := issueRefRegExp.FindAllStringSubmatch(issueSummary, -1)
|
|
|
|
|
|
|
|
|
|
issueIDs := make([]string, 0, len(issueRefMatches))
|
|
|
|
@ -290,7 +473,7 @@ func (m *IssueManager) extractIssues(ctx context.Context, forge port.Forge, proj
|
|
|
|
|
|
|
|
|
|
var fileRefRegExp = regexp.MustCompile(`(?i)(?:\/[^\/]+)+\/?[^\s]+(?:\.[^\s]+)+|[^\s]+(?:\.[^\s]+)+`)
|
|
|
|
|
|
|
|
|
|
func (m *IssueManager) extractFiles(ctx context.Context, forge port.Forge, projectID string, issueSummary string) ([]*model.Resource, error) {
|
|
|
|
|
func (m *ForgeManager) extractFiles(ctx context.Context, forge port.Forge, projectID string, issueSummary string) ([]*model.Resource, error) {
|
|
|
|
|
fileRefMatches := fileRefRegExp.FindAllStringSubmatch(issueSummary, -1)
|
|
|
|
|
|
|
|
|
|
paths := make([]string, 0, len(fileRefMatches))
|
|
|
|
@ -301,6 +484,10 @@ func (m *IssueManager) extractFiles(ctx context.Context, forge port.Forge, proje
|
|
|
|
|
resources := make([]*model.Resource, 0)
|
|
|
|
|
|
|
|
|
|
for _, p := range paths {
|
|
|
|
|
if isURL(p) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data, err := forge.GetFile(ctx, projectID, p)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.ErrorContext(ctx, "could not retrieve file", slog.Any("error", errors.WithStack(err)), slog.String("path", p))
|
|
|
|
@ -318,8 +505,8 @@ func (m *IssueManager) extractFiles(ctx context.Context, forge port.Forge, proje
|
|
|
|
|
return resources, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewIssueManager(llmClient llm.Client, forgeFactories ...ForgeFactory) *IssueManager {
|
|
|
|
|
return &IssueManager{
|
|
|
|
|
func NewForgeManager(llmClient llm.Client, forgeFactories ...ForgeFactory) *ForgeManager {
|
|
|
|
|
return &ForgeManager{
|
|
|
|
|
llmClient: llmClient,
|
|
|
|
|
forgeFactories: forgeFactories,
|
|
|
|
|
projectCache: cache.New[[]*model.Project](time.Minute*5, (time.Minute*5)/2),
|
|
|
|
@ -331,3 +518,8 @@ func toTitle(str string) string {
|
|
|
|
|
str = strings.ToLower(str)
|
|
|
|
|
return strings.ToUpper(string(str[0])) + str[1:]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isURL(str string) bool {
|
|
|
|
|
_, err := url.ParseRequestURI(str)
|
|
|
|
|
return err == nil
|
|
|
|
|
}
|