1
1
mirror of https://github.com/go-gitea/gitea synced 2025-07-03 17:17:19 +00:00

Merge branch 'main' of github.com:go-gitea/gitea into api-repo-actions

This commit is contained in:
chesterip
2023-08-23 10:14:18 -04:00
26 changed files with 326 additions and 222 deletions

View File

@ -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: 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 ```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. 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 ```bash
docker run \ docker run \
-v $(pwd)/config.yaml:/config.yaml \ -v $PWD/config.yaml:/config.yaml \
-v $(pwd)/data:/data \ -v $PWD/data:/data \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-e CONFIG_FILE=/config.yaml \ -e CONFIG_FILE=/config.yaml \
-e GITEA_INSTANCE_URL=<instance_url> \ -e GITEA_INSTANCE_URL=<instance_url> \

View File

@ -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.

View File

@ -116,70 +116,71 @@ func GetIssueStats(opts *IssuesOptions) (*IssueStats, error) {
func getIssueStatsChunk(opts *IssuesOptions, issueIDs []int64) (*IssueStats, error) { func getIssueStatsChunk(opts *IssuesOptions, issueIDs []int64) (*IssueStats, error) {
stats := &IssueStats{} stats := &IssueStats{}
countSession := func(opts *IssuesOptions, issueIDs []int64) *xorm.Session { sess := db.GetEngine(db.DefaultContext).
sess := db.GetEngine(db.DefaultContext). Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
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
}
var err error var err error
stats.OpenCount, err = countSession(opts, issueIDs). stats.OpenCount, err = applyIssuesOptions(sess, opts, issueIDs).
And("issue.is_closed = ?", false). And("issue.is_closed = ?", false).
Count(new(Issue)) Count(new(Issue))
if err != nil { if err != nil {
return stats, err return stats, err
} }
stats.ClosedCount, err = countSession(opts, issueIDs). stats.ClosedCount, err = applyIssuesOptions(sess, opts, issueIDs).
And("issue.is_closed = ?", true). And("issue.is_closed = ?", true).
Count(new(Issue)) Count(new(Issue))
return stats, err 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. // GetUserIssueStats returns issue statistic information for dashboard by given conditions.
func GetUserIssueStats(filterMode int, opts IssuesOptions) (*IssueStats, error) { func GetUserIssueStats(filterMode int, opts IssuesOptions) (*IssueStats, error) {
if opts.User == nil { if opts.User == nil {

View File

@ -113,10 +113,11 @@ func (l *Label) CalOpenIssues() {
// SetArchived set the label as archived // SetArchived set the label as archived
func (l *Label) SetArchived(isArchived bool) { func (l *Label) SetArchived(isArchived bool) {
if isArchived && l.ArchivedUnix.IsZero() { if !isArchived {
l.ArchivedUnix = timeutil.TimeStampNow()
} else {
l.ArchivedUnix = timeutil.TimeStamp(0) l.ArchivedUnix = timeutil.TimeStamp(0)
} else if isArchived && l.ArchivedUnix.IsZero() {
// Only change the date when it is newly archived.
l.ArchivedUnix = timeutil.TimeStampNow()
} }
} }

View File

@ -130,6 +130,10 @@ type SearchRepoOptions struct {
// True -> include just collaborative // True -> include just collaborative
// False -> include just non-collaborative // False -> include just non-collaborative
Collaborate util.OptionalBool 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 // None -> include forks AND non-forks
// True -> include just forks // True -> include just forks
// False -> include just non-forks // False -> include just non-forks
@ -382,19 +386,25 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond {
if opts.Collaborate != util.OptionalBoolFalse { if opts.Collaborate != util.OptionalBoolFalse {
// A Collaboration is: // A Collaboration is:
collaborateCond := builder.And(
// 1. Repository we don't own collaborateCond := builder.NewCond()
builder.Neq{"owner_id": opts.OwnerID}, // 1. Repository we don't own
// 2. But we can see because of: collaborateCond = collaborateCond.And(builder.Neq{"owner_id": opts.OwnerID})
builder.Or( // 2. But we can see because of:
// A. We have unit independent access {
UserAccessRepoCond("`repository`.id", opts.OwnerID), userAccessCond := builder.NewCond()
// B. We are in a team for // A. We have unit independent access
UserOrgTeamRepoCond("`repository`.id", opts.OwnerID), userAccessCond = userAccessCond.Or(UserAccessRepoCond("`repository`.id", opts.OwnerID))
// C. Public repositories in organizations that we are member of // B. We are in a team for
userOrgPublicRepoCondPrivate(opts.OwnerID), 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 { 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)) 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))
} }

View File

@ -13,6 +13,7 @@ import (
db_model "code.gitea.io/gitea/models/db" db_model "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" 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/graceful"
"code.gitea.io/gitea/modules/indexer/issues/bleve" "code.gitea.io/gitea/modules/indexer/issues/bleve"
"code.gitea.io/gitea/modules/indexer/issues/db" "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 // SearchOptions indicates the options for searching issues
type SearchOptions internal.SearchOptions type SearchOptions = internal.SearchOptions
const ( const (
SortByCreatedDesc = internal.SortByCreatedDesc SortByCreatedDesc = internal.SortByCreatedDesc
@ -291,7 +292,6 @@ const (
) )
// SearchIssues search issues by options. // 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) { func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, error) {
indexer := *globalIndexer.Load() indexer := *globalIndexer.Load()
@ -305,7 +305,7 @@ func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, err
indexer = db.NewIndexer() indexer = db.NewIndexer()
} }
result, err := indexer.Search(ctx, (*internal.SearchOptions)(opts)) result, err := indexer.Search(ctx, opts)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -317,3 +317,38 @@ func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, err
return ret, result.Total, nil 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
}

View File

@ -109,6 +109,19 @@ type SearchOptions struct {
SortBy SortBy // sort by field 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 type SortBy string
const ( const (

View File

@ -480,5 +480,5 @@ func DeleteAvatar(ctx *context.Context) {
ctx.Flash.Error(err.Error()) 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))
} }

View File

@ -156,7 +156,7 @@ func SettingsDeleteAvatar(ctx *context.Context) {
ctx.Flash.Error(err.Error()) ctx.Flash.Error(err.Error())
} }
ctx.Redirect(ctx.Org.OrgLink + "/settings") ctx.JSONRedirect(ctx.Org.OrgLink + "/settings")
} }
// SettingsDelete response for deleting an organization // SettingsDelete response for deleting an organization

View File

@ -72,5 +72,5 @@ func SettingsDeleteAvatar(ctx *context.Context) {
if err := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository); err != nil { if err := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository); err != nil {
ctx.Flash.Error(fmt.Sprintf("DeleteAvatar: %v", err)) ctx.Flash.Error(fmt.Sprintf("DeleteAvatar: %v", err))
} }
ctx.Redirect(ctx.Repo.RepoLink + "/settings") ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings")
} }

View File

@ -448,21 +448,26 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// - Team org's owns the repository. // - Team org's owns the repository.
// - Team has read permission to repository. // - Team has read permission to repository.
repoOpts := &repo_model.SearchRepoOptions{ repoOpts := &repo_model.SearchRepoOptions{
Actor: ctx.Doer, Actor: ctx.Doer,
OwnerID: ctx.Doer.ID, OwnerID: ctx.Doer.ID,
Private: true, Private: true,
AllPublic: false, AllPublic: false,
AllLimited: false, AllLimited: false,
Collaborate: util.OptionalBoolNone,
UnitType: unitType,
Archived: util.OptionalBoolFalse,
} }
if team != nil { if team != nil {
repoOpts.TeamID = team.ID repoOpts.TeamID = team.ID
} }
accessibleRepos := container.Set[int64]{}
{ {
ids, _, err := repo_model.SearchRepositoryIDs(repoOpts) ids, _, err := repo_model.SearchRepositoryIDs(repoOpts)
if err != nil { if err != nil {
ctx.ServerError("SearchRepositoryIDs", err) ctx.ServerError("SearchRepositoryIDs", err)
return return
} }
accessibleRepos.AddMultiple(ids...)
opts.RepoIDs = ids opts.RepoIDs = ids
if len(opts.RepoIDs) == 0 { if len(opts.RepoIDs) == 0 {
// no repos found, don't let the indexer return all repos // 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"), " ") keyword := strings.Trim(ctx.FormString("q"), " ")
ctx.Data["Keyword"] = keyword 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. // Educated guess: Do or don't show closed issues.
isShowClosed := ctx.FormString("state") == "closed" isShowClosed := ctx.FormString("state") == "closed"
opts.IsClosed = util.OptionalBoolOf(isShowClosed) opts.IsClosed = util.OptionalBoolOf(isShowClosed)
// Filter repos and count issues in them. Count will be used later. // Filter repos and count issues in them. Count will be used later.
// USING NON-FINAL STATE OF opts FOR A QUERY. // USING NON-FINAL STATE OF opts FOR A QUERY.
var issueCountByRepo map[int64]int64 issueCountByRepo, err := issue_indexer.CountIssuesByRepo(ctx, issue_indexer.ToSearchOptions(keyword, opts))
{ if err != nil {
issueIDs, err := issueIDsFromSearch(ctx, keyword, opts) ctx.ServerError("CountIssuesByRepo", err)
if err != nil { return
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
}
} }
// Make sure page number is at least 1. Will be posted to ctx.Data. // 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. // Parse ctx.FormString("repos") and remember matched repo IDs for later.
// Gets set when clicking filters on the issues overview page. // Gets set when clicking filters on the issues overview page.
repoIDs := getRepoIDs(ctx.FormString("repos")) selectedRepoIDs := getRepoIDs(ctx.FormString("repos"))
if len(repoIDs) > 0 { // Remove repo IDs that are not accessible to the user.
// Remove repo IDs that are not accessible to the user. selectedRepoIDs = util.SliceRemoveAllFunc(selectedRepoIDs, func(v int64) bool {
repoIDs = util.SliceRemoveAllFunc(repoIDs, func(v int64) bool { return !accessibleRepos.Contains(v)
return !accessibleRepos.Contains(v) })
}) if len(selectedRepoIDs) > 0 {
opts.RepoIDs = repoIDs opts.RepoIDs = selectedRepoIDs
} }
// ------------------------------ // ------------------------------
@ -568,7 +549,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// USING FINAL STATE OF opts FOR A QUERY. // USING FINAL STATE OF opts FOR A QUERY.
var issues issues_model.IssueList 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 { if err != nil {
ctx.ServerError("issueIDsFromSearch", err) ctx.ServerError("issueIDsFromSearch", err)
return return
@ -584,6 +565,18 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// Add repository pointers to Issues. // 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 maps repository IDs to their Repository pointers.
showReposMap, err := loadRepoByIDs(ctxUser, issueCountByRepo, unitType) showReposMap, err := loadRepoByIDs(ctxUser, issueCountByRepo, unitType)
if err != nil { if err != nil {
@ -615,44 +608,10 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// ------------------------------- // -------------------------------
// Fill stats to post to ctx.Data. // Fill stats to post to ctx.Data.
// ------------------------------- // -------------------------------
var issueStats *issues_model.IssueStats issueStats, err := getUserIssueStats(ctx, filterMode, issue_indexer.ToSearchOptions(keyword, opts), ctx.Doer.ID)
{ if err != nil {
statsOpts := issues_model.IssuesOptions{ ctx.ServerError("getUserIssueStats", err)
RepoIDs: repoIDs, return
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
}
}
} }
// Will be posted to ctx.Data. // Will be posted to ctx.Data.
@ -722,7 +681,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
ctx.Data["IssueStats"] = issueStats ctx.Data["IssueStats"] = issueStats
ctx.Data["ViewType"] = viewType ctx.Data["ViewType"] = viewType
ctx.Data["SortType"] = sortType ctx.Data["SortType"] = sortType
ctx.Data["RepoIDs"] = opts.RepoIDs ctx.Data["RepoIDs"] = selectedRepoIDs
ctx.Data["IsShowClosed"] = isShowClosed ctx.Data["IsShowClosed"] = isShowClosed
ctx.Data["SelectLabels"] = selectedLabels ctx.Data["SelectLabels"] = selectedLabels
@ -777,14 +736,6 @@ func getRepoIDs(reposQuery string) []int64 {
return repoIDs 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) { 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)) totalRes := make(map[int64]*repo_model.Repository, len(issueCountByRepo))
repoIDs := make([]int64, 0, 500) 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
}

View File

@ -194,7 +194,7 @@ func DeleteAvatar(ctx *context.Context) {
ctx.Flash.Error(err.Error()) 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 // Organization render all the organization of the user

View File

@ -186,7 +186,7 @@
<div class="field"> <div class="field">
<button class="ui green button">{{$.locale.Tr "settings.update_avatar"}}</button> <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> </div>
</form> </form>
</div> </div>

View File

@ -48,6 +48,7 @@
</div> </div>
<div class="flex-item-body"> <div class="flex-item-body">
Very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong content Very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong content
<span class="text truncate">Truncate very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong content</span>
</div> </div>
</div> </div>
<div class="flex-item-trailing"> <div class="flex-item-trailing">

View File

@ -94,7 +94,7 @@
<div class="field"> <div class="field">
<button class="ui green button">{{$.locale.Tr "settings.update_avatar"}}</button> <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> </div>
</form> </form>
</div> </div>

View File

@ -67,7 +67,7 @@
<div class="js-branch-tag-selector {{if .ContainerClasses}}{{.ContainerClasses}}{{end}}"> <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 */}} {{/* 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"> <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"> <span class="text gt-df gt-ac gt-mr-2">
{{if .release}} {{if .release}}

View File

@ -58,7 +58,7 @@
</div> </div>
<div class="field"> <div class="field">
<button class="ui green button">{{$.locale.Tr "settings.update_avatar"}}</button> <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> </div>
</form> </form>
</div> </div>

View File

@ -80,25 +80,21 @@
{{end}} {{end}}
</div> </div>
{{if or (eq .GetOpType 5) (eq .GetOpType 18)}} {{if or (eq .GetOpType 5) (eq .GetOpType 18)}}
<div class="gt-pl-5"> {{$push := ActionContent2Commits .}}
{{$push := ActionContent2Commits .}} {{$repoLink := .GetRepoLink}}
{{$repoLink := .GetRepoLink}} {{range $push.Commits}}
{{range $push.Commits}} {{$commitLink := printf "%s/commit/%s" $repoLink .Sha1}}
{{$commitLink := printf "%s/commit/%s" $repoLink .Sha1}} <div class="flex-text-block">
<div class="flex-item"> <img class="ui avatar" src="{{$push.AvatarLink $.Context .AuthorEmail}}" title="{{.AuthorName}}" width="16" height="16">
<img class="ui avatar" src="{{$push.AvatarLink $.Context .AuthorEmail}}" title="{{.AuthorName}}" width="16" height="16"> <a class="gt-mono" href="{{$commitLink}}">{{ShortSha .Sha1}}</a>
<a class="commit-id" href="{{$commitLink}}">{{ShortSha .Sha1}}</a> <span class="text truncate light grey">
<span class="text truncate light grey"> {{RenderCommitMessage $.Context .Message $repoLink $.ComposeMetas}}
{{RenderCommitMessage $.Context .Message $repoLink $.ComposeMetas}} </span>
</span> </div>
</div> {{end}}
{{end}} {{if and (gt $push.Len 1) $push.CompareURL}}
{{if and (gt $push.Len 1) $push.CompareURL}} <a href="{{AppSubUrl}}/{{$push.CompareURL}}">{{$.locale.Tr "action.compare_commits" $push.Len}} »</a>
<div class="flex-item"> {{end}}
<a href="{{AppSubUrl}}/{{$push.CompareURL}}">{{$.locale.Tr "action.compare_commits" $push.Len}} »</a>
</div>
{{end}}
</div>
{{else if eq .GetOpType 6}} {{else if eq .GetOpType 6}}
<span class="text truncate issue title">{{index .GetIssueInfos 1 | RenderEmoji $.Context | RenderCodeBlock}}</span> <span class="text truncate issue title">{{index .GetIssueInfos 1 | RenderEmoji $.Context | RenderCodeBlock}}</span>
{{else if eq .GetOpType 7}} {{else if eq .GetOpType 7}}

View File

@ -5,29 +5,29 @@
<div class="ui stackable grid"> <div class="ui stackable grid">
<div class="four wide column"> <div class="four wide column">
<div class="ui secondary vertical filter menu gt-bg-transparent"> <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"}} {{.locale.Tr "home.issues.in_your_repos"}}
<strong class="ui right">{{CountFmt .IssueStats.YourRepositoriesCount}}</strong> <strong class="ui right">{{CountFmt .IssueStats.YourRepositoriesCount}}</strong>
</a> </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"}} {{.locale.Tr "repo.issues.filter_type.assigned_to_you"}}
<strong class="ui right">{{CountFmt .IssueStats.AssignCount}}</strong> <strong class="ui right">{{CountFmt .IssueStats.AssignCount}}</strong>
</a> </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"}} {{.locale.Tr "repo.issues.filter_type.created_by_you"}}
<strong class="ui right">{{CountFmt .IssueStats.CreateCount}}</strong> <strong class="ui right">{{CountFmt .IssueStats.CreateCount}}</strong>
</a> </a>
{{if .PageIsPulls}} {{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"}} {{.locale.Tr "repo.issues.filter_type.review_requested"}}
<strong class="ui right">{{CountFmt .IssueStats.ReviewRequestedCount}}</strong> <strong class="ui right">{{CountFmt .IssueStats.ReviewRequestedCount}}</strong>
</a> </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"}} {{.locale.Tr "repo.issues.filter_type.reviewed_by_you"}}
<strong class="ui right">{{CountFmt .IssueStats.ReviewedCount}}</strong> <strong class="ui right">{{CountFmt .IssueStats.ReviewedCount}}</strong>
</a> </a>
{{end}} {{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"}} {{.locale.Tr "repo.issues.filter_type.mentioning_you"}}
<strong class="ui right">{{CountFmt .IssueStats.MentionCount}}</strong> <strong class="ui right">{{CountFmt .IssueStats.MentionCount}}</strong>
</a> </a>

View File

@ -126,7 +126,7 @@
<div class="field"> <div class="field">
<button class="ui green button">{{$.locale.Tr "settings.update_avatar"}}</button> <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> </div>
</form> </form>
</div> </div>

View File

@ -96,10 +96,6 @@
} }
} }
.feeds .commit-id {
font-family: var(--fonts-monospace);
}
.feeds code { .feeds code {
padding: 2px 4px; padding: 2px 4px;
border-radius: 3px; border-radius: 3px;

View File

@ -29,7 +29,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; 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 { .flex-item-header {
@ -66,6 +67,7 @@
font-size: 16px; font-size: 16px;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
word-break: break-word; word-break: break-word;
min-width: 0;
} }
.flex-item .flex-item-title a { .flex-item .flex-item-title a {

View File

@ -1,5 +1,5 @@
<template> <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"> <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"> <span class="text gt-df gt-ac gt-mr-2">
<template v-if="release">{{ textReleaseCompare }}</template> <template v-if="release">{{ textReleaseCompare }}</template>

View File

@ -95,14 +95,14 @@ async function fetchActionDoRequest(actionElem, url, opt) {
const data = await resp.json(); const data = await resp.json();
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error" // 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. // 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 { } else {
await showErrorToast(`server error: ${resp.status}`); showErrorToast(`server error: ${resp.status}`);
} }
} catch (e) { } catch (e) {
console.error('error when doRequest', e); console.error('error when doRequest', e);
actionElem.classList.remove('is-loading', 'small-loading-icon'); actionElem.classList.remove('is-loading', 'small-loading-icon');
await showErrorToast(i18n.network_error); showErrorToast(i18n.network_error);
} }
} }

View File

@ -1,6 +1,6 @@
import {htmlEscape} from 'escape-goat'; import {htmlEscape} from 'escape-goat';
import {svg} from '../svg.js'; 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 = { const levels = {
info: { info: {
@ -21,9 +21,7 @@ const levels = {
}; };
// See https://github.com/apvarun/toastify-js#api for options // See https://github.com/apvarun/toastify-js#api for options
async function showToast(message, level, {gravity, position, duration, ...other} = {}) { function showToast(message, level, {gravity, position, duration, ...other} = {}) {
if (!message) return;
const {icon, background, duration: levelDuration} = levels[level ?? 'info']; const {icon, background, duration: levelDuration} = levels[level ?? 'info'];
const toast = Toastify({ const toast = Toastify({
@ -41,20 +39,17 @@ async function showToast(message, level, {gravity, position, duration, ...other}
}); });
toast.showToast(); toast.showToast();
toast.toastElement.querySelector('.toast-close').addEventListener('click', () => toast.hideToast());
toast.toastElement.querySelector('.toast-close').addEventListener('click', () => {
toast.removeElement(toast.toastElement);
});
} }
export async function showInfoToast(message, opts) { export function showInfoToast(message, opts) {
return await showToast(message, 'info', opts); return showToast(message, 'info', opts);
} }
export async function showWarningToast(message, opts) { export function showWarningToast(message, opts) {
return await showToast(message, 'warning', opts); return showToast(message, 'warning', opts);
} }
export async function showErrorToast(message, opts) { export function showErrorToast(message, opts) {
return await showToast(message, 'error', opts); return showToast(message, 'error', opts);
} }

View File

@ -2,16 +2,16 @@ import {test, expect} from 'vitest';
import {showInfoToast, showErrorToast, showWarningToast} from './toast.js'; import {showInfoToast, showErrorToast, showWarningToast} from './toast.js';
test('showInfoToast', async () => { test('showInfoToast', async () => {
await showInfoToast('success 😀', {duration: -1}); showInfoToast('success 😀', {duration: -1});
expect(document.querySelector('.toastify')).toBeTruthy(); expect(document.querySelector('.toastify')).toBeTruthy();
}); });
test('showWarningToast', async () => { test('showWarningToast', async () => {
await showWarningToast('warning 😐', {duration: -1}); showWarningToast('warning 😐', {duration: -1});
expect(document.querySelector('.toastify')).toBeTruthy(); expect(document.querySelector('.toastify')).toBeTruthy();
}); });
test('showErrorToast', async () => { test('showErrorToast', async () => {
await showErrorToast('error 🙁', {duration: -1}); showErrorToast('error 🙁', {duration: -1});
expect(document.querySelector('.toastify')).toBeTruthy(); expect(document.querySelector('.toastify')).toBeTruthy();
}); });