package common /* * This file is part of Autogits. * * Copyright © 2024 SUSE LLC * * Autogits is free software: you can redistribute it and/or modify it under * the terms of the GNU General Public License as published by the Free Software * Foundation, either version 2 of the License, or (at your option) any later * version. * * Autogits is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with * Foobar. If not, see . */ import ( "encoding/base64" "encoding/json" "fmt" "io" "log" "net/url" "os" "path" "path/filepath" "slices" "time" transport "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" apiclient "src.opensuse.org/autogits/common/gitea-generated/client" "src.opensuse.org/autogits/common/gitea-generated/client/issue" "src.opensuse.org/autogits/common/gitea-generated/client/notification" "src.opensuse.org/autogits/common/gitea-generated/client/organization" "src.opensuse.org/autogits/common/gitea-generated/client/repository" "src.opensuse.org/autogits/common/gitea-generated/client/user" "src.opensuse.org/autogits/common/gitea-generated/models" ) //go:generate mockgen -source=gitea_utils.go -destination=mock/gitea_utils.go -typed // maintainer list file in ProjectGit const ( MaintainershipFile = "_maintainership.json" MaintainershipDir = "maintainership" ) const ( // from Gitea // ReviewStateApproved pr is approved ReviewStateApproved models.ReviewStateType = "APPROVED" // ReviewStatePending pr state is pending ReviewStatePending models.ReviewStateType = "PENDING" // ReviewStateComment is a comment review ReviewStateComment models.ReviewStateType = "COMMENT" // ReviewStateRequestChanges changes for pr are requested ReviewStateRequestChanges models.ReviewStateType = "REQUEST_CHANGES" // ReviewStateRequestReview review is requested from user ReviewStateRequestReview models.ReviewStateType = "REQUEST_REVIEW" // ReviewStateUnknown state of pr is unknown ReviewStateUnknown models.ReviewStateType = "" ) type GiteaTimelineFetcher interface { GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) } type GiteaComment interface { AddComment(pr *models.PullRequest, comment string) error } type GiteaSetRepoOptions interface { SetRepoOptions(owner, repo string, manual_merge bool) (*models.Repository, error) } type GiteaMaintainershipReader interface { FetchMaintainershipFile(org, prjGit, branch string) ([]byte, string, error) FetchMaintainershipDirFile(org, prjGit, branch, pkg string) ([]byte, string, error) } type GiteaPRFetcher interface { GetPullRequest(org, project string, num int64) (*models.PullRequest, error) } type GiteaPRUpdater interface { UpdatePullRequest(org, repo string, num int64, options *models.EditPullRequestOption) (*models.PullRequest, error) } type GiteaPRTimelineFetcher interface { GiteaPRFetcher GiteaTimelineFetcher } type GiteaCommitFetcher interface { GetCommit(org, repo, sha string) (*models.Commit, error) } type GiteaReviewFetcher interface { GetPullRequestReviews(org, project string, PRnum int64) ([]*models.PullReview, error) } type GiteaCommentFetcher interface { GetIssueComments(org, project string, issueNo int64) ([]*models.Comment, error) } type GiteaReviewTimelineFetcher interface { GiteaReviewFetcher GiteaTimelineFetcher } type GiteaPRChecker interface { GiteaReviewTimelineFetcher GiteaCommentFetcher GiteaMaintainershipReader } type GiteaReviewFetcherAndRequester interface { GiteaReviewTimelineFetcher GiteaCommentFetcher GiteaReviewRequester } type GiteaReviewRequester interface { RequestReviews(pr *models.PullRequest, reviewer ...string) ([]*models.PullReview, error) } type GiteaReviewUnrequester interface { UnrequestReview(org, repo string, id int64, reviwers ...string) error } type GiteaReviewer interface { AddReviewComment(pr *models.PullRequest, state models.ReviewStateType, comment string) (*models.PullReview, error) } type GiteaRepoFetcher interface { GetRepository(org, repo string) (*models.Repository, error) } type GiteaFileContentReader interface { GetRepositoryFileContent(org, repo, hash, path string) ([]byte, string, error) } const ( CommitStatus_Pending = "pending" CommitStatus_Success = "success" CommitStatus_Fail = "failure" CommitStatus_Error = "error" ) type GiteaCommitStatusSetter interface { SetCommitStatus(org, repo, hash string, status *models.CommitStatus) (*models.CommitStatus, error) } type GiteaCommitStatusGetter interface { GetCommitStatus(org, repo, hash string) ([]*models.CommitStatus, error) } type Gitea interface { GiteaComment GiteaRepoFetcher GiteaReviewRequester GiteaReviewUnrequester GiteaReviewer GiteaPRFetcher GiteaPRUpdater GiteaCommitFetcher GiteaReviewFetcher GiteaCommentFetcher GiteaTimelineFetcher GiteaMaintainershipReader GiteaFileContentReader GiteaCommitStatusGetter GiteaCommitStatusSetter GiteaSetRepoOptions GiteaTimelineFetcher GetNotifications(Type string, since *time.Time) ([]*models.NotificationThread, error) GetDoneNotifications(Type string, page int64) ([]*models.NotificationThread, error) SetNotificationRead(notificationId int64) error GetOrganization(orgName string) (*models.Organization, error) GetOrganizationRepositories(orgName string) ([]*models.Repository, error) CreateRepositoryIfNotExist(git Git, org, repoName string) (*models.Repository, error) CreatePullRequestIfNotExist(repo *models.Repository, srcId, targetId, title, body string) (*models.PullRequest, error) GetPullRequestFileContent(pr *models.PullRequest, path string) ([]byte, string, error) GetRecentPullRequests(org, repo, branch string) ([]*models.PullRequest, error) GetRecentCommits(org, repo, branch string, commitNo int64) ([]*models.Commit, error) GetPullRequests(org, project string) ([]*models.PullRequest, error) GetCurrentUser() (*models.User, error) } type GiteaTransport struct { transport *transport.Runtime client *apiclient.GiteaAPI } func AllocateGiteaTransport(giteaUrl string) Gitea { var r GiteaTransport url, err := url.Parse(giteaUrl) if err != nil { log.Panicln("Failed to parse gitea url:", err) } r.transport = transport.New(url.Host, apiclient.DefaultBasePath, [](string){url.Scheme}) r.transport.DefaultAuthentication = transport.BearerToken(giteaToken) r.client = apiclient.New(r.transport, nil) return &r } func (gitea *GiteaTransport) FetchMaintainershipFile(org, repo, branch string) ([]byte, string, error) { return gitea.GetRepositoryFileContent(org, repo, branch, MaintainershipFile) } func (gitea *GiteaTransport) FetchMaintainershipDirFile(org, repo, branch, pkg string) ([]byte, string, error) { return gitea.GetRepositoryFileContent(org, repo, branch, path.Join(MaintainershipDir, pkg)) } func (gitea *GiteaTransport) GetPullRequest(org, project string, num int64) (*models.PullRequest, error) { pr, err := gitea.client.Repository.RepoGetPullRequest( repository.NewRepoGetPullRequestParams(). WithDefaults(). WithOwner(org). WithRepo(project). WithIndex(num), gitea.transport.DefaultAuthentication, ) return pr.Payload, err } func (gitea *GiteaTransport) UpdatePullRequest(org, repo string, num int64, options *models.EditPullRequestOption) (*models.PullRequest, error) { pr, err := gitea.client.Repository.RepoEditPullRequest( repository.NewRepoEditPullRequestParams(). WithOwner(org). WithRepo(repo). WithIndex(num). WithBody(options), gitea.transport.DefaultAuthentication, ) return pr.Payload, err } func (gitea *GiteaTransport) GetPullRequests(org, repo string) ([]*models.PullRequest, error) { var page, limit int64 prs := make([]*models.PullRequest, 0) limit = 20 state := "open" for { page++ req, err := gitea.client.Repository.RepoListPullRequests( repository. NewRepoListPullRequestsParams(). WithDefaults(). WithOwner(org). WithRepo(repo). WithState(&state). WithPage(&page). WithLimit(&limit), gitea.transport.DefaultAuthentication) if err != nil { return nil, fmt.Errorf("cannot fetch PR list for %s / %s : %w", org, repo, err) } prs = slices.Concat(prs, req.Payload) if len(req.Payload) < int(limit) { break } } return prs, nil } func (gitea *GiteaTransport) GetCommitStatus(org, repo, hash string) ([]*models.CommitStatus, error) { page := int64(1) limit := int64(10) var res []*models.CommitStatus for { r, err := gitea.client.Repository.RepoListStatuses( repository.NewRepoListStatusesParams().WithDefaults().WithOwner(org).WithRepo(repo).WithSha(hash).WithPage(&page).WithLimit(&limit), gitea.transport.DefaultAuthentication) if err != nil { return res, err } res = append(res, r.Payload...) if len(r.Payload) < int(limit) { break } } return res, nil } func (gitea *GiteaTransport) SetCommitStatus(org, repo, hash string, status *models.CommitStatus) (*models.CommitStatus, error) { res, err := gitea.client.Repository.RepoCreateStatus( repository.NewRepoCreateStatusParams(). WithDefaults(). WithOwner(org). WithRepo(repo). WithSha(hash). WithBody(&models.CreateStatusOption{ TargetURL: status.TargetURL, Description: status.Description, Context: status.Context, State: models.CommitStatusState(status.Status), }), gitea.transport.DefaultAuthentication, ) if err != nil { return nil, err } return res.Payload, err } func (gitea *GiteaTransport) GetRepository(org, pkg string) (*models.Repository, error) { repo, err := gitea.client.Repository.RepoGet(repository.NewRepoGetParams().WithDefaults().WithOwner(org).WithRepo(pkg), gitea.transport.DefaultAuthentication) if err != nil { switch err.(type) { case *repository.RepoGetNotFound: return nil, nil } return nil, err } return repo.Payload, nil } func (gitea *GiteaTransport) GetPullRequestReviews(org, project string, PRnum int64) ([]*models.PullReview, error) { limit := int64(20) var page int64 var allReviews []*models.PullReview for { reviews, err := gitea.client.Repository.RepoListPullReviews( repository.NewRepoListPullReviewsParams(). WithDefaults(). WithOwner(org). WithRepo(project). WithIndex(PRnum). WithPage(&page). WithLimit(&limit), gitea.transport.DefaultAuthentication, ) if err != nil { return nil, err } allReviews = slices.Concat(allReviews, reviews.Payload) if len(reviews.Payload) < int(limit) { break } page++ } return allReviews, nil } func (gitea *GiteaTransport) GetCommit(org, repo, sha string) (*models.Commit, error) { f := false r, err := gitea.client.Repository.RepoGetSingleCommit( repository.NewRepoGetSingleCommitParams(). WithOwner(org). WithRepo(repo). WithSha(sha). WithStat(&f). WithFiles(&f). WithVerification(&f), gitea.transport.DefaultAuthentication) if err != nil { return nil, err } return r.Payload, nil } func (gitea *GiteaTransport) GetIssueComments(org, project string, issueNo int64) ([]*models.Comment, error) { // limit := int64(20) // var page int64 // var allComments []*models.Comment // for { c, err := gitea.client.Issue.IssueGetComments( issue.NewIssueGetCommentsParams(). WithDefaults(). WithOwner(org). WithRepo(project). WithIndex(issueNo), gitea.transport.DefaultAuthentication) if err != nil { return nil, err } return c.Payload, nil // if len(c.Payload) < int(limit) // } } func (gitea *GiteaTransport) SetRepoOptions(owner, repo string, manual_merge bool) (*models.Repository, error) { ok, err := gitea.client.Repository.RepoEdit(repository.NewRepoEditParams().WithOwner(owner).WithRepo(repo).WithBody( &models.EditRepoOption{ AllowManualMerge: manual_merge, AutodetectManualMerge: manual_merge, }), gitea.transport.DefaultAuthentication) if err != nil { return nil, err } return ok.Payload, err } const ( GiteaNotificationType_Pull = "Pull" ) func (gitea *GiteaTransport) GetNotifications(Type string, since *time.Time) ([]*models.NotificationThread, error) { bigLimit := int64(20) ret := make([]*models.NotificationThread, 0, 100) for page := int64(1); ; page++ { params := notification.NewNotifyGetListParams(). WithDefaults(). WithSubjectType([]string{Type}). WithStatusTypes([]string{"unread"}). WithLimit(&bigLimit). WithPage(&page) if since != nil { s := strfmt.DateTime(*since) params.SetSince(&s) } list, err := gitea.client.Notification.NotifyGetList(params, gitea.transport.DefaultAuthentication) if err != nil { return nil, err } ret = slices.Concat(ret, list.Payload) if len(list.Payload) < int(bigLimit) { break } } return ret, nil } func (gitea *GiteaTransport) GetDoneNotifications(Type string, page int64) ([]*models.NotificationThread, error) { limit := int64(20) t := true if page <= 0 { return nil, fmt.Errorf("Page is 1-base positive int...") } list, err := gitea.client.Notification.NotifyGetList( notification.NewNotifyGetListParams(). WithAll(&t). WithSubjectType([]string{Type}). WithStatusTypes([]string{"read"}). WithLimit(&limit). WithPage(&page), gitea.transport.DefaultAuthentication) if err != nil { return nil, err } return list.Payload, nil } func (gitea *GiteaTransport) SetNotificationRead(notificationId int64) error { _, err := gitea.client.Notification.NotifyReadThread( notification.NewNotifyReadThreadParams(). WithDefaults(). WithID(fmt.Sprint(notificationId)), gitea.transport.DefaultAuthentication, ) if err != nil { return fmt.Errorf("Error setting notification: %d. Err: %w", notificationId, err) } return nil } func (gitea *GiteaTransport) GetOrganization(orgName string) (*models.Organization, error) { org, err := gitea.client.Organization.OrgGet( organization.NewOrgGetParams().WithOrg(orgName), gitea.transport.DefaultAuthentication, ) if err != nil { return nil, fmt.Errorf("Error fetching org: '%s' data. Err: %w", orgName, err) } return org.Payload, nil } func (gitea *GiteaTransport) GetOrganizationRepositories(orgName string) ([]*models.Repository, error) { var page int64 repos := make([]*models.Repository, 0, 100) page = 1 for { ret, err := gitea.client.Organization.OrgListRepos( organization.NewOrgListReposParams().WithOrg(orgName).WithPage(&page), gitea.transport.DefaultAuthentication, ) if err != nil { return nil, fmt.Errorf("Error retrieving repository list for org: '%s'. Err: %w", orgName, err) } if len(ret.Payload) == 0 { break } repos = append(repos, ret.Payload...) page++ } return repos, nil } func (gitea *GiteaTransport) CreateRepositoryIfNotExist(git Git, org, repoName string) (*models.Repository, error) { log.Println(org, repoName) repo, err := gitea.client.Repository.RepoGet( repository.NewRepoGetParams().WithDefaults().WithOwner(org).WithRepo(repoName), gitea.transport.DefaultAuthentication) if err != nil { switch err.(type) { case *repository.RepoGetNotFound: log.Println("not found", err) repo, err := gitea.client.Organization.CreateOrgRepo( organization.NewCreateOrgRepoParams().WithDefaults().WithBody( &models.CreateRepoOption{ AutoInit: false, Name: &repoName, ObjectFormatName: models.CreateRepoOptionObjectFormatNameSha256, }, ).WithOrg(org), nil, ) if err != nil { switch err.(type) { case *organization.CreateOrgRepoCreated: // weird, but ok, repo created default: return nil, fmt.Errorf("error creating repo '%s' under '%s'. Err: %w", repoName, org, err) } } // initialize repository if err = os.Mkdir(filepath.Join(git.GetPath(), DefaultGitPrj), 0700); err != nil { return nil, err } if err = git.GitExec(DefaultGitPrj, "init", "--object-format="+repo.Payload.ObjectFormatName); err != nil { return nil, err } if err = git.GitExec(DefaultGitPrj, "checkout", "-b", repo.Payload.DefaultBranch); err != nil { return nil, err } readmeFilename := filepath.Join(git.GetPath(), DefaultGitPrj, "README.md") { file, _ := os.Create(readmeFilename) defer file.Close() io.WriteString(file, ReadmeBoilerplate) } if err = git.GitExec(DefaultGitPrj, "add", "README.md"); err != nil { return nil, err } if err = git.GitExec(DefaultGitPrj, "commit", "-m", "Automatic devel project creation"); err != nil { return nil, err } if err = git.GitExec(DefaultGitPrj, "remote", "add", "origin", repo.Payload.SSHURL); err != nil { return nil, err } return repo.Payload, nil default: return nil, fmt.Errorf("cannot fetch repo data for %s/%s: %w", org, repoName, err) } } return repo.Payload, nil } func (gitea *GiteaTransport) CreatePullRequestIfNotExist(repo *models.Repository, srcId, targetId, title, body string) (*models.PullRequest, error) { prOptions := models.CreatePullRequestOption{ Base: targetId, Head: srcId, Title: title, Body: body, } if pr, err := gitea.client.Repository.RepoGetPullRequestByBaseHead( repository.NewRepoGetPullRequestByBaseHeadParams().WithOwner(repo.Owner.UserName).WithRepo(repo.Name).WithBase(targetId).WithHead(srcId), gitea.transport.DefaultAuthentication, ); err == nil { return pr.Payload, nil } pr, err := gitea.client.Repository.RepoCreatePullRequest( repository. NewRepoCreatePullRequestParams(). WithDefaults(). WithOwner(repo.Owner.UserName). WithRepo(repo.Name). WithBody(&prOptions), gitea.transport.DefaultAuthentication, ) if err != nil { return nil, fmt.Errorf("Cannot create pull request. %w", err) } return pr.GetPayload(), nil } func (gitea *GiteaTransport) RequestReviews(pr *models.PullRequest, reviewers ...string) ([]*models.PullReview, error) { reviewOptions := models.PullReviewRequestOptions{ Reviewers: reviewers, } review, err := gitea.client.Repository.RepoCreatePullReviewRequests( repository. NewRepoCreatePullReviewRequestsParams(). WithOwner(pr.Base.Repo.Owner.UserName). WithRepo(pr.Base.Repo.Name). WithIndex(pr.Index). WithBody(&reviewOptions), gitea.transport.DefaultAuthentication, ) if err != nil { return nil, fmt.Errorf("Cannot create pull request reviews: %w", err) } return review.GetPayload(), nil } func (gitea *GiteaTransport) UnrequestReview(org, repo string, id int64, reviwers ...string) error { _, err := gitea.client.Repository.RepoDeletePullReviewRequests( repository.NewRepoDeletePullReviewRequestsParams().WithOwner(org).WithRepo(repo).WithIndex(id).WithBody(&models.PullReviewRequestOptions{ Reviewers: reviwers, }), gitea.transport.DefaultAuthentication) return err } func (gitea *GiteaTransport) AddReviewComment(pr *models.PullRequest, state models.ReviewStateType, comment string) (*models.PullReview, error) { c, err := gitea.client.Repository.RepoCreatePullReview( repository.NewRepoCreatePullReviewParams(). WithDefaults(). WithOwner(pr.Base.Repo.Owner.UserName). WithRepo(pr.Base.Repo.Name). WithIndex(pr.Index). WithBody(&models.CreatePullReviewOptions{ Event: state, Body: comment, }), gitea.transport.DefaultAuthentication, ) /* c, err := client.Repository.RepoSubmitPullReview( repository.NewRepoSubmitPullReviewParams(). WithDefaults(). WithOwner(pr.Base.Repo.Owner.UserName). WithRepo(pr.Base.Repo.Name). WithIndex(pr.Index). WithID(review.ID). WithBody(&models.SubmitPullReviewOptions{ Event: state, Body: comment, }), transport.DefaultAuthentication, ) */ if err != nil { return nil, err } return c.Payload, nil } func (gitea *GiteaTransport) AddComment(pr *models.PullRequest, comment string) error { _, err := gitea.client.Issue.IssueCreateComment( issue.NewIssueCreateCommentParams(). WithDefaults(). WithOwner(pr.Base.Repo.Owner.UserName). WithRepo(pr.Base.Repo.Name). WithIndex(pr.Index). WithBody(&models.CreateIssueCommentOption{ Body: &comment, }), gitea.transport.DefaultAuthentication) if err != nil { return err } return nil } func (gitea *GiteaTransport) GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) { page := int64(1) resCount := 1 retData := []*models.TimelineComment{} for resCount > 0 { res, err := gitea.client.Issue.IssueGetCommentsAndTimeline( issue.NewIssueGetCommentsAndTimelineParams(). WithOwner(org). WithRepo(repo). WithIndex(idx). WithPage(&page), gitea.transport.DefaultAuthentication, ) if err != nil { return nil, err } resCount = len(res.Payload) LogDebug("page:", page, "len:", resCount) page++ retData = append(retData, res.Payload...) } LogDebug("total results:", len(retData)) slices.SortFunc(retData, func(a, b *models.TimelineComment) int { return time.Time(b.Created).Compare(time.Time(a.Created)) }) return retData, nil } func (gitea *GiteaTransport) GetRepositoryFileContent(org, repo, hash, path string) ([]byte, string, error) { params := repository.NewRepoGetContentsParams().WithOwner(org).WithRepo(repo).WithFilepath(path) if len(hash) > 0 { params = params.WithRef(&hash) } content, err := gitea.client.Repository.RepoGetContents(params, gitea.transport.DefaultAuthentication, ) if err != nil { return nil, "", err } if content.Payload.Encoding != "base64" { return nil, "", fmt.Errorf("Unhandled content encoding: %s", content.Payload.Encoding) } if content.Payload.Size > 10000000 { return nil, "", fmt.Errorf("Content length is too large for %s/%s/%s#%s - %d bytes", org, repo, path, hash, content.Payload.Size) } data := make([]byte, content.Payload.Size) n, err := base64.StdEncoding.Decode(data, []byte(content.Payload.Content)) if err != nil { log.Println(content.Payload.Content[239]) log.Println(len(content.Payload.Content)) log.Println(string(data)) log.Println(content.Payload.Encoding) enc, _ := json.MarshalIndent(content.Payload, "", " ") log.Println(string(enc)) return nil, "", fmt.Errorf("Error decoding file %s/%s/%s#%s : %w", org, repo, path, hash, err) } if n != int(content.Payload.Size) { return nil, "", fmt.Errorf("Decoded length doesn't match expected for %s/%s/%s#%s - %d vs %d bytes", org, repo, path, hash, content.Payload.Size, n) } return data, content.Payload.SHA, nil } func (gitea *GiteaTransport) GetPullRequestFileContent(pr *models.PullRequest, path string) ([]byte, string, error) { return gitea.GetRepositoryFileContent(pr.Head.Repo.Owner.UserName, pr.Head.Repo.Name, pr.Head.Sha, path) } func (gitea *GiteaTransport) GetRecentPullRequests(org, repo, branch string) ([]*models.PullRequest, error) { prs := make([]*models.PullRequest, 0, 10) var page int64 page = 1 sort := "recentupdate" endPrs: for { res, err := gitea.client.Repository.RepoListPullRequests( repository.NewRepoListPullRequestsParams(). WithOwner(org). WithRepo(repo). WithPage(&page). WithSort(&sort), gitea.transport.DefaultAuthentication) if err != nil { return nil, err } if len(res.Payload) == 0 { break } for _, pr := range res.Payload { if pr.Base.Name != branch { continue } // if pr is closed for more than a week, assume that we are done too if pr.State == "closed" && time.Since(time.Time(pr.Updated)) > 7*24*time.Hour { break endPrs } prs = append(prs, pr) } page++ } return prs, nil } func (gitea *GiteaTransport) GetRecentCommits(org, repo, branch string, commitNo int64) ([]*models.Commit, error) { not := false var page int64 = 1 params := repository.NewRepoGetAllCommitsParams(). WithOwner(org). WithRepo(repo). WithPage(&page). WithStat(¬). WithFiles(¬). WithVerification(¬). WithLimit(&commitNo) if len(branch) > 0 { params = params.WithSha(&branch) } commits, err := gitea.client.Repository.RepoGetAllCommits(params, gitea.transport.DefaultAuthentication) if err != nil { switch err.(type) { case *repository.RepoGetAllCommitsNotFound: return nil, nil } return nil, err } return commits.Payload, nil } func (gitea *GiteaTransport) GetCurrentUser() (*models.User, error) { user, err := gitea.client.User.UserGetCurrent( user.NewUserGetCurrentParams(), gitea.transport.DefaultAuthentication, ) if err != nil { return nil, err } return user.GetPayload(), nil }