List all Repos at Account level or Space Level recursively (#999)

pull/3484/head
Darko Draskovic 2024-02-21 16:59:44 +00:00 committed by Harness
parent 6f270eb3ae
commit c949308596
12 changed files with 323 additions and 105 deletions

View File

@ -27,7 +27,11 @@ import (
)
// Delete deletes a space.
func (c *Controller) Delete(ctx context.Context, session *auth.Session, spaceRef string) error {
func (c *Controller) Delete(
ctx context.Context,
session *auth.Session,
spaceRef string,
) error {
space, err := c.spaceStore.FindByRef(ctx, spaceRef)
if err != nil {
return err
@ -41,7 +45,11 @@ func (c *Controller) Delete(ctx context.Context, session *auth.Session, spaceRef
// DeleteNoAuth deletes the space - no authorization is verified.
// WARNING this is meant for internal calls only.
func (c *Controller) DeleteNoAuth(ctx context.Context, session *auth.Session, spaceID int64) error {
func (c *Controller) DeleteNoAuth(
ctx context.Context,
session *auth.Session,
spaceID int64,
) error {
filter := &types.SpaceFilter{
Page: 1,
Size: math.MaxInt,
@ -72,7 +80,11 @@ func (c *Controller) DeleteNoAuth(ctx context.Context, session *auth.Session, sp
// deleteRepositoriesNoAuth deletes all repositories in a space - no authorization is verified.
// WARNING this is meant for internal calls only.
func (c *Controller) deleteRepositoriesNoAuth(ctx context.Context, session *auth.Session, spaceID int64) error {
func (c *Controller) deleteRepositoriesNoAuth(
ctx context.Context,
session *auth.Session,
spaceID int64,
) error {
filter := &types.RepoFilter{
Page: 1,
Size: int(math.MaxInt),
@ -81,6 +93,7 @@ func (c *Controller) deleteRepositoriesNoAuth(ctx context.Context, session *auth
Sort: enum.RepoAttrNone,
DeletedBefore: nil,
}
repos, _, err := c.ListRepositoriesNoAuth(ctx, spaceID, filter)
if err != nil {
return fmt.Errorf("failed to list space repositories: %w", err)

View File

@ -25,8 +25,12 @@ import (
)
// ListRepositories lists the repositories of a space.
func (c *Controller) ListRepositories(ctx context.Context, session *auth.Session,
spaceRef string, filter *types.RepoFilter) ([]*types.Repository, int64, error) {
func (c *Controller) ListRepositories(
ctx context.Context,
session *auth.Session,
spaceRef string,
filter *types.RepoFilter,
) ([]*types.Repository, int64, error) {
space, err := c.spaceStore.FindByRef(ctx, spaceRef)
if err != nil {
return nil, 0, err
@ -35,6 +39,7 @@ func (c *Controller) ListRepositories(ctx context.Context, session *auth.Session
if err = apiauth.CheckSpace(ctx, c.authorizer, session, space, enum.PermissionRepoView, true); err != nil {
return nil, 0, err
}
return c.ListRepositoriesNoAuth(ctx, space.ID, filter)
}

View File

@ -39,13 +39,20 @@ func HandleListRepos(spaceCtrl *space.Controller) http.HandlerFunc {
filter.Order = enum.OrderAsc
}
repos, totalCount, err := spaceCtrl.ListRepositories(ctx, session, spaceRef, filter)
filter.Recursive, err = request.ParseRecursiveFromQuery(r)
if err != nil {
render.TranslatedUserError(w, err)
return
}
render.Pagination(r, w, filter.Page, filter.Size, int(totalCount))
repos, count, err := spaceCtrl.ListRepositories(
ctx, session, spaceRef, filter)
if err != nil {
render.TranslatedUserError(w, err)
return
}
render.Pagination(r, w, filter.Page, filter.Size, int(count))
render.JSON(w, http.StatusOK, repos)
}
}

View File

@ -84,6 +84,20 @@ var queryParameterQueryRepo = openapi3.ParameterOrRef{
},
}
var queryParameterRecursive = openapi3.ParameterOrRef{
Parameter: &openapi3.Parameter{
Name: request.QueryParamQuery,
In: openapi3.ParameterInQuery,
Description: ptr.String("The boolean used to do space recursive op on repos."),
Required: ptr.Bool(false),
Schema: &openapi3.SchemaOrRef{
Schema: &openapi3.Schema{
Type: ptrSchemaType(openapi3.SchemaTypeBoolean),
},
},
},
}
var queryParameterSortSpace = openapi3.ParameterOrRef{
Parameter: &openapi3.Parameter{
Name: request.QueryParamSort,
@ -268,7 +282,7 @@ func spaceOperations(reflector *openapi3.Reflector) {
opRepos.WithTags("space")
opRepos.WithMapOfAnything(map[string]interface{}{"operationId": "listRepos"})
opRepos.WithParameters(queryParameterQueryRepo, queryParameterSortRepo, queryParameterOrder,
queryParameterPage, queryParameterLimit)
queryParameterPage, queryParameterLimit, queryParameterRecursive)
_ = reflector.SetRequest(&opRepos, new(spaceRequest), http.MethodGet)
_ = reflector.SetJSONResponse(&opRepos, []types.Repository{}, http.StatusOK)
_ = reflector.SetJSONResponse(&opRepos, new(usererror.Error), http.StatusInternalServerError)

View File

@ -26,6 +26,7 @@ const (
PathParamRepoRef = "repo_ref"
QueryParamRepoID = "repo_id"
QueryParamRepoDeletedAt = "repo_deleted_at"
QueryParamRecursive = "recursive"
)
func GetRepoRefFromPath(r *http.Request) (string, error) {
@ -60,3 +61,8 @@ func ParseRepoFilter(r *http.Request) *types.RepoFilter {
func GetRepoDeletedAtFromQuery(r *http.Request) (int64, error) {
return QueryParamAsPositiveInt64(r, QueryParamRepoDeletedAt)
}
// ParseRecursiveFromQuery extracts the recursive option from the URL query.
func ParseRecursiveFromQuery(r *http.Request) (bool, error) {
return QueryParamAsBoolOrDefault(r, QueryParamRecursive, false)
}

View File

@ -219,11 +219,7 @@ type (
// Count of active repos in a space. With "DeletedBefore" filter, counts only deleted repos by opts.DeletedBefore.
Count(ctx context.Context, parentID int64, opts *types.RepoFilter) (int64, error)
// Count all active repos in a hierarchy of spaces.
CountAll(ctx context.Context, spaceID int64) (int64, error)
// List returns a list of active repos in a space.
// With "DeletedBefore" filter, shows deleted repos by opts.DeletedBefore.
// List returns a list of repos in a space.
List(ctx context.Context, parentID int64, opts *types.RepoFilter) ([]*types.Repository, error)
// ListSizeInfos returns a list of all active repo sizes.

View File

@ -29,6 +29,7 @@ import (
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"github.com/Masterminds/squirrel"
"github.com/guregu/null"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
@ -496,7 +497,22 @@ func (s *RepoStore) Restore(
// Count of active repos in a space. if parentID (space) is zero then it will count all repositories in the system.
// With "DeletedBefore" filter, counts only deleted repos by opts.DeletedBefore.
func (s *RepoStore) Count(ctx context.Context, parentID int64, opts *types.RepoFilter) (int64, error) {
func (s *RepoStore) Count(
ctx context.Context,
parentID int64,
filter *types.RepoFilter,
) (int64, error) {
if filter.Recursive {
return s.countAll(ctx, parentID, filter)
}
return s.count(ctx, parentID, filter)
}
func (s *RepoStore) count(
ctx context.Context,
parentID int64,
filter *types.RepoFilter,
) (int64, error) {
stmt := database.Builder.
Select("count(*)").
From("repositories")
@ -505,15 +521,7 @@ func (s *RepoStore) Count(ctx context.Context, parentID int64, opts *types.RepoF
stmt = stmt.Where("repo_parent_id = ?", parentID)
}
if opts.Query != "" {
stmt = stmt.Where("LOWER(repo_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(opts.Query)))
}
if opts.DeletedBefore != nil {
stmt = stmt.Where("repo_deleted < ?", opts.DeletedBefore)
} else {
stmt = stmt.Where("repo_deleted IS NULL")
}
stmt = applyQueryFilter(stmt, filter)
sql, args, err := stmt.ToSql()
if err != nil {
@ -530,8 +538,11 @@ func (s *RepoStore) Count(ctx context.Context, parentID int64, opts *types.RepoF
return count, nil
}
// Count all active repos in a hierarchy of spaces.
func (s *RepoStore) CountAll(ctx context.Context, spaceID int64) (int64, error) {
func (s *RepoStore) countAll(
ctx context.Context,
parentID int64,
filter *types.RepoFilter,
) (int64, error) {
query := `WITH RECURSIVE SpaceHierarchy AS (
SELECT space_id, space_parent_id
FROM spaces
@ -549,17 +560,24 @@ FROM SpaceHierarchy h1;`
db := dbtx.GetAccessor(ctx, s.db)
var spaceIDs []int64
if err := db.SelectContext(ctx, &spaceIDs, query, spaceID); err != nil {
if err := db.SelectContext(ctx, &spaceIDs, query, parentID); err != nil {
return 0, database.ProcessSQLErrorf(err, "failed to retrieve spaces")
}
query = fmt.Sprintf(
"SELECT COUNT(repo_id) FROM repositories WHERE repo_parent_id IN (%s) AND repo_deleted IS NULL;",
intsToCSV(spaceIDs),
)
stmt := database.Builder.
Select("COUNT(repo_id)").
From("repositories").
Where(squirrel.Eq{"repo_parent_id": spaceIDs})
stmt = applyQueryFilter(stmt, filter)
sql, args, err := stmt.ToSql()
if err != nil {
return 0, errors.Wrap(err, "Failed to convert query to sql")
}
var numRepos int64
if err := db.GetContext(ctx, &numRepos, query); err != nil {
if err := db.GetContext(ctx, &numRepos, sql, args...); err != nil {
return 0, database.ProcessSQLErrorf(err, "failed to count repositories")
}
@ -568,39 +586,29 @@ FROM SpaceHierarchy h1;`
// List returns a list of active repos in a space.
// With "DeletedBefore" filter, shows deleted repos by opts.DeletedBefore.
func (s *RepoStore) List(ctx context.Context, parentID int64, opts *types.RepoFilter) ([]*types.Repository, error) {
func (s *RepoStore) List(
ctx context.Context,
parentID int64,
filter *types.RepoFilter,
) ([]*types.Repository, error) {
if filter.Recursive {
return s.listAll(ctx, parentID, filter)
}
return s.list(ctx, parentID, filter)
}
func (s *RepoStore) list(
ctx context.Context,
parentID int64,
filter *types.RepoFilter,
) ([]*types.Repository, error) {
stmt := database.Builder.
Select(repoColumnsForJoin).
From("repositories").
Where("repo_parent_id = ?", fmt.Sprint(parentID))
if opts.DeletedBefore != nil {
stmt = stmt.Where("repo_deleted < ?", opts.DeletedBefore)
} else {
stmt = stmt.Where("repo_deleted IS NULL")
}
if opts.Query != "" {
stmt = stmt.Where("LOWER(repo_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(opts.Query)))
}
stmt = stmt.Limit(database.Limit(opts.Size))
stmt = stmt.Offset(database.Offset(opts.Page, opts.Size))
switch opts.Sort {
// TODO [CODE-1363]: remove after identifier migration.
case enum.RepoAttrUID, enum.RepoAttrIdentifier, enum.RepoAttrNone:
// NOTE: string concatenation is safe because the
// order attribute is an enum and is not user-defined,
// and is therefore not subject to injection attacks.
stmt = stmt.OrderBy("repo_importing desc, repo_uid " + opts.Order.String())
case enum.RepoAttrCreated:
stmt = stmt.OrderBy("repo_created " + opts.Order.String())
case enum.RepoAttrUpdated:
stmt = stmt.OrderBy("repo_updated " + opts.Order.String())
case enum.RepoAttrDeleted:
stmt = stmt.OrderBy("repo_deleted " + opts.Order.String())
}
stmt = applyQueryFilter(stmt, filter)
stmt = applySortFilter(stmt, filter)
sql, args, err := stmt.ToSql()
if err != nil {
@ -617,6 +625,52 @@ func (s *RepoStore) List(ctx context.Context, parentID int64, opts *types.RepoFi
return s.mapToRepos(ctx, dst)
}
func (s *RepoStore) listAll(
ctx context.Context,
parentID int64,
filter *types.RepoFilter,
) ([]*types.Repository, error) {
where := `WITH RECURSIVE SpaceHierarchy AS (
SELECT space_id, space_parent_id
FROM spaces
WHERE space_id = $1
UNION
SELECT s.space_id, s.space_parent_id
FROM spaces s
JOIN SpaceHierarchy h ON s.space_parent_id = h.space_id
)
SELECT space_id
FROM SpaceHierarchy h1;`
db := dbtx.GetAccessor(ctx, s.db)
var spaceIDs []int64
if err := db.SelectContext(ctx, &spaceIDs, where, parentID); err != nil {
return nil, database.ProcessSQLErrorf(err, "failed to retrieve spaces")
}
stmt := database.Builder.
Select(repoColumnsForJoin).
From("repositories").
Where(squirrel.Eq{"repo_parent_id": spaceIDs})
stmt = applyQueryFilter(stmt, filter)
stmt = applySortFilter(stmt, filter)
sql, args, err := stmt.ToSql()
if err != nil {
return nil, errors.Wrap(err, "Failed to convert query to sql")
}
repos := []*repository{}
if err := db.SelectContext(ctx, &repos, sql, args...); err != nil {
return nil, database.ProcessSQLErrorf(err, "failed to count repositories")
}
return s.mapToRepos(ctx, repos)
}
type repoSize struct {
ID int64 `db:"repo_id"`
GitUID string `db:"repo_git_uid"`
@ -755,10 +809,36 @@ func mapToInternalRepo(in *types.Repository) *repository {
}
}
func intsToCSV(elems []int64) string {
strSlice := make([]string, len(elems))
for i, num := range elems {
strSlice[i] = fmt.Sprint(num)
func applyQueryFilter(stmt squirrel.SelectBuilder, filter *types.RepoFilter) squirrel.SelectBuilder {
if filter.Query != "" {
stmt = stmt.Where("LOWER(repo_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(filter.Query)))
}
return strings.Join(strSlice, ",")
if filter.DeletedBefore != nil {
stmt = stmt.Where("repo_deleted < ?", filter.DeletedBefore)
} else {
stmt = stmt.Where("repo_deleted IS NULL")
}
return stmt
}
func applySortFilter(stmt squirrel.SelectBuilder, filter *types.RepoFilter) squirrel.SelectBuilder {
stmt = stmt.Limit(database.Limit(filter.Size))
stmt = stmt.Offset(database.Offset(filter.Page, filter.Size))
switch filter.Sort {
// TODO [CODE-1363]: remove after identifier migration.
case enum.RepoAttrUID, enum.RepoAttrIdentifier, enum.RepoAttrNone:
// NOTE: string concatenation is safe because the
// order attribute is an enum and is not user-defined,
// and is therefore not subject to injection attacks.
stmt = stmt.OrderBy("repo_importing desc, repo_uid " + filter.Order.String())
case enum.RepoAttrCreated:
stmt = stmt.OrderBy("repo_created " + filter.Order.String())
case enum.RepoAttrUpdated:
stmt = stmt.OrderBy("repo_updated " + filter.Order.String())
case enum.RepoAttrDeleted:
}
return stmt
}

View File

@ -16,15 +16,18 @@ package database_test
import (
"context"
"math/rand"
"strconv"
"testing"
"github.com/harness/gitness/app/store"
"github.com/harness/gitness/app/store/database"
"github.com/harness/gitness/types"
)
const repoSize = int64(100)
const (
numTestRepos = 10
repoSize = int64(100)
)
func TestDatabase_GetSize(t *testing.T) {
db, teardown := setupDB(t)
@ -34,11 +37,11 @@ func TestDatabase_GetSize(t *testing.T) {
ctx := context.Background()
createUser(t, &ctx, principalStore, 1)
createSpace(t, &ctx, spaceStore, spacePathStore, userID, 1, 0)
createUser(ctx, t, principalStore)
createSpace(ctx, t, spaceStore, spacePathStore, userID, 1, 0)
repoID := int64(1)
createRepo(t, &ctx, repoStore, repoID, 1, repoSize)
createRepo(ctx, t, repoStore, repoID, 1, repoSize)
tests := []struct {
name string
@ -76,7 +79,7 @@ func TestDatabase_GetSize(t *testing.T) {
}
}
func TestDatabase_CountAll(t *testing.T) {
func TestDatabase_Count(t *testing.T) {
db, teardown := setupDB(t)
defer teardown()
@ -84,24 +87,12 @@ func TestDatabase_CountAll(t *testing.T) {
ctx := context.Background()
createUser(t, &ctx, principalStore, 1)
createUser(ctx, t, principalStore)
var numRepos int64
spaceTree, numSpaces := createSpaceTree()
createSpace(t, &ctx, spaceStore, spacePathStore, userID, 1, 0)
for i := 1; i < numSpaces; i++ {
parentID := int64(i)
for _, spaceID := range spaceTree[parentID] {
createSpace(t, &ctx, spaceStore, spacePathStore, userID, spaceID, parentID)
createSpace(ctx, t, spaceStore, spacePathStore, userID, 1, 0)
numRepos := createRepos(ctx, t, repoStore, 0, numTestRepos, 1)
for j := 0; j < rand.Intn(4); j++ {
numRepos++
createRepo(t, &ctx, repoStore, numRepos, spaceID, 0)
}
}
}
count, err := repoStore.CountAll(ctx, 1)
count, err := repoStore.Count(ctx, 1, &types.RepoFilter{})
if err != nil {
t.Fatalf("failed to count repos %v", err)
}
@ -110,9 +101,85 @@ func TestDatabase_CountAll(t *testing.T) {
}
}
func TestDatabase_CountAll(t *testing.T) {
db, teardown := setupDB(t)
defer teardown()
principalStore, spaceStore, spacePathStore, repoStore := setupStores(t, db)
ctx := context.Background()
createUser(ctx, t, principalStore)
numSpaces := createNestedSpaces(ctx, t, spaceStore, spacePathStore)
var numRepos int64
for i := 1; i <= numSpaces; i++ {
numRepos += createRepos(ctx, t, repoStore, numRepos, numTestRepos/2, int64(i))
}
count, err := repoStore.Count(ctx, 1, &types.RepoFilter{Recursive: true})
if err != nil {
t.Fatalf("failed to count repos %v", err)
}
if count != numRepos {
t.Errorf("count = %v, want %v", count, numRepos)
}
}
func TestDatabase_List(t *testing.T) {
db, teardown := setupDB(t)
defer teardown()
principalStore, spaceStore, spacePathStore, repoStore := setupStores(t, db)
ctx := context.Background()
createUser(ctx, t, principalStore)
createSpace(ctx, t, spaceStore, spacePathStore, userID, 1, 0)
numRepos := createRepos(ctx, t, repoStore, 0, numTestRepos, 1)
repos, err := repoStore.List(ctx, 1, &types.RepoFilter{})
if err != nil {
t.Fatalf("failed to count repos %v", err)
}
lenRepos := int64(len(repos))
if lenRepos != numRepos {
t.Errorf("count = %v, want %v", lenRepos, numRepos)
}
}
func TestDatabase_ListAll(t *testing.T) {
db, teardown := setupDB(t)
defer teardown()
principalStore, spaceStore, spacePathStore, repoStore := setupStores(t, db)
ctx := context.Background()
createUser(ctx, t, principalStore)
numSpaces := createNestedSpaces(ctx, t, spaceStore, spacePathStore)
var numRepos int64
for i := 1; i <= numSpaces; i++ {
numRepos += createRepos(ctx, t, repoStore, numRepos, numTestRepos/2, int64(i))
}
repos, err := repoStore.List(ctx, 1,
&types.RepoFilter{Size: numSpaces * numTestRepos, Recursive: true})
if err != nil {
t.Fatalf("failed to count repos %v", err)
}
lenRepos := int64(len(repos))
if lenRepos != numRepos {
t.Errorf("count = %v, want %v", lenRepos, numRepos)
}
}
func createRepo(
ctx context.Context,
t *testing.T,
ctx *context.Context,
repoStore *database.RepoStore,
id int64,
spaceID int64,
@ -122,7 +189,45 @@ func createRepo(
identifier := "repo_" + strconv.FormatInt(id, 10)
repo := types.Repository{Identifier: identifier, ID: id, ParentID: spaceID, GitUID: identifier, Size: size}
if err := repoStore.Create(*ctx, &repo); err != nil {
if err := repoStore.Create(ctx, &repo); err != nil {
t.Fatalf("failed to create repo %v", err)
}
}
func createRepos(
ctx context.Context,
t *testing.T,
repoStore *database.RepoStore,
numCreatedRepos int64,
numReposToCreate int64,
spaceID int64,
) int64 {
t.Helper()
var numRepos int64
for j := 0; j < int(numReposToCreate); j++ {
// numCreatedRepos+numRepos ensures the uniqueness of the repo id
createRepo(ctx, t, repoStore, numCreatedRepos+numRepos, spaceID, 0)
numRepos++
}
return numRepos
}
func createNestedSpaces(
ctx context.Context,
t *testing.T,
spaceStore *database.SpaceStore,
spacePathStore store.SpacePathStore,
) int {
t.Helper()
spaceTree, numSpaces := createSpaceTree()
createSpace(ctx, t, spaceStore, spacePathStore, userID, 1, 0)
for i := 1; i < numSpaces; i++ {
parentID := int64(i)
for _, spaceID := range spaceTree[parentID] {
createSpace(ctx, t, spaceStore, spacePathStore, userID, spaceID, parentID)
}
}
return numSpaces
}

View File

@ -84,23 +84,22 @@ func setupStores(t *testing.T, db *sqlx.DB) (
}
func createUser(
ctx context.Context,
t *testing.T,
ctx *context.Context,
principalStore *database.PrincipalStore,
userID int64,
) {
t.Helper()
uid := "user_" + strconv.FormatInt(userID, 10)
if err := principalStore.CreateUser(*ctx,
if err := principalStore.CreateUser(ctx,
&types.User{ID: userID, UID: uid}); err != nil {
t.Fatalf("failed to create user %v", err)
}
}
func createSpace(
ctx context.Context,
t *testing.T,
ctx *context.Context,
spaceStore *database.SpaceStore,
spacePathStore store.SpacePathStore,
userID int64,
@ -112,11 +111,11 @@ func createSpace(
identifier := "space_" + strconv.FormatInt(spaceID, 10)
space := types.Space{ID: spaceID, Identifier: identifier, CreatedBy: userID, ParentID: parentID}
if err := spaceStore.Create(*ctx, &space); err != nil {
if err := spaceStore.Create(ctx, &space); err != nil {
t.Fatalf("failed to create space %v", err)
}
if err := spacePathStore.InsertSegment(*ctx, &types.SpacePathSegment{
if err := spacePathStore.InsertSegment(ctx, &types.SpacePathSegment{
ID: space.ID, Identifier: identifier, CreatedBy: userID, SpaceID: spaceID, IsPrimary: true,
}); err != nil {
t.Fatalf("failed to insert segment %v", err)

View File

@ -27,17 +27,9 @@ func TestDatabase_GetRootSpace(t *testing.T) {
ctx := context.Background()
createUser(t, &ctx, principalStore, 1)
createUser(ctx, t, principalStore)
spaceTree, numSpaces := createSpaceTree()
createSpace(t, &ctx, spaceStore, spacePathStore, userID, 1, 0)
for i := 1; i < numSpaces; i++ {
parentID := int64(i)
for _, spaceID := range spaceTree[parentID] {
createSpace(t, &ctx, spaceStore, spacePathStore, 1, spaceID, parentID)
}
}
numSpaces := createNestedSpaces(ctx, t, spaceStore, spacePathStore)
for i := 1; i <= numSpaces; i++ {
rootSpc, err := spaceStore.GetRootSpace(ctx, int64(i))

View File

@ -36,7 +36,7 @@ func (e Order) String() string {
case OrderAsc:
return asc
case OrderDefault:
return defaultString
return desc
default:
return undefined
}

View File

@ -87,6 +87,7 @@ type RepoFilter struct {
Sort enum.RepoAttr `json:"sort"`
Order enum.Order `json:"order"`
DeletedBefore *int64 `json:"deleted_before,omitempty"`
Recursive bool
}
// RepositoryGitInfo holds git info for a repository.