diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 82087568df..c1ee88fc84 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -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, diff --git a/modules/templates/util_misc.go b/modules/templates/util_misc.go index 4cf339ef42..fb523fd53a 100644 --- a/modules/templates/util_misc.go +++ b/modules/templates/util_misc.go @@ -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" @@ -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 +} diff --git a/routers/common/markup.go b/routers/common/markup.go index 27c95eedb8..56b588332f 100644 --- a/routers/common/markup.go +++ b/routers/common/markup.go @@ -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" @@ -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) diff --git a/routers/web/org/mention.go b/routers/web/org/mention.go new file mode 100644 index 0000000000..ad6cc8c862 --- /dev/null +++ b/routers/web/org/mention.go @@ -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)) +} diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index a295a3c903..0fe703e150 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -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" @@ -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) -} diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index ff4ff26685..41b4b2aa17 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -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) diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 93cc38bffa..719b485bc5 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -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) { diff --git a/routers/web/repo/mention.go b/routers/web/repo/mention.go new file mode 100644 index 0000000000..72bb50120b --- /dev/null +++ b/routers/web/repo/mention.go @@ -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)) +} diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 0578ab540f..03dea19db6 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -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) { diff --git a/routers/web/shared/mention/mention.go b/routers/web/shared/mention/mention.go new file mode 100644 index 0000000000..5f163cb146 --- /dev/null +++ b/routers/web/shared/mention/mention.go @@ -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 +} diff --git a/routers/web/web.go b/routers/web/web.go index d973064b22..182c6c595b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -835,6 +835,7 @@ func registerWebRoutes(m *web.Router) { // the legacy names "reqRepoXxx" should be renamed to the correct name "reqUnitXxx", these permissions are for units, not repos reqUnitsWithMarkdown := context.RequireUnitReader(unit.TypeCode, unit.TypeIssues, unit.TypePullRequests, unit.TypeReleases, unit.TypeWiki) + reqUnitsWithMentions := context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests, unit.TypeReleases, unit.TypeWiki, unit.TypeProjects) reqUnitCodeReader := context.RequireUnitReader(unit.TypeCode) reqUnitIssuesReader := context.RequireUnitReader(unit.TypeIssues) reqUnitPullsReader := context.RequireUnitReader(unit.TypePullRequests) @@ -1025,6 +1026,9 @@ func registerWebRoutes(m *web.Router) { }, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) } + // at the moment, only editing "owner-level projects" need to "mention", maybe in the future we can relax the permission check + m.Get("/mentions-in-owner", reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), org.GetMentionsInOwner) + m.Get("/repositories", org.Repositories) m.Get("/heatmap", user.DashboardHeatmap) @@ -1074,6 +1078,11 @@ func registerWebRoutes(m *web.Router) { }, optSignIn, context.RepoAssignment, reqUnitCodeReader) // end "/{username}/{reponame}/-": migrate + m.Group("/{username}/{reponame}/-", func() { + m.Get("/mentions-in-repo", repo.GetMentionsInRepo) + }, optSignIn, context.RepoAssignment, reqUnitsWithMentions) + // end "/{username}/{reponame}/-": mentions + m.Group("/{username}/{reponame}/settings", func() { m.Group("", func() { m.Combo("").Get(repo_setting.Settings). diff --git a/services/context/context.go b/services/context/context.go index 97b9890f43..a6a861ecaa 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -105,6 +105,7 @@ func NewTemplateContextForWeb(ctx reqctx.RequestContext, req *http.Request, loca tmplCtx["Locale"] = locale tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx) tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx) + tmplCtx["MiscUtils"] = templates.NewMiscUtils(ctx) tmplCtx["RootData"] = ctx.GetData() tmplCtx["Consts"] = map[string]any{ "RepoUnitTypeCode": unit.TypeCode, diff --git a/templates/base/head_script.tmpl b/templates/base/head_script.tmpl index 9eb6b77f0f..b51f98de51 100644 --- a/templates/base/head_script.tmpl +++ b/templates/base/head_script.tmpl @@ -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: { diff --git a/templates/devtest/gitea-ui.tmpl b/templates/devtest/gitea-ui.tmpl index c1e6590a43..6167ce0253 100644 --- a/templates/devtest/gitea-ui.tmpl +++ b/templates/devtest/gitea-ui.tmpl @@ -184,7 +184,7 @@