From e6e5c9b04d714e47018a7147eb62e7116008b066 Mon Sep 17 00:00:00 2001 From: William Petit Date: Sat, 22 Feb 2025 09:42:15 +0100 Subject: [PATCH] feat: initial commit --- .env.dist | 7 +- .gitea/issue_template.md | 11 ++ README.md | 4 +- go.mod | 29 +++- go.sum | 56 ++++++- internal/adapter/gitea/forge.go | 90 ++++++++++ internal/config/auth.go | 19 ++- internal/config/config.go | 9 +- internal/config/http.go | 4 +- internal/config/storage.go | 19 --- internal/core/model/issue.go | 6 + internal/core/model/project.go | 6 + internal/core/model/user.go | 8 + internal/core/port/forge.go | 14 ++ internal/core/service/issue_manager.go | 151 +++++++++++++++++ .../core/service/issue_system_prompt.gotmpl | 20 +++ .../core/service/issue_user_prompt.gotmpl | 3 + internal/http/context/user.go | 8 +- .../webui/auth/component/login_page.templ | 8 +- .../webui/auth/component/login_page_templ.go | 10 +- internal/http/handler/webui/auth/handler.go | 2 - internal/http/handler/webui/auth/options.go | 18 +- internal/http/handler/webui/auth/provider.go | 14 +- internal/http/handler/webui/auth/session.go | 13 +- .../handler/webui/common/assets/style.css | 12 ++ .../webui/common/component/field.templ | 121 ++++++++++---- .../webui/common/component/field_templ.go | 152 ++++++++++++----- .../handler/webui/common/component/page.templ | 4 +- .../webui/common/component/page_templ.go | 4 +- .../webui/issue/component/issue_page.templ | 116 +++++++++++++ .../webui/issue/component/issue_page_templ.go | 157 ++++++++++++++++++ .../webui/issue/components/issue_page.templ | 8 - .../issue/components/issue_page_templ.go | 56 ------- internal/http/handler/webui/issue/handler.go | 11 +- .../http/handler/webui/issue/issue_page.go | 123 +++++++++++++- internal/setup/auth_handler.go | 28 +++- internal/setup/issue_handler.go | 33 +++- internal/setup/issue_manager.go | 60 +++++++ internal/setup/webui_handler.go | 2 +- misc/docker/Dockerfile | 13 +- misc/docker/docker.mk | 2 +- misc/dokku/dokku.mk | 5 + modd.conf | 2 +- 43 files changed, 1191 insertions(+), 247 deletions(-) create mode 100644 .gitea/issue_template.md create mode 100644 internal/adapter/gitea/forge.go delete mode 100644 internal/config/storage.go create mode 100644 internal/core/model/issue.go create mode 100644 internal/core/model/project.go create mode 100644 internal/core/model/user.go create mode 100644 internal/core/port/forge.go create mode 100644 internal/core/service/issue_manager.go create mode 100644 internal/core/service/issue_system_prompt.gotmpl create mode 100644 internal/core/service/issue_user_prompt.gotmpl create mode 100644 internal/http/handler/webui/issue/component/issue_page.templ create mode 100644 internal/http/handler/webui/issue/component/issue_page_templ.go delete mode 100644 internal/http/handler/webui/issue/components/issue_page.templ delete mode 100644 internal/http/handler/webui/issue/components/issue_page_templ.go create mode 100644 internal/setup/issue_manager.go create mode 100644 misc/dokku/dokku.mk diff --git a/.env.dist b/.env.dist index bbe8bbb..3681e3a 100644 --- a/.env.dist +++ b/.env.dist @@ -6,14 +6,13 @@ CLEARCASE_LOGGER_LEVEL=-4 # Gitea auth provider (disabled if empty) CLEARCASE_AUTH_PROVIDERS_GITEA_KEY= CLEARCASE_AUTH_PROVIDERS_GITEA_SECRET= -CLEARCASE_AUTH_PROVIDERS_GITEA_SCOPES="user:email" +CLEARCASE_AUTH_PROVIDERS_GITEA_AUTH_URL=https://forge.cadoles.com/login/oauth/authorize +CLEARCASE_AUTH_PROVIDERS_GITEA_TOKEN_URL=https://forge.cadoles.com/login/oauth/access_token +CLEARCASE_AUTH_PROVIDERS_GITEA_PROFILE_URL=https://forge.cadoles.com/login/oauth/userinfo # HTTP session keys (list of 32 characters strings, should be modified in production) CLEARCASE_HTTP_SESSION_KEYS=abcdefghijklmnopqrstuvwxyz000000 -# Base URL, used in templates and link generation -CLEARCASE_HTTP_BASE_URL=http://localhost:3001 - # LLM Provider # Example with ollama - llama3.1:8b : CLEARCASE_LLM_PROVIDER_BASE_URL="http://localhost:11434/api/" diff --git a/.gitea/issue_template.md b/.gitea/issue_template.md new file mode 100644 index 0000000..47b6ada --- /dev/null +++ b/.gitea/issue_template.md @@ -0,0 +1,11 @@ +## Description + +> La description du besoin. + +## Détails d'implémentation + +> Liste des actions à réaliser pour remplir le besoin. + +## Tests d'acceptance + +> Liste des critères d'acception de réalisation du ticket. diff --git a/README.md b/README.md index 8795d63..955143a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Rkvst +# ClearCase ## Getting started @@ -13,4 +13,4 @@ vim .env make watch ``` -Then open http://localhost:3000 in your browser. +Then open http://localhost:3001 in your browser. diff --git a/go.mod b/go.mod index 5e99425..a34bb35 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,19 @@ module forge.cadoles.com/wpetit/clearcase -go 1.23.1 +go 1.23.4 + +toolchain go1.23.6 require ( + code.gitea.io/sdk/gitea v0.20.0 github.com/a-h/templ v0.3.819 + github.com/bornholm/genai v0.0.0-20250222092500-1076426da67c github.com/caarlos0/env/v11 v11.2.2 + github.com/davecgh/go-spew v1.1.1 github.com/gabriel-vasile/mimetype v1.4.7 github.com/gorilla/sessions v1.1.1 github.com/markbates/goth v1.80.0 + github.com/num30/go-cache v1.0.0 github.com/pkg/errors v0.9.1 github.com/samber/slog-http v1.4.4 ) @@ -15,15 +21,34 @@ require ( require ( cloud.google.com/go/compute v1.20.1 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect + github.com/42wim/httpsig v1.2.1 // indirect + github.com/BurntSushi/toml v1.0.0 // indirect + github.com/RealAlexandreAI/json-repair v0.0.14 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/mux v1.6.2 // indirect github.com/gorilla/securecookie v1.1.1 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/openai/openai-go v0.1.0-alpha.59 // indirect + github.com/revrost/go-openrouter v0.0.0-20250128091643-3d014d57014d // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect - golang.org/x/net v0.33.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e // indirect + golang.org/x/mod v0.20.0 // indirect + golang.org/x/net v0.35.0 // indirect golang.org/x/oauth2 v0.17.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/tools v0.24.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.34.1 // indirect + honnef.co/go/tools v0.3.1 // indirect ) diff --git a/go.sum b/go.sum index 74f2619..73300e2 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,28 @@ cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZN cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +code.gitea.io/sdk/gitea v0.20.0 h1:Zm/QDwwZK1awoM4AxdjeAQbxolzx2rIP8dDfmKu+KoU= +code.gitea.io/sdk/gitea v0.20.0/go.mod h1:faouBHC/zyx5wLgjmRKR62ydyvMzwWf3QnU0bH7Cw6U= +github.com/42wim/httpsig v1.2.1 h1:oLBxptMe9U4ZmSGtkosT8Dlfg31P3VQnAGq6psXv82Y= +github.com/42wim/httpsig v1.2.1/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY= +github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU= +github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x518wl0bCsw0t0= +github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI= github.com/a-h/templ v0.3.819 h1:KDJ5jTFN15FyJnmSmo2gNirIqt7hfvBD2VXVDTySckM= github.com/a-h/templ v0.3.819/go.mod h1:iDJKJktpttVKdWoTkRNNLcllRI+BlpopJc+8au3gOUo= +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/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= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -27,40 +41,74 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0PrE= github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/markbates/goth v1.80.0 h1:NnvatczZDzOs1hn9Ug+dVYf2Viwwkp/ZDX5K+GLjan8= github.com/markbates/goth v1.80.0/go.mod h1:4/GYHo+W6NWisrMPZnq0Yr2Q70UntNLn7KXEFhrIdAY= +github.com/num30/go-cache v1.0.0 h1:uvXf3X4xEm+AB8b2MtMouFpgevR/sFCOOxXKaofzReg= +github.com/num30/go-cache v1.0.0/go.mod h1:YxYzvWbR26wa2xojzZ8W3c+NSSwBzYtrorHSkeRaJWw= +github.com/openai/openai-go v0.1.0-alpha.59 h1:T3IYwKSCezfIlL9Oi+CGvU03fq0RoH33775S78Ti48Y= +github.com/openai/openai-go v0.1.0-alpha.59/go.mod h1:3SdE6BffOX9HPEQv8IL/fi3LYZ5TUpRYaqGQZbyk11A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/revrost/go-openrouter v0.0.0-20250128091643-3d014d57014d h1:3PCl9WWy1S3YpCzlXrDoBJqPJ59IsiqKSf8d3tHDYn0= +github.com/revrost/go-openrouter v0.0.0-20250128091643-3d014d57014d/go.mod h1:UIrJIZBygNz1DygZPImp56zCjv5IJNNkdp2hNUgn9H4= github.com/samber/slog-http v1.4.4 h1:NuENLy39Lk6b7wfj9cG9R5C/JLZR4t6pb9cwlyroybI= github.com/samber/slog-http v1.4.4/go.mod h1:PAcQQrYFo5KM7Qbk50gNNwKEAMGCyfsw6GN5dI0iv9g= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e h1:qyrTQ++p1afMkO4DPEeLGq/3oTsdlvdH4vqZUBWzUKM= +golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= +golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -68,6 +116,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= @@ -78,3 +128,5 @@ google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFW google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.3.1 h1:1kJlrWJLkaGXgcaeosRXViwviqjI7nkBvU2+sZW0AYc= +honnef.co/go/tools v0.3.1/go.mod h1:vlRD9XErLMGT+mDuofSr0mMMquscM/1nQqtRSsh6m70= diff --git a/internal/adapter/gitea/forge.go b/internal/adapter/gitea/forge.go new file mode 100644 index 0000000..620838b --- /dev/null +++ b/internal/adapter/gitea/forge.go @@ -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{} diff --git a/internal/config/auth.go b/internal/config/auth.go index 8a4e91e..2104643 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -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"` +} diff --git a/internal/config/config.go b/internal/config/config.go index 4ace929..f2616bb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) { diff --git a/internal/config/http.go b/internal/config/http.go index 523ecdd..f7297b6 100644 --- a/internal/config/http.go +++ b/internal/config/http.go @@ -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_"` } diff --git a/internal/config/storage.go b/internal/config/storage.go deleted file mode 100644 index ddc4699..0000000 --- a/internal/config/storage.go +++ /dev/null @@ -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"` -} diff --git a/internal/core/model/issue.go b/internal/core/model/issue.go new file mode 100644 index 0000000..b2b9a32 --- /dev/null +++ b/internal/core/model/issue.go @@ -0,0 +1,6 @@ +package model + +type Issue struct { + ID string + Content string +} diff --git a/internal/core/model/project.go b/internal/core/model/project.go new file mode 100644 index 0000000..345f13a --- /dev/null +++ b/internal/core/model/project.go @@ -0,0 +1,6 @@ +package model + +type Project struct { + ID string + Label string +} diff --git a/internal/core/model/user.go b/internal/core/model/user.go new file mode 100644 index 0000000..830844f --- /dev/null +++ b/internal/core/model/user.go @@ -0,0 +1,8 @@ +package model + +type User struct { + ID string + Provider string + AccessToken string + IDToken string +} diff --git a/internal/core/port/forge.go b/internal/core/port/forge.go new file mode 100644 index 0000000..b0b7f2b --- /dev/null +++ b/internal/core/port/forge.go @@ -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) +} diff --git a/internal/core/service/issue_manager.go b/internal/core/service/issue_manager.go new file mode 100644 index 0000000..adcd352 --- /dev/null +++ b/internal/core/service/issue_manager.go @@ -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), + } +} diff --git a/internal/core/service/issue_system_prompt.gotmpl b/internal/core/service/issue_system_prompt.gotmpl new file mode 100644 index 0000000..6f26502 --- /dev/null +++ b/internal/core/service/issue_system_prompt.gotmpl @@ -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 }} +``` \ No newline at end of file diff --git a/internal/core/service/issue_user_prompt.gotmpl b/internal/core/service/issue_user_prompt.gotmpl new file mode 100644 index 0000000..869e06a --- /dev/null +++ b/internal/core/service/issue_user_prompt.gotmpl @@ -0,0 +1,3 @@ +Write a formatted issue/request based on theses contextual informations: + +{{ .Context }} \ No newline at end of file diff --git a/internal/http/context/user.go b/internal/http/context/user.go index 209de45..1aac78c 100644 --- a/internal/http/context/user.go +++ b/internal/http/context/user.go @@ -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) } diff --git a/internal/http/handler/webui/auth/component/login_page.templ b/internal/http/handler/webui/auth/component/login_page.templ index f3fbd58..7f8dc5b 100644 --- a/internal/http/handler/webui/auth/component/login_page.templ +++ b/internal/http/handler/webui/auth/component/login_page.templ @@ -17,15 +17,15 @@ templ LoginPage(vmodel LoginPageVModel) {
diff --git a/internal/http/handler/webui/auth/component/login_page_templ.go b/internal/http/handler/webui/auth/component/login_page_templ.go index dd49d3b..fa4f2c4 100644 --- a/internal/http/handler/webui/auth/component/login_page_templ.go +++ b/internal/http/handler/webui/auth/component/login_page_templ.go @@ -53,12 +53,12 @@ func LoginPage(vmodel LoginPageVModel) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") 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 diff --git a/internal/http/handler/webui/common/component/page.templ b/internal/http/handler/webui/common/component/page.templ index 1ff7936..6a376b3 100644 --- a/internal/http/handler/webui/common/component/page.templ +++ b/internal/http/handler/webui/common/component/page.templ @@ -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" } } } diff --git a/internal/http/handler/webui/common/component/page_templ.go b/internal/http/handler/webui/common/component/page_templ.go index fa6d28a..2db9a8e 100644 --- a/internal/http/handler/webui/common/component/page_templ.go +++ b/internal/http/handler/webui/common/component/page_templ.go @@ -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" } } } diff --git a/internal/http/handler/webui/issue/component/issue_page.templ b/internal/http/handler/webui/issue/component/issue_page.templ new file mode 100644 index 0000000..c15b2ac --- /dev/null +++ b/internal/http/handler/webui/issue/component/issue_page.templ @@ -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")) { +
+
+
+ Logout +
+
+
+
+
+
+

Describe your request

+
+
+
+ +
+
+
+ + @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") +
+
+
+
+
+

Generated issue

+
+
+
+ +
+
+
+ @common.FormTextarea(vmodel.IssueForm, "issue-content", "content", "") +
+
+
+
+ } +} + +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 +} diff --git a/internal/http/handler/webui/issue/component/issue_page_templ.go b/internal/http/handler/webui/issue/component/issue_page_templ.go new file mode 100644 index 0000000..cf4a1a3 --- /dev/null +++ b/internal/http/handler/webui/issue/component/issue_page_templ.go @@ -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, "

Describe your request

") + 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, "

Generated issue

") + 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, "
") + 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 diff --git a/internal/http/handler/webui/issue/components/issue_page.templ b/internal/http/handler/webui/issue/components/issue_page.templ deleted file mode 100644 index 44e3874..0000000 --- a/internal/http/handler/webui/issue/components/issue_page.templ +++ /dev/null @@ -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)) { - } -} diff --git a/internal/http/handler/webui/issue/components/issue_page_templ.go b/internal/http/handler/webui/issue/components/issue_page_templ.go deleted file mode 100644 index 40fed95..0000000 --- a/internal/http/handler/webui/issue/components/issue_page_templ.go +++ /dev/null @@ -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 diff --git a/internal/http/handler/webui/issue/handler.go b/internal/http/handler/webui/issue/handler.go index 1502e41..6c3a6d8 100644 --- a/internal/http/handler/webui/issue/handler.go +++ b/internal/http/handler/webui/issue/handler.go @@ -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 } diff --git a/internal/http/handler/webui/issue/issue_page.go b/internal/http/handler/webui/issue/issue_page.go index 24698d2..134d7f4 100644 --- a/internal/http/handler/webui/issue/issue_page.go +++ b/internal/http/handler/webui/issue/issue_page.go @@ -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)) } diff --git a/internal/setup/auth_handler.go b/internal/setup/auth_handler.go index 0243af3..6b5c2e7 100644 --- a/internal/setup/auth_handler.go +++ b/internal/setup/auth_handler.go @@ -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..., diff --git a/internal/setup/issue_handler.go b/internal/setup/issue_handler.go index 511b56f..0428dd7 100644 --- a/internal/setup/issue_handler.go +++ b/internal/setup/issue_handler.go @@ -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{} diff --git a/internal/setup/issue_manager.go b/internal/setup/issue_manager.go new file mode 100644 index 0000000..7d00210 --- /dev/null +++ b/internal/setup/issue_manager.go @@ -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 +} diff --git a/internal/setup/webui_handler.go b/internal/setup/webui_handler.go index 25bf74c..4282c10 100644 --- a/internal/setup/webui_handler.go +++ b/internal/setup/webui_handler.go @@ -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))) diff --git a/misc/docker/Dockerfile b/misc/docker/Dockerfile index 1ba6af6..6c6b0e0 100644 --- a/misc/docker/Dockerfile +++ b/misc/docker/Dockerfile @@ -21,18 +21,7 @@ RUN --mount=type=cache,target=/go/pkg/mod/ \ FROM alpine:3.21 AS runtime -RUN apk add \ - ca-certificates \ - openssl \ - openjdk21-jdk \ - msttcorefonts-installer \ - ttf-dejavu \ - fontconfig \ - tesseract-ocr \ - tesseract-ocr-data-eng \ - tesseract-ocr-data-fra \ - && update-ms-fonts \ - && fc-cache -f -v \ +RUN apk add ca-certificates \ && update-ca-certificates COPY --from=build /src/bin/clearcase /usr/local/bin/clearcase diff --git a/misc/docker/docker.mk b/misc/docker/docker.mk index b96862c..6c50e7b 100644 --- a/misc/docker/docker.mk +++ b/misc/docker/docker.mk @@ -5,4 +5,4 @@ docker-image: docker build -f misc/docker/Dockerfile -t $(DOCKER_STANDALONE_IMAGE_NAME):$(DOCKER_STANDALONE_IMAGE_TAG) . docker-run: - docker run -it --rm -p 3000:3000 --name rkvst --tmpfs /tmp --env-file .env $(DOCKER_STANDALONE_IMAGE_NAME):$(DOCKER_STANDALONE_IMAGE_TAG) \ No newline at end of file + docker run -it --rm -p 3000:3000 --name clearcase --tmpfs /tmp --env-file .env $(DOCKER_STANDALONE_IMAGE_NAME):$(DOCKER_STANDALONE_IMAGE_TAG) \ No newline at end of file diff --git a/misc/dokku/dokku.mk b/misc/dokku/dokku.mk new file mode 100644 index 0000000..bc767fe --- /dev/null +++ b/misc/dokku/dokku.mk @@ -0,0 +1,5 @@ +DOKKU_APP ?= clearcase +DOKKU_DEPLOY_URL ?= dokku@dev.lookingfora.name + +dokku-deploy: + git push $(DOKKU_DEPLOY_URL):$(DOKKU_APP) $(shell git rev-parse HEAD):refs/heads/master --force \ No newline at end of file diff --git a/modd.conf b/modd.conf index 4ed0448..94d954b 100644 --- a/modd.conf +++ b/modd.conf @@ -3,7 +3,7 @@ internal/**/*.go Makefile modd.conf { prep: "make -o generate build" - daemon: "make CMD='bin/rkvst' run-with-env" + daemon: "make CMD='bin/clearcase' run-with-env" } internal/**/*.templ {