mirror of https://github.com/go-gitea/gitea
Merge branch 'main' of github.com:go-gitea/gitea into api-repo-actions
This commit is contained in:
commit
38d1197a39
|
@ -81,7 +81,7 @@ docker run --entrypoint="" --rm -it gitea/act_runner:latest act_runner generate-
|
|||
When you are using the docker image, you can specify the configuration file by using the `CONFIG_FILE` environment variable. Make sure that the file is mounted into the container as a volume:
|
||||
|
||||
```bash
|
||||
docker run -v $(pwd)/config.yaml:/config.yaml -e CONFIG_FILE=/config.yaml ...
|
||||
docker run -v $PWD/config.yaml:/config.yaml -e CONFIG_FILE=/config.yaml ...
|
||||
```
|
||||
|
||||
You may notice the commands above are both incomplete, because it is not the time to run the act runner yet.
|
||||
|
@ -157,8 +157,8 @@ If you are using the docker image, behaviour will be slightly different. Registr
|
|||
|
||||
```bash
|
||||
docker run \
|
||||
-v $(pwd)/config.yaml:/config.yaml \
|
||||
-v $(pwd)/data:/data \
|
||||
-v $PWD/config.yaml:/config.yaml \
|
||||
-v $PWD/data:/data \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e CONFIG_FILE=/config.yaml \
|
||||
-e GITEA_INSTANCE_URL=<instance_url> \
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
date: "2023-08-22T14:21:00+08:00"
|
||||
title: "Usage: Multi-factor Authentication (MFA)"
|
||||
slug: "multi-factor-authentication"
|
||||
weight: 15
|
||||
toc: false
|
||||
draft: false
|
||||
menu:
|
||||
sidebar:
|
||||
parent: "usage"
|
||||
name: "Multi-factor Authentication (MFA)"
|
||||
weight: 15
|
||||
identifier: "multi-factor-authentication"
|
||||
---
|
||||
|
||||
# Multi-factor Authentication (MFA)
|
||||
|
||||
Multi-factor Authentication (also referred to as MFA or 2FA) enhances security by requiring a time-sensitive set of credentials in addition to a password.
|
||||
If a password were later to be compromised, logging into Gitea will not be possible without the additional credentials and the account would remain secure.
|
||||
Gitea supports both TOTP (Time-based One-Time Password) tokens and FIDO-based hardware keys using the Webauthn API.
|
||||
|
||||
MFA can be configured within the "Security" tab of the user settings page.
|
||||
|
||||
## MFA Considerations
|
||||
|
||||
Enabling MFA on a user does affect how the Git HTTP protocol can be used with the Git CLI.
|
||||
This interface does not support MFA, and trying to use a password normally will no longer be possible whilst MFA is enabled.
|
||||
If SSH is not an option for Git operations, an access token can be generated within the "Applications" tab of the user settings page.
|
||||
This access token can be used as if it were a password in order to allow the Git CLI to function over HTTP.
|
||||
|
||||
> **Warning** - By its very nature, an access token sidesteps the security benefits of MFA.
|
||||
> It must be kept secure and should only be used as a last resort.
|
||||
|
||||
The Gitea API supports providing the relevant TOTP password in the `X-Gitea-OTP` header, as described in [API Usage](development/api-usage.md).
|
||||
This should be used instead of an access token where possible.
|
|
@ -116,70 +116,71 @@ func GetIssueStats(opts *IssuesOptions) (*IssueStats, error) {
|
|||
func getIssueStatsChunk(opts *IssuesOptions, issueIDs []int64) (*IssueStats, error) {
|
||||
stats := &IssueStats{}
|
||||
|
||||
countSession := func(opts *IssuesOptions, issueIDs []int64) *xorm.Session {
|
||||
sess := db.GetEngine(db.DefaultContext).
|
||||
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
|
||||
if len(opts.RepoIDs) > 1 {
|
||||
sess.In("issue.repo_id", opts.RepoIDs)
|
||||
} else if len(opts.RepoIDs) == 1 {
|
||||
sess.And("issue.repo_id = ?", opts.RepoIDs[0])
|
||||
}
|
||||
|
||||
if len(issueIDs) > 0 {
|
||||
sess.In("issue.id", issueIDs)
|
||||
}
|
||||
|
||||
applyLabelsCondition(sess, opts)
|
||||
|
||||
applyMilestoneCondition(sess, opts)
|
||||
|
||||
applyProjectCondition(sess, opts)
|
||||
|
||||
if opts.AssigneeID > 0 {
|
||||
applyAssigneeCondition(sess, opts.AssigneeID)
|
||||
} else if opts.AssigneeID == db.NoConditionID {
|
||||
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
|
||||
}
|
||||
|
||||
if opts.PosterID > 0 {
|
||||
applyPosterCondition(sess, opts.PosterID)
|
||||
}
|
||||
|
||||
if opts.MentionedID > 0 {
|
||||
applyMentionedCondition(sess, opts.MentionedID)
|
||||
}
|
||||
|
||||
if opts.ReviewRequestedID > 0 {
|
||||
applyReviewRequestedCondition(sess, opts.ReviewRequestedID)
|
||||
}
|
||||
|
||||
if opts.ReviewedID > 0 {
|
||||
applyReviewedCondition(sess, opts.ReviewedID)
|
||||
}
|
||||
|
||||
switch opts.IsPull {
|
||||
case util.OptionalBoolTrue:
|
||||
sess.And("issue.is_pull=?", true)
|
||||
case util.OptionalBoolFalse:
|
||||
sess.And("issue.is_pull=?", false)
|
||||
}
|
||||
|
||||
return sess
|
||||
}
|
||||
sess := db.GetEngine(db.DefaultContext).
|
||||
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
|
||||
|
||||
var err error
|
||||
stats.OpenCount, err = countSession(opts, issueIDs).
|
||||
stats.OpenCount, err = applyIssuesOptions(sess, opts, issueIDs).
|
||||
And("issue.is_closed = ?", false).
|
||||
Count(new(Issue))
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.ClosedCount, err = countSession(opts, issueIDs).
|
||||
stats.ClosedCount, err = applyIssuesOptions(sess, opts, issueIDs).
|
||||
And("issue.is_closed = ?", true).
|
||||
Count(new(Issue))
|
||||
return stats, err
|
||||
}
|
||||
|
||||
func applyIssuesOptions(sess *xorm.Session, opts *IssuesOptions, issueIDs []int64) *xorm.Session {
|
||||
if len(opts.RepoIDs) > 1 {
|
||||
sess.In("issue.repo_id", opts.RepoIDs)
|
||||
} else if len(opts.RepoIDs) == 1 {
|
||||
sess.And("issue.repo_id = ?", opts.RepoIDs[0])
|
||||
}
|
||||
|
||||
if len(issueIDs) > 0 {
|
||||
sess.In("issue.id", issueIDs)
|
||||
}
|
||||
|
||||
applyLabelsCondition(sess, opts)
|
||||
|
||||
applyMilestoneCondition(sess, opts)
|
||||
|
||||
applyProjectCondition(sess, opts)
|
||||
|
||||
if opts.AssigneeID > 0 {
|
||||
applyAssigneeCondition(sess, opts.AssigneeID)
|
||||
} else if opts.AssigneeID == db.NoConditionID {
|
||||
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
|
||||
}
|
||||
|
||||
if opts.PosterID > 0 {
|
||||
applyPosterCondition(sess, opts.PosterID)
|
||||
}
|
||||
|
||||
if opts.MentionedID > 0 {
|
||||
applyMentionedCondition(sess, opts.MentionedID)
|
||||
}
|
||||
|
||||
if opts.ReviewRequestedID > 0 {
|
||||
applyReviewRequestedCondition(sess, opts.ReviewRequestedID)
|
||||
}
|
||||
|
||||
if opts.ReviewedID > 0 {
|
||||
applyReviewedCondition(sess, opts.ReviewedID)
|
||||
}
|
||||
|
||||
switch opts.IsPull {
|
||||
case util.OptionalBoolTrue:
|
||||
sess.And("issue.is_pull=?", true)
|
||||
case util.OptionalBoolFalse:
|
||||
sess.And("issue.is_pull=?", false)
|
||||
}
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
// GetUserIssueStats returns issue statistic information for dashboard by given conditions.
|
||||
func GetUserIssueStats(filterMode int, opts IssuesOptions) (*IssueStats, error) {
|
||||
if opts.User == nil {
|
||||
|
|
|
@ -113,10 +113,11 @@ func (l *Label) CalOpenIssues() {
|
|||
|
||||
// SetArchived set the label as archived
|
||||
func (l *Label) SetArchived(isArchived bool) {
|
||||
if isArchived && l.ArchivedUnix.IsZero() {
|
||||
l.ArchivedUnix = timeutil.TimeStampNow()
|
||||
} else {
|
||||
if !isArchived {
|
||||
l.ArchivedUnix = timeutil.TimeStamp(0)
|
||||
} else if isArchived && l.ArchivedUnix.IsZero() {
|
||||
// Only change the date when it is newly archived.
|
||||
l.ArchivedUnix = timeutil.TimeStampNow()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -130,6 +130,10 @@ type SearchRepoOptions struct {
|
|||
// True -> include just collaborative
|
||||
// False -> include just non-collaborative
|
||||
Collaborate util.OptionalBool
|
||||
// What type of unit the user can be collaborative in,
|
||||
// it is ignored if Collaborate is False.
|
||||
// TypeInvalid means any unit type.
|
||||
UnitType unit.Type
|
||||
// None -> include forks AND non-forks
|
||||
// True -> include just forks
|
||||
// False -> include just non-forks
|
||||
|
@ -382,19 +386,25 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond {
|
|||
|
||||
if opts.Collaborate != util.OptionalBoolFalse {
|
||||
// A Collaboration is:
|
||||
collaborateCond := builder.And(
|
||||
// 1. Repository we don't own
|
||||
builder.Neq{"owner_id": opts.OwnerID},
|
||||
// 2. But we can see because of:
|
||||
builder.Or(
|
||||
// A. We have unit independent access
|
||||
UserAccessRepoCond("`repository`.id", opts.OwnerID),
|
||||
// B. We are in a team for
|
||||
UserOrgTeamRepoCond("`repository`.id", opts.OwnerID),
|
||||
// C. Public repositories in organizations that we are member of
|
||||
userOrgPublicRepoCondPrivate(opts.OwnerID),
|
||||
),
|
||||
)
|
||||
|
||||
collaborateCond := builder.NewCond()
|
||||
// 1. Repository we don't own
|
||||
collaborateCond = collaborateCond.And(builder.Neq{"owner_id": opts.OwnerID})
|
||||
// 2. But we can see because of:
|
||||
{
|
||||
userAccessCond := builder.NewCond()
|
||||
// A. We have unit independent access
|
||||
userAccessCond = userAccessCond.Or(UserAccessRepoCond("`repository`.id", opts.OwnerID))
|
||||
// B. We are in a team for
|
||||
if opts.UnitType == unit.TypeInvalid {
|
||||
userAccessCond = userAccessCond.Or(UserOrgTeamRepoCond("`repository`.id", opts.OwnerID))
|
||||
} else {
|
||||
userAccessCond = userAccessCond.Or(userOrgTeamUnitRepoCond("`repository`.id", opts.OwnerID, opts.UnitType))
|
||||
}
|
||||
// C. Public repositories in organizations that we are member of
|
||||
userAccessCond = userAccessCond.Or(userOrgPublicRepoCondPrivate(opts.OwnerID))
|
||||
collaborateCond = collaborateCond.And(userAccessCond)
|
||||
}
|
||||
if !opts.Private {
|
||||
collaborateCond = collaborateCond.And(builder.Expr("owner_id NOT IN (SELECT org_id FROM org_user WHERE org_user.uid = ? AND org_user.is_public = ?)", opts.OwnerID, false))
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
|
||||
db_model "code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/indexer/issues/bleve"
|
||||
"code.gitea.io/gitea/modules/indexer/issues/db"
|
||||
|
@ -277,7 +278,7 @@ func IsAvailable(ctx context.Context) bool {
|
|||
}
|
||||
|
||||
// SearchOptions indicates the options for searching issues
|
||||
type SearchOptions internal.SearchOptions
|
||||
type SearchOptions = internal.SearchOptions
|
||||
|
||||
const (
|
||||
SortByCreatedDesc = internal.SortByCreatedDesc
|
||||
|
@ -291,7 +292,6 @@ const (
|
|||
)
|
||||
|
||||
// SearchIssues search issues by options.
|
||||
// It returns issue ids and a bool value indicates if the result is imprecise.
|
||||
func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, error) {
|
||||
indexer := *globalIndexer.Load()
|
||||
|
||||
|
@ -305,7 +305,7 @@ func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, err
|
|||
indexer = db.NewIndexer()
|
||||
}
|
||||
|
||||
result, err := indexer.Search(ctx, (*internal.SearchOptions)(opts))
|
||||
result, err := indexer.Search(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
@ -317,3 +317,38 @@ func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, err
|
|||
|
||||
return ret, result.Total, nil
|
||||
}
|
||||
|
||||
// CountIssues counts issues by options. It is a shortcut of SearchIssues(ctx, opts) but only returns the total count.
|
||||
func CountIssues(ctx context.Context, opts *SearchOptions) (int64, error) {
|
||||
opts = opts.Copy(func(options *SearchOptions) { opts.Paginator = &db_model.ListOptions{PageSize: 0} })
|
||||
|
||||
_, total, err := SearchIssues(ctx, opts)
|
||||
return total, err
|
||||
}
|
||||
|
||||
// CountIssuesByRepo counts issues by options and group by repo id.
|
||||
// It's not a complete implementation, since it requires the caller should provide the repo ids.
|
||||
// That means opts.RepoIDs must be specified, and opts.AllPublic must be false.
|
||||
// It's good enough for the current usage, and it can be improved if needed.
|
||||
// TODO: use "group by" of the indexer engines to implement it.
|
||||
func CountIssuesByRepo(ctx context.Context, opts *SearchOptions) (map[int64]int64, error) {
|
||||
if len(opts.RepoIDs) == 0 {
|
||||
return nil, fmt.Errorf("opts.RepoIDs must be specified")
|
||||
}
|
||||
if opts.AllPublic {
|
||||
return nil, fmt.Errorf("opts.AllPublic must be false")
|
||||
}
|
||||
|
||||
repoIDs := container.SetOf(opts.RepoIDs...).Values()
|
||||
ret := make(map[int64]int64, len(repoIDs))
|
||||
// TODO: it could be faster if do it in parallel for some indexer engines. Improve it if users report it's slow.
|
||||
for _, repoID := range repoIDs {
|
||||
count, err := CountIssues(ctx, opts.Copy(func(o *internal.SearchOptions) { o.RepoIDs = []int64{repoID} }))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret[repoID] = count
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
|
|
@ -109,6 +109,19 @@ type SearchOptions struct {
|
|||
SortBy SortBy // sort by field
|
||||
}
|
||||
|
||||
// Copy returns a copy of the options.
|
||||
// Be careful, it's not a deep copy, so `SearchOptions.RepoIDs = {...}` is OK while `SearchOptions.RepoIDs[0] = ...` is not.
|
||||
func (o *SearchOptions) Copy(edit ...func(options *SearchOptions)) *SearchOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
v := *o
|
||||
for _, e := range edit {
|
||||
e(&v)
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
type SortBy string
|
||||
|
||||
const (
|
||||
|
|
|
@ -480,5 +480,5 @@ func DeleteAvatar(ctx *context.Context) {
|
|||
ctx.Flash.Error(err.Error())
|
||||
}
|
||||
|
||||
ctx.Redirect(setting.AppSubURL + "/admin/users/" + strconv.FormatInt(u.ID, 10))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/admin/users/" + strconv.FormatInt(u.ID, 10))
|
||||
}
|
||||
|
|
|
@ -156,7 +156,7 @@ func SettingsDeleteAvatar(ctx *context.Context) {
|
|||
ctx.Flash.Error(err.Error())
|
||||
}
|
||||
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings")
|
||||
ctx.JSONRedirect(ctx.Org.OrgLink + "/settings")
|
||||
}
|
||||
|
||||
// SettingsDelete response for deleting an organization
|
||||
|
|
|
@ -72,5 +72,5 @@ func SettingsDeleteAvatar(ctx *context.Context) {
|
|||
if err := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository); err != nil {
|
||||
ctx.Flash.Error(fmt.Sprintf("DeleteAvatar: %v", err))
|
||||
}
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings")
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings")
|
||||
}
|
||||
|
|
|
@ -448,21 +448,26 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
|
|||
// - Team org's owns the repository.
|
||||
// - Team has read permission to repository.
|
||||
repoOpts := &repo_model.SearchRepoOptions{
|
||||
Actor: ctx.Doer,
|
||||
OwnerID: ctx.Doer.ID,
|
||||
Private: true,
|
||||
AllPublic: false,
|
||||
AllLimited: false,
|
||||
Actor: ctx.Doer,
|
||||
OwnerID: ctx.Doer.ID,
|
||||
Private: true,
|
||||
AllPublic: false,
|
||||
AllLimited: false,
|
||||
Collaborate: util.OptionalBoolNone,
|
||||
UnitType: unitType,
|
||||
Archived: util.OptionalBoolFalse,
|
||||
}
|
||||
if team != nil {
|
||||
repoOpts.TeamID = team.ID
|
||||
}
|
||||
accessibleRepos := container.Set[int64]{}
|
||||
{
|
||||
ids, _, err := repo_model.SearchRepositoryIDs(repoOpts)
|
||||
if err != nil {
|
||||
ctx.ServerError("SearchRepositoryIDs", err)
|
||||
return
|
||||
}
|
||||
accessibleRepos.AddMultiple(ids...)
|
||||
opts.RepoIDs = ids
|
||||
if len(opts.RepoIDs) == 0 {
|
||||
// no repos found, don't let the indexer return all repos
|
||||
|
@ -489,40 +494,16 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
|
|||
keyword := strings.Trim(ctx.FormString("q"), " ")
|
||||
ctx.Data["Keyword"] = keyword
|
||||
|
||||
accessibleRepos := container.Set[int64]{}
|
||||
{
|
||||
ids, err := issues_model.GetRepoIDsForIssuesOptions(opts, ctxUser)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRepoIDsForIssuesOptions", err)
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
accessibleRepos.Add(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Educated guess: Do or don't show closed issues.
|
||||
isShowClosed := ctx.FormString("state") == "closed"
|
||||
opts.IsClosed = util.OptionalBoolOf(isShowClosed)
|
||||
|
||||
// Filter repos and count issues in them. Count will be used later.
|
||||
// USING NON-FINAL STATE OF opts FOR A QUERY.
|
||||
var issueCountByRepo map[int64]int64
|
||||
{
|
||||
issueIDs, err := issueIDsFromSearch(ctx, keyword, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("issueIDsFromSearch", err)
|
||||
return
|
||||
}
|
||||
if len(issueIDs) > 0 { // else, no issues found, just leave issueCountByRepo empty
|
||||
opts.IssueIDs = issueIDs
|
||||
issueCountByRepo, err = issues_model.CountIssuesByRepo(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("CountIssuesByRepo", err)
|
||||
return
|
||||
}
|
||||
opts.IssueIDs = nil // reset, the opts will be used later
|
||||
}
|
||||
issueCountByRepo, err := issue_indexer.CountIssuesByRepo(ctx, issue_indexer.ToSearchOptions(keyword, opts))
|
||||
if err != nil {
|
||||
ctx.ServerError("CountIssuesByRepo", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure page number is at least 1. Will be posted to ctx.Data.
|
||||
|
@ -551,13 +532,13 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
|
|||
|
||||
// Parse ctx.FormString("repos") and remember matched repo IDs for later.
|
||||
// Gets set when clicking filters on the issues overview page.
|
||||
repoIDs := getRepoIDs(ctx.FormString("repos"))
|
||||
if len(repoIDs) > 0 {
|
||||
// Remove repo IDs that are not accessible to the user.
|
||||
repoIDs = util.SliceRemoveAllFunc(repoIDs, func(v int64) bool {
|
||||
return !accessibleRepos.Contains(v)
|
||||
})
|
||||
opts.RepoIDs = repoIDs
|
||||
selectedRepoIDs := getRepoIDs(ctx.FormString("repos"))
|
||||
// Remove repo IDs that are not accessible to the user.
|
||||
selectedRepoIDs = util.SliceRemoveAllFunc(selectedRepoIDs, func(v int64) bool {
|
||||
return !accessibleRepos.Contains(v)
|
||||
})
|
||||
if len(selectedRepoIDs) > 0 {
|
||||
opts.RepoIDs = selectedRepoIDs
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
|
@ -568,7 +549,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
|
|||
// USING FINAL STATE OF opts FOR A QUERY.
|
||||
var issues issues_model.IssueList
|
||||
{
|
||||
issueIDs, err := issueIDsFromSearch(ctx, keyword, opts)
|
||||
issueIDs, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts))
|
||||
if err != nil {
|
||||
ctx.ServerError("issueIDsFromSearch", err)
|
||||
return
|
||||
|
@ -584,6 +565,18 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
|
|||
// Add repository pointers to Issues.
|
||||
// ----------------------------------
|
||||
|
||||
// Remove repositories that should not be shown,
|
||||
// which are repositories that have no issues and are not selected by the user.
|
||||
selectedReposMap := make(map[int64]struct{}, len(selectedRepoIDs))
|
||||
for _, repoID := range selectedRepoIDs {
|
||||
selectedReposMap[repoID] = struct{}{}
|
||||
}
|
||||
for k, v := range issueCountByRepo {
|
||||
if _, ok := selectedReposMap[k]; !ok && v == 0 {
|
||||
delete(issueCountByRepo, k)
|
||||
}
|
||||
}
|
||||
|
||||
// showReposMap maps repository IDs to their Repository pointers.
|
||||
showReposMap, err := loadRepoByIDs(ctxUser, issueCountByRepo, unitType)
|
||||
if err != nil {
|
||||
|
@ -615,44 +608,10 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
|
|||
// -------------------------------
|
||||
// Fill stats to post to ctx.Data.
|
||||
// -------------------------------
|
||||
var issueStats *issues_model.IssueStats
|
||||
{
|
||||
statsOpts := issues_model.IssuesOptions{
|
||||
RepoIDs: repoIDs,
|
||||
User: ctx.Doer,
|
||||
IsPull: util.OptionalBoolOf(isPullList),
|
||||
IsClosed: util.OptionalBoolOf(isShowClosed),
|
||||
IssueIDs: nil,
|
||||
IsArchived: util.OptionalBoolFalse,
|
||||
LabelIDs: opts.LabelIDs,
|
||||
Org: org,
|
||||
Team: team,
|
||||
RepoCond: opts.RepoCond,
|
||||
}
|
||||
|
||||
if keyword != "" {
|
||||
statsOpts.RepoIDs = opts.RepoIDs
|
||||
allIssueIDs, err := issueIDsFromSearch(ctx, keyword, &statsOpts)
|
||||
if err != nil {
|
||||
ctx.ServerError("issueIDsFromSearch", err)
|
||||
return
|
||||
}
|
||||
statsOpts.IssueIDs = allIssueIDs
|
||||
}
|
||||
|
||||
if keyword != "" && len(statsOpts.IssueIDs) == 0 {
|
||||
// So it did search with the keyword, but no issue found.
|
||||
// Just set issueStats to empty.
|
||||
issueStats = &issues_model.IssueStats{}
|
||||
} else {
|
||||
// So it did search with the keyword, and found some issues. It needs to get issueStats of these issues.
|
||||
// Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts.
|
||||
issueStats, err = issues_model.GetUserIssueStats(filterMode, statsOpts)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserIssueStats", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
issueStats, err := getUserIssueStats(ctx, filterMode, issue_indexer.ToSearchOptions(keyword, opts), ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("getUserIssueStats", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Will be posted to ctx.Data.
|
||||
|
@ -722,7 +681,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
|
|||
ctx.Data["IssueStats"] = issueStats
|
||||
ctx.Data["ViewType"] = viewType
|
||||
ctx.Data["SortType"] = sortType
|
||||
ctx.Data["RepoIDs"] = opts.RepoIDs
|
||||
ctx.Data["RepoIDs"] = selectedRepoIDs
|
||||
ctx.Data["IsShowClosed"] = isShowClosed
|
||||
ctx.Data["SelectLabels"] = selectedLabels
|
||||
|
||||
|
@ -777,14 +736,6 @@ func getRepoIDs(reposQuery string) []int64 {
|
|||
return repoIDs
|
||||
}
|
||||
|
||||
func issueIDsFromSearch(ctx *context.Context, keyword string, opts *issues_model.IssuesOptions) ([]int64, error) {
|
||||
ids, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SearchIssues: %w", err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func loadRepoByIDs(ctxUser *user_model.User, issueCountByRepo map[int64]int64, unitType unit.Type) (map[int64]*repo_model.Repository, error) {
|
||||
totalRes := make(map[int64]*repo_model.Repository, len(issueCountByRepo))
|
||||
repoIDs := make([]int64, 0, 500)
|
||||
|
@ -913,3 +864,71 @@ func UsernameSubRoute(ctx *context.Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getUserIssueStats(ctx *context.Context, filterMode int, opts *issue_indexer.SearchOptions, doerID int64) (*issues_model.IssueStats, error) {
|
||||
opts = opts.Copy(func(o *issue_indexer.SearchOptions) {
|
||||
o.AssigneeID = nil
|
||||
o.PosterID = nil
|
||||
o.MentionID = nil
|
||||
o.ReviewRequestedID = nil
|
||||
o.ReviewedID = nil
|
||||
})
|
||||
|
||||
var (
|
||||
err error
|
||||
ret = &issues_model.IssueStats{}
|
||||
)
|
||||
|
||||
{
|
||||
openClosedOpts := opts.Copy()
|
||||
switch filterMode {
|
||||
case issues_model.FilterModeAll, issues_model.FilterModeYourRepositories:
|
||||
case issues_model.FilterModeAssign:
|
||||
openClosedOpts.AssigneeID = &doerID
|
||||
case issues_model.FilterModeCreate:
|
||||
openClosedOpts.PosterID = &doerID
|
||||
case issues_model.FilterModeMention:
|
||||
openClosedOpts.MentionID = &doerID
|
||||
case issues_model.FilterModeReviewRequested:
|
||||
openClosedOpts.ReviewRequestedID = &doerID
|
||||
case issues_model.FilterModeReviewed:
|
||||
openClosedOpts.ReviewedID = &doerID
|
||||
}
|
||||
openClosedOpts.IsClosed = util.OptionalBoolFalse
|
||||
ret.OpenCount, err = issue_indexer.CountIssues(ctx, openClosedOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
openClosedOpts.IsClosed = util.OptionalBoolTrue
|
||||
ret.ClosedCount, err = issue_indexer.CountIssues(ctx, openClosedOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
ret.YourRepositoriesCount, err = issue_indexer.CountIssues(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.AssignCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AssigneeID = &doerID }))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = &doerID }))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.MentionCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.MentionID = &doerID }))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.ReviewRequestedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewRequestedID = &doerID }))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.ReviewedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewedID = &doerID }))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
|
|
@ -194,7 +194,7 @@ func DeleteAvatar(ctx *context.Context) {
|
|||
ctx.Flash.Error(err.Error())
|
||||
}
|
||||
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings")
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/user/settings")
|
||||
}
|
||||
|
||||
// Organization render all the organization of the user
|
||||
|
|
|
@ -186,7 +186,7 @@
|
|||
|
||||
<div class="field">
|
||||
<button class="ui green button">{{$.locale.Tr "settings.update_avatar"}}</button>
|
||||
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete" data-redirect="{{.Link}}">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
|
||||
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -48,6 +48,7 @@
|
|||
</div>
|
||||
<div class="flex-item-body">
|
||||
Very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong content
|
||||
<span class="text truncate">Truncate very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong content</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-item-trailing">
|
||||
|
|
|
@ -94,7 +94,7 @@
|
|||
|
||||
<div class="field">
|
||||
<button class="ui green button">{{$.locale.Tr "settings.update_avatar"}}</button>
|
||||
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete" data-redirect="{{.Link}}">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
|
||||
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<div class="js-branch-tag-selector {{if .ContainerClasses}}{{.ContainerClasses}}{{end}}">
|
||||
{{/* show dummy elements before Vue componment is mounted, this code must match the code in BranchTagSelector.vue */}}
|
||||
<div class="ui floating filter dropdown custom">
|
||||
<div class="ui dropdown custom">
|
||||
<button class="branch-dropdown-button gt-ellipsis ui basic small compact button gt-df gt-m-0">
|
||||
<span class="text gt-df gt-ac gt-mr-2">
|
||||
{{if .release}}
|
||||
|
|
|
@ -58,7 +58,7 @@
|
|||
</div>
|
||||
<div class="field">
|
||||
<button class="ui green button">{{$.locale.Tr "settings.update_avatar"}}</button>
|
||||
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete" data-redirect="{{.Link}}">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
|
||||
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -80,25 +80,21 @@
|
|||
{{end}}
|
||||
</div>
|
||||
{{if or (eq .GetOpType 5) (eq .GetOpType 18)}}
|
||||
<div class="gt-pl-5">
|
||||
{{$push := ActionContent2Commits .}}
|
||||
{{$repoLink := .GetRepoLink}}
|
||||
{{range $push.Commits}}
|
||||
{{$commitLink := printf "%s/commit/%s" $repoLink .Sha1}}
|
||||
<div class="flex-item">
|
||||
<img class="ui avatar" src="{{$push.AvatarLink $.Context .AuthorEmail}}" title="{{.AuthorName}}" width="16" height="16">
|
||||
<a class="commit-id" href="{{$commitLink}}">{{ShortSha .Sha1}}</a>
|
||||
<span class="text truncate light grey">
|
||||
{{RenderCommitMessage $.Context .Message $repoLink $.ComposeMetas}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if and (gt $push.Len 1) $push.CompareURL}}
|
||||
<div class="flex-item">
|
||||
<a href="{{AppSubUrl}}/{{$push.CompareURL}}">{{$.locale.Tr "action.compare_commits" $push.Len}} Β»</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{$push := ActionContent2Commits .}}
|
||||
{{$repoLink := .GetRepoLink}}
|
||||
{{range $push.Commits}}
|
||||
{{$commitLink := printf "%s/commit/%s" $repoLink .Sha1}}
|
||||
<div class="flex-text-block">
|
||||
<img class="ui avatar" src="{{$push.AvatarLink $.Context .AuthorEmail}}" title="{{.AuthorName}}" width="16" height="16">
|
||||
<a class="gt-mono" href="{{$commitLink}}">{{ShortSha .Sha1}}</a>
|
||||
<span class="text truncate light grey">
|
||||
{{RenderCommitMessage $.Context .Message $repoLink $.ComposeMetas}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if and (gt $push.Len 1) $push.CompareURL}}
|
||||
<a href="{{AppSubUrl}}/{{$push.CompareURL}}">{{$.locale.Tr "action.compare_commits" $push.Len}} Β»</a>
|
||||
{{end}}
|
||||
{{else if eq .GetOpType 6}}
|
||||
<span class="text truncate issue title">{{index .GetIssueInfos 1 | RenderEmoji $.Context | RenderCodeBlock}}</span>
|
||||
{{else if eq .GetOpType 7}}
|
||||
|
|
|
@ -5,29 +5,29 @@
|
|||
<div class="ui stackable grid">
|
||||
<div class="four wide column">
|
||||
<div class="ui secondary vertical filter menu gt-bg-transparent">
|
||||
<a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="{{.Link}}?type=your_repositories&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
|
||||
<a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="{{.Link}}?type=your_repositories&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
|
||||
{{.locale.Tr "home.issues.in_your_repos"}}
|
||||
<strong class="ui right">{{CountFmt .IssueStats.YourRepositoriesCount}}</strong>
|
||||
</a>
|
||||
<a class="{{if eq .ViewType "assigned"}}active{{end}} item" href="{{.Link}}?type=assigned&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
|
||||
<a class="{{if eq .ViewType "assigned"}}active{{end}} item" href="{{.Link}}?type=assigned&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
|
||||
{{.locale.Tr "repo.issues.filter_type.assigned_to_you"}}
|
||||
<strong class="ui right">{{CountFmt .IssueStats.AssignCount}}</strong>
|
||||
</a>
|
||||
<a class="{{if eq .ViewType "created_by"}}active{{end}} item" href="{{.Link}}?type=created_by&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
|
||||
<a class="{{if eq .ViewType "created_by"}}active{{end}} item" href="{{.Link}}?type=created_by&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
|
||||
{{.locale.Tr "repo.issues.filter_type.created_by_you"}}
|
||||
<strong class="ui right">{{CountFmt .IssueStats.CreateCount}}</strong>
|
||||
</a>
|
||||
{{if .PageIsPulls}}
|
||||
<a class="{{if eq .ViewType "review_requested"}}active{{end}} item" href="{{.Link}}?type=review_requested&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
|
||||
<a class="{{if eq .ViewType "review_requested"}}active{{end}} item" href="{{.Link}}?type=review_requested&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
|
||||
{{.locale.Tr "repo.issues.filter_type.review_requested"}}
|
||||
<strong class="ui right">{{CountFmt .IssueStats.ReviewRequestedCount}}</strong>
|
||||
</a>
|
||||
<a class="{{if eq .ViewType "reviewed_by"}}active{{end}} item" href="{{.Link}}?type=reviewed_by&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
|
||||
<a class="{{if eq .ViewType "reviewed_by"}}active{{end}} item" href="{{.Link}}?type=reviewed_by&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
|
||||
{{.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"}}active{{end}} item" href="{{.Link}}?type=mentioned&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}">
|
||||
<a class="{{if eq .ViewType "mentioned"}}active{{end}} item" href="{{.Link}}?type=mentioned&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
|
||||
{{.locale.Tr "repo.issues.filter_type.mentioning_you"}}
|
||||
<strong class="ui right">{{CountFmt .IssueStats.MentionCount}}</strong>
|
||||
</a>
|
||||
|
|
|
@ -126,7 +126,7 @@
|
|||
|
||||
<div class="field">
|
||||
<button class="ui green button">{{$.locale.Tr "settings.update_avatar"}}</button>
|
||||
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete" data-redirect="{{.Link}}">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
|
||||
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{$.locale.Tr "settings.delete_current_avatar"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -96,10 +96,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.feeds .commit-id {
|
||||
font-family: var(--fonts-monospace);
|
||||
}
|
||||
|
||||
.feeds code {
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
|
|
|
@ -29,7 +29,8 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
flex-basis: 60%;
|
||||
flex-basis: 60%; /* avoid wrapping the "flex-item-trailing" too aggressively */
|
||||
min-width: 0; /* make the "text truncate" work, otherwise the flex axis is not limited and the text just overflows */
|
||||
}
|
||||
|
||||
.flex-item-header {
|
||||
|
@ -66,6 +67,7 @@
|
|||
font-size: 16px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flex-item .flex-item-title a {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="ui floating filter dropdown custom">
|
||||
<div class="ui dropdown custom">
|
||||
<button class="branch-dropdown-button gt-ellipsis ui basic small compact button gt-df gt-m-0" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
|
||||
<span class="text gt-df gt-ac gt-mr-2">
|
||||
<template v-if="release">{{ textReleaseCompare }}</template>
|
||||
|
|
|
@ -95,14 +95,14 @@ async function fetchActionDoRequest(actionElem, url, opt) {
|
|||
const data = await resp.json();
|
||||
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
|
||||
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
|
||||
await showErrorToast(data.errorMessage || `server error: ${resp.status}`);
|
||||
showErrorToast(data.errorMessage || `server error: ${resp.status}`);
|
||||
} else {
|
||||
await showErrorToast(`server error: ${resp.status}`);
|
||||
showErrorToast(`server error: ${resp.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error when doRequest', e);
|
||||
actionElem.classList.remove('is-loading', 'small-loading-icon');
|
||||
await showErrorToast(i18n.network_error);
|
||||
showErrorToast(i18n.network_error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {htmlEscape} from 'escape-goat';
|
||||
import {svg} from '../svg.js';
|
||||
import Toastify from 'toastify-js';
|
||||
import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
|
||||
|
||||
const levels = {
|
||||
info: {
|
||||
|
@ -21,9 +21,7 @@ const levels = {
|
|||
};
|
||||
|
||||
// See https://github.com/apvarun/toastify-js#api for options
|
||||
async function showToast(message, level, {gravity, position, duration, ...other} = {}) {
|
||||
if (!message) return;
|
||||
|
||||
function showToast(message, level, {gravity, position, duration, ...other} = {}) {
|
||||
const {icon, background, duration: levelDuration} = levels[level ?? 'info'];
|
||||
|
||||
const toast = Toastify({
|
||||
|
@ -41,20 +39,17 @@ async function showToast(message, level, {gravity, position, duration, ...other}
|
|||
});
|
||||
|
||||
toast.showToast();
|
||||
|
||||
toast.toastElement.querySelector('.toast-close').addEventListener('click', () => {
|
||||
toast.removeElement(toast.toastElement);
|
||||
});
|
||||
toast.toastElement.querySelector('.toast-close').addEventListener('click', () => toast.hideToast());
|
||||
}
|
||||
|
||||
export async function showInfoToast(message, opts) {
|
||||
return await showToast(message, 'info', opts);
|
||||
export function showInfoToast(message, opts) {
|
||||
return showToast(message, 'info', opts);
|
||||
}
|
||||
|
||||
export async function showWarningToast(message, opts) {
|
||||
return await showToast(message, 'warning', opts);
|
||||
export function showWarningToast(message, opts) {
|
||||
return showToast(message, 'warning', opts);
|
||||
}
|
||||
|
||||
export async function showErrorToast(message, opts) {
|
||||
return await showToast(message, 'error', opts);
|
||||
export function showErrorToast(message, opts) {
|
||||
return showToast(message, 'error', opts);
|
||||
}
|
||||
|
|
|
@ -2,16 +2,16 @@ import {test, expect} from 'vitest';
|
|||
import {showInfoToast, showErrorToast, showWarningToast} from './toast.js';
|
||||
|
||||
test('showInfoToast', async () => {
|
||||
await showInfoToast('success π', {duration: -1});
|
||||
showInfoToast('success π', {duration: -1});
|
||||
expect(document.querySelector('.toastify')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('showWarningToast', async () => {
|
||||
await showWarningToast('warning π', {duration: -1});
|
||||
showWarningToast('warning π', {duration: -1});
|
||||
expect(document.querySelector('.toastify')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('showErrorToast', async () => {
|
||||
await showErrorToast('error π', {duration: -1});
|
||||
showErrorToast('error π', {duration: -1});
|
||||
expect(document.querySelector('.toastify')).toBeTruthy();
|
||||
});
|
||||
|
|
Loadingβ¦
Reference in New Issue