package service import ( "context" "fmt" "log/slog" "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 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] forgeCache *cache.Cache[port.Forge] } 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.GetAllProjects(ctx) if err != nil { return nil, errors.WithStack(err) } slices.SortFunc(refreshedProjects, func(p1 *model.Project, p2 *model.Project) int { return strings.Compare(p1.Name, p2.Name) }) 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, string, error) { systemPrompt, err := m.getIssueSystemPrompt(ctx, user, projectID) 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 *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) { 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 *IssueManager) 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 *IssueManager) 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 *IssueManager) 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 *IssueManager) 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 { 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 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), 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:] }