feat: parse and include referenced issues as prompt context (#12)

This commit is contained in:
wpetit 2025-02-23 13:08:08 +01:00
parent 3a18c25b3c
commit a49254c9ed
11 changed files with 229 additions and 23 deletions

View File

@ -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
@ -94,13 +174,13 @@ 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,
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

View File

@ -2,5 +2,6 @@ package model
type Issue struct {
ID string
Content string
Title string
Body string
}

View File

@ -2,5 +2,6 @@ package model
type Project struct {
ID string
Label string
Name string
Description string
}

View File

@ -0,0 +1,8 @@
package model
type Resource struct {
Name string
Type string
Syntax string
Content string
}

View File

@ -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)
}

View File

@ -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),
}
}

View File

@ -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

View File

@ -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 }}
## 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}}

View File

@ -84,7 +84,7 @@ templ IssuePage(vmodel IssuePageVModel) {
}
<div class="columns">
<div class="column is-4">
<form id="summary-form" action={ common.CurrentURL(ctx) } method="put" hx-disabled-elt="#summary-form textarea, #summary-form select, #summary-form button" hx-on:htmx:before-send="savePreferredProject()" hx-indicator="#generation-progress">
<form id="summary-form" action={ common.CurrentURL(ctx) } method="put" hx-disabled-elt="textarea, input, select, button" hx-on:htmx:before-send="savePreferredProject()" hx-indicator="#generation-progress">
<h2 class="title is-size-2">Résumé de la demande</h2>
@common.FormSelect(
vmodel.SummaryForm, "issue-project", "project", "Projet",
@ -179,7 +179,7 @@ templ IssuePage(vmodel IssuePageVModel) {
func projectsToOptions(projects []*model.Project) []string {
options := make([]string, 0, len(projects)*2)
for _, p := range projects {
options = append(options, p.Label, p.ID)
options = append(options, p.Name, p.ID)
}
return options
}

View File

@ -156,7 +156,7 @@ func IssuePage(vmodel IssuePageVModel) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" method=\"put\" hx-disabled-elt=\"#summary-form textarea, #summary-form select, #summary-form button\" hx-on:htmx:before-send=\"savePreferredProject()\" hx-indicator=\"#generation-progress\"><h2 class=\"title is-size-2\">Résumé de la demande</h2>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" method=\"put\" hx-disabled-elt=\"textarea, input, select, button\" hx-on:htmx:before-send=\"savePreferredProject()\" hx-indicator=\"#generation-progress\"><h2 class=\"title is-size-2\">Résumé de la demande</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -219,7 +219,7 @@ func IssuePage(vmodel IssuePageVModel) templ.Component {
func projectsToOptions(projects []*model.Project) []string {
options := make([]string, 0, len(projects)*2)
for _, p := range projects {
options = append(options, p.Label, p.ID)
options = append(options, p.Name, p.ID)
}
return options
}

View File

@ -1,4 +1,6 @@
internal/**/*.go
internal/**/*.gotmpl
internal/**/*.txt
.env
Makefile
modd.conf {