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:] }