diff --git a/models/issues/issue.go b/models/issues/issue.go index d6a0029638..60f8d44617 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -17,6 +17,7 @@ import ( user_model "forgejo.org/models/user" "forgejo.org/modules/container" "forgejo.org/modules/log" + "forgejo.org/modules/optional" "forgejo.org/modules/setting" api "forgejo.org/modules/structs" "forgejo.org/modules/timeutil" @@ -521,6 +522,45 @@ func GetIssueByIndex(ctx context.Context, repoID, index int64) (*Issue, error) { return issue, nil } +func isPullToCond(isPull optional.Option[bool]) builder.Cond { + if isPull.Has() { + return builder.Eq{"is_pull": isPull.Value()} + } + return builder.NewCond() +} + +func FindLatestUpdatedIssues(ctx context.Context, repoID int64, isPull optional.Option[bool], pageSize int) (IssueList, error) { + issues := make([]*Issue, 0, pageSize) + err := db.GetEngine(ctx).Where("repo_id = ?", repoID). + And(isPullToCond(isPull)). + OrderBy("updated_unix DESC"). + Limit(pageSize). + Find(&issues) + return issues, err +} + +func FindIssuesSuggestionByKeyword(ctx context.Context, repoID int64, keyword string, isPull optional.Option[bool], excludedID int64, pageSize int) (IssueList, error) { + cond := builder.NewCond() + if excludedID > 0 { + cond = cond.And(builder.Neq{"`id`": excludedID}) + } + + // It seems that GitHub searches both title and content (maybe sorting by the search engine's ranking system?) + // The first PR (https://github.com/go-gitea/gitea/pull/32327) uses "search indexer" to search "name(title) + content" + // But it seems that searching "content" (especially LIKE by DB engine) generates worse (unusable) results. + // So now (https://github.com/go-gitea/gitea/pull/33538) it only searches "name(title)", leave the improvements to the future. + cond = cond.And(db.BuildCaseInsensitiveLike("`name`", keyword)) + + issues := make([]*Issue, 0, pageSize) + err := db.GetEngine(ctx).Where("repo_id = ?", repoID). + And(isPullToCond(isPull)). + And(cond). + OrderBy("updated_unix DESC, `index` DESC"). + Limit(pageSize). + Find(&issues) + return issues, err +} + // GetIssueWithAttrsByIndex returns issue by index in a repository. func GetIssueWithAttrsByIndex(ctx context.Context, repoID, index int64) (*Issue, error) { issue, err := GetIssueByIndex(ctx, repoID, index) diff --git a/package-lock.json b/package-lock.json index 169292caa8..b80a2528d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "monaco-editor": "0.52.2", "monaco-editor-webpack-plugin": "7.1.0", "pdfobject": "2.3.0", + "perfect-debounce": "1.0.0", "postcss": "8.5.2", "postcss-loader": "8.1.1", "postcss-nesting": "13.0.1", @@ -11442,6 +11443,12 @@ "integrity": "sha512-w/9pXDXTDs3IDmOri/w8lM/w6LHR0/F4fcBLLzH+4csSoyshQ5su0TE7k0FLHZO7aOjVLDGecqd1M89+PVpVAA==", "license": "MIT" }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 81b7e6c3ad..f83b9fce81 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "monaco-editor": "0.52.2", "monaco-editor-webpack-plugin": "7.1.0", "pdfobject": "2.3.0", + "perfect-debounce": "1.0.0", "postcss": "8.5.2", "postcss-loader": "8.1.1", "postcss-nesting": "13.0.1", @@ -79,8 +80,8 @@ "eslint-plugin-playwright": "2.2.0", "eslint-plugin-regexp": "2.7.0", "eslint-plugin-sonarjs": "3.0.2", - "eslint-plugin-unicorn": "59.0.0", "eslint-plugin-toml": "0.12.0", + "eslint-plugin-unicorn": "59.0.0", "eslint-plugin-vitest-globals": "1.5.0", "eslint-plugin-vue": "10.1.0", "eslint-plugin-vue-scoped-css": "2.9.0", diff --git a/routers/web/repo/issue_suggestions.go b/routers/web/repo/issue_suggestions.go new file mode 100644 index 0000000000..b87a1c0c73 --- /dev/null +++ b/routers/web/repo/issue_suggestions.go @@ -0,0 +1,36 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "forgejo.org/models/unit" + "forgejo.org/modules/optional" + "forgejo.org/services/context" + issue_service "forgejo.org/services/issue" +) + +// IssueSuggestions returns a list of issue suggestions +func IssueSuggestions(ctx *context.Context) { + keyword := ctx.Req.FormValue("q") + + canReadIssues := ctx.Repo.CanRead(unit.TypeIssues) + canReadPulls := ctx.Repo.CanRead(unit.TypePullRequests) + + var isPull optional.Option[bool] + if canReadPulls && !canReadIssues { + isPull = optional.Some(true) + } else if canReadIssues && !canReadPulls { + isPull = optional.Some(false) + } + + suggestions, err := issue_service.GetSuggestion(ctx, ctx.Repo.Repository, isPull, keyword) + if err != nil { + ctx.ServerError("GetSuggestion", err) + return + } + + ctx.JSON(http.StatusOK, suggestions) +} diff --git a/routers/web/web.go b/routers/web/web.go index 3372a5bca2..9008e8ef7a 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1337,6 +1337,7 @@ func registerRoutes(m *web.Route) { }) m.Get("/labels", reqRepoIssuesOrPullsReader, repo.RetrieveLabels, repo.Labels) m.Get("/milestones", reqRepoIssuesOrPullsReader, repo.Milestones) + m.Get("/issues/suggestions", reqRepoIssuesOrPullsReader, repo.IssueSuggestions) }, context.RepoRef()) if setting.Packages.Enabled { diff --git a/services/issue/suggestion.go b/services/issue/suggestion.go new file mode 100644 index 0000000000..7d7df54ae4 --- /dev/null +++ b/services/issue/suggestion.go @@ -0,0 +1,73 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + "strconv" + + issues_model "forgejo.org/models/issues" + repo_model "forgejo.org/models/repo" + "forgejo.org/modules/optional" + "forgejo.org/modules/structs" +) + +func GetSuggestion(ctx context.Context, repo *repo_model.Repository, isPull optional.Option[bool], keyword string) ([]*structs.Issue, error) { + var issues issues_model.IssueList + var err error + pageSize := 5 + if keyword == "" { + issues, err = issues_model.FindLatestUpdatedIssues(ctx, repo.ID, isPull, pageSize) + if err != nil { + return nil, err + } + } else { + indexKeyword, _ := strconv.ParseInt(keyword, 10, 64) + var issueByIndex *issues_model.Issue + var excludedID int64 + if indexKeyword > 0 { + issueByIndex, err = issues_model.GetIssueByIndex(ctx, repo.ID, indexKeyword) + if err != nil && !issues_model.IsErrIssueNotExist(err) { + return nil, err + } + if issueByIndex != nil { + excludedID = issueByIndex.ID + pageSize-- + } + } + + issues, err = issues_model.FindIssuesSuggestionByKeyword(ctx, repo.ID, keyword, isPull, excludedID, pageSize) + if err != nil { + return nil, err + } + + if issueByIndex != nil { + issues = append([]*issues_model.Issue{issueByIndex}, issues...) + } + } + + if err := issues.LoadPullRequests(ctx); err != nil { + return nil, err + } + + suggestions := make([]*structs.Issue, 0, len(issues)) + for _, issue := range issues { + suggestion := &structs.Issue{ + ID: issue.ID, + Index: issue.Index, + Title: issue.Title, + State: issue.State(), + } + + if issue.IsPull && issue.PullRequest != nil { + suggestion.PullRequest = &structs.PullRequestMeta{ + HasMerged: issue.PullRequest.HasMerged, + IsWorkInProgress: issue.PullRequest.IsWorkInProgress(ctx), + } + } + suggestions = append(suggestions, suggestion) + } + + return suggestions, nil +} diff --git a/services/issue/suggestion_test.go b/services/issue/suggestion_test.go new file mode 100644 index 0000000000..83992d44c8 --- /dev/null +++ b/services/issue/suggestion_test.go @@ -0,0 +1,59 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "testing" + + "forgejo.org/models/db" + repo_model "forgejo.org/models/repo" + "forgejo.org/models/unittest" + "forgejo.org/modules/optional" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Suggestion(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + testCases := []struct { + keyword string + isPull optional.Option[bool] + expectedIndexes []int64 + }{ + { + keyword: "", + expectedIndexes: []int64{5, 1, 4, 2, 3}, + }, + { + keyword: "1", + expectedIndexes: []int64{1}, + }, + { + keyword: "issue", + expectedIndexes: []int64{4, 1, 2, 3}, + }, + { + keyword: "pull", + expectedIndexes: []int64{5}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.keyword, func(t *testing.T) { + issues, err := GetSuggestion(db.DefaultContext, repo1, testCase.isPull, testCase.keyword) + require.NoError(t, err) + + issueIndexes := make([]int64, 0, len(issues)) + for _, issue := range issues { + issueIndexes = append(issueIndexes, issue.Index) + } + assert.Equal(t, testCase.expectedIndexes, issueIndexes) + }) + } +} diff --git a/templates/shared/combomarkdowneditor.tmpl b/templates/shared/combomarkdowneditor.tmpl index e9fbb67313..291c0ce6fe 100644 --- a/templates/shared/combomarkdowneditor.tmpl +++ b/templates/shared/combomarkdowneditor.tmpl @@ -53,7 +53,7 @@ Template Attributes:
- +