forked from gitea/gitea
		
	Repository transfer has to be confirmed, if user can not create repo for new owner (#14792)
* make repo as "pending transfer" if on transfer start doer has no right to create repo in new destination * if new pending transfer ocured, create UI & Mail notifications
This commit is contained in:
		
							parent
							
								
									e0900310c4
								
							
						
					
					
						commit
						a4148c0f12
					
				| @ -444,12 +444,22 @@ func TestAPIRepoTransfer(t *testing.T) { | ||||
| 		teams          *[]int64 | ||||
| 		expectedStatus int | ||||
| 	}{ | ||||
| 		{ctxUserID: 1, newOwner: "user2", teams: nil, expectedStatus: http.StatusAccepted}, | ||||
| 		{ctxUserID: 2, newOwner: "user1", teams: nil, expectedStatus: http.StatusAccepted}, | ||||
| 		{ctxUserID: 2, newOwner: "user6", teams: nil, expectedStatus: http.StatusForbidden}, | ||||
| 		{ctxUserID: 1, newOwner: "user2", teams: &[]int64{2}, expectedStatus: http.StatusUnprocessableEntity}, | ||||
| 		// Disclaimer for test story: "user1" is an admin, "user2" is normal user and part of in owner team of org "user3" | ||||
| 
 | ||||
| 		// Transfer to a user with teams in another org should fail | ||||
| 		{ctxUserID: 1, newOwner: "user3", teams: &[]int64{5}, expectedStatus: http.StatusForbidden}, | ||||
| 		// Transfer to a user with non-existent team IDs should fail | ||||
| 		{ctxUserID: 1, newOwner: "user2", teams: &[]int64{2}, expectedStatus: http.StatusUnprocessableEntity}, | ||||
| 		// Transfer should go through | ||||
| 		{ctxUserID: 1, newOwner: "user3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted}, | ||||
| 		// Let user transfer it back to himself | ||||
| 		{ctxUserID: 2, newOwner: "user2", expectedStatus: http.StatusAccepted}, | ||||
| 		// And revert transfer | ||||
| 		{ctxUserID: 2, newOwner: "user3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted}, | ||||
| 		// Cannot start transfer to an existing repo | ||||
| 		{ctxUserID: 2, newOwner: "user3", teams: nil, expectedStatus: http.StatusUnprocessableEntity}, | ||||
| 		// Start transfer, repo is now in pending transfer mode | ||||
| 		{ctxUserID: 2, newOwner: "user6", teams: nil, expectedStatus: http.StatusCreated}, | ||||
| 	} | ||||
| 
 | ||||
| 	defer prepareTestEnv(t)() | ||||
|  | ||||
| @ -757,6 +757,40 @@ func (err ErrRepoNotExist) Error() string { | ||||
| 		err.ID, err.UID, err.OwnerName, err.Name) | ||||
| } | ||||
| 
 | ||||
| // ErrNoPendingRepoTransfer is an error type for repositories without a pending | ||||
| // transfer request | ||||
| type ErrNoPendingRepoTransfer struct { | ||||
| 	RepoID int64 | ||||
| } | ||||
| 
 | ||||
| func (e ErrNoPendingRepoTransfer) Error() string { | ||||
| 	return fmt.Sprintf("repository doesn't have a pending transfer [repo_id: %d]", e.RepoID) | ||||
| } | ||||
| 
 | ||||
| // IsErrNoPendingTransfer is an error type when a repository has no pending | ||||
| // transfers | ||||
| func IsErrNoPendingTransfer(err error) bool { | ||||
| 	_, ok := err.(ErrNoPendingRepoTransfer) | ||||
| 	return ok | ||||
| } | ||||
| 
 | ||||
| // ErrRepoTransferInProgress represents the state of a repository that has an | ||||
| // ongoing transfer | ||||
| type ErrRepoTransferInProgress struct { | ||||
| 	Uname string | ||||
| 	Name  string | ||||
| } | ||||
| 
 | ||||
| // IsErrRepoTransferInProgress checks if an error is a ErrRepoTransferInProgress. | ||||
| func IsErrRepoTransferInProgress(err error) bool { | ||||
| 	_, ok := err.(ErrRepoTransferInProgress) | ||||
| 	return ok | ||||
| } | ||||
| 
 | ||||
| func (err ErrRepoTransferInProgress) Error() string { | ||||
| 	return fmt.Sprintf("repository is already being transferred [uname: %s, name: %s]", err.Uname, err.Name) | ||||
| } | ||||
| 
 | ||||
| // ErrRepoAlreadyExist represents a "RepoAlreadyExist" kind of error. | ||||
| type ErrRepoAlreadyExist struct { | ||||
| 	Uname string | ||||
|  | ||||
							
								
								
									
										7
									
								
								models/fixtures/repo_transfer.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								models/fixtures/repo_transfer.yml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| - | ||||
|   id: 1 | ||||
|   doer_id: 3 | ||||
|   recipient_id: 1 | ||||
|   repo_id: 3 | ||||
|   created_unix: 1553610671 | ||||
|   updated_unix: 1553610671 | ||||
| @ -563,7 +563,7 @@ func (issue *Issue) ReadBy(userID int64) error { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	return setNotificationStatusReadIfUnread(x, userID, issue.ID) | ||||
| 	return setIssueNotificationStatusReadIfUnread(x, userID, issue.ID) | ||||
| } | ||||
| 
 | ||||
| func updateIssueCols(e Engine, issue *Issue, cols ...string) error { | ||||
|  | ||||
| @ -294,6 +294,8 @@ var migrations = []Migration{ | ||||
| 	NewMigration("Add sessions table for go-chi/session", addSessionTable), | ||||
| 	// v173 -> v174 | ||||
| 	NewMigration("Add time_id column to Comment", addTimeIDCommentColumn), | ||||
| 	// v174 -> v175 | ||||
| 	NewMigration("create repo transfer table", addRepoTransfer), | ||||
| } | ||||
| 
 | ||||
| // GetCurrentDBVersion returns the current db version | ||||
|  | ||||
							
								
								
									
										23
									
								
								models/migrations/v174.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								models/migrations/v174.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| // Copyright 2021 The Gitea Authors. All rights reserved. | ||||
| // Use of this source code is governed by a MIT-style | ||||
| // license that can be found in the LICENSE file. | ||||
| 
 | ||||
| package migrations | ||||
| 
 | ||||
| import ( | ||||
| 	"xorm.io/xorm" | ||||
| ) | ||||
| 
 | ||||
| func addRepoTransfer(x *xorm.Engine) error { | ||||
| 	type RepoTransfer struct { | ||||
| 		ID          int64 `xorm:"pk autoincr"` | ||||
| 		DoerID      int64 | ||||
| 		RecipientID int64 | ||||
| 		RepoID      int64 | ||||
| 		TeamIDs     []int64 | ||||
| 		CreatedUnix int64 `xorm:"INDEX NOT NULL created"` | ||||
| 		UpdatedUnix int64 `xorm:"INDEX NOT NULL updated"` | ||||
| 	} | ||||
| 
 | ||||
| 	return x.Sync(new(RepoTransfer)) | ||||
| } | ||||
| @ -133,6 +133,7 @@ func init() { | ||||
| 		new(ProjectBoard), | ||||
| 		new(ProjectIssue), | ||||
| 		new(Session), | ||||
| 		new(RepoTransfer), | ||||
| 	) | ||||
| 
 | ||||
| 	gonicNames := []string{"SSL", "UID"} | ||||
|  | ||||
| @ -39,6 +39,8 @@ const ( | ||||
| 	NotificationSourcePullRequest | ||||
| 	// NotificationSourceCommit is a notification of a commit | ||||
| 	NotificationSourceCommit | ||||
| 	// NotificationSourceRepository is a notification for a repository | ||||
| 	NotificationSourceRepository | ||||
| ) | ||||
| 
 | ||||
| // Notification represents a notification | ||||
| @ -119,6 +121,46 @@ func GetNotifications(opts FindNotificationOptions) (NotificationList, error) { | ||||
| 	return getNotifications(x, opts) | ||||
| } | ||||
| 
 | ||||
| // CreateRepoTransferNotification creates  notification for the user a repository was transferred to | ||||
| func CreateRepoTransferNotification(doer, newOwner *User, repo *Repository) error { | ||||
| 	sess := x.NewSession() | ||||
| 	defer sess.Close() | ||||
| 	if err := sess.Begin(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	var notify []*Notification | ||||
| 
 | ||||
| 	if newOwner.IsOrganization() { | ||||
| 		users, err := getUsersWhoCanCreateOrgRepo(sess, newOwner.ID) | ||||
| 		if err != nil || len(users) == 0 { | ||||
| 			return err | ||||
| 		} | ||||
| 		for i := range users { | ||||
| 			notify = append(notify, &Notification{ | ||||
| 				UserID:    users[i].ID, | ||||
| 				RepoID:    repo.ID, | ||||
| 				Status:    NotificationStatusUnread, | ||||
| 				UpdatedBy: doer.ID, | ||||
| 				Source:    NotificationSourceRepository, | ||||
| 			}) | ||||
| 		} | ||||
| 	} else { | ||||
| 		notify = []*Notification{{ | ||||
| 			UserID:    newOwner.ID, | ||||
| 			RepoID:    repo.ID, | ||||
| 			Status:    NotificationStatusUnread, | ||||
| 			UpdatedBy: doer.ID, | ||||
| 			Source:    NotificationSourceRepository, | ||||
| 		}} | ||||
| 	} | ||||
| 
 | ||||
| 	if _, err := sess.InsertMulti(notify); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	return sess.Commit() | ||||
| } | ||||
| 
 | ||||
| // CreateOrUpdateIssueNotifications creates an issue notification | ||||
| // for each watcher, or updates it if already exists | ||||
| // receiverID > 0 just send to reciver, else send to all watcher | ||||
| @ -363,7 +405,7 @@ func (n *Notification) loadRepo(e Engine) (err error) { | ||||
| } | ||||
| 
 | ||||
| func (n *Notification) loadIssue(e Engine) (err error) { | ||||
| 	if n.Issue == nil { | ||||
| 	if n.Issue == nil && n.IssueID != 0 { | ||||
| 		n.Issue, err = getIssueByID(e, n.IssueID) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("getIssueByID [%d]: %v", n.IssueID, err) | ||||
| @ -374,7 +416,7 @@ func (n *Notification) loadIssue(e Engine) (err error) { | ||||
| } | ||||
| 
 | ||||
| func (n *Notification) loadComment(e Engine) (err error) { | ||||
| 	if n.Comment == nil && n.CommentID > 0 { | ||||
| 	if n.Comment == nil && n.CommentID != 0 { | ||||
| 		n.Comment, err = getCommentByID(e, n.CommentID) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("GetCommentByID [%d] for issue ID [%d]: %v", n.CommentID, n.IssueID, err) | ||||
| @ -405,10 +447,18 @@ func (n *Notification) GetIssue() (*Issue, error) { | ||||
| 
 | ||||
| // HTMLURL formats a URL-string to the notification | ||||
| func (n *Notification) HTMLURL() string { | ||||
| 	if n.Comment != nil { | ||||
| 		return n.Comment.HTMLURL() | ||||
| 	switch n.Source { | ||||
| 	case NotificationSourceIssue, NotificationSourcePullRequest: | ||||
| 		if n.Comment != nil { | ||||
| 			return n.Comment.HTMLURL() | ||||
| 		} | ||||
| 		return n.Issue.HTMLURL() | ||||
| 	case NotificationSourceCommit: | ||||
| 		return n.Repository.HTMLURL() + "/commit/" + n.CommitID | ||||
| 	case NotificationSourceRepository: | ||||
| 		return n.Repository.HTMLURL() | ||||
| 	} | ||||
| 	return n.Issue.HTMLURL() | ||||
| 	return "" | ||||
| } | ||||
| 
 | ||||
| // APIURL formats a URL-string to the notification | ||||
| @ -562,8 +612,10 @@ func (nl NotificationList) LoadIssues() ([]int, error) { | ||||
| 		if notification.Issue == nil { | ||||
| 			notification.Issue = issues[notification.IssueID] | ||||
| 			if notification.Issue == nil { | ||||
| 				log.Error("Notification[%d]: IssueID: %d Not Found", notification.ID, notification.IssueID) | ||||
| 				failures = append(failures, i) | ||||
| 				if notification.IssueID != 0 { | ||||
| 					log.Error("Notification[%d]: IssueID: %d Not Found", notification.ID, notification.IssueID) | ||||
| 					failures = append(failures, i) | ||||
| 				} | ||||
| 				continue | ||||
| 			} | ||||
| 			notification.Issue.Repo = notification.Repository | ||||
| @ -683,7 +735,7 @@ func GetUIDsAndNotificationCounts(since, until timeutil.TimeStamp) ([]UserIDCoun | ||||
| 	return res, x.SQL(sql, since, until, NotificationStatusUnread).Find(&res) | ||||
| } | ||||
| 
 | ||||
| func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error { | ||||
| func setIssueNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error { | ||||
| 	notification, err := getIssueNotification(e, userID, issueID) | ||||
| 	// ignore if not exists | ||||
| 	if err != nil { | ||||
| @ -700,6 +752,16 @@ func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error { | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| func setRepoNotificationStatusReadIfUnread(e Engine, userID, repoID int64) error { | ||||
| 	_, err := e.Where(builder.Eq{ | ||||
| 		"user_id": userID, | ||||
| 		"status":  NotificationStatusUnread, | ||||
| 		"source":  NotificationSourceRepository, | ||||
| 		"repo_id": repoID, | ||||
| 	}).Cols("status").Update(&Notification{Status: NotificationStatusRead}) | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| // SetNotificationStatus change the notification status | ||||
| func SetNotificationStatus(notificationID int64, user *User, status NotificationStatus) error { | ||||
| 	notification, err := getNotificationByID(x, notificationID) | ||||
|  | ||||
| @ -391,6 +391,20 @@ func CanCreateOrgRepo(orgID, uid int64) (bool, error) { | ||||
| 		Exist(new(Team)) | ||||
| } | ||||
| 
 | ||||
| // GetUsersWhoCanCreateOrgRepo returns users which are able to create repo in organization | ||||
| func GetUsersWhoCanCreateOrgRepo(orgID int64) ([]*User, error) { | ||||
| 	return getUsersWhoCanCreateOrgRepo(x, orgID) | ||||
| } | ||||
| 
 | ||||
| func getUsersWhoCanCreateOrgRepo(e Engine, orgID int64) ([]*User, error) { | ||||
| 	users := make([]*User, 0, 10) | ||||
| 	return users, x. | ||||
| 		Join("INNER", "`team_user`", "`team_user`.uid=`user`.id"). | ||||
| 		Join("INNER", "`team`", "`team`.id=`team_user`.team_id"). | ||||
| 		Where(builder.Eq{"team.can_create_org_repo": true}.Or(builder.Eq{"team.authorize": AccessModeOwner})). | ||||
| 		And("team_user.org_id = ?", orgID).Asc("`user`.name").Find(&users) | ||||
| } | ||||
| 
 | ||||
| func getOrgsByUserID(sess *xorm.Session, userID int64, showAll bool) ([]*User, error) { | ||||
| 	orgs := make([]*User, 0, 10) | ||||
| 	if !showAll { | ||||
|  | ||||
| @ -635,3 +635,21 @@ func TestHasOrgVisibleTypePrivate(t *testing.T) { | ||||
| 	assert.Equal(t, test2, false) // user not a part of org | ||||
| 	assert.Equal(t, test3, false) // logged out user | ||||
| } | ||||
| 
 | ||||
| func TestGetUsersWhoCanCreateOrgRepo(t *testing.T) { | ||||
| 	assert.NoError(t, PrepareTestDatabase()) | ||||
| 
 | ||||
| 	users, err := GetUsersWhoCanCreateOrgRepo(3) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.Len(t, users, 2) | ||||
| 	var ids []int64 | ||||
| 	for i := range users { | ||||
| 		ids = append(ids, users[i].ID) | ||||
| 	} | ||||
| 	assert.ElementsMatch(t, ids, []int64{2, 28}) | ||||
| 
 | ||||
| 	users, err = GetUsersWhoCanCreateOrgRepo(7) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.Len(t, users, 1) | ||||
| 	assert.EqualValues(t, 5, users[0].ID) | ||||
| } | ||||
|  | ||||
							
								
								
									
										144
									
								
								models/repo.go
									
									
									
									
									
								
							
							
						
						
									
										144
									
								
								models/repo.go
									
									
									
									
									
								
							| @ -139,8 +139,9 @@ type RepositoryStatus int | ||||
| 
 | ||||
| // all kinds of RepositoryStatus | ||||
| const ( | ||||
| 	RepositoryReady         RepositoryStatus = iota // a normal repository | ||||
| 	RepositoryBeingMigrated                         // repository is migrating | ||||
| 	RepositoryReady           RepositoryStatus = iota // a normal repository | ||||
| 	RepositoryBeingMigrated                           // repository is migrating | ||||
| 	RepositoryPendingTransfer                         // repository pending in ownership transfer state | ||||
| ) | ||||
| 
 | ||||
| // TrustModelType defines the types of trust model for this repository | ||||
| @ -872,6 +873,11 @@ func (repo *Repository) DescriptionHTML() template.HTML { | ||||
| 	return template.HTML(markup.Sanitize(string(desc))) | ||||
| } | ||||
| 
 | ||||
| // ReadBy sets repo to be visited by given user. | ||||
| func (repo *Repository) ReadBy(userID int64) error { | ||||
| 	return setRepoNotificationStatusReadIfUnread(x, userID, repo.ID) | ||||
| } | ||||
| 
 | ||||
| func isRepositoryExist(e Engine, u *User, repoName string) (bool, error) { | ||||
| 	has, err := e.Get(&Repository{ | ||||
| 		OwnerID:   u.ID, | ||||
| @ -1189,140 +1195,6 @@ func IncrementRepoForkNum(ctx DBContext, repoID int64) error { | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| // TransferOwnership transfers all corresponding setting from old user to new one. | ||||
| func TransferOwnership(doer *User, newOwnerName string, repo *Repository) error { | ||||
| 	newOwner, err := GetUserByName(newOwnerName) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("get new owner '%s': %v", newOwnerName, err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Check if new owner has repository with same name. | ||||
| 	has, err := IsRepositoryExist(newOwner, repo.Name) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("IsRepositoryExist: %v", err) | ||||
| 	} else if has { | ||||
| 		return ErrRepoAlreadyExist{newOwnerName, repo.Name} | ||||
| 	} | ||||
| 
 | ||||
| 	sess := x.NewSession() | ||||
| 	defer sess.Close() | ||||
| 	if err = sess.Begin(); err != nil { | ||||
| 		return fmt.Errorf("sess.Begin: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	oldOwner := repo.Owner | ||||
| 
 | ||||
| 	// Note: we have to set value here to make sure recalculate accesses is based on | ||||
| 	// new owner. | ||||
| 	repo.OwnerID = newOwner.ID | ||||
| 	repo.Owner = newOwner | ||||
| 	repo.OwnerName = newOwner.Name | ||||
| 
 | ||||
| 	// Update repository. | ||||
| 	if _, err := sess.ID(repo.ID).Update(repo); err != nil { | ||||
| 		return fmt.Errorf("update owner: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Remove redundant collaborators. | ||||
| 	collaborators, err := repo.getCollaborators(sess, ListOptions{}) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("getCollaborators: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Dummy object. | ||||
| 	collaboration := &Collaboration{RepoID: repo.ID} | ||||
| 	for _, c := range collaborators { | ||||
| 		if c.ID != newOwner.ID { | ||||
| 			isMember, err := isOrganizationMember(sess, newOwner.ID, c.ID) | ||||
| 			if err != nil { | ||||
| 				return fmt.Errorf("IsOrgMember: %v", err) | ||||
| 			} else if !isMember { | ||||
| 				continue | ||||
| 			} | ||||
| 		} | ||||
| 		collaboration.UserID = c.ID | ||||
| 		if _, err = sess.Delete(collaboration); err != nil { | ||||
| 			return fmt.Errorf("remove collaborator '%d': %v", c.ID, err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Remove old team-repository relations. | ||||
| 	if oldOwner.IsOrganization() { | ||||
| 		if err = oldOwner.removeOrgRepo(sess, repo.ID); err != nil { | ||||
| 			return fmt.Errorf("removeOrgRepo: %v", err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if newOwner.IsOrganization() { | ||||
| 		if err := newOwner.getTeams(sess); err != nil { | ||||
| 			return fmt.Errorf("GetTeams: %v", err) | ||||
| 		} | ||||
| 		for _, t := range newOwner.Teams { | ||||
| 			if t.IncludesAllRepositories { | ||||
| 				if err := t.addRepository(sess, repo); err != nil { | ||||
| 					return fmt.Errorf("addRepository: %v", err) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} else if err = repo.recalculateAccesses(sess); err != nil { | ||||
| 		// Organization called this in addRepository method. | ||||
| 		return fmt.Errorf("recalculateAccesses: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Update repository count. | ||||
| 	if _, err = sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil { | ||||
| 		return fmt.Errorf("increase new owner repository count: %v", err) | ||||
| 	} else if _, err = sess.Exec("UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", oldOwner.ID); err != nil { | ||||
| 		return fmt.Errorf("decrease old owner repository count: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if err = watchRepo(sess, doer.ID, repo.ID, true); err != nil { | ||||
| 		return fmt.Errorf("watchRepo: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Remove watch for organization. | ||||
| 	if oldOwner.IsOrganization() { | ||||
| 		if err = watchRepo(sess, oldOwner.ID, repo.ID, false); err != nil { | ||||
| 			return fmt.Errorf("watchRepo [false]: %v", err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Rename remote repository to new path and delete local copy. | ||||
| 	dir := UserPath(newOwner.Name) | ||||
| 
 | ||||
| 	if err := os.MkdirAll(dir, os.ModePerm); err != nil { | ||||
| 		return fmt.Errorf("Failed to create dir %s: %v", dir, err) | ||||
| 	} | ||||
| 
 | ||||
| 	if err = os.Rename(RepoPath(oldOwner.Name, repo.Name), RepoPath(newOwner.Name, repo.Name)); err != nil { | ||||
| 		return fmt.Errorf("rename repository directory: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Rename remote wiki repository to new path and delete local copy. | ||||
| 	wikiPath := WikiPath(oldOwner.Name, repo.Name) | ||||
| 	isExist, err := util.IsExist(wikiPath) | ||||
| 	if err != nil { | ||||
| 		log.Error("Unable to check if %s exists. Error: %v", wikiPath, err) | ||||
| 		return err | ||||
| 	} | ||||
| 	if isExist { | ||||
| 		if err = os.Rename(wikiPath, WikiPath(newOwner.Name, repo.Name)); err != nil { | ||||
| 			return fmt.Errorf("rename repository wiki: %v", err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// If there was previously a redirect at this location, remove it. | ||||
| 	if err = deleteRepoRedirect(sess, newOwner.ID, repo.Name); err != nil { | ||||
| 		return fmt.Errorf("delete repo redirect: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if err := newRepoRedirect(sess, oldOwner.ID, repo.ID, repo.Name, repo.Name); err != nil { | ||||
| 		return fmt.Errorf("newRepoRedirect: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return sess.Commit() | ||||
| } | ||||
| 
 | ||||
| // ChangeRepositoryName changes all corresponding setting from old repository name to new one. | ||||
| func ChangeRepositoryName(doer *User, repo *Repository, newRepoName string) (err error) { | ||||
| 	oldRepoName := repo.Name | ||||
|  | ||||
							
								
								
									
										335
									
								
								models/repo_transfer.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										335
									
								
								models/repo_transfer.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,335 @@ | ||||
| // Copyright 2021 The Gitea Authors. All rights reserved. | ||||
| // Use of this source code is governed by a MIT-style | ||||
| // license that can be found in the LICENSE file. | ||||
| 
 | ||||
| package models | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 
 | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/timeutil" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| ) | ||||
| 
 | ||||
| // RepoTransfer is used to manage repository transfers | ||||
| type RepoTransfer struct { | ||||
| 	ID          int64 `xorm:"pk autoincr"` | ||||
| 	DoerID      int64 | ||||
| 	Doer        *User `xorm:"-"` | ||||
| 	RecipientID int64 | ||||
| 	Recipient   *User `xorm:"-"` | ||||
| 	RepoID      int64 | ||||
| 	TeamIDs     []int64 | ||||
| 	Teams       []*Team `xorm:"-"` | ||||
| 
 | ||||
| 	CreatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL created"` | ||||
| 	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL updated"` | ||||
| } | ||||
| 
 | ||||
| // LoadAttributes fetches the transfer recipient from the database | ||||
| func (r *RepoTransfer) LoadAttributes() error { | ||||
| 	if r.Recipient == nil { | ||||
| 		u, err := GetUserByID(r.RecipientID) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		r.Recipient = u | ||||
| 	} | ||||
| 
 | ||||
| 	if r.Recipient.IsOrganization() && len(r.TeamIDs) != len(r.Teams) { | ||||
| 
 | ||||
| 		for _, v := range r.TeamIDs { | ||||
| 			team, err := GetTeamByID(v) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 
 | ||||
| 			if team.OrgID != r.Recipient.ID { | ||||
| 				return fmt.Errorf("team %d belongs not to org %d", v, r.Recipient.ID) | ||||
| 			} | ||||
| 
 | ||||
| 			r.Teams = append(r.Teams, team) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if r.Doer == nil { | ||||
| 		u, err := GetUserByID(r.DoerID) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		r.Doer = u | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // CanUserAcceptTransfer checks if the user has the rights to accept/decline a repo transfer. | ||||
| // For user, it checks if it's himself | ||||
| // For organizations, it checks if the user is able to create repos | ||||
| func (r *RepoTransfer) CanUserAcceptTransfer(u *User) bool { | ||||
| 	if err := r.LoadAttributes(); err != nil { | ||||
| 		log.Error("LoadAttributes: %v", err) | ||||
| 		return false | ||||
| 	} | ||||
| 
 | ||||
| 	if !r.Recipient.IsOrganization() { | ||||
| 		return r.RecipientID == u.ID | ||||
| 	} | ||||
| 
 | ||||
| 	allowed, err := CanCreateOrgRepo(r.RecipientID, u.ID) | ||||
| 	if err != nil { | ||||
| 		log.Error("CanCreateOrgRepo: %v", err) | ||||
| 		return false | ||||
| 	} | ||||
| 
 | ||||
| 	return allowed | ||||
| } | ||||
| 
 | ||||
| // GetPendingRepositoryTransfer fetches the most recent and ongoing transfer | ||||
| // process for the repository | ||||
| func GetPendingRepositoryTransfer(repo *Repository) (*RepoTransfer, error) { | ||||
| 	var transfer = new(RepoTransfer) | ||||
| 
 | ||||
| 	has, err := x.Where("repo_id = ? ", repo.ID).Get(transfer) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	if !has { | ||||
| 		return nil, ErrNoPendingRepoTransfer{RepoID: repo.ID} | ||||
| 	} | ||||
| 
 | ||||
| 	return transfer, nil | ||||
| } | ||||
| 
 | ||||
| func deleteRepositoryTransfer(e Engine, repoID int64) error { | ||||
| 	_, err := e.Where("repo_id = ?", repoID).Delete(&RepoTransfer{}) | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| // CancelRepositoryTransfer marks the repository as ready and remove pending transfer entry, | ||||
| // thus cancel the transfer process. | ||||
| func CancelRepositoryTransfer(repo *Repository) error { | ||||
| 	sess := x.NewSession() | ||||
| 	defer sess.Close() | ||||
| 	if err := sess.Begin(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	repo.Status = RepositoryReady | ||||
| 	if err := updateRepositoryCols(sess, repo, "status"); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if err := deleteRepositoryTransfer(sess, repo.ID); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	return sess.Commit() | ||||
| } | ||||
| 
 | ||||
| // TestRepositoryReadyForTransfer make sure repo is ready to transfer | ||||
| func TestRepositoryReadyForTransfer(status RepositoryStatus) error { | ||||
| 	switch status { | ||||
| 	case RepositoryBeingMigrated: | ||||
| 		return fmt.Errorf("repo is not ready, currently migrating") | ||||
| 	case RepositoryPendingTransfer: | ||||
| 		return ErrRepoTransferInProgress{} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // CreatePendingRepositoryTransfer transfer a repo from one owner to a new one. | ||||
| // it marks the repository transfer as "pending" | ||||
| func CreatePendingRepositoryTransfer(doer, newOwner *User, repoID int64, teams []*Team) error { | ||||
| 	sess := x.NewSession() | ||||
| 	defer sess.Close() | ||||
| 	if err := sess.Begin(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	repo, err := getRepositoryByID(sess, repoID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// Make sure repo is ready to transfer | ||||
| 	if err := TestRepositoryReadyForTransfer(repo.Status); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	repo.Status = RepositoryPendingTransfer | ||||
| 	if err := updateRepositoryCols(sess, repo, "status"); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// Check if new owner has repository with same name. | ||||
| 	if has, err := isRepositoryExist(sess, newOwner, repo.Name); err != nil { | ||||
| 		return fmt.Errorf("IsRepositoryExist: %v", err) | ||||
| 	} else if has { | ||||
| 		return ErrRepoAlreadyExist{newOwner.LowerName, repo.Name} | ||||
| 	} | ||||
| 
 | ||||
| 	transfer := &RepoTransfer{ | ||||
| 		RepoID:      repo.ID, | ||||
| 		RecipientID: newOwner.ID, | ||||
| 		CreatedUnix: timeutil.TimeStampNow(), | ||||
| 		UpdatedUnix: timeutil.TimeStampNow(), | ||||
| 		DoerID:      doer.ID, | ||||
| 		TeamIDs:     make([]int64, 0, len(teams)), | ||||
| 	} | ||||
| 
 | ||||
| 	for k := range teams { | ||||
| 		transfer.TeamIDs = append(transfer.TeamIDs, teams[k].ID) | ||||
| 	} | ||||
| 
 | ||||
| 	if _, err := sess.Insert(transfer); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	return sess.Commit() | ||||
| } | ||||
| 
 | ||||
| // TransferOwnership transfers all corresponding repository items from old user to new one. | ||||
| func TransferOwnership(doer *User, newOwnerName string, repo *Repository) error { | ||||
| 	sess := x.NewSession() | ||||
| 	defer sess.Close() | ||||
| 	if err := sess.Begin(); err != nil { | ||||
| 		return fmt.Errorf("sess.Begin: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	newOwner, err := getUserByName(sess, newOwnerName) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("get new owner '%s': %v", newOwnerName, err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Check if new owner has repository with same name. | ||||
| 	if has, err := isRepositoryExist(sess, newOwner, repo.Name); err != nil { | ||||
| 		return fmt.Errorf("IsRepositoryExist: %v", err) | ||||
| 	} else if has { | ||||
| 		return ErrRepoAlreadyExist{newOwnerName, repo.Name} | ||||
| 	} | ||||
| 
 | ||||
| 	oldOwner := repo.Owner | ||||
| 
 | ||||
| 	// Note: we have to set value here to make sure recalculate accesses is based on | ||||
| 	// new owner. | ||||
| 	repo.OwnerID = newOwner.ID | ||||
| 	repo.Owner = newOwner | ||||
| 	repo.OwnerName = newOwner.Name | ||||
| 
 | ||||
| 	// Update repository. | ||||
| 	if _, err := sess.ID(repo.ID).Update(repo); err != nil { | ||||
| 		return fmt.Errorf("update owner: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Remove redundant collaborators. | ||||
| 	collaborators, err := repo.getCollaborators(sess, ListOptions{}) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("getCollaborators: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Dummy object. | ||||
| 	collaboration := &Collaboration{RepoID: repo.ID} | ||||
| 	for _, c := range collaborators { | ||||
| 		if c.ID != newOwner.ID { | ||||
| 			isMember, err := isOrganizationMember(sess, newOwner.ID, c.ID) | ||||
| 			if err != nil { | ||||
| 				return fmt.Errorf("IsOrgMember: %v", err) | ||||
| 			} else if !isMember { | ||||
| 				continue | ||||
| 			} | ||||
| 		} | ||||
| 		collaboration.UserID = c.ID | ||||
| 		if _, err := sess.Delete(collaboration); err != nil { | ||||
| 			return fmt.Errorf("remove collaborator '%d': %v", c.ID, err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Remove old team-repository relations. | ||||
| 	if oldOwner.IsOrganization() { | ||||
| 		if err := oldOwner.removeOrgRepo(sess, repo.ID); err != nil { | ||||
| 			return fmt.Errorf("removeOrgRepo: %v", err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if newOwner.IsOrganization() { | ||||
| 		if err := newOwner.getTeams(sess); err != nil { | ||||
| 			return fmt.Errorf("GetTeams: %v", err) | ||||
| 		} | ||||
| 		for _, t := range newOwner.Teams { | ||||
| 			if t.IncludesAllRepositories { | ||||
| 				if err := t.addRepository(sess, repo); err != nil { | ||||
| 					return fmt.Errorf("addRepository: %v", err) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} else if err := repo.recalculateAccesses(sess); err != nil { | ||||
| 		// Organization called this in addRepository method. | ||||
| 		return fmt.Errorf("recalculateAccesses: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Update repository count. | ||||
| 	if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil { | ||||
| 		return fmt.Errorf("increase new owner repository count: %v", err) | ||||
| 	} else if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", oldOwner.ID); err != nil { | ||||
| 		return fmt.Errorf("decrease old owner repository count: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if err := watchRepo(sess, doer.ID, repo.ID, true); err != nil { | ||||
| 		return fmt.Errorf("watchRepo: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Remove watch for organization. | ||||
| 	if oldOwner.IsOrganization() { | ||||
| 		if err := watchRepo(sess, oldOwner.ID, repo.ID, false); err != nil { | ||||
| 			return fmt.Errorf("watchRepo [false]: %v", err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Rename remote repository to new path and delete local copy. | ||||
| 	dir := UserPath(newOwner.Name) | ||||
| 
 | ||||
| 	if err := os.MkdirAll(dir, os.ModePerm); err != nil { | ||||
| 		return fmt.Errorf("Failed to create dir %s: %v", dir, err) | ||||
| 	} | ||||
| 
 | ||||
| 	if err := os.Rename(RepoPath(oldOwner.Name, repo.Name), RepoPath(newOwner.Name, repo.Name)); err != nil { | ||||
| 		return fmt.Errorf("rename repository directory: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Rename remote wiki repository to new path and delete local copy. | ||||
| 	wikiPath := WikiPath(oldOwner.Name, repo.Name) | ||||
| 
 | ||||
| 	if isExist, err := util.IsExist(wikiPath); err != nil { | ||||
| 		log.Error("Unable to check if %s exists. Error: %v", wikiPath, err) | ||||
| 		return err | ||||
| 	} else if isExist { | ||||
| 		if err := os.Rename(wikiPath, WikiPath(newOwner.Name, repo.Name)); err != nil { | ||||
| 			return fmt.Errorf("rename repository wiki: %v", err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if err := deleteRepositoryTransfer(sess, repo.ID); err != nil { | ||||
| 		return fmt.Errorf("deleteRepositoryTransfer: %v", err) | ||||
| 	} | ||||
| 	repo.Status = RepositoryReady | ||||
| 	if err := updateRepositoryCols(sess, repo, "status"); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// If there was previously a redirect at this location, remove it. | ||||
| 	if err := deleteRepoRedirect(sess, newOwner.ID, repo.Name); err != nil { | ||||
| 		return fmt.Errorf("delete repo redirect: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if err := newRepoRedirect(sess, oldOwner.ID, repo.ID, repo.Name, repo.Name); err != nil { | ||||
| 		return fmt.Errorf("newRepoRedirect: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return sess.Commit() | ||||
| } | ||||
							
								
								
									
										54
									
								
								models/repo_transfer_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								models/repo_transfer_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,54 @@ | ||||
| // Copyright 2021 The Gitea Authors. All rights reserved. | ||||
| // Use of this source code is governed by a MIT-style | ||||
| // license that can be found in the LICENSE file. | ||||
| 
 | ||||
| package models | ||||
| 
 | ||||
| import ( | ||||
| 	"testing" | ||||
| 
 | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
| 
 | ||||
| func TestRepositoryTransfer(t *testing.T) { | ||||
| 
 | ||||
| 	assert.NoError(t, PrepareTestDatabase()) | ||||
| 
 | ||||
| 	doer := AssertExistsAndLoadBean(t, &User{ID: 3}).(*User) | ||||
| 	repo := AssertExistsAndLoadBean(t, &Repository{ID: 3}).(*Repository) | ||||
| 
 | ||||
| 	transfer, err := GetPendingRepositoryTransfer(repo) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.NotNil(t, transfer) | ||||
| 
 | ||||
| 	// Cancel transfer | ||||
| 	assert.NoError(t, CancelRepositoryTransfer(repo)) | ||||
| 
 | ||||
| 	transfer, err = GetPendingRepositoryTransfer(repo) | ||||
| 	assert.Error(t, err) | ||||
| 	assert.Nil(t, transfer) | ||||
| 	assert.True(t, IsErrNoPendingTransfer(err)) | ||||
| 
 | ||||
| 	user2 := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) | ||||
| 
 | ||||
| 	assert.NoError(t, CreatePendingRepositoryTransfer(doer, user2, repo.ID, nil)) | ||||
| 
 | ||||
| 	transfer, err = GetPendingRepositoryTransfer(repo) | ||||
| 	assert.Nil(t, err) | ||||
| 	assert.NoError(t, transfer.LoadAttributes()) | ||||
| 	assert.Equal(t, "user2", transfer.Recipient.Name) | ||||
| 
 | ||||
| 	user6 := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) | ||||
| 
 | ||||
| 	// Only transfer can be started at any given time | ||||
| 	err = CreatePendingRepositoryTransfer(doer, user6, repo.ID, nil) | ||||
| 	assert.Error(t, err) | ||||
| 	assert.True(t, IsErrRepoTransferInProgress(err)) | ||||
| 
 | ||||
| 	// Unknown user | ||||
| 	err = CreatePendingRepositoryTransfer(doer, &User{ID: 1000, LowerName: "user1000"}, repo.ID, nil) | ||||
| 	assert.Error(t, err) | ||||
| 
 | ||||
| 	// Cancel transfer | ||||
| 	assert.NoError(t, CancelRepositoryTransfer(repo)) | ||||
| } | ||||
| @ -600,6 +600,24 @@ func RepoAssignment() func(http.Handler) http.Handler { | ||||
| 			ctx.Data["CanCompareOrPull"] = canCompare | ||||
| 			ctx.Data["PullRequestCtx"] = ctx.Repo.PullRequest | ||||
| 
 | ||||
| 			if ctx.Repo.Repository.Status == models.RepositoryPendingTransfer { | ||||
| 				repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) | ||||
| 				if err != nil { | ||||
| 					ctx.ServerError("GetPendingRepositoryTransfer", err) | ||||
| 					return | ||||
| 				} | ||||
| 
 | ||||
| 				if err := repoTransfer.LoadAttributes(); err != nil { | ||||
| 					ctx.ServerError("LoadRecipient", err) | ||||
| 					return | ||||
| 				} | ||||
| 
 | ||||
| 				ctx.Data["RepoTransfer"] = repoTransfer | ||||
| 				if ctx.User != nil { | ||||
| 					ctx.Data["CanUserAcceptTransfer"] = repoTransfer.CanUserAcceptTransfer(ctx.User) | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			if ctx.Query("go-get") == "1" { | ||||
| 				ctx.Data["GoGetImport"] = ComposeGoGetImport(owner.Name, repo.Name) | ||||
| 				prefix := setting.AppURL + path.Join(owner.Name, repo.Name, "src", "branch", ctx.Repo.BranchName) | ||||
|  | ||||
| @ -52,8 +52,14 @@ func ToNotificationThread(n *models.Notification) *api.NotificationThread { | ||||
| 		result.Subject = &api.NotificationSubject{ | ||||
| 			Type:  "Commit", | ||||
| 			Title: n.CommitID, | ||||
| 			URL:   n.Repository.HTMLURL() + "/commit/" + n.CommitID, | ||||
| 		} | ||||
| 	case models.NotificationSourceRepository: | ||||
| 		result.Subject = &api.NotificationSubject{ | ||||
| 			Type:  "Repository", | ||||
| 			Title: n.Repository.FullName(), | ||||
| 			URL:   n.Repository.Link(), | ||||
| 		} | ||||
| 		//unused until now | ||||
| 	} | ||||
| 
 | ||||
| 	return result | ||||
|  | ||||
| @ -57,4 +57,6 @@ type Notifier interface { | ||||
| 	NotifySyncPushCommits(pusher *models.User, repo *models.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) | ||||
| 	NotifySyncCreateRef(doer *models.User, repo *models.Repository, refType, refFullName string) | ||||
| 	NotifySyncDeleteRef(doer *models.User, repo *models.Repository, refType, refFullName string) | ||||
| 
 | ||||
| 	NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) | ||||
| } | ||||
|  | ||||
| @ -166,3 +166,7 @@ func (*NullNotifier) NotifySyncCreateRef(doer *models.User, repo *models.Reposit | ||||
| // NotifySyncDeleteRef places a place holder function | ||||
| func (*NullNotifier) NotifySyncDeleteRef(doer *models.User, repo *models.Repository, refType, refFullName string) { | ||||
| } | ||||
| 
 | ||||
| // NotifyRepoPendingTransfer places a place holder function | ||||
| func (*NullNotifier) NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) { | ||||
| } | ||||
|  | ||||
| @ -170,3 +170,9 @@ func (m *mailNotifier) NotifyNewRelease(rel *models.Release) { | ||||
| 
 | ||||
| 	mailer.MailNewRelease(rel) | ||||
| } | ||||
| 
 | ||||
| func (m *mailNotifier) NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) { | ||||
| 	if err := mailer.SendRepoTransferNotifyMail(doer, newOwner, repo); err != nil { | ||||
| 		log.Error("NotifyRepoPendingTransfer: %v", err) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -290,3 +290,10 @@ func NotifySyncDeleteRef(pusher *models.User, repo *models.Repository, refType, | ||||
| 		notifier.NotifySyncDeleteRef(pusher, repo, refType, refFullName) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // NotifyRepoPendingTransfer notifies creation of pending transfer to notifiers | ||||
| func NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) { | ||||
| 	for _, notifier := range notifiers { | ||||
| 		notifier.NotifyRepoPendingTransfer(doer, newOwner, repo) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -201,3 +201,9 @@ func (ns *notificationService) NotifyPullReviewRequest(doer *models.User, issue | ||||
| 		_ = ns.issueQueue.Push(opts) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (ns *notificationService) NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) { | ||||
| 	if err := models.CreateRepoTransferNotification(doer, newOwner, repo); err != nil { | ||||
| 		log.Error("NotifyRepoPendingTransfer: %v", err) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -735,6 +735,13 @@ delete_preexisting = Delete pre-existing files | ||||
| delete_preexisting_content = Delete files in %s | ||||
| delete_preexisting_success = Deleted unadopted files in %s | ||||
| 
 | ||||
| transfer.accept = Accept Transfer | ||||
| transfer.accept_desc =  Transfer to "%s" | ||||
| transfer.reject = Reject Transfer | ||||
| transfer.reject_desc =  Cancel transfer to "%s" | ||||
| transfer.no_permission_to_accept = You do not have permission to Accept | ||||
| transfer.no_permission_to_reject = You do not have permission to Reject | ||||
| 
 | ||||
| desc.private = Private | ||||
| desc.public = Public | ||||
| desc.private_template = Private template | ||||
| @ -1554,10 +1561,20 @@ settings.convert_fork_notices_1 = This operation will convert the fork into a re | ||||
| settings.convert_fork_confirm = Convert Repository | ||||
| settings.convert_fork_succeed = The fork has been converted into a regular repository. | ||||
| settings.transfer = Transfer Ownership | ||||
| settings.transfer.rejected = Repository transfer was rejected. | ||||
| settings.transfer.success = Repository transfer was successful. | ||||
| settings.transfer_abort = Cancel transfer | ||||
| settings.transfer_abort_invalid = You cannot cancel a non existent repository transfer. | ||||
| settings.transfer_abort_success = The repository transfer to %s was successfully cancelled. | ||||
| settings.transfer_desc = Transfer this repository to a user or to an organization for which you have administrator rights. | ||||
| settings.transfer_form_title = Enter the repository name as confirmation: | ||||
| settings.transfer_in_progress = There is currently an ongoing transfer. Please cancel it if you will like to transfer this repository to another user. | ||||
| settings.transfer_notices_1 = - You will lose access to the repository if you transfer it to an individual user. | ||||
| settings.transfer_notices_2 = - You will keep access to the repository if you transfer it to an organization that you (co-)own. | ||||
| settings.transfer_form_title = Enter the repository name as confirmation: | ||||
| settings.transfer_owner = New Owner | ||||
| settings.transfer_perform = Perform Transfer | ||||
| settings.transfer_started = This repository has been marked for transfer and awaits confirmation from "%s" | ||||
| settings.transfer_succeed = The repository has been transferred. | ||||
| settings.signing_settings = Signing Verification Settings | ||||
| settings.trust_model = Signature Trust Model | ||||
| settings.trust_model.default = Default Trust Model | ||||
| @ -1583,9 +1600,6 @@ settings.delete_notices_2 = - This operation will permanently delete the <strong | ||||
| settings.delete_notices_fork_1 = - Forks of this repository will become independent after deletion. | ||||
| settings.deletion_success = The repository has been deleted. | ||||
| settings.update_settings_success = The repository settings have been updated. | ||||
| settings.transfer_owner = New Owner | ||||
| settings.make_transfer = Perform Transfer | ||||
| settings.transfer_succeed = The repository has been transferred. | ||||
| settings.confirm_delete = Delete Repository | ||||
| settings.add_collaborator = Add Collaborator | ||||
| settings.add_collaborator_success = The collaborator has been added. | ||||
|  | ||||
| @ -96,17 +96,27 @@ func Transfer(ctx *context.APIContext) { | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if err = repo_service.TransferOwnership(ctx.User, newOwner, ctx.Repo.Repository, teams); err != nil { | ||||
| 	if err := repo_service.StartRepositoryTransfer(ctx.User, newOwner, ctx.Repo.Repository, teams); err != nil { | ||||
| 		if models.IsErrRepoTransferInProgress(err) { | ||||
| 			ctx.Error(http.StatusConflict, "CreatePendingRepositoryTransfer", err) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		if models.IsErrRepoAlreadyExist(err) { | ||||
| 			ctx.Error(http.StatusUnprocessableEntity, "CreatePendingRepositoryTransfer", err) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		ctx.InternalServerError(err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	newRepo, err := models.GetRepositoryByName(newOwner.ID, ctx.Repo.Repository.Name) | ||||
| 	if err != nil { | ||||
| 		ctx.InternalServerError(err) | ||||
| 	if ctx.Repo.Repository.Status == models.RepositoryPendingTransfer { | ||||
| 		log.Trace("Repository transfer initiated: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name) | ||||
| 		ctx.JSON(http.StatusCreated, convert.ToRepo(ctx.Repo.Repository, models.AccessModeAdmin)) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	log.Trace("Repository transferred: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name) | ||||
| 	ctx.JSON(http.StatusAccepted, convert.ToRepo(newRepo, models.AccessModeAdmin)) | ||||
| 	ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx.Repo.Repository, models.AccessModeAdmin)) | ||||
| } | ||||
|  | ||||
| @ -6,6 +6,7 @@ | ||||
| package repo | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| @ -274,6 +275,10 @@ func Action(ctx *context.Context) { | ||||
| 		err = models.StarRepo(ctx.User.ID, ctx.Repo.Repository.ID, true) | ||||
| 	case "unstar": | ||||
| 		err = models.StarRepo(ctx.User.ID, ctx.Repo.Repository.ID, false) | ||||
| 	case "accept_transfer": | ||||
| 		err = acceptOrRejectRepoTransfer(ctx, true) | ||||
| 	case "reject_transfer": | ||||
| 		err = acceptOrRejectRepoTransfer(ctx, false) | ||||
| 	case "desc": // FIXME: this is not used | ||||
| 		if !ctx.Repo.IsOwner() { | ||||
| 			ctx.Error(404) | ||||
| @ -293,6 +298,36 @@ func Action(ctx *context.Context) { | ||||
| 	ctx.RedirectToFirst(ctx.Query("redirect_to"), ctx.Repo.RepoLink) | ||||
| } | ||||
| 
 | ||||
| func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) error { | ||||
| 	repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if err := repoTransfer.LoadAttributes(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if !repoTransfer.CanUserAcceptTransfer(ctx.User) { | ||||
| 		return errors.New("user does not have enough permissions") | ||||
| 	} | ||||
| 
 | ||||
| 	if accept { | ||||
| 		if err := repo_service.TransferOwnership(repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success")) | ||||
| 	} else { | ||||
| 		if err := models.CancelRepositoryTransfer(ctx.Repo.Repository); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected")) | ||||
| 	} | ||||
| 
 | ||||
| 	ctx.Redirect(ctx.Repo.Repository.HTMLURL()) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // RedirectDownload return a file based on the following infos: | ||||
| func RedirectDownload(ctx *context.Context) { | ||||
| 	var ( | ||||
|  | ||||
| @ -477,18 +477,54 @@ func SettingsPost(ctx *context.Context) { | ||||
| 			ctx.Repo.GitRepo.Close() | ||||
| 			ctx.Repo.GitRepo = nil | ||||
| 		} | ||||
| 		if err = repo_service.TransferOwnership(ctx.User, newOwner, repo, nil); err != nil { | ||||
| 
 | ||||
| 		if err := repo_service.StartRepositoryTransfer(ctx.User, newOwner, repo, nil); err != nil { | ||||
| 			if models.IsErrRepoAlreadyExist(err) { | ||||
| 				ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil) | ||||
| 			} else if models.IsErrRepoTransferInProgress(err) { | ||||
| 				ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_in_progress"), tplSettingsOptions, nil) | ||||
| 			} else { | ||||
| 				ctx.ServerError("TransferOwnership", err) | ||||
| 			} | ||||
| 
 | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		log.Trace("Repository transferred: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner) | ||||
| 		ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed")) | ||||
| 		ctx.Redirect(setting.AppSubURL + "/" + newOwner.Name + "/" + repo.Name) | ||||
| 		log.Trace("Repository transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner) | ||||
| 		ctx.Flash.Success(ctx.Tr("repo.settings.transfer_started", newOwner.DisplayName())) | ||||
| 		ctx.Redirect(setting.AppSubURL + "/" + ctx.Repo.Owner.Name + "/" + repo.Name + "/settings") | ||||
| 
 | ||||
| 	case "cancel_transfer": | ||||
| 		if !ctx.Repo.IsOwner() { | ||||
| 			ctx.Error(404) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) | ||||
| 		if err != nil { | ||||
| 			if models.IsErrNoPendingTransfer(err) { | ||||
| 				ctx.Flash.Error("repo.settings.transfer_abort_invalid") | ||||
| 				ctx.Redirect(setting.AppSubURL + "/" + ctx.User.Name + "/" + repo.Name + "/settings") | ||||
| 			} else { | ||||
| 				ctx.ServerError("GetPendingRepositoryTransfer", err) | ||||
| 			} | ||||
| 
 | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		if err := repoTransfer.LoadAttributes(); err != nil { | ||||
| 			ctx.ServerError("LoadRecipient", err) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		if err := models.CancelRepositoryTransfer(ctx.Repo.Repository); err != nil { | ||||
| 			ctx.ServerError("CancelRepositoryTransfer", err) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		log.Trace("Repository transfer process was cancelled: %s/%s ", ctx.Repo.Owner.Name, repo.Name) | ||||
| 		ctx.Flash.Success(ctx.Tr("repo.settings.transfer_abort_success", repoTransfer.Recipient.Name)) | ||||
| 		ctx.Redirect(setting.AppSubURL + "/" + ctx.Repo.Owner.Name + "/" + repo.Name + "/settings") | ||||
| 
 | ||||
| 	case "delete": | ||||
| 		if !ctx.Repo.IsOwner() { | ||||
|  | ||||
| @ -586,6 +586,14 @@ func Home(ctx *context.Context) { | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		if ctx.IsSigned { | ||||
| 			// Set repo notification-status read if unread | ||||
| 			if err := ctx.Repo.Repository.ReadBy(ctx.User.ID); err != nil { | ||||
| 				ctx.ServerError("ReadBy", err) | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		var firstUnit *models.Unit | ||||
| 		for _, repoUnit := range ctx.Repo.Units { | ||||
| 			if repoUnit.Type == models.UnitTypeCode { | ||||
|  | ||||
| @ -34,6 +34,8 @@ const ( | ||||
| 
 | ||||
| 	mailNotifyCollaborator base.TplName = "notify/collaborator" | ||||
| 
 | ||||
| 	mailRepoTransferNotify base.TplName = "notify/repo_transfer" | ||||
| 
 | ||||
| 	// There's no actual limit for subject in RFC 5322 | ||||
| 	mailMaxSubjectRunes = 256 | ||||
| ) | ||||
|  | ||||
							
								
								
									
										57
									
								
								services/mailer/mail_repo.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								services/mailer/mail_repo.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,57 @@ | ||||
| // Copyright 2021 The Gitea Authors. All rights reserved. | ||||
| // Use of this source code is governed by a MIT-style | ||||
| // license that can be found in the LICENSE file. | ||||
| 
 | ||||
| package mailer | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"fmt" | ||||
| 
 | ||||
| 	"code.gitea.io/gitea/models" | ||||
| ) | ||||
| 
 | ||||
| // SendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created | ||||
| func SendRepoTransferNotifyMail(doer, newOwner *models.User, repo *models.Repository) error { | ||||
| 	var ( | ||||
| 		emails      []string | ||||
| 		destination string | ||||
| 		content     bytes.Buffer | ||||
| 	) | ||||
| 
 | ||||
| 	if newOwner.IsOrganization() { | ||||
| 		users, err := models.GetUsersWhoCanCreateOrgRepo(newOwner.ID) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		for i := range users { | ||||
| 			emails = append(emails, users[i].Email) | ||||
| 		} | ||||
| 		destination = newOwner.DisplayName() | ||||
| 	} else { | ||||
| 		emails = []string{newOwner.Email} | ||||
| 		destination = "you" | ||||
| 	} | ||||
| 
 | ||||
| 	subject := fmt.Sprintf("%s would like to transfer \"%s\" to %s", doer.DisplayName(), repo.FullName(), destination) | ||||
| 	data := map[string]interface{}{ | ||||
| 		"Doer":    doer, | ||||
| 		"User":    repo.Owner, | ||||
| 		"Repo":    repo.FullName(), | ||||
| 		"Link":    repo.HTMLURL(), | ||||
| 		"Subject": subject, | ||||
| 
 | ||||
| 		"Destination": destination, | ||||
| 	} | ||||
| 
 | ||||
| 	if err := bodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	msg := NewMessage(emails, subject, content.String()) | ||||
| 	msg.Info = fmt.Sprintf("UID: %d, repository pending transfer notification", newOwner.ID) | ||||
| 
 | ||||
| 	SendAsync(msg) | ||||
| 	return nil | ||||
| } | ||||
| @ -70,3 +70,38 @@ func ChangeRepositoryName(doer *models.User, repo *models.Repository, newRepoNam | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // StartRepositoryTransfer transfer a repo from one owner to a new one. | ||||
| // it make repository into pending transfer state, if doer can not create repo for new owner. | ||||
| func StartRepositoryTransfer(doer, newOwner *models.User, repo *models.Repository, teams []*models.Team) error { | ||||
| 	if err := models.TestRepositoryReadyForTransfer(repo.Status); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// Admin is always allowed to transfer || user transfer repo back to his account | ||||
| 	if doer.IsAdmin || doer.ID == newOwner.ID { | ||||
| 		return TransferOwnership(doer, newOwner, repo, teams) | ||||
| 	} | ||||
| 
 | ||||
| 	// If new owner is an org and user can create repos he can transfer directly too | ||||
| 	if newOwner.IsOrganization() { | ||||
| 		allowed, err := models.CanCreateOrgRepo(newOwner.ID, doer.ID) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		if allowed { | ||||
| 			return TransferOwnership(doer, newOwner, repo, teams) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Make repo as pending for transfer | ||||
| 	repo.Status = models.RepositoryPendingTransfer | ||||
| 	if err := models.CreatePendingRepositoryTransfer(doer, newOwner, repo.ID, teams); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// notify users who are able to accept / reject transfer | ||||
| 	notification.NotifyRepoPendingTransfer(doer, newOwner, repo) | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
							
								
								
									
										17
									
								
								templates/mail/notify/repo_transfer.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								templates/mail/notify/repo_transfer.tmpl
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,17 @@ | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
| 	<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> | ||||
| 	<title>{{.Subject}}</title> | ||||
| </head> | ||||
| 
 | ||||
| <body> | ||||
| 	<p>{{.Subject}}. | ||||
| 		To accept or reject it visit <a href="{{.Link}}">{{.Repo}}</a> or just ignore it. | ||||
| 	<p> | ||||
| 		--- | ||||
| 		<br> | ||||
| 		<a href="{{.Link}}">View it on Gitea</a>. | ||||
| 	</p> | ||||
| </body> | ||||
| </html> | ||||
| @ -42,6 +42,24 @@ | ||||
| 			</div> | ||||
| 			{{if not .IsBeingCreated}} | ||||
| 				<div class="repo-buttons"> | ||||
| 					{{if $.RepoTransfer}} | ||||
| 						<form method="post" action="{{$.RepoLink}}/action/accept_transfer?redirect_to={{$.RepoLink}}"> | ||||
| 							{{$.CsrfTokenHtml}} | ||||
| 							<div class="ui poping up" data-content="{{if $.CanUserAcceptTransfer}}{{$.i18n.Tr "repo.transfer.accept_desc" $.RepoTransfer.Recipient.DisplayName}}{{else}}{{$.i18n.Tr "repo.transfer.no_permission_to_accept"}}{{end}}" data-position="bottom center" data-variation="tiny"> | ||||
| 								<button type="submit" class="ui button {{if $.CanUserAcceptTransfer}}green {{end}} ok inverted small"{{if not $.CanUserAcceptTransfer}} disabled{{end}}> | ||||
| 									{{$.i18n.Tr "repo.transfer.accept"}} | ||||
| 								</button> | ||||
| 							</div> | ||||
| 						</form> | ||||
| 						<form method="post" action="{{$.RepoLink}}/action/reject_transfer?redirect_to={{$.RepoLink}}"> | ||||
| 							{{$.CsrfTokenHtml}} | ||||
| 							<div class="ui poping up" data-content="{{if $.CanUserAcceptTransfer}}{{$.i18n.Tr "repo.transfer.reject_desc" $.RepoTransfer.Recipient.DisplayName}}{{else}}{{$.i18n.Tr "repo.transfer.no_permission_to_reject"}}{{end}}" data-position="bottom center" data-variation="tiny"> | ||||
| 								<button type="submit" class="ui button {{if $.CanUserAcceptTransfer}}red {{end}}ok inverted small"{{if not $.CanUserAcceptTransfer}} disabled{{end}}> | ||||
| 									{{$.i18n.Tr "repo.transfer.reject"}} | ||||
| 								</button> | ||||
| 							</div> | ||||
| 						</form> | ||||
| 					{{end}} | ||||
| 					<form method="post" action="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}un{{end}}watch?redirect_to={{$.Link}}"> | ||||
| 						{{$.CsrfTokenHtml}} | ||||
| 						<div class="ui labeled button{{if not $.IsSigned}} poping up{{end}}" tabindex="0"{{if not $.IsSigned}} data-content="{{$.i18n.Tr "repo.watch_guest_user" }}" data-position="top center" data-variation="tiny"{{end}}> | ||||
|  | ||||
| @ -444,11 +444,23 @@ | ||||
| 			{{end}} | ||||
| 			<div class="item"> | ||||
| 				<div class="ui right"> | ||||
| 					<button class="ui basic red show-modal button" data-modal="#transfer-repo-modal">{{.i18n.Tr "repo.settings.transfer"}}</button> | ||||
| 					{{if .RepoTransfer}} | ||||
| 						<form class="ui form" action="{{.Link}}" method="post"> | ||||
| 							{{.CsrfTokenHtml}} | ||||
| 							<input type="hidden" name="action" value="cancel_transfer"> | ||||
| 							<button class="ui red button">{{.i18n.Tr "repo.settings.transfer_abort"}}</button> | ||||
| 						</form> | ||||
| 					{{ else }} | ||||
| 						<button class="ui basic red show-modal button" data-modal="#transfer-repo-modal">{{.i18n.Tr "repo.settings.transfer"}}</button> | ||||
| 					{{ end }} | ||||
| 				</div> | ||||
| 				<div> | ||||
| 					<h5>{{.i18n.Tr "repo.settings.transfer"}}</h5> | ||||
| 					<p>{{.i18n.Tr "repo.settings.transfer_desc"}}</p> | ||||
| 					{{if .RepoTransfer}} | ||||
| 						<p>{{.i18n.Tr "repo.settings.transfer_started" .RepoTransfer.Recipient.DisplayName}}</p> | ||||
| 					{{else}} | ||||
| 						<p>{{.i18n.Tr "repo.settings.transfer_desc"}}</p> | ||||
| 					{{end}} | ||||
| 				</div> | ||||
| 			</div> | ||||
| 
 | ||||
| @ -599,7 +611,7 @@ | ||||
| 
 | ||||
| 				<div class="text right actions"> | ||||
| 					<div class="ui cancel button">{{.i18n.Tr "settings.cancel"}}</div> | ||||
| 					<button class="ui red button">{{.i18n.Tr "repo.settings.make_transfer"}}</button> | ||||
| 					<button class="ui red button">{{.i18n.Tr "repo.settings.transfer_perform"}}</button> | ||||
| 				</div> | ||||
| 			</form> | ||||
| 		</div> | ||||
|  | ||||
| @ -39,6 +39,8 @@ | ||||
|                                 <td class="collapsing" data-href="{{.HTMLURL}}"> | ||||
|                                     {{if eq .Status 3}} | ||||
|                                         <span class="blue">{{svg "octicon-pin"}}</span> | ||||
|                                     {{else if not $issue}} | ||||
|                                         <span class="gray">{{svg "octicon-repo"}}</span> | ||||
|                                     {{else if $issue.IsPull}} | ||||
|                                         {{if $issue.IsClosed}} | ||||
|                                             {{if $issue.GetPullRequest.HasMerged}} | ||||
| @ -59,7 +61,11 @@ | ||||
|                                 </td> | ||||
|                                 <td class="eleven wide" data-href="{{.HTMLURL}}"> | ||||
|                                     <a class="item" href="{{.HTMLURL}}"> | ||||
|                                         #{{$issue.Index}} - {{$issue.Title}} | ||||
|                                         {{if $issue}} | ||||
|                                             #{{$issue.Index}} - {{$issue.Title}} | ||||
|                                         {{else}} | ||||
|                                             {{$repo.FullName}} | ||||
|                                         {{end}} | ||||
|                                     </a> | ||||
|                                 </td> | ||||
|                                 <td data-href="{{AppSubUrl}}/{{$repoOwner.Name}}/{{$repo.Name}}"> | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 6543
						6543