feat: pull request generation

This commit is contained in:
2025-03-06 22:47:02 +01:00
parent 4d6459fae5
commit 367f9f9e70
28 changed files with 1918 additions and 181 deletions

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"net/url"
"path/filepath"
"regexp"
"slices"
@ -23,28 +24,51 @@ var (
ErrForgeNotAvailable = errors.New("forge not available")
)
//go:embed issue_system_prompt.gotmpl
//go:embed prompts/issue_system_prompt.gotmpl
var issueSystemPromptRawTemplate string
//go:embed issue_user_prompt.gotmpl
//go:embed prompts/issue_user_prompt.gotmpl
var issueUserPromptRawTemplate string
//go:embed issue_default_template.txt
//go:embed prompts/issue_default_template.txt
var issueDefaultTemplate string
//go:embed prompts/pull_request_system_prompt.gotmpl
var pullRequestSystemPromptRawTemplate string
//go:embed prompts/pull_request_user_prompt.gotmpl
var pullRequestUserPromptRawTemplate string
//go:embed prompts/pull_request_default_template.txt
var pullRequestDefaultTemplate string
type ForgeFactory interface {
Match(user *model.User) bool
Create(ctx context.Context, user *model.User) (port.Forge, error)
}
type IssueManager struct {
type ForgeManager 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) {
func (m *ForgeManager) UpdatePullRequest(ctx context.Context, user *model.User, projectID string, pullRequestID string, title string, body string) (string, error) {
forge, err := m.getUserForge(ctx, user)
if err != nil {
return "", errors.WithStack(err)
}
pullRequestURL, err := forge.UpdatePullRequest(ctx, projectID, pullRequestID, title, body)
if err != nil {
return "", errors.WithStack(err)
}
return pullRequestURL, nil
}
func (m *ForgeManager) 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)
@ -58,7 +82,21 @@ func (m *IssueManager) CreateIssue(ctx context.Context, user *model.User, projec
return issueURL, nil
}
func (m *IssueManager) GetUserProjects(ctx context.Context, user *model.User) ([]*model.Project, error) {
func (m *ForgeManager) GetUserProjectOpenedPullRequests(ctx context.Context, user *model.User, projectID string) ([]*model.PullRequest, error) {
forge, err := m.getUserForge(ctx, user)
if err != nil {
return nil, errors.WithStack(err)
}
pullRequests, err := forge.ListOpenedPullRequests(ctx, projectID)
if err != nil {
return nil, errors.WithStack(err)
}
return pullRequests, nil
}
func (m *ForgeManager) 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)
@ -68,7 +106,7 @@ func (m *IssueManager) GetUserProjects(ctx context.Context, user *model.User) ([
return nil, errors.WithStack(err)
}
refreshedProjects, err := forge.GetAllProjects(ctx)
refreshedProjects, err := forge.ListProjects(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
@ -85,12 +123,157 @@ func (m *IssueManager) GetUserProjects(ctx context.Context, user *model.User) ([
return projects, nil
}
func (m *ForgeManager) GetUserProjectPullRequest(ctx context.Context, user *model.User, projectID string, pullRequestID string) (*model.PullRequest, error) {
forge, err := m.getUserForge(ctx, user)
if err != nil {
return nil, errors.WithStack(err)
}
pullRequests, err := forge.GetPullRequests(ctx, projectID, pullRequestID)
if err != nil {
return nil, errors.WithStack(err)
}
if len(pullRequests) == 0 {
return nil, errors.WithStack(port.ErrPullRequestNotFound)
}
return pullRequests[0], nil
}
type GeneratIssueOptions struct {
IssueSummary string
IssueTemplate string
}
func (m *IssueManager) GenerateIssue(ctx context.Context, user *model.User, projectID string, issueSummary string, overwrittenIssueTemplate string) (string, string, string, error) {
func (m *ForgeManager) GeneratePullRequest(ctx context.Context, user *model.User, projectID string, pullRequestID string, summary string, overwrittenTemplate string) (string, string, string, error) {
systemPrompt, err := m.getPullRequestSystemPrompt(ctx, user, projectID, pullRequestID, overwrittenTemplate)
if err != nil {
return "", "", "", errors.WithStack(err)
}
slog.DebugContext(ctx, "using system prompt", slog.String("systemPrompt", systemPrompt))
userPrompt, err := m.getPullRequestUserPrompt(ctx, user, projectID, pullRequestID, summary)
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 *ForgeManager) getPullRequestSystemPrompt(ctx context.Context, user *model.User, projectID string, pullRequestID string, prTemplate string) (string, error) {
if prTemplate == "" {
forge, err := m.getUserForge(ctx, user)
if err != nil {
return "", errors.WithStack(err)
}
repoPullRequestTemplate, err := forge.GetPullRequestTemplate(ctx, projectID)
if err != nil && !errors.Is(err, port.ErrFileNotFound) {
return "", errors.WithStack(err)
}
prTemplate = repoPullRequestTemplate
}
if prTemplate == "" {
prTemplate = pullRequestDefaultTemplate
}
systemPrompt, err := llm.PromptTemplate(pullRequestSystemPromptRawTemplate, struct {
PullRequestTemplate string
}{
PullRequestTemplate: prTemplate,
})
if err != nil {
return "", errors.WithStack(err)
}
return systemPrompt, nil
}
func (m *ForgeManager) getPullRequestUserPrompt(ctx context.Context, user *model.User, projectID string, pullRequestID string, summary 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)
}
diff, err := forge.GetPullRequestDiff(ctx, projectID, pullRequestID)
if err != nil {
return "", errors.WithStack(err)
}
resources, err := m.extractResources(ctx, forge, projectID, summary)
if err != nil {
return "", errors.WithStack(err)
}
userPrompt, err := llm.PromptTemplate(pullRequestUserPromptRawTemplate, struct {
Summary string
Project *model.Project
ProjectLanguages []string
Resources []*model.Resource
Diff string
}{
Summary: summary,
Project: project,
ProjectLanguages: projectLanguages,
Resources: resources,
Diff: diff,
})
if err != nil {
return "", errors.WithStack(err)
}
return userPrompt, nil
}
func (m *ForgeManager) GenerateIssue(ctx context.Context, user *model.User, projectID string, issueSummary string, overwrittenIssueTemplate string) (string, string, string, error) {
systemPrompt, err := m.getIssueSystemPrompt(ctx, user, projectID, overwrittenIssueTemplate)
if err != nil {
return "", "", "", errors.WithStack(err)
@ -140,7 +323,7 @@ func (m *IssueManager) GenerateIssue(ctx context.Context, user *model.User, proj
return title, body, tips, nil
}
func (m *IssueManager) getIssueSystemPrompt(ctx context.Context, user *model.User, projectID string, issueTemplate string) (string, error) {
func (m *ForgeManager) getIssueSystemPrompt(ctx context.Context, user *model.User, projectID string, issueTemplate string) (string, error) {
if issueTemplate == "" {
forge, err := m.getUserForge(ctx, user)
if err != nil {
@ -171,7 +354,7 @@ func (m *IssueManager) getIssueSystemPrompt(ctx context.Context, user *model.Use
return systemPrompt, nil
}
func (m *IssueManager) getIssueUserPrompt(ctx context.Context, user *model.User, projectID string, issueSummary string) (string, error) {
func (m *ForgeManager) 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)
@ -210,7 +393,7 @@ func (m *IssueManager) getIssueUserPrompt(ctx context.Context, user *model.User,
return userPrompt, nil
}
func (m *IssueManager) getUserForge(ctx context.Context, user *model.User) (port.Forge, error) {
func (m *ForgeManager) getUserForge(ctx context.Context, user *model.User) (port.Forge, error) {
forge, exists := m.forgeCache.Get(user.AccessToken)
if exists {
return forge, nil
@ -235,7 +418,7 @@ func (m *IssueManager) getUserForge(ctx context.Context, user *model.User) (port
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) {
func (m *ForgeManager) 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)
@ -257,7 +440,7 @@ func (m *IssueManager) extractResources(ctx context.Context, forge port.Forge, p
var issueRefRegExp = regexp.MustCompile(`#([0-9]+)`)
func (m *IssueManager) extractIssues(ctx context.Context, forge port.Forge, projectID string, issueSummary string) ([]*model.Resource, error) {
func (m *ForgeManager) 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))
@ -290,7 +473,7 @@ func (m *IssueManager) extractIssues(ctx context.Context, forge port.Forge, proj
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) {
func (m *ForgeManager) 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))
@ -301,6 +484,10 @@ func (m *IssueManager) extractFiles(ctx context.Context, forge port.Forge, proje
resources := make([]*model.Resource, 0)
for _, p := range paths {
if isURL(p) {
continue
}
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))
@ -318,8 +505,8 @@ func (m *IssueManager) extractFiles(ctx context.Context, forge port.Forge, proje
return resources, nil
}
func NewIssueManager(llmClient llm.Client, forgeFactories ...ForgeFactory) *IssueManager {
return &IssueManager{
func NewForgeManager(llmClient llm.Client, forgeFactories ...ForgeFactory) *ForgeManager {
return &ForgeManager{
llmClient: llmClient,
forgeFactories: forgeFactories,
projectCache: cache.New[[]*model.Project](time.Minute*5, (time.Minute*5)/2),
@ -331,3 +518,8 @@ func toTitle(str string) string {
str = strings.ToLower(str)
return strings.ToUpper(string(str[0])) + str[1:]
}
func isURL(str string) bool {
_, err := url.ParseRequestURI(str)
return err == nil
}

View File

@ -0,0 +1,7 @@
## Description
Description générale des objectifs liés à cette pull request.
## Changements
Liste des changements apportés.

View File

@ -0,0 +1,29 @@
You are an expert software developer with extensive experience in writing clear and comprehensive pull requests for software forges. Your task is to create well-structured pull request based on the provided contextual information, following a predefined Markdown layout.
**Instructions:**
1. **Pull Request Description**:
- Provide a detailed description of the pull request, including:
- Background information.
- Steps to reproduce the issue.
- Expected behavior.
- Actual behavior.
- Any relevant error messages or logs.
- Always use the user prompt summary main language.
2. **Additional Context**:
- Include any other relevant information that might help in understanding or resolving the pull request.
3. Keep resources references when available.
4. Do not include the raw diff in your response.
5. Do not include general informations about the project. Keep the description focused on the current changes.
6. Let think step by step.
**Markdown Layout:**
```markdown
{{ .PullRequestTemplate }}
```

View File

@ -0,0 +1,38 @@
Write a formatted pull request based on the following informations:
## Pull Request Summary
{{ .Summary }}
## General Project Informations
- Name: {{ .Project.Name }}
- Description: {{ .Project.Description }}
- Languages:
{{- range .ProjectLanguages }}
- {{ . -}}
{{ end }}
## Changelog
```
{{ .Diff }}
```
{{ if gt (len .Resources) 0 }}
## Additional Resources
{{ range .Resources }}
### {{ .Name }}
**Type:**
{{ .Type }}
**Content:**
```{{ .Syntax }}
{{ .Content }}
```
{{end}}
{{end}}