mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-20 23:20:41 +00:00
11de54dd41
Backport #38893 by @HsukqiLee Closes #38872 Labels in the label selection dropdown (issue/PR sidebar, new issue form) were always listed alphabetically, so a scoped set like the default Priority labels showed up as Critical, High, Low, Medium even though each label carries an exclusive order. This adds `CompareLabelForDisplay`/`SortLabelsForDisplay` in `models/issues`: labels are grouped by their exclusive scope and sorted by exclusive order within a scope (unordered ones last), falling back to name order. The sorting is applied to the issue page sidebar data and the shared label filter data, so the filter dropdown on the issue list gets the same ordering. Unscoped labels are unaffected and still sort by name. Includes a unit test covering the default Priority label set. Co-authored-by: Hsukqi Lee <team@tsinbei.com>
533 lines
15 KiB
Go
533 lines
15 KiB
Go
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package repo
|
|
|
|
import (
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.dev/models/db"
|
|
issues_model "gitea.dev/models/issues"
|
|
"gitea.dev/models/organization"
|
|
project_model "gitea.dev/models/project"
|
|
repo_model "gitea.dev/models/repo"
|
|
user_model "gitea.dev/models/user"
|
|
"gitea.dev/modules/container"
|
|
"gitea.dev/modules/optional"
|
|
shared_user "gitea.dev/routers/web/shared/user"
|
|
"gitea.dev/services/context"
|
|
issue_service "gitea.dev/services/issue"
|
|
pull_service "gitea.dev/services/pull"
|
|
)
|
|
|
|
type issueSidebarMilestoneData struct {
|
|
SelectedMilestoneID int64
|
|
OpenMilestones []*issues_model.Milestone
|
|
ClosedMilestones []*issues_model.Milestone
|
|
}
|
|
|
|
type issueSidebarAssigneesData struct {
|
|
SelectedAssigneeIDs string
|
|
CandidateAssignees []*user_model.User
|
|
}
|
|
|
|
type issueSidebarProjectCardData struct {
|
|
Project *project_model.Project
|
|
Columns []*project_model.Column
|
|
SelectedColumn *project_model.Column
|
|
}
|
|
|
|
type issueSidebarProjectsData struct {
|
|
SelectedProjectIDs []int64
|
|
ProjectCards []*issueSidebarProjectCardData
|
|
|
|
OpenProjects []*project_model.Project
|
|
ClosedProjects []*project_model.Project
|
|
}
|
|
|
|
type IssuePageMetaData struct {
|
|
RepoLink string
|
|
Repository *repo_model.Repository
|
|
Issue *issues_model.Issue
|
|
IsPullRequest bool
|
|
CanModifyIssueOrPull bool
|
|
|
|
ReviewersData *issueSidebarReviewersData
|
|
LabelsData *issueSidebarLabelsData
|
|
MilestonesData *issueSidebarMilestoneData
|
|
ProjectsData *issueSidebarProjectsData
|
|
AssigneesData *issueSidebarAssigneesData
|
|
}
|
|
|
|
func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository, issue *issues_model.Issue, isPull bool) *IssuePageMetaData {
|
|
data := &IssuePageMetaData{
|
|
RepoLink: ctx.Repo.RepoLink,
|
|
Repository: repo,
|
|
Issue: issue,
|
|
IsPullRequest: isPull,
|
|
|
|
ReviewersData: &issueSidebarReviewersData{},
|
|
LabelsData: &issueSidebarLabelsData{},
|
|
MilestonesData: &issueSidebarMilestoneData{},
|
|
ProjectsData: &issueSidebarProjectsData{},
|
|
AssigneesData: &issueSidebarAssigneesData{},
|
|
}
|
|
ctx.Data["IssuePageMetaData"] = data
|
|
|
|
if isPull {
|
|
data.retrieveReviewersData(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
}
|
|
data.retrieveLabelsData(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
|
|
// it sets "Branches" template data,
|
|
// it is used to render the "edit PR target branches" dropdown, and the "branch selector" in the issue's sidebar.
|
|
PrepareBranchList(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
|
|
// it sets the "Assignees" template data, and the data is also used to "mention" users.
|
|
data.retrieveAssigneesData(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
|
|
data.retrieveProjectData(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
|
|
// TODO: the issue/pull permissions are quite complex and unclear
|
|
// A reader could create an issue/PR with setting some meta (eg: assignees from issue template, reviewers, target branch)
|
|
// A reader(creator) could update some meta (eg: target branch), but can't change assignees anymore.
|
|
// For non-creator users, only writers could update some meta (eg: assignees, milestone, project)
|
|
// Need to clarify the logic and add some tests in the future
|
|
data.CanModifyIssueOrPull = ctx.Repo.Permission.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived
|
|
if !data.CanModifyIssueOrPull {
|
|
return data
|
|
}
|
|
|
|
data.retrieveMilestonesDataForIssueWriter(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
|
|
data.retrieveProjectsDataForIssueWriter(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
|
|
ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, isPull)
|
|
return data
|
|
}
|
|
|
|
func (d *IssuePageMetaData) retrieveMilestonesDataForIssueWriter(ctx *context.Context) {
|
|
var err error
|
|
if d.Issue != nil {
|
|
d.MilestonesData.SelectedMilestoneID = d.Issue.MilestoneID
|
|
}
|
|
d.MilestonesData.OpenMilestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
|
|
RepoID: d.Repository.ID,
|
|
IsClosed: optional.Some(false),
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("GetMilestones", err)
|
|
return
|
|
}
|
|
d.MilestonesData.ClosedMilestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
|
|
RepoID: d.Repository.ID,
|
|
IsClosed: optional.Some(true),
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("GetMilestones", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) {
|
|
var err error
|
|
d.AssigneesData.CandidateAssignees, err = repo_model.GetRepoAssignees(ctx, d.Repository)
|
|
if err != nil {
|
|
ctx.ServerError("GetRepoAssignees", err)
|
|
return
|
|
}
|
|
d.AssigneesData.CandidateAssignees = shared_user.MakeSelfOnTop(ctx.Doer, d.AssigneesData.CandidateAssignees)
|
|
if d.Issue != nil {
|
|
_ = d.Issue.LoadAssignees(ctx)
|
|
ids := make([]string, 0, len(d.Issue.Assignees))
|
|
for _, a := range d.Issue.Assignees {
|
|
ids = append(ids, strconv.FormatInt(a.ID, 10))
|
|
}
|
|
d.AssigneesData.SelectedAssigneeIDs = strings.Join(ids, ",")
|
|
}
|
|
ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees
|
|
}
|
|
|
|
func (d *IssuePageMetaData) retrieveProjectCardsForExistingIssue(ctx *context.Context) {
|
|
if err := d.Issue.LoadProjects(ctx); err != nil {
|
|
ctx.ServerError("LoadProjects", err)
|
|
return
|
|
}
|
|
|
|
// Load column mappings for all projects
|
|
projectColumnMap, err := d.Issue.ProjectColumnMap(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("ProjectColumnMap", err)
|
|
return
|
|
}
|
|
|
|
// Build project cards for each project
|
|
d.ProjectsData.ProjectCards = make([]*issueSidebarProjectCardData, 0, len(d.Issue.Projects))
|
|
for _, project := range d.Issue.Projects {
|
|
columns, err := project.GetColumns(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("GetProjectColumns", err)
|
|
return
|
|
}
|
|
|
|
var selectedColumn *project_model.Column
|
|
columnID := projectColumnMap[project.ID]
|
|
for _, col := range columns {
|
|
if col.ID == columnID {
|
|
selectedColumn = col
|
|
break
|
|
}
|
|
}
|
|
|
|
if selectedColumn == nil {
|
|
selectedColumn, err = project.MustDefaultColumn(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("MustDefaultColumn", err)
|
|
return
|
|
}
|
|
}
|
|
d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{
|
|
Project: project,
|
|
Columns: columns,
|
|
SelectedColumn: selectedColumn,
|
|
})
|
|
}
|
|
d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards))
|
|
for _, card := range d.ProjectsData.ProjectCards {
|
|
d.ProjectsData.SelectedProjectIDs = append(d.ProjectsData.SelectedProjectIDs, card.Project.ID)
|
|
}
|
|
}
|
|
|
|
func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
|
|
if d.Issue == nil {
|
|
return
|
|
}
|
|
d.retrieveProjectCardsForExistingIssue(ctx)
|
|
}
|
|
|
|
func (d *IssuePageMetaData) SetSelectedProjectIDs(ids []int64) {
|
|
allProjects := map[int64]*project_model.Project{}
|
|
for _, p := range d.ProjectsData.OpenProjects {
|
|
allProjects[p.ID] = p
|
|
}
|
|
for _, p := range d.ProjectsData.ClosedProjects {
|
|
allProjects[p.ID] = p
|
|
}
|
|
for _, id := range ids {
|
|
if project, ok := allProjects[id]; ok {
|
|
d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{Project: project})
|
|
}
|
|
}
|
|
d.ProjectsData.SelectedProjectIDs = ids
|
|
}
|
|
|
|
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
|
|
d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository)
|
|
}
|
|
|
|
// repoReviewerSelection items to bee shown
|
|
type repoReviewerSelection struct {
|
|
IsTeam bool
|
|
Team *organization.Team
|
|
User *user_model.User
|
|
Review *issues_model.Review
|
|
CanBeDismissed bool
|
|
CanChange bool
|
|
Requested bool
|
|
ItemID int64
|
|
}
|
|
|
|
type issueSidebarReviewersData struct {
|
|
CanChooseReviewer bool
|
|
OriginalReviews issues_model.ReviewList
|
|
TeamReviewers []*repoReviewerSelection
|
|
Reviewers []*repoReviewerSelection
|
|
CurrentPullReviewers []*repoReviewerSelection
|
|
}
|
|
|
|
// RetrieveRepoReviewers find all reviewers of a repository. If issue is nil, it means the doer is creating a new PR.
|
|
func (d *IssuePageMetaData) retrieveReviewersData(ctx *context.Context) {
|
|
data := d.ReviewersData
|
|
repo := d.Repository
|
|
if ctx.Doer != nil && ctx.IsSigned {
|
|
if d.Issue == nil {
|
|
data.CanChooseReviewer = true
|
|
} else {
|
|
data.CanChooseReviewer = issue_service.CanDoerChangeReviewRequests(ctx, ctx.Doer, repo, d.Issue.PosterID)
|
|
}
|
|
}
|
|
|
|
var posterID int64
|
|
var isClosed bool
|
|
var reviews issues_model.ReviewList
|
|
var err error
|
|
|
|
if d.Issue == nil {
|
|
if ctx.Doer != nil {
|
|
posterID = ctx.Doer.ID
|
|
}
|
|
} else {
|
|
posterID = d.Issue.PosterID
|
|
if d.Issue.OriginalAuthorID > 0 {
|
|
posterID = 0 // for migrated PRs, no poster ID
|
|
}
|
|
|
|
isClosed = d.Issue.IsClosed || d.Issue.PullRequest.HasMerged
|
|
|
|
reviews, data.OriginalReviews, err = issues_model.GetReviewsByIssueID(ctx, d.Issue.ID)
|
|
if err != nil {
|
|
ctx.ServerError("GetReviewersByIssueID", err)
|
|
return
|
|
}
|
|
if len(reviews) == 0 && !data.CanChooseReviewer {
|
|
return
|
|
}
|
|
}
|
|
|
|
var (
|
|
pullReviews []*repoReviewerSelection
|
|
reviewersResult []*repoReviewerSelection
|
|
teamReviewersResult []*repoReviewerSelection
|
|
teamReviewers []*organization.Team
|
|
reviewers []*user_model.User
|
|
)
|
|
|
|
if data.CanChooseReviewer {
|
|
var err error
|
|
reviewers, err = pull_service.GetReviewers(ctx, repo, ctx.Doer.ID, posterID)
|
|
if err != nil {
|
|
ctx.ServerError("GetReviewers", err)
|
|
return
|
|
}
|
|
|
|
teamReviewers, err = pull_service.GetReviewerTeams(ctx, repo)
|
|
if err != nil {
|
|
ctx.ServerError("GetReviewerTeams", err)
|
|
return
|
|
}
|
|
|
|
if len(reviewers) > 0 {
|
|
reviewersResult = make([]*repoReviewerSelection, 0, len(reviewers))
|
|
}
|
|
|
|
if len(teamReviewers) > 0 {
|
|
teamReviewersResult = make([]*repoReviewerSelection, 0, len(teamReviewers))
|
|
}
|
|
}
|
|
|
|
pullReviews = make([]*repoReviewerSelection, 0, len(reviews))
|
|
|
|
for _, review := range reviews {
|
|
tmp := &repoReviewerSelection{
|
|
Requested: review.Type == issues_model.ReviewTypeRequest,
|
|
Review: review,
|
|
ItemID: review.ReviewerID,
|
|
}
|
|
if review.ReviewerTeamID > 0 {
|
|
tmp.IsTeam = true
|
|
tmp.ItemID = -review.ReviewerTeamID
|
|
}
|
|
|
|
if data.CanChooseReviewer {
|
|
// Users who can choose reviewers can also remove review requests
|
|
tmp.CanChange = true
|
|
} else if ctx.Doer != nil && ctx.Doer.ID == review.ReviewerID && review.Type == issues_model.ReviewTypeRequest {
|
|
// A user can refuse review requests
|
|
tmp.CanChange = true
|
|
}
|
|
|
|
pullReviews = append(pullReviews, tmp)
|
|
|
|
if data.CanChooseReviewer {
|
|
if tmp.IsTeam {
|
|
teamReviewersResult = append(teamReviewersResult, tmp)
|
|
} else {
|
|
reviewersResult = append(reviewersResult, tmp)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(pullReviews) > 0 {
|
|
// Drop all non-existing users and teams from the reviews
|
|
currentPullReviewers := make([]*repoReviewerSelection, 0, len(pullReviews))
|
|
for _, item := range pullReviews {
|
|
if item.Review.ReviewerID > 0 {
|
|
if err := item.Review.LoadReviewer(ctx); err != nil {
|
|
if user_model.IsErrUserNotExist(err) {
|
|
continue
|
|
}
|
|
ctx.ServerError("LoadReviewer", err)
|
|
return
|
|
}
|
|
item.User = item.Review.Reviewer
|
|
} else if item.Review.ReviewerTeamID > 0 {
|
|
if err := item.Review.LoadReviewerTeam(ctx); err != nil {
|
|
if organization.IsErrTeamNotExist(err) {
|
|
continue
|
|
}
|
|
ctx.ServerError("LoadReviewerTeam", err)
|
|
return
|
|
}
|
|
item.Team = item.Review.ReviewerTeam
|
|
} else {
|
|
continue
|
|
}
|
|
item.CanBeDismissed = ctx.Repo.Permission.IsAdmin() && !isClosed &&
|
|
(item.Review.Type == issues_model.ReviewTypeApprove || item.Review.Type == issues_model.ReviewTypeReject)
|
|
currentPullReviewers = append(currentPullReviewers, item)
|
|
}
|
|
data.CurrentPullReviewers = currentPullReviewers
|
|
}
|
|
|
|
if data.CanChooseReviewer && reviewersResult != nil {
|
|
preadded := len(reviewersResult)
|
|
for _, reviewer := range reviewers {
|
|
found := false
|
|
reviewAddLoop:
|
|
for _, tmp := range reviewersResult[:preadded] {
|
|
if tmp.ItemID == reviewer.ID {
|
|
tmp.User = reviewer
|
|
found = true
|
|
break reviewAddLoop
|
|
}
|
|
}
|
|
|
|
if found {
|
|
continue
|
|
}
|
|
|
|
reviewersResult = append(reviewersResult, &repoReviewerSelection{
|
|
IsTeam: false,
|
|
CanChange: true,
|
|
User: reviewer,
|
|
ItemID: reviewer.ID,
|
|
})
|
|
}
|
|
|
|
data.Reviewers = reviewersResult
|
|
}
|
|
|
|
if data.CanChooseReviewer && teamReviewersResult != nil {
|
|
preadded := len(teamReviewersResult)
|
|
for _, team := range teamReviewers {
|
|
found := false
|
|
teamReviewAddLoop:
|
|
for _, tmp := range teamReviewersResult[:preadded] {
|
|
if tmp.ItemID == -team.ID {
|
|
tmp.Team = team
|
|
found = true
|
|
break teamReviewAddLoop
|
|
}
|
|
}
|
|
|
|
if found {
|
|
continue
|
|
}
|
|
|
|
teamReviewersResult = append(teamReviewersResult, &repoReviewerSelection{
|
|
IsTeam: true,
|
|
CanChange: true,
|
|
Team: team,
|
|
ItemID: -team.ID,
|
|
})
|
|
}
|
|
|
|
data.TeamReviewers = teamReviewersResult
|
|
}
|
|
}
|
|
|
|
type issueSidebarLabelsData struct {
|
|
AllLabels []*issues_model.Label
|
|
RepoLabels []*issues_model.Label
|
|
OrgLabels []*issues_model.Label
|
|
SelectedLabelIDs string
|
|
}
|
|
|
|
func makeSelectedStringIDs[KeyType, ItemType comparable](
|
|
allLabels []*issues_model.Label, candidateKey func(candidate *issues_model.Label) KeyType,
|
|
selectedItems []ItemType, selectedKey func(selected ItemType) KeyType,
|
|
) string {
|
|
selectedIDSet := make(container.Set[string])
|
|
allLabelMap := map[KeyType]*issues_model.Label{}
|
|
for _, label := range allLabels {
|
|
allLabelMap[candidateKey(label)] = label
|
|
}
|
|
for _, item := range selectedItems {
|
|
if label, ok := allLabelMap[selectedKey(item)]; ok {
|
|
label.IsChecked = true
|
|
selectedIDSet.Add(strconv.FormatInt(label.ID, 10))
|
|
}
|
|
}
|
|
ids := selectedIDSet.Values()
|
|
sort.Strings(ids)
|
|
return strings.Join(ids, ",")
|
|
}
|
|
|
|
func (d *issueSidebarLabelsData) SetSelectedLabels(labels []*issues_model.Label) {
|
|
d.SelectedLabelIDs = makeSelectedStringIDs(
|
|
d.AllLabels, func(label *issues_model.Label) int64 { return label.ID },
|
|
labels, func(label *issues_model.Label) int64 { return label.ID },
|
|
)
|
|
}
|
|
|
|
func (d *issueSidebarLabelsData) SetSelectedLabelNames(labelNames []string) {
|
|
d.SelectedLabelIDs = makeSelectedStringIDs(
|
|
d.AllLabels, func(label *issues_model.Label) string { return strings.ToLower(label.Name) },
|
|
labelNames, strings.ToLower,
|
|
)
|
|
}
|
|
|
|
func (d *issueSidebarLabelsData) SetSelectedLabelIDs(labelIDs []int64) {
|
|
d.SelectedLabelIDs = makeSelectedStringIDs(
|
|
d.AllLabels, func(label *issues_model.Label) int64 { return label.ID },
|
|
labelIDs, func(labelID int64) int64 { return labelID },
|
|
)
|
|
}
|
|
|
|
func (d *IssuePageMetaData) retrieveLabelsData(ctx *context.Context) {
|
|
repo := d.Repository
|
|
labelsData := d.LabelsData
|
|
|
|
labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{})
|
|
if err != nil {
|
|
ctx.ServerError("GetLabelsByRepoID", err)
|
|
return
|
|
}
|
|
issues_model.SortLabelsForDisplay(labels)
|
|
labelsData.RepoLabels = labels
|
|
|
|
if repo.Owner.IsOrganization() {
|
|
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{})
|
|
if err != nil {
|
|
return
|
|
}
|
|
issues_model.SortLabelsForDisplay(orgLabels)
|
|
labelsData.OrgLabels = orgLabels
|
|
}
|
|
labelsData.AllLabels = append(labelsData.AllLabels, labelsData.RepoLabels...)
|
|
labelsData.AllLabels = append(labelsData.AllLabels, labelsData.OrgLabels...)
|
|
}
|