feat: overwrite project issue template

This commit is contained in:
wpetit 2025-02-27 22:20:40 +01:00
parent 08cdb44490
commit 406aa46a5a
7 changed files with 112 additions and 27 deletions

2
go.mod
View File

@ -7,7 +7,7 @@ toolchain go1.23.6
require (
code.gitea.io/sdk/gitea v0.20.0
github.com/a-h/templ v0.3.833
github.com/bornholm/genai v0.0.0-20250222092500-1076426da67c
github.com/bornholm/genai v0.0.0-20250227201654-4c93b20ee628
github.com/caarlos0/env/v11 v11.2.2
github.com/gabriel-vasile/mimetype v1.4.7
github.com/google/go-github/v69 v69.2.0

2
go.sum
View File

@ -14,6 +14,8 @@ github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
github.com/bornholm/genai v0.0.0-20250222092500-1076426da67c h1:bI0ebsgO1/7Jx6+ZQdDF/I6tTZxyB5hODYz7x/XxwK8=
github.com/bornholm/genai v0.0.0-20250222092500-1076426da67c/go.mod h1:MnuvwSsBEWv/joeK/WgUyfZfOLcLTpd81NJdWoRpRfI=
github.com/bornholm/genai v0.0.0-20250227201654-4c93b20ee628 h1:YsrF9+NUdwYPLfpJUUfD0h/yH0jvpnaMxtM/sPsFsPg=
github.com/bornholm/genai v0.0.0-20250227201654-4c93b20ee628/go.mod h1:kgZb50LiE3cLjyGdUzNwDtpxL5QRllZIsWT2Ub24fIM=
github.com/caarlos0/env/v11 v11.2.2 h1:95fApNrUyueipoZN/EhA8mMxiNxrBwDa+oAZrMWl3Kg=
github.com/caarlos0/env/v11 v11.2.2/go.mod h1:JBfcdeQiBoI3Zh1QRAWfe+tpiNTmDtcCj/hHHHMx0vc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=

View File

@ -85,8 +85,13 @@ func (m *IssueManager) GetUserProjects(ctx context.Context, user *model.User) ([
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)
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) {
systemPrompt, err := m.getIssueSystemPrompt(ctx, user, projectID, overwrittenIssueTemplate)
if err != nil {
return "", "", "", errors.WithStack(err)
}
@ -135,15 +140,19 @@ 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) (string, error) {
forge, err := m.getUserForge(ctx, user)
if err != nil {
return "", errors.WithStack(err)
}
func (m *IssueManager) getIssueSystemPrompt(ctx context.Context, user *model.User, projectID string, issueTemplate string) (string, error) {
if issueTemplate == "" {
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)
repoIssueTemplate, err := forge.GetIssueTemplate(ctx, projectID)
if err != nil && !errors.Is(err, port.ErrFileNotFound) {
return "", errors.WithStack(err)
}
issueTemplate = repoIssueTemplate
}
if issueTemplate == "" {

View File

@ -31,6 +31,10 @@ const bodyPlaceholder = `
Une fois votre demande générée, vous pourrez l'éditer puis la créer directement en cliquant sur le bouton 'Créer' ci-dessous.
`
const issueTemplatePlaceholder = `
Vous pouvez surcharger le modèle de demande fourni par le projet en remplissant ce champ.
`
func NewIssueSummaryForm() *form.Form {
return form.New(
form.NewField(
@ -47,6 +51,14 @@ func NewIssueSummaryForm() *form.Form {
},
form.NonEmpty("Ce champ ne doit pas être vide."),
),
form.NewField(
"template",
form.Attrs{
"type": "textarea",
"rows": "20",
"placeholder": strings.TrimSpace(issueTemplatePlaceholder),
},
),
)
}
@ -127,6 +139,15 @@ templ IssuePage(vmodel IssuePageVModel) {
"hx-on:change", "onSummaryChange(event)",
),
)
<details class="my-3">
<summary class="is-clickable">Paramètres avancés</summary>
@common.FormTextarea(
vmodel.SummaryForm, "issue-template", "template", "Surcharger le modèle de demande",
common.WithTextareaAttrs(
"hx-on:change", "onIssueTemplateChange(event)",
),
)
</details>
<div class="buttons is-right">
<button type="submit" class="button is-info is-large">
<span class="icon">
@ -184,6 +205,12 @@ templ IssuePage(vmodel IssuePageVModel) {
sessionStorage.setItem(`summary-${projectId}`, summary);
}
function onIssueTemplateChange(evt) {
const issueTemplate = evt.currentTarget.value;
const projectId = document.getElementById("issue-project").value;
localStorage.setItem(`issue-template-${projectId}`, issueTemplate);
}
function savePreferredProject() {
const projectId = document.getElementById("issue-project").value;
localStorage.setItem(`preferred-project`, projectId);
@ -211,9 +238,22 @@ templ IssuePage(vmodel IssuePageVModel) {
summaryTextarea.value = savedSummary;
}
function restoreIssueTemplate() {
const issueTemplateTextarea = document.getElementById("issue-template");
if (!issueTemplateTextarea) return;
const issueTemplate = issueTemplateTextarea.value;
if (issueTemplate !== "") return;
const projectId = document.getElementById("issue-project").value;
if (!projectId) return;
const savedIssueTemplate = localStorage.getItem(`issue-template-${projectId}`);
if (!savedIssueTemplate) return;
issueTemplateTextarea.value = savedIssueTemplate;
}
htmx.onLoad(function(){
restoreLastSummary();
restorePreferredProject();
restoreIssueTemplate();
})
</script>
}

View File

@ -39,6 +39,10 @@ const bodyPlaceholder = `
Une fois votre demande générée, vous pourrez l'éditer puis la créer directement en cliquant sur le bouton 'Créer' ci-dessous.
`
const issueTemplatePlaceholder = `
Vous pouvez surcharger le modèle de demande fourni par le projet en remplissant ce champ.
`
func NewIssueSummaryForm() *form.Form {
return form.New(
form.NewField(
@ -55,6 +59,14 @@ func NewIssueSummaryForm() *form.Form {
},
form.NonEmpty("Ce champ ne doit pas être vide."),
),
form.NewField(
"template",
form.Attrs{
"type": "textarea",
"rows": "20",
"placeholder": strings.TrimSpace(issueTemplatePlaceholder),
},
),
)
}
@ -143,7 +155,7 @@ func IssuePage(vmodel IssuePageVModel) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(vmodel.IssueURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/issue/component/issue_page.templ`, Line: 97, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/issue/component/issue_page.templ`, Line: 109, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@ -200,7 +212,20 @@ func IssuePage(vmodel IssuePageVModel) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"buttons is-right\"><button type=\"submit\" class=\"button is-info is-large\"><span class=\"icon\"><i class=\"fa fa-robot\"></i></span> <span>Générer</span></button></div></form></div><div class=\"column\"><h2 class=\"title is-size-3\">Votre demande</h2><form action=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<details class=\"my-3\"><summary class=\"is-clickable\">Paramètres avancés</summary>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = common.FormTextarea(
vmodel.SummaryForm, "issue-template", "template", "Surcharger le modèle de demande",
common.WithTextareaAttrs(
"hx-on:change", "onIssueTemplateChange(event)",
),
).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</details><div class=\"buttons is-right\"><button type=\"submit\" class=\"button is-info is-large\"><span class=\"icon\"><i class=\"fa fa-robot\"></i></span> <span>Générer</span></button></div></form></div><div class=\"column\"><h2 class=\"title is-size-3\">Votre demande</h2><form action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -209,7 +234,7 @@ func IssuePage(vmodel IssuePageVModel) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" method=\"post\" hx-disabled-elt=\"textarea, input, select, button\" hx-indicator=\"#generation-progress\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" method=\"post\" hx-disabled-elt=\"textarea, input, select, button\" hx-indicator=\"#generation-progress\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -221,14 +246,14 @@ func IssuePage(vmodel IssuePageVModel) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"buttons is-right\"><button type=\"submit\" class=\"button is-info is-large\"><span class=\"icon\"><i class=\"fa fa-rocket\"></i></span> <span>Créer</span></button></div></form></div></div><progress id=\"generation-progress\" class=\"htmx-indicator progress\"></progress> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"buttons is-right\"><button type=\"submit\" class=\"button is-info is-large\"><span class=\"icon\"><i class=\"fa fa-rocket\"></i></span> <span>Créer</span></button></div></form></div></div><progress id=\"generation-progress\" class=\"htmx-indicator progress\"></progress> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if vmodel.IssueTips != "" {
html := markdownToHTML(ctx, vmodel.IssueTips)
if html != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<article class=\"message is-info mt-5\"><div class=\"message-header\"><p><span class=\"icon\"><i class=\"fa fa-lightbulb\"></i></span>Questionnements</p><button class=\"delete\" aria-label=\"delete\" hx-on:click=\"onCloseMessage(this)\"></button></div><div class=\"message-body\"><div class=\"content\"><p>Utilisez ces quelques questions pour réfléchir aux éléments d'informations nécessaire à la bonne rédaction de votre demande:</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<article class=\"message is-info mt-5\"><div class=\"message-header\"><p><span class=\"icon\"><i class=\"fa fa-lightbulb\"></i></span>Questionnements</p><button class=\"delete\" aria-label=\"delete\" hx-on:click=\"onCloseMessage(this)\"></button></div><div class=\"message-body\"><div class=\"content\"><p>Utilisez ces quelques questions pour réfléchir aux éléments d'informations nécessaire à la bonne rédaction de votre demande:</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -236,13 +261,13 @@ func IssuePage(vmodel IssuePageVModel) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></div></article>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></div></article>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</section></div><script type=\"text/javascript\">\n\t\tfunction onCloseMessage(closeElement) {\n\t\t\tcloseElement.closest('.message').style.display = 'none';\n\t\t}\n\n\t\tfunction onSummaryChange(evt) {\n\t\t\tconst summary = evt.currentTarget.value;\n\t\t\tconst projectId = document.getElementById(\"issue-project\").value;\n\t\t\tsessionStorage.setItem(`summary-${projectId}`, summary);\n\t\t}\n\n\t\tfunction savePreferredProject() {\n\t\t\tconst projectId = document.getElementById(\"issue-project\").value;\n\t\t\tlocalStorage.setItem(`preferred-project`, projectId);\n\t\t}\n\n\t\tfunction restorePreferredProject() {\n\t\t\tconst preferredProject = localStorage.getItem(`preferred-project`);\n\t\t\tif (!preferredProject) return;\n\t\t\tconst projectElement = document.getElementById(\"issue-project\");\n\t\t\tif (!projectElement) return;\n\t\t\tif (preferredProject === projectElement.value) return;\n\t\t\tprojectElement.value = preferredProject;\n\t\t\tprojectElement.dispatchEvent(new Event('change'));\n\t\t}\n\n\t\tfunction restoreLastSummary() {\n\t\t\tconst summaryTextarea = document.getElementById(\"issue-summary\");\n\t\t\tif (!summaryTextarea) return;\n const summary = summaryTextarea.value;\n\t\t\tif (summary !== \"\") return;\n\t\t\tconst projectId = document.getElementById(\"issue-project\").value;\n\t\t\tif (!projectId) return;\n\t\t\tconst savedSummary = sessionStorage.getItem(`summary-${projectId}`);\n\t\t\tif (!savedSummary) return;\n\t\t\tsummaryTextarea.value = savedSummary;\n\t\t}\n\n\t\thtmx.onLoad(function(){\n\t\t\trestoreLastSummary();\n\t\t\trestorePreferredProject();\n })\n\t\t</script>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</section></div><script type=\"text/javascript\">\n\t\tfunction onCloseMessage(closeElement) {\n\t\t\tcloseElement.closest('.message').style.display = 'none';\n\t\t}\n\n\t\tfunction onSummaryChange(evt) {\n\t\t\tconst summary = evt.currentTarget.value;\n\t\t\tconst projectId = document.getElementById(\"issue-project\").value;\n\t\t\tsessionStorage.setItem(`summary-${projectId}`, summary);\n\t\t}\n\n\t\tfunction onIssueTemplateChange(evt) {\n\t\t\tconst issueTemplate = evt.currentTarget.value;\n\t\t\tconst projectId = document.getElementById(\"issue-project\").value;\n\t\t\tlocalStorage.setItem(`issue-template-${projectId}`, issueTemplate);\n\t\t}\n\n\t\tfunction savePreferredProject() {\n\t\t\tconst projectId = document.getElementById(\"issue-project\").value;\n\t\t\tlocalStorage.setItem(`preferred-project`, projectId);\n\t\t}\n\n\t\tfunction restorePreferredProject() {\n\t\t\tconst preferredProject = localStorage.getItem(`preferred-project`);\n\t\t\tif (!preferredProject) return;\n\t\t\tconst projectElement = document.getElementById(\"issue-project\");\n\t\t\tif (!projectElement) return;\n\t\t\tif (preferredProject === projectElement.value) return;\n\t\t\tprojectElement.value = preferredProject;\n\t\t\tprojectElement.dispatchEvent(new Event('change'));\n\t\t}\n\n\t\tfunction restoreLastSummary() {\n\t\t\tconst summaryTextarea = document.getElementById(\"issue-summary\");\n\t\t\tif (!summaryTextarea) return;\n const summary = summaryTextarea.value;\n\t\t\tif (summary !== \"\") return;\n\t\t\tconst projectId = document.getElementById(\"issue-project\").value;\n\t\t\tif (!projectId) return;\n\t\t\tconst savedSummary = sessionStorage.getItem(`summary-${projectId}`);\n\t\t\tif (!savedSummary) return;\n\t\t\tsummaryTextarea.value = savedSummary;\n\t\t}\n\n\t\tfunction restoreIssueTemplate() {\n\t\t\tconst issueTemplateTextarea = document.getElementById(\"issue-template\");\n\t\t\tif (!issueTemplateTextarea) return;\n const issueTemplate = issueTemplateTextarea.value;\n\t\t\tif (issueTemplate !== \"\") return;\n\t\t\tconst projectId = document.getElementById(\"issue-project\").value;\n\t\t\tif (!projectId) return;\n\t\t\tconst savedIssueTemplate = localStorage.getItem(`issue-template-${projectId}`);\n\t\t\tif (!savedIssueTemplate) return;\n\t\t\tissueTemplateTextarea.value = savedIssueTemplate;\n\t\t}\n\n\t\thtmx.onLoad(function(){\n\t\t\trestoreLastSummary();\n\t\t\trestorePreferredProject();\n\t\t\trestoreIssueTemplate();\n })\n\t\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@ -3,6 +3,7 @@ package issue
import (
"context"
"net/http"
"strings"
"forge.cadoles.com/wpetit/clearcase/internal/core/service"
httpCtx "forge.cadoles.com/wpetit/clearcase/internal/http/context"
@ -99,13 +100,21 @@ func (h *Handler) handleIssueSummaryForm(w http.ResponseWriter, r *http.Request)
return
}
summary, err := form.FormFieldAttr[string](issueSummaryForm, "summary", "value")
issueSummary, err := form.FormFieldAttr[string](issueSummaryForm, "summary", "value")
if err != nil {
h.handleError(w, r, errors.WithStack(err))
return
}
issueTitle, issueBody, issueTips, err := h.issueManager.GenerateIssue(ctx, httpCtx.User(ctx), projectID, summary)
issueTemplate, err := form.FormFieldAttr[string](issueSummaryForm, "template", "value")
if err != nil {
h.handleError(w, r, errors.WithStack(err))
return
}
issueTemplate = strings.TrimSpace(issueTemplate)
issueTitle, issueBody, issueTips, err := h.issueManager.GenerateIssue(ctx, httpCtx.User(ctx), projectID, issueSummary, issueTemplate)
if err != nil {
h.handleError(w, r, errors.WithStack(err))
return

View File

@ -13,13 +13,13 @@ import (
)
func NewIssueManagerFromConfig(ctx context.Context, conf *config.Config) (*service.IssueManager, error) {
llmCtx := provider.FromMap(ctx, "", map[string]string{
string(provider.ContextKeyAPIBaseURL): conf.LLM.Provider.BaseURL,
string(provider.ContextKeyAPIKey): conf.LLM.Provider.Key,
string(provider.ContextKeyModel): conf.LLM.Provider.Model,
})
client, err := provider.Create(llmCtx, provider.Name(conf.LLM.Provider.Name))
client, err := provider.Create(ctx,
provider.WithConfig(&provider.Config{
Provider: provider.Name(conf.LLM.Provider.Name),
BaseURL: conf.LLM.Provider.BaseURL,
Key: conf.LLM.Provider.Key,
Model: conf.LLM.Provider.Model,
}))
if err != nil {
return nil, errors.Wrapf(err, "could not create llm client '%s'", conf.LLM.Provider.Name)
}