PR AI 정적 분석 기능 API 구현 (#24)

* feat: PR 리뷰 ai 정적 분석 기능 추가

* fix: CreateAiPullComment의 반환값을 *AiPullComment로 변경

* fix: review_test 모킹 타입 오류 수정

* fix: ai interface server config 파일 설정 추가 및 ai interface server 요청 방식 변경
This commit is contained in:
Lieslot 2024-08-02 10:41:38 +09:00 committed by GitHub
parent 88f4754d06
commit de827e114d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

AI 샘플 코드 생성 중입니다

Loading...
20 changed files with 704 additions and 10 deletions

1
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

View File

@ -0,0 +1,6 @@
-
id: 2 # create 전용
poster_id: 1
pull_id: 1

140
models/issues/ai_comment.go Normal file
View File

@ -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

View File

@ -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{})
}

View File

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

View File

@ -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)
}

View File

@ -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

13
modules/structs/ai.go Normal file
View File

@ -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"`
}

View File

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

View File

@ -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",
})
}

View File

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

View File

@ -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 리뷰 삭제

View File

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

View File

@ -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())

165
services/ai/review.go Normal file
View File

@ -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)
}

111
services/ai/review_test.go Normal file
View File

@ -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 테스트

View File

@ -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
}

View File

@ -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

View File

@ -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": [