From 10cdcb9ea8077098921d72720f9f36fcfd950452 Mon Sep 17 00:00:00 2001
From: Brecht Van Lommel <brecht@blender.org>
Date: Sat, 25 Feb 2023 03:55:50 +0100
Subject: [PATCH] Add "Reviewed by you" filter for pull requests (#22927)

This includes pull requests that you approved, requested changes or
commented on. Currently such pull requests are not visible in any of the
filters on /pulls, while they may need further action like merging, or
prodding the author or reviewers.

Especially when working with a large team on a repository it's helpful
to get a full overview of pull requests that may need your attention,
without having to sift through the complete list.
---
 models/issues/issue.go                     | 61 ++++++++++++++++++++++
 options/locale/locale_en-US.ini            |  1 +
 routers/api/v1/repo/issue.go               |  7 +++
 routers/web/repo/issue.go                  | 10 +++-
 routers/web/user/home.go                   |  4 ++
 templates/repo/issue/list.tmpl             |  3 +-
 templates/repo/issue/milestone_issues.tmpl |  3 +-
 templates/swagger/v1_json.tmpl             |  6 +++
 templates/user/dashboard/issues.tmpl       | 12 +++--
 9 files changed, 100 insertions(+), 7 deletions(-)

diff --git a/models/issues/issue.go b/models/issues/issue.go
index c59e9d14e5..edd74261ec 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -1148,6 +1148,7 @@ type IssuesOptions struct { //nolint
 	PosterID           int64
 	MentionedID        int64
 	ReviewRequestedID  int64
+	ReviewedID         int64
 	SubscriberID       int64
 	MilestoneIDs       []int64
 	ProjectID          int64
@@ -1262,6 +1263,10 @@ func (opts *IssuesOptions) setupSessionNoLimit(sess *xorm.Session) {
 		applyReviewRequestedCondition(sess, opts.ReviewRequestedID)
 	}
 
+	if opts.ReviewedID > 0 {
+		applyReviewedCondition(sess, opts.ReviewedID)
+	}
+
 	if opts.SubscriberID > 0 {
 		applySubscribedCondition(sess, opts.SubscriberID)
 	}
@@ -1432,6 +1437,36 @@ func applyReviewRequestedCondition(sess *xorm.Session, reviewRequestedID int64)
 			reviewRequestedID, ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest, reviewRequestedID)
 }
 
+func applyReviewedCondition(sess *xorm.Session, reviewedID int64) *xorm.Session {
+	// Query for pull requests where you are a reviewer or commenter, excluding
+	// any pull requests already returned by the the review requested filter.
+	notPoster := builder.Neq{"issue.poster_id": reviewedID}
+	reviewed := builder.In("issue.id", builder.
+		Select("issue_id").
+		From("review").
+		Where(builder.And(
+			builder.Neq{"type": ReviewTypeRequest},
+			builder.Or(
+				builder.Eq{"reviewer_id": reviewedID},
+				builder.In("reviewer_team_id", builder.
+					Select("team_id").
+					From("team_user").
+					Where(builder.Eq{"uid": reviewedID}),
+				),
+			),
+		)),
+	)
+	commented := builder.In("issue.id", builder.
+		Select("issue_id").
+		From("comment").
+		Where(builder.And(
+			builder.Eq{"poster_id": reviewedID},
+			builder.In("type", CommentTypeComment, CommentTypeCode, CommentTypeReview),
+		)),
+	)
+	return sess.And(notPoster, builder.Or(reviewed, commented))
+}
+
 func applySubscribedCondition(sess *xorm.Session, subscriberID int64) *xorm.Session {
 	return sess.And(
 		builder.
@@ -1586,6 +1621,7 @@ type IssueStats struct {
 	CreateCount            int64
 	MentionCount           int64
 	ReviewRequestedCount   int64
+	ReviewedCount          int64
 }
 
 // Filter modes.
@@ -1595,6 +1631,7 @@ const (
 	FilterModeCreate
 	FilterModeMention
 	FilterModeReviewRequested
+	FilterModeReviewed
 	FilterModeYourRepositories
 )
 
@@ -1608,6 +1645,7 @@ type IssueStatsOptions struct {
 	MentionedID       int64
 	PosterID          int64
 	ReviewRequestedID int64
+	ReviewedID        int64
 	IsPull            util.OptionalBool
 	IssueIDs          []int64
 }
@@ -1646,6 +1684,7 @@ func GetIssueStats(opts *IssueStatsOptions) (*IssueStats, error) {
 		accum.CreateCount += stats.CreateCount
 		accum.OpenCount += stats.MentionCount
 		accum.ReviewRequestedCount += stats.ReviewRequestedCount
+		accum.ReviewedCount += stats.ReviewedCount
 		i = chunk
 	}
 	return accum, nil
@@ -1703,6 +1742,10 @@ func getIssueStatsChunk(opts *IssueStatsOptions, issueIDs []int64) (*IssueStats,
 			applyReviewRequestedCondition(sess, opts.ReviewRequestedID)
 		}
 
+		if opts.ReviewedID > 0 {
+			applyReviewedCondition(sess, opts.ReviewedID)
+		}
+
 		switch opts.IsPull {
 		case util.OptionalBoolTrue:
 			sess.And("issue.is_pull=?", true)
@@ -1843,6 +1886,19 @@ func GetUserIssueStats(opts UserIssueStatsOptions) (*IssueStats, error) {
 		if err != nil {
 			return nil, err
 		}
+	case FilterModeReviewed:
+		stats.OpenCount, err = applyReviewedCondition(sess(cond), opts.UserID).
+			And("issue.is_closed = ?", false).
+			Count(new(Issue))
+		if err != nil {
+			return nil, err
+		}
+		stats.ClosedCount, err = applyReviewedCondition(sess(cond), opts.UserID).
+			And("issue.is_closed = ?", true).
+			Count(new(Issue))
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	cond = cond.And(builder.Eq{"issue.is_closed": opts.IsClosed})
@@ -1871,6 +1927,11 @@ func GetUserIssueStats(opts UserIssueStatsOptions) (*IssueStats, error) {
 		return nil, err
 	}
 
+	stats.ReviewedCount, err = applyReviewedCondition(sess(cond), opts.UserID).Count(new(Issue))
+	if err != nil {
+		return nil, err
+	}
+
 	return stats, nil
 }
 
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index c818b0dcc5..2109950ca8 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1323,6 +1323,7 @@ issues.filter_type.assigned_to_you = Assigned to you
 issues.filter_type.created_by_you = Created by you
 issues.filter_type.mentioning_you = Mentioning you
 issues.filter_type.review_requested = Review requested
+issues.filter_type.reviewed_by_you = Reviewed by you
 issues.filter_sort = Sort
 issues.filter_sort.latest = Newest
 issues.filter_sort.oldest = Oldest
diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go
index 458838b935..06bf06b4e8 100644
--- a/routers/api/v1/repo/issue.go
+++ b/routers/api/v1/repo/issue.go
@@ -92,6 +92,10 @@ func SearchIssues(ctx *context.APIContext) {
 	//   in: query
 	//   description: filter pulls requesting your review, default is false
 	//   type: boolean
+	// - name: reviewed
+	//   in: query
+	//   description: filter pulls reviewed by you, default is false
+	//   type: boolean
 	// - name: owner
 	//   in: query
 	//   description: filter by owner
@@ -266,6 +270,9 @@ func SearchIssues(ctx *context.APIContext) {
 		if ctx.FormBool("review_requested") {
 			issuesOpt.ReviewRequestedID = ctxUserID
 		}
+		if ctx.FormBool("reviewed") {
+			issuesOpt.ReviewedID = ctxUserID
+		}
 
 		if issues, err = issues_model.Issues(ctx, issuesOpt); err != nil {
 			ctx.Error(http.StatusInternalServerError, "Issues", err)
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 05ba26a70c..745d6e70a0 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -138,7 +138,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
 	var err error
 	viewType := ctx.FormString("type")
 	sortType := ctx.FormString("sort")
-	types := []string{"all", "your_repositories", "assigned", "created_by", "mentioned", "review_requested"}
+	types := []string{"all", "your_repositories", "assigned", "created_by", "mentioned", "review_requested", "reviewed_by"}
 	if !util.SliceContainsString(types, viewType, true) {
 		viewType = "all"
 	}
@@ -148,6 +148,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
 		posterID          = ctx.FormInt64("poster")
 		mentionedID       int64
 		reviewRequestedID int64
+		reviewedID        int64
 		forceEmpty        bool
 	)
 
@@ -161,6 +162,8 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
 			assigneeID = ctx.Doer.ID
 		case "review_requested":
 			reviewRequestedID = ctx.Doer.ID
+		case "reviewed_by":
+			reviewedID = ctx.Doer.ID
 		}
 	}
 
@@ -208,6 +211,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
 			MentionedID:       mentionedID,
 			PosterID:          posterID,
 			ReviewRequestedID: reviewRequestedID,
+			ReviewedID:        reviewedID,
 			IsPull:            isPullOption,
 			IssueIDs:          issueIDs,
 		})
@@ -255,6 +259,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
 			PosterID:          posterID,
 			MentionedID:       mentionedID,
 			ReviewRequestedID: reviewRequestedID,
+			ReviewedID:        reviewedID,
 			MilestoneIDs:      mileIDs,
 			ProjectID:         projectID,
 			IsClosed:          util.OptionalBoolOf(isShowClosed),
@@ -2425,6 +2430,9 @@ func SearchIssues(ctx *context.Context) {
 		if ctx.FormBool("review_requested") {
 			issuesOpt.ReviewRequestedID = ctxUserID
 		}
+		if ctx.FormBool("reviewed") {
+			issuesOpt.ReviewedID = ctxUserID
+		}
 
 		if issues, err = issues_model.Issues(ctx, issuesOpt); err != nil {
 			ctx.Error(http.StatusInternalServerError, "Issues", err.Error())
diff --git a/routers/web/user/home.go b/routers/web/user/home.go
index 2593ab148c..a0a5dc3c4b 100644
--- a/routers/web/user/home.go
+++ b/routers/web/user/home.go
@@ -385,6 +385,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
 		filterMode = issues_model.FilterModeMention
 	case "review_requested":
 		filterMode = issues_model.FilterModeReviewRequested
+	case "reviewed_by":
+		filterMode = issues_model.FilterModeReviewed
 	case "your_repositories":
 		fallthrough
 	default:
@@ -453,6 +455,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
 		opts.MentionedID = ctx.Doer.ID
 	case issues_model.FilterModeReviewRequested:
 		opts.ReviewRequestedID = ctx.Doer.ID
+	case issues_model.FilterModeReviewed:
+		opts.ReviewedID = ctx.Doer.ID
 	}
 
 	// keyword holds the search term entered into the search field.
diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl
index 2b1bea822f..23a8a1d0e1 100644
--- a/templates/repo/issue/list.tmpl
+++ b/templates/repo/issue/list.tmpl
@@ -171,10 +171,11 @@
 								<a class="{{if eq .ViewType "all"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=all&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.all_issues"}}</a>
 								<a class="{{if eq .ViewType "assigned"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=assigned&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.assigned_to_you"}}</a>
 								<a class="{{if eq .ViewType "created_by"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=created_by&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.created_by_you"}}</a>
-								<a class="{{if eq .ViewType "mentioned"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=mentioned&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.mentioning_you"}}</a>
 								{{if .PageIsPullList}}
 									<a class="{{if eq .ViewType "review_requested"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=review_requested&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.review_requested"}}</a>
+									<a class="{{if eq .ViewType "reviewed_by"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=reviewed_by&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.reviewed_by_you"}}</a>
 								{{end}}
+								<a class="{{if eq .ViewType "mentioned"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=mentioned&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.mentioning_you"}}</a>
 							</div>
 						</div>
 					{{end}}
diff --git a/templates/repo/issue/milestone_issues.tmpl b/templates/repo/issue/milestone_issues.tmpl
index fca9597446..d73fb56fbc 100644
--- a/templates/repo/issue/milestone_issues.tmpl
+++ b/templates/repo/issue/milestone_issues.tmpl
@@ -111,8 +111,9 @@
 								<a class="{{if eq .ViewType "all"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=all&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.all_issues"}}</a>
 								<a class="{{if eq .ViewType "assigned"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=assigned&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.assigned_to_you"}}</a>
 								<a class="{{if eq .ViewType "created_by"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=created_by&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.created_by_you"}}</a>
-								<a class="{{if eq .ViewType "mentioned"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=mentioned&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.mentioning_you"}}</a>
 								<a class="{{if eq .ViewType "review_requested"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=review_requested&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.review_requested"}}</a>
+								<a class="{{if eq .ViewType "reviewed_by"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=reviewed_by&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.reviewed_by_you"}}</a>
+								<a class="{{if eq .ViewType "mentioned"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type=mentioned&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_type.mentioning_you"}}</a>
 							</div>
 						</div>
 					{{end}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index de774deaed..0605937599 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -2366,6 +2366,12 @@
             "name": "review_requested",
             "in": "query"
           },
+          {
+            "type": "boolean",
+            "description": "filter pulls reviewed by you, default is false",
+            "name": "reviewed",
+            "in": "query"
+          },
           {
             "type": "string",
             "description": "filter by owner",
diff --git a/templates/user/dashboard/issues.tmpl b/templates/user/dashboard/issues.tmpl
index 049b6a1681..29023d921a 100644
--- a/templates/user/dashboard/issues.tmpl
+++ b/templates/user/dashboard/issues.tmpl
@@ -17,16 +17,20 @@
 						{{.locale.Tr "repo.issues.filter_type.created_by_you"}}
 						<strong class="ui right">{{CountFmt .IssueStats.CreateCount}}</strong>
 					</a>
-					<a class="{{if eq .ViewType "mentioned"}}ui basic primary button{{end}} item" href="{{.Link}}?type=mentioned&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
-						{{.locale.Tr "repo.issues.filter_type.mentioning_you"}}
-						<strong class="ui right">{{CountFmt .IssueStats.MentionCount}}</strong>
-					</a>
 					{{if .PageIsPulls}}
 						<a class="{{if eq .ViewType "review_requested"}}ui basic primary button{{end}} item" href="{{.Link}}?type=review_requested&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
 							{{.locale.Tr "repo.issues.filter_type.review_requested"}}
 							<strong class="ui right">{{CountFmt .IssueStats.ReviewRequestedCount}}</strong>
 						</a>
+						<a class="{{if eq .ViewType "reviewed_by"}}ui basic primary button{{end}} item" href="{{.Link}}?type=reviewed_by&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
+							{{.locale.Tr "repo.issues.filter_type.reviewed_by_you"}}
+							<strong class="ui right">{{CountFmt .IssueStats.ReviewedCount}}</strong>
+						</a>
 					{{end}}
+					<a class="{{if eq .ViewType "mentioned"}}ui basic primary button{{end}} item" href="{{.Link}}?type=mentioned&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
+						{{.locale.Tr "repo.issues.filter_type.mentioning_you"}}
+						<strong class="ui right">{{CountFmt .IssueStats.MentionCount}}</strong>
+					</a>
 					<div class="ui divider"></div>
 					<a class="{{if not $.RepoIDs}}ui basic primary button{{end}} repo name item" href="{{$.Link}}?type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&q={{$.Keyword}}">
 						<span class="text truncate">All</span>