191 lines
4.7 KiB
Go
191 lines
4.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"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 issue_system_prompt.gotmpl
|
|
var issueSystemPromptRawTemplate string
|
|
|
|
//go:embed issue_user_prompt.gotmpl
|
|
var issueUserPromptRawTemplate string
|
|
|
|
//go:embed issue_default_template.txt
|
|
var issueDefaultTemplate string
|
|
|
|
type ForgeFactory interface {
|
|
Match(user *model.User) bool
|
|
Create(ctx context.Context, user *model.User) (port.Forge, error)
|
|
}
|
|
|
|
type IssueManager struct {
|
|
forgeFactories []ForgeFactory
|
|
llmClient llm.Client
|
|
projectCache *cache.Cache[[]*model.Project]
|
|
}
|
|
|
|
func (m *IssueManager) 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 *IssueManager) 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.GetProjects(ctx)
|
|
if err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
|
|
m.projectCache.Set(cacheKey, refreshedProjects, 0)
|
|
|
|
projects = refreshedProjects
|
|
}
|
|
|
|
return projects, nil
|
|
}
|
|
|
|
func (m *IssueManager) GenerateIssue(ctx context.Context, user *model.User, projectID string, issueSummary string) (string, string, error) {
|
|
systemPrompt, err := m.getIssueSystemPrompt(ctx, user, projectID)
|
|
if err != nil {
|
|
return "", "", errors.WithStack(err)
|
|
}
|
|
|
|
userPrompt, err := m.getIssueUserPrompt(ctx, user, projectID, issueSummary)
|
|
if err != nil {
|
|
return "", "", errors.WithStack(err)
|
|
}
|
|
|
|
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())
|
|
|
|
return title, body, nil
|
|
}
|
|
|
|
func (m *IssueManager) getIssueSystemPrompt(ctx context.Context, user *model.User, projectID string) (string, error) {
|
|
forge, err := m.getUserForge(ctx, user)
|
|
if err != nil {
|
|
return "", errors.WithStack(err)
|
|
}
|
|
|
|
issueTemplate, err := forge.GetIssueTemplate(ctx, projectID)
|
|
if err != nil && !errors.Is(err, port.ErrFileNotFound) {
|
|
return "", errors.WithStack(err)
|
|
}
|
|
|
|
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 *IssueManager) getIssueUserPrompt(ctx context.Context, user *model.User, projectID string, issueSummary string) (string, error) {
|
|
_, err := m.getUserForge(ctx, user)
|
|
if err != nil {
|
|
return "", errors.WithStack(err)
|
|
}
|
|
|
|
userPrompt, err := llm.PromptTemplate(issueUserPromptRawTemplate, struct {
|
|
Context string
|
|
}{
|
|
Context: issueSummary,
|
|
})
|
|
if err != nil {
|
|
return "", errors.WithStack(err)
|
|
}
|
|
|
|
return userPrompt, nil
|
|
}
|
|
|
|
func (m *IssueManager) getUserForge(ctx context.Context, user *model.User) (port.Forge, error) {
|
|
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)
|
|
}
|
|
|
|
return forge, nil
|
|
}
|
|
|
|
return nil, errors.New("no forge matching user found")
|
|
}
|
|
|
|
func NewIssueManager(llmClient llm.Client, forgeFactories ...ForgeFactory) *IssueManager {
|
|
return &IssueManager{
|
|
llmClient: llmClient,
|
|
forgeFactories: forgeFactories,
|
|
projectCache: cache.New[[]*model.Project](time.Minute*5, (time.Minute*5)/2),
|
|
}
|
|
}
|
|
|
|
func toTitle(str string) string {
|
|
str = strings.ToLower(str)
|
|
return strings.ToUpper(string(str[0])) + str[1:]
|
|
}
|