diff --git a/.eslintrc.yaml b/.eslintrc.yaml index cbb169d06eec8..dd2c32eec06dc 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -422,7 +422,6 @@ rules: no-restricted-imports: [0] no-restricted-syntax: [2, WithStatement, ForInStatement, LabeledStatement, SequenceExpression] no-return-assign: [0] - no-return-await: [0] no-script-url: [2] no-self-assign: [2, {props: true}] no-self-compare: [2] diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index 12e5e64e80763..7cddaff63b5ab 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -98,6 +98,12 @@ jobs: discovery.type: single-node ports: - "9200:9200" + meilisearch: + image: getmeili/meilisearch:v1.2.0 + env: + MEILI_ENV: development # disable auth + ports: + - "7700:7700" smtpimap: image: tabascoterrier/docker-imap-devel:latest ports: @@ -128,7 +134,7 @@ jobs: go-version: ">=1.20" check-latest: true - name: Add hosts to /etc/hosts - run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mysql elasticsearch smtpimap" | sudo tee -a /etc/hosts' + run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mysql elasticsearch meilisearch smtpimap" | sudo tee -a /etc/hosts' - run: make deps-backend - run: make backend env: diff --git a/.ignore b/.ignore index cd869d51cb924..5c945ab9810ee 100644 --- a/.ignore +++ b/.ignore @@ -5,4 +5,5 @@ /modules/public/bindata.go /modules/templates/bindata.go /vendor +/public/assets node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index c08834645cb7f..c6699a6bfad51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ This changelog goes through all the changes that have been made in each release without substantial changes to our git log; to see the highlights of what has been added to each release, please refer to the [blog](https://blog.gitea.io). +## [1.20.2](https://github.com/go-gitea/gitea/releases/tag/1.20.2) - 2023-07-29 + +* ENHANCEMENTS + * Calculate MAX_WORKERS default value by CPU number (#26177) (#26183) + * Display deprecated warning in admin panel pages as well as in the log file (#26094) (#26154) +* BUGFIXES + * Fix allowed user types setting problem (#26200) (#26206) + * Fix handling of plenty Nuget package versions (#26075) (#26173) + * Fix UI regression of asciinema player (#26159) (#26162) + * Fix LFS object list style (#26133) (#26147) + * Fix allowed user types setting problem (#26200) (#26206) + * Prevent primary key update on migration (#26192) (#26199) + * Fix bug when pushing to a pull request which enabled dismiss approval automatically (#25882) (#26158) + * Fix bugs in LFS meta garbage collection (#26122) (#26157) + * Update xorm version (#26128) (#26150) + * Remove "misc" scope check from public API endpoints (#26134) (#26149) + * Fix CLI allowing creation of access tokens with existing name (#26071) (#26144) + * Fix incorrect router logger (#26137) (#26143) + * Improve commit graph alignment and truncating (#26112) (#26127) + * Avoid writing config file if not installed (#26107) (#26113) + * Fix escape problems in the branch selector (#25875) (#26103) + * Fix handling of Debian files with trailing slash (#26087) (#26098) + * Fix Missing 404 swagger response docs for /admin/users/{username} (#26086) (#26089) + * Use stderr as fallback if the log file can't be opened (#26074) (#26083) + * Increase table cell horizontal padding (#26140) (#26142) + * Fix wrong workflow status when rerun a job in an already finished workflow (#26119) (#26124) + * Fix duplicated url prefix on issue context menu (#26066) (#26067) + ## [1.20.1](https://github.com/go-gitea/gitea/releases/tag/1.20.1) - 2023-07-22 * SECURITY diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index e5f72d436c4b0..94c15a60d9c60 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -827,6 +827,15 @@ LEVEL = Info ;; Dependencies can be added from any repository where the user is granted access or only from the current repository depending on this setting. ;ALLOW_CROSS_REPOSITORY_DEPENDENCIES = true ;; +;; Default map service. No external API support has been included. A service has to allow +;; searching using URL parameters, the location will be appended to the URL as escaped query parameter. +;; Disabled by default, some example values are: +;; - OpenStreetMap: https://www.openstreetmap.org/search?query= +;; - Google Maps: https://www.google.com/maps/place/ +;; - MapQuest: https://www.mapquest.com/search/ +;; - Bing Maps: https://www.bing.com/maps?where1= +; USER_LOCATION_MAP_URL = +;; ;; Enable heatmap on users profiles. ;ENABLE_USER_HEATMAP = true ;; diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md index f30e0e246a83b..30751bf0711d2 100644 --- a/docs/content/administration/config-cheat-sheet.en-us.md +++ b/docs/content/administration/config-cheat-sheet.en-us.md @@ -648,6 +648,7 @@ And the following unique queues: - `DEFAULT_USER_IS_RESTRICTED`: **false**: Give new users restricted permissions by default - `DEFAULT_ENABLE_DEPENDENCIES`: **true**: Enable this to have dependencies enabled by default. - `ALLOW_CROSS_REPOSITORY_DEPENDENCIES` : **true** Enable this to allow dependencies on issues from any repository where the user is granted access. +- `USER_LOCATION_MAP_URL`: **""**: A map service URL to show user's location on a map. The location will be appended to the URL as escaped query parameter. - `ENABLE_USER_HEATMAP`: **true**: Enable this to display the heatmap on users profiles. - `ENABLE_TIMETRACKING`: **true**: Enable Timetracking feature. - `DEFAULT_ENABLE_TIMETRACKING`: **true**: Allow repositories to use timetracking by default. diff --git a/models/db/common.go b/models/db/common.go index af6130c9f255b..2a5043a8e7849 100644 --- a/models/db/common.go +++ b/models/db/common.go @@ -20,3 +20,20 @@ func BuildCaseInsensitiveLike(key, value string) builder.Cond { } return builder.Like{"UPPER(" + key + ")", strings.ToUpper(value)} } + +// BuildCaseInsensitiveIn returns a condition to check if the given value is in the given values case-insensitively. +// Handles especially SQLite correctly as UPPER there only transforms ASCII letters. +func BuildCaseInsensitiveIn(key string, values []string) builder.Cond { + uppers := make([]string, 0, len(values)) + if setting.Database.Type.IsSQLite3() { + for _, value := range values { + uppers = append(uppers, util.ToUpperASCII(value)) + } + } else { + for _, value := range values { + uppers = append(uppers, strings.ToUpper(value)) + } + } + + return builder.In("UPPER("+key+")", uppers) +} diff --git a/models/fixtures/issue.yml b/models/fixtures/issue.yml index 174345ff5a1fc..fa72f9b647da0 100644 --- a/models/fixtures/issue.yml +++ b/models/fixtures/issue.yml @@ -304,3 +304,20 @@ created_unix: 946684830 updated_unix: 978307200 is_locked: false + +- + id: 19 + repo_id: 58 + index: 1 + poster_id: 2 + original_author_id: 0 + name: issue for pr + content: content + milestone_id: 0 + priority: 0 + is_closed: false + is_pull: true + num_comments: 0 + created_unix: 946684830 + updated_unix: 978307200 + is_locked: false diff --git a/models/fixtures/pull_request.yml b/models/fixtures/pull_request.yml index 165437f0328dd..e5589ac703d77 100644 --- a/models/fixtures/pull_request.yml +++ b/models/fixtures/pull_request.yml @@ -76,3 +76,16 @@ base_branch: master merge_base: 2a47ca4b614a9f5a has_merged: false + +- + id: 7 + type: 0 # gitea pull request + status: 2 # mergable + issue_id: 19 + index: 1 + head_repo_id: 58 + base_repo_id: 58 + head_branch: branch1 + base_branch: main + merge_base: cbff181af4c9c7fee3cf6c106699e07d9a3f54e6 + has_merged: false diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml index 5bb974a7d7088..bb8715a2028f2 100644 --- a/models/fixtures/repo_unit.yml +++ b/models/fixtures/repo_unit.yml @@ -607,3 +607,33 @@ repo_id: 52 type: 1 created_unix: 946684810 + +- + id: 91 + repo_id: 58 + type: 1 + created_unix: 946684810 + +- + id: 92 + repo_id: 58 + type: 2 + created_unix: 946684810 + +- + id: 93 + repo_id: 58 + type: 3 + created_unix: 946684810 + +- + id: 94 + repo_id: 58 + type: 4 + created_unix: 946684810 + +- + id: 95 + repo_id: 58 + type: 5 + created_unix: 946684810 diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index 050a9e2d06f4a..15668e6caed68 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -1662,3 +1662,34 @@ is_private: false status: 0 num_issues: 0 + +- + id: 58 # org public repo + owner_id: 2 + owner_name: user2 + lower_name: commitsonpr + name: commitsonpr + default_branch: main + num_watches: 0 + num_stars: 0 + num_forks: 0 + num_issues: 0 + num_closed_issues: 0 + num_pulls: 1 + num_closed_pulls: 0 + num_milestones: 0 + num_closed_milestones: 0 + num_projects: 0 + num_closed_projects: 0 + is_private: false + is_empty: false + is_archived: false + is_mirror: false + status: 0 + is_fork: false + fork_id: 0 + is_template: false + template_id: 0 + size: 0 + is_fsck_enabled: true + close_issues_via_commit_in_any_branch: false diff --git a/models/fixtures/team_unit.yml b/models/fixtures/team_unit.yml index 5d2ba2fb6cbd7..c5531aa57af52 100644 --- a/models/fixtures/team_unit.yml +++ b/models/fixtures/team_unit.yml @@ -280,3 +280,9 @@ team_id: 20 type: 9 # package access_mode: 2 + +- + id: 48 + team_id: 2 + type: 8 + access_mode: 2 diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index 26bb7a9f4ba89..c7c5c024be89a 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -66,7 +66,7 @@ num_followers: 2 num_following: 1 num_stars: 2 - num_repos: 13 + num_repos: 14 num_teams: 0 num_members: 0 visibility: 0 diff --git a/models/git/commit_status.go b/models/git/commit_status.go index 17090f30dd55b..e7616fb167392 100644 --- a/models/git/commit_status.go +++ b/models/git/commit_status.go @@ -283,9 +283,9 @@ func GetLatestCommitStatus(ctx context.Context, repoID int64, sha string, listOp Where("repo_id = ?", repoID).And("sha = ?", sha). Select("max( id ) as id"). GroupBy("context_hash").OrderBy("max( id ) desc") - - sess = db.SetSessionPagination(sess, &listOptions) - + if !listOptions.IsListAll() { + sess = db.SetSessionPagination(sess, &listOptions) + } count, err := sess.FindAndCount(&ids) if err != nil { return nil, count, err diff --git a/models/issues/issue.go b/models/issues/issue.go index 9d60d011ed7a9..38726de85a307 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -13,6 +13,7 @@ import ( project_model "code.gitea.io/gitea/models/project" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -550,9 +551,30 @@ func GetIssueWithAttrsByID(id int64) (*Issue, error) { } // GetIssuesByIDs return issues with the given IDs. -func GetIssuesByIDs(ctx context.Context, issueIDs []int64) (IssueList, error) { +// If keepOrder is true, the order of the returned issues will be the same as the given IDs. +func GetIssuesByIDs(ctx context.Context, issueIDs []int64, keepOrder ...bool) (IssueList, error) { issues := make([]*Issue, 0, len(issueIDs)) - return issues, db.GetEngine(ctx).In("id", issueIDs).Find(&issues) + + if err := db.GetEngine(ctx).In("id", issueIDs).Find(&issues); err != nil { + return nil, err + } + + if len(keepOrder) > 0 && keepOrder[0] { + m := make(map[int64]*Issue, len(issues)) + appended := container.Set[int64]{} + for _, issue := range issues { + m[issue.ID] = issue + } + issues = issues[:0] + for _, id := range issueIDs { + if issue, ok := m[id]; ok && !appended.Contains(id) { // make sure the id is existed and not appended + appended.Add(id) + issues = append(issues, issue) + } + } + } + + return issues, nil } // GetIssueIDsByRepoID returns all issue ids by repo id diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index 6540ce02c0c50..f9c1dbb38471d 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -21,7 +21,7 @@ import ( // IssuesOptions represents options of an issue. type IssuesOptions struct { //nolint - db.ListOptions + db.Paginator RepoIDs []int64 // overwrites RepoCond if the length is not 0 RepoCond builder.Cond AssigneeID int64 @@ -99,15 +99,28 @@ func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) { } func applyLimit(sess *xorm.Session, opts *IssuesOptions) *xorm.Session { - if opts.Page >= 0 && opts.PageSize > 0 { - var start int - if opts.Page == 0 { - start = 0 - } else { - start = (opts.Page - 1) * opts.PageSize + if opts.Paginator == nil || opts.Paginator.IsListAll() { + return sess + } + + // Warning: Do not use GetSkipTake() for *db.ListOptions + // Its implementation could reset the page size with setting.API.MaxResponseItems + if listOptions, ok := opts.Paginator.(*db.ListOptions); ok { + if listOptions.Page >= 0 && listOptions.PageSize > 0 { + var start int + if listOptions.Page == 0 { + start = 0 + } else { + start = (listOptions.Page - 1) * listOptions.PageSize + } + sess.Limit(listOptions.PageSize, start) } - sess.Limit(opts.PageSize, start) + return sess } + + start, limit := opts.Paginator.GetSkipTake() + sess.Limit(limit, start) + return sess } @@ -435,7 +448,7 @@ func Issues(ctx context.Context, opts *IssuesOptions) ([]*Issue, error) { applyConditions(sess, opts) applySorts(sess, opts.SortType, opts.PriorityRepoID) - issues := make(IssueList, 0, opts.ListOptions.PageSize) + issues := IssueList{} if err := sess.Find(&issues); err != nil { return nil, fmt.Errorf("unable to query Issues: %w", err) } @@ -447,45 +460,23 @@ func Issues(ctx context.Context, opts *IssuesOptions) ([]*Issue, error) { return issues, nil } -// SearchIssueIDsByKeyword search issues on database -func SearchIssueIDsByKeyword(ctx context.Context, kw string, repoIDs []int64, limit, start int) (int64, []int64, error) { - repoCond := builder.In("repo_id", repoIDs) - subQuery := builder.Select("id").From("issue").Where(repoCond) - cond := builder.And( - repoCond, - builder.Or( - db.BuildCaseInsensitiveLike("name", kw), - db.BuildCaseInsensitiveLike("content", kw), - builder.In("id", builder.Select("issue_id"). - From("comment"). - Where(builder.And( - builder.Eq{"type": CommentTypeComment}, - builder.In("issue_id", subQuery), - db.BuildCaseInsensitiveLike("content", kw), - )), - ), - ), - ) - - ids := make([]int64, 0, limit) - res := make([]struct { - ID int64 - UpdatedUnix int64 - }, 0, limit) - err := db.GetEngine(ctx).Distinct("id", "updated_unix").Table("issue").Where(cond). - OrderBy("`updated_unix` DESC").Limit(limit, start). - Find(&res) - if err != nil { - return 0, nil, err - } - for _, r := range res { - ids = append(ids, r.ID) +// IssueIDs returns a list of issue ids by given conditions. +func IssueIDs(ctx context.Context, opts *IssuesOptions, otherConds ...builder.Cond) ([]int64, int64, error) { + sess := db.GetEngine(ctx). + Join("INNER", "repository", "`issue`.repo_id = `repository`.id") + applyConditions(sess, opts) + for _, cond := range otherConds { + sess.And(cond) } - total, err := db.GetEngine(ctx).Distinct("id").Table("issue").Where(cond).Count() + applyLimit(sess, opts) + applySorts(sess, opts.SortType, opts.PriorityRepoID) + + var res []int64 + total, err := sess.Select("`issue`.id").Table(&Issue{}).FindAndCount(&res) if err != nil { - return 0, nil, err + return nil, 0, err } - return total, ids, nil + return res, total, nil } diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 7f1eab1971378..0f2ceadc6b6e1 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -73,7 +73,7 @@ func TestIssueAPIURL(t *testing.T) { func TestGetIssuesByIDs(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) testSuccess := func(expectedIssueIDs, nonExistentIssueIDs []int64) { - issues, err := issues_model.GetIssuesByIDs(db.DefaultContext, append(expectedIssueIDs, nonExistentIssueIDs...)) + issues, err := issues_model.GetIssuesByIDs(db.DefaultContext, append(expectedIssueIDs, nonExistentIssueIDs...), true) assert.NoError(t, err) actualIssueIDs := make([]int64, len(issues)) for i, issue := range issues { @@ -83,6 +83,7 @@ func TestGetIssuesByIDs(t *testing.T) { } testSuccess([]int64{1, 2, 3}, []int64{}) testSuccess([]int64{1, 2, 3}, []int64{unittest.NonexistentID}) + testSuccess([]int64{3, 2, 1}, []int64{}) } func TestGetParticipantIDsByIssue(t *testing.T) { @@ -165,7 +166,7 @@ func TestIssues(t *testing.T) { issues_model.IssuesOptions{ RepoCond: builder.In("repo_id", 1, 3), SortType: "oldest", - ListOptions: db.ListOptions{ + Paginator: &db.ListOptions{ Page: 1, PageSize: 4, }, @@ -175,7 +176,7 @@ func TestIssues(t *testing.T) { { issues_model.IssuesOptions{ LabelIDs: []int64{1}, - ListOptions: db.ListOptions{ + Paginator: &db.ListOptions{ Page: 1, PageSize: 4, }, @@ -185,7 +186,7 @@ func TestIssues(t *testing.T) { { issues_model.IssuesOptions{ LabelIDs: []int64{1, 2}, - ListOptions: db.ListOptions{ + Paginator: &db.ListOptions{ Page: 1, PageSize: 4, }, @@ -333,30 +334,6 @@ func TestIssue_loadTotalTimes(t *testing.T) { assert.Equal(t, int64(3682), ms.TotalTrackedTime) } -func TestIssue_SearchIssueIDsByKeyword(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - total, ids, err := issues_model.SearchIssueIDsByKeyword(context.TODO(), "issue2", []int64{1}, 10, 0) - assert.NoError(t, err) - assert.EqualValues(t, 1, total) - assert.EqualValues(t, []int64{2}, ids) - - total, ids, err = issues_model.SearchIssueIDsByKeyword(context.TODO(), "first", []int64{1}, 10, 0) - assert.NoError(t, err) - assert.EqualValues(t, 1, total) - assert.EqualValues(t, []int64{1}, ids) - - total, ids, err = issues_model.SearchIssueIDsByKeyword(context.TODO(), "for", []int64{1}, 10, 0) - assert.NoError(t, err) - assert.EqualValues(t, 5, total) - assert.ElementsMatch(t, []int64{1, 2, 3, 5, 11}, ids) - - // issue1's comment id 2 - total, ids, err = issues_model.SearchIssueIDsByKeyword(context.TODO(), "good", []int64{1}, 10, 0) - assert.NoError(t, err) - assert.EqualValues(t, 1, total) - assert.EqualValues(t, []int64{1}, ids) -} - func TestGetRepoIDsForIssuesOptions(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) @@ -496,7 +473,19 @@ func TestCorrectIssueStats(t *testing.T) { wg.Wait() // Now we will get all issueID's that match the "Bugs are nasty" query. - total, ids, err := issues_model.SearchIssueIDsByKeyword(context.TODO(), "Bugs are nasty", []int64{1}, issueAmount, 0) + issues, err := issues_model.Issues(context.TODO(), &issues_model.IssuesOptions{ + Paginator: &db.ListOptions{ + PageSize: issueAmount, + }, + RepoIDs: []int64{1}, + }) + total := int64(len(issues)) + var ids []int64 + for _, issue := range issues { + if issue.Content == "Bugs are nasty" { + ids = append(ids, issue.ID) + } + } // Just to be sure. assert.NoError(t, err) @@ -538,7 +527,7 @@ func TestCountIssues(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) count, err := issues_model.CountIssues(db.DefaultContext, &issues_model.IssuesOptions{}) assert.NoError(t, err) - assert.EqualValues(t, 18, count) + assert.EqualValues(t, 19, count) } func TestIssueLoadAttributes(t *testing.T) { diff --git a/models/issues/issue_user.go b/models/issues/issue_user.go index 4a537752a2ca4..47da5c62c4d4a 100644 --- a/models/issues/issue_user.go +++ b/models/issues/issue_user.go @@ -84,3 +84,13 @@ func UpdateIssueUsersByMentions(ctx context.Context, issueID int64, uids []int64 } return nil } + +// GetIssueMentionIDs returns all mentioned user IDs of an issue. +func GetIssueMentionIDs(ctx context.Context, issueID int64) ([]int64, error) { + var ids []int64 + return ids, db.GetEngine(ctx).Table(IssueUser{}). + Where("issue_id=?", issueID). + And("is_mentioned=?", true). + Select("uid"). + Find(&ids) +} diff --git a/models/issues/label.go b/models/issues/label.go index 8f2cf05a28d65..57a2e67f8cd51 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -272,12 +272,12 @@ func GetLabelByID(ctx context.Context, labelID int64) (*Label, error) { } // GetLabelsByIDs returns a list of labels by IDs -func GetLabelsByIDs(labelIDs []int64) ([]*Label, error) { +func GetLabelsByIDs(labelIDs []int64, cols ...string) ([]*Label, error) { labels := make([]*Label, 0, len(labelIDs)) return labels, db.GetEngine(db.DefaultContext).Table("label"). In("id", labelIDs). Asc("name"). - Cols("id", "repo_id", "org_id"). + Cols(cols...). Find(&labels) } @@ -476,6 +476,18 @@ func GetLabelsByOrgID(ctx context.Context, orgID int64, sortType string, listOpt return labels, sess.Find(&labels) } +// GetLabelIDsByNames returns a list of labelIDs by names. +// It doesn't filter them by repo or org, so it could return labels belonging to different repos/orgs. +// It's used for filtering issues via indexer, otherwise it would be useless. +// Since it could return labels with the same name, so the length of returned ids could be more than the length of names. +func GetLabelIDsByNames(ctx context.Context, labelNames []string) ([]int64, error) { + labelIDs := make([]int64, 0, len(labelNames)) + return labelIDs, db.GetEngine(ctx).Table("label"). + In("name", labelNames). + Cols("id"). + Find(&labelIDs) +} + // CountLabelsByOrgID count all labels that belong to given organization by ID. func CountLabelsByOrgID(orgID int64) (int64, error) { return db.GetEngine(db.DefaultContext).Where("org_id = ?", orgID).Count(&Label{}) diff --git a/models/issues/milestone.go b/models/issues/milestone.go index ffe5c8eb509ba..1418e0869d376 100644 --- a/models/issues/milestone.go +++ b/models/issues/milestone.go @@ -396,6 +396,18 @@ func GetMilestones(opts GetMilestonesOption) (MilestoneList, int64, error) { return miles, total, err } +// GetMilestoneIDsByNames returns a list of milestone ids by given names. +// It doesn't filter them by repo, so it could return milestones belonging to different repos. +// It's used for filtering issues via indexer, otherwise it would be useless. +// Since it could return milestones with the same name, so the length of returned ids could be more than the length of names. +func GetMilestoneIDsByNames(ctx context.Context, names []string) ([]int64, error) { + var ids []int64 + return ids, db.GetEngine(ctx).Table("milestone"). + Where(db.BuildCaseInsensitiveIn("name", names)). + Cols("id"). + Find(&ids) +} + // SearchMilestones search milestones func SearchMilestones(repoCond builder.Cond, page int, isClosed bool, sortType, keyword string) (MilestoneList, error) { miles := make([]*Milestone, 0, setting.UI.IssuePagingNum) diff --git a/models/issues/review_list.go b/models/issues/review_list.go index c044fe915ad64..9f50d8e09d552 100644 --- a/models/issues/review_list.go +++ b/models/issues/review_list.go @@ -114,7 +114,7 @@ func FindLatestReviews(ctx context.Context, opts FindReviewOptions) (ReviewList, } sess.In("id", builder. - Select("max ( id ) "). + Select("max(id)"). From("review"). Where(cond). GroupBy("reviewer_id")) diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 2b090a913238d..b2140a1eb1327 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -521,6 +521,8 @@ var migrations = []Migration{ NewMigration("Update Action Ref", v1_21.UpdateActionsRefIndex), // v269 -> v270 NewMigration("Drop deleted branch table", v1_21.DropDeletedBranchTable), + // v270 -> v271 + NewMigration("Fix PackageProperty typo", v1_21.FixPackagePropertyTypo), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_20/v250.go b/models/migrations/v1_20/v250.go index e05646e5c6dc5..a09957b2917d9 100644 --- a/models/migrations/v1_20/v250.go +++ b/models/migrations/v1_20/v250.go @@ -20,7 +20,7 @@ func ChangeContainerMetadataMultiArch(x *xorm.Engine) error { } type PackageVersion struct { - ID int64 `xorm:"pk"` + ID int64 `xorm:"pk autoincr"` MetadataJSON string `xorm:"metadata_json"` } diff --git a/models/migrations/v1_21/v270.go b/models/migrations/v1_21/v270.go new file mode 100644 index 0000000000000..b9cc84d3ac41b --- /dev/null +++ b/models/migrations/v1_21/v270.go @@ -0,0 +1,26 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_21 //nolint + +import ( + "xorm.io/xorm" +) + +func FixPackagePropertyTypo(x *xorm.Engine) error { + sess := x.NewSession() + defer sess.Close() + + if err := sess.Begin(); err != nil { + return err + } + + if _, err := sess.Exec(`UPDATE package_property SET name = 'rpm.metadata' WHERE name = 'rpm.metdata'`); err != nil { + return err + } + if _, err := sess.Exec(`UPDATE package_property SET name = 'conda.metadata' WHERE name = 'conda.metdata'`); err != nil { + return err + } + + return sess.Commit() +} diff --git a/models/repo/repo_list_test.go b/models/repo/repo_list_test.go index 573281ea0b018..e013953c68a80 100644 --- a/models/repo/repo_list_test.go +++ b/models/repo/repo_list_test.go @@ -235,12 +235,12 @@ func TestSearchRepository(t *testing.T) { { name: "AllPublic/PublicRepositoriesOfUserIncludingCollaborative", opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, AllPublic: true, Template: util.OptionalBoolFalse}, - count: 30, + count: 31, }, { name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborative", opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, AllPublic: true, AllLimited: true, Template: util.OptionalBoolFalse}, - count: 35, + count: 36, }, { name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborativeByName", @@ -255,7 +255,7 @@ func TestSearchRepository(t *testing.T) { { name: "AllPublic/PublicRepositoriesOfOrganization", opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 17, AllPublic: true, Collaborate: util.OptionalBoolFalse, Template: util.OptionalBoolFalse}, - count: 30, + count: 31, }, { name: "AllTemplates", diff --git a/models/user/user.go b/models/user/user.go index 4b19eda67b61c..86cf2ad28060f 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -203,11 +203,16 @@ func UpdateUserTheme(u *User, themeName string) error { return UpdateUserCols(db.DefaultContext, u, "theme") } +// GetPlaceholderEmail returns an noreply email +func (u *User) GetPlaceholderEmail() string { + return fmt.Sprintf("%s@%s", u.LowerName, setting.Service.NoReplyAddress) +} + // GetEmail returns an noreply email, if the user has set to keep his // email address private, otherwise the primary email address. func (u *User) GetEmail() string { if u.KeepEmailPrivate { - return fmt.Sprintf("%s@%s", u.LowerName, setting.Service.NoReplyAddress) + return u.GetPlaceholderEmail() } return u.Email } diff --git a/modules/indexer/code/bleve/bleve.go b/modules/indexer/code/bleve/bleve.go index 1e34226e8df14..0bfd85cb3f30d 100644 --- a/modules/indexer/code/bleve/bleve.go +++ b/modules/indexer/code/bleve/bleve.go @@ -41,15 +41,6 @@ const ( maxBatchSize = 16 ) -// numericEqualityQuery a numeric equality query for the given value and field -func numericEqualityQuery(value int64, field string) *query.NumericRangeQuery { - f := float64(value) - tru := true - q := bleve.NewNumericRangeInclusiveQuery(&f, &f, &tru, &tru) - q.SetField(field) - return q -} - func addUnicodeNormalizeTokenFilter(m *mapping.IndexMappingImpl) error { return m.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ "type": unicodenorm.Name, @@ -225,7 +216,7 @@ func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha st // Delete deletes indexes by ids func (b *Indexer) Delete(_ context.Context, repoID int64) error { - query := numericEqualityQuery(repoID, "RepoID") + query := inner_bleve.NumericEqualityQuery(repoID, "RepoID") searchRequest := bleve.NewSearchRequestOptions(query, 2147483647, 0, false) result, err := b.inner.Indexer.Search(searchRequest) if err != nil { @@ -262,7 +253,7 @@ func (b *Indexer) Search(ctx context.Context, repoIDs []int64, language, keyword if len(repoIDs) > 0 { repoQueries := make([]query.Query, 0, len(repoIDs)) for _, repoID := range repoIDs { - repoQueries = append(repoQueries, numericEqualityQuery(repoID, "RepoID")) + repoQueries = append(repoQueries, inner_bleve.NumericEqualityQuery(repoID, "RepoID")) } indexerQuery = bleve.NewConjunctionQuery( diff --git a/modules/indexer/internal/bleve/query.go b/modules/indexer/internal/bleve/query.go new file mode 100644 index 0000000000000..c7d66538c1263 --- /dev/null +++ b/modules/indexer/internal/bleve/query.go @@ -0,0 +1,53 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package bleve + +import ( + "github.com/blevesearch/bleve/v2" + "github.com/blevesearch/bleve/v2/search/query" +) + +// NumericEqualityQuery generates a numeric equality query for the given value and field +func NumericEqualityQuery(value int64, field string) *query.NumericRangeQuery { + f := float64(value) + tru := true + q := bleve.NewNumericRangeInclusiveQuery(&f, &f, &tru, &tru) + q.SetField(field) + return q +} + +// MatchPhraseQuery generates a match phrase query for the given phrase, field and analyzer +func MatchPhraseQuery(matchPhrase, field, analyzer string) *query.MatchPhraseQuery { + q := bleve.NewMatchPhraseQuery(matchPhrase) + q.FieldVal = field + q.Analyzer = analyzer + return q +} + +// BoolFieldQuery generates a bool field query for the given value and field +func BoolFieldQuery(value bool, field string) *query.BoolFieldQuery { + q := bleve.NewBoolFieldQuery(value) + q.SetField(field) + return q +} + +func NumericRangeInclusiveQuery(min, max *int64, field string) *query.NumericRangeQuery { + var minF, maxF *float64 + var minI, maxI *bool + if min != nil { + minF = new(float64) + *minF = float64(*min) + minI = new(bool) + *minI = true + } + if max != nil { + maxF = new(float64) + *maxF = float64(*max) + maxI = new(bool) + *maxI = true + } + q := bleve.NewNumericRangeInclusiveQuery(minF, maxF, minI, maxI) + q.SetField(field) + return q +} diff --git a/modules/indexer/internal/elasticsearch/indexer.go b/modules/indexer/internal/elasticsearch/indexer.go index 2c60efad564fc..395eea3bce652 100644 --- a/modules/indexer/internal/elasticsearch/indexer.go +++ b/modules/indexer/internal/elasticsearch/indexer.go @@ -76,7 +76,8 @@ func (i *Indexer) Ping(ctx context.Context) error { if err != nil { return err } - if resp.Status != "green" { + if resp.Status != "green" && resp.Status != "yellow" { + // It's healthy if the status is green, and it's available if the status is yellow, // see https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-health.html return fmt.Errorf("status of elasticsearch cluster is %s", resp.Status) } diff --git a/modules/indexer/internal/meilisearch/filter.go b/modules/indexer/internal/meilisearch/filter.go new file mode 100644 index 0000000000000..593177f163731 --- /dev/null +++ b/modules/indexer/internal/meilisearch/filter.go @@ -0,0 +1,119 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package meilisearch + +import ( + "fmt" + "strings" +) + +// Filter represents a filter for meilisearch queries. +// It's just a simple wrapper around a string. +// DO NOT assume that it is a complete implementation. +type Filter interface { + Statement() string +} + +type FilterAnd struct { + filters []Filter +} + +func (f *FilterAnd) Statement() string { + var statements []string + for _, filter := range f.filters { + if s := filter.Statement(); s != "" { + statements = append(statements, fmt.Sprintf("(%s)", s)) + } + } + return strings.Join(statements, " AND ") +} + +func (f *FilterAnd) And(filter Filter) *FilterAnd { + f.filters = append(f.filters, filter) + return f +} + +type FilterOr struct { + filters []Filter +} + +func (f *FilterOr) Statement() string { + var statements []string + for _, filter := range f.filters { + if s := filter.Statement(); s != "" { + statements = append(statements, fmt.Sprintf("(%s)", s)) + } + } + return strings.Join(statements, " OR ") +} + +func (f *FilterOr) Or(filter Filter) *FilterOr { + f.filters = append(f.filters, filter) + return f +} + +type FilterIn string + +// NewFilterIn creates a new FilterIn. +// It supports int64 only, to avoid extra works to handle strings with special characters. +func NewFilterIn[T int64](field string, values ...T) FilterIn { + if len(values) == 0 { + return "" + } + vs := make([]string, len(values)) + for i, v := range values { + vs[i] = fmt.Sprintf("%v", v) + } + return FilterIn(fmt.Sprintf("%s IN [%v]", field, strings.Join(vs, ", "))) +} + +func (f FilterIn) Statement() string { + return string(f) +} + +type FilterEq string + +// NewFilterEq creates a new FilterEq. +// It supports int64 and bool only, to avoid extra works to handle strings with special characters. +func NewFilterEq[T bool | int64](field string, value T) FilterEq { + return FilterEq(fmt.Sprintf("%s = %v", field, value)) +} + +func (f FilterEq) Statement() string { + return string(f) +} + +type FilterNot string + +func NewFilterNot(filter Filter) FilterNot { + return FilterNot(fmt.Sprintf("NOT (%s)", filter.Statement())) +} + +func (f FilterNot) Statement() string { + return string(f) +} + +type FilterGte string + +// NewFilterGte creates a new FilterGte. +// It supports int64 only, to avoid extra works to handle strings with special characters. +func NewFilterGte[T int64](field string, value T) FilterGte { + return FilterGte(fmt.Sprintf("%s >= %v", field, value)) +} + +func (f FilterGte) Statement() string { + return string(f) +} + +type FilterLte string + +// NewFilterLte creates a new FilterLte. +// It supports int64 only, to avoid extra works to handle strings with special characters. +func NewFilterLte[T int64](field string, value T) FilterLte { + return FilterLte(fmt.Sprintf("%s <= %v", field, value)) +} + +func (f FilterLte) Statement() string { + return string(f) +} diff --git a/modules/indexer/internal/meilisearch/indexer.go b/modules/indexer/internal/meilisearch/indexer.go index 06747ff7e07ae..b037249d43210 100644 --- a/modules/indexer/internal/meilisearch/indexer.go +++ b/modules/indexer/internal/meilisearch/indexer.go @@ -17,14 +17,16 @@ type Indexer struct { url, apiKey string indexName string version int + settings *meilisearch.Settings } -func NewIndexer(url, apiKey, indexName string, version int) *Indexer { +func NewIndexer(url, apiKey, indexName string, version int, settings *meilisearch.Settings) *Indexer { return &Indexer{ url: url, apiKey: apiKey, indexName: indexName, version: version, + settings: settings, } } @@ -57,7 +59,7 @@ func (i *Indexer) Init(_ context.Context) (bool, error) { i.checkOldIndexes() - _, err = i.Client.Index(i.VersionedIndexName()).UpdateFilterableAttributes(&[]string{"repo_id"}) + _, err = i.Client.Index(i.VersionedIndexName()).UpdateSettings(i.settings) return false, err } diff --git a/modules/indexer/internal/paginator.go b/modules/indexer/internal/paginator.go new file mode 100644 index 0000000000000..de0a33c06ff80 --- /dev/null +++ b/modules/indexer/internal/paginator.go @@ -0,0 +1,41 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package internal + +import ( + "math" + + "code.gitea.io/gitea/models/db" +) + +// ParsePaginator parses a db.Paginator into a skip and limit +func ParsePaginator(paginator db.Paginator, max ...int) (int, int) { + // Use a very large number to indicate no limit + unlimited := math.MaxInt32 + if len(max) > 0 { + // Some indexer engines have a limit on the page size, respect that + unlimited = max[0] + } + + if paginator == nil || paginator.IsListAll() { + return 0, unlimited + } + + // Warning: Do not use GetSkipTake() for *db.ListOptions + // Its implementation could reset the page size with setting.API.MaxResponseItems + if listOptions, ok := paginator.(*db.ListOptions); ok { + if listOptions.Page >= 0 && listOptions.PageSize > 0 { + var start int + if listOptions.Page == 0 { + start = 0 + } else { + start = (listOptions.Page - 1) * listOptions.PageSize + } + return start, listOptions.PageSize + } + return 0, unlimited + } + + return paginator.GetSkipTake() +} diff --git a/modules/indexer/issues/bleve/bleve.go b/modules/indexer/issues/bleve/bleve.go index c368a67ab5863..7c82cfbb792ef 100644 --- a/modules/indexer/issues/bleve/bleve.go +++ b/modules/indexer/issues/bleve/bleve.go @@ -23,25 +23,9 @@ import ( const ( issueIndexerAnalyzer = "issueIndexer" issueIndexerDocType = "issueIndexerDocType" - issueIndexerLatestVersion = 3 + issueIndexerLatestVersion = 4 ) -// numericEqualityQuery a numeric equality query for the given value and field -func numericEqualityQuery(value int64, field string) *query.NumericRangeQuery { - f := float64(value) - tru := true - q := bleve.NewNumericRangeInclusiveQuery(&f, &f, &tru, &tru) - q.SetField(field) - return q -} - -func newMatchPhraseQuery(matchPhrase, field, analyzer string) *query.MatchPhraseQuery { - q := bleve.NewMatchPhraseQuery(matchPhrase) - q.FieldVal = field - q.Analyzer = analyzer - return q -} - const unicodeNormalizeName = "unicodeNormalize" func addUnicodeNormalizeTokenFilter(m *mapping.IndexMappingImpl) error { @@ -74,10 +58,40 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) { textFieldMapping := bleve.NewTextFieldMapping() textFieldMapping.Store = false textFieldMapping.IncludeInAll = false + + boolFieldMapping := bleve.NewBooleanFieldMapping() + boolFieldMapping.Store = false + boolFieldMapping.IncludeInAll = false + + numberFieldMapping := bleve.NewNumericFieldMapping() + numberFieldMapping.Store = false + numberFieldMapping.IncludeInAll = false + + docMapping.AddFieldMappingsAt("is_public", boolFieldMapping) + docMapping.AddFieldMappingsAt("title", textFieldMapping) docMapping.AddFieldMappingsAt("content", textFieldMapping) docMapping.AddFieldMappingsAt("comments", textFieldMapping) + docMapping.AddFieldMappingsAt("is_pull", boolFieldMapping) + docMapping.AddFieldMappingsAt("is_closed", boolFieldMapping) + docMapping.AddFieldMappingsAt("label_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("no_label", boolFieldMapping) + docMapping.AddFieldMappingsAt("milestone_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("project_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("project_board_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("poster_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("assignee_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("mention_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("reviewed_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("review_requested_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("subscriber_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("updated_unix", numberFieldMapping) + + docMapping.AddFieldMappingsAt("created_unix", numberFieldMapping) + docMapping.AddFieldMappingsAt("deadline_unix", numberFieldMapping) + docMapping.AddFieldMappingsAt("comment_count", numberFieldMapping) + if err := addUnicodeNormalizeTokenFilter(mapping); err != nil { return nil, err } else if err = mapping.AddCustomAnalyzer(issueIndexerAnalyzer, map[string]any{ @@ -115,7 +129,7 @@ func NewIndexer(indexDir string) *Indexer { } // Index will save the index data -func (b *Indexer) Index(_ context.Context, issues []*internal.IndexerData) error { +func (b *Indexer) Index(_ context.Context, issues ...*internal.IndexerData) error { batch := inner_bleve.NewFlushingBatch(b.inner.Indexer, maxBatchSize) for _, issue := range issues { if err := batch.Index(indexer_internal.Base36(issue.ID), (*IndexerData)(issue)); err != nil { @@ -138,33 +152,127 @@ func (b *Indexer) Delete(_ context.Context, ids ...int64) error { // Search searches for issues by given conditions. // Returns the matching issue IDs -func (b *Indexer) Search(ctx context.Context, keyword string, repoIDs []int64, limit, start int, state string) (*internal.SearchResult, error) { - var repoQueriesP []*query.NumericRangeQuery - for _, repoID := range repoIDs { - repoQueriesP = append(repoQueriesP, numericEqualityQuery(repoID, "repo_id")) - } - repoQueries := make([]query.Query, len(repoQueriesP)) - for i, v := range repoQueriesP { - repoQueries[i] = query.Query(v) - } - - indexerQuery := bleve.NewConjunctionQuery( - bleve.NewDisjunctionQuery(repoQueries...), - bleve.NewDisjunctionQuery( - newMatchPhraseQuery(keyword, "title", issueIndexerAnalyzer), - newMatchPhraseQuery(keyword, "content", issueIndexerAnalyzer), - newMatchPhraseQuery(keyword, "comments", issueIndexerAnalyzer), - )) - search := bleve.NewSearchRequestOptions(indexerQuery, limit, start, false) - search.SortBy([]string{"-_score"}) +func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) { + var queries []query.Query + + if options.Keyword != "" { + keywordQueries := []query.Query{ + inner_bleve.MatchPhraseQuery(options.Keyword, "title", issueIndexerAnalyzer), + inner_bleve.MatchPhraseQuery(options.Keyword, "content", issueIndexerAnalyzer), + inner_bleve.MatchPhraseQuery(options.Keyword, "comments", issueIndexerAnalyzer), + } + queries = append(queries, bleve.NewDisjunctionQuery(keywordQueries...)) + } + + if len(options.RepoIDs) > 0 || options.AllPublic { + var repoQueries []query.Query + for _, repoID := range options.RepoIDs { + repoQueries = append(repoQueries, inner_bleve.NumericEqualityQuery(repoID, "repo_id")) + } + if options.AllPublic { + repoQueries = append(repoQueries, inner_bleve.BoolFieldQuery(true, "is_public")) + } + queries = append(queries, bleve.NewDisjunctionQuery(repoQueries...)) + } + + if !options.IsPull.IsNone() { + queries = append(queries, inner_bleve.BoolFieldQuery(options.IsPull.IsTrue(), "is_pull")) + } + if !options.IsClosed.IsNone() { + queries = append(queries, inner_bleve.BoolFieldQuery(options.IsClosed.IsTrue(), "is_closed")) + } + + if options.NoLabelOnly { + queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_label")) + } else { + if len(options.IncludedLabelIDs) > 0 { + var includeQueries []query.Query + for _, labelID := range options.IncludedLabelIDs { + includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids")) + } + queries = append(queries, bleve.NewConjunctionQuery(includeQueries...)) + } else if len(options.IncludedAnyLabelIDs) > 0 { + var includeQueries []query.Query + for _, labelID := range options.IncludedAnyLabelIDs { + includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids")) + } + queries = append(queries, bleve.NewDisjunctionQuery(includeQueries...)) + } + if len(options.ExcludedLabelIDs) > 0 { + var excludeQueries []query.Query + for _, labelID := range options.ExcludedLabelIDs { + q := bleve.NewBooleanQuery() + q.AddMustNot(inner_bleve.NumericEqualityQuery(labelID, "label_ids")) + excludeQueries = append(excludeQueries, q) + } + queries = append(queries, bleve.NewConjunctionQuery(excludeQueries...)) + } + } + + if len(options.MilestoneIDs) > 0 { + var milestoneQueries []query.Query + for _, milestoneID := range options.MilestoneIDs { + milestoneQueries = append(milestoneQueries, inner_bleve.NumericEqualityQuery(milestoneID, "milestone_id")) + } + queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...)) + } + + if options.ProjectID != nil { + queries = append(queries, inner_bleve.NumericEqualityQuery(*options.ProjectID, "project_id")) + } + if options.ProjectBoardID != nil { + queries = append(queries, inner_bleve.NumericEqualityQuery(*options.ProjectBoardID, "project_board_id")) + } + + if options.PosterID != nil { + queries = append(queries, inner_bleve.NumericEqualityQuery(*options.PosterID, "poster_id")) + } + + if options.AssigneeID != nil { + queries = append(queries, inner_bleve.NumericEqualityQuery(*options.AssigneeID, "assignee_id")) + } + + if options.MentionID != nil { + queries = append(queries, inner_bleve.NumericEqualityQuery(*options.MentionID, "mention_ids")) + } + + if options.ReviewedID != nil { + queries = append(queries, inner_bleve.NumericEqualityQuery(*options.ReviewedID, "reviewed_ids")) + } + if options.ReviewRequestedID != nil { + queries = append(queries, inner_bleve.NumericEqualityQuery(*options.ReviewRequestedID, "review_requested_ids")) + } + + if options.SubscriberID != nil { + queries = append(queries, inner_bleve.NumericEqualityQuery(*options.SubscriberID, "subscriber_ids")) + } + + if options.UpdatedAfterUnix != nil || options.UpdatedBeforeUnix != nil { + queries = append(queries, inner_bleve.NumericRangeInclusiveQuery(options.UpdatedAfterUnix, options.UpdatedBeforeUnix, "updated_unix")) + } + + var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) + if len(queries) == 0 { + indexerQuery = bleve.NewMatchAllQuery() + } + + skip, limit := indexer_internal.ParsePaginator(options.Paginator) + search := bleve.NewSearchRequestOptions(indexerQuery, limit, skip, false) + + if options.SortBy == "" { + options.SortBy = internal.SortByCreatedAsc + } + + search.SortBy([]string{string(options.SortBy), "-_id"}) result, err := b.inner.Indexer.SearchInContext(ctx, search) if err != nil { return nil, err } - ret := internal.SearchResult{ - Hits: make([]internal.Match, 0, len(result.Hits)), + ret := &internal.SearchResult{ + Total: int64(result.Total), + Hits: make([]internal.Match, 0, len(result.Hits)), } for _, hit := range result.Hits { id, err := indexer_internal.ParseBase36(hit.ID) @@ -175,5 +283,5 @@ func (b *Indexer) Search(ctx context.Context, keyword string, repoIDs []int64, l ID: id, }) } - return &ret, nil + return ret, nil } diff --git a/modules/indexer/issues/bleve/bleve_test.go b/modules/indexer/issues/bleve/bleve_test.go index 0eb136d22b276..908514a01a2d6 100644 --- a/modules/indexer/issues/bleve/bleve_test.go +++ b/modules/indexer/issues/bleve/bleve_test.go @@ -4,86 +4,15 @@ package bleve import ( - "context" "testing" - "code.gitea.io/gitea/modules/indexer/issues/internal" - - "github.com/stretchr/testify/assert" + "code.gitea.io/gitea/modules/indexer/issues/internal/tests" ) -func TestBleveIndexAndSearch(t *testing.T) { +func TestBleveIndexer(t *testing.T) { dir := t.TempDir() indexer := NewIndexer(dir) defer indexer.Close() - if _, err := indexer.Init(context.Background()); err != nil { - assert.Fail(t, "Unable to initialize bleve indexer: %v", err) - return - } - - err := indexer.Index(context.Background(), []*internal.IndexerData{ - { - ID: 1, - RepoID: 2, - Title: "Issue search should support Chinese", - Content: "As title", - Comments: []string{ - "test1", - "test2", - }, - }, - { - ID: 2, - RepoID: 2, - Title: "CJK support could be optional", - Content: "Chinese Korean and Japanese should be supported but I would like it's not enabled by default", - Comments: []string{ - "LGTM", - "Good idea", - }, - }, - }) - assert.NoError(t, err) - - keywords := []struct { - Keyword string - IDs []int64 - }{ - { - Keyword: "search", - IDs: []int64{1}, - }, - { - Keyword: "test1", - IDs: []int64{1}, - }, - { - Keyword: "test2", - IDs: []int64{1}, - }, - { - Keyword: "support", - IDs: []int64{1, 2}, - }, - { - Keyword: "chinese", - IDs: []int64{1, 2}, - }, - { - Keyword: "help", - IDs: []int64{}, - }, - } - - for _, kw := range keywords { - res, err := indexer.Search(context.TODO(), kw.Keyword, []int64{2}, 10, 0, "") - assert.NoError(t, err) - - ids := make([]int64, 0, len(res.Hits)) - for _, hit := range res.Hits { - ids = append(ids, hit.ID) - } - assert.ElementsMatch(t, kw.IDs, ids) - } + tests.TestIndexer(t, indexer) } diff --git a/modules/indexer/issues/db/db.go b/modules/indexer/issues/db/db.go index b054b9d800edb..1016523b7291e 100644 --- a/modules/indexer/issues/db/db.go +++ b/modules/indexer/issues/db/db.go @@ -6,10 +6,13 @@ package db import ( "context" - issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/db" + issue_model "code.gitea.io/gitea/models/issues" indexer_internal "code.gitea.io/gitea/modules/indexer/internal" inner_db "code.gitea.io/gitea/modules/indexer/internal/db" "code.gitea.io/gitea/modules/indexer/issues/internal" + + "xorm.io/builder" ) var _ internal.Indexer = &Indexer{} @@ -26,7 +29,7 @@ func NewIndexer() *Indexer { } // Index dummy function -func (i *Indexer) Index(_ context.Context, _ []*internal.IndexerData) error { +func (i *Indexer) Index(_ context.Context, _ ...*internal.IndexerData) error { return nil } @@ -36,19 +39,58 @@ func (i *Indexer) Delete(_ context.Context, _ ...int64) error { } // Search searches for issues -func (i *Indexer) Search(ctx context.Context, kw string, repoIDs []int64, limit, start int, state string) (*internal.SearchResult, error) { - total, ids, err := issues_model.SearchIssueIDsByKeyword(ctx, kw, repoIDs, limit, start) +func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) { + // FIXME: I tried to avoid importing models here, but it seems to be impossible. + // We can provide a function to register the search function, so models/issues can register it. + // So models/issues will import modules/indexer/issues, it's OK because it's by design. + // But modules/indexer/issues has already imported models/issues to do UpdateRepoIndexer and UpdateIssueIndexer. + // And to avoid circular import, we have to move the functions to another package. + // I believe it should be services/indexer, sounds great! + // But the two functions are used in modules/notification/indexer, that means we will import services/indexer in modules/notification/indexer. + // So that's the root problem: + // The notification is defined in modules, but it's using lots of things should be in services. + + cond := builder.NewCond() + + if options.Keyword != "" { + repoCond := builder.In("repo_id", options.RepoIDs) + if len(options.RepoIDs) == 1 { + repoCond = builder.Eq{"repo_id": options.RepoIDs[0]} + } + subQuery := builder.Select("id").From("issue").Where(repoCond) + + cond = builder.Or( + db.BuildCaseInsensitiveLike("issue.name", options.Keyword), + db.BuildCaseInsensitiveLike("issue.content", options.Keyword), + builder.In("issue.id", builder.Select("issue_id"). + From("comment"). + Where(builder.And( + builder.Eq{"type": issue_model.CommentTypeComment}, + builder.In("issue_id", subQuery), + db.BuildCaseInsensitiveLike("content", options.Keyword), + )), + ), + ) + } + + opt, err := ToDBOptions(ctx, options) if err != nil { return nil, err } - result := internal.SearchResult{ - Total: total, - Hits: make([]internal.Match, 0, limit), + + ids, total, err := issue_model.IssueIDs(ctx, opt, cond) + if err != nil { + return nil, err } + + hits := make([]internal.Match, 0, len(ids)) for _, id := range ids { - result.Hits = append(result.Hits, internal.Match{ + hits = append(hits, internal.Match{ ID: id, }) } - return &result, nil + return &internal.SearchResult{ + Total: total, + Hits: hits, + }, nil } diff --git a/modules/indexer/issues/db/options.go b/modules/indexer/issues/db/options.go new file mode 100644 index 0000000000000..ebd672a6954a4 --- /dev/null +++ b/modules/indexer/issues/db/options.go @@ -0,0 +1,114 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package db + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + issue_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/indexer/issues/internal" +) + +func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_model.IssuesOptions, error) { + convertID := func(id *int64) int64 { + if id == nil { + return 0 + } + if *id == 0 { + return db.NoConditionID + } + return *id + } + convertInt64 := func(i *int64) int64 { + if i == nil { + return 0 + } + return *i + } + var sortType string + switch options.SortBy { + case internal.SortByCreatedAsc: + sortType = "oldest" + case internal.SortByUpdatedAsc: + sortType = "leastupdate" + case internal.SortByCommentsAsc: + sortType = "leastcomment" + case internal.SortByDeadlineAsc: + sortType = "farduedate" + case internal.SortByCreatedDesc: + sortType = "newest" + case internal.SortByUpdatedDesc: + sortType = "recentupdate" + case internal.SortByCommentsDesc: + sortType = "mostcomment" + case internal.SortByDeadlineDesc: + sortType = "nearduedate" + default: + sortType = "newest" + } + + opts := &issue_model.IssuesOptions{ + Paginator: options.Paginator, + RepoIDs: options.RepoIDs, + RepoCond: nil, + AssigneeID: convertID(options.AssigneeID), + PosterID: convertID(options.PosterID), + MentionedID: convertID(options.MentionID), + ReviewRequestedID: convertID(options.ReviewRequestedID), + ReviewedID: convertID(options.ReviewedID), + SubscriberID: convertID(options.SubscriberID), + ProjectID: convertID(options.ProjectID), + ProjectBoardID: convertID(options.ProjectBoardID), + IsClosed: options.IsClosed, + IsPull: options.IsPull, + IncludedLabelNames: nil, + ExcludedLabelNames: nil, + IncludeMilestones: nil, + SortType: sortType, + IssueIDs: nil, + UpdatedAfterUnix: convertInt64(options.UpdatedAfterUnix), + UpdatedBeforeUnix: convertInt64(options.UpdatedBeforeUnix), + PriorityRepoID: 0, + IsArchived: 0, + Org: nil, + Team: nil, + User: nil, + } + + if len(options.MilestoneIDs) == 1 && options.MilestoneIDs[0] == 0 { + opts.MilestoneIDs = []int64{db.NoConditionID} + } else { + opts.MilestoneIDs = options.MilestoneIDs + } + + if options.NoLabelOnly { + opts.LabelIDs = []int64{0} // Be careful, it's zero, not db.NoConditionID + } else { + opts.LabelIDs = make([]int64, 0, len(options.IncludedLabelIDs)+len(options.ExcludedLabelIDs)) + opts.LabelIDs = append(opts.LabelIDs, options.IncludedLabelIDs...) + for _, id := range options.ExcludedLabelIDs { + opts.LabelIDs = append(opts.LabelIDs, -id) + } + + if len(options.IncludedLabelIDs) == 0 && len(options.IncludedAnyLabelIDs) > 0 { + _ = ctx // issue_model.GetLabelsByIDs should be called with ctx, this line can be removed when it's done. + labels, err := issue_model.GetLabelsByIDs(options.IncludedAnyLabelIDs, "name") + if err != nil { + return nil, fmt.Errorf("GetLabelsByIDs: %v", err) + } + set := container.Set[string]{} + for _, label := range labels { + if !set.Contains(label.Name) { + set.Add(label.Name) + opts.IncludedLabelNames = append(opts.IncludedLabelNames, label.Name) + } + } + } + } + + return opts, nil +} diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go new file mode 100644 index 0000000000000..6a41afadd7077 --- /dev/null +++ b/modules/indexer/issues/dboptions.go @@ -0,0 +1,93 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" +) + +func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions { + searchOpt := &SearchOptions{ + Keyword: keyword, + RepoIDs: opts.RepoIDs, + AllPublic: false, + IsPull: opts.IsPull, + IsClosed: opts.IsClosed, + } + + if len(opts.LabelIDs) == 1 && opts.LabelIDs[0] == 0 { + searchOpt.NoLabelOnly = true + } else { + for _, labelID := range opts.LabelIDs { + if labelID > 0 { + searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID) + } else { + searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID) + } + } + // opts.IncludedLabelNames and opts.ExcludedLabelNames are not supported here. + // It's not a TO DO, it's just unnecessary. + } + + if len(opts.MilestoneIDs) == 1 && opts.MilestoneIDs[0] == db.NoConditionID { + searchOpt.MilestoneIDs = []int64{0} + } else { + searchOpt.MilestoneIDs = opts.MilestoneIDs + } + + if opts.AssigneeID > 0 { + searchOpt.AssigneeID = &opts.AssigneeID + } + if opts.PosterID > 0 { + searchOpt.PosterID = &opts.PosterID + } + if opts.MentionedID > 0 { + searchOpt.MentionID = &opts.MentionedID + } + if opts.ReviewedID > 0 { + searchOpt.ReviewedID = &opts.ReviewedID + } + if opts.ReviewRequestedID > 0 { + searchOpt.ReviewRequestedID = &opts.ReviewRequestedID + } + if opts.SubscriberID > 0 { + searchOpt.SubscriberID = &opts.SubscriberID + } + + if opts.UpdatedAfterUnix > 0 { + searchOpt.UpdatedAfterUnix = &opts.UpdatedAfterUnix + } + if opts.UpdatedBeforeUnix > 0 { + searchOpt.UpdatedBeforeUnix = &opts.UpdatedBeforeUnix + } + + searchOpt.Paginator = opts.Paginator + + switch opts.SortType { + case "": + searchOpt.SortBy = SortByCreatedDesc + case "oldest": + searchOpt.SortBy = SortByCreatedAsc + case "recentupdate": + searchOpt.SortBy = SortByUpdatedDesc + case "leastupdate": + searchOpt.SortBy = SortByUpdatedAsc + case "mostcomment": + searchOpt.SortBy = SortByCommentsDesc + case "leastcomment": + searchOpt.SortBy = SortByCommentsAsc + case "nearduedate": + searchOpt.SortBy = SortByDeadlineAsc + case "farduedate": + searchOpt.SortBy = SortByDeadlineDesc + case "priority", "priorityrepo", "project-column-sorting": + // Unsupported sort type for search + searchOpt.SortBy = SortByUpdatedDesc + default: + searchOpt.SortBy = SortByUpdatedDesc + } + + return searchOpt +} diff --git a/modules/indexer/issues/elasticsearch/elasticsearch.go b/modules/indexer/issues/elasticsearch/elasticsearch.go index cfd3628c18507..d059f76b3288a 100644 --- a/modules/indexer/issues/elasticsearch/elasticsearch.go +++ b/modules/indexer/issues/elasticsearch/elasticsearch.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "strconv" + "strings" "code.gitea.io/gitea/modules/graceful" indexer_internal "code.gitea.io/gitea/modules/indexer/internal" @@ -17,7 +18,7 @@ import ( ) const ( - issueIndexerLatestVersion = 0 + issueIndexerLatestVersion = 1 ) var _ internal.Indexer = &Indexer{} @@ -39,36 +40,44 @@ func NewIndexer(url, indexerName string) *Indexer { } const ( - defaultMapping = `{ - "mappings": { - "properties": { - "id": { - "type": "integer", - "index": true - }, - "repo_id": { - "type": "integer", - "index": true - }, - "title": { - "type": "text", - "index": true - }, - "content": { - "type": "text", - "index": true - }, - "comments": { - "type" : "text", - "index": true - } - } + defaultMapping = ` +{ + "mappings": { + "properties": { + "id": { "type": "integer", "index": true }, + "repo_id": { "type": "integer", "index": true }, + "is_public": { "type": "boolean", "index": true }, + + "title": { "type": "text", "index": true }, + "content": { "type": "text", "index": true }, + "comments": { "type" : "text", "index": true }, + + "is_pull": { "type": "boolean", "index": true }, + "is_closed": { "type": "boolean", "index": true }, + "label_ids": { "type": "integer", "index": true }, + "no_label": { "type": "boolean", "index": true }, + "milestone_id": { "type": "integer", "index": true }, + "project_id": { "type": "integer", "index": true }, + "project_board_id": { "type": "integer", "index": true }, + "poster_id": { "type": "integer", "index": true }, + "assignee_id": { "type": "integer", "index": true }, + "mention_ids": { "type": "integer", "index": true }, + "reviewed_ids": { "type": "integer", "index": true }, + "review_requested_ids": { "type": "integer", "index": true }, + "subscriber_ids": { "type": "integer", "index": true }, + "updated_unix": { "type": "integer", "index": true }, + + "created_unix": { "type": "integer", "index": true }, + "deadline_unix": { "type": "integer", "index": true }, + "comment_count": { "type": "integer", "index": true } } - }` + } +} +` ) // Index will save the index data -func (b *Indexer) Index(ctx context.Context, issues []*internal.IndexerData) error { +func (b *Indexer) Index(ctx context.Context, issues ...*internal.IndexerData) error { if len(issues) == 0 { return nil } else if len(issues) == 1 { @@ -76,13 +85,7 @@ func (b *Indexer) Index(ctx context.Context, issues []*internal.IndexerData) err _, err := b.inner.Client.Index(). Index(b.inner.VersionedIndexName()). Id(fmt.Sprintf("%d", issue.ID)). - BodyJson(map[string]any{ - "id": issue.ID, - "repo_id": issue.RepoID, - "title": issue.Title, - "content": issue.Content, - "comments": issue.Comments, - }). + BodyJson(issue). Do(ctx) return err } @@ -93,13 +96,7 @@ func (b *Indexer) Index(ctx context.Context, issues []*internal.IndexerData) err elastic.NewBulkIndexRequest(). Index(b.inner.VersionedIndexName()). Id(fmt.Sprintf("%d", issue.ID)). - Doc(map[string]any{ - "id": issue.ID, - "repo_id": issue.RepoID, - "title": issue.Title, - "content": issue.Content, - "comments": issue.Comments, - }), + Doc(issue), ) } @@ -140,23 +137,113 @@ func (b *Indexer) Delete(ctx context.Context, ids ...int64) error { // Search searches for issues by given conditions. // Returns the matching issue IDs -func (b *Indexer) Search(ctx context.Context, keyword string, repoIDs []int64, limit, start int, state string) (*internal.SearchResult, error) { - kwQuery := elastic.NewMultiMatchQuery(keyword, "title", "content", "comments") +func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) { query := elastic.NewBoolQuery() - query = query.Must(kwQuery) - if len(repoIDs) > 0 { - repoStrs := make([]any, 0, len(repoIDs)) - for _, repoID := range repoIDs { - repoStrs = append(repoStrs, repoID) + + if options.Keyword != "" { + query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments")) + } + + if len(options.RepoIDs) > 0 { + q := elastic.NewBoolQuery() + q.Should(elastic.NewTermsQuery("repo_id", toAnySlice(options.RepoIDs)...)) + if options.AllPublic { + q.Should(elastic.NewTermQuery("is_public", true)) } - repoQuery := elastic.NewTermsQuery("repo_id", repoStrs...) - query = query.Must(repoQuery) + query.Must(q) + } + + if !options.IsPull.IsNone() { + query.Must(elastic.NewTermQuery("is_pull", options.IsPull.IsTrue())) + } + if !options.IsClosed.IsNone() { + query.Must(elastic.NewTermQuery("is_closed", options.IsClosed.IsTrue())) + } + + if options.NoLabelOnly { + query.Must(elastic.NewTermQuery("no_label", true)) + } else { + if len(options.IncludedLabelIDs) > 0 { + q := elastic.NewBoolQuery() + for _, labelID := range options.IncludedLabelIDs { + q.Must(elastic.NewTermQuery("label_ids", labelID)) + } + query.Must(q) + } else if len(options.IncludedAnyLabelIDs) > 0 { + query.Must(elastic.NewTermsQuery("label_ids", toAnySlice(options.IncludedAnyLabelIDs)...)) + } + if len(options.ExcludedLabelIDs) > 0 { + q := elastic.NewBoolQuery() + for _, labelID := range options.ExcludedLabelIDs { + q.MustNot(elastic.NewTermQuery("label_ids", labelID)) + } + query.Must(q) + } + } + + if len(options.MilestoneIDs) > 0 { + query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...)) + } + + if options.ProjectID != nil { + query.Must(elastic.NewTermQuery("project_id", *options.ProjectID)) + } + if options.ProjectBoardID != nil { + query.Must(elastic.NewTermQuery("project_board_id", *options.ProjectBoardID)) + } + + if options.PosterID != nil { + query.Must(elastic.NewTermQuery("poster_id", *options.PosterID)) } + + if options.AssigneeID != nil { + query.Must(elastic.NewTermQuery("assignee_id", *options.AssigneeID)) + } + + if options.MentionID != nil { + query.Must(elastic.NewTermQuery("mention_ids", *options.MentionID)) + } + + if options.ReviewedID != nil { + query.Must(elastic.NewTermQuery("reviewed_ids", *options.ReviewedID)) + } + if options.ReviewRequestedID != nil { + query.Must(elastic.NewTermQuery("review_requested_ids", *options.ReviewRequestedID)) + } + + if options.SubscriberID != nil { + query.Must(elastic.NewTermQuery("subscriber_ids", *options.SubscriberID)) + } + + if options.UpdatedAfterUnix != nil || options.UpdatedBeforeUnix != nil { + q := elastic.NewRangeQuery("updated_unix") + if options.UpdatedAfterUnix != nil { + q.Gte(*options.UpdatedAfterUnix) + } + if options.UpdatedBeforeUnix != nil { + q.Lte(*options.UpdatedBeforeUnix) + } + query.Must(q) + } + + if options.SortBy == "" { + options.SortBy = internal.SortByCreatedAsc + } + sortBy := []elastic.Sorter{ + parseSortBy(options.SortBy), + elastic.NewFieldSort("id").Desc(), + } + + // See https://stackoverflow.com/questions/35206409/elasticsearch-2-1-result-window-is-too-large-index-max-result-window/35221900 + // TODO: make it configurable since it's configurable in elasticsearch + const maxPageSize = 10000 + + skip, limit := indexer_internal.ParsePaginator(options.Paginator, maxPageSize) searchResult, err := b.inner.Client.Search(). Index(b.inner.VersionedIndexName()). Query(query). - Sort("_score", false). - From(start).Size(limit). + SortBy(sortBy...). + From(skip).Size(limit). Do(ctx) if err != nil { return nil, err @@ -175,3 +262,20 @@ func (b *Indexer) Search(ctx context.Context, keyword string, repoIDs []int64, l Hits: hits, }, nil } + +func toAnySlice[T any](s []T) []any { + ret := make([]any, 0, len(s)) + for _, item := range s { + ret = append(ret, item) + } + return ret +} + +func parseSortBy(sortBy internal.SortBy) elastic.Sorter { + field := strings.TrimPrefix(string(sortBy), "-") + ret := elastic.NewFieldSort(field) + if strings.HasPrefix(string(sortBy), "-") { + ret.Desc() + } + return ret +} diff --git a/modules/indexer/issues/elasticsearch/elasticsearch_test.go b/modules/indexer/issues/elasticsearch/elasticsearch_test.go new file mode 100644 index 0000000000000..ffd85b1aa1dbb --- /dev/null +++ b/modules/indexer/issues/elasticsearch/elasticsearch_test.go @@ -0,0 +1,48 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package elasticsearch + +import ( + "fmt" + "net/http" + "os" + "testing" + "time" + + "code.gitea.io/gitea/modules/indexer/issues/internal/tests" +) + +func TestElasticsearchIndexer(t *testing.T) { + // The elasticsearch instance started by pull-db-tests.yml > test-unit > services > elasticsearch + url := "http://elastic:changeme@elasticsearch:9200" + + if os.Getenv("CI") == "" { + // Make it possible to run tests against a local elasticsearch instance + url = os.Getenv("TEST_ELASTICSEARCH_URL") + if url == "" { + t.Skip("TEST_ELASTICSEARCH_URL not set and not running in CI") + return + } + } + + ok := false + for i := 0; i < 60; i++ { + resp, err := http.Get(url) + if err == nil && resp.StatusCode == http.StatusOK { + ok = true + break + } + t.Logf("Waiting for elasticsearch to be up: %v", err) + time.Sleep(time.Second) + } + if !ok { + t.Fatalf("Failed to wait for elasticsearch to be up") + return + } + + indexer := NewIndexer(url, fmt.Sprintf("test_elasticsearch_indexer_%d", time.Now().Unix())) + defer indexer.Close() + + tests.TestIndexer(t, indexer) +} diff --git a/modules/indexer/issues/indexer.go b/modules/indexer/issues/indexer.go index fe5c5d8f26d30..42279cbddb648 100644 --- a/modules/indexer/issues/indexer.go +++ b/modules/indexer/issues/indexer.go @@ -11,7 +11,6 @@ import ( "time" db_model "code.gitea.io/gitea/models/db" - issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/indexer/issues/bleve" @@ -26,9 +25,24 @@ import ( "code.gitea.io/gitea/modules/util" ) +// IndexerMetadata is used to send data to the queue, so it contains only the ids. +// It may look weired, because it has to be compatible with the old queue data format. +// If the IsDelete flag is true, the IDs specify the issues to delete from the index without querying the database. +// If the IsDelete flag is false, the ID specify the issue to index, so Indexer will query the database to get the issue data. +// It should be noted that if the id is not existing in the database, it's index will be deleted too even if IsDelete is false. +// Valid values: +// - IsDelete = true, IDs = [1, 2, 3], and ID will be ignored +// - IsDelete = false, ID = 1, and IDs will be ignored +type IndexerMetadata struct { + ID int64 `json:"id"` + + IsDelete bool `json:"is_delete"` + IDs []int64 `json:"ids"` +} + var ( // issueIndexerQueue queue of issue ids to be updated - issueIndexerQueue *queue.WorkerPoolQueue[*internal.IndexerData] + issueIndexerQueue *queue.WorkerPoolQueue[*IndexerMetadata] // globalIndexer is the global indexer, it cannot be nil. // When the real indexer is not ready, it will be a dummy indexer which will return error to explain it's not ready. // So it's always safe use it as *globalIndexer.Load() and call its methods. @@ -50,37 +64,7 @@ func InitIssueIndexer(syncReindex bool) { indexerInitWaitChannel := make(chan time.Duration, 1) // Create the Queue - switch setting.Indexer.IssueType { - case "bleve", "elasticsearch", "meilisearch": - handler := func(items ...*internal.IndexerData) (unhandled []*internal.IndexerData) { - indexer := *globalIndexer.Load() - toIndex := make([]*internal.IndexerData, 0, len(items)) - for _, indexerData := range items { - log.Trace("IndexerData Process: %d %v %t", indexerData.ID, indexerData.IDs, indexerData.IsDelete) - if indexerData.IsDelete { - if err := indexer.Delete(ctx, indexerData.IDs...); err != nil { - log.Error("Issue indexer handler: failed to from index: %v Error: %v", indexerData.IDs, err) - unhandled = append(unhandled, indexerData) - } - continue - } - toIndex = append(toIndex, indexerData) - } - if err := indexer.Index(ctx, toIndex); err != nil { - log.Error("Error whilst indexing: %v Error: %v", toIndex, err) - unhandled = append(unhandled, toIndex...) - } - return unhandled - } - - issueIndexerQueue = queue.CreateSimpleQueue(ctx, "issue_indexer", handler) - - if issueIndexerQueue == nil { - log.Fatal("Unable to create issue indexer queue") - } - default: - issueIndexerQueue = queue.CreateSimpleQueue[*internal.IndexerData](ctx, "issue_indexer", nil) - } + issueIndexerQueue = queue.CreateUniqueQueue(ctx, "issue_indexer", getIssueIndexerQueueHandler(ctx)) graceful.GetManager().RunAtTerminate(finished) @@ -176,6 +160,44 @@ func InitIssueIndexer(syncReindex bool) { } } +func getIssueIndexerQueueHandler(ctx context.Context) func(items ...*IndexerMetadata) []*IndexerMetadata { + return func(items ...*IndexerMetadata) []*IndexerMetadata { + var unhandled []*IndexerMetadata + + indexer := *globalIndexer.Load() + for _, item := range items { + log.Trace("IndexerMetadata Process: %d %v %t", item.ID, item.IDs, item.IsDelete) + if item.IsDelete { + if err := indexer.Delete(ctx, item.IDs...); err != nil { + log.Error("Issue indexer handler: failed to from index: %v Error: %v", item.IDs, err) + unhandled = append(unhandled, item) + } + continue + } + data, existed, err := getIssueIndexerData(ctx, item.ID) + if err != nil { + log.Error("Issue indexer handler: failed to get issue data of %d: %v", item.ID, err) + unhandled = append(unhandled, item) + continue + } + if !existed { + if err := indexer.Delete(ctx, item.ID); err != nil { + log.Error("Issue indexer handler: failed to delete issue %d from index: %v", item.ID, err) + unhandled = append(unhandled, item) + } + continue + } + if err := indexer.Index(ctx, data); err != nil { + log.Error("Issue indexer handler: failed to index issue %d: %v", item.ID, err) + unhandled = append(unhandled, item) + continue + } + } + + return unhandled + } +} + // populateIssueIndexer populate the issue indexer with issue data func populateIssueIndexer(ctx context.Context) { ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: PopulateIssueIndexer", process.SystemProcessType, true) @@ -203,101 +225,87 @@ func populateIssueIndexer(ctx context.Context) { } for _, repo := range repos { - select { - case <-ctx.Done(): - log.Info("Issue Indexer population shutdown before completion") - return - default: + for { + select { + case <-ctx.Done(): + log.Info("Issue Indexer population shutdown before completion") + return + default: + } + if err := updateRepoIndexer(ctx, repo.ID); err != nil { + log.Warn("Retry to populate issue indexer for repo %d: %v", repo.ID, err) + continue + } + break } - UpdateRepoIndexer(ctx, repo) } } } // UpdateRepoIndexer add/update all issues of the repositories -func UpdateRepoIndexer(ctx context.Context, repo *repo_model.Repository) { - is, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ - RepoIDs: []int64{repo.ID}, - IsClosed: util.OptionalBoolNone, - IsPull: util.OptionalBoolNone, - }) - if err != nil { - log.Error("Issues: %v", err) - return - } - if err = issues_model.IssueList(is).LoadDiscussComments(ctx); err != nil { - log.Error("LoadDiscussComments: %v", err) - return - } - for _, issue := range is { - UpdateIssueIndexer(issue) +func UpdateRepoIndexer(ctx context.Context, repoID int64) { + if err := updateRepoIndexer(ctx, repoID); err != nil { + log.Error("Unable to push repo %d to issue indexer: %v", repoID, err) } } // UpdateIssueIndexer add/update an issue to the issue indexer -func UpdateIssueIndexer(issue *issues_model.Issue) { - var comments []string - for _, comment := range issue.Comments { - if comment.Type == issues_model.CommentTypeComment { - comments = append(comments, comment.Content) - } - } - issueType := "issue" - if issue.IsPull { - issueType = "pull" - } - indexerData := &internal.IndexerData{ - ID: issue.ID, - RepoID: issue.RepoID, - State: string(issue.State()), - IssueType: issueType, - Title: issue.Title, - Content: issue.Content, - Comments: comments, - } - log.Debug("Adding to channel: %v", indexerData) - if err := issueIndexerQueue.Push(indexerData); err != nil { - log.Error("Unable to push to issue indexer: %v: Error: %v", indexerData, err) +func UpdateIssueIndexer(issueID int64) { + if err := updateIssueIndexer(issueID); err != nil { + log.Error("Unable to push issue %d to issue indexer: %v", issueID, err) } } // DeleteRepoIssueIndexer deletes repo's all issues indexes -func DeleteRepoIssueIndexer(ctx context.Context, repo *repo_model.Repository) { - var ids []int64 - ids, err := issues_model.GetIssueIDsByRepoID(ctx, repo.ID) - if err != nil { - log.Error("GetIssueIDsByRepoID failed: %v", err) - return +func DeleteRepoIssueIndexer(ctx context.Context, repoID int64) { + if err := deleteRepoIssueIndexer(ctx, repoID); err != nil { + log.Error("Unable to push deleted repo %d to issue indexer: %v", repoID, err) } +} - if len(ids) == 0 { - return - } - indexerData := &internal.IndexerData{ - IDs: ids, - IsDelete: true, - } - if err := issueIndexerQueue.Push(indexerData); err != nil { - log.Error("Unable to push to issue indexer: %v: Error: %v", indexerData, err) - } +// IsAvailable checks if issue indexer is available +func IsAvailable(ctx context.Context) bool { + return (*globalIndexer.Load()).Ping(ctx) == nil } -// SearchIssuesByKeyword search issue ids by keywords and repo id -// WARNNING: You have to ensure user have permission to visit repoIDs' issues -func SearchIssuesByKeyword(ctx context.Context, repoIDs []int64, keyword, state string) ([]int64, error) { - var issueIDs []int64 +// SearchOptions indicates the options for searching issues +type SearchOptions internal.SearchOptions + +const ( + SortByCreatedDesc = internal.SortByCreatedDesc + SortByUpdatedDesc = internal.SortByUpdatedDesc + SortByCommentsDesc = internal.SortByCommentsDesc + SortByDeadlineDesc = internal.SortByDeadlineDesc + SortByCreatedAsc = internal.SortByCreatedAsc + SortByUpdatedAsc = internal.SortByUpdatedAsc + SortByCommentsAsc = internal.SortByCommentsAsc + SortByDeadlineAsc = internal.SortByDeadlineAsc +) + +// 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() - res, err := indexer.Search(ctx, keyword, repoIDs, 50, 0, state) + + if opts.Keyword == "" { + // This is a conservative shortcut. + // If the keyword is empty, db has better (at least not worse) performance to filter issues. + // When the keyword is empty, it tends to listing rather than searching issues. + // So if the user creates an issue and list issues immediately, the issue may not be listed because the indexer needs time to index the issue. + // Even worse, the external indexer like elastic search may not be available for a while, + // and the user may not be able to list issues completely until it is available again. + indexer = db.NewIndexer() + } + + result, err := indexer.Search(ctx, (*internal.SearchOptions)(opts)) if err != nil { - return nil, err + return nil, 0, err } - for _, r := range res.Hits { - issueIDs = append(issueIDs, r.ID) + + ret := make([]int64, 0, len(result.Hits)) + for _, hit := range result.Hits { + ret = append(ret, hit.ID) } - return issueIDs, nil -} -// IsAvailable checks if issue indexer is available -func IsAvailable(ctx context.Context) bool { - return (*globalIndexer.Load()).Ping(ctx) == nil + return ret, result.Total, nil } diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go index 757eb2f3d9338..d6812f714e086 100644 --- a/modules/indexer/issues/indexer_test.go +++ b/modules/indexer/issues/indexer_test.go @@ -50,21 +50,41 @@ func TestBleveSearchIssues(t *testing.T) { time.Sleep(5 * time.Second) - ids, err := SearchIssuesByKeyword(context.TODO(), []int64{1}, "issue2", "") - assert.NoError(t, err) - assert.EqualValues(t, []int64{2}, ids) + t.Run("issue2", func(t *testing.T) { + ids, _, err := SearchIssues(context.TODO(), &SearchOptions{ + Keyword: "issue2", + RepoIDs: []int64{1}, + }) + assert.NoError(t, err) + assert.EqualValues(t, []int64{2}, ids) + }) - ids, err = SearchIssuesByKeyword(context.TODO(), []int64{1}, "first", "") - assert.NoError(t, err) - assert.EqualValues(t, []int64{1}, ids) + t.Run("first", func(t *testing.T) { + ids, _, err := SearchIssues(context.TODO(), &SearchOptions{ + Keyword: "first", + RepoIDs: []int64{1}, + }) + assert.NoError(t, err) + assert.EqualValues(t, []int64{1}, ids) + }) - ids, err = SearchIssuesByKeyword(context.TODO(), []int64{1}, "for", "") - assert.NoError(t, err) - assert.ElementsMatch(t, []int64{1, 2, 3, 5, 11}, ids) + t.Run("for", func(t *testing.T) { + ids, _, err := SearchIssues(context.TODO(), &SearchOptions{ + Keyword: "for", + RepoIDs: []int64{1}, + }) + assert.NoError(t, err) + assert.ElementsMatch(t, []int64{1, 2, 3, 5, 11}, ids) + }) - ids, err = SearchIssuesByKeyword(context.TODO(), []int64{1}, "good", "") - assert.NoError(t, err) - assert.EqualValues(t, []int64{1}, ids) + t.Run("good", func(t *testing.T) { + ids, _, err := SearchIssues(context.TODO(), &SearchOptions{ + Keyword: "good", + RepoIDs: []int64{1}, + }) + assert.NoError(t, err) + assert.EqualValues(t, []int64{1}, ids) + }) } func TestDBSearchIssues(t *testing.T) { @@ -73,19 +93,39 @@ func TestDBSearchIssues(t *testing.T) { setting.Indexer.IssueType = "db" InitIssueIndexer(true) - ids, err := SearchIssuesByKeyword(context.TODO(), []int64{1}, "issue2", "") - assert.NoError(t, err) - assert.EqualValues(t, []int64{2}, ids) + t.Run("issue2", func(t *testing.T) { + ids, _, err := SearchIssues(context.TODO(), &SearchOptions{ + Keyword: "issue2", + RepoIDs: []int64{1}, + }) + assert.NoError(t, err) + assert.EqualValues(t, []int64{2}, ids) + }) - ids, err = SearchIssuesByKeyword(context.TODO(), []int64{1}, "first", "") - assert.NoError(t, err) - assert.EqualValues(t, []int64{1}, ids) + t.Run("first", func(t *testing.T) { + ids, _, err := SearchIssues(context.TODO(), &SearchOptions{ + Keyword: "first", + RepoIDs: []int64{1}, + }) + assert.NoError(t, err) + assert.EqualValues(t, []int64{1}, ids) + }) - ids, err = SearchIssuesByKeyword(context.TODO(), []int64{1}, "for", "") - assert.NoError(t, err) - assert.ElementsMatch(t, []int64{1, 2, 3, 5, 11}, ids) + t.Run("for", func(t *testing.T) { + ids, _, err := SearchIssues(context.TODO(), &SearchOptions{ + Keyword: "for", + RepoIDs: []int64{1}, + }) + assert.NoError(t, err) + assert.ElementsMatch(t, []int64{1, 2, 3, 5, 11}, ids) + }) - ids, err = SearchIssuesByKeyword(context.TODO(), []int64{1}, "good", "") - assert.NoError(t, err) - assert.EqualValues(t, []int64{1}, ids) + t.Run("good", func(t *testing.T) { + ids, _, err := SearchIssues(context.TODO(), &SearchOptions{ + Keyword: "good", + RepoIDs: []int64{1}, + }) + assert.NoError(t, err) + assert.EqualValues(t, []int64{1}, ids) + }) } diff --git a/modules/indexer/issues/internal/indexer.go b/modules/indexer/issues/internal/indexer.go index b96517bb80db9..95740bc598d6f 100644 --- a/modules/indexer/issues/internal/indexer.go +++ b/modules/indexer/issues/internal/indexer.go @@ -13,9 +13,9 @@ import ( // Indexer defines an interface to indexer issues contents type Indexer interface { internal.Indexer - Index(ctx context.Context, issue []*IndexerData) error + Index(ctx context.Context, issue ...*IndexerData) error Delete(ctx context.Context, ids ...int64) error - Search(ctx context.Context, kw string, repoIDs []int64, limit, start int, state string) (*SearchResult, error) + Search(ctx context.Context, options *SearchOptions) (*SearchResult, error) } // NewDummyIndexer returns a dummy indexer @@ -29,14 +29,14 @@ type dummyIndexer struct { internal.Indexer } -func (d *dummyIndexer) Index(ctx context.Context, issue []*IndexerData) error { +func (d *dummyIndexer) Index(_ context.Context, _ ...*IndexerData) error { return fmt.Errorf("indexer is not ready") } -func (d *dummyIndexer) Delete(ctx context.Context, ids ...int64) error { +func (d *dummyIndexer) Delete(_ context.Context, _ ...int64) error { return fmt.Errorf("indexer is not ready") } -func (d *dummyIndexer) Search(ctx context.Context, kw string, repoIDs []int64, limit, start int, state string) (*SearchResult, error) { +func (d *dummyIndexer) Search(_ context.Context, _ *SearchOptions) (*SearchResult, error) { return nil, fmt.Errorf("indexer is not ready") } diff --git a/modules/indexer/issues/internal/model.go b/modules/indexer/issues/internal/model.go index 2b52d32302a06..31acd16bd44e0 100644 --- a/modules/indexer/issues/internal/model.go +++ b/modules/indexer/issues/internal/model.go @@ -3,17 +3,45 @@ package internal +import ( + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" +) + // IndexerData data stored in the issue indexer type IndexerData struct { - ID int64 `json:"id"` - RepoID int64 `json:"repo_id"` - State string `json:"state"` // open, closed, all - IssueType string `json:"type"` // issue or pull - Title string `json:"title"` - Content string `json:"content"` - Comments []string `json:"comments"` - IsDelete bool `json:"is_delete"` - IDs []int64 `json:"ids"` + ID int64 `json:"id"` + RepoID int64 `json:"repo_id"` + IsPublic bool `json:"is_public"` // If the repo is public + + // Fields used for keyword searching + Title string `json:"title"` + Content string `json:"content"` + Comments []string `json:"comments"` + + // Fields used for filtering + IsPull bool `json:"is_pull"` + IsClosed bool `json:"is_closed"` + LabelIDs []int64 `json:"label_ids"` + NoLabel bool `json:"no_label"` // True if LabelIDs is empty + MilestoneID int64 `json:"milestone_id"` + ProjectID int64 `json:"project_id"` + ProjectBoardID int64 `json:"project_board_id"` + PosterID int64 `json:"poster_id"` + AssigneeID int64 `json:"assignee_id"` + MentionIDs []int64 `json:"mention_ids"` + ReviewedIDs []int64 `json:"reviewed_ids"` + ReviewRequestedIDs []int64 `json:"review_requested_ids"` + SubscriberIDs []int64 `json:"subscriber_ids"` + UpdatedUnix timeutil.TimeStamp `json:"updated_unix"` + + // Fields used for sorting + // UpdatedUnix is both used for filtering and sorting. + // ID is used for sorting too, to make the sorting stable. + CreatedUnix timeutil.TimeStamp `json:"created_unix"` + DeadlineUnix timeutil.TimeStamp `json:"deadline_unix"` + CommentCount int64 `json:"comment_count"` } // Match represents on search result @@ -27,3 +55,67 @@ type SearchResult struct { Total int64 Hits []Match } + +// SearchOptions represents search options +type SearchOptions struct { + Keyword string // keyword to search + + RepoIDs []int64 // repository IDs which the issues belong to + AllPublic bool // if include all public repositories + + IsPull util.OptionalBool // if the issues is a pull request + IsClosed util.OptionalBool // if the issues is closed + + IncludedLabelIDs []int64 // labels the issues have + ExcludedLabelIDs []int64 // labels the issues don't have + IncludedAnyLabelIDs []int64 // labels the issues have at least one. It will be ignored if IncludedLabelIDs is not empty. It's an uncommon filter, but it has been supported accidentally by issues.IssuesOptions.IncludedLabelNames. + NoLabelOnly bool // if the issues have no label, if true, IncludedLabelIDs and ExcludedLabelIDs, IncludedAnyLabelIDs will be ignored + + MilestoneIDs []int64 // milestones the issues have + + ProjectID *int64 // project the issues belong to + ProjectBoardID *int64 // project board the issues belong to + + PosterID *int64 // poster of the issues + + AssigneeID *int64 // assignee of the issues, zero means no assignee + + MentionID *int64 // mentioned user of the issues + + ReviewedID *int64 // reviewer of the issues + ReviewRequestedID *int64 // requested reviewer of the issues + + SubscriberID *int64 // subscriber of the issues + + UpdatedAfterUnix *int64 + UpdatedBeforeUnix *int64 + + db.Paginator + + SortBy SortBy // sort by field +} + +type SortBy string + +const ( + SortByCreatedDesc SortBy = "-created_unix" + SortByUpdatedDesc SortBy = "-updated_unix" + SortByCommentsDesc SortBy = "-comment_count" + SortByDeadlineDesc SortBy = "-deadline_unix" + SortByCreatedAsc SortBy = "created_unix" + SortByUpdatedAsc SortBy = "updated_unix" + SortByCommentsAsc SortBy = "comment_count" + SortByDeadlineAsc SortBy = "deadline_unix" + // Unsupported sort types which are supported by issues.IssuesOptions.SortType: + // + // - "priorityrepo": + // It's impossible to support it in the indexer. + // It is based on the specified repository in the request, so we cannot add static field to the indexer. + // If we do something like that query the issues in the specified repository first then append other issues, + // it will break the pagination. + // + // - "project-column-sorting": + // Although it's possible to support it by adding project.ProjectIssue.Sorting to the indexer, + // but what if the issue belongs to multiple projects? + // Since it's unsupported to search issues with keyword in project page, we don't need to support it. +) diff --git a/modules/indexer/issues/internal/tests/tests.go b/modules/indexer/issues/internal/tests/tests.go new file mode 100644 index 0000000000000..93d38a0b37996 --- /dev/null +++ b/modules/indexer/issues/internal/tests/tests.go @@ -0,0 +1,804 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// This package contains tests for issues indexer modules. +// All the code in this package is only used for testing. +// Do not put any production code in this package to avoid it being included in the final binary. + +package tests + +import ( + "context" + "fmt" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/indexer/issues/internal" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIndexer(t *testing.T, indexer internal.Indexer) { + _, err := indexer.Init(context.Background()) + require.NoError(t, err) + + require.NoError(t, indexer.Ping(context.Background())) + + var ( + ids []int64 + data = map[int64]*internal.IndexerData{} + ) + { + d := generateDefaultIndexerData() + for _, v := range d { + ids = append(ids, v.ID) + data[v.ID] = v + } + require.NoError(t, indexer.Index(context.Background(), d...)) + require.NoError(t, waitData(indexer, int64(len(data)))) + } + + defer func() { + require.NoError(t, indexer.Delete(context.Background(), ids...)) + }() + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + if len(c.ExtraData) > 0 { + require.NoError(t, indexer.Index(context.Background(), c.ExtraData...)) + for _, v := range c.ExtraData { + data[v.ID] = v + } + require.NoError(t, waitData(indexer, int64(len(data)))) + defer func() { + for _, v := range c.ExtraData { + require.NoError(t, indexer.Delete(context.Background(), v.ID)) + delete(data, v.ID) + } + require.NoError(t, waitData(indexer, int64(len(data)))) + }() + } + + result, err := indexer.Search(context.Background(), c.SearchOptions) + require.NoError(t, err) + + if c.Expected != nil { + c.Expected(t, data, result) + } else { + ids := make([]int64, 0, len(result.Hits)) + for _, hit := range result.Hits { + ids = append(ids, hit.ID) + } + assert.Equal(t, c.ExpectedIDs, ids) + assert.Equal(t, c.ExpectedTotal, result.Total) + } + }) + } +} + +var cases = []*testIndexerCase{ + { + Name: "default", + SearchOptions: &internal.SearchOptions{}, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + }, + }, + { + Name: "empty", + SearchOptions: &internal.SearchOptions{ + Keyword: "f1dfac73-fda6-4a6b-b8a4-2408fcb8ef69", + }, + ExpectedIDs: []int64{}, + ExpectedTotal: 0, + }, + { + Name: "with limit", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + }, + }, + { + Name: "Keyword", + ExtraData: []*internal.IndexerData{ + {ID: 1000, Title: "hi hello world"}, + {ID: 1001, Content: "hi hello world"}, + {ID: 1002, Comments: []string{"hi", "hello world"}}, + }, + SearchOptions: &internal.SearchOptions{ + Keyword: "hello", + }, + ExpectedIDs: []int64{1002, 1001, 1000}, + ExpectedTotal: 3, + }, + { + Name: "RepoIDs", + ExtraData: []*internal.IndexerData{ + {ID: 1001, Title: "hello world", RepoID: 1, IsPublic: false}, + {ID: 1002, Title: "hello world", RepoID: 1, IsPublic: false}, + {ID: 1003, Title: "hello world", RepoID: 2, IsPublic: true}, + {ID: 1004, Title: "hello world", RepoID: 2, IsPublic: true}, + {ID: 1005, Title: "hello world", RepoID: 3, IsPublic: true}, + {ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false}, + {ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false}, + }, + SearchOptions: &internal.SearchOptions{ + Keyword: "hello", + RepoIDs: []int64{1, 4}, + }, + ExpectedIDs: []int64{1006, 1002, 1001}, + ExpectedTotal: 3, + }, + { + Name: "RepoIDs and AllPublic", + ExtraData: []*internal.IndexerData{ + {ID: 1001, Title: "hello world", RepoID: 1, IsPublic: false}, + {ID: 1002, Title: "hello world", RepoID: 1, IsPublic: false}, + {ID: 1003, Title: "hello world", RepoID: 2, IsPublic: true}, + {ID: 1004, Title: "hello world", RepoID: 2, IsPublic: true}, + {ID: 1005, Title: "hello world", RepoID: 3, IsPublic: true}, + {ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false}, + {ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false}, + }, + SearchOptions: &internal.SearchOptions{ + Keyword: "hello", + RepoIDs: []int64{1, 4}, + AllPublic: true, + }, + ExpectedIDs: []int64{1006, 1005, 1004, 1003, 1002, 1001}, + ExpectedTotal: 6, + }, + { + Name: "issue only", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + IsPull: util.OptionalBoolFalse, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.False(t, data[v.ID].IsPull) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return !v.IsPull }), result.Total) + }, + }, + { + Name: "pull only", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + IsPull: util.OptionalBoolTrue, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.True(t, data[v.ID].IsPull) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return v.IsPull }), result.Total) + }, + }, + { + Name: "opened only", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + IsClosed: util.OptionalBoolFalse, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.False(t, data[v.ID].IsClosed) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return !v.IsClosed }), result.Total) + }, + }, + { + Name: "closed only", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + IsClosed: util.OptionalBoolTrue, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.True(t, data[v.ID].IsClosed) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return v.IsClosed }), result.Total) + }, + }, + { + Name: "labels", + ExtraData: []*internal.IndexerData{ + {ID: 1000, Title: "hello a", LabelIDs: []int64{2000, 2001, 2002}}, + {ID: 1001, Title: "hello b", LabelIDs: []int64{2000, 2001}}, + {ID: 1002, Title: "hello c", LabelIDs: []int64{2000, 2001, 2003}}, + {ID: 1003, Title: "hello d", LabelIDs: []int64{2000}}, + {ID: 1004, Title: "hello e", LabelIDs: []int64{}}, + }, + SearchOptions: &internal.SearchOptions{ + Keyword: "hello", + IncludedLabelIDs: []int64{2000, 2001}, + ExcludedLabelIDs: []int64{2003}, + }, + ExpectedIDs: []int64{1001, 1000}, + ExpectedTotal: 2, + }, + { + Name: "include any labels", + ExtraData: []*internal.IndexerData{ + {ID: 1000, Title: "hello a", LabelIDs: []int64{2000, 2001, 2002}}, + {ID: 1001, Title: "hello b", LabelIDs: []int64{2001}}, + {ID: 1002, Title: "hello c", LabelIDs: []int64{2000, 2001, 2003}}, + {ID: 1003, Title: "hello d", LabelIDs: []int64{2002}}, + {ID: 1004, Title: "hello e", LabelIDs: []int64{}}, + }, + SearchOptions: &internal.SearchOptions{ + Keyword: "hello", + IncludedAnyLabelIDs: []int64{2001, 2002}, + ExcludedLabelIDs: []int64{2003}, + }, + ExpectedIDs: []int64{1003, 1001, 1000}, + ExpectedTotal: 3, + }, + { + Name: "MilestoneIDs", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + MilestoneIDs: []int64{1, 2, 6}, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Contains(t, []int64{1, 2, 6}, data[v.ID].MilestoneID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.MilestoneID == 1 || v.MilestoneID == 2 || v.MilestoneID == 6 + }), result.Total) + }, + }, + { + Name: "no MilestoneIDs", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + MilestoneIDs: []int64{0}, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(0), data[v.ID].MilestoneID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.MilestoneID == 0 + }), result.Total) + }, + }, + { + Name: "ProjectID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + ProjectID: func() *int64 { + id := int64(1) + return &id + }(), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(1), data[v.ID].ProjectID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.ProjectID == 1 + }), result.Total) + }, + }, + { + Name: "no ProjectID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + ProjectID: func() *int64 { + id := int64(0) + return &id + }(), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(0), data[v.ID].ProjectID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.ProjectID == 0 + }), result.Total) + }, + }, + { + Name: "ProjectBoardID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + ProjectBoardID: func() *int64 { + id := int64(1) + return &id + }(), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(1), data[v.ID].ProjectBoardID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.ProjectBoardID == 1 + }), result.Total) + }, + }, + { + Name: "no ProjectBoardID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + ProjectBoardID: func() *int64 { + id := int64(0) + return &id + }(), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(0), data[v.ID].ProjectBoardID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.ProjectBoardID == 0 + }), result.Total) + }, + }, + { + Name: "PosterID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + PosterID: func() *int64 { + id := int64(1) + return &id + }(), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(1), data[v.ID].PosterID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.PosterID == 1 + }), result.Total) + }, + }, + { + Name: "AssigneeID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + AssigneeID: func() *int64 { + id := int64(1) + return &id + }(), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(1), data[v.ID].AssigneeID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.AssigneeID == 1 + }), result.Total) + }, + }, + { + Name: "no AssigneeID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + AssigneeID: func() *int64 { + id := int64(0) + return &id + }(), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(0), data[v.ID].AssigneeID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.AssigneeID == 0 + }), result.Total) + }, + }, + { + Name: "MentionID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + MentionID: func() *int64 { + id := int64(1) + return &id + }(), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Contains(t, data[v.ID].MentionIDs, int64(1)) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return util.SliceContains(v.MentionIDs, 1) + }), result.Total) + }, + }, + { + Name: "ReviewedID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + ReviewedID: func() *int64 { + id := int64(1) + return &id + }(), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Contains(t, data[v.ID].ReviewedIDs, int64(1)) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return util.SliceContains(v.ReviewedIDs, 1) + }), result.Total) + }, + }, + { + Name: "ReviewRequestedID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + ReviewRequestedID: func() *int64 { + id := int64(1) + return &id + }(), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Contains(t, data[v.ID].ReviewRequestedIDs, int64(1)) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return util.SliceContains(v.ReviewRequestedIDs, 1) + }), result.Total) + }, + }, + { + Name: "SubscriberID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + SubscriberID: func() *int64 { + id := int64(1) + return &id + }(), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Contains(t, data[v.ID].SubscriberIDs, int64(1)) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return util.SliceContains(v.SubscriberIDs, 1) + }), result.Total) + }, + }, + { + Name: "updated", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + UpdatedAfterUnix: func() *int64 { + var t int64 = 20 + return &t + }(), + UpdatedBeforeUnix: func() *int64 { + var t int64 = 30 + return &t + }(), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.GreaterOrEqual(t, data[v.ID].UpdatedUnix, int64(20)) + assert.LessOrEqual(t, data[v.ID].UpdatedUnix, int64(30)) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return data[v.ID].UpdatedUnix >= 20 && data[v.ID].UpdatedUnix <= 30 + }), result.Total) + }, + }, + { + Name: "SortByCreatedDesc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + ListAll: true, + }, + SortBy: internal.SortByCreatedDesc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.GreaterOrEqual(t, data[v.ID].CreatedUnix, data[result.Hits[i+1].ID].CreatedUnix) + } + } + }, + }, + { + Name: "SortByUpdatedDesc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + ListAll: true, + }, + SortBy: internal.SortByUpdatedDesc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.GreaterOrEqual(t, data[v.ID].UpdatedUnix, data[result.Hits[i+1].ID].UpdatedUnix) + } + } + }, + }, + { + Name: "SortByCommentsDesc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + ListAll: true, + }, + SortBy: internal.SortByCommentsDesc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.GreaterOrEqual(t, data[v.ID].CommentCount, data[result.Hits[i+1].ID].CommentCount) + } + } + }, + }, + { + Name: "SortByDeadlineDesc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + ListAll: true, + }, + SortBy: internal.SortByDeadlineDesc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.GreaterOrEqual(t, data[v.ID].DeadlineUnix, data[result.Hits[i+1].ID].DeadlineUnix) + } + } + }, + }, + { + Name: "SortByCreatedAsc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + ListAll: true, + }, + SortBy: internal.SortByCreatedAsc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.LessOrEqual(t, data[v.ID].CreatedUnix, data[result.Hits[i+1].ID].CreatedUnix) + } + } + }, + }, + { + Name: "SortByUpdatedAsc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + ListAll: true, + }, + SortBy: internal.SortByUpdatedAsc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.LessOrEqual(t, data[v.ID].UpdatedUnix, data[result.Hits[i+1].ID].UpdatedUnix) + } + } + }, + }, + { + Name: "SortByCommentsAsc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + ListAll: true, + }, + SortBy: internal.SortByCommentsAsc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.LessOrEqual(t, data[v.ID].CommentCount, data[result.Hits[i+1].ID].CommentCount) + } + } + }, + }, + { + Name: "SortByDeadlineAsc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + ListAll: true, + }, + SortBy: internal.SortByDeadlineAsc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.LessOrEqual(t, data[v.ID].DeadlineUnix, data[result.Hits[i+1].ID].DeadlineUnix) + } + } + }, + }, +} + +type testIndexerCase struct { + Name string + ExtraData []*internal.IndexerData + + SearchOptions *internal.SearchOptions + + Expected func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) // if nil, use ExpectedIDs, ExpectedTotal + ExpectedIDs []int64 + ExpectedTotal int64 +} + +func generateDefaultIndexerData() []*internal.IndexerData { + var id int64 + var data []*internal.IndexerData + for repoID := int64(1); repoID <= 10; repoID++ { + for issueIndex := int64(1); issueIndex <= 20; issueIndex++ { + id++ + + comments := make([]string, id%4) + for i := range comments { + comments[i] = fmt.Sprintf("comment%d", i) + } + + labelIDs := make([]int64, id%5) + for i := range labelIDs { + labelIDs[i] = int64(i) + 1 // LabelID should not be 0 + } + mentionIDs := make([]int64, id%6) + for i := range mentionIDs { + mentionIDs[i] = int64(i) + 1 // MentionID should not be 0 + } + reviewedIDs := make([]int64, id%7) + for i := range reviewedIDs { + reviewedIDs[i] = int64(i) + 1 // ReviewID should not be 0 + } + reviewRequestedIDs := make([]int64, id%8) + for i := range reviewRequestedIDs { + reviewRequestedIDs[i] = int64(i) + 1 // ReviewRequestedID should not be 0 + } + subscriberIDs := make([]int64, id%9) + for i := range subscriberIDs { + subscriberIDs[i] = int64(i) + 1 // SubscriberID should not be 0 + } + + data = append(data, &internal.IndexerData{ + ID: id, + RepoID: repoID, + IsPublic: repoID%2 == 0, + Title: fmt.Sprintf("issue%d of repo%d", issueIndex, repoID), + Content: fmt.Sprintf("content%d", issueIndex), + Comments: comments, + IsPull: issueIndex%2 == 0, + IsClosed: issueIndex%3 == 0, + LabelIDs: labelIDs, + NoLabel: len(labelIDs) == 0, + MilestoneID: issueIndex % 4, + ProjectID: issueIndex % 5, + ProjectBoardID: issueIndex % 6, + PosterID: id%10 + 1, // PosterID should not be 0 + AssigneeID: issueIndex % 10, + MentionIDs: mentionIDs, + ReviewedIDs: reviewedIDs, + ReviewRequestedIDs: reviewRequestedIDs, + SubscriberIDs: subscriberIDs, + UpdatedUnix: timeutil.TimeStamp(id + issueIndex), + CreatedUnix: timeutil.TimeStamp(id), + DeadlineUnix: timeutil.TimeStamp(id + issueIndex + repoID), + CommentCount: int64(len(comments)), + }) + } + } + + return data +} + +func countIndexerData(data map[int64]*internal.IndexerData, f func(v *internal.IndexerData) bool) int64 { + var count int64 + for _, v := range data { + if f(v) { + count++ + } + } + return count +} + +// waitData waits for the indexer to index all data. +// Some engines like Elasticsearch index data asynchronously, so we need to wait for a while. +func waitData(indexer internal.Indexer, total int64) error { + var actual int64 + for i := 0; i < 100; i++ { + result, err := indexer.Search(context.Background(), &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 0, + }, + }) + if err != nil { + return err + } + actual = result.Total + if actual == total { + return nil + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf("waitData: expected %d, actual %d", total, actual) +} diff --git a/modules/indexer/issues/meilisearch/meilisearch.go b/modules/indexer/issues/meilisearch/meilisearch.go index 2ea06b576c0b8..335395f2f6714 100644 --- a/modules/indexer/issues/meilisearch/meilisearch.go +++ b/modules/indexer/issues/meilisearch/meilisearch.go @@ -16,7 +16,10 @@ import ( ) const ( - issueIndexerLatestVersion = 1 + issueIndexerLatestVersion = 2 + + // TODO: make this configurable if necessary + maxTotalHits = 10000 ) var _ internal.Indexer = &Indexer{} @@ -29,7 +32,53 @@ type Indexer struct { // NewIndexer creates a new meilisearch indexer func NewIndexer(url, apiKey, indexerName string) *Indexer { - inner := inner_meilisearch.NewIndexer(url, apiKey, indexerName, issueIndexerLatestVersion) + settings := &meilisearch.Settings{ + // The default ranking rules of meilisearch are: ["words", "typo", "proximity", "attribute", "sort", "exactness"] + // So even if we specify the sort order, it could not be respected because the priority of "sort" is so low. + // So we need to specify the ranking rules to make sure the sort order is respected. + // See https://www.meilisearch.com/docs/learn/core_concepts/relevancy + RankingRules: []string{"sort", // make sure "sort" has the highest priority + "words", "typo", "proximity", "attribute", "exactness"}, + + SearchableAttributes: []string{ + "title", + "content", + "comments", + }, + DisplayedAttributes: []string{ + "id", + }, + FilterableAttributes: []string{ + "repo_id", + "is_public", + "is_pull", + "is_closed", + "label_ids", + "no_label", + "milestone_id", + "project_id", + "project_board_id", + "poster_id", + "assignee_id", + "mention_ids", + "reviewed_ids", + "review_requested_ids", + "subscriber_ids", + "updated_unix", + }, + SortableAttributes: []string{ + "updated_unix", + "created_unix", + "deadline_unix", + "comment_count", + "id", + }, + Pagination: &meilisearch.Pagination{ + MaxTotalHits: maxTotalHits, + }, + } + + inner := inner_meilisearch.NewIndexer(url, apiKey, indexerName, issueIndexerLatestVersion, settings) indexer := &Indexer{ inner: inner, Indexer: inner, @@ -38,7 +87,7 @@ func NewIndexer(url, apiKey, indexerName string) *Indexer { } // Index will save the index data -func (b *Indexer) Index(_ context.Context, issues []*internal.IndexerData) error { +func (b *Indexer) Index(_ context.Context, issues ...*internal.IndexerData) error { if len(issues) == 0 { return nil } @@ -70,23 +119,102 @@ func (b *Indexer) Delete(_ context.Context, ids ...int64) error { // Search searches for issues by given conditions. // Returns the matching issue IDs -func (b *Indexer) Search(ctx context.Context, keyword string, repoIDs []int64, limit, start int, state string) (*internal.SearchResult, error) { - repoFilters := make([]string, 0, len(repoIDs)) - for _, repoID := range repoIDs { - repoFilters = append(repoFilters, "repo_id = "+strconv.FormatInt(repoID, 10)) - } - filter := strings.Join(repoFilters, " OR ") - if state == "open" || state == "closed" { - if filter != "" { - filter = "(" + filter + ") AND state = " + state - } else { - filter = "state = " + state +func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) { + query := inner_meilisearch.FilterAnd{} + + if len(options.RepoIDs) > 0 { + q := &inner_meilisearch.FilterOr{} + q.Or(inner_meilisearch.NewFilterIn("repo_id", options.RepoIDs...)) + if options.AllPublic { + q.Or(inner_meilisearch.NewFilterEq("is_public", true)) + } + query.And(q) + } + + if !options.IsPull.IsNone() { + query.And(inner_meilisearch.NewFilterEq("is_pull", options.IsPull.IsTrue())) + } + if !options.IsClosed.IsNone() { + query.And(inner_meilisearch.NewFilterEq("is_closed", options.IsClosed.IsTrue())) + } + + if options.NoLabelOnly { + query.And(inner_meilisearch.NewFilterEq("no_label", true)) + } else { + if len(options.IncludedLabelIDs) > 0 { + q := &inner_meilisearch.FilterAnd{} + for _, labelID := range options.IncludedLabelIDs { + q.And(inner_meilisearch.NewFilterEq("label_ids", labelID)) + } + query.And(q) + } else if len(options.IncludedAnyLabelIDs) > 0 { + query.And(inner_meilisearch.NewFilterIn("label_ids", options.IncludedAnyLabelIDs...)) + } + if len(options.ExcludedLabelIDs) > 0 { + q := &inner_meilisearch.FilterAnd{} + for _, labelID := range options.ExcludedLabelIDs { + q.And(inner_meilisearch.NewFilterNot(inner_meilisearch.NewFilterEq("label_ids", labelID))) + } + query.And(q) } } - searchRes, err := b.inner.Client.Index(b.inner.VersionedIndexName()).Search(keyword, &meilisearch.SearchRequest{ - Filter: filter, + + if len(options.MilestoneIDs) > 0 { + query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...)) + } + + if options.ProjectID != nil { + query.And(inner_meilisearch.NewFilterEq("project_id", *options.ProjectID)) + } + if options.ProjectBoardID != nil { + query.And(inner_meilisearch.NewFilterEq("project_board_id", *options.ProjectBoardID)) + } + + if options.PosterID != nil { + query.And(inner_meilisearch.NewFilterEq("poster_id", *options.PosterID)) + } + + if options.AssigneeID != nil { + query.And(inner_meilisearch.NewFilterEq("assignee_id", *options.AssigneeID)) + } + + if options.MentionID != nil { + query.And(inner_meilisearch.NewFilterEq("mention_ids", *options.MentionID)) + } + + if options.ReviewedID != nil { + query.And(inner_meilisearch.NewFilterEq("reviewed_ids", *options.ReviewedID)) + } + if options.ReviewRequestedID != nil { + query.And(inner_meilisearch.NewFilterEq("review_requested_ids", *options.ReviewRequestedID)) + } + + if options.SubscriberID != nil { + query.And(inner_meilisearch.NewFilterEq("subscriber_ids", *options.SubscriberID)) + } + + if options.UpdatedAfterUnix != nil { + query.And(inner_meilisearch.NewFilterGte("updated_unix", *options.UpdatedAfterUnix)) + } + if options.UpdatedBeforeUnix != nil { + query.And(inner_meilisearch.NewFilterLte("updated_unix", *options.UpdatedBeforeUnix)) + } + + if options.SortBy == "" { + options.SortBy = internal.SortByCreatedAsc + } + sortBy := []string{ + parseSortBy(options.SortBy), + "id:desc", + } + + skip, limit := indexer_internal.ParsePaginator(options.Paginator, maxTotalHits) + + searchRes, err := b.inner.Client.Index(b.inner.VersionedIndexName()).Search(options.Keyword, &meilisearch.SearchRequest{ + Filter: query.Statement(), Limit: int64(limit), - Offset: int64(start), + Offset: int64(skip), + Sort: sortBy, }) if err != nil { return nil, err @@ -98,8 +226,17 @@ func (b *Indexer) Search(ctx context.Context, keyword string, repoIDs []int64, l ID: int64(hit.(map[string]any)["id"].(float64)), }) } + return &internal.SearchResult{ - Total: searchRes.TotalHits, + Total: searchRes.EstimatedTotalHits, Hits: hits, }, nil } + +func parseSortBy(sortBy internal.SortBy) string { + field := strings.TrimPrefix(string(sortBy), "-") + if strings.HasPrefix(string(sortBy), "-") { + return field + ":desc" + } + return field + ":asc" +} diff --git a/modules/indexer/issues/meilisearch/meilisearch_test.go b/modules/indexer/issues/meilisearch/meilisearch_test.go new file mode 100644 index 0000000000000..3d7237268e1bd --- /dev/null +++ b/modules/indexer/issues/meilisearch/meilisearch_test.go @@ -0,0 +1,50 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package meilisearch + +import ( + "fmt" + "net/http" + "os" + "testing" + "time" + + "code.gitea.io/gitea/modules/indexer/issues/internal/tests" +) + +func TestMeilisearchIndexer(t *testing.T) { + // The meilisearch instance started by pull-db-tests.yml > test-unit > services > meilisearch + url := "http://meilisearch:7700" + key := "" // auth has been disabled in test environment + + if os.Getenv("CI") == "" { + // Make it possible to run tests against a local meilisearch instance + url = os.Getenv("TEST_MEILISEARCH_URL") + if url == "" { + t.Skip("TEST_MEILISEARCH_URL not set and not running in CI") + return + } + key = os.Getenv("TEST_MEILISEARCH_KEY") + } + + ok := false + for i := 0; i < 60; i++ { + resp, err := http.Get(url) + if err == nil && resp.StatusCode == http.StatusOK { + ok = true + break + } + t.Logf("Waiting for meilisearch to be up: %v", err) + time.Sleep(time.Second) + } + if !ok { + t.Fatalf("Failed to wait for meilisearch to be up") + return + } + + indexer := NewIndexer(url, key, fmt.Sprintf("test_meilisearch_indexer_%d", time.Now().Unix())) + defer indexer.Close() + + tests.TestIndexer(t, indexer) +} diff --git a/modules/indexer/issues/util.go b/modules/indexer/issues/util.go new file mode 100644 index 0000000000000..2dec3b71db0b4 --- /dev/null +++ b/modules/indexer/issues/util.go @@ -0,0 +1,173 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "context" + "errors" + "fmt" + + "code.gitea.io/gitea/models/db" + issue_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/indexer/issues/internal" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/queue" +) + +// getIssueIndexerData returns the indexer data of an issue and a bool value indicating whether the issue exists. +func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerData, bool, error) { + issue, err := issue_model.GetIssueByID(ctx, issueID) + if err != nil { + if issue_model.IsErrIssueNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + + // FIXME: what if users want to search for a review comment of a pull request? + // The comment type is CommentTypeCode or CommentTypeReview. + // But LoadDiscussComments only loads CommentTypeComment. + if err := issue.LoadDiscussComments(ctx); err != nil { + return nil, false, err + } + + comments := make([]string, 0, len(issue.Comments)) + for _, comment := range issue.Comments { + if comment.Content != "" { + // what ever the comment type is, index the content if it is not empty. + comments = append(comments, comment.Content) + } + } + + if err := issue.LoadAttributes(ctx); err != nil { + return nil, false, err + } + + labels := make([]int64, 0, len(issue.Labels)) + for _, label := range issue.Labels { + labels = append(labels, label.ID) + } + + mentionIDs, err := issue_model.GetIssueMentionIDs(ctx, issueID) + if err != nil { + return nil, false, err + } + + var ( + reviewedIDs []int64 + reviewRequestedIDs []int64 + ) + { + reviews, err := issue_model.FindReviews(ctx, issue_model.FindReviewOptions{ + ListOptions: db.ListOptions{ + ListAll: true, + }, + IssueID: issueID, + OfficialOnly: false, + }) + if err != nil { + return nil, false, err + } + + reviewedIDsSet := make(container.Set[int64], len(reviews)) + reviewRequestedIDsSet := make(container.Set[int64], len(reviews)) + for _, review := range reviews { + if review.Type == issue_model.ReviewTypeRequest { + reviewRequestedIDsSet.Add(review.ReviewerID) + } else { + reviewedIDsSet.Add(review.ReviewerID) + } + } + reviewedIDs = reviewedIDsSet.Values() + reviewRequestedIDs = reviewRequestedIDsSet.Values() + } + + subscriberIDs, err := issue_model.GetIssueWatchersIDs(ctx, issue.ID, true) + if err != nil { + return nil, false, err + } + + var projectID int64 + if issue.Project != nil { + projectID = issue.Project.ID + } + + return &internal.IndexerData{ + ID: issue.ID, + RepoID: issue.RepoID, + IsPublic: !issue.Repo.IsPrivate, + Title: issue.Title, + Content: issue.Content, + Comments: comments, + IsPull: issue.IsPull, + IsClosed: issue.IsClosed, + LabelIDs: labels, + NoLabel: len(labels) == 0, + MilestoneID: issue.MilestoneID, + ProjectID: projectID, + ProjectBoardID: issue.ProjectBoardID(), + PosterID: issue.PosterID, + AssigneeID: issue.AssigneeID, + MentionIDs: mentionIDs, + ReviewedIDs: reviewedIDs, + ReviewRequestedIDs: reviewRequestedIDs, + SubscriberIDs: subscriberIDs, + UpdatedUnix: issue.UpdatedUnix, + CreatedUnix: issue.CreatedUnix, + DeadlineUnix: issue.DeadlineUnix, + CommentCount: int64(len(issue.Comments)), + }, true, nil +} + +func updateRepoIndexer(ctx context.Context, repoID int64) error { + ids, err := issue_model.GetIssueIDsByRepoID(ctx, repoID) + if err != nil { + return fmt.Errorf("issue_model.GetIssueIDsByRepoID: %w", err) + } + for _, id := range ids { + if err := updateIssueIndexer(id); err != nil { + return err + } + } + return nil +} + +func updateIssueIndexer(issueID int64) error { + return pushIssueIndexerQueue(&IndexerMetadata{ID: issueID}) +} + +func deleteRepoIssueIndexer(ctx context.Context, repoID int64) error { + var ids []int64 + ids, err := issue_model.GetIssueIDsByRepoID(ctx, repoID) + if err != nil { + return fmt.Errorf("issue_model.GetIssueIDsByRepoID: %w", err) + } + + if len(ids) == 0 { + return nil + } + return pushIssueIndexerQueue(&IndexerMetadata{ + IDs: ids, + IsDelete: true, + }) +} + +func pushIssueIndexerQueue(data *IndexerMetadata) error { + if issueIndexerQueue == nil { + // Some unit tests will trigger indexing, but the queue is not initialized. + // It's OK to ignore it, but log a warning message in case it's not a unit test. + log.Warn("Trying to push %+v to issue indexer queue, but the queue is not initialized, it's OK if it's a unit test", data) + return nil + } + + err := issueIndexerQueue.Push(data) + if errors.Is(err, queue.ErrAlreadyInQueue) { + return nil + } + if errors.Is(err, context.DeadlineExceeded) { + log.Warn("It seems that issue indexer is slow and the queue is full. Please check the issue indexer or increase the queue size.") + } + return err +} diff --git a/modules/notification/indexer/indexer.go b/modules/notification/indexer/indexer.go index bb652e39426be..96da23e58e3e5 100644 --- a/modules/notification/indexer/indexer.go +++ b/modules/notification/indexer/indexer.go @@ -46,40 +46,22 @@ func (r *indexerNotifier) NotifyCreateIssueComment(ctx context.Context, doer *us issue.Comments = append(issue.Comments, comment) } - issue_indexer.UpdateIssueIndexer(issue) + issue_indexer.UpdateIssueIndexer(issue.ID) } } func (r *indexerNotifier) NotifyNewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { - issue_indexer.UpdateIssueIndexer(issue) + issue_indexer.UpdateIssueIndexer(issue.ID) } func (r *indexerNotifier) NotifyNewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) { - issue_indexer.UpdateIssueIndexer(pr.Issue) + issue_indexer.UpdateIssueIndexer(pr.Issue.ID) } func (r *indexerNotifier) NotifyUpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) { - if c.Type == issues_model.CommentTypeComment { - var found bool - if c.Issue.Comments != nil { - for i := 0; i < len(c.Issue.Comments); i++ { - if c.Issue.Comments[i].ID == c.ID { - c.Issue.Comments[i] = c - found = true - break - } - } - } - - if !found { - if err := c.Issue.LoadDiscussComments(ctx); err != nil { - log.Error("LoadDiscussComments failed: %v", err) - return - } - } - - issue_indexer.UpdateIssueIndexer(c.Issue) - } + // Whatever the comment type is, just update the issue indexer. + // So that the issue indexer will be updated when Status/Assignee/Label and so on changed. + issue_indexer.UpdateIssueIndexer(c.Issue.ID) } func (r *indexerNotifier) NotifyDeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) { @@ -107,19 +89,19 @@ func (r *indexerNotifier) NotifyDeleteComment(ctx context.Context, doer *user_mo } } // reload comments to delete the old comment - issue_indexer.UpdateIssueIndexer(comment.Issue) + issue_indexer.UpdateIssueIndexer(comment.Issue.ID) } } func (r *indexerNotifier) NotifyDeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) { - issue_indexer.DeleteRepoIssueIndexer(ctx, repo) + issue_indexer.DeleteRepoIssueIndexer(ctx, repo.ID) if setting.Indexer.RepoIndexerEnabled { code_indexer.UpdateRepoIndexer(repo) } } func (r *indexerNotifier) NotifyMigrateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { - issue_indexer.UpdateRepoIndexer(ctx, repo) + issue_indexer.UpdateRepoIndexer(ctx, repo.ID) if setting.Indexer.RepoIndexerEnabled && !repo.IsEmpty { code_indexer.UpdateRepoIndexer(repo) } @@ -155,13 +137,13 @@ func (r *indexerNotifier) NotifySyncPushCommits(ctx context.Context, pusher *use } func (r *indexerNotifier) NotifyIssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) { - issue_indexer.UpdateIssueIndexer(issue) + issue_indexer.UpdateIssueIndexer(issue.ID) } func (r *indexerNotifier) NotifyIssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) { - issue_indexer.UpdateIssueIndexer(issue) + issue_indexer.UpdateIssueIndexer(issue.ID) } func (r *indexerNotifier) NotifyIssueChangeRef(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldRef string) { - issue_indexer.UpdateIssueIndexer(issue) + issue_indexer.UpdateIssueIndexer(issue.ID) } diff --git a/modules/packages/conda/metadata.go b/modules/packages/conda/metadata.go index 02dbf313ba2d8..5eb72b8e38455 100644 --- a/modules/packages/conda/metadata.go +++ b/modules/packages/conda/metadata.go @@ -27,7 +27,7 @@ const ( PropertyName = "conda.name" PropertyChannel = "conda.channel" PropertySubdir = "conda.subdir" - PropertyMetadata = "conda.metdata" + PropertyMetadata = "conda.metadata" ) // Package represents a Conda package diff --git a/modules/packages/rpm/metadata.go b/modules/packages/rpm/metadata.go index 607ea42ea0ab2..f019a8dde1aaa 100644 --- a/modules/packages/rpm/metadata.go +++ b/modules/packages/rpm/metadata.go @@ -15,7 +15,7 @@ import ( ) const ( - PropertyMetadata = "rpm.metdata" + PropertyMetadata = "rpm.metadata" SettingKeyPrivate = "rpm.key.private" SettingKeyPublic = "rpm.key.public" diff --git a/modules/repository/create.go b/modules/repository/create.go index e8a1b8ba2bf81..10a1e872df989 100644 --- a/modules/repository/create.go +++ b/modules/repository/create.go @@ -23,6 +23,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/git" + issue_indexer "code.gitea.io/gitea/modules/indexer/issues" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -418,6 +419,10 @@ func UpdateRepository(ctx context.Context, repo *repo_model.Repository, visibili return fmt.Errorf("updateRepository[%d]: %w", forkRepos[i].ID, err) } } + + // If visibility is changed, we need to update the issue indexer. + // Since the data in the issue indexer have field to indicate if the repo is public or not. + issue_indexer.UpdateRepoIndexer(ctx, repo.ID) } return nil diff --git a/modules/setting/service.go b/modules/setting/service.go index 03225f566bae0..595ea6528f886 100644 --- a/modules/setting/service.go +++ b/modules/setting/service.go @@ -73,6 +73,7 @@ var Service = struct { AllowCrossRepositoryDependencies bool DefaultAllowOnlyContributorsToTrackTime bool NoReplyAddress string + UserLocationMapURL string EnableUserHeatmap bool AutoWatchNewRepos bool AutoWatchOnChanges bool @@ -185,18 +186,37 @@ func loadServiceFrom(rootCfg ConfigProvider) { Service.AllowCrossRepositoryDependencies = sec.Key("ALLOW_CROSS_REPOSITORY_DEPENDENCIES").MustBool(true) Service.DefaultAllowOnlyContributorsToTrackTime = sec.Key("DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME").MustBool(true) Service.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply." + Domain) + Service.UserLocationMapURL = sec.Key("USER_LOCATION_MAP_URL").String() Service.EnableUserHeatmap = sec.Key("ENABLE_USER_HEATMAP").MustBool(true) Service.AutoWatchNewRepos = sec.Key("AUTO_WATCH_NEW_REPOS").MustBool(true) Service.AutoWatchOnChanges = sec.Key("AUTO_WATCH_ON_CHANGES").MustBool(false) - Service.DefaultUserVisibility = sec.Key("DEFAULT_USER_VISIBILITY").In("public", structs.ExtractKeysFromMapString(structs.VisibilityModes)) - Service.DefaultUserVisibilityMode = structs.VisibilityModes[Service.DefaultUserVisibility] - Service.AllowedUserVisibilityModes = sec.Key("ALLOWED_USER_VISIBILITY_MODES").Strings(",") - if len(Service.AllowedUserVisibilityModes) != 0 { + modes := sec.Key("ALLOWED_USER_VISIBILITY_MODES").Strings(",") + if len(modes) != 0 { + Service.AllowedUserVisibilityModes = []string{} Service.AllowedUserVisibilityModesSlice = []bool{false, false, false} - for _, sMode := range Service.AllowedUserVisibilityModes { - Service.AllowedUserVisibilityModesSlice[structs.VisibilityModes[sMode]] = true + for _, sMode := range modes { + if tp, ok := structs.VisibilityModes[sMode]; ok { // remove unsupported modes + Service.AllowedUserVisibilityModes = append(Service.AllowedUserVisibilityModes, sMode) + Service.AllowedUserVisibilityModesSlice[tp] = true + } else { + log.Warn("ALLOWED_USER_VISIBILITY_MODES %s is unsupported", sMode) + } } } + + if len(Service.AllowedUserVisibilityModes) == 0 { + Service.AllowedUserVisibilityModes = []string{"public", "limited", "private"} + Service.AllowedUserVisibilityModesSlice = []bool{true, true, true} + } + + Service.DefaultUserVisibility = sec.Key("DEFAULT_USER_VISIBILITY").String() + if Service.DefaultUserVisibility == "" { + Service.DefaultUserVisibility = Service.AllowedUserVisibilityModes[0] + } else if !Service.AllowedUserVisibilityModesSlice[structs.VisibilityModes[Service.DefaultUserVisibility]] { + log.Warn("DEFAULT_USER_VISIBILITY %s is wrong or not in ALLOWED_USER_VISIBILITY_MODES, using first allowed", Service.DefaultUserVisibility) + Service.DefaultUserVisibility = Service.AllowedUserVisibilityModes[0] + } + Service.DefaultUserVisibilityMode = structs.VisibilityModes[Service.DefaultUserVisibility] Service.DefaultOrgVisibility = sec.Key("DEFAULT_ORG_VISIBILITY").In("public", structs.ExtractKeysFromMapString(structs.VisibilityModes)) Service.DefaultOrgVisibilityMode = structs.VisibilityModes[Service.DefaultOrgVisibility] Service.DefaultOrgMemberVisible = sec.Key("DEFAULT_ORG_MEMBER_VISIBLE").MustBool() diff --git a/modules/setting/service_test.go b/modules/setting/service_test.go index 656e759f423ec..1647bcec160a1 100644 --- a/modules/setting/service_test.go +++ b/modules/setting/service_test.go @@ -6,6 +6,8 @@ package setting import ( "testing" + "code.gitea.io/gitea/modules/structs" + "github.com/gobwas/glob" "github.com/stretchr/testify/assert" ) @@ -44,3 +46,87 @@ EMAIL_DOMAIN_BLOCKLIST = d3, *.b assert.True(t, match(Service.EmailDomainBlockList, "foo.b")) assert.False(t, match(Service.EmailDomainBlockList, "d1")) } + +func TestLoadServiceVisibilityModes(t *testing.T) { + oldService := Service + defer func() { + Service = oldService + }() + + kases := map[string]func(){ + ` +[service] +DEFAULT_USER_VISIBILITY = public +ALLOWED_USER_VISIBILITY_MODES = public,limited,private +`: func() { + assert.Equal(t, "public", Service.DefaultUserVisibility) + assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode) + assert.Equal(t, []string{"public", "limited", "private"}, Service.AllowedUserVisibilityModes) + }, + ` + [service] + DEFAULT_USER_VISIBILITY = public + `: func() { + assert.Equal(t, "public", Service.DefaultUserVisibility) + assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode) + assert.Equal(t, []string{"public", "limited", "private"}, Service.AllowedUserVisibilityModes) + }, + ` + [service] + DEFAULT_USER_VISIBILITY = limited + `: func() { + assert.Equal(t, "limited", Service.DefaultUserVisibility) + assert.Equal(t, structs.VisibleTypeLimited, Service.DefaultUserVisibilityMode) + assert.Equal(t, []string{"public", "limited", "private"}, Service.AllowedUserVisibilityModes) + }, + ` +[service] +ALLOWED_USER_VISIBILITY_MODES = public,limited,private +`: func() { + assert.Equal(t, "public", Service.DefaultUserVisibility) + assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode) + assert.Equal(t, []string{"public", "limited", "private"}, Service.AllowedUserVisibilityModes) + }, + ` +[service] +DEFAULT_USER_VISIBILITY = public +ALLOWED_USER_VISIBILITY_MODES = limited,private +`: func() { + assert.Equal(t, "limited", Service.DefaultUserVisibility) + assert.Equal(t, structs.VisibleTypeLimited, Service.DefaultUserVisibilityMode) + assert.Equal(t, []string{"limited", "private"}, Service.AllowedUserVisibilityModes) + }, + ` +[service] +DEFAULT_USER_VISIBILITY = my_type +ALLOWED_USER_VISIBILITY_MODES = limited,private +`: func() { + assert.Equal(t, "limited", Service.DefaultUserVisibility) + assert.Equal(t, structs.VisibleTypeLimited, Service.DefaultUserVisibilityMode) + assert.Equal(t, []string{"limited", "private"}, Service.AllowedUserVisibilityModes) + }, + ` +[service] +DEFAULT_USER_VISIBILITY = public +ALLOWED_USER_VISIBILITY_MODES = public, limit, privated +`: func() { + assert.Equal(t, "public", Service.DefaultUserVisibility) + assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode) + assert.Equal(t, []string{"public"}, Service.AllowedUserVisibilityModes) + }, + } + + for kase, fun := range kases { + t.Run(kase, func(t *testing.T) { + cfg, err := NewConfigProviderFromData(kase) + assert.NoError(t, err) + loadServiceFrom(cfg) + fun() + // reset + Service.AllowedUserVisibilityModesSlice = []bool{true, true, true} + Service.AllowedUserVisibilityModes = []string{} + Service.DefaultUserVisibility = "" + Service.DefaultUserVisibilityMode = structs.VisibleTypePublic + }) + } +} diff --git a/options/license/Autoconf-exception-generic-3.0 b/options/license/Autoconf-exception-generic-3.0 new file mode 100644 index 0000000000000..2d3036772c64b --- /dev/null +++ b/options/license/Autoconf-exception-generic-3.0 @@ -0,0 +1,6 @@ +As a special exception to the GNU General Public License, if you +distribute this file as part of a program that contains a +configuration script generated by Autoconf, you may include it under +the same distribution terms that you use for the rest of that +program. This Exception is an additional permission under section 7 +of the GNU General Public License, version 3 ("GPLv3"). diff --git a/options/license/SL b/options/license/SL new file mode 100644 index 0000000000000..cc3857d2247ae --- /dev/null +++ b/options/license/SL @@ -0,0 +1,4 @@ +Everyone is permitted to do anything on this program including copying, +modifying, and improving, unless you try to pretend that you wrote it. +i.e., the above copyright notice has to appear in all copies. +THE AUTHOR DISCLAIMS ANY RESPONSIBILITY WITH REGARD TO THIS SOFTWARE. diff --git a/options/license/Zeeff b/options/license/Zeeff new file mode 100644 index 0000000000000..408efb2ffda89 --- /dev/null +++ b/options/license/Zeeff @@ -0,0 +1,3 @@ +Copyright 1988 Jon Zeeff (zeeff@b-tech.ann-arbor.mi.us) +You can use this code in any manner, as long as you leave my +name on it and don't hold me responsible for any problems with it. diff --git a/options/license/check-cvs b/options/license/check-cvs new file mode 100644 index 0000000000000..85fee4791ebe5 --- /dev/null +++ b/options/license/check-cvs @@ -0,0 +1,2 @@ +Permission is granted to copy and/or distribute this file, with or +without modifications, provided this notice is preserved. diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index dc88c422b5ea1..3256d2ba911ca 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -601,6 +601,7 @@ user_bio = Biography disabled_public_activity = This user has disabled the public visibility of the activity. email_visibility.limited = Your email address is visible to all authenticated users email_visibility.private = Your email address is only visible to you and administrators +show_on_map = Show this place on a map form.name_reserved = The username "%s" is reserved. form.name_pattern_not_allowed = The pattern "%s" is not allowed in a username. @@ -627,6 +628,7 @@ webauthn = Security Keys public_profile = Public Profile biography_placeholder = Tell us a little bit about yourself +location_placeholder = Share your approximate location with others profile_desc = Your email address will be used for notifications and other operations. password_username_disabled = Non-local users are not allowed to change their username. Please contact your site administrator for more details. full_name = Full Name @@ -1662,6 +1664,13 @@ pulls.switch_comparison_type = Switch comparison type pulls.switch_head_and_base = Switch head and base pulls.filter_branch = Filter branch pulls.no_results = No results found. +pulls.show_all_commits = Show all commits +pulls.show_changes_since_your_last_review = Show changes since your last review +pulls.showing_only_single_commit = Showing only changes of commit %[1]s +pulls.showing_specified_commit_range = Showing only changes between %[1]s..%[2]s +pulls.select_commit_hold_shift_for_range = Select commit. Hold shift + click to select a range +pulls.review_only_possible_for_full_diff = Review is only possible when viewing the full diff +pulls.filter_changes_by_commit = Filter by commit pulls.nothing_to_compare = These branches are equal. There is no need to create a pull request. pulls.nothing_to_compare_and_allow_empty_pr = These branches are equal. This PR will be empty. pulls.has_pull_request = `A pull request between these branches already exists: %[2]s#%[3]d` diff --git a/package-lock.json b/package-lock.json index 4e2212b2e220f..170e0b89bfcd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,24 +14,24 @@ "@github/relative-time-element": "4.3.0", "@github/text-expander-element": "2.5.0", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", - "@primer/octicons": "19.4.0", + "@primer/octicons": "19.5.0", "@webcomponents/custom-elements": "1.6.0", "add-asset-webpack-plugin": "2.0.1", "ansi_up": "5.2.1", "asciinema-player": "3.5.0", - "clippie": "4.0.1", + "clippie": "4.0.6", "css-loader": "6.8.1", "dropzone": "6.0.0-beta.2", "easymde": "2.18.0", "esbuild-loader": "3.0.1", "escape-goat": "4.0.0", - "fast-glob": "3.3.0", + "fast-glob": "3.3.1", "jquery": "3.7.0", "jquery.are-you-sure": "1.9.0", "katex": "0.16.8", "license-checker-webpack-plugin": "0.2.1", "lightningcss-loader": "2.1.0", - "mermaid": "10.2.4", + "mermaid": "10.3.0", "mini-css-extract-plugin": "2.7.6", "minimatch": "9.0.3", "monaco-editor": "0.40.0", @@ -39,7 +39,7 @@ "pdfobject": "2.2.12", "pretty-ms": "8.0.0", "sortablejs": "1.15.0", - "swagger-ui-dist": "5.1.3", + "swagger-ui-dist": "5.2.0", "throttle-debounce": "5.0.0", "tinycolor2": "1.6.0", "tippy.js": "6.3.7", @@ -56,20 +56,20 @@ }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "3.2.1", - "@playwright/test": "1.36.1", - "@stoplight/spectral-cli": "6.8.0", + "@playwright/test": "1.36.2", + "@stoplight/spectral-cli": "6.10.0", "@vitejs/plugin-vue": "4.2.3", - "eslint": "8.45.0", + "eslint": "8.46.0", "eslint-plugin-array-func": "3.1.8", "eslint-plugin-custom-elements": "0.0.8", - "eslint-plugin-import": "2.27.5", + "eslint-plugin-import": "2.28.0", "eslint-plugin-jquery": "1.5.1", "eslint-plugin-no-jquery": "2.7.0", "eslint-plugin-no-use-extend-native": "0.5.0", "eslint-plugin-regexp": "1.15.0", "eslint-plugin-sonarjs": "0.19.0", - "eslint-plugin-unicorn": "48.0.0", - "eslint-plugin-vue": "9.15.1", + "eslint-plugin-unicorn": "48.0.1", + "eslint-plugin-vue": "9.16.0", "eslint-plugin-wc": "1.5.0", "jsdom": "22.1.0", "markdownlint-cli": "0.35.0", @@ -80,7 +80,7 @@ "stylelint-stylistic": "0.4.3", "svgo": "3.0.2", "updates": "14.3.4", - "vite-string-plugin": "1.1.1", + "vite-string-plugin": "1.1.2", "vitest": "0.33.0" }, "engines": { @@ -828,18 +828,18 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", - "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.6.2.tgz", + "integrity": "sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/eslintrc": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.0.tgz", - "integrity": "sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.1.tgz", + "integrity": "sha512-9t7ZA7NGGK8ckelF0PQCfcxIUzs1Md5rrO6U/c+FIQNanea5UZC0wqKXH4vHBccmu4ZJgZ2idtPeW7+Q2npOEA==", "dev": true, "dependencies": { "ajv": "^6.12.4", @@ -904,9 +904,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.44.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.44.0.tgz", - "integrity": "sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==", + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.46.0.tgz", + "integrity": "sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -1237,13 +1237,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.36.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.36.1.tgz", - "integrity": "sha512-YK7yGWK0N3C2QInPU6iaf/L3N95dlGdbsezLya4n0ZCh3IL7VgPGxC6Gnznh9ApWdOmkJeleT2kMTcWPRZvzqg==", + "version": "1.36.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.36.2.tgz", + "integrity": "sha512-2rVZeyPRjxfPH6J0oGJqE8YxiM1IBRyM8hyrXYK7eSiAqmbNhxwcLa7dZ7fy9Kj26V7FYia5fh9XJRq4Dqme+g==", "dev": true, "dependencies": { "@types/node": "*", - "playwright-core": "1.36.1" + "playwright-core": "1.36.2" }, "bin": { "playwright": "cli.js" @@ -1265,9 +1265,9 @@ } }, "node_modules/@primer/octicons": { - "version": "19.4.0", - "resolved": "https://registry.npmjs.org/@primer/octicons/-/octicons-19.4.0.tgz", - "integrity": "sha512-92eXALm3ucZkzqpJmJbC+fR9ldiuNd4W4s2MZQNQIBahpg14emJ+I9fdHqCummFlfgyohLzXn++7rz0NlkqAJA==", + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@primer/octicons/-/octicons-19.5.0.tgz", + "integrity": "sha512-b3IBp3EmzLc/YMw3xdqy7Lg8CgFObYaWegPntoKO1bZLZ4sAG5PRMPp36rj4TF1sDHbNufhGMvdCCM5VdS3mPQ==", "dependencies": { "object-assign": "^4.1.1" } @@ -1339,9 +1339,9 @@ } }, "node_modules/@stoplight/json": { - "version": "3.20.3", - "resolved": "https://registry.npmjs.org/@stoplight/json/-/json-3.20.3.tgz", - "integrity": "sha512-2m+Km6CkEPWj+H+CXxFyQB9+mVK8ifz9izK0UZpz4G1ZBx2Pd2hI+qw24FJ+X3DTYtMPYIeINTOEaTFWOmbRxQ==", + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@stoplight/json/-/json-3.21.0.tgz", + "integrity": "sha512-5O0apqJ/t4sIevXCO3SBN9AHCEKKR/Zb4gaj7wYe5863jme9g02Q0n/GhM7ZCALkL+vGPTe4ZzTETP8TFtsw3g==", "dev": true, "dependencies": { "@stoplight/ordered-object-literal": "^1.0.3", @@ -1395,23 +1395,6 @@ "node": ">=8.3.0" } }, - "node_modules/@stoplight/json-ref-resolver/node_modules/@stoplight/json": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/@stoplight/json/-/json-3.21.0.tgz", - "integrity": "sha512-5O0apqJ/t4sIevXCO3SBN9AHCEKKR/Zb4gaj7wYe5863jme9g02Q0n/GhM7ZCALkL+vGPTe4ZzTETP8TFtsw3g==", - "dev": true, - "dependencies": { - "@stoplight/ordered-object-literal": "^1.0.3", - "@stoplight/path": "^1.3.2", - "@stoplight/types": "^13.6.0", - "jsonc-parser": "~2.2.1", - "lodash": "^4.17.21", - "safe-stable-stringify": "^1.1" - }, - "engines": { - "node": ">=8.3.0" - } - }, "node_modules/@stoplight/ordered-object-literal": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@stoplight/ordered-object-literal/-/ordered-object-literal-1.0.4.tgz", @@ -1431,27 +1414,27 @@ } }, "node_modules/@stoplight/spectral-cli": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-cli/-/spectral-cli-6.8.0.tgz", - "integrity": "sha512-gwNAZSdUdxxzH8X2yf1YoDQmo9DYnqPhqFCaKx5alQa/SQG6DOG1taNeODq3wapLn6GDh0d5D8b1DVe7N6bWEw==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-cli/-/spectral-cli-6.10.0.tgz", + "integrity": "sha512-mJOlLtKvhiVUSXslcjHFrb+MPIMe6yrqsuABFyXqb2SMk/zOsLDIEKgB/UQ1w6hTcr54o6CVTAYf+0lnhCsWQw==", "dev": true, "dependencies": { - "@stoplight/json": "~3.20.1", + "@stoplight/json": "~3.21.0", "@stoplight/path": "1.3.2", - "@stoplight/spectral-core": "^1.15.1", - "@stoplight/spectral-formatters": "^1.0.0", - "@stoplight/spectral-parsers": "^1.0.2", - "@stoplight/spectral-ref-resolver": "^1.0.2", - "@stoplight/spectral-ruleset-bundler": "^1.4.0", - "@stoplight/spectral-ruleset-migrator": "^1.9.0", + "@stoplight/spectral-core": "^1.18.3", + "@stoplight/spectral-formatters": "^1.2.0", + "@stoplight/spectral-parsers": "^1.0.3", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-ruleset-bundler": "^1.5.2", + "@stoplight/spectral-ruleset-migrator": "^1.9.5", "@stoplight/spectral-rulesets": ">=1", "@stoplight/spectral-runtime": "^1.1.2", "@stoplight/types": "^13.6.0", "chalk": "4.1.2", "fast-glob": "~3.2.12", + "hpagent": "~1.2.0", "lodash": "~4.17.21", "pony-cause": "^1.0.0", - "proxy-agent": "5.0.0", "stacktracey": "^2.1.7", "tslib": "^2.3.0", "yargs": "17.3.1" @@ -1523,23 +1506,6 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/@stoplight/spectral-core/node_modules/@stoplight/json": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/@stoplight/json/-/json-3.21.0.tgz", - "integrity": "sha512-5O0apqJ/t4sIevXCO3SBN9AHCEKKR/Zb4gaj7wYe5863jme9g02Q0n/GhM7ZCALkL+vGPTe4ZzTETP8TFtsw3g==", - "dev": true, - "dependencies": { - "@stoplight/ordered-object-literal": "^1.0.3", - "@stoplight/path": "^1.3.2", - "@stoplight/types": "^13.6.0", - "jsonc-parser": "~2.2.1", - "lodash": "^4.17.21", - "safe-stable-stringify": "^1.1" - }, - "engines": { - "node": ">=8.3.0" - } - }, "node_modules/@stoplight/spectral-core/node_modules/@stoplight/types": { "version": "13.6.0", "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.6.0.tgz", @@ -1591,9 +1557,9 @@ } }, "node_modules/@stoplight/spectral-formatters": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-formatters/-/spectral-formatters-1.1.0.tgz", - "integrity": "sha512-KUOKOF0Wz9iFS4pKHO8mSx0OZc9J5ziFJfbxFOL8xDGlxYTfBIQsaOjwi6GItcar7wK8S2ksAIUS2ijzAygZ5A==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-formatters/-/spectral-formatters-1.2.0.tgz", + "integrity": "sha512-1IrQksU1fpuvK7oT8t0jk419vkvzHbwqKYtnyoF9yZa+MV1AcSsieD5I6wBFL0WlgFr6iCg23s1V99VXlrFelw==", "dev": true, "dependencies": { "@stoplight/path": "^1.3.2", @@ -1648,23 +1614,6 @@ "node": "^12.20 || >=14.13" } }, - "node_modules/@stoplight/spectral-parsers/node_modules/@stoplight/json": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/@stoplight/json/-/json-3.21.0.tgz", - "integrity": "sha512-5O0apqJ/t4sIevXCO3SBN9AHCEKKR/Zb4gaj7wYe5863jme9g02Q0n/GhM7ZCALkL+vGPTe4ZzTETP8TFtsw3g==", - "dev": true, - "dependencies": { - "@stoplight/ordered-object-literal": "^1.0.3", - "@stoplight/path": "^1.3.2", - "@stoplight/types": "^13.6.0", - "jsonc-parser": "~2.2.1", - "lodash": "^4.17.21", - "safe-stable-stringify": "^1.1" - }, - "engines": { - "node": ">=8.3.0" - } - }, "node_modules/@stoplight/spectral-ref-resolver": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@stoplight/spectral-ref-resolver/-/spectral-ref-resolver-1.0.4.tgz", @@ -1709,12 +1658,12 @@ } }, "node_modules/@stoplight/spectral-ruleset-migrator": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-migrator/-/spectral-ruleset-migrator-1.9.4.tgz", - "integrity": "sha512-bQjmYTf1COdhXdFg4dRzfZ7Ukc9ylr9f9J8c1PO3NGZtryUavw/109BrYfdQGgO0Hfkc/yVsRbkI4mKYNlvnXg==", + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-migrator/-/spectral-ruleset-migrator-1.9.5.tgz", + "integrity": "sha512-76n/HETr3UinVl/xLNldrH9p0JNoD8Gz4K75J6E4OHp4xD0P+BA2e8+W30HjIvqm1LJdLU2BNma0ioy+q3B9RA==", "dev": true, "dependencies": { - "@stoplight/json": "~3.20.1", + "@stoplight/json": "~3.21.0", "@stoplight/ordered-object-literal": "~1.0.4", "@stoplight/path": "1.3.2", "@stoplight/spectral-functions": "^1.0.0", @@ -1869,6 +1818,24 @@ "@types/tern": "*" } }, + "node_modules/@types/d3-scale": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.3.tgz", + "integrity": "sha512-PATBiMCpvHJSMtZAMEhc2WyL+hnzarKzI6wAHYjhsonjWJYGq5BXTzQjv4l8m2jO183/4wZ90rKvSeT7o72xNQ==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz", + "integrity": "sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==" + }, + "node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==" + }, "node_modules/@types/debug": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", @@ -2651,6 +2618,25 @@ "node": ">=8" } }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.2.tgz", + "integrity": "sha512-tb5thFFlUcp7NdNF6/MpDk/1r/4awWG1FIz3YqDf+/zJSTezBb+/5WViH41obXULHVpDzoiCLpJ/ZO9YbJMsdw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.flat": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz", @@ -2925,15 +2911,6 @@ "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==", "dev": true }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -3118,9 +3095,9 @@ } }, "node_modules/clippie": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clippie/-/clippie-4.0.1.tgz", - "integrity": "sha512-glbCTIRm5gL/vOZ02Dvxa2DXNYrwn8tylBHP3Bz77O1rQ4GMzbzm2Vzv8NYBv1kQYQsHXaUxHBRof8c/f2NXrg==" + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/clippie/-/clippie-4.0.6.tgz", + "integrity": "sha512-E5EtOw8iMH0enuL3kBZJ+Po1nPnBD7O+HHpIaWpfWgHbHmdoOQoERrlNOcEEn2yMJQ98WqeKacouAcnRXn7oWA==" }, "node_modules/cliui": { "version": "7.0.4", @@ -3254,12 +3231,6 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true - }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -3783,6 +3754,41 @@ "node": ">=12" } }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -3901,15 +3907,6 @@ "lodash-es": "^4.17.21" } }, - "node_modules/data-uri-to-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz", - "integrity": "sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/data-urls": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", @@ -4052,33 +4049,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/degenerator": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-3.0.4.tgz", - "integrity": "sha512-Z66uPeBfHZAHVmue3HPfyKu2Q0rC2cRxbTOsvmU/po5fvvcx27W4mIu9n0PUlQih4oUYvcG1BsbtVv8x7KDOSw==", - "dev": true, - "dependencies": { - "ast-types": "^0.13.2", - "escodegen": "^1.8.1", - "esprima": "^4.0.0", - "vm2": "^3.9.17" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/degenerator/node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "dev": true, - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/delaunator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.0.tgz", @@ -4096,15 +4066,6 @@ "node": ">=0.4.0" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dependency-graph": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", @@ -4240,9 +4201,9 @@ } }, "node_modules/dompurify": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.3.tgz", - "integrity": "sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ==" + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-F9e6wPGtY+8KNMRAVfxeCOHU0/NPWMSENNq4pQctuXRqqdEPW7q3CrLbR5Nse044WwacyjHGOMlvNsBe1y6z9A==" }, "node_modules/domutils": { "version": "3.1.0", @@ -4560,110 +4521,28 @@ "node": ">=0.8.0" } }, - "node_modules/escodegen": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", - "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", - "dev": true, - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^4.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=4.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/escodegen/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/escodegen/node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/eslint": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.45.0.tgz", - "integrity": "sha512-pd8KSxiQpdYRfYa9Wufvdoct3ZPQQuVuU5O6scNgMuOMYuxvH0IGaYK0wUFjo4UYYQQCUndlXiMbnxopwvvTiw==", + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.46.0.tgz", + "integrity": "sha512-cIO74PvbW0qU8e0mIvk5IV3ToWdCq5FYG6gWPHHkx6gNdjlbAYvtfHmlCMXxjcoVaIdwy/IAt3+mDkZkfvb2Dg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.1.0", - "@eslint/js": "8.44.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.1", + "@eslint/js": "^8.46.0", "@humanwhocodes/config-array": "^0.11.10", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.0", - "eslint-visitor-keys": "^3.4.1", - "espree": "^9.6.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.2", + "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -4764,26 +4643,29 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.27.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz", - "integrity": "sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.28.0.tgz", + "integrity": "sha512-B8s/n+ZluN7sxj9eUf7/pRFERX0r5bnFA2dCaLHy2ZeaQEAz0k+ZZkFWRFHJAqxfxQDx6KLv9LeIki7cFdwW+Q==", "dev": true, "dependencies": { "array-includes": "^3.1.6", + "array.prototype.findlastindex": "^1.2.2", "array.prototype.flat": "^1.3.1", "array.prototype.flatmap": "^1.3.1", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.7", - "eslint-module-utils": "^2.7.4", + "eslint-module-utils": "^2.8.0", "has": "^1.0.3", - "is-core-module": "^2.11.0", + "is-core-module": "^2.12.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", + "object.fromentries": "^2.0.6", + "object.groupby": "^1.0.0", "object.values": "^1.1.6", - "resolve": "^1.22.1", - "semver": "^6.3.0", - "tsconfig-paths": "^3.14.1" + "resolve": "^1.22.3", + "semver": "^6.3.1", + "tsconfig-paths": "^3.14.2" }, "engines": { "node": ">=4" @@ -4912,9 +4794,9 @@ } }, "node_modules/eslint-plugin-unicorn": { - "version": "48.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-48.0.0.tgz", - "integrity": "sha512-8fk/v3p1ro34JSVDBEmtOq6EEQRpMR0iTir79q69KnXFZ6DJyPkT3RAi+ZoTqhQMdDSpGh8BGR68ne1sP5cnAA==", + "version": "48.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-48.0.1.tgz", + "integrity": "sha512-FW+4r20myG/DqFcCSzoumaddKBicIPeFnTrifon2mWIzlfyvzwyqZjqVP7m4Cqr/ZYisS2aiLghkUWaPg6vtCw==", "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.22.5", @@ -4944,17 +4826,17 @@ } }, "node_modules/eslint-plugin-vue": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.15.1.tgz", - "integrity": "sha512-CJE/oZOslvmAR9hf8SClTdQ9JLweghT6JCBQNrT2Iel1uVw0W0OLJxzvPd6CxmABKCvLrtyDnqGV37O7KQv6+A==", + "version": "9.16.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.16.0.tgz", + "integrity": "sha512-SonAuvQXFm9HBV9ww/YkYZVYPMR8ptxxuJwcKGnG9A65SyvNANP9MKlfnC49L6DIwt/YEQZwZWEMHOkL1d5s1A==", "dev": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.3.0", + "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", - "nth-check": "^2.0.1", - "postcss-selector-parser": "^6.0.9", - "semver": "^7.3.5", - "vue-eslint-parser": "^9.3.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^6.0.13", + "semver": "^7.5.4", + "vue-eslint-parser": "^9.3.1", "xml-name-validator": "^4.0.0" }, "engines": { @@ -4978,9 +4860,9 @@ } }, "node_modules/eslint-scope": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.1.tgz", - "integrity": "sha512-CvefSOsDdaYYvxChovdrPo/ZGt8d5lrJWleAc1diXRKhHGiTYEI26cvo8Kle/wGnsizoCJjK73FMg1/IkIwiNA==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "dependencies": { "esrecurse": "^4.3.0", @@ -4994,9 +4876,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", - "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.2.tgz", + "integrity": "sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -5078,19 +4960,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", @@ -5159,9 +5028,9 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.0.tgz", - "integrity": "sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -5237,15 +5106,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-2.0.0.tgz", - "integrity": "sha512-hjPFI8oE/2iQPVe4gbrJ73Pp+Xfub2+WI2LlXDbsaJBwT5wuMh35WNWVYYTpnz895shtwfyutMFLFywpQAFdLg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -5331,20 +5191,6 @@ "node": ">= 6" } }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5364,19 +5210,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/ftp": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz", - "integrity": "sha512-faFVML1aBx2UoDStmLwv2Wptt4vw5x03xxX172nhA5Y5HBshW5JweqQ2W4xL4dezQTG8inJsuYcpPHHU3X5OTQ==", - "dev": true, - "dependencies": { - "readable-stream": "1.1.x", - "xregexp": "2.0.0" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -5506,32 +5339,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/get-uri": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-3.0.2.tgz", - "integrity": "sha512-+5s0SJbGoyiJTZZ2JTpFPLMPSch72KEqGOTvQsBqg0RBWvwhWUSYZFAtz3TPW0GXJuLBJPts1E241iHg+VRfhg==", - "dev": true, - "dependencies": { - "@tootallnate/once": "1", - "data-uri-to-buffer": "3", - "debug": "4", - "file-uri-to-path": "2", - "fs-extra": "^8.1.0", - "ftp": "^0.3.10" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/get-uri/node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5826,6 +5633,15 @@ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -5881,22 +5697,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -6098,12 +5898,6 @@ "node": ">=10.13.0" } }, - "node_modules/ip": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.8.tgz", - "integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==", - "dev": true - }, "node_modules/is-array-buffer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", @@ -6442,12 +6236,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6662,15 +6450,6 @@ "integrity": "sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w==", "dev": true }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsonpath-plus": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-7.1.0.tgz", @@ -7152,15 +6931,6 @@ "node": ">=0.10.0" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -7480,18 +7250,21 @@ } }, "node_modules/mermaid": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-10.2.4.tgz", - "integrity": "sha512-zHGjEI7lBvWZX+PQYmlhSA2p40OzW6QbGodTCSzDeVpqaTnyAC+2sRGqrpXO+uQk3CnoeClHQPraQUMStdqy2g==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-10.3.0.tgz", + "integrity": "sha512-H5quxuQjwXC8M1WuuzhAp2TdqGg74t5skfDBrNKJ7dt3z8Wprl5S6h9VJsRhoBUTSs1TMtHEdplLhCqXleZZLw==", "dependencies": { "@braintree/sanitize-url": "^6.0.2", + "@types/d3-scale": "^4.0.3", + "@types/d3-scale-chromatic": "^3.0.0", "cytoscape": "^3.23.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.1.0", "d3": "^7.4.0", + "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.10", "dayjs": "^1.11.7", - "dompurify": "3.0.3", + "dompurify": "3.0.5", "elkjs": "^0.8.2", "khroma": "^2.0.0", "lodash-es": "^4.17.21", @@ -8103,15 +7876,6 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "dev": true, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/nimma": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/nimma/-/nimma-0.2.2.tgz", @@ -8290,6 +8054,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.fromentries": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.6.tgz", + "integrity": "sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.0.tgz", + "integrity": "sha512-70MWG6NfRH9GnbZOikuhPPYzpUpof9iW2J9E4dW7FXTqPNb6rllE6u39SKwwiNh8lCwX3DDb5OgcKGiEBrTTyw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.21.2", + "get-intrinsic": "^1.2.1" + } + }, "node_modules/object.values": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", @@ -8370,63 +8163,6 @@ "node": ">=6" } }, - "node_modules/pac-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-5.0.0.tgz", - "integrity": "sha512-CcFG3ZtnxO8McDigozwE3AqAw15zDvGH+OjXO4kzf7IkEKkQ4gxQ+3sdF50WmhQ4P/bVusXcqNE2S3XrNURwzQ==", - "dev": true, - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4", - "get-uri": "3", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "5", - "pac-resolver": "^5.0.0", - "raw-body": "^2.2.0", - "socks-proxy-agent": "5" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/pac-proxy-agent/node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/pac-proxy-agent/node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/pac-resolver": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-5.0.1.tgz", - "integrity": "sha512-cy7u00ko2KVgBAjuhevqpPeHIkCIqPe1v24cydhWjmeuzaBfmUWFCZJ1iAh5TuVzVZoUzXIW7K8sMYOZ84uZ9Q==", - "dev": true, - "dependencies": { - "degenerator": "^3.0.2", - "ip": "^1.1.5", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8668,9 +8404,9 @@ "dev": true }, "node_modules/playwright-core": { - "version": "1.36.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.36.1.tgz", - "integrity": "sha512-7+tmPuMcEW4xeCL9cp9KxmYpQYHKkyjwoXRnoeTowaeNat8PoBMk/HwCYhqkH2fRkshfKEOiVus/IhID2Pg8kg==", + "version": "1.36.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.36.2.tgz", + "integrity": "sha512-sQYZt31dwkqxOrP7xy2ggDfEzUxM1lodjhsQ3NMMv5uGTRDsLxU0e4xf4wwMkF2gplIxf17QMBCodSFgm6bFVQ==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -8903,54 +8639,6 @@ "node": ">=4" } }, - "node_modules/proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-5.0.0.tgz", - "integrity": "sha512-gkH7BkvLVkSfX9Dk27W6TyNOWWZWRilRfk1XxGNWOYJ2TuedAv1yFpCaU9QSBmBe716XOTNpYNOzhysyw8xn7g==", - "dev": true, - "dependencies": { - "agent-base": "^6.0.0", - "debug": "4", - "http-proxy-agent": "^4.0.0", - "https-proxy-agent": "^5.0.0", - "lru-cache": "^5.1.1", - "pac-proxy-agent": "^5.0.0", - "proxy-from-env": "^1.0.0", - "socks-proxy-agent": "^5.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/proxy-agent/node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/proxy-agent/node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true - }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -9010,33 +8698,6 @@ "safe-buffer": "^5.1.0" } }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dev": true, - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -9145,18 +8806,6 @@ "node": ">=8" } }, - "node_modules/readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -9321,11 +8970,11 @@ } }, "node_modules/resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.3.tgz", + "integrity": "sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==", "dependencies": { - "is-core-module": "^2.11.0", + "is-core-module": "^2.12.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -9630,12 +9279,6 @@ "node": ">=10" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true - }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -9745,50 +9388,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", - "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", - "dev": true, - "dependencies": { - "ip": "^2.0.0", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.13.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-5.0.1.tgz", - "integrity": "sha512-vZdmnjb9a2Tz6WEQVIurybSwElwPxMZaIc7PzqbJTrezcKNznv6giT7J7tZDZ1BojVaa1jvO/UiUdhDVB0ACoQ==", - "dev": true, - "dependencies": { - "agent-base": "^6.0.2", - "debug": "4", - "socks": "^2.3.3" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/socks/node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", - "dev": true - }, "node_modules/solid-js": { "version": "1.7.8", "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.7.8.tgz", @@ -9918,27 +9517,12 @@ "get-source": "^2.0.12" } }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.3.3.tgz", "integrity": "sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg==", "dev": true }, - "node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "dev": true - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -10286,9 +9870,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.1.3.tgz", - "integrity": "sha512-W/vZFeZHG+xTN4yu8LXdaIrcnT4Hbr7qRUILYlMEoIiG6nuTylnEGeRcvL64F2eHRA2Jo/fgCSTU06Qfh0lT3g==" + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.2.0.tgz", + "integrity": "sha512-rLvJBgualxNZcwKOmTFzy4zF1nHy+3S0pUDDR/ageDRZgi8aITSe7pVYiAy03xGQZtqEifjwEtHQE+eF14gveg==" }, "node_modules/symbol-tree": { "version": "3.2.4", @@ -10499,15 +10083,6 @@ "resolved": "https://registry.npmjs.org/toastify-js/-/toastify-js-1.12.0.tgz", "integrity": "sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==" }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "engines": { - "node": ">=0.6" - } - }, "node_modules/tough-cookie": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", @@ -10746,24 +10321,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/update-browserslist-db": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", @@ -10966,9 +10523,9 @@ } }, "node_modules/vite-string-plugin": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vite-string-plugin/-/vite-string-plugin-1.1.1.tgz", - "integrity": "sha512-F2cCVxnLYfrn8S3DycEFzf8RJcrFpET8D0rusog7r25wK3zRQ1nq7eYfKEZkapCCa+1g4j/llcI5hwkbjryPVg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vite-string-plugin/-/vite-string-plugin-1.1.2.tgz", + "integrity": "sha512-j1EvpPlpwYUzFE8UNy/EZbbTRBkHcATpgzkyAIslCu/51Zg//LR+cYqpNDcfFnlACWZSQSSGlzDswlLQwi5rlw==", "dev": true, "engines": { "node": ">=16" @@ -11474,23 +11031,6 @@ "node": ">=12" } }, - "node_modules/vm2": { - "version": "3.9.19", - "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.19.tgz", - "integrity": "sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==", - "deprecated": "The library contains critical security issues and should not be used for production! The maintenance of the project has been discontinued. Consider migrating your code to isolated-vm.", - "dev": true, - "dependencies": { - "acorn": "^8.7.0", - "acorn-walk": "^8.2.0" - }, - "bin": { - "vm2": "bin/vm2" - }, - "engines": { - "node": ">=6.0" - } - }, "node_modules/vue": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.4.tgz", @@ -11908,15 +11448,6 @@ "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==" }, - "node_modules/word-wrap": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", - "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -12062,15 +11593,6 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, - "node_modules/xregexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz", - "integrity": "sha512-xl/50/Cf32VsGq/1R8jJE5ajH1yMCQkpmoS10QbFZWl2Oor4H0Me64Pu2yxvsRWK3m6soJbmGfzSR7BYmDcWAA==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -12080,12 +11602,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, "node_modules/yargs": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz", diff --git a/package.json b/package.json index 571de3acf526b..996771b31aa01 100644 --- a/package.json +++ b/package.json @@ -13,24 +13,24 @@ "@github/relative-time-element": "4.3.0", "@github/text-expander-element": "2.5.0", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", - "@primer/octicons": "19.4.0", + "@primer/octicons": "19.5.0", "@webcomponents/custom-elements": "1.6.0", "add-asset-webpack-plugin": "2.0.1", "ansi_up": "5.2.1", "asciinema-player": "3.5.0", - "clippie": "4.0.1", + "clippie": "4.0.6", "css-loader": "6.8.1", "dropzone": "6.0.0-beta.2", "easymde": "2.18.0", "esbuild-loader": "3.0.1", "escape-goat": "4.0.0", - "fast-glob": "3.3.0", + "fast-glob": "3.3.1", "jquery": "3.7.0", "jquery.are-you-sure": "1.9.0", "katex": "0.16.8", "license-checker-webpack-plugin": "0.2.1", "lightningcss-loader": "2.1.0", - "mermaid": "10.2.4", + "mermaid": "10.3.0", "mini-css-extract-plugin": "2.7.6", "minimatch": "9.0.3", "monaco-editor": "0.40.0", @@ -38,7 +38,7 @@ "pdfobject": "2.2.12", "pretty-ms": "8.0.0", "sortablejs": "1.15.0", - "swagger-ui-dist": "5.1.3", + "swagger-ui-dist": "5.2.0", "throttle-debounce": "5.0.0", "tinycolor2": "1.6.0", "tippy.js": "6.3.7", @@ -55,20 +55,20 @@ }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "3.2.1", - "@playwright/test": "1.36.1", - "@stoplight/spectral-cli": "6.8.0", + "@playwright/test": "1.36.2", + "@stoplight/spectral-cli": "6.10.0", "@vitejs/plugin-vue": "4.2.3", - "eslint": "8.45.0", + "eslint": "8.46.0", "eslint-plugin-array-func": "3.1.8", "eslint-plugin-custom-elements": "0.0.8", - "eslint-plugin-import": "2.27.5", + "eslint-plugin-import": "2.28.0", "eslint-plugin-jquery": "1.5.1", "eslint-plugin-no-jquery": "2.7.0", "eslint-plugin-no-use-extend-native": "0.5.0", "eslint-plugin-regexp": "1.15.0", "eslint-plugin-sonarjs": "0.19.0", - "eslint-plugin-unicorn": "48.0.0", - "eslint-plugin-vue": "9.15.1", + "eslint-plugin-unicorn": "48.0.1", + "eslint-plugin-vue": "9.16.0", "eslint-plugin-wc": "1.5.0", "jsdom": "22.1.0", "markdownlint-cli": "0.35.0", @@ -79,7 +79,7 @@ "stylelint-stylistic": "0.4.3", "svgo": "3.0.2", "updates": "14.3.4", - "vite-string-plugin": "1.1.1", + "vite-string-plugin": "1.1.2", "vitest": "0.33.0" }, "browserslist": [ diff --git a/poetry.lock b/poetry.lock index ae1fd9895bc7c..69fc27b1079c8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -27,12 +27,12 @@ files = [ [[package]] name = "cssbeautifier" -version = "1.14.8" +version = "1.14.9" description = "CSS unobfuscator and beautifier." optional = false python-versions = "*" files = [ - {file = "cssbeautifier-1.14.8.tar.gz", hash = "sha256:9ad4c5b2ffe0b439a4bed278bc440b6a89c40823c3f19db38f808d256216a592"}, + {file = "cssbeautifier-1.14.9.tar.gz", hash = "sha256:2da432472f68170eb854aff97b16a24721f5090ee36af2e31199590a89e7f71f"}, ] [package.dependencies] @@ -42,13 +42,13 @@ six = ">=1.13.0" [[package]] name = "djlint" -version = "1.32.0" +version = "1.32.1" description = "HTML Template Linter and Formatter" optional = false python-versions = ">=3.8.0,<4.0.0" files = [ - {file = "djlint-1.32.0-py3-none-any.whl", hash = "sha256:b1b14731a39c36be654712a930c6a8b41dd0eefddf83641d8d6e00cfa798644c"}, - {file = "djlint-1.32.0.tar.gz", hash = "sha256:0f53692b9b9731966d955b805dc8c6d43c99b967a7c2222d112451cb96145bd2"}, + {file = "djlint-1.32.1-py3-none-any.whl", hash = "sha256:8aaec6776376cfd41f3e742ed9f5e3c46dc7c6cd7646abf9e99860fb1ec9f19a"}, + {file = "djlint-1.32.1.tar.gz", hash = "sha256:c1e2141acf1547d244af7ef8abd15fbbae2abcc7a03e2bf31f8a491de69a6c72"}, ] [package.dependencies] @@ -100,12 +100,12 @@ files = [ [[package]] name = "jsbeautifier" -version = "1.14.8" +version = "1.14.9" description = "JavaScript unobfuscator and beautifier." optional = false python-versions = "*" files = [ - {file = "jsbeautifier-1.14.8.tar.gz", hash = "sha256:d4c4e263a42dd6194afb9dbe54710be3c5604492cbec3e89c92dd98513f98b9f"}, + {file = "jsbeautifier-1.14.9.tar.gz", hash = "sha256:c738ebc36b47bd94e4ca6dd17a9004c3cc74edad582ca1d60e0e5d5945a63cb9"}, ] [package.dependencies] @@ -128,13 +128,13 @@ dev = ["hypothesis"] [[package]] name = "pathspec" -version = "0.11.1" +version = "0.11.2" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.7" files = [ - {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, - {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, + {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, + {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, ] [[package]] @@ -328,4 +328,4 @@ telegram = ["requests"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "69e0bae3b5139a7980b1fd8cca7a1e4c8096aea9833b721d3b1a808f83eb26f0" +content-hash = "1b154f70c35b75d47c843959af9df0e7343f3bb579835825ca889ec9350afc41" diff --git a/public/assets/img/svg/octicon-pin-slash.svg b/public/assets/img/svg/octicon-pin-slash.svg index 4ba1d4a5e2153..4cd88d58a1456 100644 --- a/public/assets/img/svg/octicon-pin-slash.svg +++ b/public/assets/img/svg/octicon-pin-slash.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-repo-clone.svg b/public/assets/img/svg/octicon-repo-clone.svg index 03c4a24c46ab9..72e245fc34270 100644 --- a/public/assets/img/svg/octicon-repo-clone.svg +++ b/public/assets/img/svg/octicon-repo-clone.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-repo-pull.svg b/public/assets/img/svg/octicon-repo-pull.svg index 82e3cca7da1b4..743aeec31d20c 100644 --- a/public/assets/img/svg/octicon-repo-pull.svg +++ b/public/assets/img/svg/octicon-repo-pull.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a2a386385922d..549a8cb2b0e2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [] python = "^3.8" [tool.poetry.group.dev.dependencies] -djlint = "1.32.0" +djlint = "1.32.1" [tool.djlint] profile="golang" diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 8a61fc9834518..861e63a9b82f8 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -132,74 +132,73 @@ func SearchIssues(ctx *context.APIContext) { isClosed = util.OptionalBoolFalse } - // find repos user can access (for issue search) - opts := &repo_model.SearchRepoOptions{ - Private: false, - AllPublic: true, - TopicOnly: false, - Collaborate: util.OptionalBoolNone, - // This needs to be a column that is not nil in fixtures or - // MySQL will return different results when sorting by null in some cases - OrderBy: db.SearchOrderByAlphabetically, - Actor: ctx.Doer, - } - if ctx.IsSigned { - opts.Private = true - opts.AllLimited = true - } - if ctx.FormString("owner") != "" { - owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) - if err != nil { - if user_model.IsErrUserNotExist(err) { - ctx.Error(http.StatusBadRequest, "Owner not found", err) - } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + var ( + repoIDs []int64 + allPublic bool + ) + { + // find repos user can access (for issue search) + opts := &repo_model.SearchRepoOptions{ + Private: false, + AllPublic: true, + TopicOnly: false, + Collaborate: util.OptionalBoolNone, + // This needs to be a column that is not nil in fixtures or + // MySQL will return different results when sorting by null in some cases + OrderBy: db.SearchOrderByAlphabetically, + Actor: ctx.Doer, + } + if ctx.IsSigned { + opts.Private = true + opts.AllLimited = true + } + if ctx.FormString("owner") != "" { + owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusBadRequest, "Owner not found", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return } - return + opts.OwnerID = owner.ID + opts.AllLimited = false + opts.AllPublic = false + opts.Collaborate = util.OptionalBoolFalse + } + if ctx.FormString("team") != "" { + if ctx.FormString("owner") == "" { + ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") + return + } + team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team")) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusBadRequest, "Team not found", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return + } + opts.TeamID = team.ID } - opts.OwnerID = owner.ID - opts.AllLimited = false - opts.AllPublic = false - opts.Collaborate = util.OptionalBoolFalse - } - if ctx.FormString("team") != "" { - if ctx.FormString("owner") == "" { - ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") - return + + if opts.AllPublic { + allPublic = true + opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer } - team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team")) + repoIDs, _, err = repo_model.SearchRepositoryIDs(opts) if err != nil { - if organization.IsErrTeamNotExist(err) { - ctx.Error(http.StatusBadRequest, "Team not found", err) - } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err) - } + ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err) return } - opts.TeamID = team.ID } - repoCond := repo_model.SearchRepositoryCondition(opts) - repoIDs, _, err := repo_model.SearchRepositoryIDs(opts) - if err != nil { - ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err) - return - } - - var issues []*issues_model.Issue - var filteredCount int64 - keyword := ctx.FormTrim("q") if strings.IndexByte(keyword, 0) >= 0 { keyword = "" } - var issueIDs []int64 - if len(keyword) > 0 && len(repoIDs) > 0 { - if issueIDs, err = issue_indexer.SearchIssuesByKeyword(ctx, repoIDs, keyword, ctx.FormString("state")); err != nil { - ctx.Error(http.StatusInternalServerError, "SearchIssuesByKeyword", err) - return - } - } var isPull util.OptionalBool switch ctx.FormString("type") { @@ -211,16 +210,33 @@ func SearchIssues(ctx *context.APIContext) { isPull = util.OptionalBoolNone } - labels := ctx.FormTrim("labels") - var includedLabelNames []string - if len(labels) > 0 { - includedLabelNames = strings.Split(labels, ",") + var includedAnyLabels []int64 + { + + labels := ctx.FormTrim("labels") + var includedLabelNames []string + if len(labels) > 0 { + includedLabelNames = strings.Split(labels, ",") + } + includedAnyLabels, err = issues_model.GetLabelIDsByNames(ctx, includedLabelNames) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelIDsByNames", err) + return + } } - milestones := ctx.FormTrim("milestones") - var includedMilestones []string - if len(milestones) > 0 { - includedMilestones = strings.Split(milestones, ",") + var includedMilestones []int64 + { + milestones := ctx.FormTrim("milestones") + var includedMilestoneNames []string + if len(milestones) > 0 { + includedMilestoneNames = strings.Split(milestones, ",") + } + includedMilestones, err = issues_model.GetMilestoneIDsByNames(ctx, includedMilestoneNames) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetMilestoneIDsByNames", err) + return + } } // this api is also used in UI, @@ -232,64 +248,64 @@ func SearchIssues(ctx *context.APIContext) { limit = setting.API.MaxResponseItems } - // Only fetch the issues if we either don't have a keyword or the search returned issues - // This would otherwise return all issues if no issues were found by the search. - if len(keyword) == 0 || len(issueIDs) > 0 || len(includedLabelNames) > 0 || len(includedMilestones) > 0 { - issuesOpt := &issues_model.IssuesOptions{ - ListOptions: db.ListOptions{ - Page: ctx.FormInt("page"), - PageSize: limit, - }, - RepoCond: repoCond, - IsClosed: isClosed, - IssueIDs: issueIDs, - IncludedLabelNames: includedLabelNames, - IncludeMilestones: includedMilestones, - SortType: "priorityrepo", - PriorityRepoID: ctx.FormInt64("priority_repo_id"), - IsPull: isPull, - UpdatedBeforeUnix: before, - UpdatedAfterUnix: since, - } - - ctxUserID := int64(0) - if ctx.IsSigned { - ctxUserID = ctx.Doer.ID - } + searchOpt := &issue_indexer.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: limit, + Page: ctx.FormInt("page"), + }, + Keyword: keyword, + RepoIDs: repoIDs, + AllPublic: allPublic, + IsPull: isPull, + IsClosed: isClosed, + IncludedAnyLabelIDs: includedAnyLabels, + MilestoneIDs: includedMilestones, + SortBy: issue_indexer.SortByCreatedDesc, + } - // Filter for: Created by User, Assigned to User, Mentioning User, Review of User Requested + if since != 0 { + searchOpt.UpdatedAfterUnix = &since + } + if before != 0 { + searchOpt.UpdatedBeforeUnix = &before + } + + if ctx.IsSigned { + ctxUserID := ctx.Doer.ID if ctx.FormBool("created") { - issuesOpt.PosterID = ctxUserID + searchOpt.PosterID = &ctxUserID } if ctx.FormBool("assigned") { - issuesOpt.AssigneeID = ctxUserID + searchOpt.AssigneeID = &ctxUserID } if ctx.FormBool("mentioned") { - issuesOpt.MentionedID = ctxUserID + searchOpt.MentionID = &ctxUserID } if ctx.FormBool("review_requested") { - issuesOpt.ReviewRequestedID = ctxUserID + searchOpt.ReviewRequestedID = &ctxUserID } if ctx.FormBool("reviewed") { - issuesOpt.ReviewedID = ctxUserID + searchOpt.ReviewedID = &ctxUserID } + } - if issues, err = issues_model.Issues(ctx, issuesOpt); err != nil { - ctx.Error(http.StatusInternalServerError, "Issues", err) - return - } + // FIXME: It's unsupported to sort by priority repo when searching by indexer, + // it's indeed an regression, but I think it is worth to support filtering by indexer first. + _ = ctx.FormInt64("priority_repo_id") - issuesOpt.ListOptions = db.ListOptions{ - Page: -1, - } - if filteredCount, err = issues_model.CountIssues(ctx, issuesOpt); err != nil { - ctx.Error(http.StatusInternalServerError, "CountIssues", err) - return - } + ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchIssues", err) + return + } + issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err) + return } - ctx.SetLinkHeader(int(filteredCount), limit) - ctx.SetTotalCountHeader(filteredCount) + ctx.SetLinkHeader(int(total), limit) + ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues)) } @@ -384,23 +400,12 @@ func ListIssues(ctx *context.APIContext) { isClosed = util.OptionalBoolFalse } - var issues []*issues_model.Issue - var filteredCount int64 - keyword := ctx.FormTrim("q") if strings.IndexByte(keyword, 0) >= 0 { keyword = "" } - var issueIDs []int64 - var labelIDs []int64 - if len(keyword) > 0 { - issueIDs, err = issue_indexer.SearchIssuesByKeyword(ctx, []int64{ctx.Repo.Repository.ID}, keyword, ctx.FormString("state")) - if err != nil { - ctx.Error(http.StatusInternalServerError, "SearchIssuesByKeyword", err) - return - } - } + var labelIDs []int64 if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 { labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx.Repo.Repository.ID, splitted) if err != nil { @@ -465,40 +470,61 @@ func ListIssues(ctx *context.APIContext) { return } - // Only fetch the issues if we either don't have a keyword or the search returned issues - // This would otherwise return all issues if no issues were found by the search. - if len(keyword) == 0 || len(issueIDs) > 0 || len(labelIDs) > 0 { - issuesOpt := &issues_model.IssuesOptions{ - ListOptions: listOptions, - RepoIDs: []int64{ctx.Repo.Repository.ID}, - IsClosed: isClosed, - IssueIDs: issueIDs, - LabelIDs: labelIDs, - MilestoneIDs: mileIDs, - IsPull: isPull, - UpdatedBeforeUnix: before, - UpdatedAfterUnix: since, - PosterID: createdByID, - AssigneeID: assignedByID, - MentionedID: mentionedByID, - } - - if issues, err = issues_model.Issues(ctx, issuesOpt); err != nil { - ctx.Error(http.StatusInternalServerError, "Issues", err) - return + searchOpt := &issue_indexer.SearchOptions{ + Paginator: &listOptions, + Keyword: keyword, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + IsPull: isPull, + IsClosed: isClosed, + SortBy: issue_indexer.SortByCreatedDesc, + } + if since != 0 { + searchOpt.UpdatedAfterUnix = &since + } + if before != 0 { + searchOpt.UpdatedBeforeUnix = &before + } + if len(labelIDs) == 1 && labelIDs[0] == 0 { + searchOpt.NoLabelOnly = true + } else { + for _, labelID := range labelIDs { + if labelID > 0 { + searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID) + } else { + searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID) + } } + } - issuesOpt.ListOptions = db.ListOptions{ - Page: -1, - } - if filteredCount, err = issues_model.CountIssues(ctx, issuesOpt); err != nil { - ctx.Error(http.StatusInternalServerError, "CountIssues", err) - return - } + if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID { + searchOpt.MilestoneIDs = []int64{0} + } else { + searchOpt.MilestoneIDs = mileIDs + } + + if createdByID > 0 { + searchOpt.PosterID = &createdByID + } + if assignedByID > 0 { + searchOpt.AssigneeID = &assignedByID + } + if mentionedByID > 0 { + searchOpt.MentionID = &mentionedByID + } + + ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchIssues", err) + return + } + issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err) + return } - ctx.SetLinkHeader(int(filteredCount), listOptions.PageSize) - ctx.SetTotalCountHeader(filteredCount) + ctx.SetLinkHeader(int(total), listOptions.PageSize) + ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues)) } diff --git a/routers/api/v1/repo/issue_label.go b/routers/api/v1/repo/issue_label.go index fc83b6f14c94e..a2814a03db81e 100644 --- a/routers/api/v1/repo/issue_label.go +++ b/routers/api/v1/repo/issue_label.go @@ -309,7 +309,7 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) return nil, nil, err } - labels, err := issues_model.GetLabelsByIDs(form.Labels) + labels, err := issues_model.GetLabelsByIDs(form.Labels, "id", "repo_id", "org_id") if err != nil { ctx.Error(http.StatusInternalServerError, "GetLabelsByIDs", err) return nil, nil, err diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 5b32591b89140..7513f9360b1d9 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -349,7 +349,7 @@ func Diff(ctx *context.Context) { ctx.Data["Commit"] = commit ctx.Data["Diff"] = diff - statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptions{}) + statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptions{ListAll: true}) if err != nil { log.Error("GetLatestCommitStatus: %v", err) } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index f243507c3347b..7bddabd10a680 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -150,7 +150,6 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti mentionedID int64 reviewRequestedID int64 reviewedID int64 - forceEmpty bool ) if ctx.IsSigned { @@ -191,31 +190,14 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti keyword = "" } - var issueIDs []int64 - if len(keyword) > 0 { - issueIDs, err = issue_indexer.SearchIssuesByKeyword(ctx, []int64{repo.ID}, keyword, ctx.FormString("state")) - if err != nil { - if issue_indexer.IsAvailable(ctx) { - ctx.ServerError("issueIndexer.Search", err) - return - } - ctx.Data["IssueIndexerUnavailable"] = true - } - if len(issueIDs) == 0 { - forceEmpty = true - } - } - var mileIDs []int64 if milestoneID > 0 || milestoneID == db.NoConditionID { // -1 to get those issues which have no any milestone assigned mileIDs = []int64{milestoneID} } var issueStats *issues_model.IssueStats - if forceEmpty { - issueStats = &issues_model.IssueStats{} - } else { - issueStats, err = issues_model.GetIssueStats(&issues_model.IssuesOptions{ + { + statsOpts := &issues_model.IssuesOptions{ RepoIDs: []int64{repo.ID}, LabelIDs: labelIDs, MilestoneIDs: mileIDs, @@ -226,12 +208,34 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti ReviewRequestedID: reviewRequestedID, ReviewedID: reviewedID, IsPull: isPullOption, - IssueIDs: issueIDs, - }) - if err != nil { - ctx.ServerError("GetIssueStats", err) - return + IssueIDs: nil, + } + if keyword != "" { + allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts) + if err != nil { + if issue_indexer.IsAvailable(ctx) { + ctx.ServerError("issueIDsFromSearch", err) + return + } + ctx.Data["IssueIndexerUnavailable"] = true + 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.GetIssueStats(statsOpts) + if err != nil { + ctx.ServerError("GetIssueStats", err) + return + } + } + } isShowClosed := ctx.FormString("state") == "closed" @@ -253,12 +257,10 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti } pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5) - var issues []*issues_model.Issue - if forceEmpty { - issues = []*issues_model.Issue{} - } else { - issues, err = issues_model.Issues(ctx, &issues_model.IssuesOptions{ - ListOptions: db.ListOptions{ + var issues issues_model.IssueList + { + ids, err := issueIDsFromSearch(ctx, keyword, &issues_model.IssuesOptions{ + Paginator: &db.ListOptions{ Page: pager.Paginater.Current(), PageSize: setting.UI.IssuePagingNum, }, @@ -274,16 +276,23 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti IsPull: isPullOption, LabelIDs: labelIDs, SortType: sortType, - IssueIDs: issueIDs, }) if err != nil { - ctx.ServerError("Issues", err) + if issue_indexer.IsAvailable(ctx) { + ctx.ServerError("issueIDsFromSearch", err) + return + } + ctx.Data["IssueIndexerUnavailable"] = true + return + } + issues, err = issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) return } } - issueList := issues_model.IssueList(issues) - approvalCounts, err := issueList.GetApprovalCounts(ctx) + approvalCounts, err := issues.GetApprovalCounts(ctx) if err != nil { ctx.ServerError("ApprovalCounts", err) return @@ -306,6 +315,11 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti return } + if err := issues.LoadAttributes(ctx); err != nil { + ctx.ServerError("issues.LoadAttributes", err) + return + } + ctx.Data["Issues"] = issues ctx.Data["CommitLastStatus"] = lastStatus ctx.Data["CommitStatuses"] = commitStatuses @@ -429,6 +443,14 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti ctx.Data["Page"] = pager } +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 +} + // Issues render issues page func Issues(ctx *context.Context) { isPullList := ctx.Params(":type") == "pulls" @@ -518,6 +540,12 @@ func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *repo_model.R } func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { + // Distinguish whether the owner of the repository + // is an individual or an organization + repoOwnerType := project_model.TypeIndividual + if repo.Owner.IsOrganization() { + repoOwnerType = project_model.TypeOrganization + } var err error projects, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{ RepoID: repo.ID, @@ -533,7 +561,7 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { OwnerID: repo.OwnerID, Page: -1, IsClosed: util.OptionalBoolFalse, - Type: project_model.TypeOrganization, + Type: repoOwnerType, }) if err != nil { ctx.ServerError("GetProjects", err) @@ -556,7 +584,7 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { OwnerID: repo.OwnerID, Page: -1, IsClosed: util.OptionalBoolTrue, - Type: project_model.TypeOrganization, + Type: repoOwnerType, }) if err != nil { ctx.ServerError("GetProjects", err) @@ -2413,74 +2441,73 @@ func SearchIssues(ctx *context.Context) { isClosed = util.OptionalBoolFalse } - // find repos user can access (for issue search) - opts := &repo_model.SearchRepoOptions{ - Private: false, - AllPublic: true, - TopicOnly: false, - Collaborate: util.OptionalBoolNone, - // This needs to be a column that is not nil in fixtures or - // MySQL will return different results when sorting by null in some cases - OrderBy: db.SearchOrderByAlphabetically, - Actor: ctx.Doer, - } - if ctx.IsSigned { - opts.Private = true - opts.AllLimited = true - } - if ctx.FormString("owner") != "" { - owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) - if err != nil { - if user_model.IsErrUserNotExist(err) { - ctx.Error(http.StatusBadRequest, "Owner not found", err.Error()) - } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) + var ( + repoIDs []int64 + allPublic bool + ) + { + // find repos user can access (for issue search) + opts := &repo_model.SearchRepoOptions{ + Private: false, + AllPublic: true, + TopicOnly: false, + Collaborate: util.OptionalBoolNone, + // This needs to be a column that is not nil in fixtures or + // MySQL will return different results when sorting by null in some cases + OrderBy: db.SearchOrderByAlphabetically, + Actor: ctx.Doer, + } + if ctx.IsSigned { + opts.Private = true + opts.AllLimited = true + } + if ctx.FormString("owner") != "" { + owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusBadRequest, "Owner not found", err.Error()) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) + } + return } - return + opts.OwnerID = owner.ID + opts.AllLimited = false + opts.AllPublic = false + opts.Collaborate = util.OptionalBoolFalse } - opts.OwnerID = owner.ID - opts.AllLimited = false - opts.AllPublic = false - opts.Collaborate = util.OptionalBoolFalse - } - if ctx.FormString("team") != "" { - if ctx.FormString("owner") == "" { - ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") - return + if ctx.FormString("team") != "" { + if ctx.FormString("owner") == "" { + ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") + return + } + team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team")) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusBadRequest, "Team not found", err.Error()) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) + } + return + } + opts.TeamID = team.ID } - team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team")) + + if opts.AllPublic { + allPublic = true + opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer + } + repoIDs, _, err = repo_model.SearchRepositoryIDs(opts) if err != nil { - if organization.IsErrTeamNotExist(err) { - ctx.Error(http.StatusBadRequest, "Team not found", err.Error()) - } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) - } + ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err.Error()) return } - opts.TeamID = team.ID } - repoCond := repo_model.SearchRepositoryCondition(opts) - repoIDs, _, err := repo_model.SearchRepositoryIDs(opts) - if err != nil { - ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err.Error()) - return - } - - var issues []*issues_model.Issue - var filteredCount int64 - keyword := ctx.FormTrim("q") if strings.IndexByte(keyword, 0) >= 0 { keyword = "" } - var issueIDs []int64 - if len(keyword) > 0 && len(repoIDs) > 0 { - if issueIDs, err = issue_indexer.SearchIssuesByKeyword(ctx, repoIDs, keyword, ctx.FormString("state")); err != nil { - ctx.Error(http.StatusInternalServerError, "SearchIssuesByKeyword", err.Error()) - return - } - } var isPull util.OptionalBool switch ctx.FormString("type") { @@ -2492,19 +2519,39 @@ func SearchIssues(ctx *context.Context) { isPull = util.OptionalBoolNone } - labels := ctx.FormTrim("labels") - var includedLabelNames []string - if len(labels) > 0 { - includedLabelNames = strings.Split(labels, ",") + var includedAnyLabels []int64 + { + + labels := ctx.FormTrim("labels") + var includedLabelNames []string + if len(labels) > 0 { + includedLabelNames = strings.Split(labels, ",") + } + includedAnyLabels, err = issues_model.GetLabelIDsByNames(ctx, includedLabelNames) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelIDsByNames", err.Error()) + return + } } - milestones := ctx.FormTrim("milestones") - var includedMilestones []string - if len(milestones) > 0 { - includedMilestones = strings.Split(milestones, ",") + var includedMilestones []int64 + { + milestones := ctx.FormTrim("milestones") + var includedMilestoneNames []string + if len(milestones) > 0 { + includedMilestoneNames = strings.Split(milestones, ",") + } + includedMilestones, err = issues_model.GetMilestoneIDsByNames(ctx, includedMilestoneNames) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetMilestoneIDsByNames", err.Error()) + return + } } - projectID := ctx.FormInt64("project") + var projectID *int64 + if v := ctx.FormInt64("project"); v > 0 { + projectID = &v + } // this api is also used in UI, // so the default limit is set to fit UI needs @@ -2515,64 +2562,64 @@ func SearchIssues(ctx *context.Context) { limit = setting.API.MaxResponseItems } - // Only fetch the issues if we either don't have a keyword or the search returned issues - // This would otherwise return all issues if no issues were found by the search. - if len(keyword) == 0 || len(issueIDs) > 0 || len(includedLabelNames) > 0 || len(includedMilestones) > 0 { - issuesOpt := &issues_model.IssuesOptions{ - ListOptions: db.ListOptions{ - Page: ctx.FormInt("page"), - PageSize: limit, - }, - RepoCond: repoCond, - IsClosed: isClosed, - IssueIDs: issueIDs, - IncludedLabelNames: includedLabelNames, - IncludeMilestones: includedMilestones, - ProjectID: projectID, - SortType: "priorityrepo", - PriorityRepoID: ctx.FormInt64("priority_repo_id"), - IsPull: isPull, - UpdatedBeforeUnix: before, - UpdatedAfterUnix: since, - } - - ctxUserID := int64(0) - if ctx.IsSigned { - ctxUserID = ctx.Doer.ID - } + searchOpt := &issue_indexer.SearchOptions{ + Paginator: &db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: limit, + }, + Keyword: keyword, + RepoIDs: repoIDs, + AllPublic: allPublic, + IsPull: isPull, + IsClosed: isClosed, + IncludedAnyLabelIDs: includedAnyLabels, + MilestoneIDs: includedMilestones, + ProjectID: projectID, + SortBy: issue_indexer.SortByCreatedDesc, + } - // Filter for: Created by User, Assigned to User, Mentioning User, Review of User Requested + if since != 0 { + searchOpt.UpdatedAfterUnix = &since + } + if before != 0 { + searchOpt.UpdatedBeforeUnix = &before + } + + if ctx.IsSigned { + ctxUserID := ctx.Doer.ID if ctx.FormBool("created") { - issuesOpt.PosterID = ctxUserID + searchOpt.PosterID = &ctxUserID } if ctx.FormBool("assigned") { - issuesOpt.AssigneeID = ctxUserID + searchOpt.AssigneeID = &ctxUserID } if ctx.FormBool("mentioned") { - issuesOpt.MentionedID = ctxUserID + searchOpt.MentionID = &ctxUserID } if ctx.FormBool("review_requested") { - issuesOpt.ReviewRequestedID = ctxUserID + searchOpt.ReviewRequestedID = &ctxUserID } if ctx.FormBool("reviewed") { - issuesOpt.ReviewedID = ctxUserID + searchOpt.ReviewedID = &ctxUserID } + } - if issues, err = issues_model.Issues(ctx, issuesOpt); err != nil { - ctx.Error(http.StatusInternalServerError, "Issues", err.Error()) - return - } + // FIXME: It's unsupported to sort by priority repo when searching by indexer, + // it's indeed an regression, but I think it is worth to support filtering by indexer first. + _ = ctx.FormInt64("priority_repo_id") - issuesOpt.ListOptions = db.ListOptions{ - Page: -1, - } - if filteredCount, err = issues_model.CountIssues(ctx, issuesOpt); err != nil { - ctx.Error(http.StatusInternalServerError, "CountIssues", err.Error()) - return - } + ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchIssues", err.Error()) + return + } + issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err.Error()) + return } - ctx.SetTotalCountHeader(filteredCount) + ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, issues)) } @@ -2614,23 +2661,12 @@ func ListIssues(ctx *context.Context) { isClosed = util.OptionalBoolFalse } - var issues []*issues_model.Issue - var filteredCount int64 - keyword := ctx.FormTrim("q") if strings.IndexByte(keyword, 0) >= 0 { keyword = "" } - var issueIDs []int64 - var labelIDs []int64 - if len(keyword) > 0 { - issueIDs, err = issue_indexer.SearchIssuesByKeyword(ctx, []int64{ctx.Repo.Repository.ID}, keyword, ctx.FormString("state")) - if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - } + var labelIDs []int64 if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 { labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx.Repo.Repository.ID, splitted) if err != nil { @@ -2669,11 +2705,9 @@ func ListIssues(ctx *context.Context) { } } - projectID := ctx.FormInt64("project") - - listOptions := db.ListOptions{ - Page: ctx.FormInt("page"), - PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + var projectID *int64 + if v := ctx.FormInt64("project"); v > 0 { + projectID = &v } var isPull util.OptionalBool @@ -2700,40 +2734,64 @@ func ListIssues(ctx *context.Context) { return } - // Only fetch the issues if we either don't have a keyword or the search returned issues - // This would otherwise return all issues if no issues were found by the search. - if len(keyword) == 0 || len(issueIDs) > 0 || len(labelIDs) > 0 { - issuesOpt := &issues_model.IssuesOptions{ - ListOptions: listOptions, - RepoIDs: []int64{ctx.Repo.Repository.ID}, - IsClosed: isClosed, - IssueIDs: issueIDs, - LabelIDs: labelIDs, - MilestoneIDs: mileIDs, - ProjectID: projectID, - IsPull: isPull, - UpdatedBeforeUnix: before, - UpdatedAfterUnix: since, - PosterID: createdByID, - AssigneeID: assignedByID, - MentionedID: mentionedByID, + searchOpt := &issue_indexer.SearchOptions{ + Paginator: &db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + Keyword: keyword, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + IsPull: isPull, + IsClosed: isClosed, + ProjectBoardID: projectID, + SortBy: issue_indexer.SortByCreatedDesc, + } + if since != 0 { + searchOpt.UpdatedAfterUnix = &since + } + if before != 0 { + searchOpt.UpdatedBeforeUnix = &before + } + if len(labelIDs) == 1 && labelIDs[0] == 0 { + searchOpt.NoLabelOnly = true + } else { + for _, labelID := range labelIDs { + if labelID > 0 { + searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID) + } else { + searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID) + } } + } - if issues, err = issues_model.Issues(ctx, issuesOpt); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } + if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID { + searchOpt.MilestoneIDs = []int64{0} + } else { + searchOpt.MilestoneIDs = mileIDs + } - issuesOpt.ListOptions = db.ListOptions{ - Page: -1, - } - if filteredCount, err = issues_model.CountIssues(ctx, issuesOpt); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } + if createdByID > 0 { + searchOpt.PosterID = &createdByID + } + if assignedByID > 0 { + searchOpt.AssigneeID = &assignedByID + } + if mentionedByID > 0 { + searchOpt.MentionID = &mentionedByID + } + + ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchIssues", err.Error()) + return + } + issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err.Error()) + return } - ctx.SetTotalCountHeader(filteredCount) + ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, issues)) } diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 237e53413f767..d76e90bf248b8 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -469,7 +469,7 @@ func PrepareMergedViewPullInfo(ctx *context.Context, issue *issues_model.Issue) if len(compareInfo.Commits) != 0 { sha := compareInfo.Commits[0].ID.String() - commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, sha, db.ListOptions{}) + commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, sha, db.ListOptions{ListAll: true}) if err != nil { ctx.ServerError("GetLatestCommitStatus", err) return nil @@ -531,7 +531,7 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C ctx.ServerError(fmt.Sprintf("GetRefCommitID(%s)", pull.GetGitRefName()), err) return nil } - commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptions{}) + commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptions{ListAll: true}) if err != nil { ctx.ServerError("GetLatestCommitStatus", err) return nil @@ -623,7 +623,7 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C return nil } - commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptions{}) + commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptions{ListAll: true}) if err != nil { ctx.ServerError("GetLatestCommitStatus", err) return nil @@ -694,6 +694,42 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C return compareInfo } +type pullCommitList struct { + Commits []pull_service.CommitInfo `json:"commits"` + LastReviewCommitSha string `json:"last_review_commit_sha"` + Locale map[string]string `json:"locale"` +} + +// GetPullCommits get all commits for given pull request +func GetPullCommits(ctx *context.Context) { + issue := checkPullInfo(ctx) + if ctx.Written() { + return + } + resp := &pullCommitList{} + + commits, lastReviewCommitSha, err := pull_service.GetPullCommits(ctx, issue) + if err != nil { + ctx.JSON(http.StatusInternalServerError, err) + return + } + + // Get the needed locale + resp.Locale = map[string]string{ + "lang": ctx.Locale.Language(), + "filter_changes_by_commit": ctx.Tr("repo.pulls.filter_changes_by_commit"), + "show_all_commits": ctx.Tr("repo.pulls.show_all_commits"), + "stats_num_commits": ctx.TrN(len(commits), "repo.activity.git_stats_commit_1", "repo.activity.git_stats_commit_n", len(commits)), + "show_changes_since_your_last_review": ctx.Tr("repo.pulls.show_changes_since_your_last_review"), + "select_commit_hold_shift_for_range": ctx.Tr("repo.pulls.select_commit_hold_shift_for_range"), + } + + resp.Commits = commits + resp.LastReviewCommitSha = lastReviewCommitSha + + ctx.JSON(http.StatusOK, resp) +} + // ViewPullCommits show commits for a pull request func ViewPullCommits(ctx *context.Context) { ctx.Data["PageIsPullList"] = true @@ -739,7 +775,7 @@ func ViewPullCommits(ctx *context.Context) { } // ViewPullFiles render pull request changed files list page -func ViewPullFiles(ctx *context.Context) { +func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommit string, willShowSpecifiedCommitRange, willShowSpecifiedCommit bool) { ctx.Data["PageIsPullList"] = true ctx.Data["PageIsPullFiles"] = true @@ -762,6 +798,33 @@ func ViewPullFiles(ctx *context.Context) { prInfo = PrepareViewPullInfo(ctx, issue) } + // Validate the given commit sha to show (if any passed) + if willShowSpecifiedCommit || willShowSpecifiedCommitRange { + + foundStartCommit := len(specifiedStartCommit) == 0 + foundEndCommit := len(specifiedEndCommit) == 0 + + if !(foundStartCommit && foundEndCommit) { + for _, commit := range prInfo.Commits { + if commit.ID.String() == specifiedStartCommit { + foundStartCommit = true + } + if commit.ID.String() == specifiedEndCommit { + foundEndCommit = true + } + + if foundStartCommit && foundEndCommit { + break + } + } + } + + if !(foundStartCommit && foundEndCommit) { + ctx.NotFound("Given SHA1 not found for this PR", nil) + return + } + } + if ctx.Written() { return } else if prInfo == nil { @@ -775,12 +838,30 @@ func ViewPullFiles(ctx *context.Context) { return } - startCommitID = prInfo.MergeBase - endCommitID = headCommitID + ctx.Data["IsShowingOnlySingleCommit"] = willShowSpecifiedCommit + + if willShowSpecifiedCommit || willShowSpecifiedCommitRange { + if len(specifiedEndCommit) > 0 { + endCommitID = specifiedEndCommit + } else { + endCommitID = headCommitID + } + if len(specifiedStartCommit) > 0 { + startCommitID = specifiedStartCommit + } else { + startCommitID = prInfo.MergeBase + } + ctx.Data["IsShowingAllCommits"] = false + } else { + endCommitID = headCommitID + startCommitID = prInfo.MergeBase + ctx.Data["IsShowingAllCommits"] = true + } ctx.Data["Username"] = ctx.Repo.Owner.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name ctx.Data["AfterCommitID"] = endCommitID + ctx.Data["BeforeCommitID"] = startCommitID fileOnly := ctx.FormBool("file-only") @@ -789,8 +870,8 @@ func ViewPullFiles(ctx *context.Context) { if fileOnly && (len(files) == 2 || len(files) == 1) { maxLines, maxFiles = -1, -1 } + diffOptions := &gitdiff.DiffOptions{ - BeforeCommitID: startCommitID, AfterCommitID: endCommitID, SkipTo: ctx.FormString("skip-to"), MaxLines: maxLines, @@ -799,9 +880,18 @@ func ViewPullFiles(ctx *context.Context) { WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)), } + if !willShowSpecifiedCommit { + diffOptions.BeforeCommitID = startCommitID + } + var methodWithError string var diff *gitdiff.Diff - if !ctx.IsSigned { + + // if we're not logged in or only a single commit (or commit range) is shown we + // have to load only the diff and not get the viewed information + // as the viewed information is designed to be loaded only on latest PR + // diff and if you're signed in. + if !ctx.IsSigned || willShowSpecifiedCommit || willShowSpecifiedCommitRange { diff, err = gitdiff.GetDiff(gitRepo, diffOptions, files...) methodWithError = "GetDiff" } else { @@ -908,6 +998,22 @@ func ViewPullFiles(ctx *context.Context) { ctx.HTML(http.StatusOK, tplPullFiles) } +func ViewPullFilesForSingleCommit(ctx *context.Context) { + viewPullFiles(ctx, "", ctx.Params("sha"), true, true) +} + +func ViewPullFilesForRange(ctx *context.Context) { + viewPullFiles(ctx, ctx.Params("shaFrom"), ctx.Params("shaTo"), true, false) +} + +func ViewPullFilesStartingFromCommit(ctx *context.Context) { + viewPullFiles(ctx, "", ctx.Params("sha"), true, false) +} + +func ViewPullFilesForAllCommitsOfPr(ctx *context.Context) { + viewPullFiles(ctx, "", "", false, false) +} + // UpdatePullRequest merge PR's baseBranch into headBranch func UpdatePullRequest(ctx *context.Context) { issue := checkPullInfo(ctx) diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 221c1f4c4f62b..9e6b3e7825763 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -9,6 +9,7 @@ import ( gocontext "context" "encoding/base64" "fmt" + "image" "io" "net/http" "net/url" @@ -16,6 +17,10 @@ import ( "strings" "time" + _ "image/gif" // for processing gif images + _ "image/jpeg" // for processing jpeg images + _ "image/png" // for processing png images + activities_model "code.gitea.io/gitea/models/activities" admin_model "code.gitea.io/gitea/models/admin" asymkey_model "code.gitea.io/gitea/models/asymkey" @@ -44,6 +49,9 @@ import ( issue_service "code.gitea.io/gitea/services/issue" "github.com/nektos/act/pkg/model" + + _ "golang.org/x/image/bmp" // for processing bmp images + _ "golang.org/x/image/webp" // for processing webp images ) const ( @@ -578,6 +586,15 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st } } + if fInfo.st.IsImage() && !fInfo.st.IsSvgImage() { + img, _, err := image.DecodeConfig(bytes.NewReader(buf)) + if err == nil { + // There are Image formats go can't decode + // Instead of throwing an error in that case, we show the size only when we can decode + ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height) + } + } + if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { ctx.Data["CanDeleteFile"] = false @@ -842,7 +859,7 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri ctx.Data["LatestCommitVerification"] = verification ctx.Data["LatestCommitUser"] = user_model.ValidateCommitWithEmail(ctx, latestCommit) - statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptions{}) + statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptions{ListAll: true}) if err != nil { log.Error("GetLatestCommitStatus: %v", err) } diff --git a/routers/web/user/home.go b/routers/web/user/home.go index 5f1e0eb4277b8..77974a84a242e 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/context" issue_indexer "code.gitea.io/gitea/modules/indexer/issues" "code.gitea.io/gitea/modules/json" @@ -466,21 +467,16 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { keyword := strings.Trim(ctx.FormString("q"), " ") ctx.Data["Keyword"] = keyword - // Execute keyword search for issues. - // USING NON-FINAL STATE OF opts FOR A QUERY. - issueIDsFromSearch, err := issueIDsFromSearch(ctx, ctxUser, keyword, opts) - if err != nil { - ctx.ServerError("issueIDsFromSearch", err) - return - } - - // Ensure no issues are returned if a keyword was provided that didn't match any issues. - var forceEmpty bool - - if len(issueIDsFromSearch) > 0 { - opts.IssueIDs = issueIDsFromSearch - } else if len(keyword) > 0 { - forceEmpty = true + 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. @@ -490,12 +486,21 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // 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 - if !forceEmpty { - issueCountByRepo, err = issues_model.CountIssuesByRepo(ctx, opts) + { + issueIDs, err := issueIDsFromSearch(ctx, keyword, opts) if err != nil { - ctx.ServerError("CountIssuesByRepo", err) + 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. @@ -503,14 +508,17 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { if page <= 1 { page = 1 } - opts.Page = page - opts.PageSize = setting.UI.IssuePagingNum + opts.Paginator = &db.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + } // Get IDs for labels (a filter option for issues/pulls). // Required for IssuesOptions. var labelIDs []int64 selectedLabels := ctx.FormString("labels") if len(selectedLabels) > 0 && selectedLabels != "0" { + var err error labelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ",")) if err != nil { ctx.ServerError("StringsToInt64s", err) @@ -521,7 +529,16 @@ 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. - opts.RepoIDs = getRepoIDs(ctx.FormString("repos")) + repoIDs := getRepoIDs(ctx.FormString("repos")) + if len(repoIDs) == 0 { + repoIDs = accessibleRepos.Values() + } else { + // 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 // ------------------------------ // Get issues as defined by opts. @@ -529,15 +546,18 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // Slice of Issues that will be displayed on the overview page // USING FINAL STATE OF opts FOR A QUERY. - var issues []*issues_model.Issue - if !forceEmpty { - issues, err = issues_model.Issues(ctx, opts) + var issues issues_model.IssueList + { + issueIDs, err := issueIDsFromSearch(ctx, keyword, opts) if err != nil { - ctx.ServerError("Issues", err) + ctx.ServerError("issueIDsFromSearch", err) + return + } + issues, err = issues_model.GetIssuesByIDs(ctx, issueIDs, true) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) return } - } else { - issues = []*issues_model.Issue{} } // ---------------------------------- @@ -576,12 +596,12 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // Fill stats to post to ctx.Data. // ------------------------------- var issueStats *issues_model.IssueStats - if !forceEmpty { + { statsOpts := issues_model.IssuesOptions{ User: ctx.Doer, IsPull: util.OptionalBoolOf(isPullList), IsClosed: util.OptionalBoolOf(isShowClosed), - IssueIDs: issueIDsFromSearch, + IssueIDs: nil, IsArchived: util.OptionalBoolFalse, LabelIDs: opts.LabelIDs, Org: org, @@ -589,13 +609,29 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { RepoCond: opts.RepoCond, } - issueStats, err = issues_model.GetUserIssueStats(filterMode, statsOpts) - if err != nil { - ctx.ServerError("GetUserIssueStats Shown", err) - return + 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 + } } - } else { - issueStats = &issues_model.IssueStats{} } // Will be posted to ctx.Data. @@ -629,9 +665,13 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.FormString("RepoLink")) + if err := issues.LoadAttributes(ctx); err != nil { + ctx.ServerError("issues.LoadAttributes", err) + return + } ctx.Data["Issues"] = issues - approvalCounts, err := issues_model.IssueList(issues).GetApprovalCounts(ctx) + approvalCounts, err := issues.GetApprovalCounts(ctx) if err != nil { ctx.ServerError("ApprovalCounts", err) return @@ -716,21 +756,12 @@ func getRepoIDs(reposQuery string) []int64 { return repoIDs } -func issueIDsFromSearch(ctx *context.Context, ctxUser *user_model.User, keyword string, opts *issues_model.IssuesOptions) ([]int64, error) { - if len(keyword) == 0 { - return []int64{}, nil - } - - searchRepoIDs, err := issues_model.GetRepoIDsForIssuesOptions(opts, ctxUser) +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("GetRepoIDsForIssuesOptions: %w", err) + return nil, fmt.Errorf("SearchIssues: %w", err) } - issueIDsFromSearch, err := issue_indexer.SearchIssuesByKeyword(ctx, searchRepoIDs, keyword, ctx.FormString("state")) - if err != nil { - return nil, fmt.Errorf("SearchIssuesByKeyword: %w", err) - } - - return issueIDsFromSearch, nil + return ids, nil } func loadRepoByIDs(ctxUser *user_model.User, issueCountByRepo map[int64]int64, unitType unit.Type) (map[int64]*repo_model.Repository, error) { diff --git a/routers/web/user/home_test.go b/routers/web/user/home_test.go index 3a06a38c2450d..634a91545e9cf 100644 --- a/routers/web/user/home_test.go +++ b/routers/web/user/home_test.go @@ -75,7 +75,7 @@ func TestPulls(t *testing.T) { Pulls(ctx) assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) - assert.Len(t, ctx.Data["Issues"], 4) + assert.Len(t, ctx.Data["Issues"], 5) } func TestMilestones(t *testing.T) { diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go index cae12f4126775..60ae628445a93 100644 --- a/routers/web/user/notification.go +++ b/routers/web/user/notification.go @@ -263,7 +263,7 @@ func NotificationSubscriptions(ctx *context.Context) { return } issues, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ - ListOptions: db.ListOptions{ + Paginator: &db.ListOptions{ PageSize: setting.UI.IssuePagingNum, Page: page, }, diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 15a9197b980e8..7f6e41d30f264 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -52,6 +52,7 @@ func userProfile(ctx *context.Context) { ctx.Data["Title"] = ctx.ContextUser.DisplayName() ctx.Data["PageIsUserProfile"] = true + ctx.Data["UserLocationMapURL"] = setting.Service.UserLocationMapURL // prepare heatmap data if setting.Service.EnableUserHeatmap { diff --git a/routers/web/web.go b/routers/web/web.go index 0b519614453a7..ca75bd5967271 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -254,9 +254,10 @@ func registerRoutes(m *web.Route) { } } - reqUnitAccess := func(unitType unit.Type, accessMode perm.AccessMode) func(ctx *context.Context) { + reqUnitAccess := func(unitType unit.Type, accessMode perm.AccessMode, ignoreGlobal bool) func(ctx *context.Context) { return func(ctx *context.Context) { - if unitType.UnitGlobalDisabled() { + // only check global disabled units when ignoreGlobal is false + if !ignoreGlobal && unitType.UnitGlobalDisabled() { ctx.NotFound(unitType.String(), nil) return } @@ -832,7 +833,7 @@ func registerRoutes(m *web.Route) { m.Group("", func() { m.Get("", org.Projects) m.Get("/{id}", org.ViewProject) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead)) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) m.Group("", func() { //nolint:dupl m.Get("/new", org.RenderNewProject) m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) @@ -853,17 +854,17 @@ func registerRoutes(m *web.Route) { m.Post("/move", org.MoveIssues) }) }) - }, reqSignIn, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite), func(ctx *context.Context) { + }, reqSignIn, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.Context) { if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { ctx.NotFound("NewProject", nil) return } }) - }, repo.MustEnableProjects) + }) m.Group("", func() { m.Get("/code", user.CodeSearch) - }, reqUnitAccess(unit.TypeCode, perm.AccessModeRead)) + }, reqUnitAccess(unit.TypeCode, perm.AccessModeRead, false)) }, ignSignIn, context_service.UserAssignmentWeb(), context.OrgAssignment()) // for "/{username}/-" (packages, projects, code) // ***** Release Attachment Download without Signin @@ -1279,14 +1280,20 @@ func registerRoutes(m *web.Route) { m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue) m.Get(".diff", repo.DownloadPullDiff) m.Get(".patch", repo.DownloadPullPatch) - m.Get("/commits", context.RepoRef(), repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewPullCommits) + m.Group("/commits", func() { + m.Get("", context.RepoRef(), repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewPullCommits) + m.Get("/list", context.RepoRef(), repo.GetPullCommits) + m.Get("/{sha:[a-f0-9]{7,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForSingleCommit) + }) m.Post("/merge", context.RepoMustNotBeArchived(), web.Bind(forms.MergePullRequestForm{}), repo.MergePullRequest) m.Post("/cancel_auto_merge", context.RepoMustNotBeArchived(), repo.CancelAutoMergePullRequest) m.Post("/update", repo.UpdatePullRequest) m.Post("/set_allow_maintainer_edit", web.Bind(forms.UpdateAllowEditsForm{}), repo.SetAllowEdits) m.Post("/cleanup", context.RepoMustNotBeArchived(), context.RepoRef(), repo.CleanUpPullRequest) m.Group("/files", func() { - m.Get("", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFiles) + m.Get("", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForAllCommitsOfPr) + m.Get("/{sha:[a-f0-9]{7,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesStartingFromCommit) + m.Get("/{shaFrom:[a-f0-9]{7,40}}..{shaTo:[a-f0-9]{7,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForRange) m.Group("/reviews", func() { m.Get("/new_comment", repo.RenderNewCodeCommentForm) m.Post("/comments", web.Bind(forms.CodeCommentForm{}), repo.SetShowOutdatedComments, repo.CreateCodeComment) diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index 925d0a33968aa..08a7dde67c85b 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -75,7 +75,7 @@ func createCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) er } ctxname := fmt.Sprintf("%s / %s (%s)", runName, job.Name, event) state := toCommitStatus(job.Status) - if statuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptions{}); err == nil { + if statuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptions{ListAll: true}); err == nil { for _, v := range statuses { if v.Context == ctxname { if v.State == state { diff --git a/services/convert/user.go b/services/convert/user.go index 79fcba0176f9a..3521dd2f905c3 100644 --- a/services/convert/user.go +++ b/services/convert/user.go @@ -51,7 +51,7 @@ func toUser(ctx context.Context, user *user_model.User, signed, authed bool) *ap ID: user.ID, UserName: user.Name, FullName: user.FullName, - Email: user.GetEmail(), + Email: user.GetPlaceholderEmail(), AvatarURL: user.AvatarLink(ctx), Created: user.CreatedUnix.AsTime(), Restricted: user.IsRestricted, diff --git a/services/pull/commit_status.go b/services/pull/commit_status.go index 51ba06da27586..39d60380ff202 100644 --- a/services/pull/commit_status.go +++ b/services/pull/commit_status.go @@ -143,7 +143,7 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullR return "", errors.Wrap(err, "LoadBaseRepo") } - commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptions{}) + commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptions{ListAll: true}) if err != nil { return "", errors.Wrap(err, "GetLatestCommitStatus") } diff --git a/services/pull/pull.go b/services/pull/pull.go index 3c6caec8825a8..cf49d2fe206ca 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -10,6 +10,7 @@ import ( "os" "regexp" "strings" + "time" "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" @@ -17,7 +18,9 @@ import ( issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/container" + gitea_context "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/json" @@ -801,7 +804,7 @@ func getAllCommitStatus(gitRepo *git.Repository, pr *issues_model.PullRequest) ( return nil, nil, shaErr } - statuses, _, err = git_model.GetLatestCommitStatus(db.DefaultContext, pr.BaseRepo.ID, sha, db.ListOptions{}) + statuses, _, err = git_model.GetLatestCommitStatus(db.DefaultContext, pr.BaseRepo.ID, sha, db.ListOptions{ListAll: true}) lastStatus = git_model.CalcCommitStatus(statuses) return statuses, lastStatus, err } @@ -856,3 +859,71 @@ func IsHeadEqualWithBranch(ctx context.Context, pr *issues_model.PullRequest, br } return baseCommit.HasPreviousCommit(headCommit.ID) } + +type CommitInfo struct { + Summary string `json:"summary"` + CommitterOrAuthorName string `json:"committer_or_author_name"` + ID string `json:"id"` + ShortSha string `json:"short_sha"` + Time string `json:"time"` +} + +// GetPullCommits returns all commits on given pull request and the last review commit sha +func GetPullCommits(ctx *gitea_context.Context, issue *issues_model.Issue) ([]CommitInfo, string, error) { + pull := issue.PullRequest + + baseGitRepo := ctx.Repo.GitRepo + + if err := pull.LoadBaseRepo(ctx); err != nil { + return nil, "", err + } + baseBranch := pull.BaseBranch + if pull.HasMerged { + baseBranch = pull.MergeBase + } + prInfo, err := baseGitRepo.GetCompareInfo(pull.BaseRepo.RepoPath(), baseBranch, pull.GetGitRefName(), true, false) + if err != nil { + return nil, "", err + } + + commits := make([]CommitInfo, 0, len(prInfo.Commits)) + + for _, commit := range prInfo.Commits { + var committerOrAuthorName string + var commitTime time.Time + if commit.Committer != nil { + committerOrAuthorName = commit.Committer.Name + commitTime = commit.Committer.When + } else { + committerOrAuthorName = commit.Author.Name + commitTime = commit.Author.When + } + + commits = append(commits, CommitInfo{ + Summary: commit.Summary(), + CommitterOrAuthorName: committerOrAuthorName, + ID: commit.ID.String(), + ShortSha: base.ShortSha(commit.ID.String()), + Time: commitTime.Format(time.RFC3339), + }) + } + + var lastReviewCommitID string + if ctx.IsSigned { + // get last review of current user and store information in context (if available) + lastreview, err := issues_model.FindLatestReviews(ctx, issues_model.FindReviewOptions{ + IssueID: issue.ID, + ReviewerID: ctx.Doer.ID, + Type: issues_model.ReviewTypeUnknown, + }) + + if err != nil && !issues_model.IsErrReviewNotExist(err) { + return nil, "", err + } + if len(lastreview) > 0 { + lastReviewCommitID = lastreview[0].CommitID + } + } + + return commits, lastReviewCommitID, nil +} diff --git a/services/webhook/webhook.go b/services/webhook/webhook.go index 3cd9deafd82f7..9d5dab85f7977 100644 --- a/services/webhook/webhook.go +++ b/services/webhook/webhook.go @@ -5,6 +5,7 @@ package webhook import ( "context" + "errors" "fmt" "strings" @@ -111,7 +112,11 @@ func handler(items ...int64) []int64 { for _, taskID := range items { task, err := webhook_model.GetHookTaskByID(ctx, taskID) if err != nil { - log.Error("GetHookTaskByID[%d] failed: %v", taskID, err) + if errors.Is(err, util.ErrNotExist) { + log.Warn("GetHookTaskByID[%d] warn: %v", taskID, err) + } else { + log.Error("GetHookTaskByID[%d] failed: %v", taskID, err) + } continue } diff --git a/templates/admin/hook_new.tmpl b/templates/admin/hook_new.tmpl index 1abdd4c813a28..e72e7bba62088 100644 --- a/templates/admin/hook_new.tmpl +++ b/templates/admin/hook_new.tmpl @@ -11,29 +11,7 @@ {{.locale.Tr "admin.defaulthooks.update_webhook"}} {{end}}