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