diff --git a/custom/conf/app.ini.sample b/custom/conf/app.ini.sample index e6ccab95d95..33cd0506ed4 100644 --- a/custom/conf/app.ini.sample +++ b/custom/conf/app.ini.sample @@ -479,6 +479,8 @@ DEFAULT_ORG_MEMBER_VISIBLE = false ; Default value for EnableDependencies ; Repositories will use dependencies by default depending on this setting DEFAULT_ENABLE_DEPENDENCIES = true +; 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 ; Enable heatmap on users profiles. ENABLE_USER_HEATMAP = true ; Enable Timetracking diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index bcf871a3a4e..1e24255d8d7 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -297,6 +297,7 @@ relation to port exhaustion. - `RECAPTCHA_SITEKEY`: **""**: Go to https://www.google.com/recaptcha/admin to get a sitekey for recaptcha. - `RECAPTCHA_URL`: **https://www.google.com/recaptcha/**: Set the recaptcha url - allows the use of recaptcha net. - `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. - `ENABLE_USER_HEATMAP`: **true**: Enable this to display the heatmap on users profiles. - `EMAIL_DOMAIN_WHITELIST`: **\**: If non-empty, list of domain names that can only be used to register on this instance. diff --git a/models/issue.go b/models/issue.go index 17205cc2fa6..78413468b20 100644 --- a/models/issue.go +++ b/models/issue.go @@ -9,6 +9,7 @@ import ( "path" "regexp" "sort" + "strconv" "strings" "code.gitea.io/gitea/modules/base" @@ -378,6 +379,12 @@ func (issue *Issue) apiFormat(e Engine) *api.Issue { Updated: issue.UpdatedUnix.AsTime(), } + apiIssue.Repo = &api.RepositoryMeta{ + ID: issue.Repo.ID, + Name: issue.Repo.Name, + FullName: issue.Repo.FullName(), + } + if issue.ClosedUnix != 0 { apiIssue.Closed = issue.ClosedUnix.AsTimePtr() } @@ -1047,11 +1054,13 @@ type IssuesOptions struct { LabelIDs []int64 SortType string IssueIDs []int64 + // prioritize issues from this repo + PriorityRepoID int64 } // sortIssuesSession sort an issues-related session based on the provided // sortType string -func sortIssuesSession(sess *xorm.Session, sortType string) { +func sortIssuesSession(sess *xorm.Session, sortType string, priorityRepoID int64) { switch sortType { case "oldest": sess.Asc("issue.created_unix") @@ -1069,6 +1078,8 @@ func sortIssuesSession(sess *xorm.Session, sortType string) { sess.Asc("issue.deadline_unix") case "farduedate": sess.Desc("issue.deadline_unix") + case "priorityrepo": + sess.OrderBy("CASE WHEN issue.repo_id = " + strconv.FormatInt(priorityRepoID, 10) + " THEN 1 ELSE 2 END, issue.created_unix DESC") default: sess.Desc("issue.created_unix") } @@ -1170,7 +1181,7 @@ func Issues(opts *IssuesOptions) ([]*Issue, error) { defer sess.Close() opts.setupSession(sess) - sortIssuesSession(sess, opts.SortType) + sortIssuesSession(sess, opts.SortType, opts.PriorityRepoID) issues := make([]*Issue, 0, setting.UI.IssuePagingNum) if err := sess.Find(&issues); err != nil { @@ -1476,8 +1487,8 @@ func GetRepoIssueStats(repoID, uid int64, filterMode int, isPull bool) (numOpen } // SearchIssueIDsByKeyword search issues on database -func SearchIssueIDsByKeyword(kw string, repoID int64, limit, start int) (int64, []int64, error) { - var repoCond = builder.Eq{"repo_id": repoID} +func SearchIssueIDsByKeyword(kw string, repoIDs []int64, limit, start int) (int64, []int64, error) { + var repoCond = builder.In("repo_id", repoIDs) var subQuery = builder.Select("id").From("issue").Where(repoCond) var cond = builder.And( repoCond, @@ -1566,33 +1577,43 @@ func UpdateIssueDeadline(issue *Issue, deadlineUnix timeutil.TimeStamp, doer *Us return sess.Commit() } +// DependencyInfo represents high level information about an issue which is a dependency of another issue. +type DependencyInfo struct { + Issue `xorm:"extends"` + Repository `xorm:"extends"` +} + // Get Blocked By Dependencies, aka all issues this issue is blocked by. -func (issue *Issue) getBlockedByDependencies(e Engine) (issueDeps []*Issue, err error) { +func (issue *Issue) getBlockedByDependencies(e Engine) (issueDeps []*DependencyInfo, err error) { return issueDeps, e. - Table("issue_dependency"). - Select("issue.*"). - Join("INNER", "issue", "issue.id = issue_dependency.dependency_id"). + Table("issue"). + Join("INNER", "repository", "repository.id = issue.repo_id"). + Join("INNER", "issue_dependency", "issue_dependency.dependency_id = issue.id"). Where("issue_id = ?", issue.ID). + //sort by repo id then created date, with the issues of the same repo at the beginning of the list + OrderBy("CASE WHEN issue.repo_id = " + strconv.FormatInt(issue.RepoID, 10) + " THEN 0 ELSE issue.repo_id END, issue.created_unix DESC"). Find(&issueDeps) } // Get Blocking Dependencies, aka all issues this issue blocks. -func (issue *Issue) getBlockingDependencies(e Engine) (issueDeps []*Issue, err error) { +func (issue *Issue) getBlockingDependencies(e Engine) (issueDeps []*DependencyInfo, err error) { return issueDeps, e. - Table("issue_dependency"). - Select("issue.*"). - Join("INNER", "issue", "issue.id = issue_dependency.issue_id"). + Table("issue"). + Join("INNER", "repository", "repository.id = issue.repo_id"). + Join("INNER", "issue_dependency", "issue_dependency.issue_id = issue.id"). Where("dependency_id = ?", issue.ID). + //sort by repo id then created date, with the issues of the same repo at the beginning of the list + OrderBy("CASE WHEN issue.repo_id = " + strconv.FormatInt(issue.RepoID, 10) + " THEN 0 ELSE issue.repo_id END, issue.created_unix DESC"). Find(&issueDeps) } // BlockedByDependencies finds all Dependencies an issue is blocked by -func (issue *Issue) BlockedByDependencies() ([]*Issue, error) { +func (issue *Issue) BlockedByDependencies() ([]*DependencyInfo, error) { return issue.getBlockedByDependencies(x) } // BlockingDependencies returns all blocking dependencies, aka all other issues a given issue blocks -func (issue *Issue) BlockingDependencies() ([]*Issue, error) { +func (issue *Issue) BlockingDependencies() ([]*DependencyInfo, error) { return issue.getBlockingDependencies(x) } diff --git a/models/issue_label.go b/models/issue_label.go index 1fc873cfd44..497756af5bb 100644 --- a/models/issue_label.go +++ b/models/issue_label.go @@ -250,6 +250,19 @@ func GetLabelIDsInRepoByNames(repoID int64, labelNames []string) ([]int64, error Find(&labelIDs) } +// GetLabelIDsInReposByNames returns a list of labelIDs by names in one of the given +// repositories. +// it silently ignores label names that do not belong to the repository. +func GetLabelIDsInReposByNames(repoIDs []int64, labelNames []string) ([]int64, error) { + labelIDs := make([]int64, 0, len(labelNames)) + return labelIDs, x.Table("label"). + In("repo_id", repoIDs). + In("name", labelNames). + Asc("name"). + Cols("id"). + Find(&labelIDs) +} + // GetLabelInRepoByID returns a label by ID in given repository. func GetLabelInRepoByID(repoID, labelID int64) (*Label, error) { return getLabelInRepoByID(x, repoID, labelID) diff --git a/models/issue_test.go b/models/issue_test.go index 592a0e3d777..d16c1de25d7 100644 --- a/models/issue_test.go +++ b/models/issue_test.go @@ -264,24 +264,23 @@ func TestIssue_loadTotalTimes(t *testing.T) { func TestIssue_SearchIssueIDsByKeyword(t *testing.T) { assert.NoError(t, PrepareTestDatabase()) - - total, ids, err := SearchIssueIDsByKeyword("issue2", 1, 10, 0) + total, ids, err := SearchIssueIDsByKeyword("issue2", []int64{1}, 10, 0) assert.NoError(t, err) assert.EqualValues(t, 1, total) assert.EqualValues(t, []int64{2}, ids) - total, ids, err = SearchIssueIDsByKeyword("first", 1, 10, 0) + total, ids, err = SearchIssueIDsByKeyword("first", []int64{1}, 10, 0) assert.NoError(t, err) assert.EqualValues(t, 1, total) assert.EqualValues(t, []int64{1}, ids) - total, ids, err = SearchIssueIDsByKeyword("for", 1, 10, 0) + total, ids, err = SearchIssueIDsByKeyword("for", []int64{1}, 10, 0) assert.NoError(t, err) assert.EqualValues(t, 4, total) assert.EqualValues(t, []int64{1, 2, 3, 5}, ids) // issue1's comment id 2 - total, ids, err = SearchIssueIDsByKeyword("good", 1, 10, 0) + total, ids, err = SearchIssueIDsByKeyword("good", []int64{1}, 10, 0) assert.NoError(t, err) assert.EqualValues(t, 1, total) assert.EqualValues(t, []int64{1}, ids) diff --git a/models/pull_list.go b/models/pull_list.go index 4ec6fdde3b5..2c2f53f4a15 100644 --- a/models/pull_list.go +++ b/models/pull_list.go @@ -87,7 +87,7 @@ func PullRequests(baseRepoID int64, opts *PullRequestsOptions) ([]*PullRequest, prs := make([]*PullRequest, 0, ItemsPerPage) findSession, err := listPullRequestStatement(baseRepoID, opts) - sortIssuesSession(findSession, opts.SortType) + sortIssuesSession(findSession, opts.SortType, 0) if err != nil { log.Error("listPullRequestStatement: %v", err) return nil, maxResults, err diff --git a/modules/indexer/issues/bleve.go b/modules/indexer/issues/bleve.go index 36279198b86..24443e54a34 100644 --- a/modules/indexer/issues/bleve.go +++ b/modules/indexer/issues/bleve.go @@ -218,9 +218,18 @@ func (b *BleveIndexer) Delete(ids ...int64) error { // Search searches for issues by given conditions. // Returns the matching issue IDs -func (b *BleveIndexer) Search(keyword string, repoID int64, limit, start int) (*SearchResult, error) { +func (b *BleveIndexer) Search(keyword string, repoIDs []int64, limit, start int) (*SearchResult, error) { + var repoQueriesP []*query.NumericRangeQuery + for _, repoID := range repoIDs { + repoQueriesP = append(repoQueriesP, numericEqualityQuery(repoID, "RepoID")) + } + repoQueries := make([]query.Query, len(repoQueriesP)) + for i, v := range repoQueriesP { + repoQueries[i] = query.Query(v) + } + indexerQuery := bleve.NewConjunctionQuery( - numericEqualityQuery(repoID, "RepoID"), + bleve.NewDisjunctionQuery(repoQueries...), bleve.NewDisjunctionQuery( newMatchPhraseQuery(keyword, "Title", issueIndexerAnalyzer), newMatchPhraseQuery(keyword, "Content", issueIndexerAnalyzer), @@ -242,8 +251,7 @@ func (b *BleveIndexer) Search(keyword string, repoID int64, limit, start int) (* return nil, err } ret.Hits = append(ret.Hits, Match{ - ID: id, - RepoID: repoID, + ID: id, }) } return &ret, nil diff --git a/modules/indexer/issues/bleve_test.go b/modules/indexer/issues/bleve_test.go index 8ec274566fa..94d935d89da 100644 --- a/modules/indexer/issues/bleve_test.go +++ b/modules/indexer/issues/bleve_test.go @@ -76,7 +76,7 @@ func TestBleveIndexAndSearch(t *testing.T) { ) for _, kw := range keywords { - res, err := indexer.Search(kw.Keyword, 2, 10, 0) + res, err := indexer.Search(kw.Keyword, []int64{2}, 10, 0) assert.NoError(t, err) var ids = make([]int64, 0, len(res.Hits)) diff --git a/modules/indexer/issues/db.go b/modules/indexer/issues/db.go index 6e7f0c1a6e1..7d4e3894712 100644 --- a/modules/indexer/issues/db.go +++ b/modules/indexer/issues/db.go @@ -26,8 +26,8 @@ func (db *DBIndexer) Delete(ids ...int64) error { } // Search dummy function -func (db *DBIndexer) Search(kw string, repoID int64, limit, start int) (*SearchResult, error) { - total, ids, err := models.SearchIssueIDsByKeyword(kw, repoID, limit, start) +func (db *DBIndexer) Search(kw string, repoIDs []int64, limit, start int) (*SearchResult, error) { + total, ids, err := models.SearchIssueIDsByKeyword(kw, repoIDs, limit, start) if err != nil { return nil, err } @@ -37,8 +37,7 @@ func (db *DBIndexer) Search(kw string, repoID int64, limit, start int) (*SearchR } for _, id := range ids { result.Hits = append(result.Hits, Match{ - ID: id, - RepoID: repoID, + ID: id, }) } return &result, nil diff --git a/modules/indexer/issues/indexer.go b/modules/indexer/issues/indexer.go index 4f410daf4cb..76da46d7591 100644 --- a/modules/indexer/issues/indexer.go +++ b/modules/indexer/issues/indexer.go @@ -28,9 +28,8 @@ type IndexerData struct { // Match represents on search result type Match struct { - ID int64 `json:"id"` - RepoID int64 `json:"repo_id"` - Score float64 `json:"score"` + ID int64 `json:"id"` + Score float64 `json:"score"` } // SearchResult represents search results @@ -44,7 +43,7 @@ type Indexer interface { Init() (bool, error) Index(issue []*IndexerData) error Delete(ids ...int64) error - Search(kw string, repoID int64, limit, start int) (*SearchResult, error) + Search(kw string, repoIDs []int64, limit, start int) (*SearchResult, error) } type indexerHolder struct { @@ -262,9 +261,9 @@ func DeleteRepoIssueIndexer(repo *models.Repository) { } // SearchIssuesByKeyword search issue ids by keywords and repo id -func SearchIssuesByKeyword(repoID int64, keyword string) ([]int64, error) { +func SearchIssuesByKeyword(repoIDs []int64, keyword string) ([]int64, error) { var issueIDs []int64 - res, err := holder.get().Search(keyword, repoID, 1000, 0) + res, err := holder.get().Search(keyword, repoIDs, 1000, 0) if err != nil { return nil, err } diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go index 212c2edfbe1..a45fede9ac0 100644 --- a/modules/indexer/issues/indexer_test.go +++ b/modules/indexer/issues/indexer_test.go @@ -30,19 +30,19 @@ func TestBleveSearchIssues(t *testing.T) { time.Sleep(5 * time.Second) - ids, err := SearchIssuesByKeyword(1, "issue2") + ids, err := SearchIssuesByKeyword([]int64{1}, "issue2") assert.NoError(t, err) assert.EqualValues(t, []int64{2}, ids) - ids, err = SearchIssuesByKeyword(1, "first") + ids, err = SearchIssuesByKeyword([]int64{1}, "first") assert.NoError(t, err) assert.EqualValues(t, []int64{1}, ids) - ids, err = SearchIssuesByKeyword(1, "for") + ids, err = SearchIssuesByKeyword([]int64{1}, "for") assert.NoError(t, err) assert.EqualValues(t, []int64{1, 2, 3, 5}, ids) - ids, err = SearchIssuesByKeyword(1, "good") + ids, err = SearchIssuesByKeyword([]int64{1}, "good") assert.NoError(t, err) assert.EqualValues(t, []int64{1}, ids) } @@ -53,19 +53,19 @@ func TestDBSearchIssues(t *testing.T) { setting.Indexer.IssueType = "db" InitIssueIndexer(true) - ids, err := SearchIssuesByKeyword(1, "issue2") + ids, err := SearchIssuesByKeyword([]int64{1}, "issue2") assert.NoError(t, err) assert.EqualValues(t, []int64{2}, ids) - ids, err = SearchIssuesByKeyword(1, "first") + ids, err = SearchIssuesByKeyword([]int64{1}, "first") assert.NoError(t, err) assert.EqualValues(t, []int64{1}, ids) - ids, err = SearchIssuesByKeyword(1, "for") + ids, err = SearchIssuesByKeyword([]int64{1}, "for") assert.NoError(t, err) assert.EqualValues(t, []int64{1, 2, 3, 5}, ids) - ids, err = SearchIssuesByKeyword(1, "good") + ids, err = SearchIssuesByKeyword([]int64{1}, "good") assert.NoError(t, err) assert.EqualValues(t, []int64{1}, ids) } diff --git a/modules/setting/service.go b/modules/setting/service.go index dea4081ee87..93629100a2e 100644 --- a/modules/setting/service.go +++ b/modules/setting/service.go @@ -39,6 +39,7 @@ var Service struct { EnableTimetracking bool DefaultEnableTimetracking bool DefaultEnableDependencies bool + AllowCrossRepositoryDependencies bool DefaultAllowOnlyContributorsToTrackTime bool NoReplyAddress string EnableUserHeatmap bool @@ -79,6 +80,7 @@ func newService() { Service.DefaultEnableTimetracking = sec.Key("DEFAULT_ENABLE_TIMETRACKING").MustBool(true) } Service.DefaultEnableDependencies = sec.Key("DEFAULT_ENABLE_DEPENDENCIES").MustBool(true) + 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.example.org") Service.EnableUserHeatmap = sec.Key("ENABLE_USER_HEATMAP").MustBool(true) diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 58fd7344b4f..bd39f9ea444 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -26,6 +26,13 @@ type PullRequestMeta struct { Merged *time.Time `json:"merged_at"` } +// RepositoryMeta basic repository information +type RepositoryMeta struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` +} + // Issue represents an issue in a repository // swagger:model type Issue struct { @@ -57,6 +64,7 @@ type Issue struct { Deadline *time.Time `json:"due_date"` PullRequest *PullRequestMeta `json:"pull_request"` + Repo *RepositoryMeta `json:"repository"` } // ListIssueOption list issue options diff --git a/public/css/index.css b/public/css/index.css index dca2d6f0b6f..f7eb02b2968 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -78,6 +78,7 @@ a{cursor:pointer} .ui.form .ui.button{font-weight:400} .ui.floating.label{z-index:10} .ui.transparent.label{background-color:transparent} +.ui.nopadding{padding:0} .ui.menu,.ui.segment,.ui.vertical.menu{box-shadow:none} .ui .menu:not(.vertical) .item>.button.compact{padding:.58928571em 1.125em} .ui .menu:not(.vertical) .item>.button.small{font-size:.92857143rem} @@ -109,6 +110,8 @@ a{cursor:pointer} .ui .text.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block} .ui .text.thin{font-weight:400} .ui .text.middle{vertical-align:middle} +.ui .text.nopadding{padding:0} +.ui .text.nomargin{margin:0} .ui .message{text-align:center} .ui.bottom.attached.message{font-weight:700;text-align:left;color:#000} .ui.bottom.attached.message .pull-right{color:#000} diff --git a/public/js/index.js b/public/js/index.js index e76e993a1d8..bfcf36f5286 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -3254,10 +3254,16 @@ function deleteDependencyModal(id, type) { function initIssueList() { const repolink = $('#repolink').val(); + const repoId = $('#repoId').val(); + const crossRepoSearch = $('#crossRepoSearch').val(); + let issueSearchUrl = suburl + '/api/v1/repos/' + repolink + '/issues?q={query}'; + if (crossRepoSearch === 'true') { + issueSearchUrl = suburl + '/api/v1/repos/issues/search?q={query}&priority_repo_id=' + repoId; + } $('#new-dependency-drop-list') .dropdown({ apiSettings: { - url: suburl + '/api/v1/repos/' + repolink + '/issues?q={query}', + url: issueSearchUrl, onResponse: function(response) { const filteredResponse = {'success': true, 'results': []}; const currIssueId = $('#new-dependency-drop-list').data('issue-id'); @@ -3268,7 +3274,8 @@ function initIssueList() { return; } filteredResponse.results.push({ - 'name' : '#' + issue.number + ' ' + htmlEncode(issue.title), + 'name' : '#' + issue.number + ' ' + htmlEncode(issue.title) + + '
' + htmlEncode(issue.repository.full_name) + '
', 'value' : issue.id }); }); diff --git a/public/less/_base.less b/public/less/_base.less index 7fcfaf82eab..8bf49b1ef90 100644 --- a/public/less/_base.less +++ b/public/less/_base.less @@ -321,6 +321,10 @@ code, background-color: transparent; } + &.nopadding { + padding: 0; + } + &.menu, &.vertical.menu, &.segment { @@ -453,6 +457,14 @@ code, &.middle { vertical-align: middle; } + + &.nopadding { + padding: 0; + } + + &.nomargin { + margin: 0; + } } .message { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f8ab9025b78..1acd849b8d8 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -596,6 +596,8 @@ func RegisterRoutes(m *macaron.Macaron) { m.Get("/search", repo.Search) }) + m.Get("/repos/issues/search", repo.SearchIssues) + m.Combo("/repositories/:id", reqToken()).Get(repo.GetByID) m.Group("/repos", func() { diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 426826653c1..fe5862ea5e4 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/context" 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" "code.gitea.io/gitea/modules/timeutil" @@ -22,6 +23,137 @@ import ( milestone_service "code.gitea.io/gitea/services/milestone" ) +// SearchIssues searches for issues across the repositories that the user has access to +func SearchIssues(ctx *context.APIContext) { + // swagger:operation GET /repos/issues/search issue issueSearchIssues + // --- + // summary: Search for issues across the repositories that the user has access to + // produces: + // - application/json + // parameters: + // - name: state + // in: query + // description: whether issue is open or closed + // type: string + // - name: labels + // in: query + // description: comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded + // type: string + // - name: page + // in: query + // description: page number of requested issues + // type: integer + // - name: q + // in: query + // description: search string + // type: string + // - name: priority_repo_id + // in: query + // description: repository to prioritize in the results + // type: integer + // format: int64 + // responses: + // "200": + // "$ref": "#/responses/IssueList" + var isClosed util.OptionalBool + switch ctx.Query("state") { + case "closed": + isClosed = util.OptionalBoolTrue + case "all": + isClosed = util.OptionalBoolNone + default: + isClosed = util.OptionalBoolFalse + } + + // find repos user can access (for issue search) + repoIDs := make([]int64, 0) + issueCount := 0 + for page := 1; ; page++ { + repos, count, err := models.SearchRepositoryByName(&models.SearchRepoOptions{ + Page: page, + PageSize: 15, + Private: true, + Keyword: "", + OwnerID: ctx.User.ID, + TopicOnly: false, + Collaborate: util.OptionalBoolNone, + UserIsAdmin: ctx.IsUserSiteAdmin(), + UserID: ctx.User.ID, + OrderBy: models.SearchOrderByRecentUpdated, + }) + if err != nil { + ctx.Error(500, "SearchRepositoryByName", err) + return + } + + if len(repos) == 0 { + break + } + log.Trace("Processing next %d repos of %d", len(repos), count) + for _, repo := range repos { + switch isClosed { + case util.OptionalBoolTrue: + issueCount += repo.NumClosedIssues + case util.OptionalBoolFalse: + issueCount += repo.NumOpenIssues + case util.OptionalBoolNone: + issueCount += repo.NumIssues + } + repoIDs = append(repoIDs, repo.ID) + } + } + + var issues []*models.Issue + + keyword := strings.Trim(ctx.Query("q"), " ") + if strings.IndexByte(keyword, 0) >= 0 { + keyword = "" + } + var issueIDs []int64 + var labelIDs []int64 + var err error + if len(keyword) > 0 && len(repoIDs) > 0 { + issueIDs, err = issue_indexer.SearchIssuesByKeyword(repoIDs, keyword) + } + + labels := ctx.Query("labels") + if splitted := strings.Split(labels, ","); labels != "" && len(splitted) > 0 { + labelIDs, err = models.GetLabelIDsInReposByNames(repoIDs, splitted) + if err != nil { + ctx.Error(500, "GetLabelIDsInRepoByNames", err) + 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 { + issues, err = models.Issues(&models.IssuesOptions{ + RepoIDs: repoIDs, + Page: ctx.QueryInt("page"), + PageSize: setting.UI.IssuePagingNum, + IsClosed: isClosed, + IssueIDs: issueIDs, + LabelIDs: labelIDs, + SortType: "priorityrepo", + PriorityRepoID: ctx.QueryInt64("priority_repo_id"), + }) + } + + if err != nil { + ctx.Error(500, "Issues", err) + return + } + + apiIssues := make([]*api.Issue, len(issues)) + for i := range issues { + apiIssues[i] = issues[i].APIFormat() + } + + ctx.SetLinkHeader(issueCount, setting.UI.IssuePagingNum) + ctx.JSON(200, &apiIssues) +} + // ListIssues list the issues of a repository func ListIssues(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/issues issue issueListIssues @@ -79,7 +211,7 @@ func ListIssues(ctx *context.APIContext) { var labelIDs []int64 var err error if len(keyword) > 0 { - issueIDs, err = issue_indexer.SearchIssuesByKeyword(ctx.Repo.Repository.ID, keyword) + issueIDs, err = issue_indexer.SearchIssuesByKeyword([]int64{ctx.Repo.Repository.ID}, keyword) } if splitted := strings.Split(ctx.Query("labels"), ","); len(splitted) > 0 { diff --git a/routers/repo/issue.go b/routers/repo/issue.go index 04c718d5b95..9a691471d54 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -149,7 +149,7 @@ func issues(ctx *context.Context, milestoneID int64, isPullOption util.OptionalB var issueIDs []int64 if len(keyword) > 0 { - issueIDs, err = issue_indexer.SearchIssuesByKeyword(repo.ID, keyword) + issueIDs, err = issue_indexer.SearchIssuesByKeyword([]int64{repo.ID}, keyword) if err != nil { ctx.ServerError("issueIndexer.Search", err) return @@ -778,6 +778,9 @@ func ViewIssue(ctx *context.Context) { // Check if the user can use the dependencies ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx.User) + // check if dependencies can be created across repositories + ctx.Data["AllowCrossRepositoryDependencies"] = setting.Service.AllowCrossRepositoryDependencies + // Render comments and and fetch participants. participants[0] = issue.Poster for _, comment = range issue.Comments { diff --git a/routers/repo/issue_dependency.go b/routers/repo/issue_dependency.go index 730271126d9..6b11f0cdf15 100644 --- a/routers/repo/issue_dependency.go +++ b/routers/repo/issue_dependency.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" ) // AddDependency adds new dependencies @@ -39,14 +40,14 @@ func AddDependency(ctx *context.Context) { return } - // Check if both issues are in the same repo - if issue.RepoID != dep.RepoID { + // Check if both issues are in the same repo if cross repository dependencies is not enabled + if issue.RepoID != dep.RepoID && !setting.Service.AllowCrossRepositoryDependencies { ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_same_repo")) return } // Check if issue and dependency is the same - if dep.Index == issueIndex { + if dep.ID == issue.ID { ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_same_issue")) return } diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index c42d8aff7fe..637d4ad04ae 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -274,14 +274,15 @@
{{range .BlockingDependencies}} -
-
#{{.Index}}
- {{.Title}} -
+
+ #{{.Issue.Index}} + {{.Issue.Title}} +
{{.Repository.OwnerName}}/{{.Repository.Name}}
+
{{if and $.CanCreateIssueDependencies (not $.Repository.IsArchived)}} - - + + {{end}}
@@ -300,14 +301,15 @@
{{range .BlockedByDependencies}} -
-
#{{.Index}}
- {{.Title}} -
- {{if and $.CanCreateIssueDependencies (not $.IsArchived)}} - - +
+ #{{.Issue.Index}} + {{.Issue.Title}} +
{{.Repository.OwnerName}}/{{.Repository.Name}}
+
+ {{if and $.CanCreateIssueDependencies (not $.Repository.IsArchived)}} + + {{end}}
@@ -424,6 +426,8 @@
{{if and .CanCreateIssueDependencies (not .Repository.IsArchived)}} + + diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 5be36d23be9..da7ebda8523 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1111,6 +1111,56 @@ } } }, + "/repos/issues/search": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Search for issues across the repositories that the user has access to", + "operationId": "issueSearchIssues", + "parameters": [ + { + "type": "string", + "description": "whether issue is open or closed", + "name": "state", + "in": "query" + }, + { + "type": "string", + "description": "comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded", + "name": "labels", + "in": "query" + }, + { + "type": "integer", + "description": "page number of requested issues", + "name": "page", + "in": "query" + }, + { + "type": "string", + "description": "search string", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "repository to prioritize in the results", + "name": "priority_repo_id", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/IssueList" + } + } + } + }, "/repos/migrate": { "post": { "consumes": [ @@ -9199,6 +9249,9 @@ "pull_request": { "$ref": "#/definitions/PullRequestMeta" }, + "repository": { + "$ref": "#/definitions/RepositoryMeta" + }, "state": { "$ref": "#/definitions/StateType" }, @@ -10095,6 +10148,26 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "RepositoryMeta": { + "description": "RepositoryMeta basic repository information", + "type": "object", + "properties": { + "full_name": { + "type": "string", + "x-go-name": "FullName" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "SearchResults": { "description": "SearchResults results of a successful search", "type": "object",