Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
39 changes: 9 additions & 30 deletions routers/web/repo/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -642,46 +642,25 @@ 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

// getMentionableTeams returns the teams that the current user can mention in the repo context.
func getMentionableTeams(ctx *context.Context) ([]*organization.Team, error) {
Comment thread
silverwind marked this conversation as resolved.
Outdated
if ctx.Doer == nil || !ctx.Repo.Owner.IsOrganization() {
return
return nil, nil
}

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 := ctx.Doer.IsAdmin
if !isAdmin {
var err error
isAdmin, err = org.IsOwnedBy(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("IsOwnedBy", err)
return
return nil, err
}
}

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
}
return org.LoadTeams(ctx)
}

ctx.Data["MentionableTeams"] = teams
ctx.Data["MentionableTeamsOrg"] = ctx.Repo.Owner.Name
ctx.Data["MentionableTeamsOrgAvatar"] = ctx.Repo.Owner.AvatarLink(ctx)
return org.GetUserTeams(ctx, ctx.Doer.ID)
}
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
98 changes: 98 additions & 0 deletions routers/web/repo/mention.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package repo

import (
"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/services/context"
)

type mentionValue struct {
Key string `json:"key"`
Value string `json:"value"`
Name string `json:"name"`
FullName string `json:"fullname"`
Avatar string `json:"avatar"`
}

// MentionValues returns JSON data for mention autocomplete (assignees, participants, mentionable teams).
func MentionValues(ctx *context.Context) {
Comment thread
silverwind marked this conversation as resolved.
Outdated
seen := make(map[string]bool)
var result []mentionValue

addUser := func(u *user_model.User) {
if !seen[u.Name] {
seen[u.Name] = true
result = append(result, mentionValue{
Key: u.Name + " " + u.FullName,
Value: u.Name,
Name: u.Name,
FullName: u.FullName,
Avatar: u.AvatarLink(ctx),
})
}
}

// 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 {
ctx.ServerError("GetIssueByIndex", err)
Comment thread
silverwind marked this conversation as resolved.
return
}
userIDs, err := issue.GetParticipantIDsByIssue(ctx)
if err != nil {
ctx.ServerError("GetParticipantIDsByIssue", err)
return
}
if len(userIDs) > 0 {
users, err := user_model.GetUsersByIDs(ctx, userIDs)
if err != nil {
ctx.ServerError("GetUsersByIDs", err)
return
}
for _, u := range users {
addUser(u)
}
}
}

// Get repo assignees
assignees, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository)
if err != nil {
ctx.ServerError("GetRepoAssignees", err)
return
}
for _, u := range assignees {
addUser(u)
}

// Get mentionable teams for org repos
teams, err := getMentionableTeams(ctx)
if err != nil {
ctx.ServerError("getMentionableTeams", err)
return
}
for _, team := range teams {
key := ctx.Repo.Owner.Name + "/" + team.Name
if !seen[key] {
seen[key] = true
result = append(result, mentionValue{
Key: key,
Value: key,
Name: key,
Avatar: ctx.Repo.Owner.AvatarLink(ctx),
})
}
}

if result == nil {
result = []mentionValue{}
}
ctx.JSON(http.StatusOK, result)
}
Comment thread
wxiaoguang marked this conversation as resolved.
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
5 changes: 5 additions & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,11 @@ func registerWebRoutes(m *web.Router) {
}, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{reponame}/-": migrate

m.Group("/{username}/{reponame}/-", func() {
m.Get("/mentionvalues", repo.MentionValues)
}, optSignIn, context.RepoAssignment)
// end "/{username}/{reponame}/-": mentionvalues

m.Group("/{username}/{reponame}/settings", func() {
m.Group("", func() {
m.Combo("").Get(repo_setting.Settings).
Expand Down
15 changes: 0 additions & 15 deletions templates/base/head_script.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,6 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
pageData: {{.PageData}},
notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
enableTimeTracking: {{EnableTimetracking}},
{{if or .Participants .Assignees .MentionableTeams}}
mentionValues: Array.from(new Map([
{{- range .Participants -}}
['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}', name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink ctx}}'}],
{{- end -}}
{{- range .Assignees -}}
['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}', name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink ctx}}'}],
{{- end -}}
{{- range .MentionableTeams -}}
['{{$.MentionableTeamsOrg}}/{{.Name}}', {key: '{{$.MentionableTeamsOrg}}/{{.Name}}', value: '{{$.MentionableTeamsOrg}}/{{.Name}}', name: '{{$.MentionableTeamsOrg}}/{{.Name}}', avatar: '{{$.MentionableTeamsOrgAvatar}}'}],
{{- end -}}
]).values()),
{{else}}
mentionValues: [],
{{end}}
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
i18n: {
Expand Down
58 changes: 30 additions & 28 deletions web_src/js/features/comp/TextExpander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,36 +83,38 @@ export function initTextExpander(expander: TextExpanderElement) {

provide({matched: true, fragment: ul});
} else if (key === '@') {
const matches = matchMention(text);
if (!matches.length) return provide({matched: false});

const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const {value, name, fullname, avatar} of matches) {
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('data-value', `${key}${value}`);

const img = document.createElement('img');
img.src = avatar;
li.append(img);

const nameSpan = document.createElement('span');
nameSpan.classList.add('name');
nameSpan.textContent = name;
li.append(nameSpan);

if (fullname && fullname.toLowerCase() !== name) {
const fullnameSpan = document.createElement('span');
fullnameSpan.classList.add('fullname');
fullnameSpan.textContent = fullname;
li.append(fullnameSpan);
provide((async (): Promise<TextExpanderResult> => {
const matches = await matchMention(text);
if (!matches.length) return {matched: false};

const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const {value, name, fullname, avatar} of matches) {
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('data-value', `${key}${value}`);

const img = document.createElement('img');
img.src = avatar;
li.append(img);

const nameSpan = document.createElement('span');
nameSpan.classList.add('name');
nameSpan.textContent = name;
li.append(nameSpan);

if (fullname && fullname.toLowerCase() !== name) {
const fullnameSpan = document.createElement('span');
fullnameSpan.classList.add('fullname');
fullnameSpan.textContent = fullname;
li.append(fullnameSpan);
}

ul.append(li);
}

ul.append(li);
}

provide({matched: true, fragment: ul});
return {matched: true, fragment: ul};
})());
} else if (key === '#') {
provide(debouncedIssueSuggestions(key, text));
}
Expand Down
5 changes: 4 additions & 1 deletion web_src/js/features/tribute.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
import {html, htmlRaw} from '../utils/html.ts';
import {fetchMentionValues} from '../utils/match.ts';
import type {TributeCollection} from 'tributejs';
import type {MentionValue} from '../types.ts';

Expand Down Expand Up @@ -30,7 +31,9 @@ export async function attachTribute(element: HTMLElement) {
};

const mentionCollection: TributeCollection<MentionValue> = {
values: window.config.mentionValues,
values: async (_query: string, cb: (matches: MentionValue[]) => void) => { // eslint-disable-line @typescript-eslint/no-misused-promises
cb(await fetchMentionValues());
},
requireLeadingSpace: true,
menuItemTemplate: (item) => {
const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : '';
Expand Down
1 change: 0 additions & 1 deletion web_src/js/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ interface Window {
pageData: Record<string, any>,
notificationSettings: Record<string, any>,
enableTimeTracking: boolean,
mentionValues: Array<import('./types.ts').MentionValue>,
mermaidMaxSourceCharacters: number,
i18n: Record<string, string>,
},
Expand Down
25 changes: 22 additions & 3 deletions web_src/js/utils/match.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import {GET} from '../modules/fetch.ts';
import {matchEmoji, matchMention} from './match.ts';

vi.mock('../modules/fetch.ts', () => ({
GET: vi.fn(),
}));

const testMentionValues = [
{key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
{key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'},
{key: 'org3 User 3', value: 'org3', name: 'org3', fullname: 'User 3', avatar: 'https://avatar3.com'},
{key: 'user4 User 4', value: 'user4', name: 'user4', fullname: 'User 4', avatar: 'https://avatar4.com'},
{key: 'user5 User 5', value: 'user5', name: 'user5', fullname: 'User 5', avatar: 'https://avatar5.com'},
{key: 'org6 User 6', value: 'org6', name: 'org6', fullname: 'User 6', avatar: 'https://avatar6.com'},
{key: 'org7 User 7', value: 'org7', name: 'org7', fullname: 'User 7', avatar: 'https://avatar7.com'},
];

test('matchEmoji', () => {
expect(matchEmoji('')).toMatchInlineSnapshot(`
[
Expand Down Expand Up @@ -56,7 +71,11 @@ test('matchEmoji', () => {
`);
});

test('matchMention', () => {
expect(matchMention('')).toEqual(window.config.mentionValues.slice(0, 6));
expect(matchMention('user4')).toEqual([window.config.mentionValues[3]]);
test('matchMention', async () => {
const oldLocation = String(window.location);
window.location.assign('http://localhost/owner/repo/issues/1');
vi.mocked(GET).mockResolvedValue({json: () => Promise.resolve(testMentionValues)} as Response);
expect(await matchMention('')).toEqual(testMentionValues.slice(0, 6));
expect(await matchMention('user4')).toEqual([testMentionValues[3]]);
window.location.assign(oldLocation);
});
Comment thread
silverwind marked this conversation as resolved.
29 changes: 26 additions & 3 deletions web_src/js/utils/match.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'};
import {GET} from '../modules/fetch.ts';
import type {Issue} from '../types.ts';
import {showErrorToast} from '../modules/toast.ts';
import {parseIssueHref, parseRepoOwnerPathInfo} from '../utils.ts';
import type {Issue, MentionValue} from '../types.ts';

const maxMatches = 6;

Expand Down Expand Up @@ -29,13 +31,34 @@ export function matchEmoji(queryText: string): string[] {
return sortAndReduce(results);
}

let cachedMentionValues: MentionValue[] | undefined;

export async function fetchMentionValues(): Promise<MentionValue[]> {
if (cachedMentionValues === undefined) {
try {
const {ownerName, repoName} = parseRepoOwnerPathInfo(window.location.pathname);
if (ownerName && repoName) {
const {indexString} = parseIssueHref(window.location.href);
const query = indexString ? `?issue_index=${indexString}` : '';
const res = await GET(`${window.config.appSubUrl}/${ownerName}/${repoName}/-/mentionvalues${query}`);
cachedMentionValues = await res.json();
}
} catch (e) {
showErrorToast(`Failed to load mention values: ${e}`);
}
cachedMentionValues ??= [];
}
Comment thread
silverwind marked this conversation as resolved.
return cachedMentionValues;
}

type MentionSuggestion = {value: string; name: string; fullname: string; avatar: string};
export function matchMention(queryText: string): MentionSuggestion[] {
export async function matchMention(queryText: string): Promise<MentionSuggestion[]> {
const values = await fetchMentionValues();
const query = queryText.toLowerCase();

// results is a map of weights, lower is better
const results = new Map<MentionSuggestion, number>();
for (const obj of window.config.mentionValues) {
for (const obj of values) {
const index = obj.key.toLowerCase().indexOf(query);
if (index === -1) continue;
const existing = results.get(obj);
Expand Down
Loading
Loading