Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4a9939a
Lazy-load mentionValues via JSON endpoint instead of inline template
silverwind Feb 24, 2026
01e438c
Address review comments on mention values endpoint and caching
silverwind Feb 24, 2026
f642326
Address review: rename to GetMentions, move getMentionableTeams to me…
silverwind Feb 25, 2026
b639fcb
Add permission check for mentions endpoint
silverwind Feb 25, 2026
3e9ead2
Use dedicated permission check for mentions endpoint
silverwind Feb 25, 2026
f9e8a34
Add org-level mentions endpoint for project pages
silverwind Feb 26, 2026
339be61
Fix mock response in matchMention test
silverwind Feb 26, 2026
6cc1905
always show the @ button
silverwind Feb 26, 2026
448438c
Merge branch 'main' into asyncmention
silverwind Feb 26, 2026
dc2117f
Extract shared mention helpers to reduce duplication
silverwind Feb 26, 2026
bdb59cc
Add permission check to org mentions endpoint
silverwind Feb 26, 2026
91048f1
Rename reqUnitCommentWriter to reqUnitCommentsReader
silverwind Feb 26, 2026
30bf6e9
Rename reqUnitCommentsReader to reqUnitsWithMentions
silverwind Feb 26, 2026
6d84f78
Merge branch 'main' into asyncmention
silverwind Feb 26, 2026
ace6125
Merge branch 'main' into asyncmention
silverwind Mar 1, 2026
059c011
Use util.SliceNilAsEmpty instead of custom ResultOrEmpty method
silverwind Mar 1, 2026
ebfcd1c
Merge branch 'main' into asyncmention
silverwind Mar 6, 2026
1447dc9
Pass mentions URL from server via data attribute
silverwind Mar 6, 2026
801127d
Merge branch 'main' into asyncmention
silverwind Mar 6, 2026
2824ead
do not create strange dependencies between unrelated modules
wxiaoguang Mar 7, 2026
186a7e9
do not create strange dependencies between unrelated modules
wxiaoguang Mar 7, 2026
6d5f18c
refactor
wxiaoguang Mar 7, 2026
5ec22c9
fix
wxiaoguang Mar 7, 2026
01da8d2
null safe
wxiaoguang Mar 7, 2026
9870285
comment "markdown preview context path" problem
wxiaoguang Mar 7, 2026
af66a12
fine tune error handling
wxiaoguang Mar 7, 2026
ffc7841
Merge branch 'main' into asyncmention
wxiaoguang Mar 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion modules/templates/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func NewFuncMap() template.FuncMap {
"ReactionToEmoji": reactionToEmoji,

// -----------------------------------------------------------------
// misc
// misc (TODO: move them to MiscUtils to avoid bloating the main func map)
"ShortSha": base.ShortSha,
"ActionContent2Commits": ActionContent2Commits,
"IsMultilineCommitMessage": isMultilineCommitMessage,
Expand Down
48 changes: 48 additions & 0 deletions modules/templates/util_misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import (

activities_model "code.gitea.io/gitea/models/activities"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg"

"github.com/editorconfig/editorconfig-core-go/v2"
Expand Down Expand Up @@ -185,3 +187,49 @@ func tabSizeClass(ec *editorconfig.Editorconfig, filename string) string {
}
return "tab-size-4"
}

type MiscUtils struct {
ctx context.Context
}

func NewMiscUtils(ctx context.Context) *MiscUtils {
return &MiscUtils{ctx: ctx}
}

type MarkdownEditorContext struct {
PreviewMode string // "comment", "wiki", or empty for general
PreviewContext string // the path for resolving the links in the preview (repo preview already has default correct value)
PreviewLink string
MentionsLink string
}

func (m *MiscUtils) MarkdownEditorComment(repo *repo_model.Repository) *MarkdownEditorContext {
if repo == nil {
return nil
}
return &MarkdownEditorContext{
PreviewMode: "comment",
PreviewLink: repo.Link() + "/markup",
MentionsLink: repo.Link() + "/-/mentions-in-repo",
}
}

func (m *MiscUtils) MarkdownEditorWiki(repo *repo_model.Repository) *MarkdownEditorContext {
if repo == nil {
return nil
}
return &MarkdownEditorContext{
PreviewMode: "wiki",
PreviewLink: repo.Link() + "/markup",
MentionsLink: repo.Link() + "/-/mentions-in-repo",
}
}

func (m *MiscUtils) MarkdownEditorGeneral(owner *user_model.User) *MarkdownEditorContext {
ret := &MarkdownEditorContext{PreviewLink: setting.AppSubURL + "/-/markup"}
if owner != nil {
ret.PreviewContext = owner.HomeLink()
ret.MentionsLink = owner.HomeLink() + "/-/mentions-in-owner"
}
return ret
}
11 changes: 3 additions & 8 deletions routers/common/markup.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,12 @@ package common

import (
"errors"
"fmt"
"net/http"
"path"
"strings"

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

if mode == "" || mode == "markdown" {
// raw Markdown doesn't need any special handling
baseLink := urlPathContext
if baseLink == "" {
baseLink = fmt.Sprintf("%s%s", httplib.GuessCurrentHostURL(ctx), urlPathContext)
}
rctx := renderhelper.NewRenderContextSimpleDocument(ctx, baseLink).WithUseAbsoluteLink(true).
// raw Markdown doesn't do any special handling
// TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect
rctx := renderhelper.NewRenderContextSimpleDocument(ctx, urlPathContext).WithUseAbsoluteLink(true).
WithMarkupType(markdown.MarkupName)
if err := markdown.RenderRaw(rctx, strings.NewReader(text), ctx.Resp); err != nil {
log.Error("RenderMarkupRaw: %v", err)
Expand Down
42 changes: 42 additions & 0 deletions routers/web/org/mention.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package org

import (
"net/http"

"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/modules/util"
shared_mention "code.gitea.io/gitea/routers/web/shared/mention"
"code.gitea.io/gitea/services/context"
)

// GetMentionsInOwner returns JSON data for mention autocomplete on owner-level pages.
func GetMentionsInOwner(ctx *context.Context) {
// for individual users, we don't have a concept of "mentionable" users or teams, so just return an empty list
if !ctx.ContextUser.IsOrganization() {
ctx.JSON(http.StatusOK, []shared_mention.Mention{})
return
}

// for org, return members and teams
c := shared_mention.NewCollector()
org := organization.OrgFromUser(ctx.ContextUser)

// Get org members
members, _, err := org.GetMembers(ctx, ctx.Doer)
if err != nil {
ctx.ServerError("GetMembers", err)
return
}
c.AddUsers(ctx, members)

// Get mentionable teams
if err := c.AddMentionableTeams(ctx, ctx.Doer, ctx.ContextUser); err != nil {
ctx.ServerError("AddMentionableTeams", err)
return
}

ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(c.Result))
}
45 changes: 0 additions & 45 deletions routers/web/repo/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import (

"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
access_model "code.gitea.io/gitea/models/perm/access"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/models/renderhelper"
Expand Down Expand Up @@ -649,47 +648,3 @@ func attachmentsHTML(ctx *context.Context, attachments []*repo_model.Attachment,
}
return attachHTML
}

// handleMentionableAssigneesAndTeams gets all teams that current user can mention, and fills the assignee users to the context data
func handleMentionableAssigneesAndTeams(ctx *context.Context, assignees []*user_model.User) {
// TODO: need to figure out how many places this is really used, and rename it to "MentionableAssignees"
// at the moment it is used on the issue list page, for the markdown editor mention
ctx.Data["Assignees"] = assignees

if ctx.Doer == nil || !ctx.Repo.Owner.IsOrganization() {
return
}

var isAdmin bool
var err error
var teams []*organization.Team
org := organization.OrgFromUser(ctx.Repo.Owner)
// Admin has super access.
if ctx.Doer.IsAdmin {
isAdmin = true
} else {
isAdmin, err = org.IsOwnedBy(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("IsOwnedBy", err)
return
}
}

if isAdmin {
teams, err = org.LoadTeams(ctx)
if err != nil {
ctx.ServerError("LoadTeams", err)
return
}
} else {
teams, err = org.GetUserTeams(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("GetUserTeams", err)
return
}
}

ctx.Data["MentionableTeams"] = teams
ctx.Data["MentionableTeamsOrg"] = ctx.Repo.Owner.Name
ctx.Data["MentionableTeamsOrgAvatar"] = ctx.Repo.Owner.AvatarLink(ctx)
}
5 changes: 1 addition & 4 deletions routers/web/repo/issue_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -662,10 +662,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
ctx.ServerError("GetRepoAssignees", err)
return
}
handleMentionableAssigneesAndTeams(ctx, shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers))
if ctx.Written() {
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)

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

Expand Down
3 changes: 1 addition & 2 deletions routers/web/repo/issue_page_meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,7 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) {
}
d.AssigneesData.SelectedAssigneeIDs = strings.Join(ids, ",")
}
// FIXME: this is a tricky part which writes ctx.Data["Mentionable*"]
handleMentionableAssigneesAndTeams(ctx, d.AssigneesData.CandidateAssignees)
ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees
}

func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
Expand Down
59 changes: 59 additions & 0 deletions routers/web/repo/mention.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package repo

import (
"errors"
"net/http"

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/util"
shared_mention "code.gitea.io/gitea/routers/web/shared/mention"
"code.gitea.io/gitea/services/context"
)

// GetMentionsInRepo returns JSON data for mention autocomplete (assignees, participants, mentionable teams).
func GetMentionsInRepo(ctx *context.Context) {
c := shared_mention.NewCollector()

// Get participants if issue_index is provided
if issueIndex := ctx.FormInt64("issue_index"); issueIndex > 0 {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, issueIndex)
if err != nil && !errors.Is(err, util.ErrNotExist) {
ctx.ServerError("GetIssueByIndex", err)
return
}
if issue != nil {
userIDs, err := issue.GetParticipantIDsByIssue(ctx)
if err != nil {
ctx.ServerError("GetParticipantIDsByIssue", err)
return
}
users, err := user_model.GetUsersByIDs(ctx, userIDs)
if err != nil {
ctx.ServerError("GetUsersByIDs", err)
return
}
c.AddUsers(ctx, users)
}
}

// Get repo assignees
assignees, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository)
if err != nil {
ctx.ServerError("GetRepoAssignees", err)
return
}
c.AddUsers(ctx, assignees)

// Get mentionable teams for org repos
if err := c.AddMentionableTeams(ctx, ctx.Doer, ctx.Repo.Owner); err != nil {
ctx.ServerError("AddMentionableTeams", err)
return
}

ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(c.Result))
}
5 changes: 1 addition & 4 deletions routers/web/repo/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -913,10 +913,7 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
ctx.ServerError("GetRepoAssignees", err)
return
}
handleMentionableAssigneesAndTeams(ctx, shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers))
if ctx.Written() {
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)

currentReview, err := issues_model.GetCurrentReview(ctx, ctx.Doer, issue)
if err != nil && !issues_model.IsErrReviewNotExist(err) {
Expand Down
89 changes: 89 additions & 0 deletions routers/web/shared/mention/mention.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package mention

import (
"context"

"code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
)

// Mention is the JSON structure returned by mention autocomplete endpoints.
type Mention struct {
Key string `json:"key"`
Value string `json:"value"`
Name string `json:"name"`
FullName string `json:"fullname"`
Avatar string `json:"avatar"`
}

// Collector builds a deduplicated list of Mention entries.
type Collector struct {
seen map[string]bool
Result []Mention
}

// NewCollector creates a new Collector.
func NewCollector() *Collector {
return &Collector{seen: make(map[string]bool)}
}

// AddUsers adds user mentions, skipping duplicates.
func (c *Collector) AddUsers(ctx context.Context, users []*user_model.User) {
for _, u := range users {
if !c.seen[u.Name] {
c.seen[u.Name] = true
c.Result = append(c.Result, Mention{
Key: u.Name + " " + u.FullName,
Value: u.Name,
Name: u.Name,
FullName: u.FullName,
Avatar: u.AvatarLink(ctx),
})
}
}
}

// AddMentionableTeams loads and adds team mentions for the given owner (if it's an org).
func (c *Collector) AddMentionableTeams(ctx context.Context, doer, owner *user_model.User) error {
if doer == nil || !owner.IsOrganization() {
return nil
}

org := organization.OrgFromUser(owner)
isAdmin := doer.IsAdmin
if !isAdmin {
var err error
isAdmin, err = org.IsOwnedBy(ctx, doer.ID)
if err != nil {
return err
}
}

var teams []*organization.Team
var err error
if isAdmin {
teams, err = org.LoadTeams(ctx)
} else {
teams, err = org.GetUserTeams(ctx, doer.ID)
}
if err != nil {
return err
}

for _, team := range teams {
key := owner.Name + "/" + team.Name
if !c.seen[key] {
c.seen[key] = true
c.Result = append(c.Result, Mention{
Key: key,
Value: key,
Name: key,
Avatar: owner.AvatarLink(ctx),
})
}
}
return nil
}
Loading
Loading