2025-02-22 09:42:15 +01:00
package service
import (
"context"
"fmt"
"log/slog"
2025-02-22 18:01:45 +01:00
"strings"
2025-02-22 09:42:15 +01:00
"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
2025-02-22 14:21:30 +01:00
//go:embed issue_default_template.txt
var issueDefaultTemplate string
2025-02-22 09:42:15 +01:00
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 ]
}
2025-02-22 18:01:45 +01:00
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
}
2025-02-22 09:42:15 +01:00
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
}
2025-02-22 18:01:45 +01:00
func ( m * IssueManager ) GenerateIssue ( ctx context . Context , user * model . User , projectID string , issueSummary string ) ( string , string , error ) {
2025-02-22 09:42:15 +01:00
systemPrompt , err := m . getIssueSystemPrompt ( ctx , user , projectID )
if err != nil {
2025-02-22 18:01:45 +01:00
return "" , "" , errors . WithStack ( err )
2025-02-22 09:42:15 +01:00
}
userPrompt , err := m . getIssueUserPrompt ( ctx , user , projectID , issueSummary )
if err != nil {
2025-02-22 18:01:45 +01:00
return "" , "" , errors . WithStack ( err )
2025-02-22 09:42:15 +01:00
}
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 {
2025-02-22 18:01:45 +01:00
return "" , "" , errors . WithStack ( err )
2025-02-22 09:42:15 +01:00
}
2025-02-22 18:01:45 +01:00
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
2025-02-22 09:42:15 +01:00
}
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 )
2025-02-22 14:21:30 +01:00
if err != nil && ! errors . Is ( err , port . ErrFileNotFound ) {
2025-02-22 09:42:15 +01:00
return "" , errors . WithStack ( err )
}
2025-02-22 14:21:30 +01:00
if issueTemplate == "" {
issueTemplate = issueDefaultTemplate
}
2025-02-22 09:42:15 +01:00
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 ) ,
}
}
2025-02-22 18:01:45 +01:00
func toTitle ( str string ) string {
str = strings . ToLower ( str )
return strings . ToUpper ( string ( str [ 0 ] ) ) + str [ 1 : ]
}