From 4810fe55e3e73edb962052df46bef125eb1817b3 Mon Sep 17 00:00:00 2001
From: Yarden Shoham <git@yardenshoham.com>
Date: Sun, 14 May 2023 00:59:01 +0300
Subject: [PATCH] Add status indicator on main home screen for each repo
 (#24638)

It will show the calculated commit status state of the latest commit on
the default branch for each repository in the dashboard repo list

- Closes #15620

# Before

![image](https://github.com/go-gitea/gitea/assets/20454870/aa1326c7-43c0-458a-a798-3102c766bcf9)

# After

![image](https://github.com/go-gitea/gitea/assets/20454870/8658cc03-2224-442a-b1c8-bf64126e4575)

---------

Signed-off-by: Yarden Shoham <git@yardenshoham.com>
Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: Giteabot <teabot@gitea.io>
---
 models/git/commit_status.go                 | 50 +++++++++++++++++++
 modules/git/repo_branch.go                  | 11 +++++
 routers/web/repo/repo.go                    | 54 ++++++++++++++++-----
 services/repository/branch.go               |  4 ++
 services/repository/repository.go           | 14 ++++++
 web_src/js/components/DashboardRepoList.vue | 21 +++++++-
 web_src/js/features/org-team.js             |  4 +-
 web_src/js/features/repo-issue.js           |  4 +-
 web_src/js/features/repo-template.js        |  4 +-
 web_src/js/svg.js                           |  6 +++
 10 files changed, 152 insertions(+), 20 deletions(-)

diff --git a/models/git/commit_status.go b/models/git/commit_status.go
index 82cbb2363739..6028e4664932 100644
--- a/models/git/commit_status.go
+++ b/models/git/commit_status.go
@@ -23,6 +23,7 @@ import (
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/timeutil"
 
+	"xorm.io/builder"
 	"xorm.io/xorm"
 )
 
@@ -240,6 +241,55 @@ func GetLatestCommitStatus(ctx context.Context, repoID int64, sha string, listOp
 	return statuses, count, db.GetEngine(ctx).In("id", ids).Find(&statuses)
 }
 
+// GetLatestCommitStatusForPairs returns all statuses with a unique context for a given list of repo-sha pairs
+func GetLatestCommitStatusForPairs(ctx context.Context, repoIDsToLatestCommitSHAs map[int64]string, listOptions db.ListOptions) (map[int64][]*CommitStatus, error) {
+	type result struct {
+		ID     int64
+		RepoID int64
+	}
+
+	results := make([]result, 0, len(repoIDsToLatestCommitSHAs))
+
+	sess := db.GetEngine(ctx).Table(&CommitStatus{})
+
+	// Create a disjunction of conditions for each repoID and SHA pair
+	conds := make([]builder.Cond, 0, len(repoIDsToLatestCommitSHAs))
+	for repoID, sha := range repoIDsToLatestCommitSHAs {
+		conds = append(conds, builder.Eq{"repo_id": repoID, "sha": sha})
+	}
+	sess = sess.Where(builder.Or(conds...)).
+		Select("max( id ) as id, repo_id").
+		GroupBy("context_hash, repo_id").OrderBy("max( id ) desc")
+
+	sess = db.SetSessionPagination(sess, &listOptions)
+
+	err := sess.Find(&results)
+	if err != nil {
+		return nil, err
+	}
+
+	ids := make([]int64, 0, len(results))
+	repoStatuses := make(map[int64][]*CommitStatus)
+	for _, result := range results {
+		ids = append(ids, result.ID)
+	}
+
+	statuses := make([]*CommitStatus, 0, len(ids))
+	if len(ids) > 0 {
+		err = db.GetEngine(ctx).In("id", ids).Find(&statuses)
+		if err != nil {
+			return nil, err
+		}
+
+		// Group the statuses by repo ID
+		for _, status := range statuses {
+			repoStatuses[status.RepoID] = append(repoStatuses[status.RepoID], status)
+		}
+	}
+
+	return repoStatuses, nil
+}
+
 // FindRepoRecentCommitStatusContexts returns repository's recent commit status contexts
 func FindRepoRecentCommitStatusContexts(ctx context.Context, repoID int64, before time.Duration) ([]string, error) {
 	start := timeutil.TimeStampNow().AddDuration(-before)
diff --git a/modules/git/repo_branch.go b/modules/git/repo_branch.go
index 14dcf14d8a0d..3bb6ef5223a2 100644
--- a/modules/git/repo_branch.go
+++ b/modules/git/repo_branch.go
@@ -106,6 +106,17 @@ func GetBranchesByPath(ctx context.Context, path string, skip, limit int) ([]*Br
 	return gitRepo.GetBranches(skip, limit)
 }
 
+// GetBranchCommitID returns a branch commit ID by its name
+func GetBranchCommitID(ctx context.Context, path, branch string) (string, error) {
+	gitRepo, err := OpenRepository(ctx, path)
+	if err != nil {
+		return "", err
+	}
+	defer gitRepo.Close()
+
+	return gitRepo.GetBranchCommitID(branch)
+}
+
 // GetBranches returns a slice of *git.Branch
 func (repo *Repository) GetBranches(skip, limit int) ([]*Branch, int, error) {
 	brs, countAll, err := repo.GetBranchNames(skip, limit)
diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go
index 2f87e190228a..f697d9433e15 100644
--- a/routers/web/repo/repo.go
+++ b/routers/web/repo/repo.go
@@ -9,9 +9,11 @@ import (
 	"fmt"
 	"net/http"
 	"strings"
+	"sync"
 
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/models/db"
+	git_model "code.gitea.io/gitea/models/git"
 	"code.gitea.io/gitea/models/organization"
 	access_model "code.gitea.io/gitea/models/perm/access"
 	repo_model "code.gitea.io/gitea/models/repo"
@@ -576,23 +578,49 @@ func SearchRepo(ctx *context.Context) {
 		return
 	}
 
-	results := make([]*api.Repository, len(repos))
+	// collect the latest commit of each repo
+	repoIDsToLatestCommitSHAs := make(map[int64]string)
+	wg := sync.WaitGroup{}
+	wg.Add(len(repos))
+	for _, repo := range repos {
+		go func(repo *repo_model.Repository) {
+			defer wg.Done()
+			commitID, err := repo_service.GetBranchCommitID(ctx, repo, repo.DefaultBranch)
+			if err != nil {
+				return
+			}
+			repoIDsToLatestCommitSHAs[repo.ID] = commitID
+		}(repo)
+	}
+	wg.Wait()
+
+	// call the database O(1) times to get the commit statuses for all repos
+	repoToItsLatestCommitStatuses, err := git_model.GetLatestCommitStatusForPairs(ctx, repoIDsToLatestCommitSHAs, db.ListOptions{})
+	if err != nil {
+		log.Error("GetLatestCommitStatusForPairs: %v", err)
+		return
+	}
+
+	results := make([]*repo_service.WebSearchRepository, len(repos))
 	for i, repo := range repos {
-		results[i] = &api.Repository{
-			ID:       repo.ID,
-			FullName: repo.FullName(),
-			Fork:     repo.IsFork,
-			Private:  repo.IsPrivate,
-			Template: repo.IsTemplate,
-			Mirror:   repo.IsMirror,
-			Stars:    repo.NumStars,
-			HTMLURL:  repo.HTMLURL(),
-			Link:     repo.Link(),
-			Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate,
+		results[i] = &repo_service.WebSearchRepository{
+			Repository: &api.Repository{
+				ID:       repo.ID,
+				FullName: repo.FullName(),
+				Fork:     repo.IsFork,
+				Private:  repo.IsPrivate,
+				Template: repo.IsTemplate,
+				Mirror:   repo.IsMirror,
+				Stars:    repo.NumStars,
+				HTMLURL:  repo.HTMLURL(),
+				Link:     repo.Link(),
+				Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate,
+			},
+			LatestCommitStatus: git_model.CalcCommitStatus(repoToItsLatestCommitStatuses[repo.ID]),
 		}
 	}
 
-	ctx.JSON(http.StatusOK, api.SearchResults{
+	ctx.JSON(http.StatusOK, repo_service.WebSearchResults{
 		OK:   true,
 		Data: results,
 	})
diff --git a/services/repository/branch.go b/services/repository/branch.go
index a085026ae156..cafad34cef17 100644
--- a/services/repository/branch.go
+++ b/services/repository/branch.go
@@ -53,6 +53,10 @@ func GetBranches(ctx context.Context, repo *repo_model.Repository, skip, limit i
 	return git.GetBranchesByPath(ctx, repo.RepoPath(), skip, limit)
 }
 
+func GetBranchCommitID(ctx context.Context, repo *repo_model.Repository, branch string) (string, error) {
+	return git.GetBranchCommitID(ctx, repo.RepoPath(), branch)
+}
+
 // checkBranchName validates branch name with existing repository branches
 func checkBranchName(ctx context.Context, repo *repo_model.Repository, name string) error {
 	_, err := git.WalkReferences(ctx, repo.RepoPath(), func(_, refName string) error {
diff --git a/services/repository/repository.go b/services/repository/repository.go
index 0d6529383cc8..0914a8f6ec6a 100644
--- a/services/repository/repository.go
+++ b/services/repository/repository.go
@@ -9,6 +9,7 @@ import (
 
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/git"
 	issues_model "code.gitea.io/gitea/models/issues"
 	"code.gitea.io/gitea/models/organization"
 	packages_model "code.gitea.io/gitea/models/packages"
@@ -20,9 +21,22 @@ import (
 	"code.gitea.io/gitea/modules/notification"
 	repo_module "code.gitea.io/gitea/modules/repository"
 	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/structs"
 	pull_service "code.gitea.io/gitea/services/pull"
 )
 
+// WebSearchRepository represents a repository returned by web search
+type WebSearchRepository struct {
+	Repository         *structs.Repository `json:"repository"`
+	LatestCommitStatus *git.CommitStatus   `json:"latest_commit_status"`
+}
+
+// WebSearchResults results of a successful web search
+type WebSearchResults struct {
+	OK   bool                   `json:"ok"`
+	Data []*WebSearchRepository `json:"data"`
+}
+
 // CreateRepository creates a repository for the user/organization.
 func CreateRepository(ctx context.Context, doer, owner *user_model.User, opts repo_module.CreateRepoOptions) (*repo_model.Repository, error) {
 	repo, err := repo_module.CreateRepository(doer, owner, opts)
diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue
index 161fca94140e..84ee8866181a 100644
--- a/web_src/js/components/DashboardRepoList.vue
+++ b/web_src/js/components/DashboardRepoList.vue
@@ -79,6 +79,8 @@
                   <svg-icon name="octicon-archive" :size="16" class-name="gt-ml-2"/>
                 </span>
               </div>
+              <!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl -->
+              <svg-icon v-if="repo.latest_commit_status_state" :name="statusIcon(repo.latest_commit_status_state)" :class-name="'commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/>
             </a>
           </li>
         </ul>
@@ -154,6 +156,15 @@ import {SvgIcon} from '../svg.js';
 
 const {appSubUrl, assetUrlPrefix, pageData} = window.config;
 
+const commitStatus = {
+  pending: {name: 'octicon-dot-fill', color: 'grey'},
+  running: {name: 'octicon-dot-fill', color: 'yellow'},
+  success: {name: 'octicon-check', color: 'green'},
+  error: {name: 'gitea-exclamation', color: 'red'},
+  failure: {name: 'octicon-x', color: 'red'},
+  warning: {name: 'gitea-exclamation', color: 'yellow'},
+};
+
 const sfc = {
   components: {SvgIcon},
   data() {
@@ -387,7 +398,7 @@ const sfc = {
       }
 
       if (searchedURL === this.searchURL) {
-        this.repos = json.data;
+        this.repos = json.data.map((webSearchRepo) => {return {...webSearchRepo.repository, latest_commit_status_state: webSearchRepo.latest_commit_status.State}});
         const count = response.headers.get('X-Total-Count');
         if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') {
           this.reposTotalCount = count;
@@ -412,6 +423,14 @@ const sfc = {
         return 'octicon-repo';
       }
       return 'octicon-repo';
+    },
+
+    statusIcon(status) {
+      return commitStatus[status].name;
+    },
+
+    statusColor(status) {
+      return commitStatus[status].color;
     }
   },
 };
diff --git a/web_src/js/features/org-team.js b/web_src/js/features/org-team.js
index 3640bb96f740..957dce02d8e3 100644
--- a/web_src/js/features/org-team.js
+++ b/web_src/js/features/org-team.js
@@ -26,8 +26,8 @@ export function initOrgTeamSearchRepoBox() {
         const items = [];
         $.each(response.data, (_i, item) => {
           items.push({
-            title: item.full_name.split('/')[1],
-            description: item.full_name
+            title: item.repository.full_name.split('/')[1],
+            description: item.repository.full_name
           });
         });
 
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js
index d2942cd93331..3723e0f627e6 100644
--- a/web_src/js/features/repo-issue.js
+++ b/web_src/js/features/repo-issue.js
@@ -291,8 +291,8 @@ export function initRepoIssueReferenceRepositorySearch() {
           const filteredResponse = {success: true, results: []};
           $.each(response.data, (_r, repo) => {
             filteredResponse.results.push({
-              name: htmlEscape(repo.full_name),
-              value: repo.full_name
+              name: htmlEscape(repo.repository.full_name),
+              value: repo.repository.full_name
             });
           });
           return filteredResponse;
diff --git a/web_src/js/features/repo-template.js b/web_src/js/features/repo-template.js
index 0c5ea5233af9..1e83e74780fb 100644
--- a/web_src/js/features/repo-template.js
+++ b/web_src/js/features/repo-template.js
@@ -34,8 +34,8 @@ export function initRepoTemplateSearch() {
             // Parse the response from the api to work with our dropdown
             $.each(response.data, (_r, repo) => {
               filteredResponse.results.push({
-                name: htmlEscape(repo.full_name),
-                value: repo.id
+                name: htmlEscape(repo.repository.full_name),
+                value: repo.repository.id
               });
             });
             return filteredResponse;
diff --git a/web_src/js/svg.js b/web_src/js/svg.js
index 0894bbb169c2..49376c16434a 100644
--- a/web_src/js/svg.js
+++ b/web_src/js/svg.js
@@ -2,10 +2,12 @@ import {h} from 'vue';
 import giteaDoubleChevronLeft from '../../public/img/svg/gitea-double-chevron-left.svg';
 import giteaDoubleChevronRight from '../../public/img/svg/gitea-double-chevron-right.svg';
 import giteaEmptyCheckbox from '../../public/img/svg/gitea-empty-checkbox.svg';
+import giteaExclamation from '../../public/img/svg/gitea-exclamation.svg';
 import octiconArchive from '../../public/img/svg/octicon-archive.svg';
 import octiconArrowSwitch from '../../public/img/svg/octicon-arrow-switch.svg';
 import octiconBlocked from '../../public/img/svg/octicon-blocked.svg';
 import octiconBold from '../../public/img/svg/octicon-bold.svg';
+import octiconCheck from '../../public/img/svg/octicon-check.svg';
 import octiconCheckbox from '../../public/img/svg/octicon-checkbox.svg';
 import octiconCheckCircleFill from '../../public/img/svg/octicon-check-circle-fill.svg';
 import octiconChevronDown from '../../public/img/svg/octicon-chevron-down.svg';
@@ -19,6 +21,7 @@ import octiconDiffAdded from '../../public/img/svg/octicon-diff-added.svg';
 import octiconDiffModified from '../../public/img/svg/octicon-diff-modified.svg';
 import octiconDiffRemoved from '../../public/img/svg/octicon-diff-removed.svg';
 import octiconDiffRenamed from '../../public/img/svg/octicon-diff-renamed.svg';
+import octiconDotFill from '../../public/img/svg/octicon-dot-fill.svg';
 import octiconEye from '../../public/img/svg/octicon-eye.svg';
 import octiconFile from '../../public/img/svg/octicon-file.svg';
 import octiconFileDirectoryFill from '../../public/img/svg/octicon-file-directory-fill.svg';
@@ -67,10 +70,12 @@ const svgs = {
   'gitea-double-chevron-left': giteaDoubleChevronLeft,
   'gitea-double-chevron-right': giteaDoubleChevronRight,
   'gitea-empty-checkbox': giteaEmptyCheckbox,
+  'gitea-exclamation': giteaExclamation,
   'octicon-archive': octiconArchive,
   'octicon-arrow-switch': octiconArrowSwitch,
   'octicon-blocked': octiconBlocked,
   'octicon-bold': octiconBold,
+  'octicon-check': octiconCheck,
   'octicon-check-circle-fill': octiconCheckCircleFill,
   'octicon-checkbox': octiconCheckbox,
   'octicon-chevron-down': octiconChevronDown,
@@ -84,6 +89,7 @@ const svgs = {
   'octicon-diff-modified': octiconDiffModified,
   'octicon-diff-removed': octiconDiffRemoved,
   'octicon-diff-renamed': octiconDiffRenamed,
+  'octicon-dot-fill': octiconDotFill,
   'octicon-eye': octiconEye,
   'octicon-file': octiconFile,
   'octicon-file-directory-fill': octiconFileDirectoryFill,