diff --git a/internal/adapter/gitea/forge.go b/internal/adapter/gitea/forge.go index 39f3277..49b3565 100644 --- a/internal/adapter/gitea/forge.go +++ b/internal/adapter/gitea/forge.go @@ -2,6 +2,7 @@ package gitea import ( "context" + "log/slog" "net/http" "slices" "strconv" @@ -17,6 +18,51 @@ type Forge struct { client *gitea.Client } +// GetProject implements port.Forge. +func (f *Forge) GetProject(ctx context.Context, rawProjectID string) (*model.Project, error) { + projectID, err := strconv.ParseInt(rawProjectID, 10, 64) + if err != nil { + return nil, errors.WithStack(err) + } + + project, _, err := f.client.GetRepoByID(projectID) + if err != nil { + return nil, errors.WithStack(err) + } + + return &model.Project{ + ID: rawProjectID, + Name: project.FullName, + Description: project.Description, + }, nil +} + +// GetProjectLanguages implements port.Forge. +func (f *Forge) GetProjectLanguages(ctx context.Context, rawProjectID string) ([]string, error) { + projectID, err := strconv.ParseInt(rawProjectID, 10, 64) + if err != nil { + return nil, errors.WithStack(err) + } + + project, _, err := f.client.GetRepoByID(projectID) + if err != nil { + return nil, errors.WithStack(err) + } + + mappedLanguages, _, err := f.client.GetRepoLanguages(project.Owner.UserName, project.Name) + if err != nil { + return nil, errors.WithStack(err) + } + + languages := make([]string, 0, len(mappedLanguages)) + + for l := range mappedLanguages { + languages = append(languages, l) + } + + return languages, nil +} + // CreateIssue implements port.Forge. func (f *Forge) CreateIssue(ctx context.Context, rawProjectID string, title string, body string) (string, error) { projectID, err := strconv.ParseInt(rawProjectID, 10, 64) @@ -65,12 +111,46 @@ func (f *Forge) GetIssueTemplate(ctx context.Context, rawProjectID string) (stri } // GetIssues implements port.Forge. -func (f *Forge) GetIssues(ctx context.Context, projectID string, issueIDs ...string) ([]*model.Issue, error) { - panic("unimplemented") +func (f *Forge) GetIssues(ctx context.Context, rawProjectID string, issueIDs ...string) ([]*model.Issue, error) { + projectID, err := strconv.ParseInt(rawProjectID, 10, 64) + if err != nil { + return nil, errors.WithStack(err) + } + + project, _, err := f.client.GetRepoByID(projectID) + if err != nil { + return nil, errors.WithStack(err) + } + + issues := make([]*model.Issue, 0) + + for _, rawIssueID := range issueIDs { + issueID, err := strconv.ParseInt(rawIssueID, 10, 64) + if err != nil { + slog.ErrorContext(ctx, "could not parse issue id", slog.Any("error", errors.WithStack(err))) + issues = append(issues, nil) + continue + } + + issue, _, err := f.client.GetIssue(project.Owner.UserName, project.Name, issueID) + if err != nil { + slog.ErrorContext(ctx, "could not get issue", slog.String("project", project.FullName), slog.Int64("issueID", issueID), slog.Any("error", errors.WithStack(err))) + issues = append(issues, nil) + continue + } + + issues = append(issues, &model.Issue{ + ID: rawIssueID, + Title: issue.Title, + Body: issue.Body, + }) + } + + return issues, nil } -// ListProjects implements port.Forge. -func (f *Forge) GetProjects(ctx context.Context) ([]*model.Project, error) { +// GetAllProjects implements port.Forge. +func (f *Forge) GetAllProjects(ctx context.Context) ([]*model.Project, error) { projects := make([]*model.Project, 0) page := 1 @@ -93,14 +173,14 @@ func (f *Forge) GetProjects(ctx context.Context) ([]*model.Project, error) { for _, r := range repositories { projects = append(projects, &model.Project{ - ID: strconv.FormatInt(r.ID, 10), - Label: r.Owner.UserName + "/" + r.Name, + ID: strconv.FormatInt(r.ID, 10), + Name: r.FullName, }) } } slices.SortFunc(projects, func(p1 *model.Project, p2 *model.Project) int { - return strings.Compare(p1.Label, p2.Label) + return strings.Compare(p1.Name, p2.Name) }) return projects, nil diff --git a/internal/core/model/issue.go b/internal/core/model/issue.go index b2b9a32..ee02948 100644 --- a/internal/core/model/issue.go +++ b/internal/core/model/issue.go @@ -1,6 +1,7 @@ package model type Issue struct { - ID string - Content string + ID string + Title string + Body string } diff --git a/internal/core/model/project.go b/internal/core/model/project.go index 345f13a..96f1f3a 100644 --- a/internal/core/model/project.go +++ b/internal/core/model/project.go @@ -1,6 +1,7 @@ package model type Project struct { - ID string - Label string + ID string + Name string + Description string } diff --git a/internal/core/model/resource.go b/internal/core/model/resource.go new file mode 100644 index 0000000..4e5c105 --- /dev/null +++ b/internal/core/model/resource.go @@ -0,0 +1,8 @@ +package model + +type Resource struct { + Name string + Type string + Syntax string + Content string +} diff --git a/internal/core/port/forge.go b/internal/core/port/forge.go index 2660283..02f563d 100644 --- a/internal/core/port/forge.go +++ b/internal/core/port/forge.go @@ -12,8 +12,10 @@ var ( ) type Forge interface { - GetProjects(ctx context.Context) ([]*model.Project, error) + GetAllProjects(ctx context.Context) ([]*model.Project, error) CreateIssue(ctx context.Context, projectID string, title string, body string) (string, error) GetIssues(ctx context.Context, projectID string, issueIDs ...string) ([]*model.Issue, error) GetIssueTemplate(ctx context.Context, projectID string) (string, error) + GetProjectLanguages(ctx context.Context, projectID string) ([]string, error) + GetProject(ctx context.Context, projectID string) (*model.Project, error) } diff --git a/internal/core/service/issue_manager.go b/internal/core/service/issue_manager.go index 97946e6..4e71f3c 100644 --- a/internal/core/service/issue_manager.go +++ b/internal/core/service/issue_manager.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "regexp" "strings" "time" @@ -38,6 +39,7 @@ 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) { @@ -64,7 +66,7 @@ func (m *IssueManager) GetUserProjects(ctx context.Context, user *model.User) ([ return nil, errors.WithStack(err) } - refreshedProjects, err := forge.GetProjects(ctx) + refreshedProjects, err := forge.GetAllProjects(ctx) if err != nil { return nil, errors.WithStack(err) } @@ -83,11 +85,15 @@ func (m *IssueManager) GenerateIssue(ctx context.Context, user *model.User, proj 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), @@ -141,15 +147,36 @@ func (m *IssueManager) getIssueSystemPrompt(ctx context.Context, user *model.Use } func (m *IssueManager) getIssueUserPrompt(ctx context.Context, user *model.User, projectID string, issueSummary string) (string, error) { - _, err := m.getUserForge(ctx, user) + 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 { - Context string + Summary string + Project *model.Project + ProjectLanguages []string + Resources []*model.Resource }{ - Context: issueSummary, + Summary: issueSummary, + Project: project, + ProjectLanguages: projectLanguages, + Resources: resources, }) if err != nil { return "", errors.WithStack(err) @@ -159,6 +186,11 @@ func (m *IssueManager) getIssueUserPrompt(ctx context.Context, user *model.User, } 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 @@ -170,17 +202,66 @@ func (m *IssueManager) getUserForge(ctx context.Context, user *model.User) (port 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...) + + 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 +} + 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), } } diff --git a/internal/core/service/issue_system_prompt.gotmpl b/internal/core/service/issue_system_prompt.gotmpl index 0eb8269..066b326 100644 --- a/internal/core/service/issue_system_prompt.gotmpl +++ b/internal/core/service/issue_system_prompt.gotmpl @@ -9,11 +9,13 @@ You are an expert software developer with extensive experience in writing clear - Expected behavior. - Actual behavior. - Any relevant error messages or logs. - - Always use the user prompt context main language. + - Always use the user prompt summary main language. 2. **Additional Context**: - Include any other relevant information that might help in understanding or resolving the issue/request. +3. Let think step by step. + **Markdown Layout:** ```markdown diff --git a/internal/core/service/issue_user_prompt.gotmpl b/internal/core/service/issue_user_prompt.gotmpl index 869e06a..7d4ad0d 100644 --- a/internal/core/service/issue_user_prompt.gotmpl +++ b/internal/core/service/issue_user_prompt.gotmpl @@ -1,3 +1,32 @@ -Write a formatted issue/request based on theses contextual informations: +Write a formatted issue/request based on the following informations: -{{ .Context }} \ No newline at end of file +## Request Summary + +{{ .Summary }} + +## General Project Informations + +- Name: {{ .Project.Name }} +- Description: {{ .Project.Description }} +- Languages: +{{- range .ProjectLanguages }} + - {{ . -}} +{{ end }} + +{{ if gt (len .Resources) 0 }} +## Additional Resources + +{{ range .Resources }} +### {{ .Name }} + +**Type:** + +{{ .Type }} + +**Content:** + +```{{ .Syntax }} +{{ .Content }} +``` +{{end}} +{{end}} \ No newline at end of file diff --git a/internal/http/handler/webui/issue/component/issue_page.templ b/internal/http/handler/webui/issue/component/issue_page.templ index f775a18..380f330 100644 --- a/internal/http/handler/webui/issue/component/issue_page.templ +++ b/internal/http/handler/webui/issue/component/issue_page.templ @@ -84,7 +84,7 @@ templ IssuePage(vmodel IssuePageVModel) { }