Skip to content

Commit 130e349

Browse files
silverwindclaudewxiaoguang
authored
Load mentionValues asynchronously (#36739)
Eliminate a few database queries on all issue and pull request pages by moving mention autocomplete data to async JSON endpoints fetched on-demand when the user types `@`. See #36739 (comment) for the full table of affected pages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
1 parent f250138 commit 130e349

32 files changed

Lines changed: 367 additions & 165 deletions

modules/templates/helper.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ func NewFuncMap() template.FuncMap {
141141
"ReactionToEmoji": reactionToEmoji,
142142

143143
// -----------------------------------------------------------------
144-
// misc
144+
// misc (TODO: move them to MiscUtils to avoid bloating the main func map)
145145
"ShortSha": base.ShortSha,
146146
"ActionContent2Commits": ActionContent2Commits,
147147
"IsMultilineCommitMessage": isMultilineCommitMessage,

modules/templates/util_misc.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ import (
1414

1515
activities_model "code.gitea.io/gitea/models/activities"
1616
repo_model "code.gitea.io/gitea/models/repo"
17+
user_model "code.gitea.io/gitea/models/user"
1718
"code.gitea.io/gitea/modules/gitrepo"
1819
"code.gitea.io/gitea/modules/json"
1920
"code.gitea.io/gitea/modules/log"
2021
"code.gitea.io/gitea/modules/repository"
22+
"code.gitea.io/gitea/modules/setting"
2123
"code.gitea.io/gitea/modules/svg"
2224

2325
"github.com/editorconfig/editorconfig-core-go/v2"
@@ -185,3 +187,49 @@ func tabSizeClass(ec *editorconfig.Editorconfig, filename string) string {
185187
}
186188
return "tab-size-4"
187189
}
190+
191+
type MiscUtils struct {
192+
ctx context.Context
193+
}
194+
195+
func NewMiscUtils(ctx context.Context) *MiscUtils {
196+
return &MiscUtils{ctx: ctx}
197+
}
198+
199+
type MarkdownEditorContext struct {
200+
PreviewMode string // "comment", "wiki", or empty for general
201+
PreviewContext string // the path for resolving the links in the preview (repo preview already has default correct value)
202+
PreviewLink string
203+
MentionsLink string
204+
}
205+
206+
func (m *MiscUtils) MarkdownEditorComment(repo *repo_model.Repository) *MarkdownEditorContext {
207+
if repo == nil {
208+
return nil
209+
}
210+
return &MarkdownEditorContext{
211+
PreviewMode: "comment",
212+
PreviewLink: repo.Link() + "/markup",
213+
MentionsLink: repo.Link() + "/-/mentions-in-repo",
214+
}
215+
}
216+
217+
func (m *MiscUtils) MarkdownEditorWiki(repo *repo_model.Repository) *MarkdownEditorContext {
218+
if repo == nil {
219+
return nil
220+
}
221+
return &MarkdownEditorContext{
222+
PreviewMode: "wiki",
223+
PreviewLink: repo.Link() + "/markup",
224+
MentionsLink: repo.Link() + "/-/mentions-in-repo",
225+
}
226+
}
227+
228+
func (m *MiscUtils) MarkdownEditorGeneral(owner *user_model.User) *MarkdownEditorContext {
229+
ret := &MarkdownEditorContext{PreviewLink: setting.AppSubURL + "/-/markup"}
230+
if owner != nil {
231+
ret.PreviewContext = owner.HomeLink()
232+
ret.MentionsLink = owner.HomeLink() + "/-/mentions-in-owner"
233+
}
234+
return ret
235+
}

routers/common/markup.go

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,12 @@ package common
66

77
import (
88
"errors"
9-
"fmt"
109
"net/http"
1110
"path"
1211
"strings"
1312

1413
"code.gitea.io/gitea/models/renderhelper"
1514
"code.gitea.io/gitea/models/repo"
16-
"code.gitea.io/gitea/modules/httplib"
1715
"code.gitea.io/gitea/modules/log"
1816
"code.gitea.io/gitea/modules/markup"
1917
"code.gitea.io/gitea/modules/markup/markdown"
@@ -32,12 +30,9 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
3230
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"
3331

3432
if mode == "" || mode == "markdown" {
35-
// raw Markdown doesn't need any special handling
36-
baseLink := urlPathContext
37-
if baseLink == "" {
38-
baseLink = fmt.Sprintf("%s%s", httplib.GuessCurrentHostURL(ctx), urlPathContext)
39-
}
40-
rctx := renderhelper.NewRenderContextSimpleDocument(ctx, baseLink).WithUseAbsoluteLink(true).
33+
// raw Markdown doesn't do any special handling
34+
// TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect
35+
rctx := renderhelper.NewRenderContextSimpleDocument(ctx, urlPathContext).WithUseAbsoluteLink(true).
4136
WithMarkupType(markdown.MarkupName)
4237
if err := markdown.RenderRaw(rctx, strings.NewReader(text), ctx.Resp); err != nil {
4338
log.Error("RenderMarkupRaw: %v", err)

routers/web/org/mention.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package org
5+
6+
import (
7+
"net/http"
8+
9+
"code.gitea.io/gitea/models/organization"
10+
"code.gitea.io/gitea/modules/util"
11+
shared_mention "code.gitea.io/gitea/routers/web/shared/mention"
12+
"code.gitea.io/gitea/services/context"
13+
)
14+
15+
// GetMentionsInOwner returns JSON data for mention autocomplete on owner-level pages.
16+
func GetMentionsInOwner(ctx *context.Context) {
17+
// for individual users, we don't have a concept of "mentionable" users or teams, so just return an empty list
18+
if !ctx.ContextUser.IsOrganization() {
19+
ctx.JSON(http.StatusOK, []shared_mention.Mention{})
20+
return
21+
}
22+
23+
// for org, return members and teams
24+
c := shared_mention.NewCollector()
25+
org := organization.OrgFromUser(ctx.ContextUser)
26+
27+
// Get org members
28+
members, _, err := org.GetMembers(ctx, ctx.Doer)
29+
if err != nil {
30+
ctx.ServerError("GetMembers", err)
31+
return
32+
}
33+
c.AddUsers(ctx, members)
34+
35+
// Get mentionable teams
36+
if err := c.AddMentionableTeams(ctx, ctx.Doer, ctx.ContextUser); err != nil {
37+
ctx.ServerError("AddMentionableTeams", err)
38+
return
39+
}
40+
41+
ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(c.Result))
42+
}

routers/web/repo/issue.go

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import (
1414

1515
"code.gitea.io/gitea/models/db"
1616
issues_model "code.gitea.io/gitea/models/issues"
17-
"code.gitea.io/gitea/models/organization"
1817
access_model "code.gitea.io/gitea/models/perm/access"
1918
project_model "code.gitea.io/gitea/models/project"
2019
"code.gitea.io/gitea/models/renderhelper"
@@ -649,47 +648,3 @@ func attachmentsHTML(ctx *context.Context, attachments []*repo_model.Attachment,
649648
}
650649
return attachHTML
651650
}
652-
653-
// handleMentionableAssigneesAndTeams gets all teams that current user can mention, and fills the assignee users to the context data
654-
func handleMentionableAssigneesAndTeams(ctx *context.Context, assignees []*user_model.User) {
655-
// TODO: need to figure out how many places this is really used, and rename it to "MentionableAssignees"
656-
// at the moment it is used on the issue list page, for the markdown editor mention
657-
ctx.Data["Assignees"] = assignees
658-
659-
if ctx.Doer == nil || !ctx.Repo.Owner.IsOrganization() {
660-
return
661-
}
662-
663-
var isAdmin bool
664-
var err error
665-
var teams []*organization.Team
666-
org := organization.OrgFromUser(ctx.Repo.Owner)
667-
// Admin has super access.
668-
if ctx.Doer.IsAdmin {
669-
isAdmin = true
670-
} else {
671-
isAdmin, err = org.IsOwnedBy(ctx, ctx.Doer.ID)
672-
if err != nil {
673-
ctx.ServerError("IsOwnedBy", err)
674-
return
675-
}
676-
}
677-
678-
if isAdmin {
679-
teams, err = org.LoadTeams(ctx)
680-
if err != nil {
681-
ctx.ServerError("LoadTeams", err)
682-
return
683-
}
684-
} else {
685-
teams, err = org.GetUserTeams(ctx, ctx.Doer.ID)
686-
if err != nil {
687-
ctx.ServerError("GetUserTeams", err)
688-
return
689-
}
690-
}
691-
692-
ctx.Data["MentionableTeams"] = teams
693-
ctx.Data["MentionableTeamsOrg"] = ctx.Repo.Owner.Name
694-
ctx.Data["MentionableTeamsOrgAvatar"] = ctx.Repo.Owner.AvatarLink(ctx)
695-
}

routers/web/repo/issue_list.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -662,10 +662,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
662662
ctx.ServerError("GetRepoAssignees", err)
663663
return
664664
}
665-
handleMentionableAssigneesAndTeams(ctx, shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers))
666-
if ctx.Written() {
667-
return
668-
}
665+
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
669666

670667
ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.Repo.RepoLink)
671668

routers/web/repo/issue_page_meta.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,7 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) {
155155
}
156156
d.AssigneesData.SelectedAssigneeIDs = strings.Join(ids, ",")
157157
}
158-
// FIXME: this is a tricky part which writes ctx.Data["Mentionable*"]
159-
handleMentionableAssigneesAndTeams(ctx, d.AssigneesData.CandidateAssignees)
158+
ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees
160159
}
161160

162161
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {

routers/web/repo/mention.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package repo
5+
6+
import (
7+
"errors"
8+
"net/http"
9+
10+
issues_model "code.gitea.io/gitea/models/issues"
11+
repo_model "code.gitea.io/gitea/models/repo"
12+
user_model "code.gitea.io/gitea/models/user"
13+
"code.gitea.io/gitea/modules/util"
14+
shared_mention "code.gitea.io/gitea/routers/web/shared/mention"
15+
"code.gitea.io/gitea/services/context"
16+
)
17+
18+
// GetMentionsInRepo returns JSON data for mention autocomplete (assignees, participants, mentionable teams).
19+
func GetMentionsInRepo(ctx *context.Context) {
20+
c := shared_mention.NewCollector()
21+
22+
// Get participants if issue_index is provided
23+
if issueIndex := ctx.FormInt64("issue_index"); issueIndex > 0 {
24+
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, issueIndex)
25+
if err != nil && !errors.Is(err, util.ErrNotExist) {
26+
ctx.ServerError("GetIssueByIndex", err)
27+
return
28+
}
29+
if issue != nil {
30+
userIDs, err := issue.GetParticipantIDsByIssue(ctx)
31+
if err != nil {
32+
ctx.ServerError("GetParticipantIDsByIssue", err)
33+
return
34+
}
35+
users, err := user_model.GetUsersByIDs(ctx, userIDs)
36+
if err != nil {
37+
ctx.ServerError("GetUsersByIDs", err)
38+
return
39+
}
40+
c.AddUsers(ctx, users)
41+
}
42+
}
43+
44+
// Get repo assignees
45+
assignees, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository)
46+
if err != nil {
47+
ctx.ServerError("GetRepoAssignees", err)
48+
return
49+
}
50+
c.AddUsers(ctx, assignees)
51+
52+
// Get mentionable teams for org repos
53+
if err := c.AddMentionableTeams(ctx, ctx.Doer, ctx.Repo.Owner); err != nil {
54+
ctx.ServerError("AddMentionableTeams", err)
55+
return
56+
}
57+
58+
ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(c.Result))
59+
}

routers/web/repo/pull.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -913,10 +913,7 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
913913
ctx.ServerError("GetRepoAssignees", err)
914914
return
915915
}
916-
handleMentionableAssigneesAndTeams(ctx, shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers))
917-
if ctx.Written() {
918-
return
919-
}
916+
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
920917

921918
currentReview, err := issues_model.GetCurrentReview(ctx, ctx.Doer, issue)
922919
if err != nil && !issues_model.IsErrReviewNotExist(err) {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package mention
5+
6+
import (
7+
"context"
8+
9+
"code.gitea.io/gitea/models/organization"
10+
user_model "code.gitea.io/gitea/models/user"
11+
)
12+
13+
// Mention is the JSON structure returned by mention autocomplete endpoints.
14+
type Mention struct {
15+
Key string `json:"key"`
16+
Value string `json:"value"`
17+
Name string `json:"name"`
18+
FullName string `json:"fullname"`
19+
Avatar string `json:"avatar"`
20+
}
21+
22+
// Collector builds a deduplicated list of Mention entries.
23+
type Collector struct {
24+
seen map[string]bool
25+
Result []Mention
26+
}
27+
28+
// NewCollector creates a new Collector.
29+
func NewCollector() *Collector {
30+
return &Collector{seen: make(map[string]bool)}
31+
}
32+
33+
// AddUsers adds user mentions, skipping duplicates.
34+
func (c *Collector) AddUsers(ctx context.Context, users []*user_model.User) {
35+
for _, u := range users {
36+
if !c.seen[u.Name] {
37+
c.seen[u.Name] = true
38+
c.Result = append(c.Result, Mention{
39+
Key: u.Name + " " + u.FullName,
40+
Value: u.Name,
41+
Name: u.Name,
42+
FullName: u.FullName,
43+
Avatar: u.AvatarLink(ctx),
44+
})
45+
}
46+
}
47+
}
48+
49+
// AddMentionableTeams loads and adds team mentions for the given owner (if it's an org).
50+
func (c *Collector) AddMentionableTeams(ctx context.Context, doer, owner *user_model.User) error {
51+
if doer == nil || !owner.IsOrganization() {
52+
return nil
53+
}
54+
55+
org := organization.OrgFromUser(owner)
56+
isAdmin := doer.IsAdmin
57+
if !isAdmin {
58+
var err error
59+
isAdmin, err = org.IsOwnedBy(ctx, doer.ID)
60+
if err != nil {
61+
return err
62+
}
63+
}
64+
65+
var teams []*organization.Team
66+
var err error
67+
if isAdmin {
68+
teams, err = org.LoadTeams(ctx)
69+
} else {
70+
teams, err = org.GetUserTeams(ctx, doer.ID)
71+
}
72+
if err != nil {
73+
return err
74+
}
75+
76+
for _, team := range teams {
77+
key := owner.Name + "/" + team.Name
78+
if !c.seen[key] {
79+
c.seen[key] = true
80+
c.Result = append(c.Result, Mention{
81+
Key: key,
82+
Value: key,
83+
Name: key,
84+
Avatar: owner.AvatarLink(ctx),
85+
})
86+
}
87+
}
88+
return nil
89+
}

0 commit comments

Comments
 (0)