mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-08-02 08:25:20 +02:00
feat: show more relevant results for 'dependencies' dropdown (#8003)
- Fix issue dropdown breaking when currently selected issue is included in results. - Add `sort` parameter to `/issues/search` API. - Sort dropdown by relevance. - Make priority_repo_id work again. - Added E2E test. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8003 Reviewed-by: Shiny Nematoda <snematoda@noreply.codeberg.org> Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: Danko Aleksejevs <danko@very.lv> Co-committed-by: Danko Aleksejevs <danko@very.lv>
This commit is contained in:
parent
414199fc66
commit
184e068f37
17 changed files with 269 additions and 41 deletions
|
@ -32,7 +32,7 @@
|
||||||
created_unix: 1731254961
|
created_unix: 1731254961
|
||||||
updated_unix: 1731254961
|
updated_unix: 1731254961
|
||||||
topics: '[]'
|
topics: '[]'
|
||||||
|
|
||||||
-
|
-
|
||||||
id: 2
|
id: 2
|
||||||
owner_id: 2
|
owner_id: 2
|
||||||
|
|
|
@ -48,7 +48,9 @@ type IssuesOptions struct { //nolint
|
||||||
UpdatedBeforeUnix int64
|
UpdatedBeforeUnix int64
|
||||||
// prioritize issues from this repo
|
// prioritize issues from this repo
|
||||||
PriorityRepoID int64
|
PriorityRepoID int64
|
||||||
IsArchived optional.Option[bool]
|
// if this issue index (not ID) exists and matches the filters, *and* priorityrepo sort is used, show it first
|
||||||
|
PriorityIssueIndex int64
|
||||||
|
IsArchived optional.Option[bool]
|
||||||
|
|
||||||
// If combined with AllPublic, then private as well as public issues
|
// If combined with AllPublic, then private as well as public issues
|
||||||
// that matches the criteria will be returned, if AllPublic is false
|
// that matches the criteria will be returned, if AllPublic is false
|
||||||
|
@ -60,7 +62,7 @@ type IssuesOptions struct { //nolint
|
||||||
|
|
||||||
// applySorts sort an issues-related session based on the provided
|
// applySorts sort an issues-related session based on the provided
|
||||||
// sortType string
|
// sortType string
|
||||||
func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) {
|
func applySorts(sess *xorm.Session, sortType string, priorityRepoID, priorityIssueIndex int64) {
|
||||||
switch sortType {
|
switch sortType {
|
||||||
case "oldest":
|
case "oldest":
|
||||||
sess.Asc("issue.created_unix").Asc("issue.id")
|
sess.Asc("issue.created_unix").Asc("issue.id")
|
||||||
|
@ -97,8 +99,11 @@ func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) {
|
||||||
case "priorityrepo":
|
case "priorityrepo":
|
||||||
sess.OrderBy("CASE "+
|
sess.OrderBy("CASE "+
|
||||||
"WHEN issue.repo_id = ? THEN 1 "+
|
"WHEN issue.repo_id = ? THEN 1 "+
|
||||||
"ELSE 2 END ASC", priorityRepoID).
|
"ELSE 2 END ASC", priorityRepoID)
|
||||||
Desc("issue.created_unix").
|
if priorityIssueIndex != 0 {
|
||||||
|
sess.OrderBy("issue.index = ? DESC", priorityIssueIndex)
|
||||||
|
}
|
||||||
|
sess.Desc("issue.created_unix").
|
||||||
Desc("issue.id")
|
Desc("issue.id")
|
||||||
case "project-column-sorting":
|
case "project-column-sorting":
|
||||||
sess.Asc("project_issue.sorting").Desc("issue.created_unix").Desc("issue.id")
|
sess.Asc("project_issue.sorting").Desc("issue.created_unix").Desc("issue.id")
|
||||||
|
@ -470,7 +475,7 @@ func Issues(ctx context.Context, opts *IssuesOptions) (IssueList, error) {
|
||||||
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
|
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
|
||||||
applyLimit(sess, opts)
|
applyLimit(sess, opts)
|
||||||
applyConditions(sess, opts)
|
applyConditions(sess, opts)
|
||||||
applySorts(sess, opts.SortType, opts.PriorityRepoID)
|
applySorts(sess, opts.SortType, opts.PriorityRepoID, opts.PriorityIssueIndex)
|
||||||
|
|
||||||
issues := IssueList{}
|
issues := IssueList{}
|
||||||
if err := sess.Find(&issues); err != nil {
|
if err := sess.Find(&issues); err != nil {
|
||||||
|
@ -494,7 +499,7 @@ func IssueIDs(ctx context.Context, opts *IssuesOptions, otherConds ...builder.Co
|
||||||
}
|
}
|
||||||
|
|
||||||
applyLimit(sess, opts)
|
applyLimit(sess, opts)
|
||||||
applySorts(sess, opts.SortType, opts.PriorityRepoID)
|
applySorts(sess, opts.SortType, opts.PriorityRepoID, opts.PriorityIssueIndex)
|
||||||
|
|
||||||
var res []int64
|
var res []int64
|
||||||
total, err := sess.Select("`issue`.id").Table(&Issue{}).FindAndCount(&res)
|
total, err := sess.Select("`issue`.id").Table(&Issue{}).FindAndCount(&res)
|
||||||
|
|
|
@ -149,7 +149,7 @@ func PullRequests(ctx context.Context, baseRepoID int64, opts *PullRequestsOptio
|
||||||
}
|
}
|
||||||
|
|
||||||
findSession := listPullRequestStatement(ctx, baseRepoID, opts)
|
findSession := listPullRequestStatement(ctx, baseRepoID, opts)
|
||||||
applySorts(findSession, opts.SortType, 0)
|
applySorts(findSession, opts.SortType, 0, 0)
|
||||||
findSession = db.SetSessionPagination(findSession, opts)
|
findSession = db.SetSessionPagination(findSession, opts)
|
||||||
prs := make([]*PullRequest, 0, opts.PageSize)
|
prs := make([]*PullRequest, 0, opts.PageSize)
|
||||||
found := findSession.Find(&prs)
|
found := findSession.Find(&prs)
|
||||||
|
|
|
@ -170,7 +170,7 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
||||||
|
|
||||||
if issueID, err := token.ParseIssueReference(); err == nil {
|
if issueID, err := token.ParseIssueReference(); err == nil {
|
||||||
idQuery := inner_bleve.NumericEqualityQuery(issueID, "index")
|
idQuery := inner_bleve.NumericEqualityQuery(issueID, "index")
|
||||||
idQuery.SetBoost(5.0)
|
idQuery.SetBoost(20.0)
|
||||||
innerQ.AddQuery(idQuery)
|
innerQ.AddQuery(idQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -197,6 +197,15 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
||||||
queries = append(queries, bleve.NewDisjunctionQuery(repoQueries...))
|
queries = append(queries, bleve.NewDisjunctionQuery(repoQueries...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if options.PriorityRepoID.Has() {
|
||||||
|
eq := inner_bleve.NumericEqualityQuery(options.PriorityRepoID.Value(), "repo_id")
|
||||||
|
eq.SetBoost(10.0)
|
||||||
|
meh := bleve.NewMatchAllQuery()
|
||||||
|
meh.SetBoost(0)
|
||||||
|
should := bleve.NewDisjunctionQuery(eq, meh)
|
||||||
|
queries = append(queries, should)
|
||||||
|
}
|
||||||
|
|
||||||
if options.IsPull.Has() {
|
if options.IsPull.Has() {
|
||||||
queries = append(queries, inner_bleve.BoolFieldQuery(options.IsPull.Value(), "is_pull"))
|
queries = append(queries, inner_bleve.BoolFieldQuery(options.IsPull.Value(), "is_pull"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,6 +53,7 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
||||||
|
|
||||||
cond := builder.NewCond()
|
cond := builder.NewCond()
|
||||||
|
|
||||||
|
var priorityIssueIndex int64
|
||||||
if options.Keyword != "" {
|
if options.Keyword != "" {
|
||||||
repoCond := builder.In("repo_id", options.RepoIDs)
|
repoCond := builder.In("repo_id", options.RepoIDs)
|
||||||
if len(options.RepoIDs) == 1 {
|
if len(options.RepoIDs) == 1 {
|
||||||
|
@ -82,6 +83,7 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
||||||
builder.Eq{"`index`": issueID},
|
builder.Eq{"`index`": issueID},
|
||||||
cond,
|
cond,
|
||||||
)
|
)
|
||||||
|
priorityIssueIndex = issueID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -89,6 +91,7 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
opt.PriorityIssueIndex = priorityIssueIndex
|
||||||
|
|
||||||
// If pagesize == 0, return total count only. It's a special case for search count.
|
// If pagesize == 0, return total count only. It's a special case for search count.
|
||||||
if options.Paginator != nil && options.Paginator.PageSize == 0 {
|
if options.Paginator != nil && options.Paginator.PageSize == 0 {
|
||||||
|
|
|
@ -78,6 +78,11 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
|
||||||
User: nil,
|
User: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if options.PriorityRepoID.Has() {
|
||||||
|
opts.SortType = "priorityrepo"
|
||||||
|
opts.PriorityRepoID = options.PriorityRepoID.Value()
|
||||||
|
}
|
||||||
|
|
||||||
if len(options.MilestoneIDs) == 1 && options.MilestoneIDs[0] == 0 {
|
if len(options.MilestoneIDs) == 1 && options.MilestoneIDs[0] == 0 {
|
||||||
opts.MilestoneIDs = []int64{db.NoConditionID}
|
opts.MilestoneIDs = []int64{db.NoConditionID}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -165,7 +165,7 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
||||||
}
|
}
|
||||||
var eitherQ elastic.Query = innerQ
|
var eitherQ elastic.Query = innerQ
|
||||||
if issueID, err := token.ParseIssueReference(); err == nil {
|
if issueID, err := token.ParseIssueReference(); err == nil {
|
||||||
indexQ := elastic.NewTermQuery("index", issueID).Boost(15.0)
|
indexQ := elastic.NewTermQuery("index", issueID).Boost(20)
|
||||||
eitherQ = elastic.NewDisMaxQuery().Query(indexQ).Query(innerQ).TieBreaker(0.5)
|
eitherQ = elastic.NewDisMaxQuery().Query(indexQ).Query(innerQ).TieBreaker(0.5)
|
||||||
}
|
}
|
||||||
switch token.Kind {
|
switch token.Kind {
|
||||||
|
@ -188,6 +188,10 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
||||||
}
|
}
|
||||||
query.Must(q)
|
query.Must(q)
|
||||||
}
|
}
|
||||||
|
if options.PriorityRepoID.Has() {
|
||||||
|
q := elastic.NewTermQuery("repo_id", options.PriorityRepoID.Value()).Boost(10)
|
||||||
|
query.Should(q)
|
||||||
|
}
|
||||||
|
|
||||||
if options.IsPull.Has() {
|
if options.IsPull.Has() {
|
||||||
query.Must(elastic.NewTermQuery("is_pull", options.IsPull.Value()))
|
query.Must(elastic.NewTermQuery("is_pull", options.IsPull.Value()))
|
||||||
|
|
|
@ -75,8 +75,9 @@ type SearchResult struct {
|
||||||
type SearchOptions struct {
|
type SearchOptions struct {
|
||||||
Keyword string // keyword to search
|
Keyword string // keyword to search
|
||||||
|
|
||||||
RepoIDs []int64 // repository IDs which the issues belong to
|
RepoIDs []int64 // repository IDs which the issues belong to
|
||||||
AllPublic bool // if include all public repositories
|
AllPublic bool // if include all public repositories
|
||||||
|
PriorityRepoID optional.Option[int64] // issues from this repository will be prioritized when SortByScore
|
||||||
|
|
||||||
IsPull optional.Option[bool] // if the issues is a pull request
|
IsPull optional.Option[bool] // if the issues is a pull request
|
||||||
IsClosed optional.Option[bool] // if the issues is closed
|
IsClosed optional.Option[bool] // if the issues is closed
|
||||||
|
|
|
@ -742,6 +742,25 @@ var cases = []*testIndexerCase{
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "PriorityRepoID",
|
||||||
|
SearchOptions: &internal.SearchOptions{
|
||||||
|
IsPull: optional.Some(false),
|
||||||
|
IsClosed: optional.Some(false),
|
||||||
|
PriorityRepoID: optional.Some(int64(3)),
|
||||||
|
Paginator: &db.ListOptionsAll,
|
||||||
|
SortBy: internal.SortByScore,
|
||||||
|
},
|
||||||
|
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
|
||||||
|
for i, v := range result.Hits {
|
||||||
|
if i < 7 {
|
||||||
|
assert.Equal(t, int64(3), data[v.ID].RepoID)
|
||||||
|
} else {
|
||||||
|
assert.NotEqual(t, int64(3), data[v.ID].RepoID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
type testIndexerCase struct {
|
type testIndexerCase struct {
|
||||||
|
|
|
@ -121,6 +121,12 @@ func SearchIssues(ctx *context.APIContext) {
|
||||||
// description: Number of items per page
|
// description: Number of items per page
|
||||||
// type: integer
|
// type: integer
|
||||||
// minimum: 0
|
// minimum: 0
|
||||||
|
// - name: sort
|
||||||
|
// in: query
|
||||||
|
// description: Type of sort
|
||||||
|
// type: string
|
||||||
|
// enum: [relevance, latest, oldest, recentupdate, leastupdate, mostcomment, leastcomment, nearduedate, farduedate]
|
||||||
|
// default: latest
|
||||||
// responses:
|
// responses:
|
||||||
// "200":
|
// "200":
|
||||||
// "$ref": "#/responses/IssueList"
|
// "$ref": "#/responses/IssueList"
|
||||||
|
@ -276,7 +282,7 @@ func SearchIssues(ctx *context.APIContext) {
|
||||||
IsClosed: isClosed,
|
IsClosed: isClosed,
|
||||||
IncludedAnyLabelIDs: includedAnyLabels,
|
IncludedAnyLabelIDs: includedAnyLabels,
|
||||||
MilestoneIDs: includedMilestones,
|
MilestoneIDs: includedMilestones,
|
||||||
SortBy: issue_indexer.SortByCreatedDesc,
|
SortBy: issue_indexer.ParseSortBy(ctx.FormString("sort"), issue_indexer.SortByCreatedDesc),
|
||||||
}
|
}
|
||||||
|
|
||||||
if since != 0 {
|
if since != 0 {
|
||||||
|
@ -305,9 +311,10 @@ func SearchIssues(ctx *context.APIContext) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: It's unsupported to sort by priority repo when searching by indexer,
|
priorityRepoID := ctx.FormInt64("priority_repo_id")
|
||||||
// it's indeed an regression, but I think it is worth to support filtering by indexer first.
|
if priorityRepoID > 0 {
|
||||||
_ = ctx.FormInt64("priority_repo_id")
|
searchOpt.PriorityRepoID = optional.Some(priorityRepoID)
|
||||||
|
}
|
||||||
|
|
||||||
ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt)
|
ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -2775,7 +2775,7 @@ func SearchIssues(ctx *context.Context) {
|
||||||
IncludedAnyLabelIDs: includedAnyLabels,
|
IncludedAnyLabelIDs: includedAnyLabels,
|
||||||
MilestoneIDs: includedMilestones,
|
MilestoneIDs: includedMilestones,
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
SortBy: issue_indexer.SortByCreatedDesc,
|
SortBy: issue_indexer.ParseSortBy(ctx.FormString("sort"), issue_indexer.SortByCreatedDesc),
|
||||||
}
|
}
|
||||||
|
|
||||||
if since != 0 {
|
if since != 0 {
|
||||||
|
@ -2804,9 +2804,10 @@ func SearchIssues(ctx *context.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: It's unsupported to sort by priority repo when searching by indexer,
|
priorityRepoID := ctx.FormInt64("priority_repo_id")
|
||||||
// it's indeed an regression, but I think it is worth to support filtering by indexer first.
|
if priorityRepoID > 0 {
|
||||||
_ = ctx.FormInt64("priority_repo_id")
|
searchOpt.PriorityRepoID = optional.Some(priorityRepoID)
|
||||||
|
}
|
||||||
|
|
||||||
ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt)
|
ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -2944,7 +2945,7 @@ func ListIssues(ctx *context.Context) {
|
||||||
IsPull: isPull,
|
IsPull: isPull,
|
||||||
IsClosed: isClosed,
|
IsClosed: isClosed,
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
SortBy: issue_indexer.SortByCreatedDesc,
|
SortBy: issue_indexer.ParseSortBy(ctx.FormString("sort"), issue_indexer.SortByCreatedDesc),
|
||||||
}
|
}
|
||||||
if since != 0 {
|
if since != 0 {
|
||||||
searchOpt.UpdatedAfterUnix = optional.Some(since)
|
searchOpt.UpdatedAfterUnix = optional.Some(since)
|
||||||
|
|
18
templates/swagger/v1_json.tmpl
generated
18
templates/swagger/v1_json.tmpl
generated
|
@ -4524,6 +4524,24 @@
|
||||||
"description": "Number of items per page",
|
"description": "Number of items per page",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"relevance",
|
||||||
|
"latest",
|
||||||
|
"oldest",
|
||||||
|
"recentupdate",
|
||||||
|
"leastupdate",
|
||||||
|
"mostcomment",
|
||||||
|
"leastcomment",
|
||||||
|
"nearduedate",
|
||||||
|
"farduedate"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"default": "latest",
|
||||||
|
"description": "Type of sort",
|
||||||
|
"name": "sort",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
|
@ -9,16 +9,23 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"forgejo.org/models/db"
|
||||||
|
issues_model "forgejo.org/models/issues"
|
||||||
|
repo_model "forgejo.org/models/repo"
|
||||||
unit_model "forgejo.org/models/unit"
|
unit_model "forgejo.org/models/unit"
|
||||||
"forgejo.org/models/unittest"
|
"forgejo.org/models/unittest"
|
||||||
user_model "forgejo.org/models/user"
|
user_model "forgejo.org/models/user"
|
||||||
"forgejo.org/modules/git"
|
"forgejo.org/modules/git"
|
||||||
"forgejo.org/modules/indexer/stats"
|
"forgejo.org/modules/indexer/stats"
|
||||||
|
"forgejo.org/modules/optional"
|
||||||
|
"forgejo.org/modules/timeutil"
|
||||||
|
issue_service "forgejo.org/services/issue"
|
||||||
files_service "forgejo.org/services/repository/files"
|
files_service "forgejo.org/services/repository/files"
|
||||||
"forgejo.org/tests"
|
"forgejo.org/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"xorm.io/xorm/convert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// first entry represents filename
|
// first entry represents filename
|
||||||
|
@ -29,19 +36,34 @@ type FileChanges struct {
|
||||||
Versions []string
|
Versions []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// performs additional repo setup as needed
|
||||||
|
type SetupRepo func(*user_model.User, *repo_model.Repository)
|
||||||
|
|
||||||
// put your Git repo declarations in here
|
// put your Git repo declarations in here
|
||||||
// feel free to amend the helper function below or use the raw variant directly
|
// feel free to amend the helper function below or use the raw variant directly
|
||||||
func DeclareGitRepos(t *testing.T) func() {
|
func DeclareGitRepos(t *testing.T) func() {
|
||||||
|
now := timeutil.TimeStampNow()
|
||||||
|
postIssue := func(repo *repo_model.Repository, user *user_model.User, age int64, title, content string) {
|
||||||
|
issue := &issues_model.Issue{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
PosterID: user.ID,
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
CreatedUnix: now.Add(-age),
|
||||||
|
}
|
||||||
|
require.NoError(t, issue_service.NewIssue(db.DefaultContext, repo, issue, nil, nil, nil))
|
||||||
|
}
|
||||||
|
|
||||||
cleanupFunctions := []func(){
|
cleanupFunctions := []func(){
|
||||||
newRepo(t, 2, "diff-test", []FileChanges{{
|
newRepo(t, 2, "diff-test", nil, []FileChanges{{
|
||||||
Filename: "testfile",
|
Filename: "testfile",
|
||||||
Versions: []string{"hello", "hallo", "hola", "native", "ubuntu-latest", "- runs-on: ubuntu-latest", "- runs-on: debian-latest"},
|
Versions: []string{"hello", "hallo", "hola", "native", "ubuntu-latest", "- runs-on: ubuntu-latest", "- runs-on: debian-latest"},
|
||||||
}}),
|
}}, nil),
|
||||||
newRepo(t, 2, "language-stats-test", []FileChanges{{
|
newRepo(t, 2, "language-stats-test", nil, []FileChanges{{
|
||||||
Filename: "main.rs",
|
Filename: "main.rs",
|
||||||
Versions: []string{"fn main() {", "println!(\"Hello World!\");", "}"},
|
Versions: []string{"fn main() {", "println!(\"Hello World!\");", "}"},
|
||||||
}}),
|
}}, nil),
|
||||||
newRepo(t, 2, "mentions-highlighted", []FileChanges{
|
newRepo(t, 2, "mentions-highlighted", nil, []FileChanges{
|
||||||
{
|
{
|
||||||
Filename: "history1.md",
|
Filename: "history1.md",
|
||||||
Versions: []string{""},
|
Versions: []string{""},
|
||||||
|
@ -52,11 +74,34 @@ func DeclareGitRepos(t *testing.T) func() {
|
||||||
Versions: []string{""},
|
Versions: []string{""},
|
||||||
CommitMsg: "Another commit which mentions @user1 in the title\nand @user2 in the text",
|
CommitMsg: "Another commit which mentions @user1 in the title\nand @user2 in the text",
|
||||||
},
|
},
|
||||||
}),
|
}, nil),
|
||||||
newRepo(t, 2, "unicode-escaping", []FileChanges{{
|
newRepo(t, 2, "unicode-escaping", nil, []FileChanges{{
|
||||||
Filename: "a-file",
|
Filename: "a-file",
|
||||||
Versions: []string{"{a}{а}"},
|
Versions: []string{"{a}{а}"},
|
||||||
}}),
|
}}, nil),
|
||||||
|
newRepo(t, 11, "dependency-test", &tests.DeclarativeRepoOptions{
|
||||||
|
UnitConfig: optional.Some(map[unit_model.Type]convert.Conversion{
|
||||||
|
unit_model.TypeIssues: &repo_model.IssuesConfig{
|
||||||
|
EnableDependencies: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}, []FileChanges{}, func(user *user_model.User, repo *repo_model.Repository) {
|
||||||
|
postIssue(repo, user, 500, "first issue here", "an issue created earlier")
|
||||||
|
postIssue(repo, user, 400, "second issue here (not 1)", "not the right issue, but in the right repo")
|
||||||
|
postIssue(repo, user, 300, "third issue here", "depends on things")
|
||||||
|
postIssue(repo, user, 200, "unrelated issue", "shrug emoji")
|
||||||
|
postIssue(repo, user, 100, "newest issue", "very new")
|
||||||
|
}),
|
||||||
|
newRepo(t, 11, "dependency-test-2", &tests.DeclarativeRepoOptions{
|
||||||
|
UnitConfig: optional.Some(map[unit_model.Type]convert.Conversion{
|
||||||
|
unit_model.TypeIssues: &repo_model.IssuesConfig{
|
||||||
|
EnableDependencies: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}, []FileChanges{}, func(user *user_model.User, repo *repo_model.Repository) {
|
||||||
|
postIssue(repo, user, 450, "right issue", "an issue containing word right")
|
||||||
|
postIssue(repo, user, 150, "left issue", "an issue containing word left")
|
||||||
|
}),
|
||||||
// add your repo declarations here
|
// add your repo declarations here
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,12 +112,18 @@ func DeclareGitRepos(t *testing.T) func() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRepo(t *testing.T, userID int64, repoName string, fileChanges []FileChanges) func() {
|
func newRepo(t *testing.T, userID int64, repoName string, initOpts *tests.DeclarativeRepoOptions, fileChanges []FileChanges, setup SetupRepo) func() {
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
|
||||||
somerepo, _, cleanupFunc := tests.CreateDeclarativeRepo(t, user, repoName,
|
|
||||||
[]unit_model.Type{unit_model.TypeCode, unit_model.TypeIssues}, nil,
|
opts := tests.DeclarativeRepoOptions{}
|
||||||
nil,
|
if initOpts != nil {
|
||||||
)
|
opts = *initOpts
|
||||||
|
}
|
||||||
|
opts.Name = optional.Some(repoName)
|
||||||
|
if !opts.EnabledUnits.Has() {
|
||||||
|
opts.EnabledUnits = optional.Some([]unit_model.Type{unit_model.TypeCode, unit_model.TypeIssues})
|
||||||
|
}
|
||||||
|
somerepo, _, cleanupFunc := tests.CreateDeclarativeRepoWithOptions(t, user, opts)
|
||||||
|
|
||||||
var lastCommitID string
|
var lastCommitID string
|
||||||
for _, file := range fileChanges {
|
for _, file := range fileChanges {
|
||||||
|
@ -118,6 +169,10 @@ func newRepo(t *testing.T, userID int64, repoName string, fileChanges []FileChan
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if setup != nil {
|
||||||
|
setup(user, somerepo)
|
||||||
|
}
|
||||||
|
|
||||||
err := stats.UpdateRepoIndexer(somerepo)
|
err := stats.UpdateRepoIndexer(somerepo)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
|
@ -262,3 +262,91 @@ test('New Issue: Milestone', async ({page}, workerInfo) => {
|
||||||
await expect(selectedMilestone).toContainText('No milestone');
|
await expect(selectedMilestone).toContainText('No milestone');
|
||||||
await save_visual(page);
|
await save_visual(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Dependency dropdown', () => {
|
||||||
|
test.use({user: 'user11'});
|
||||||
|
test('Issue: Dependencies', async ({page}) => {
|
||||||
|
const response = await page.goto('/user11/dependency-test/issues/3');
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
|
||||||
|
const depsBlock = page.locator('.issue-content-right .depending');
|
||||||
|
const deleteDepBtn = page.locator('.issue-content-right .depending .delete-dependency-button');
|
||||||
|
|
||||||
|
const input = page.locator('#new-dependency-drop-list .search');
|
||||||
|
const current = page.locator('#new-dependency-drop-list .text').first();
|
||||||
|
const menu = page.locator('#new-dependency-drop-list .menu');
|
||||||
|
const items = page.locator('#new-dependency-drop-list .menu .item');
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
const modal = page.locator('.modal.remove-dependency');
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
await expect(modal).toContainText('This will remove the dependency from this issue');
|
||||||
|
await modal.locator('button.ok').click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// A kludge to set the dropdown to the *wrong* value so it lets us select the correct one next.
|
||||||
|
const resetDropdown = async () => {
|
||||||
|
if (await current.textContent().then((s) => s.includes('#4'))) return;
|
||||||
|
await input.click();
|
||||||
|
await input.fill('unrelated');
|
||||||
|
await expect(items.first()).toContainText('unrelated');
|
||||||
|
await items.first().click();
|
||||||
|
await expect(current).toContainText('#4');
|
||||||
|
await input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(depsBlock).toBeVisible();
|
||||||
|
while (await deleteDepBtn.first().isVisible()) {
|
||||||
|
await deleteDepBtn.first().click(); // wipe added dependencies from any previously failed tests
|
||||||
|
await confirmDelete();
|
||||||
|
}
|
||||||
|
await expect(depsBlock).toContainText('No dependencies set');
|
||||||
|
|
||||||
|
await input.scrollIntoViewIfNeeded();
|
||||||
|
await input.click();
|
||||||
|
|
||||||
|
const first = 'first issue here';
|
||||||
|
const second = 'second issue here';
|
||||||
|
const newest = 'newest issue';
|
||||||
|
|
||||||
|
// Without query, it should show issues in the same repo, sorted by date, except current one.
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
await expect(items).toHaveCount(4); // 5 issues in this repo, minus current one
|
||||||
|
await expect(items.first()).toContainText(newest);
|
||||||
|
await expect(items.last()).toContainText(first);
|
||||||
|
await resetDropdown();
|
||||||
|
|
||||||
|
// With query, it should search all repos, but show current repo issues first.
|
||||||
|
await input.fill('right');
|
||||||
|
await expect(items.first()).toContainText(second);
|
||||||
|
await expect.poll(() => items.count()).toBeGreaterThan(1); // there is an issue in user11/dependency-test-2 containing the word "right"
|
||||||
|
await resetDropdown();
|
||||||
|
|
||||||
|
// When entering an issue number, it should always show that one first, then all text matches.
|
||||||
|
await input.fill('1');
|
||||||
|
await expect(items.first()).toContainText(first);
|
||||||
|
await expect(items.nth(1)).toBeVisible();
|
||||||
|
await resetDropdown();
|
||||||
|
|
||||||
|
// Should behave the same with a prefix
|
||||||
|
await input.fill('#1');
|
||||||
|
await expect(items.first()).toContainText(first);
|
||||||
|
|
||||||
|
// Selecting an issue
|
||||||
|
await items.first().click();
|
||||||
|
await expect(current).toContainText(first);
|
||||||
|
|
||||||
|
// Add dependency
|
||||||
|
const link = page.locator('.issue-content-right .depending .dependency a.title');
|
||||||
|
await page.locator('.issue-content-right .depending button').click();
|
||||||
|
await expect(link).toHaveAttribute('href', '/user11/dependency-test/issues/1');
|
||||||
|
|
||||||
|
// Remove dependency
|
||||||
|
await expect(deleteDepBtn).toBeVisible();
|
||||||
|
await deleteDepBtn.click();
|
||||||
|
|
||||||
|
await confirmDelete();
|
||||||
|
|
||||||
|
await expect(depsBlock).toContainText('No dependencies set');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -93,6 +93,7 @@ func createSessions(t testing.TB) {
|
||||||
users := []string{
|
users := []string{
|
||||||
"user1",
|
"user1",
|
||||||
"user2",
|
"user2",
|
||||||
|
"user11",
|
||||||
"user12",
|
"user12",
|
||||||
"user18",
|
"user18",
|
||||||
"user29",
|
"user29",
|
||||||
|
|
|
@ -42,6 +42,7 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"xorm.io/xorm/convert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func exitf(format string, args ...any) {
|
func exitf(format string, args ...any) {
|
||||||
|
@ -342,6 +343,7 @@ type DeclarativeRepoOptions struct {
|
||||||
Name optional.Option[string]
|
Name optional.Option[string]
|
||||||
EnabledUnits optional.Option[[]unit_model.Type]
|
EnabledUnits optional.Option[[]unit_model.Type]
|
||||||
DisabledUnits optional.Option[[]unit_model.Type]
|
DisabledUnits optional.Option[[]unit_model.Type]
|
||||||
|
UnitConfig optional.Option[map[unit_model.Type]convert.Conversion]
|
||||||
Files optional.Option[[]*files_service.ChangeRepoFile]
|
Files optional.Option[[]*files_service.ChangeRepoFile]
|
||||||
WikiBranch optional.Option[string]
|
WikiBranch optional.Option[string]
|
||||||
AutoInit optional.Option[bool]
|
AutoInit optional.Option[bool]
|
||||||
|
@ -390,9 +392,14 @@ func CreateDeclarativeRepoWithOptions(t *testing.T, owner *user_model.User, opts
|
||||||
enabledUnits = make([]repo_model.RepoUnit, len(units))
|
enabledUnits = make([]repo_model.RepoUnit, len(units))
|
||||||
|
|
||||||
for i, unitType := range units {
|
for i, unitType := range units {
|
||||||
|
var config convert.Conversion
|
||||||
|
if cfg, ok := opts.UnitConfig.Value()[unitType]; ok {
|
||||||
|
config = cfg
|
||||||
|
}
|
||||||
enabledUnits[i] = repo_model.RepoUnit{
|
enabledUnits[i] = repo_model.RepoUnit{
|
||||||
RepoID: repo.ID,
|
RepoID: repo.ID,
|
||||||
Type: unitType,
|
Type: unitType,
|
||||||
|
Config: config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -125,16 +125,21 @@ function excludeLabel(item) {
|
||||||
export function initRepoIssueSidebarList() {
|
export function initRepoIssueSidebarList() {
|
||||||
const repolink = $('#repolink').val();
|
const repolink = $('#repolink').val();
|
||||||
const repoId = $('#repoId').val();
|
const repoId = $('#repoId').val();
|
||||||
const crossRepoSearch = $('#crossRepoSearch').val();
|
const crossRepoSearch = $('#crossRepoSearch').val() === 'true';
|
||||||
const tp = $('#type').val();
|
const tp = $('#type').val();
|
||||||
let issueSearchUrl = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}`;
|
|
||||||
if (crossRepoSearch === 'true') {
|
|
||||||
issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}`;
|
|
||||||
}
|
|
||||||
$('#new-dependency-drop-list')
|
$('#new-dependency-drop-list')
|
||||||
.dropdown({
|
.dropdown({
|
||||||
apiSettings: {
|
apiSettings: {
|
||||||
url: issueSearchUrl,
|
beforeSend(settings) {
|
||||||
|
if (!settings.urlData.query.trim()) {
|
||||||
|
settings.url = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}&sort=updated`;
|
||||||
|
} else if (crossRepoSearch) {
|
||||||
|
settings.url = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}&sort=relevance`;
|
||||||
|
} else {
|
||||||
|
settings.url = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}&sort=relevance`;
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
},
|
||||||
onResponse(response) {
|
onResponse(response) {
|
||||||
const filteredResponse = {success: true, results: []};
|
const filteredResponse = {success: true, results: []};
|
||||||
const currIssueId = $('#new-dependency-drop-list').data('issue-id');
|
const currIssueId = $('#new-dependency-drop-list').data('issue-id');
|
||||||
|
@ -142,7 +147,7 @@ export function initRepoIssueSidebarList() {
|
||||||
for (const [_, issue] of Object.entries(response)) {
|
for (const [_, issue] of Object.entries(response)) {
|
||||||
// Don't list current issue in the dependency list.
|
// Don't list current issue in the dependency list.
|
||||||
if (issue.id === currIssueId) {
|
if (issue.id === currIssueId) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
filteredResponse.results.push({
|
filteredResponse.results.push({
|
||||||
name: `#${issue.number} ${issueTitleHTML(htmlEscape(issue.title))
|
name: `#${issue.number} ${issueTitleHTML(htmlEscape(issue.title))
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue