diff --git a/app/api/controller/repo/controller.go b/app/api/controller/repo/controller.go index bb445279f..5a98c4afb 100644 --- a/app/api/controller/repo/controller.go +++ b/app/api/controller/repo/controller.go @@ -25,6 +25,7 @@ import ( "github.com/harness/gitness/app/auth" "github.com/harness/gitness/app/auth/authz" "github.com/harness/gitness/app/githook" + "github.com/harness/gitness/app/services/codeowners" "github.com/harness/gitness/app/services/importer" "github.com/harness/gitness/app/services/protection" "github.com/harness/gitness/app/store" @@ -50,6 +51,7 @@ type Controller struct { protectionManager *protection.Manager gitRPCClient gitrpc.Interface importer *importer.Repository + codeOwners *codeowners.Service } func NewController( @@ -66,6 +68,7 @@ func NewController( protectionManager *protection.Manager, gitRPCClient gitrpc.Interface, importer *importer.Repository, + codeOwners *codeowners.Service, ) *Controller { return &Controller{ defaultBranch: defaultBranch, @@ -81,6 +84,7 @@ func NewController( protectionManager: protectionManager, gitRPCClient: gitRPCClient, importer: importer, + codeOwners: codeOwners, } } diff --git a/app/api/controller/repo/wire.go b/app/api/controller/repo/wire.go index 7d609c253..8a26c269b 100644 --- a/app/api/controller/repo/wire.go +++ b/app/api/controller/repo/wire.go @@ -16,6 +16,7 @@ package repo import ( "github.com/harness/gitness/app/auth/authz" + "github.com/harness/gitness/app/services/codeowners" "github.com/harness/gitness/app/services/importer" "github.com/harness/gitness/app/services/protection" "github.com/harness/gitness/app/store" @@ -37,11 +38,11 @@ func ProvideController(config *types.Config, tx dbtx.Transactor, urlProvider url uidCheck check.PathUID, authorizer authz.Authorizer, repoStore store.RepoStore, spaceStore store.SpaceStore, pipelineStore store.PipelineStore, principalStore store.PrincipalStore, ruleStore store.RuleStore, protectionManager *protection.Manager, - rpcClient gitrpc.Interface, importer *importer.Repository, + rpcClient gitrpc.Interface, importer *importer.Repository, codeOwners *codeowners.Service, ) *Controller { return NewController(config.Git.DefaultBranch, tx, urlProvider, uidCheck, authorizer, repoStore, spaceStore, pipelineStore, principalStore, ruleStore, protectionManager, - rpcClient, importer) + rpcClient, importer, codeOwners) } diff --git a/app/services/codeowners/service.go b/app/services/codeowners/service.go new file mode 100644 index 000000000..88b500db3 --- /dev/null +++ b/app/services/codeowners/service.go @@ -0,0 +1,165 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package codeowners + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + + "github.com/harness/gitness/app/store" + "github.com/harness/gitness/gitrpc" + "github.com/harness/gitness/types" +) + +const ( + // maxGetContentFileSize specifies the maximum number of bytes a file content response contains. + // If a file is any larger, the content is truncated. + maxGetContentFileSize = 1 << 20 // 1 MB +) + +type Config struct { + CodeOwnerFilePath string +} + +type Service struct { + repoStore store.RepoStore + git gitrpc.Interface + Config Config +} + +type codeOwnerFile struct { + Content string + SHA string +} + +type CodeOwners struct { + CodeOwnerFileSha string + CodeOwnerDetails []codeOwnerDetail +} + +type codeOwnerDetail struct { + Pattern string + Owners []string +} + +func New( + repoStore store.RepoStore, + git gitrpc.Interface, + config Config, +) (*Service, error) { + + service := &Service{ + repoStore: repoStore, + git: git, + Config: config, + } + return service, nil +} + +func (s *Service) Get(ctx context.Context, + repoID int64) (*CodeOwners, error) { + repo, err := s.repoStore.Find(ctx, repoID) + if err != nil { + return nil, fmt.Errorf("unable to retrieve repo %w", err) + } + codeOwnerFile, err := s.getCodeOwnerFile(ctx, repo) + if err != nil { + return nil, fmt.Errorf("unable to get codeowner details %w", err) + } + + owner, err := s.ParseCodeOwner(codeOwnerFile.Content) + if err != nil { + return nil, fmt.Errorf("unable to parse codeowner %w", err) + } + + return &CodeOwners{ + CodeOwnerFileSha: codeOwnerFile.SHA, + CodeOwnerDetails: owner, + }, nil +} + +func (s *Service) ParseCodeOwner(codeOwnersContent string) ([]codeOwnerDetail, error) { + var codeOwners []codeOwnerDetail + scanner := bufio.NewScanner(strings.NewReader(codeOwnersContent)) + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.Split(line, " ") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid line: %s", line) + } + + pattern := parts[0] + owners := parts[1:] + + codeOwner := codeOwnerDetail{ + Pattern: pattern, + Owners: owners, + } + + codeOwners = append(codeOwners, codeOwner) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading input: %v", err) + } + + return codeOwners, nil +} + +func (s *Service) getCodeOwnerFile(ctx context.Context, + repo *types.Repository, +) (*codeOwnerFile, error) { + params := gitrpc.CreateRPCReadParams(repo) + node, err := s.git.GetTreeNode(ctx, &gitrpc.GetTreeNodeParams{ + ReadParams: params, + GitREF: "refs/heads/" + repo.DefaultBranch, + Path: s.Config.CodeOwnerFilePath, + }) + + if err != nil { + // todo: check for path not found and return empty codeowners + return nil, fmt.Errorf("unable to retrieve codeowner file %w", err) + } + + if node.Node.Mode != gitrpc.TreeNodeModeFile { + return nil, fmt.Errorf("codeowner file not of right format") + } + + output, err := s.git.GetBlob(ctx, &gitrpc.GetBlobParams{ + ReadParams: params, + SHA: node.Node.SHA, + SizeLimit: maxGetContentFileSize, + }) + if err != nil { + return nil, fmt.Errorf("failed to get file content: %w", err) + } + + content, err := io.ReadAll(output.Content) + if err != nil { + return nil, fmt.Errorf("failed to read blob content: %w", err) + } + + return &codeOwnerFile{ + Content: string(content), + SHA: output.SHA, + }, nil +} diff --git a/app/services/codeowners/service_test.go b/app/services/codeowners/service_test.go new file mode 100644 index 000000000..7b45eaf52 --- /dev/null +++ b/app/services/codeowners/service_test.go @@ -0,0 +1,83 @@ +package codeowners + +import ( + "reflect" + "testing" + + "github.com/harness/gitness/app/store" + "github.com/harness/gitness/gitrpc" +) + +func TestService_ParseCodeOwner(t *testing.T) { + content1 := "**/contracts/openapi/v1/ mankrit.singh@harness.io ashish.sanodia@harness.io\n" + content2 := "**/contracts/openapi/v1/ mankrit.singh@harness.io ashish.sanodia@harness.io\n/scripts/api mankrit.singh@harness.io ashish.sanodia@harness.io" + content3 := "# codeowner file \n**/contracts/openapi/v1/ mankrit.singh@harness.io ashish.sanodia@harness.io\n#\n/scripts/api mankrit.singh@harness.io ashish.sanodia@harness.io" + type fields struct { + repoStore store.RepoStore + git gitrpc.Interface + Config Config + } + type args struct { + codeOwnersContent string + } + tests := []struct { + name string + fields fields + args args + want []codeOwnerDetail + wantErr bool + }{ + { + name: "Code owners Single", + args: args{codeOwnersContent: content1}, + want: []codeOwnerDetail{{ + Pattern: "**/contracts/openapi/v1/", + Owners: []string{"mankrit.singh@harness.io", "ashish.sanodia@harness.io"}, + }, + }, + }, + { + name: "Code owners Multiple", + args: args{codeOwnersContent: content2}, + want: []codeOwnerDetail{{ + Pattern: "**/contracts/openapi/v1/", + Owners: []string{"mankrit.singh@harness.io", "ashish.sanodia@harness.io"}, + }, + { + Pattern: "/scripts/api", + Owners: []string{"mankrit.singh@harness.io", "ashish.sanodia@harness.io"}, + }, + }, + }, + { + name: "Code owners With comments", + args: args{codeOwnersContent: content3}, + want: []codeOwnerDetail{{ + Pattern: "**/contracts/openapi/v1/", + Owners: []string{"mankrit.singh@harness.io", "ashish.sanodia@harness.io"}, + }, + { + Pattern: "/scripts/api", + Owners: []string{"mankrit.singh@harness.io", "ashish.sanodia@harness.io"}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + repoStore: tt.fields.repoStore, + git: tt.fields.git, + Config: tt.fields.Config, + } + got, err := s.ParseCodeOwner(tt.args.codeOwnersContent) + if (err != nil) != tt.wantErr { + t.Errorf("ParseCodeOwner() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseCodeOwner() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/app/services/codeowners/wire.go b/app/services/codeowners/wire.go new file mode 100644 index 000000000..e19dcbeb3 --- /dev/null +++ b/app/services/codeowners/wire.go @@ -0,0 +1,39 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package codeowners + +import ( + "github.com/harness/gitness/app/store" + "github.com/harness/gitness/gitrpc" + + "github.com/google/wire" +) + +var WireSet = wire.NewSet( + ProvideCodeOwners, +) + +func ProvideCodeOwners( + gitRPCClient gitrpc.Interface, + repoStore store.RepoStore, + config Config, +) (*Service, error) { + service, err := New(repoStore, gitRPCClient, config) + if err != nil { + return nil, err + } + + return service, nil +} diff --git a/cli/server/config.go b/cli/server/config.go index 939a170ea..450f6e181 100644 --- a/cli/server/config.go +++ b/cli/server/config.go @@ -23,6 +23,7 @@ import ( "unicode" "github.com/harness/gitness/app/services/cleanup" + "github.com/harness/gitness/app/services/codeowners" "github.com/harness/gitness/app/services/trigger" "github.com/harness/gitness/app/services/webhook" "github.com/harness/gitness/blob" @@ -334,3 +335,10 @@ func ProvideCleanupConfig(config *types.Config) cleanup.Config { WebhookExecutionsRetentionTime: config.Webhook.RetentionTime, } } + +// ProvideCodeOwnerConfig loads the codeowner config from the main config. +func ProvideCodeOwnerConfig(config *types.Config) codeowners.Config { + return codeowners.Config{ + CodeOwnerFilePath: config.CodeOwners.CodeOwnerFilePath, + } +} diff --git a/cmd/gitness/wire.go b/cmd/gitness/wire.go index 5c97ebc08..adce61759 100644 --- a/cmd/gitness/wire.go +++ b/cmd/gitness/wire.go @@ -9,8 +9,6 @@ package main import ( "context" - "github.com/harness/gitness/app/api/controller/upload" - "github.com/harness/gitness/blob" checkcontroller "github.com/harness/gitness/app/api/controller/check" "github.com/harness/gitness/app/api/controller/connector" @@ -29,6 +27,7 @@ import ( "github.com/harness/gitness/app/api/controller/system" "github.com/harness/gitness/app/api/controller/template" controllertrigger "github.com/harness/gitness/app/api/controller/trigger" + "github.com/harness/gitness/app/api/controller/upload" "github.com/harness/gitness/app/api/controller/user" controllerwebhook "github.com/harness/gitness/app/api/controller/webhook" "github.com/harness/gitness/app/auth/authn" @@ -49,6 +48,7 @@ import ( "github.com/harness/gitness/app/services" "github.com/harness/gitness/app/services/cleanup" "github.com/harness/gitness/app/services/codecomments" + "github.com/harness/gitness/app/services/codeowners" "github.com/harness/gitness/app/services/exporter" "github.com/harness/gitness/app/services/importer" "github.com/harness/gitness/app/services/job" @@ -63,6 +63,7 @@ import ( "github.com/harness/gitness/app/store/database" "github.com/harness/gitness/app/store/logs" "github.com/harness/gitness/app/url" + "github.com/harness/gitness/blob" cliserver "github.com/harness/gitness/cli/server" "github.com/harness/gitness/encrypt" "github.com/harness/gitness/events" @@ -156,6 +157,8 @@ func initSystem(ctx context.Context, config *types.Config) (*cliserver.System, e canceler.WireSet, exporter.WireSet, metric.WireSet, + cliserver.ProvideCodeOwnerConfig, + codeowners.WireSet, ) return &cliserver.System{}, nil } diff --git a/cmd/gitness/wire_gen.go b/cmd/gitness/wire_gen.go index a16e6d3e5..5546d8441 100644 --- a/cmd/gitness/wire_gen.go +++ b/cmd/gitness/wire_gen.go @@ -8,6 +8,7 @@ package main import ( "context" + check2 "github.com/harness/gitness/app/api/controller/check" "github.com/harness/gitness/app/api/controller/connector" "github.com/harness/gitness/app/api/controller/execution" @@ -46,6 +47,7 @@ import ( "github.com/harness/gitness/app/services" "github.com/harness/gitness/app/services/cleanup" "github.com/harness/gitness/app/services/codecomments" + "github.com/harness/gitness/app/services/codeowners" "github.com/harness/gitness/app/services/exporter" "github.com/harness/gitness/app/services/importer" "github.com/harness/gitness/app/services/job" @@ -73,9 +75,7 @@ import ( "github.com/harness/gitness/store/database/dbtx" "github.com/harness/gitness/types" "github.com/harness/gitness/types/check" -) -import ( _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" ) @@ -151,7 +151,12 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro if err != nil { return nil, err } - repoController := repo.ProvideController(config, transactor, provider, pathUID, authorizer, repoStore, spaceStore, pipelineStore, principalStore, ruleStore, protectionManager, gitrpcInterface, repository) + codeownersConfig := server.ProvideCodeOwnerConfig(config) + codeownersService, err := codeowners.ProvideCodeOwners(gitrpcInterface, repoStore, codeownersConfig) + if err != nil { + return nil, err + } + repoController := repo.ProvideController(config, transactor, provider, pathUID, authorizer, repoStore, spaceStore, pipelineStore, principalStore, ruleStore, protectionManager, gitrpcInterface, repository, codeownersService) executionStore := database.ProvideExecutionStore(db) checkStore := database.ProvideCheckStore(db, principalInfoCache) stageStore := database.ProvideStageStore(db) diff --git a/types/config.go b/types/config.go index 2c19ff240..d117d82bf 100644 --- a/types/config.go +++ b/types/config.go @@ -281,4 +281,8 @@ type Config struct { Endpoint string `envconfig:"GITNESS_METRIC_ENDPOINT" default:"https://stats.drone.ci/api/v1/gitness"` Token string `envconfig:"GITNESS_METRIC_TOKEN"` } + + CodeOwners struct { + CodeOwnerFilePath string `envconfig:"GITNESS_CODEOWNERS_FILEPATH" default:".gitness/CODEOWNERS"` + } }