Compare commits

...

10 Commits

Author SHA1 Message Date
cloudchamb3r
cdc9fda92a
Merge pull request #27 from SWM-Codection/dev
main 최신화
2024-08-02 11:15:18 +09:00
Lieslot
de827e114d
PR AI 정적 분석 기능 API 구현 (#24)
* feat: PR 리뷰 ai 정적 분석 기능 추가

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

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

* fix: ai interface server config 파일 설정 추가 및 ai interface server 요청 방식 변경
2024-08-02 10:41:38 +09:00
cloudchamb3r
88f4754d06
디스커션 API 클라이언트 구현 (#26)
* feat. add discussion clients...

* apply feedback
2024-08-01 23:14:12 +09:00
Lieslot
54407a6d0b
Merge pull request #25 from SWM-Codection/feature/discussion_server_settings
디스커션 서버 세팅 추가
2024-08-01 13:26:50 +09:00
cloudchamb3r
0390e49210 feat. add discussion_server setting configuration 2024-08-01 12:11:51 +09:00
cloudchamb3r
f9c73568c5
Feature/discussion err#11 (#18)
* add new discussions

* feat discussion 스탯 별도 처리 로직 추가
2024-07-17 14:10:26 +09:00
cloudchamb3r
b15346f4a1
change deadline confirm icon (#13) 2024-07-16 14:13:46 +09:00
cloudchamb3r
8f0af1d889
Add Discussion frontend
* Discussion 프론트엔드 기능 추가..

* remove verbose log
2024-07-11 15:46:05 +09:00
cloudchamb3r
882144d133
Merge pull request #6 from SWM-Codection/feat/issue-link#5
이슈 및 기타 리다이렉트 링크 수정
2024-07-10 14:22:08 +09:00
cloudchamb3r
385f737d20 이슈 및 기타 리다이렉트 링크 수정 2024-07-10 14:18:32 +09:00

AI 샘플 코드 생성 중입니다

Loading...
35 changed files with 946 additions and 41 deletions

15
client/base.go Normal file
View File

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

View File

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

10
go.mod
View File

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

14
go.sum
View File

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

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

@ -36,6 +36,7 @@ type IssuesOptions struct { //nolint
ProjectBoardID int64
IsClosed optional.Option[bool]
IsPull optional.Option[bool]
IsDiscussion optional.Option[bool]
LabelIDs []int64
IncludedLabelNames []string
ExcludedLabelNames []string

View File

@ -117,7 +117,13 @@ func GetIssueStats(ctx context.Context, opts *IssuesOptions) (*IssueStats, error
func getIssueStatsChunk(ctx context.Context, opts *IssuesOptions, issueIDs []int64) (*IssueStats, error) {
stats := &IssueStats{}
if opts.IsDiscussion.Has() && opts.IsDiscussion.Value() {
// dummy data for open/closed
// TODO: 백엔드 연동시 수정 필요
stats.OpenCount = 2
stats.ClosedCount = 0
return stats, nil
}
sess := db.GetEngine(ctx).
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")

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

@ -10,12 +10,14 @@ import (
)
func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
searchOpt := &SearchOptions{
Keyword: keyword,
RepoIDs: opts.RepoIDs,
AllPublic: opts.AllPublic,
IsPull: opts.IsPull,
IsClosed: opts.IsClosed,
Keyword: keyword,
RepoIDs: opts.RepoIDs,
AllPublic: opts.AllPublic,
IsPull: opts.IsPull,
IsDiscussion: opts.IsDiscussion,
IsClosed: opts.IsClosed,
}
if len(opts.LabelIDs) == 1 && opts.LabelIDs[0] == 0 {

View File

@ -79,8 +79,9 @@ type SearchOptions struct {
RepoIDs []int64 // repository IDs which the issues belong to
AllPublic bool // if include all public repositories
IsPull optional.Option[bool] // if the issues is a pull request
IsClosed optional.Option[bool] // if the issues is closed
IsPull optional.Option[bool] // if the issues is a pull request
IsDiscussion optional.Option[bool] // if the issues is a discussion
IsClosed optional.Option[bool] // if the issues is closed
IncludedLabelIDs []int64 // labels the issues have
ExcludedLabelIDs []int64 // labels the issues don't have

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

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

View File

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

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

@ -1214,6 +1214,7 @@ find_tag = Find tag
branches = Branches
tags = Tags
issues = Issues
discussions=Discussions
pulls = Pull Requests
project_board = Projects
packages = Packages
@ -1755,6 +1756,8 @@ issues.reference_link = Reference: %s
compare.compare_base = base
compare.compare_head = compare
discussions.new = New Discussion
pulls.desc = Enable pull requests and code reviews.
pulls.new = New Pull Request
pulls.new.blocked_user = Cannot create pull request because you are blocked by the repository owner.

View File

@ -110,7 +110,7 @@ license=오픈 소스
[install]
install=설치
title=초기 설정
docker_helper="Gitea를 Docker에서 실행하려면 설정 전에 이 <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">문서</a>를 읽어보세요."
docker_helper=Gitea를 Docker에서 실행하려면 설정 전에 이 <a target="_blank" rel="noopener noreferrer" href="%s">문서</a>를 읽어보세요.
db_title=데이터베이스 설정
db_type=데이터베이스 유형
host=호스트
@ -441,8 +441,8 @@ manage_gpg_keys=GPG 키 관리
add_key=키 추가
ssh_desc=이러한 SSH 공용 키는 귀하의 계정과 연결되어 있습니다. 해당 개인 키는 당신의 저장소에 대한 전체 액세스를 가능하게 합니다.
gpg_desc=이러한 GPG 공개키는 당신의 계정과 연결되어있습니다. 커밋이 검증될 수 있도록 당신의 개인 키를 안전하게 유지하십시오.
ssh_helper="<strong>도움이 필요하세요?</strong> GitHub의 설명서를 참조하시기 바랍니다: <a href=\"%s\">SSH 키 생성하기</a> 또는 SSH를 사용할 때 <a href=\"%s\">일반적인 문제</a>"
gpg_helper="<strong>도움이 필요하세요?</strong> GitHub의 설명서를 참조하시기 바랍니다: <a href=\"%s\">GPG키에 대하여</a>."
ssh_helper=<strong>도움이 필요하세요?</strong> GitHub의 설명서를 참조하시기 바랍니다: <a href="%s">SSH 키 생성하기</a> 또는 SSH를 사용할 때 <a href="%s">일반적인 문제</a>
gpg_helper=<strong>도움이 필요하세요?</strong> GitHub의 설명서를 참조하시기 바랍니다: <a href="%s">GPG키에 대하여</a>.
add_new_key=SSH 키 추가
add_new_gpg_key=GPG 키 추가
gpg_key_id_used=같은 ID의 GPG 공개키가 이미 존재합니다.
@ -547,7 +547,7 @@ template_helper=템플릿으로 저장소 만들기
visibility=가시성
visibility_helper_forced=사이트 관리자가 새 레포지토리에 대해 비공개로만 생성되도록 하였습니다.
visibility_fork_helper=(변경사항을 적용하는 경우 모든 포크가 영향을 받게 됩니다.)
clone_helper="클론하는데에 도움이 필요하면 <a target=\"_blank\" href=\"%s\">Help</a>에 방문하세요."
clone_helper=클론하는데에 도움이 필요하면 <a target="_blank" href="%s">Help</a>에 방문하세요.
fork_repo=저장소 포크
fork_from=원본 프로젝트 :
fork_visibility_helper=포크된 저장소의 가시성은 변경하실 수 없습니다.
@ -621,6 +621,7 @@ branches=브랜치
tags=태그
issues=이슈
pulls=풀 리퀘스트
discussions=토론
labels=레이블
milestones=마일스톤
@ -650,7 +651,7 @@ editor.or=혹은
editor.cancel_lower=취소
editor.commit_changes=변경 내용을 커밋
editor.commit_message_desc=선택적 확장 설명을 추가...
editor.commit_directly_to_this_branch="<strong class=\"branch-name\">%s</strong> 브랜치에서 직접 커밋해주세요."
editor.commit_directly_to_this_branch=<strong class="branch-name">%s</strong> 브랜치에서 직접 커밋해주세요.
editor.create_new_branch=이 커밋에 대한 <strong>새로운 브랜치</strong>를 만들고 끌어오기 요청을 시작합니다.
editor.new_branch_name_desc=새로운 브랜치 명...
editor.cancel=취소
@ -743,7 +744,7 @@ issues.action_milestone=마일스톤
issues.action_milestone_no_select=마일스톤 없음
issues.action_assignee=담당자
issues.action_assignee_no_select=담당자 없음
issues.opened_by="<a href=\"%[2]s\"> %[3]s</a>가 %[1]s을 오픈"
issues.opened_by=<a href="%[2]s"> %[3]s</a>가 %[1]s을 오픈
issues.previous=이전
issues.next=다음
issues.open_title=오픈
@ -761,7 +762,7 @@ issues.create_comment=코멘트
issues.commit_ref_at=` 커밋 <a id="%[1]s" href="#%[1]s">%[2]s</a>에서 이 이슈 언급`
issues.role.owner=소유자
issues.role.member=멤버
issues.sign_in_require_desc="<a href=\"%s\">로그인</a>하여 이 대화에 참여"
issues.sign_in_require_desc=<a href="%s">로그인</a>하여 이 대화에 참여
issues.edit=수정
issues.cancel=취소
issues.save=저장
@ -835,6 +836,7 @@ issues.review.reviewers=리뷰어
issues.review.show_outdated=오래된 내역 보기
issues.review.hide_outdated=오래된 내역 숨기기
discussions.new=새로운 토론
pulls.new=새 풀 리퀘스트
pulls.compare_changes=새 풀 리퀘스트
@ -842,7 +844,7 @@ pulls.compare_base=병합하기
pulls.compare_compare=다음으로부터 풀
pulls.filter_branch=Filter Branch
pulls.create=풀 리퀘스트 생성
pulls.title_desc="<code>%[2]s</code> 에서 <code id=\"branch_target\">%[3]s</code> 로 %[1]d commits 를 머지하려 합니다"
pulls.title_desc=<code>%[2]s</code> 에서 <code id="branch_target">%[3]s</code> 로 %[1]d commits 를 머지하려 합니다
pulls.merged_title_desc=<code>%[2]s</code> 에서 <code>%[3]s</code> 로 %[1]d commits 를 머지했습니다 %[4]s
pulls.tab_conversation=대화
pulls.tab_commits=커밋

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

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

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

@ -141,7 +141,17 @@ func MustAllowPulls(ctx *context.Context) {
}
}
func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) {
func MustAllowDiscussions(ctx *context.Context) {
// var repositoryId = ctx.Repo.Repository.ID
// TODO: check enable discussions && check enable Read
var check = true
if !check {
ctx.NotFound("MustAllowDiscussions", nil)
return
}
}
func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool], isDiscussionOption optional.Option[bool]) {
var err error
viewType := ctx.FormString("type")
sortType := ctx.FormString("sort")
@ -213,6 +223,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ReviewRequestedID: reviewRequestedID,
ReviewedID: reviewedID,
IsPull: isPullOption,
IsDiscussion: isDiscussionOption,
IssueIDs: nil,
}
if keyword != "" {
@ -299,6 +310,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ProjectID: projectID,
IsClosed: isShowClosed,
IsPull: isPullOption,
IsDiscussion: isDiscussionOption,
LabelIDs: labelIDs,
SortType: sortType,
})
@ -310,6 +322,8 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ctx.Data["IssueIndexerUnavailable"] = true
return
}
// TODO: GetIssuesByIDs를 디스커션도 조회하도록 만들기
issues, err = issues_model.GetIssuesByIDs(ctx, ids, true)
if err != nil {
ctx.ServerError("GetIssuesByIDs", err)
@ -486,9 +500,19 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
}
func issueIDsFromSearch(ctx *context.Context, keyword string, opts *issues_model.IssuesOptions) ([]int64, error) {
ids, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts))
if err != nil {
return nil, fmt.Errorf("SearchIssues: %w", err)
var searchOptions = issue_indexer.ToSearchOptions(keyword, opts)
var ids []int64
var err error
if !searchOptions.IsDiscussion.ValueOrDefault(false) {
ids, _, err = issue_indexer.SearchIssues(ctx, searchOptions)
if err != nil {
return nil, fmt.Errorf("SearchIssues: %w", err)
}
} else {
ids = []int64{1, 2}
// TODO: 특정 레포지토리의 디스커션의 아이디들을 반환할수 있도록 로직 작성
}
return ids, nil
}
@ -496,14 +520,26 @@ func issueIDsFromSearch(ctx *context.Context, keyword string, opts *issues_model
// Issues render issues page
func Issues(ctx *context.Context) {
isPullList := ctx.Params(":type") == "pulls"
isDiscussion := ctx.Params(":type") == "discussions"
if isPullList {
// handle pull requests
MustAllowPulls(ctx)
if ctx.Written() {
return
}
ctx.Data["Title"] = ctx.Tr("repo.pulls")
ctx.Data["PageIsPullList"] = true
} else if isDiscussion {
// handle discussions
MustAllowDiscussions(ctx)
if ctx.Written() {
return
}
ctx.Data["Title"] = ctx.Tr("repo.discussions")
ctx.Data["PageIsDiscussionList"] = true
} else {
// handle issuses
MustEnableIssues(ctx)
if ctx.Written() {
return
@ -513,7 +549,7 @@ func Issues(ctx *context.Context) {
ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
}
issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList))
issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList), optional.Some(isDiscussion))
if ctx.Written() {
return
}

View File

@ -292,7 +292,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) {
ctx.Data["Title"] = milestone.Name
ctx.Data["Milestone"] = milestone
issues(ctx, milestoneID, projectID, optional.None[bool]())
issues(ctx, milestoneID, projectID, optional.None[bool](), optional.None[bool]())
ret := issue.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0

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)
@ -1162,7 +1181,7 @@ func registerRoutes(m *web.Route) {
// end "/{username}/{reponame}": view milestone, label, issue, pull, etc
m.Group("/{username}/{reponame}", func() {
m.Group("/{type:issues|pulls}", func() {
m.Group("/{type:issues|pulls|discussions}", func() {
m.Get("", repo.Issues)
m.Group("/{index}", func() {
m.Get("", repo.ViewIssue)
@ -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

@ -152,6 +152,11 @@
{{svg "octicon-link-external"}} {{ctx.Locale.Tr "repo.issues"}}
</a>
{{end}}
<!-- discussions -->
<a class="{{if .PageIsDiscussionList}}active {{end}}item" href="{{.RepoLink}}/discussions" >
{{ svg "octicon-comment-discussion" }} {{ctx.Locale.Tr "repo.discussions"}}
</a>
{{if and .Repository.CanEnablePulls (.Permission.CanRead ctx.Consts.RepoUnitTypePullRequests)}}
<a class="{{if .PageIsPullList}}active {{end}}item" href="{{.RepoLink}}/pulls">

View File

@ -19,9 +19,20 @@
{{template "repo/issue/search" .}}
{{if not .Repository.IsArchived}}
{{if .PageIsIssueList}}
<a class="ui small primary button issue-list-new" href="{{.RepoLink}}/issues/new{{if .NewIssueChooseTemplate}}/choose{{end}}">{{ctx.Locale.Tr "repo.issues.new"}}</a>
{{else}}
<a class="ui small primary button new-pr-button issue-list-new{{if not .PullRequestCtx.Allowed}} disabled{{end}}" href="{{if .PullRequestCtx.Allowed}}{{.Repository.Link}}/compare/{{.Repository.DefaultBranch | PathEscapeSegments}}...{{if ne .Repository.Owner.Name .PullRequestCtx.BaseRepo.Owner.Name}}{{PathEscape .Repository.Owner.Name}}:{{end}}{{.Repository.DefaultBranch | PathEscapeSegments}}{{end}}">{{ctx.Locale.Tr "repo.pulls.new"}}</a>
<a class="ui small primary button issue-list-new"
href="{{.RepoLink}}/issues/new{{if .NewIssueChooseTemplate}}/choose{{end}}">
{{ctx.Locale.Tr "repo.issues.new"}}
</a>
{{else if .PageIsPullList}}
<a class="ui small primary button new-pr-button issue-list-new{{if not .PullRequestCtx.Allowed}} disabled{{end}}"
href="{{if .PullRequestCtx.Allowed}}{{.Repository.Link}}/compare/{{.Repository.DefaultBranch | PathEscapeSegments}}...{{if ne .Repository.Owner.Name .PullRequestCtx.BaseRepo.Owner.Name}}{{PathEscape .Repository.Owner.Name}}:{{end}}{{.Repository.DefaultBranch | PathEscapeSegments}}{{end}}">
{{ctx.Locale.Tr "repo.pulls.new"}}
</a>
{{else if .PageIsDiscussionList}}
<a class="ui small primary button new-discussion-button issue-list-new"
href="{{.RepoLink}}/discussions/new">
{{ctx.Locale.Tr "repo.discussions.new"}}
</a>
{{end}}
{{else}}
{{if not .PageIsIssueList}}

View File

@ -4,6 +4,8 @@
{{svg "octicon-milestone" 16 "tw-mr-2"}}
{{else if .PageIsPullList}}
{{svg "octicon-git-pull-request" 16 "tw-mr-2"}}
{{else if .PageIsDiscussionList}}
{{svg "octicon-comment-discussion" 16 "tw-mr-2"}}
{{else}}
{{svg "octicon-issue-opened" 16 "tw-mr-2"}}
{{end}}

View File

@ -387,7 +387,7 @@
{{if ne .Issue.DeadlineUnix 0}}
{{svg "octicon-pencil"}}
{{else}}
{{svg "octicon-plus"}}
{{svg "octicon-check"}}
{{end}}
</button>
</form>

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