diff --git a/client/base.go b/client/base.go new file mode 100644 index 000000000..fd73925d2 --- /dev/null +++ b/client/base.go @@ -0,0 +1,15 @@ +package client + +import ( + "code.gitea.io/gitea/modules/setting" + "github.com/go-resty/resty/v2" +) + +var client *resty.Client = nil + +func Request() *resty.Request { + if client == nil { + client = resty.New().SetBaseURL(setting.DiscussionServer.Url) + } + return client.R() +} diff --git a/client/discussion/discussion_client.go b/client/discussion/discussion_client.go new file mode 100644 index 000000000..1a5011de3 --- /dev/null +++ b/client/discussion/discussion_client.go @@ -0,0 +1,89 @@ +package discussion + +import ( + "database/sql" + "fmt" + + "code.gitea.io/gitea/client" + "github.com/go-resty/resty/v2" +) + +type DiscussionCode struct { + Id sql.NullInt64 `json:"id"` + FilePath string `json:"filePath"` + StartLine int `json:"startLine"` + EndLine int `json:"endLine"` +} + +type PostDiscussionRequest struct { + RepoId int64 `json:"repoId"` + PosterId int64 `json:"posterId"` + Name string `json:"name"` + Content string `json:"content"` + Codes []DiscussionCode `json:"codes"` +} + +type DiscussionAvailableRequest struct { + RepoId int64 `json:"repoId"` + Available bool `json:"available"` +} + +type CommentScopeEnum int + +const ( + CommentScopeGlobal CommentScopeEnum = iota + CommentScopeLocal +) + +type PostCommentRequest struct { + DiscussionId int64 `json:"discussionId"` + CodeId int64 `json:"codeId"` + PosterId int64 `json:"posterId"` + Scope CommentScopeEnum `json:"scope"` + StartLine sql.NullInt32 `json:"startLine"` + EndLine sql.NullInt32 `json:"endLine"` + Content string `json:"content"` +} + +type ModifyDiscussionRequest struct { + RepoId int64 `json:"repoId"` + DiscussionId int64 `json:"discussionId"` + PosterId int64 `json:"posterId"` + Name string `json:"name"` + Content string `json:"content"` + Codes []DiscussionCode `json:"codes"` +} + +func PostDiscussion(request PostDiscussionRequest) (*resty.Response, error) { + return client.Request().SetBody(request).Post("/discussion") +} + +func GetDiscussionCount(repoId int64, isClosed bool) (*resty.Response, error) { + var isClosedAsInt = map[bool]int{false: 0, true: 1}[isClosed] + return client.Request(). + SetQueryParam("isClosed", string(isClosedAsInt)). + Get(fmt.Sprintf("/discussion/%d/count", repoId)) +} + +func GetDiscussionList(repoId int64, isClosed bool) (*resty.Response, error) { + var isClosedAsInt = map[bool]int{false: 0, true: 1}[isClosed] + return client.Request(). + SetQueryParam("isClosed", string(isClosedAsInt)). + Get(fmt.Sprintf("/discussion/%d/list", repoId)) +} + +func HandleDiscussionAvailable() (*resty.Response, error) { + return client.Request().Post("/discussion/available") +} + +func GetDiscussionContents(discussionId int64) (*resty.Response, error) { + return client.Request().Get(fmt.Sprintf("/discussion/%d/codes", discussionId)) +} + +func PostComment(request PostCommentRequest) (*resty.Response, error) { + return client.Request().SetBody(request).Post("/discussion/comment") +} + +func ModifyDiscussion(request ModifyDiscussionRequest) (*resty.Response, error) { + return client.Request().SetBody(request).Put("/discussion") +} diff --git a/go.mod b/go.mod index 3f780c390..a0e88727d 100644 --- a/go.mod +++ b/go.mod @@ -104,12 +104,12 @@ require ( github.com/yuin/goldmark v1.7.0 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc github.com/yuin/goldmark-meta v1.1.0 - golang.org/x/crypto v0.22.0 + golang.org/x/crypto v0.23.0 golang.org/x/image v0.15.0 - golang.org/x/net v0.24.0 + golang.org/x/net v0.25.0 golang.org/x/oauth2 v0.18.0 - golang.org/x/sys v0.19.0 - golang.org/x/text v0.14.0 + golang.org/x/sys v0.20.0 + golang.org/x/text v0.15.0 golang.org/x/tools v0.19.0 google.golang.org/grpc v1.62.1 google.golang.org/protobuf v1.33.0 @@ -194,6 +194,7 @@ require ( github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect + github.com/go-resty/resty/v2 v2.13.1 // indirect github.com/go-webauthn/x v0.1.9 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect @@ -267,6 +268,7 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.18.2 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/toqueteos/webbrowser v1.2.0 // indirect github.com/unknwon/com v1.0.1 // indirect diff --git a/go.sum b/go.sum index 5bf8d737e..4088ed2ec 100644 --- a/go.sum +++ b/go.sum @@ -329,6 +329,8 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-redis/redis v6.15.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= +github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= +github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.8.0 h1:UtktXaU2Nb64z/pLiGIxY4431SJ4/dR5cjMmlVHgnT4= github.com/go-sql-driver/mysql v1.8.0/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= @@ -736,6 +738,8 @@ github.com/steveyen/gtreap v0.1.0/go.mod h1:kl/5J7XbrOmlIbYIXdRHDDE5QxHqpk0cmkT7 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -854,8 +858,10 @@ golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2Uz golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f h1:3CW0unweImhOzd5FmYuRsD4Y4oQFKZIjAnKbjV4WIrw= golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= @@ -888,8 +894,11 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -939,8 +948,10 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.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.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -950,8 +961,10 @@ golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -965,6 +978,7 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/models/fixtures/ai_pull_comment.yml b/models/fixtures/ai_pull_comment.yml new file mode 100644 index 000000000..a2810179c --- /dev/null +++ b/models/fixtures/ai_pull_comment.yml @@ -0,0 +1,6 @@ +- + id: 2 # create 전용 + poster_id: 1 + pull_id: 1 + + diff --git a/models/issues/ai_comment.go b/models/issues/ai_comment.go new file mode 100644 index 000000000..966d27c91 --- /dev/null +++ b/models/issues/ai_comment.go @@ -0,0 +1,140 @@ +package issues + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + + "code.gitea.io/gitea/modules/timeutil" +) + +// init 메소드가 있으면 자동적으로 xorm에서 이 메소드를 실행하는듯 하다. +func init() { + db.RegisterModel(new(AiPullComment)) +} + +// TODOC AI 코멘트 테이블 만들기 +// TODOC outdated가 어떤 식으로 나타나는 것인지 알아보기 +// TODOC 먼저 영속성 계층부터-도메인 계층 순서로 만들어가기 +type AiPullComment struct { + ID int64 `xorm:"pk autoincr"` + PosterID int64 `xorm:"INDEX"` + Poster *user_model.User `xorm:"-"` + PullID int64 + Pull *Issue `xorm:"-"` + TreePath string + Content string `xorm:"LONGTEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + Status string `xorm:"status"` // + DeletedUnix timeutil.TimeStamp `xorm:"deleted"` + CommitSHA string `xorm:"VARCHAR(64)"` + // CommitID int64 + +} + +type CreateAiPullCommentOption struct { + Doer *user_model.User + Repo *repo_model.Repository + Pull *Issue + TreePath string + Content string + CommitSHA string + // CommitID string + +} + +type ErrAiPullCommentNotExist struct { + ID int64 + RepoID int64 +} + +func IsErrAiPullCommentNotExist(err error) bool { + _, ok := err.(ErrIssueWasClosed) + return ok +} + +func (err ErrAiPullCommentNotExist) Error() string { + return fmt.Sprintf("AiPullComment does not exist [id: %d, repo_id: %d]", err.ID, err.RepoID) +} + +func CreateAiPullComment(ctx context.Context, opts *CreateAiPullCommentOption) (*AiPullComment, error) { + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + aiPullComment := &AiPullComment{ + PosterID: opts.Doer.ID, + PullID: opts.Pull.ID, + TreePath: opts.TreePath, + Content: opts.Content, + CommitSHA: opts.CommitSHA, + } + + e := db.GetEngine(ctx) + _, err = e.Insert(aiPullComment) + if err != nil { + fmt.Errorf("new Comment insert is invalid") + return nil, err + } + + if err = committer.Commit(); err != nil { + return nil, err + } + + return aiPullComment, nil + +} + +func GetAIPullCommentByID(ctx context.Context, id int64) (*AiPullComment, error) { + comment := new(AiPullComment) + has, err := db.GetEngine(ctx).ID(id).Get(comment) + + if err != nil { + return nil, ErrAiPullCommentNotExist{id, 0} + } else if !has { + return nil, err + } + + return comment, nil + +} + +func DeleteAiPullCommentByID(ctx context.Context, id int64) error { + _, err := GetAIPullCommentByID(ctx, id) + + if err != nil { + + if IsErrAiPullCommentNotExist(err) { + return nil + } + return err + + } + + dbCtx, commiter, err := db.TxContext(ctx) + + defer commiter.Close() + + if err != nil { + return err + } + + _, err = db.DeleteByID[AiPullComment](dbCtx, id) + + if err != nil { + return err + } + + return commiter.Commit() + +} + +// TODOC repo가 삭제되면 Ai Comment도 삭제하는 로직 +// TODOC diff --git a/models/issues/ai_comment_test.go b/models/issues/ai_comment_test.go new file mode 100644 index 000000000..defa0a9f3 --- /dev/null +++ b/models/issues/ai_comment_test.go @@ -0,0 +1,52 @@ +package issues_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + + ai_comment "code.gitea.io/gitea/models/issues" +) + +func TestCreateAiPullComment(t *testing.T) { + + assert := assert.New(t) + assert.NoError(unittest.PrepareTestDatabase()) + + pull := unittest.AssertExistsAndLoadBean(t, &ai_comment.Issue{ID: 2}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pull.RepoID}) + content := "this code is sh**" + treePath := "/src/ddd" + newAiComment, err := ai_comment.CreateAiPullComment(db.DefaultContext, &ai_comment.CreateAiPullCommentOption{ + Doer: doer, + Pull: pull, + Repo: repo, + Content: content, + TreePath: treePath, + }) + assert.NoError(err) + assert.EqualValues(newAiComment.Content, content) + assert.EqualValues(newAiComment.TreePath, treePath) + assert.EqualValues(newAiComment.PosterID, doer.ID) + assert.EqualValues(newAiComment.PullID, pull.ID) + +} + +func TestDeleteAiPullRequest(t *testing.T) { + + + assert.NoError(t, unittest.PrepareTestDatabase()) + + comment := unittest.AssertExistsAndLoadBean(t, &ai_comment.AiPullComment{ID: 2}) + assert.NoError(t, ai_comment.DeleteAiPullCommentByID(db.DefaultContext, comment.ID)) + unittest.AssertNotExistsBean(t, &ai_comment.AiPullComment{ID: comment.ID}) + + assert.NoError(t, ai_comment.DeleteAiPullCommentByID(db.DefaultContext, unittest.NonexistentID)) + unittest.CheckConsistencyFor(t, &ai_comment.AiPullComment{}) +} diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 147b7eb3b..4808ee753 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -453,7 +453,7 @@ func UpdateIssueDeadline(ctx context.Context, issue *Issue, deadlineUnix timeuti return committer.Commit() } - +// 커멘트에서 멘션된 유저를 찾고 데이터베이스에 저장하기 // FindAndUpdateIssueMentions finds users mentioned in the given content string, and saves them in the database. func FindAndUpdateIssueMentions(ctx context.Context, issue *Issue, doer *user_model.User, content string) (mentions []*user_model.User, err error) { rawMentions := references.FindAllMentionsMarkdown(content) diff --git a/modules/setting/ai_server.go b/modules/setting/ai_server.go new file mode 100644 index 000000000..ada4f5145 --- /dev/null +++ b/modules/setting/ai_server.go @@ -0,0 +1,20 @@ +package setting + +import "fmt" + +var AiServer = struct { + Host string + Port int + Url string +}{ + Host: "localhost", + Port: 8000, + Url: "http://localhost:8000", +} + +func loadAiServerFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("ai_server") + AiServer.Host = sec.Key("host").MustString("localhost") + AiServer.Port = sec.Key("PORT").MustInt(8000) + AiServer.Url = fmt.Sprintf("http://%s:%d", AiServer.Host, AiServer.Port) +} diff --git a/modules/setting/discussion_server.go b/modules/setting/discussion_server.go new file mode 100644 index 000000000..f149006cb --- /dev/null +++ b/modules/setting/discussion_server.go @@ -0,0 +1,20 @@ +package setting + +import "fmt" + +var DiscussionServer = struct { + Host string + Port int + Url string +}{ + Host: "localhost", + Port: 8081, + Url: "http://localhost:8081", +} + +func loadDiscussionServerFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("discussion_server") + DiscussionServer.Host = sec.Key("HOST").MustString("localhost") + DiscussionServer.Port = sec.Key("PORT").MustInt(8081) + DiscussionServer.Url = fmt.Sprintf("http://%s:%d", DiscussionServer.Host, DiscussionServer.Port) +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index f056fbfc6..c77219c4c 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -118,6 +118,8 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { loadLogGlobalFrom(cfg) loadServerFrom(cfg) loadSSHFrom(cfg) + loadDiscussionServerFrom(cfg) + loadAiServerFrom(cfg) mustCurrentRunUserMatch(cfg) // it depends on the SSH config, only non-builtin SSH server requires this check diff --git a/modules/structs/ai.go b/modules/structs/ai.go new file mode 100644 index 000000000..6c790f72a --- /dev/null +++ b/modules/structs/ai.go @@ -0,0 +1,13 @@ +package structs + +type CreateAiPullCommentForm struct { + Branch string `json:"branch"` + FileContents *[]PathContentMap `json:"file_contents"` + RepoID string `json:"repo_id"` + PullID string `json:"pull_id"` +} + +type PathContentMap struct { + TreePath string `json:"file_path"` + Content string `json:"code"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 73071aa8d..88674ded7 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -920,6 +920,8 @@ func Routes() *web.Route { Patch(reqToken(), notify.ReadThread) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification)) + + // Users (requires user scope) m.Group("/users", func() { m.Get("/search", reqExploreSignIn(), user.Search) diff --git a/routers/api/v1/repo/ai_comment.go b/routers/api/v1/repo/ai_comment.go new file mode 100644 index 000000000..9ee70632e --- /dev/null +++ b/routers/api/v1/repo/ai_comment.go @@ -0,0 +1,56 @@ +package repo + +import ( + "net/http" + + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + ai_service "code.gitea.io/gitea/services/ai" + "code.gitea.io/gitea/services/context" +) + +// TODOC 특정한 PR에 대한 AI comment 리퀘스트가 이미 이루어졌을 경우를 체크해서 중복 요청 차단. +// CreateAiPullComment creates an attachment and saves the given file +func CreateAiPullComment(ctx *context.Context) { + // swagger:operation POST /ai/pull/review repository repoCreateAiPullComment + // --- + // summary: Create ai pull comment + // produces: + // - application/json + // consumes: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateAiPullCommentForm" + // responses: + // "201": + // "$ref": "#/responses/Attachment" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + // Check if attachments are enabled + + form := web.GetForm(ctx).(*api.CreateAiPullCommentForm) + + // TODOC 싱글톤으로 바꾼 뒤에 + // TODOC 설정을 통해 의존성 주입하는 방식으로 바꾸기 + + aiService := new(ai_service.AiServiceImpl) + aiRequester := new(ai_service.AiRequesterImpl) + adapter := new(ai_service.DbAdapterImpl) + err := ai_service.AiService.CreateAiPullComment(aiService, ctx, form, aiRequester, adapter) + + if err != nil { + ctx.JSON(http.StatusBadRequest, map[string]any{ + "message": err.Error()}) + return + } + + ctx.JSON(http.StatusAccepted, map[string]any{ + "message": "request has accepted", + }) +} diff --git a/routers/install/install.go b/routers/install/install.go index fde8b37ed..f60b64eee 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -399,6 +399,12 @@ func SubmitInstall(ctx *context.Context) { cfg.Section("server").Key("ROOT_URL").SetValue(form.AppURL) cfg.Section("server").Key("APP_DATA_PATH").SetValue(setting.AppDataPath) + // TODO: hardcoded for now, make it configurable later + cfg.Section("discussion_server").Key("HOST").SetValue("localhost") + cfg.Section("discussion_server").Key("PORT").SetValue("8081") + cfg.Section("ai_server").Key("HOST").SetValue("localhost") + cfg.Section("ai_server").Key("PORT").SetValue("8000") + if form.SSHPort == 0 { cfg.Section("server").Key("DISABLE_SSH").SetValue("true") } else { diff --git a/routers/web/repo/ai_review.go b/routers/web/repo/ai_review.go new file mode 100644 index 000000000..64fd8d28b --- /dev/null +++ b/routers/web/repo/ai_review.go @@ -0,0 +1,60 @@ +package repo + +// TODO 추후에 api.go로 옮기기 + +// import ( +// "fmt" + +// issues_model "code.gitea.io/gitea/models/issues" +// ai_service "code.gitea.io/gitea/services/ai" + +// api "code.gitea.io/gitea/modules/structs" +// "code.gitea.io/gitea/modules/web" +// "code.gitea.io/gitea/services/context" +// ) + +// type AiController interface { +// CreateAiReviewComment(ctx *context.Context, service *ai_service.AiService, aiRequest ai_service.AiRequester, issueService ai_service.DbAdapter) +// } + +// type AiControllerImpl struct{} + +// func GetActionPull(ctx *context.Context) *issues_model.Issue { +// return GetActionIssue(ctx) +// } + +// var _ AiController = &AiControllerImpl{} + +// func (aiController *AiControllerImpl) CreateAiReviewComment(ctx *context.Context, service *ai_service.AiService, aiRequest ai_service.AiRequester, issueService ai_service.DbAdapter) { + +// pull := GetActionPull(ctx) +// form := web.GetForm(ctx).(*api.CreateAiPullCommentForm) +// // TODO 결과를 받아서 AiPullComment로 저장 + +// if ctx.Written() { +// return +// } +// if !pull.IsPull { +// return +// } + +// if ctx.HasError() { +// ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) +// ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, pull.Index)) +// return +// } + +// ai_service.AiService.CreateAiPullComment(*service, ctx, form, aiRequest, issueService) + +// // ai_service.AiService.CreateAiPullComment(*service, +// // ctx, +// // &ai_service.AiReviewCommentResult{ +// // PullID: form.PullID, +// // RepoID: form.RepoID, +// // Content: result.Content, +// // TreePath: result.TreePath, +// // }) + +// } + +// // TODOC AI 리뷰 삭제 \ No newline at end of file diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index 62f6d71c5..92ead330e 100644 --- a/routers/web/repo/pull_review.go +++ b/routers/web/repo/pull_review.go @@ -58,6 +58,7 @@ func RenderNewCodeCommentForm(ctx *context.Context) { ctx.HTML(http.StatusOK, tplNewComment) } +// 체크 코드 코멘트를 만드는 곳 // CreateCodeComment will create a code comment including an pending review if required func CreateCodeComment(ctx *context.Context) { form := web.GetForm(ctx).(*forms.CodeCommentForm) diff --git a/routers/web/web.go b/routers/web/web.go index 82ce369d4..b28cd507b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -5,6 +5,7 @@ package web import ( gocontext "context" + "fmt" "net/http" "strings" @@ -23,6 +24,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/routing" + api_repo_router "code.gitea.io/gitea/routers/api/v1/repo" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/routers/web/admin" "code.gitea.io/gitea/routers/web/auth" @@ -47,7 +49,7 @@ import ( "code.gitea.io/gitea/services/lfs" _ "code.gitea.io/gitea/modules/session" // to registers all internal adapters - + "gitea.com/go-chi/binding" "gitea.com/go-chi/captcha" chi_middleware "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" @@ -290,6 +292,19 @@ func Routes() *web.Route { return routes } +// bind binding an obj to a func(ctx *context.APIContext) +func bind[T any](_ T) any { + return func(ctx *context.Context) { + theObj := new(T) // create a new form obj for every request but not use obj directly + errs := binding.Bind(ctx.Req, theObj) + if len(errs) > 0 { + ctx.Error(http.StatusUnprocessableEntity, "validationError", fmt.Sprintf("%s: %s", errs[0].FieldNames, errs[0].Error())) + return + } + web.SetForm(ctx, theObj) + } +} + var ignSignInAndCsrf = verifyAuthWithOptions(&common.VerifyOptions{DisableCSRF: true}) // registerRoutes register routes @@ -841,6 +856,10 @@ func registerRoutes(m *web.Route) { } } + m.Group("/ai/pull/review", func() { + m.Post("", bind(structs.CreateAiPullCommentForm{}), api_repo_router.CreateAiPullComment) // 라우팅 + }) + m.Group("/org", func() { m.Group("/{org}", func() { m.Get("/members", org.Members) @@ -1499,6 +1518,7 @@ func registerRoutes(m *web.Route) { m.Get("/{shaFrom:[a-f0-9]{7,40}}..{shaTo:[a-f0-9]{7,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForRange) m.Group("/reviews", func() { m.Get("/new_comment", repo.RenderNewCodeCommentForm) + // 체크 = CreateCodeComment를 호출 m.Post("/comments", web.Bind(forms.CodeCommentForm{}), repo.SetShowOutdatedComments, repo.CreateCodeComment) m.Post("/submit", web.Bind(forms.SubmitReviewForm{}), repo.SubmitReview) }, context.RepoMustNotBeArchived()) diff --git a/services/ai/review.go b/services/ai/review.go new file mode 100644 index 000000000..d365e237b --- /dev/null +++ b/services/ai/review.go @@ -0,0 +1,165 @@ +package ai + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strconv" + "sync" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" +) + +type AiService interface { + CreateAiPullComment(ctx *context.Context, form *api.CreateAiPullCommentForm, aiRequester AiRequester, adapter DbAdapter) error + DeleteAiPullComment(ctx *context.Context, id int64, adapter DbAdapter) error +} + +type AiReviewRequest struct { + Branch string `json:"branch"` + TreePath string `json:"file_path"` + Content string `json:"code"` +} + +type AiReviewResponse struct { + Branch string `json:"branch"` + TreePath string `json:"file_path"` + Content string `json:"code"` +} + +type AiServiceImpl struct{} + +type AiRequesterImpl struct{} + +type AiRequester interface { + RequestReviewToAI(ctx *context.Context, request *AiReviewRequest) (*AiReviewResponse, error) +} + +var _ AiService = &AiServiceImpl{} +var _ AiRequester = &AiRequesterImpl{} + +var apiURL = setting.AiServer.Url + +type AiReviewCommentResult struct { + PullID string + RepoID string + TreePath string + Content string +} + +func (aiRequest *AiRequesterImpl) RequestReviewToAI(ctx *context.Context, request *AiReviewRequest) (*AiReviewResponse, error) { + requestBytes, _ := json.Marshal(request) + buffer := bytes.NewBuffer(requestBytes) + response, err := http.Post(apiURL, "application/json", buffer) + + if err != nil { + return nil, err + } + + defer response.Body.Close() + + bodyJson, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + result := &AiReviewResponse{} + err = json.Unmarshal(bodyJson, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// TODOC 잘못된 형식의 json이 돌아올 때 예외 반환하기(json 형식 표시하도록) +func (aiController *AiServiceImpl) CreateAiPullComment(ctx *context.Context, form *api.CreateAiPullCommentForm, aiRequester AiRequester, adapter DbAdapter) error { + branch := form.Branch + var wg *sync.WaitGroup = new(sync.WaitGroup) + + requestCnt := len(*form.FileContents) + wg.Add(requestCnt) + + resultQueue := make(chan *AiReviewResponse, requestCnt) + + for _, fileContent := range *form.FileContents { + go func(fileContent *api.PathContentMap) { + defer wg.Done() + result, err := aiRequester.RequestReviewToAI(ctx, &AiReviewRequest{ + Branch: branch, + TreePath: fileContent.TreePath, + Content: fileContent.Content, + }) + if err != nil { + fmt.Errorf("request to ai server fail %s", result.TreePath) + resultQueue <- nil + return + } + resultQueue <- result + }(&fileContent) + } + + wg.Wait() + close(resultQueue) + + pullID, err := strconv.ParseInt(form.PullID, 10, 64) + if err != nil { + return fmt.Errorf("pullID is invalid") + } + + return saveResults(ctx, resultQueue, pullID, adapter) +} + +func (aiService *AiServiceImpl) DeleteAiPullComment(ctx *context.Context, id int64, adapter DbAdapter) error { + + return adapter.DeleteAiPullCommentByID(ctx, id) +} + +func saveResults(ctx *context.Context, reviewResults chan *AiReviewResponse, pullID int64, adapter DbAdapter) error { + pull, err := adapter.GetIssueByID(ctx, pullID) + if err != nil { + return fmt.Errorf("pr not found by id") + } + + for result := range reviewResults { + _, err := adapter.CreateAiPullComment(ctx, &issues_model.CreateAiPullCommentOption{ + Doer: ctx.Doer, + Repo: pull.Repo, + Pull: pull, + TreePath: result.TreePath, + Content: result.Content, + }) + if err != nil { + return err + } + } + + return nil +} + +type DbAdapter interface { + GetIssueByID(ctx *context.Context, id int64) (*issues_model.Issue, error) + CreateAiPullComment(ctx *context.Context, opts *issues_model.CreateAiPullCommentOption) (*issues_model.AiPullComment, error) + DeleteAiPullCommentByID(ctx *context.Context, id int64) error +} + +type DbAdapterImpl struct{} + +func (is *DbAdapterImpl) GetIssueByID(ctx *context.Context, id int64) (*issues_model.Issue, error) { + return issues_model.GetIssueByID(ctx, id) +} + +func (is *DbAdapterImpl) CreateAiPullComment(ctx *context.Context, opts *issues_model.CreateAiPullCommentOption) (*issues_model.AiPullComment, error) { + return issues_model.CreateAiPullComment(ctx, opts) +} + +func (is *DbAdapterImpl) DeleteAiPullCommentByID(ctx *context.Context, id int64) error { + + return issues_model.DeleteAiPullCommentByID(ctx, id) + +} diff --git a/services/ai/review_test.go b/services/ai/review_test.go new file mode 100644 index 000000000..a03ff4279 --- /dev/null +++ b/services/ai/review_test.go @@ -0,0 +1,111 @@ +package ai + +import ( + "fmt" + "testing" + "time" + + "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + +) + +// MockAiRequester is a mock implementation of AiRequester +type MockAiRequester struct { + mock.Mock +} + +func (m *MockAiRequester) RequestReviewToAI(ctx *context.Context, request *AiReviewRequest) (*AiReviewResponse, error) { + time.Sleep(1000 * time.Millisecond) + args := m.Called(ctx, request) + return args.Get(0).(*AiReviewResponse), args.Error(1) +} + +// MockDbAdapter is a mock implementation of DbAdapter +type MockDbAdapter struct { + mock.Mock +} + +func (m *MockDbAdapter) GetIssueByID(ctx *context.Context, id int64) (*issues.Issue, error) { + args := m.Called(ctx, id) + return args.Get(0).(*issues.Issue), args.Error(1) +} + +func (m *MockDbAdapter) CreateAiPullComment(ctx *context.Context, opts *issues.CreateAiPullCommentOption) (*issues.AiPullComment, error) { + args := m.Called(ctx, opts) + if args.Get(0) == nil { + return nil, args.Error(1) + } + commentID := args.Get(0).(*issues.AiPullComment) + return commentID, args.Error(1) +} + +func (m *MockDbAdapter) DeleteAiPullCommentByID(ctx *context.Context, id int64) error { + args := m.Called(ctx, 1) + + return args.Error(0) +} + +func TestCreateAiPullComment(t *testing.T) { + // Set up the mock AiRequester + mockRequester := new(MockAiRequester) + aiService := &AiServiceImpl{} + + // Set up the mock DbAdapter + mockDbAdapter := new(MockDbAdapter) + + + // Mock context and form + ctx := &context.Context{} + + var fileContent *[]structs.PathContentMap = new([]structs.PathContentMap) + for i := 0; i < 100; i++ { + *fileContent = append(*fileContent, structs.PathContentMap{ + TreePath: fmt.Sprintf("file%d.go", i), + Content: fmt.Sprintf("code content %d", i), + + + }) + + mockRequester.On("RequestReviewToAI", ctx, &AiReviewRequest{ + Branch: "main", + TreePath: fmt.Sprintf("file%d.go", i), + Content: fmt.Sprintf("code content %d", i), + }).Return(&AiReviewResponse{ + Branch: "main", + TreePath: fmt.Sprintf("file%d.go", i+100), + Content: fmt.Sprintf("code content %d", i+100), + }, nil) + + } + + form := &structs.CreateAiPullCommentForm{ + PullID: "123", + Branch: "main", + FileContents: fileContent, + } + + // Mock response from AI + + + // Mock GetIssueByID + issue := &issues.Issue{} + mockDbAdapter.On("GetIssueByID", ctx, int64(123)).Return(issue, nil) + + // Mock CreateAiPullComment + comment := issues.AiPullComment{ID: 10} + mockDbAdapter.On("CreateAiPullComment", ctx, mock.Anything).Return(&comment, nil) + + // Call the method under test + err := aiService.CreateAiPullComment(ctx, form, mockRequester, mockDbAdapter) + + // Assert the expectations + assert.NoError(t, err) + mockRequester.AssertExpectations(t) + mockDbAdapter.AssertExpectations(t) +} + +// TODOC delete 테스트 diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index f49cc2e86..0b54794c3 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -621,12 +621,12 @@ func (f *MergePullRequestForm) Validate(req *http.Request, errs binding.Errors) // CodeCommentForm form for adding code comments for PRs type CodeCommentForm struct { - Origin string `binding:"Required;In(timeline,diff)"` - Content string `binding:"Required"` - Side string `binding:"Required;In(previous,proposed)"` - Line int64 - TreePath string `form:"path" binding:"Required"` - SingleReview bool `form:"single_review"` + Origin string `binding:"Required;In(timeline,diff)"` // diff인지 timeline인지 + Content string `binding:"Required"` // 리뷰 내용 들어갈 + Side string `binding:"Required;In(previous,proposed)"` // + Line int64 // 줄 수 + TreePath string `form:"path" binding:"Required"` // 해당 파일의 경로 같은 거 + SingleReview bool `form:"single_review"` // Reply int64 `form:"reply"` LatestCommitID string Files []string @@ -902,3 +902,10 @@ func (f *DeadlineForm) Validate(req *http.Request, errs binding.Errors) binding. ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } + +type AiReviewRequestForm struct { + + TreePath []string `form:"path" binding:"Required"` // 해당 파일의 경로 같은 거 + LatestCommitID string + Files []string +} diff --git a/services/pull/review.go b/services/pull/review.go index e303cd9a9..d49c698bd 100644 --- a/services/pull/review.go +++ b/services/pull/review.go @@ -89,7 +89,7 @@ func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestLis } return nil } - +// 체크 코드 코멘트에 필요한 것들이 모여있음. // CreateCodeComment creates a comment on the code line func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, line int64, content, treePath string, pendingReview bool, replyReviewID int64, latestCommitID string, attachments []string) (*issues_model.Comment, error) { var ( @@ -182,7 +182,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git. return comment, nil } - +// 체크 코드에 대한 코멘트가 작성되는 곳 // createCodeComment creates a plain code comment at the specified line / path func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content, treePath string, line, reviewID int64, attachments []string) (*issues_model.Comment, error) { var commitID, patch string diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 9ea6763a1..78cfe8503 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1009,6 +1009,41 @@ } } }, + "/ai/pull/review": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Create ai pull comment", + "operationId": "repoCreateAiPullComment", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateAiPullCommentForm" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Attachment" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/gitignore/templates": { "get": { "produces": [