Add status indicator on main home screen for each repo ()

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 

# 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>
This commit is contained in:
Yarden Shoham 2023-05-14 00:59:01 +03:00 committed by GitHub
parent 68081c4721
commit 4810fe55e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 152 additions and 20 deletions
models/git
modules/git
routers/web/repo
services/repository
web_src/js

View file

@ -23,6 +23,7 @@ import (
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"xorm.io/builder"
"xorm.io/xorm" "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) 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 // FindRepoRecentCommitStatusContexts returns repository's recent commit status contexts
func FindRepoRecentCommitStatusContexts(ctx context.Context, repoID int64, before time.Duration) ([]string, error) { func FindRepoRecentCommitStatusContexts(ctx context.Context, repoID int64, before time.Duration) ([]string, error) {
start := timeutil.TimeStampNow().AddDuration(-before) start := timeutil.TimeStampNow().AddDuration(-before)

View file

@ -106,6 +106,17 @@ func GetBranchesByPath(ctx context.Context, path string, skip, limit int) ([]*Br
return gitRepo.GetBranches(skip, limit) 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 // GetBranches returns a slice of *git.Branch
func (repo *Repository) GetBranches(skip, limit int) ([]*Branch, int, error) { func (repo *Repository) GetBranches(skip, limit int) ([]*Branch, int, error) {
brs, countAll, err := repo.GetBranchNames(skip, limit) brs, countAll, err := repo.GetBranchNames(skip, limit)

View file

@ -9,9 +9,11 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"sync"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/organization"
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
@ -576,9 +578,33 @@ func SearchRepo(ctx *context.Context) {
return 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 { for i, repo := range repos {
results[i] = &api.Repository{ results[i] = &repo_service.WebSearchRepository{
Repository: &api.Repository{
ID: repo.ID, ID: repo.ID,
FullName: repo.FullName(), FullName: repo.FullName(),
Fork: repo.IsFork, Fork: repo.IsFork,
@ -589,10 +615,12 @@ func SearchRepo(ctx *context.Context) {
HTMLURL: repo.HTMLURL(), HTMLURL: repo.HTMLURL(),
Link: repo.Link(), Link: repo.Link(),
Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate, 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, OK: true,
Data: results, Data: results,
}) })

View file

@ -53,6 +53,10 @@ func GetBranches(ctx context.Context, repo *repo_model.Repository, skip, limit i
return git.GetBranchesByPath(ctx, repo.RepoPath(), skip, limit) 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 // checkBranchName validates branch name with existing repository branches
func checkBranchName(ctx context.Context, repo *repo_model.Repository, name string) error { func checkBranchName(ctx context.Context, repo *repo_model.Repository, name string) error {
_, err := git.WalkReferences(ctx, repo.RepoPath(), func(_, refName string) error { _, err := git.WalkReferences(ctx, repo.RepoPath(), func(_, refName string) error {

View file

@ -9,6 +9,7 @@ import (
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/organization"
packages_model "code.gitea.io/gitea/models/packages" packages_model "code.gitea.io/gitea/models/packages"
@ -20,9 +21,22 @@ import (
"code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/notification"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
pull_service "code.gitea.io/gitea/services/pull" 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. // 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) { 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) repo, err := repo_module.CreateRepository(doer, owner, opts)

View file

@ -79,6 +79,8 @@
<svg-icon name="octicon-archive" :size="16" class-name="gt-ml-2"/> <svg-icon name="octicon-archive" :size="16" class-name="gt-ml-2"/>
</span> </span>
</div> </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> </a>
</li> </li>
</ul> </ul>
@ -154,6 +156,15 @@ import {SvgIcon} from '../svg.js';
const {appSubUrl, assetUrlPrefix, pageData} = window.config; 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 = { const sfc = {
components: {SvgIcon}, components: {SvgIcon},
data() { data() {
@ -387,7 +398,7 @@ const sfc = {
} }
if (searchedURL === this.searchURL) { 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'); const count = response.headers.get('X-Total-Count');
if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') { if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') {
this.reposTotalCount = count; this.reposTotalCount = count;
@ -412,6 +423,14 @@ const sfc = {
return 'octicon-repo'; return 'octicon-repo';
} }
return 'octicon-repo'; return 'octicon-repo';
},
statusIcon(status) {
return commitStatus[status].name;
},
statusColor(status) {
return commitStatus[status].color;
} }
}, },
}; };

View file

@ -26,8 +26,8 @@ export function initOrgTeamSearchRepoBox() {
const items = []; const items = [];
$.each(response.data, (_i, item) => { $.each(response.data, (_i, item) => {
items.push({ items.push({
title: item.full_name.split('/')[1], title: item.repository.full_name.split('/')[1],
description: item.full_name description: item.repository.full_name
}); });
}); });

View file

@ -291,8 +291,8 @@ export function initRepoIssueReferenceRepositorySearch() {
const filteredResponse = {success: true, results: []}; const filteredResponse = {success: true, results: []};
$.each(response.data, (_r, repo) => { $.each(response.data, (_r, repo) => {
filteredResponse.results.push({ filteredResponse.results.push({
name: htmlEscape(repo.full_name), name: htmlEscape(repo.repository.full_name),
value: repo.full_name value: repo.repository.full_name
}); });
}); });
return filteredResponse; return filteredResponse;

View file

@ -34,8 +34,8 @@ export function initRepoTemplateSearch() {
// Parse the response from the api to work with our dropdown // Parse the response from the api to work with our dropdown
$.each(response.data, (_r, repo) => { $.each(response.data, (_r, repo) => {
filteredResponse.results.push({ filteredResponse.results.push({
name: htmlEscape(repo.full_name), name: htmlEscape(repo.repository.full_name),
value: repo.id value: repo.repository.id
}); });
}); });
return filteredResponse; return filteredResponse;

View file

@ -2,10 +2,12 @@ import {h} from 'vue';
import giteaDoubleChevronLeft from '../../public/img/svg/gitea-double-chevron-left.svg'; import giteaDoubleChevronLeft from '../../public/img/svg/gitea-double-chevron-left.svg';
import giteaDoubleChevronRight from '../../public/img/svg/gitea-double-chevron-right.svg'; import giteaDoubleChevronRight from '../../public/img/svg/gitea-double-chevron-right.svg';
import giteaEmptyCheckbox from '../../public/img/svg/gitea-empty-checkbox.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 octiconArchive from '../../public/img/svg/octicon-archive.svg';
import octiconArrowSwitch from '../../public/img/svg/octicon-arrow-switch.svg'; import octiconArrowSwitch from '../../public/img/svg/octicon-arrow-switch.svg';
import octiconBlocked from '../../public/img/svg/octicon-blocked.svg'; import octiconBlocked from '../../public/img/svg/octicon-blocked.svg';
import octiconBold from '../../public/img/svg/octicon-bold.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 octiconCheckbox from '../../public/img/svg/octicon-checkbox.svg';
import octiconCheckCircleFill from '../../public/img/svg/octicon-check-circle-fill.svg'; import octiconCheckCircleFill from '../../public/img/svg/octicon-check-circle-fill.svg';
import octiconChevronDown from '../../public/img/svg/octicon-chevron-down.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 octiconDiffModified from '../../public/img/svg/octicon-diff-modified.svg';
import octiconDiffRemoved from '../../public/img/svg/octicon-diff-removed.svg'; import octiconDiffRemoved from '../../public/img/svg/octicon-diff-removed.svg';
import octiconDiffRenamed from '../../public/img/svg/octicon-diff-renamed.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 octiconEye from '../../public/img/svg/octicon-eye.svg';
import octiconFile from '../../public/img/svg/octicon-file.svg'; import octiconFile from '../../public/img/svg/octicon-file.svg';
import octiconFileDirectoryFill from '../../public/img/svg/octicon-file-directory-fill.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-left': giteaDoubleChevronLeft,
'gitea-double-chevron-right': giteaDoubleChevronRight, 'gitea-double-chevron-right': giteaDoubleChevronRight,
'gitea-empty-checkbox': giteaEmptyCheckbox, 'gitea-empty-checkbox': giteaEmptyCheckbox,
'gitea-exclamation': giteaExclamation,
'octicon-archive': octiconArchive, 'octicon-archive': octiconArchive,
'octicon-arrow-switch': octiconArrowSwitch, 'octicon-arrow-switch': octiconArrowSwitch,
'octicon-blocked': octiconBlocked, 'octicon-blocked': octiconBlocked,
'octicon-bold': octiconBold, 'octicon-bold': octiconBold,
'octicon-check': octiconCheck,
'octicon-check-circle-fill': octiconCheckCircleFill, 'octicon-check-circle-fill': octiconCheckCircleFill,
'octicon-checkbox': octiconCheckbox, 'octicon-checkbox': octiconCheckbox,
'octicon-chevron-down': octiconChevronDown, 'octicon-chevron-down': octiconChevronDown,
@ -84,6 +89,7 @@ const svgs = {
'octicon-diff-modified': octiconDiffModified, 'octicon-diff-modified': octiconDiffModified,
'octicon-diff-removed': octiconDiffRemoved, 'octicon-diff-removed': octiconDiffRemoved,
'octicon-diff-renamed': octiconDiffRenamed, 'octicon-diff-renamed': octiconDiffRenamed,
'octicon-dot-fill': octiconDotFill,
'octicon-eye': octiconEye, 'octicon-eye': octiconEye,
'octicon-file': octiconFile, 'octicon-file': octiconFile,
'octicon-file-directory-fill': octiconFileDirectoryFill, 'octicon-file-directory-fill': octiconFileDirectoryFill,