From de827e114df1d5ff0c6b18bc985dadbbfd2eb716 Mon Sep 17 00:00:00 2001 From: Lieslot <94232383+Lieslot@users.noreply.github.com> Date: Fri, 2 Aug 2024 10:41:38 +0900 Subject: [PATCH] =?UTF-8?q?PR=20AI=20=EC=A0=95=EC=A0=81=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EA=B8=B0=EB=8A=A5=20API=20=EA=B5=AC=ED=98=84=20(#2?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: PR 리뷰 ai 정적 분석 기능 추가 * fix: CreateAiPullComment의 반환값을 *AiPullComment로 변경 * fix: review_test 모킹 타입 오류 수정 * fix: ai interface server config 파일 설정 추가 및 ai interface server 요청 방식 변경 --- go.mod | 1 + go.sum | 2 + models/fixtures/ai_pull_comment.yml | 6 + models/issues/ai_comment.go | 140 +++++++++++++++++++++++ models/issues/ai_comment_test.go | 52 +++++++++ models/issues/issue_update.go | 2 +- modules/setting/ai_server.go | 20 ++++ modules/setting/setting.go | 1 + modules/structs/ai.go | 13 +++ routers/api/v1/api.go | 2 + routers/api/v1/repo/ai_comment.go | 56 ++++++++++ routers/install/install.go | 2 + routers/web/repo/ai_review.go | 60 ++++++++++ routers/web/repo/pull_review.go | 1 + routers/web/web.go | 22 +++- services/ai/review.go | 165 ++++++++++++++++++++++++++++ services/ai/review_test.go | 111 +++++++++++++++++++ services/forms/repo_form.go | 19 +++- services/pull/review.go | 4 +- templates/swagger/v1_json.tmpl | 35 ++++++ 20 files changed, 704 insertions(+), 10 deletions(-) create mode 100644 models/fixtures/ai_pull_comment.yml create mode 100644 models/issues/ai_comment.go create mode 100644 models/issues/ai_comment_test.go create mode 100644 modules/setting/ai_server.go create mode 100644 modules/structs/ai.go create mode 100644 routers/api/v1/repo/ai_comment.go create mode 100644 routers/web/repo/ai_review.go create mode 100644 services/ai/review.go create mode 100644 services/ai/review_test.go diff --git a/go.mod b/go.mod index a520fb467..a0e88727d 100644 --- a/go.mod +++ b/go.mod @@ -268,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 6e98accd1..4088ed2ec 100644 --- a/go.sum +++ b/go.sum @@ -738,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= 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/setting.go b/modules/setting/setting.go index c08736898..c77219c4c 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -119,6 +119,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { 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 497a6e09e..f60b64eee 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -402,6 +402,8 @@ func SubmitInstall(ctx *context.Context) { // 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") 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": [