clearcase/internal/core/service/forge_manager.go

526 lines
14 KiB
Go

package service
import (
"context"
"fmt"
"log/slog"
"net/url"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
_ "embed"
"forge.cadoles.com/wpetit/clearcase/internal/core/model"
"forge.cadoles.com/wpetit/clearcase/internal/core/port"
"github.com/bornholm/genai/llm"
"github.com/num30/go-cache"
"github.com/pkg/errors"
)
var (
ErrForgeNotAvailable = errors.New("forge not available")
)
//go:embed prompts/issue_system_prompt.gotmpl
var issueSystemPromptRawTemplate string
//go:embed prompts/issue_user_prompt.gotmpl
var issueUserPromptRawTemplate string
//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 ForgeManager struct {
forgeFactories []ForgeFactory
llmClient llm.Client
projectCache *cache.Cache[[]*model.Project]
forgeCache *cache.Cache[port.Forge]
}
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)
}
issueURL, err := forge.CreateIssue(ctx, projectID, title, body)
if err != nil {
return "", errors.WithStack(err)
}
return issueURL, nil
}
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)
if !exists {
forge, err := m.getUserForge(ctx, user)
if err != nil {
return nil, errors.WithStack(err)
}
refreshedProjects, err := forge.ListProjects(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
slices.SortFunc(refreshedProjects, func(p1 *model.Project, p2 *model.Project) int {
return strings.Compare(strings.ToLower(p1.Name), strings.ToLower(p2.Name))
})
m.projectCache.Set(cacheKey, refreshedProjects, 0)
projects = refreshedProjects
}
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 *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)
}
slog.DebugContext(ctx, "using system prompt", slog.String("systemPrompt", systemPrompt))
userPrompt, err := m.getIssueUserPrompt(ctx, user, projectID, issueSummary)
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) getIssueSystemPrompt(ctx context.Context, user *model.User, projectID string, issueTemplate string) (string, error) {
if issueTemplate == "" {
forge, err := m.getUserForge(ctx, user)
if err != nil {
return "", errors.WithStack(err)
}
repoIssueTemplate, err := forge.GetIssueTemplate(ctx, projectID)
if err != nil && !errors.Is(err, port.ErrFileNotFound) {
return "", errors.WithStack(err)
}
issueTemplate = repoIssueTemplate
}
if issueTemplate == "" {
issueTemplate = issueDefaultTemplate
}
systemPrompt, err := llm.PromptTemplate(issueSystemPromptRawTemplate, struct {
IssueTemplate string
}{
IssueTemplate: issueTemplate,
})
if err != nil {
return "", errors.WithStack(err)
}
return systemPrompt, nil
}
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)
}
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)
}
resources, err := m.extractResources(ctx, forge, projectID, issueSummary)
if err != nil {
return "", errors.WithStack(err)
}
userPrompt, err := llm.PromptTemplate(issueUserPromptRawTemplate, struct {
Summary string
Project *model.Project
ProjectLanguages []string
Resources []*model.Resource
}{
Summary: issueSummary,
Project: project,
ProjectLanguages: projectLanguages,
Resources: resources,
})
if err != nil {
return "", errors.WithStack(err)
}
return userPrompt, nil
}
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
}
for _, f := range m.forgeFactories {
if !f.Match(user) {
continue
}
forge, err := f.Create(ctx, user)
if err != nil {
slog.ErrorContext(ctx, "could not retrieve user forge", slog.Any("error", errors.WithStack(err)))
return nil, errors.WithStack(ErrForgeNotAvailable)
}
m.forgeCache.Set(user.AccessToken, forge, cache.DefaultExpiration)
return forge, nil
}
return nil, errors.New("no forge matching user found")
}
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)
if err != nil {
return nil, errors.Wrap(err, "could not extract issues")
}
resources = append(resources, issues...)
files, err := m.extractFiles(ctx, forge, projectID, issueSummary)
if err != nil {
return nil, errors.Wrap(err, "could not extract files")
}
resources = append(resources, files...)
return resources, nil
}
var issueRefRegExp = regexp.MustCompile(`#([0-9]+)`)
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))
for _, m := range issueRefMatches {
issueIDs = append(issueIDs, m[1])
}
issues, err := forge.GetIssues(ctx, projectID, issueIDs...)
if err != nil {
return nil, errors.WithStack(err)
}
resources := make([]*model.Resource, 0, len(issues))
for _, iss := range issues {
if iss == nil {
continue
}
resources = append(resources, &model.Resource{
Name: fmt.Sprintf("#%s - %s", iss.ID, iss.Title),
Type: "Issue",
Syntax: "",
Content: iss.Body,
})
}
return resources, nil
}
var fileRefRegExp = regexp.MustCompile(`(?i)(?:\/[^\/]+)+\/?[^\s]+(?:\.[^\s]+)+|[^\s]+(?:\.[^\s]+)+`)
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))
for _, m := range fileRefMatches {
paths = append(paths, m[0])
}
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))
continue
}
resources = append(resources, &model.Resource{
Name: p,
Type: "File",
Syntax: strings.TrimPrefix(filepath.Ext(p), "."),
Content: string(data),
})
}
return resources, nil
}
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),
forgeCache: cache.New[port.Forge](time.Minute, time.Minute/2),
}
}
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
}