From 4810fe55e3e73edb962052df46bef125eb1817b3 Mon Sep 17 00:00:00 2001 From: Yarden Shoham 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 Co-authored-by: delvh Co-authored-by: Giteabot --- 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 82cbb23637..6028e46649 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 14dcf14d8a..3bb6ef5223 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 2f87e19022..f697d9433e 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 a085026ae1..cafad34cef 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 0d6529383c..0914a8f6ec 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 161fca9414..84ee886618 100644 --- a/web_src/js/components/DashboardRepoList.vue +++ b/web_src/js/components/DashboardRepoList.vue @@ -79,6 +79,8 @@ + + @@ -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 3640bb96f7..957dce02d8 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 d2942cd933..3723e0f627 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 0c5ea5233a..1e83e74780 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 0894bbb169..49376c1643 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,