feat: initial commit

This commit is contained in:
2025-02-22 09:42:15 +01:00
parent ee4a65b345
commit e6e5c9b04d
43 changed files with 1191 additions and 247 deletions

View File

@ -0,0 +1,90 @@
package gitea
import (
"context"
"slices"
"strconv"
"strings"
"code.gitea.io/sdk/gitea"
"forge.cadoles.com/wpetit/clearcase/internal/core/model"
"forge.cadoles.com/wpetit/clearcase/internal/core/port"
"github.com/pkg/errors"
)
type Forge struct {
client *gitea.Client
}
// CreateIssue implements port.Forge.
func (f *Forge) CreateIssue(ctx context.Context, projectID string, title string, content string) error {
return nil
}
// GetIssueTemplate implements port.Forge.
func (f *Forge) GetIssueTemplate(ctx context.Context, rawProjectID string) (string, error) {
projectID, err := strconv.ParseInt(rawProjectID, 10, 64)
if err != nil {
return "", errors.WithStack(err)
}
project, _, err := f.client.GetRepoByID(projectID)
if err != nil {
return "", errors.WithStack(err)
}
data, _, err := f.client.GetFile(project.Owner.UserName, project.Name, project.DefaultBranch, ".gitea/issue_template.md")
if err != nil {
return "", errors.WithStack(err)
}
return string(data), nil
}
// GetIssues implements port.Forge.
func (f *Forge) GetIssues(ctx context.Context, projectID string, issueIDs ...string) ([]*model.Issue, error) {
panic("unimplemented")
}
// ListProjects implements port.Forge.
func (f *Forge) GetProjects(ctx context.Context) ([]*model.Project, error) {
projects := make([]*model.Project, 0)
page := 1
for {
repositories, res, err := f.client.ListMyRepos(gitea.ListReposOptions{
ListOptions: gitea.ListOptions{
Page: page,
PageSize: 100,
},
})
if err != nil {
return nil, errors.WithStack(err)
}
page = res.NextPage
if res.NextPage == 0 {
break
}
for _, r := range repositories {
projects = append(projects, &model.Project{
ID: strconv.FormatInt(r.ID, 10),
Label: r.Owner.UserName + "/" + r.Name,
})
}
}
slices.SortFunc(projects, func(p1 *model.Project, p2 *model.Project) int {
return strings.Compare(p1.Label, p2.Label)
})
return projects, nil
}
func NewForge(client *gitea.Client) *Forge {
return &Forge{client: client}
}
var _ port.Forge = &Forge{}

View File

@ -1,25 +1,20 @@
package config
type Auth struct {
DefaultAdmin DefaultAdmin `envPrefix:"DEFAULT_ADMIN_"`
Providers AuthProviders `envPrefix:"PROVIDERS_"`
}
type DefaultAdmin struct {
Email string `env:"EMAIL,expand"`
Provider string `env:"PROVIDER,expand"`
Providers AuthProviders `envPrefix:"PROVIDERS_"`
}
type AuthProviders struct {
Google OAuth2Provider `envPrefix:"GOOGLE_"`
Github OAuth2Provider `envPrefix:"GITHUB_"`
Gitea GiteaProvider `envPrefix:"GITEA_"`
OIDC OIDCProvider `envPrefix:"OIDC_"`
}
type OAuth2Provider struct {
Key string `env:"KEY,expand"`
Secret string `env:"SECRET,expand"`
Scopes []string `env:"SCOPES",expand"`
Scopes []string `env:"SCOPES,expand"`
}
type OIDCProvider struct {
@ -28,3 +23,11 @@ type OIDCProvider struct {
Icon string `env:"ICON,expand" envDefault:"fa-passport"`
Label string `env:"LABEL,expand" envDefault:"OpenID Connect"`
}
type GiteaProvider struct {
OAuth2Provider
TokenURL string `env:"TOKEN_URL,expand"`
AuthURL string `env:"AUTH_URL,expand"`
ProfileURL string `env:"PROFILE_URL,expand"`
Label string `env:"LABEL,expand" envDefault:"Gitea"`
}

View File

@ -6,11 +6,10 @@ import (
)
type Config struct {
Logger Logger `envPrefix:"LOGGER_"`
Auth Auth `envPrefix:"AUTH_"`
HTTP HTTP `envPrefix:"HTTP_"`
Storage Storage `envPrefix:"STORAGE_"`
LLM LLM `envPrefix:"LLM_"`
Logger Logger `envPrefix:"LOGGER_"`
Auth Auth `envPrefix:"AUTH_"`
HTTP HTTP `envPrefix:"HTTP_"`
LLM LLM `envPrefix:"LLM_"`
}
func Parse() (*Config, error) {

View File

@ -3,8 +3,8 @@ package config
import "time"
type HTTP struct {
BaseURL string `env:"BASE_URL" envDefault:"http://localhost:3000"`
Address string `env:"ADDRESS,expand" envDefault:":3000"`
BaseURL string `env:"BASE_URL" envDefault:"http://localhost:3001"`
Address string `env:"ADDRESS,expand" envDefault:":3001"`
Session Session `envPrefix:"SESSION_"`
}

View File

@ -1,19 +0,0 @@
package config
type Storage struct {
Database Database `envPrefix:"DATABASE_"`
Object Object `envPrefix:"OBJECT_"`
}
type Database struct {
DSN string `env:"DSN" envDefault:"sqlite://data.sqlite"`
}
type Object struct {
DSN string `env:"DSN" envDefault:"sqlite://data.sqlite"`
Encryption Encryption `envPrefix:"ENCRYPTION_"`
}
type Encryption struct {
Key string `env:"KEY,unset"`
}

View File

@ -0,0 +1,6 @@
package model
type Issue struct {
ID string
Content string
}

View File

@ -0,0 +1,6 @@
package model
type Project struct {
ID string
Label string
}

View File

@ -0,0 +1,8 @@
package model
type User struct {
ID string
Provider string
AccessToken string
IDToken string
}

View File

@ -0,0 +1,14 @@
package port
import (
"context"
"forge.cadoles.com/wpetit/clearcase/internal/core/model"
)
type Forge interface {
GetProjects(ctx context.Context) ([]*model.Project, error)
CreateIssue(ctx context.Context, projectID string, title string, content string) error
GetIssues(ctx context.Context, projectID string, issueIDs ...string) ([]*model.Issue, error)
GetIssueTemplate(ctx context.Context, projectID string) (string, error)
}

View File

@ -0,0 +1,151 @@
package service
import (
"context"
"fmt"
"log/slog"
"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
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]
}
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
}
func (m *IssueManager) GenerateIssue(ctx context.Context, user *model.User, projectID string, issueSummary string) (string, error) {
systemPrompt, err := m.getIssueSystemPrompt(ctx, user, projectID)
if err != nil {
return "", errors.WithStack(err)
}
userPrompt, err := m.getIssueUserPrompt(ctx, user, projectID, issueSummary)
if err != nil {
return "", errors.WithStack(err)
}
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)
}
return res.Message().Content(), 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)
}
issueTemplate, err := forge.GetIssueTemplate(ctx, projectID)
if err != nil {
return "", errors.WithStack(err)
}
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),
}
}

View File

@ -0,0 +1,20 @@
You are an expert software developer with extensive experience in writing clear and comprehensive issues and requests for software forges. Your task is to create well-structured issues/requests based on the provided contextual information, following a predefined Markdown layout.
**Instructions:**
1. **Issue/request Description**:
- Provide a detailed description of the issue/request, including:
- Background information.
- Steps to reproduce the issue.
- Expected behavior.
- Actual behavior.
- Any relevant error messages or logs.
2. **Additional Context**:
- Include any other relevant information that might help in understanding or resolving the issue/request.
**Markdown Layout:**
```markdown
{{ .IssueTemplate }}
```

View File

@ -0,0 +1,3 @@
Write a formatted issue/request based on theses contextual informations:
{{ .Context }}

View File

@ -3,14 +3,14 @@ package context
import (
"context"
"github.com/markbates/goth"
"forge.cadoles.com/wpetit/clearcase/internal/core/model"
"github.com/pkg/errors"
)
const keyUser = "user"
func User(ctx context.Context) *goth.User {
user, ok := ctx.Value(keyUser).(*goth.User)
func User(ctx context.Context) *model.User {
user, ok := ctx.Value(keyUser).(*model.User)
if !ok {
panic(errors.New("no user in context"))
}
@ -18,6 +18,6 @@ func User(ctx context.Context) *goth.User {
return user
}
func SetUser(ctx context.Context, user *goth.User) context.Context {
func SetUser(ctx context.Context, user *model.User) context.Context {
return context.WithValue(ctx, keyUser, user)
}

View File

@ -17,15 +17,15 @@ templ LoginPage(vmodel LoginPageVModel) {
<div class="is-flex is-justify-content-center is-align-items-center is-fullheight">
<nav class="panel is-link" style="min-width: 33%">
<p class="panel-heading">
<div class="title">ClearCase</div>
<span>&nbsp;- choose your provider</span>
<span class="title">ClearCase</span>
<span>&nbsp;- choose your platform</span>
</p>
for _, provider := range vmodel.Providers {
<a class="panel-block" href={ templ.URL("/auth/providers/" + provider.ID) } hx-boost="false">
<a class="panel-block py-5" href={ templ.URL("/auth/providers/" + provider.ID) } hx-boost="false">
<span class="panel-icon is-size-3">
<i class={ "fab", provider.Icon } aria-hidden="true"></i>
</span>
{ provider.Label }
<span class="is-size-5">{ provider.Label }</span>
</a>
}
</nav>

View File

@ -53,12 +53,12 @@ func LoginPage(vmodel LoginPageVModel) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"is-flex is-justify-content-center is-align-items-center is-fullheight\"><nav class=\"panel is-link\" style=\"min-width: 33%\"><p class=\"panel-heading\"><div class=\"title\">ClearCase</div><span>&nbsp;- choose your provider</span></p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"is-flex is-justify-content-center is-align-items-center is-fullheight\"><nav class=\"panel is-link\" style=\"min-width: 33%\"><p class=\"panel-heading\"><span class=\"title\">ClearCase</span> <span>&nbsp;- choose your platform</span></p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, provider := range vmodel.Providers {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a class=\"panel-block\" href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a class=\"panel-block py-5\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -89,20 +89,20 @@ func LoginPage(vmodel LoginPageVModel) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" aria-hidden=\"true\"></i></span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" aria-hidden=\"true\"></i></span> <span class=\"is-size-5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(provider.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/auth/component/login_page.templ`, Line: 28, Col: 22}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/auth/component/login_page.templ`, Line: 28, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@ -15,7 +15,6 @@ type Handler struct {
sessionStore sessions.Store
sessionName string
providers []Provider
defaultAdmin *DefaultAdmin
}
// ServeHTTP implements http.Handler.
@ -30,7 +29,6 @@ func NewHandler(sessionStore sessions.Store, funcs ...OptionFunc) *Handler {
sessionStore: sessionStore,
sessionName: opts.SessionName,
providers: opts.Providers,
defaultAdmin: opts.DefaultAdmin,
}
h.mux.HandleFunc("GET /login", h.getLoginPage)

View File

@ -1,14 +1,8 @@
package auth
type DefaultAdmin struct {
Provider string
Email string
}
type Options struct {
Providers []Provider
DefaultAdmin *DefaultAdmin
SessionName string
Providers []Provider
SessionName string
}
type OptionFunc func(opts *Options)
@ -16,7 +10,7 @@ type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
Providers: make([]Provider, 0),
SessionName: "rkvst_auth",
SessionName: "clearcase_auth",
}
for _, fn := range funcs {
@ -32,12 +26,6 @@ func WithProviders(providers ...Provider) OptionFunc {
}
}
func WithDefaultAdmin(defaultAdmin DefaultAdmin) OptionFunc {
return func(opts *Options) {
opts.DefaultAdmin = &defaultAdmin
}
}
func WithSessionName(sessionName string) OptionFunc {
return func(opts *Options) {
opts.SessionName = sessionName

View File

@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"forge.cadoles.com/wpetit/clearcase/internal/core/model"
"forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common"
"github.com/markbates/goth/gothic"
"github.com/pkg/errors"
@ -19,7 +20,7 @@ func (h *Handler) handleProvider(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) handleProviderCallback(w http.ResponseWriter, r *http.Request) {
user, err := gothic.CompleteUserAuth(w, r)
gothUser, err := gothic.CompleteUserAuth(w, r)
if err != nil {
slog.ErrorContext(r.Context(), "could not complete user auth", slog.Any("error", errors.WithStack(err)))
http.Redirect(w, r, "/auth/logout", http.StatusTemporaryRedirect)
@ -28,9 +29,16 @@ func (h *Handler) handleProviderCallback(w http.ResponseWriter, r *http.Request)
ctx := r.Context()
slog.DebugContext(ctx, "authenticated user", slog.Any("user", user))
slog.DebugContext(ctx, "authenticated user", slog.Any("user", gothUser))
if err := h.storeSessionUser(w, r, &user); err != nil {
user := &model.User{
ID: gothUser.UserID,
Provider: gothUser.Provider,
AccessToken: gothUser.AccessToken,
IDToken: gothUser.IDToken,
}
if err := h.storeSessionUser(w, r, user); err != nil {
slog.ErrorContext(r.Context(), "could not store session user", slog.Any("error", errors.WithStack(err)))
http.Redirect(w, r, "/auth/logout", http.StatusTemporaryRedirect)
return

View File

@ -1,11 +1,12 @@
package auth
import (
"encoding/gob"
"log/slog"
"net/http"
"forge.cadoles.com/wpetit/clearcase/internal/core/model"
"github.com/gorilla/sessions"
"github.com/markbates/goth"
"github.com/pkg/errors"
)
@ -14,7 +15,11 @@ const tenantIDAttr = "t"
var errSessionNotFound = errors.New("session not found")
func (h *Handler) storeSessionUser(w http.ResponseWriter, r *http.Request, user *goth.User) error {
func init() {
gob.Register(&model.User{})
}
func (h *Handler) storeSessionUser(w http.ResponseWriter, r *http.Request, user *model.User) error {
sess, err := h.getSession(r)
if err != nil {
return errors.WithStack(err)
@ -29,13 +34,13 @@ func (h *Handler) storeSessionUser(w http.ResponseWriter, r *http.Request, user
return nil
}
func (h *Handler) retrieveSessionUser(r *http.Request) (*goth.User, error) {
func (h *Handler) retrieveSessionUser(r *http.Request) (*model.User, error) {
sess, err := h.getSession(r)
if err != nil {
return nil, errors.WithStack(err)
}
user, ok := sess.Values[userAttr].(*goth.User)
user, ok := sess.Values[userAttr].(*model.User)
if !ok {
return nil, errors.WithStack(errSessionNotFound)
}

View File

@ -16,3 +16,15 @@ body {
overflow-x: hidden;
text-overflow: ellipsis;
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: block;
}
.htmx-request.htmx-indicator {
display: block;
}

View File

@ -42,18 +42,99 @@ templ FormTextarea(form *form.Form, id string, name string, label string) {
}
}
templ FormSelect(form *form.Form, id string, name string, label string, kvOptions ...string) {
type FormSelectOptions struct {
Options []formSelectOption
Attrs map[string]any
}
type FormSelectOptionFunc func(opts *FormSelectOptions)
func WithOptions(kvOptions ...string) FormSelectOptionFunc {
return func(opts *FormSelectOptions) {
opts.Options = keyValuesToOptions(kvOptions)
}
}
func WithAttrs(kvAttrs ...any) FormSelectOptionFunc {
return func(opts *FormSelectOptions) {
opts.Attrs = keyValuesToAttrs(kvAttrs)
}
}
func keyValuesToAttrs(kv []any) map[string]any {
if len(kv)%2 != 0 {
panic(errors.New("expected pair number of key/values"))
}
attrs := make(map[string]any, 0)
var key string
for idx := range kv {
if idx%2 == 0 {
key = kv[idx].(string)
continue
}
attrs[key] = kv[idx]
}
return attrs
}
type formSelectOption struct {
Value string
Label string
}
func keyValuesToOptions(kv []string) []formSelectOption {
if len(kv)%2 != 0 {
panic(errors.New("expected pair number of key/values"))
}
options := make([]formSelectOption, 0)
var key string
for idx := range kv {
if idx%2 == 0 {
key = kv[idx]
continue
}
options = append(options, formSelectOption{
Value: kv[idx],
Label: key,
})
}
return options
}
func newFormSelectOptions(funcs ...FormSelectOptionFunc) *FormSelectOptions {
opts := &FormSelectOptions{}
for _, fn := range funcs {
fn(opts)
}
return opts
}
templ FormSelect(form *form.Form, id string, name string, label string, funcs ...FormSelectOptionFunc) {
{{ opts := newFormSelectOptions(funcs...) }}
{{ field := form.Field(name) }}
if field != nil {
<div class="field">
<label class="label" for={ id }>{ label }</label>
<div class="control">
{{ err, hasErr := form.Error(name) }}
{{ value, hasValue := field.Get("value") }}
<div class="select is-fullwidth">
<select id={ id } name={ field.Name() } { field.Attrs()... }>
{{ options := keyValuesToOptions(kvOptions) }}
for _, o := range options {
<option value={ o.Value }>{ o.Label }</option>
<select id={ id } name={ field.Name() } { mergeAttrs(field.Attrs(), opts.Attrs)... }>
for _, o := range opts.Options {
<option
if hasValue && value == o.Value {
selected
}
value={ o.Value }
>{ o.Label }</option>
}
</select>
</div>
@ -65,30 +146,12 @@ templ FormSelect(form *form.Form, id string, name string, label string, kvOption
}
}
type SelectOption struct {
Value string
Label string
}
func keyValuesToOptions(kv []string) []SelectOption {
if len(kv)%2 != 0 {
panic(errors.New("expected pair number of key/values"))
}
options := make([]SelectOption, 0)
var key string
for idx := range kv {
if idx%2 == 0 {
key = kv[idx]
continue
func mergeAttrs(attrs ...map[string]any) map[string]any {
merged := make(form.Attrs)
for _, a := range attrs {
for k, v := range a {
merged[k] = v
}
options = append(options, SelectOption{
Value: kv[idx],
Label: key,
})
}
return options
return merged
}

View File

@ -327,7 +327,82 @@ func FormTextarea(form *form.Form, id string, name string, label string) templ.C
})
}
func FormSelect(form *form.Form, id string, name string, label string, kvOptions ...string) templ.Component {
type FormSelectOptions struct {
Options []formSelectOption
Attrs map[string]any
}
type FormSelectOptionFunc func(opts *FormSelectOptions)
func WithOptions(kvOptions ...string) FormSelectOptionFunc {
return func(opts *FormSelectOptions) {
opts.Options = keyValuesToOptions(kvOptions)
}
}
func WithAttrs(kvAttrs ...any) FormSelectOptionFunc {
return func(opts *FormSelectOptions) {
opts.Attrs = keyValuesToAttrs(kvAttrs)
}
}
func keyValuesToAttrs(kv []any) map[string]any {
if len(kv)%2 != 0 {
panic(errors.New("expected pair number of key/values"))
}
attrs := make(map[string]any, 0)
var key string
for idx := range kv {
if idx%2 == 0 {
key = kv[idx].(string)
continue
}
attrs[key] = kv[idx]
}
return attrs
}
type formSelectOption struct {
Value string
Label string
}
func keyValuesToOptions(kv []string) []formSelectOption {
if len(kv)%2 != 0 {
panic(errors.New("expected pair number of key/values"))
}
options := make([]formSelectOption, 0)
var key string
for idx := range kv {
if idx%2 == 0 {
key = kv[idx]
continue
}
options = append(options, formSelectOption{
Value: kv[idx],
Label: key,
})
}
return options
}
func newFormSelectOptions(funcs ...FormSelectOptionFunc) *FormSelectOptions {
opts := &FormSelectOptions{}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func FormSelect(form *form.Form, id string, name string, label string, funcs ...FormSelectOptionFunc) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@ -348,6 +423,7 @@ func FormSelect(form *form.Form, id string, name string, label string, kvOptions
templ_7745c5c3_Var20 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
opts := newFormSelectOptions(funcs...)
field := form.Field(name)
if field != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"field\"><label class=\"label\" for=\"")
@ -357,7 +433,7 @@ func FormSelect(form *form.Form, id string, name string, label string, kvOptions
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 49, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 125, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
@ -370,7 +446,7 @@ func FormSelect(form *form.Form, id string, name string, label string, kvOptions
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 49, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 125, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@ -381,6 +457,7 @@ func FormSelect(form *form.Form, id string, name string, label string, kvOptions
return templ_7745c5c3_Err
}
err, hasErr := form.Error(name)
value, hasValue := field.Get("value")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"select is-fullwidth\"><select id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@ -388,7 +465,7 @@ func FormSelect(form *form.Form, id string, name string, label string, kvOptions
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 53, Col: 20}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 130, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
@ -401,7 +478,7 @@ func FormSelect(form *form.Form, id string, name string, label string, kvOptions
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(field.Name())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 53, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 130, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@ -411,7 +488,7 @@ func FormSelect(form *form.Form, id string, name string, label string, kvOptions
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, field.Attrs())
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, mergeAttrs(field.Attrs(), opts.Attrs))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -419,63 +496,72 @@ func FormSelect(form *form.Form, id string, name string, label string, kvOptions
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
options := keyValuesToOptions(kvOptions)
for _, o := range options {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<option value=\"")
for _, o := range opts.Options {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<option")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasValue && value == o.Value {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(o.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 56, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 136, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(o.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 56, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 137, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</option>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</select></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasErr {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<p class=\"help is-danger\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<p class=\"help is-danger\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(err.Message())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 61, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 142, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -484,32 +570,14 @@ func FormSelect(form *form.Form, id string, name string, label string, kvOptions
})
}
type SelectOption struct {
Value string
Label string
}
func keyValuesToOptions(kv []string) []SelectOption {
if len(kv)%2 != 0 {
panic(errors.New("expected pair number of key/values"))
}
options := make([]SelectOption, 0)
var key string
for idx := range kv {
if idx%2 == 0 {
key = kv[idx]
continue
func mergeAttrs(attrs ...map[string]any) map[string]any {
merged := make(form.Attrs)
for _, a := range attrs {
for k, v := range a {
merged[k] = v
}
options = append(options, SelectOption{
Value: kv[idx],
Label: key,
})
}
return options
return merged
}
var _ = templruntime.GeneratedTemplate

View File

@ -10,9 +10,9 @@ type PageOptionFunc func(opts *PageOptions)
func WithTitle(title string) PageOptionFunc {
return func(opts *PageOptions) {
if title != "" {
opts.Title = title + " | Rkvst"
opts.Title = title + " | ClearCase"
} else {
opts.Title = "Rkvst"
opts.Title = "ClearCase"
}
}
}

View File

@ -18,9 +18,9 @@ type PageOptionFunc func(opts *PageOptions)
func WithTitle(title string) PageOptionFunc {
return func(opts *PageOptions) {
if title != "" {
opts.Title = title + " | Rkvst"
opts.Title = title + " | ClearCase"
} else {
opts.Title = "Rkvst"
opts.Title = "ClearCase"
}
}
}

View File

@ -0,0 +1,116 @@
package component
import (
"forge.cadoles.com/wpetit/clearcase/internal/core/model"
"forge.cadoles.com/wpetit/clearcase/internal/http/form"
common "forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common/component"
)
type IssuePageVModel struct {
SummaryForm *form.Form
IssueForm *form.Form
Projects []*model.Project
SelectedProjectID string
}
func NewIssueSummaryForm() *form.Form {
return form.New(
form.NewField(
"project",
form.Attrs{},
form.NonEmpty("This field should not be empty"),
),
form.NewField(
"summary",
form.Attrs{
"type": "textarea",
"rows": "20",
"placeholder": "Write a rapid description of the issue here...",
},
form.NonEmpty("This field should not be empty"),
),
)
}
func NewIssueForm() *form.Form {
return form.New(
form.NewField(
"content",
form.Attrs{
"type": "textarea",
"rows": "25",
},
form.NonEmpty("This field should not be empty"),
),
)
}
templ IssuePage(vmodel IssuePageVModel) {
@common.Page(common.WithTitle("New issue")) {
<div class="container is-fluid">
<section class="section">
<div class="buttons is-right">
<a class="button is-medium" href={ common.BaseURL(ctx, common.WithPath("/auth/logout")) }>Logout</a>
</div>
<div class="columns">
<div class="column">
<form id="summary-form" action={ common.CurrentURL(ctx) } method="post" hx-disabled-elt="#summary-form textarea, #summary-form select, #summary-form button" hx-indicator="#generation-progress">
<div class="level">
<div class="level-left">
<h2 class="title is-size-2 level-item">Describe your request</h2>
</div>
<div class="level-right">
<div class="buttons is-right level-item">
<button type="submit" class="button is-primary is-large">
<span class="icon">
<i class="fa fa-robot"></i>
</span>
<span>Generate</span>
</button>
</div>
</div>
</div>
<progress id="generation-progress" class="htmx-indicator progress"></progress>
@common.FormSelect(
vmodel.SummaryForm, "issue-project", "project", "Project",
common.WithOptions(projectsToOptions(vmodel.Projects)...),
common.WithAttrs(
"hx-get", string(common.CurrentURL(ctx, common.WithoutValues("project", "*"))),
"hx-target", "body",
"hx-push-url", "true",
),
)
@common.FormTextarea(vmodel.SummaryForm, "issue-summary", "summary", "Summary")
</form>
</div>
<div class="column">
<div class="level">
<div class="level-left">
<h2 class="title is-size-2">Generated issue</h2>
</div>
<div class="level-right">
<div class="buttons is-right">
<button disabled type="submit" class="button is-primary is-large">
<span class="icon">
<i class="fa fa-rocket"></i>
</span>
<span>Create issue</span>
</button>
</div>
</div>
</div>
@common.FormTextarea(vmodel.IssueForm, "issue-content", "content", "")
</div>
</div>
</section>
</div>
}
}
func projectsToOptions(projects []*model.Project) []string {
options := make([]string, 0, len(projects)*2)
for _, p := range projects {
options = append(options, p.Label, p.ID)
}
return options
}

View File

@ -0,0 +1,157 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.819
package component
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"forge.cadoles.com/wpetit/clearcase/internal/core/model"
"forge.cadoles.com/wpetit/clearcase/internal/http/form"
common "forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common/component"
)
type IssuePageVModel struct {
SummaryForm *form.Form
IssueForm *form.Form
Projects []*model.Project
SelectedProjectID string
}
func NewIssueSummaryForm() *form.Form {
return form.New(
form.NewField(
"project",
form.Attrs{},
form.NonEmpty("This field should not be empty"),
),
form.NewField(
"summary",
form.Attrs{
"type": "textarea",
"rows": "20",
"placeholder": "Write a rapid description of the issue here...",
},
form.NonEmpty("This field should not be empty"),
),
)
}
func NewIssueForm() *form.Form {
return form.New(
form.NewField(
"content",
form.Attrs{
"type": "textarea",
"rows": "25",
},
form.NonEmpty("This field should not be empty"),
),
)
}
func IssuePage(vmodel IssuePageVModel) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container is-fluid\"><section class=\"section\"><div class=\"buttons is-right\"><a class=\"button is-medium\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL = common.BaseURL(ctx, common.WithPath("/auth/logout"))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var3)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">Logout</a></div><div class=\"columns\"><div class=\"column\"><form id=\"summary-form\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL = common.CurrentURL(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" method=\"post\" hx-disabled-elt=\"#summary-form textarea, #summary-form select, #summary-form button\" hx-indicator=\"#generation-progress\"><div class=\"level\"><div class=\"level-left\"><h2 class=\"title is-size-2 level-item\">Describe your request</h2></div><div class=\"level-right\"><div class=\"buttons is-right level-item\"><button type=\"submit\" class=\"button is-primary is-large\"><span class=\"icon\"><i class=\"fa fa-robot\"></i></span> <span>Generate</span></button></div></div></div><progress id=\"generation-progress\" class=\"htmx-indicator progress\"></progress>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = common.FormSelect(
vmodel.SummaryForm, "issue-project", "project", "Project",
common.WithOptions(projectsToOptions(vmodel.Projects)...),
common.WithAttrs(
"hx-get", string(common.CurrentURL(ctx, common.WithoutValues("project", "*"))),
"hx-target", "body",
"hx-push-url", "true",
),
).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = common.FormTextarea(vmodel.SummaryForm, "issue-summary", "summary", "Summary").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</form></div><div class=\"column\"><div class=\"level\"><div class=\"level-left\"><h2 class=\"title is-size-2\">Generated issue</h2></div><div class=\"level-right\"><div class=\"buttons is-right\"><button disabled type=\"submit\" class=\"button is-primary is-large\"><span class=\"icon\"><i class=\"fa fa-rocket\"></i></span> <span>Create issue</span></button></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = common.FormTextarea(vmodel.IssueForm, "issue-content", "content", "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div></section></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = common.Page(common.WithTitle("New issue")).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func projectsToOptions(projects []*model.Project) []string {
options := make([]string, 0, len(projects)*2)
for _, p := range projects {
options = append(options, p.Label, p.ID)
}
return options
}
var _ = templruntime.GeneratedTemplate

View File

@ -1,8 +0,0 @@
package component
import common "forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common/component"
templ IssuePage(title string) {
@common.Page(common.WithTitle(title)) {
}
}

View File

@ -1,56 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.819
package component
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import common "forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common/component"
func IssuePage(title string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
return nil
})
templ_7745c5c3_Err = common.Page(common.WithTitle(title)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -2,10 +2,13 @@ package issue
import (
"net/http"
"forge.cadoles.com/wpetit/clearcase/internal/core/service"
)
type Handler struct {
mux *http.ServeMux
mux *http.ServeMux
issueManager *service.IssueManager
}
// ServeHTTP implements http.Handler.
@ -13,12 +16,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mux.ServeHTTP(w, r)
}
func NewHandler() *Handler {
func NewHandler(issueManager *service.IssueManager) *Handler {
h := &Handler{
mux: http.NewServeMux(),
mux: http.NewServeMux(),
issueManager: issueManager,
}
h.mux.HandleFunc("GET /", h.getIssuePage)
h.mux.HandleFunc("POST /", h.handleIssueSummary)
return h
}

View File

@ -1,7 +1,128 @@
package issue
import "net/http"
import (
"context"
"net/http"
"forge.cadoles.com/wpetit/clearcase/internal/core/service"
httpCtx "forge.cadoles.com/wpetit/clearcase/internal/http/context"
"forge.cadoles.com/wpetit/clearcase/internal/http/form"
"forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common"
"forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/issue/component"
"forge.cadoles.com/wpetit/clearcase/internal/http/url"
"github.com/a-h/templ"
"github.com/pkg/errors"
)
func (h *Handler) getIssuePage(w http.ResponseWriter, r *http.Request) {
vmodel, err := h.fillIssuePageVModel(r)
if err != nil {
h.handleError(w, r, errors.WithStack(err))
return
}
issue := component.IssuePage(*vmodel)
templ.Handler(issue).ServeHTTP(w, r)
}
func (h *Handler) fillIssuePageVModel(r *http.Request) (*component.IssuePageVModel, error) {
vmodel := &component.IssuePageVModel{
SummaryForm: component.NewIssueSummaryForm(),
IssueForm: component.NewIssueForm(),
}
err := common.FillViewModel(
r.Context(), vmodel, r,
h.fillIssuePageProjects,
h.fillIssuePageSelectedProject,
)
if err != nil {
return nil, errors.WithStack(err)
}
return vmodel, nil
}
func (h *Handler) fillIssuePageProjects(ctx context.Context, vmodel *component.IssuePageVModel, r *http.Request) error {
user := httpCtx.User(ctx)
projects, err := h.issueManager.GetUserProjects(ctx, user)
if err != nil {
return errors.WithStack(err)
}
vmodel.Projects = projects
return nil
}
func (h *Handler) fillIssuePageSelectedProject(ctx context.Context, vmodel *component.IssuePageVModel, r *http.Request) error {
project := r.URL.Query().Get("project")
if project == "" {
return nil
}
vmodel.SelectedProjectID = project
vmodel.SummaryForm.Field("project").Set("value", project)
return nil
}
func (h *Handler) handleIssueSummary(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
issueSummaryForm := component.NewIssueSummaryForm()
if err := issueSummaryForm.Handle(r); err != nil {
h.handleError(w, r, errors.WithStack(err))
return
}
vmodel, err := h.fillIssuePageVModel(r)
if err != nil {
h.handleError(w, r, errors.WithStack(err))
return
}
vmodel.SummaryForm = issueSummaryForm
if errs := issueSummaryForm.Validate(); errs != nil {
page := component.IssuePage(*vmodel)
templ.Handler(page).ServeHTTP(w, r)
return
}
projectID, err := form.FormFieldAttr[string](issueSummaryForm, "project", "value")
if err != nil {
h.handleError(w, r, errors.WithStack(err))
return
}
summary, err := form.FormFieldAttr[string](issueSummaryForm, "summary", "value")
if err != nil {
h.handleError(w, r, errors.WithStack(err))
return
}
issueContent, err := h.issueManager.GenerateIssue(ctx, httpCtx.User(ctx), projectID, summary)
if err != nil {
h.handleError(w, r, errors.WithStack(err))
return
}
vmodel.IssueForm.Field("content").Set("value", issueContent)
page := component.IssuePage(*vmodel)
templ.Handler(page).ServeHTTP(w, r)
}
func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, service.ErrForgeNotAvailable) {
baseURL := url.Mutate(httpCtx.BaseURL(r.Context()), url.WithPath("/auth/logout"))
http.Redirect(w, r, baseURL.String(), http.StatusTemporaryRedirect)
return
}
common.HandleError(w, r, errors.WithStack(err))
}

View File

@ -10,6 +10,7 @@ import (
"github.com/gorilla/sessions"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/gitea"
"github.com/markbates/goth/providers/github"
"github.com/markbates/goth/providers/google"
"github.com/markbates/goth/providers/openidConnect"
@ -79,6 +80,26 @@ func NewAuthHandlerFromConfig(ctx context.Context, conf *config.Config) (*auth.H
})
}
if conf.Auth.Providers.Gitea.Key != "" && conf.Auth.Providers.Gitea.Secret != "" {
giteaProvider := gitea.NewCustomisedURL(
conf.Auth.Providers.Gitea.Key,
conf.Auth.Providers.Gitea.Secret,
fmt.Sprintf("%s/auth/providers/gitea/callback", conf.HTTP.BaseURL),
conf.Auth.Providers.Gitea.AuthURL,
conf.Auth.Providers.Gitea.TokenURL,
conf.Auth.Providers.Gitea.ProfileURL,
conf.Auth.Providers.Gitea.Scopes...,
)
gothProviders = append(gothProviders, giteaProvider)
providers = append(providers, auth.Provider{
ID: giteaProvider.Name(),
Label: conf.Auth.Providers.Gitea.Label,
Icon: "fa-git-alt",
})
}
if conf.Auth.Providers.OIDC.Key != "" && conf.Auth.Providers.OIDC.Secret != "" {
oidcProvider, err := openidConnect.New(
conf.Auth.Providers.OIDC.Key,
@ -107,13 +128,6 @@ func NewAuthHandlerFromConfig(ctx context.Context, conf *config.Config) (*auth.H
auth.WithProviders(providers...),
}
if conf.Auth.DefaultAdmin.Email != "" && conf.Auth.DefaultAdmin.Provider != "" {
opts = append(opts, auth.WithDefaultAdmin(auth.DefaultAdmin{
Provider: conf.Auth.DefaultAdmin.Provider,
Email: conf.Auth.DefaultAdmin.Email,
}))
}
auth := auth.NewHandler(
sessionStore,
opts...,

View File

@ -4,9 +4,40 @@ import (
"context"
"forge.cadoles.com/wpetit/clearcase/internal/config"
"forge.cadoles.com/wpetit/clearcase/internal/core/model"
"forge.cadoles.com/wpetit/clearcase/internal/core/port"
"forge.cadoles.com/wpetit/clearcase/internal/core/service"
"forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/issue"
"github.com/pkg/errors"
)
func NewIssueHandlerFromConfig(ctx context.Context, conf *config.Config) (*issue.Handler, error) {
return issue.NewHandler(), nil
issueManager, err := NewIssueManagerFromConfig(ctx, conf)
if err != nil {
return nil, errors.WithStack(err)
}
return issue.NewHandler(issueManager), nil
}
type authProviderBasedForgeFactory struct {
provider string
create func(ctx context.Context, user *model.User) (port.Forge, error)
}
// Create implements service.ForgeFactory.
func (a *authProviderBasedForgeFactory) Create(ctx context.Context, user *model.User) (port.Forge, error) {
forge, err := a.create(ctx, user)
if err != nil {
return nil, errors.WithStack(err)
}
return forge, nil
}
// Match implements service.ForgeFactory.
func (a *authProviderBasedForgeFactory) Match(user *model.User) bool {
return user.Provider == a.provider
}
var _ service.ForgeFactory = &authProviderBasedForgeFactory{}

View File

@ -0,0 +1,60 @@
package setup
import (
"context"
"net/url"
"code.gitea.io/sdk/gitea"
giteaAdapter "forge.cadoles.com/wpetit/clearcase/internal/adapter/gitea"
"forge.cadoles.com/wpetit/clearcase/internal/config"
"forge.cadoles.com/wpetit/clearcase/internal/core/model"
"forge.cadoles.com/wpetit/clearcase/internal/core/port"
"forge.cadoles.com/wpetit/clearcase/internal/core/service"
"github.com/bornholm/genai/llm/provider"
"github.com/pkg/errors"
_ "github.com/bornholm/genai/llm/provider/openai"
_ "github.com/bornholm/genai/llm/provider/openrouter"
)
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))
if err != nil {
return nil, errors.Wrapf(err, "could not create llm client '%s'", conf.LLM.Provider.Name)
}
forgeFactories := make([]service.ForgeFactory, 0)
if conf.Auth.Providers.Gitea.Key != "" && conf.Auth.Providers.Gitea.Secret != "" {
baseURL, err := url.Parse(conf.Auth.Providers.Gitea.AuthURL)
if err != nil {
return nil, errors.Wrapf(err, "could not parse gitea auth url '%s'", conf.Auth.Providers.Gitea.AuthURL)
}
baseURL.Path = ""
forgeFactories = append(forgeFactories, &authProviderBasedForgeFactory{
provider: "gitea",
create: func(ctx context.Context, user *model.User) (port.Forge, error) {
client, err := gitea.NewClient(baseURL.String(), gitea.SetToken(user.AccessToken))
if err != nil {
return nil, errors.WithStack(err)
}
forge := giteaAdapter.NewForge(client)
return forge, nil
},
})
}
issueManager := service.NewIssueManager(client, forgeFactories...)
return issueManager, nil
}

View File

@ -25,7 +25,7 @@ func NewWebUIHandlerFromConfig(ctx context.Context, conf *config.Config) (*webui
// Configure issue handler
issueHandler, err := NewIssueHandlerFromConfig(ctx, conf)
if err != nil {
return nil, errors.Wrap(err, "could not configure explorer handler from config")
return nil, errors.Wrap(err, "could not configure issue handler from config")
}
opts = append(opts, webui.WithMount("/", authMiddleware(issueHandler)))