From d2e4039def61d9cc9952be462216001125327270 Mon Sep 17 00:00:00 2001
From: yp05327 <576951401@qq.com>
Date: Thu, 24 Aug 2023 14:06:17 +0900
Subject: [PATCH] Add `member`, `collaborator`, `contributor`, and `first-time
 contributor` roles and tooltips (#26658)

GitHub like role descriptor

![image](https://github.com/go-gitea/gitea/assets/18380374/ceaed92c-6749-47b3-89e8-0e0e7ae65321)

![image](https://github.com/go-gitea/gitea/assets/18380374/8193ec34-cbf0-47f9-b0de-10dbddd66970)

![image](https://github.com/go-gitea/gitea/assets/18380374/56c7ed85-6177-425e-9f2f-926e99770782)

---------

Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
---
 models/issues/comment.go                      | 47 +++++-----
 models/issues/pull_list.go                    | 13 +++
 options/locale/locale_en-US.ini               | 15 +++-
 routers/web/repo/issue.go                     | 87 ++++++++++++-------
 .../repo/issue/view_content/show_role.tmpl    | 17 ++--
 5 files changed, 106 insertions(+), 73 deletions(-)

diff --git a/models/issues/comment.go b/models/issues/comment.go
index e78193126..17e579b45 100644
--- a/models/issues/comment.go
+++ b/models/issues/comment.go
@@ -23,6 +23,7 @@ import (
 	"code.gitea.io/gitea/modules/references"
 	"code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/timeutil"
+	"code.gitea.io/gitea/modules/translation"
 	"code.gitea.io/gitea/modules/util"
 
 	"xorm.io/builder"
@@ -181,40 +182,32 @@ func (t CommentType) HasAttachmentSupport() bool {
 	return false
 }
 
-// RoleDescriptor defines comment tag type
-type RoleDescriptor int
+// RoleInRepo presents the user's participation in the repo
+type RoleInRepo string
+
+// RoleDescriptor defines comment "role" tags
+type RoleDescriptor struct {
+	IsPoster   bool
+	RoleInRepo RoleInRepo
+}
 
 // Enumerate all the role tags.
 const (
-	RoleDescriptorNone RoleDescriptor = iota
-	RoleDescriptorPoster
-	RoleDescriptorWriter
-	RoleDescriptorOwner
+	RoleRepoOwner                RoleInRepo = "owner"
+	RoleRepoMember               RoleInRepo = "member"
+	RoleRepoCollaborator         RoleInRepo = "collaborator"
+	RoleRepoFirstTimeContributor RoleInRepo = "first_time_contributor"
+	RoleRepoContributor          RoleInRepo = "contributor"
 )
 
-// WithRole enable a specific tag on the RoleDescriptor.
-func (rd RoleDescriptor) WithRole(role RoleDescriptor) RoleDescriptor {
-	return rd | (1 << role)
+// LocaleString returns the locale string name of the role
+func (r RoleInRepo) LocaleString(lang translation.Locale) string {
+	return lang.Tr("repo.issues.role." + string(r))
 }
 
-func stringToRoleDescriptor(role string) RoleDescriptor {
-	switch role {
-	case "Poster":
-		return RoleDescriptorPoster
-	case "Writer":
-		return RoleDescriptorWriter
-	case "Owner":
-		return RoleDescriptorOwner
-	default:
-		return RoleDescriptorNone
-	}
-}
-
-// HasRole returns if a certain role is enabled on the RoleDescriptor.
-func (rd RoleDescriptor) HasRole(role string) bool {
-	roleDescriptor := stringToRoleDescriptor(role)
-	bitValue := rd & (1 << roleDescriptor)
-	return (bitValue > 0)
+// LocaleHelper returns the locale tooltip of the role
+func (r RoleInRepo) LocaleHelper(lang translation.Locale) string {
+	return lang.Tr("repo.issues.role." + string(r) + "_helper")
 }
 
 // Comment represents a comment in commit and issue page.
diff --git a/models/issues/pull_list.go b/models/issues/pull_list.go
index 3b2416900..c4506ef15 100644
--- a/models/issues/pull_list.go
+++ b/models/issues/pull_list.go
@@ -199,3 +199,16 @@ func (prs PullRequestList) GetIssueIDs() []int64 {
 	}
 	return issueIDs
 }
+
+// HasMergedPullRequestInRepo returns whether the user(poster) has merged pull-request in the repo
+func HasMergedPullRequestInRepo(ctx context.Context, repoID, posterID int64) (bool, error) {
+	return db.GetEngine(ctx).
+		Join("INNER", "pull_request", "pull_request.issue_id = issue.id").
+		Where("repo_id=?", repoID).
+		And("poster_id=?", posterID).
+		And("is_pull=?", true).
+		And("pull_request.has_merged=?", true).
+		Select("issue.id").
+		Limit(1).
+		Get(new(Issue))
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index f08d2b7ea..e32399dd8 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1480,9 +1480,18 @@ issues.ref_reopening_from = `<a href="%[3]s">referenced a pull request %[4]s tha
 issues.ref_closed_from = `<a href="%[3]s">closed this issue %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
 issues.ref_reopened_from = `<a href="%[3]s">reopened this issue %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
 issues.ref_from = `from %[1]s`
-issues.poster = Poster
-issues.collaborator = Collaborator
-issues.owner = Owner
+issues.author = Author
+issues.author_helper = This user is the author.
+issues.role.owner = Owner
+issues.role.owner_helper = This user is the owner of this repository.
+issues.role.member = Member
+issues.role.member_helper = This user is a member of the organization owning this repository.
+issues.role.collaborator = Collaborator
+issues.role.collaborator_helper = This user has been invited to collaborate on the repository.
+issues.role.first_time_contributor = First-time contributor
+issues.role.first_time_contributor_helper = This is the first contribution of this user to the repository.
+issues.role.contributor = Contributor
+issues.role.contributor_helper = This user has previously committed to the repository.
 issues.re_request_review=Re-request review
 issues.is_stale = There have been changes to this PR since this review
 issues.remove_request_review=Remove review request
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index b04802e45..9a2add145 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -1228,47 +1228,70 @@ func NewIssuePost(ctx *context.Context) {
 	}
 }
 
-// roleDescriptor returns the Role Descriptor for a comment in/with the given repo, poster and issue
+// roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and issue
 func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *user_model.User, issue *issues_model.Issue, hasOriginalAuthor bool) (issues_model.RoleDescriptor, error) {
+	roleDescriptor := issues_model.RoleDescriptor{}
+
 	if hasOriginalAuthor {
-		return issues_model.RoleDescriptorNone, nil
+		return roleDescriptor, nil
 	}
 
 	perm, err := access_model.GetUserRepoPermission(ctx, repo, poster)
 	if err != nil {
-		return issues_model.RoleDescriptorNone, err
-	}
-
-	// By default the poster has no roles on the comment.
-	roleDescriptor := issues_model.RoleDescriptorNone
-
-	// Check if the poster is owner of the repo.
-	if perm.IsOwner() {
-		// If the poster isn't a admin, enable the owner role.
-		if !poster.IsAdmin {
-			roleDescriptor = roleDescriptor.WithRole(issues_model.RoleDescriptorOwner)
-		} else {
-
-			// Otherwise check if poster is the real repo admin.
-			ok, err := access_model.IsUserRealRepoAdmin(repo, poster)
-			if err != nil {
-				return issues_model.RoleDescriptorNone, err
-			}
-			if ok {
-				roleDescriptor = roleDescriptor.WithRole(issues_model.RoleDescriptorOwner)
-			}
-		}
-	}
-
-	// Is the poster can write issues or pulls to the repo, enable the Writer role.
-	// Only enable this if the poster doesn't have the owner role already.
-	if !roleDescriptor.HasRole("Owner") && perm.CanWriteIssuesOrPulls(issue.IsPull) {
-		roleDescriptor = roleDescriptor.WithRole(issues_model.RoleDescriptorWriter)
+		return roleDescriptor, err
 	}
 
 	// If the poster is the actual poster of the issue, enable Poster role.
-	if issue.IsPoster(poster.ID) {
-		roleDescriptor = roleDescriptor.WithRole(issues_model.RoleDescriptorPoster)
+	roleDescriptor.IsPoster = issue.IsPoster(poster.ID)
+
+	// Check if the poster is owner of the repo.
+	if perm.IsOwner() {
+		// If the poster isn't an admin, enable the owner role.
+		if !poster.IsAdmin {
+			roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner
+			return roleDescriptor, nil
+		}
+
+		// Otherwise check if poster is the real repo admin.
+		ok, err := access_model.IsUserRealRepoAdmin(repo, poster)
+		if err != nil {
+			return roleDescriptor, err
+		}
+		if ok {
+			roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner
+			return roleDescriptor, nil
+		}
+	}
+
+	// If repo is organization, check Member role
+	if err := repo.LoadOwner(ctx); err != nil {
+		return roleDescriptor, err
+	}
+	if repo.Owner.IsOrganization() {
+		if isMember, err := organization.IsOrganizationMember(ctx, repo.Owner.ID, poster.ID); err != nil {
+			return roleDescriptor, err
+		} else if isMember {
+			roleDescriptor.RoleInRepo = issues_model.RoleRepoMember
+			return roleDescriptor, nil
+		}
+	}
+
+	// If the poster is the collaborator of the repo
+	if isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, poster.ID); err != nil {
+		return roleDescriptor, err
+	} else if isCollaborator {
+		roleDescriptor.RoleInRepo = issues_model.RoleRepoCollaborator
+		return roleDescriptor, nil
+	}
+
+	hasMergedPR, err := issues_model.HasMergedPullRequestInRepo(ctx, repo.ID, poster.ID)
+	if err != nil {
+		return roleDescriptor, err
+	} else if hasMergedPR {
+		roleDescriptor.RoleInRepo = issues_model.RoleRepoContributor
+	} else {
+		// only display first time contributor in the first opening pull request
+		roleDescriptor.RoleInRepo = issues_model.RoleRepoFirstTimeContributor
 	}
 
 	return roleDescriptor, nil
diff --git a/templates/repo/issue/view_content/show_role.tmpl b/templates/repo/issue/view_content/show_role.tmpl
index f85f43bd6..40c8b67fa 100644
--- a/templates/repo/issue/view_content/show_role.tmpl
+++ b/templates/repo/issue/view_content/show_role.tmpl
@@ -1,15 +1,10 @@
-{{if and (.ShowRole.HasRole "Poster") (not .IgnorePoster)}}
-	<div class="ui basic label role-label">
-		{{ctx.Locale.Tr "repo.issues.poster"}}
+{{if and .ShowRole.IsPoster (not .IgnorePoster)}}
+	<div class="ui basic label role-label" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.author_helper"}}">
+		{{ctx.Locale.Tr "repo.issues.author"}}
 	</div>
 {{end}}
-{{if (.ShowRole.HasRole "Writer")}}
-	<div class="ui basic label role-label">
-		{{ctx.Locale.Tr "repo.issues.collaborator"}}
-	</div>
-{{end}}
-{{if (.ShowRole.HasRole "Owner")}}
-	<div class="ui basic label role-label">
-		{{ctx.Locale.Tr "repo.issues.owner"}}
+{{if .ShowRole.RoleInRepo}}
+	<div class="ui basic label role-label" data-tooltip-content="{{.ShowRole.RoleInRepo.LocaleHelper ctx.Locale}}">
+		{{.ShowRole.RoleInRepo.LocaleString ctx.Locale}}
 	</div>
 {{end}}