Files
gitea/routers/web/repo/projects.go
T
Shivansh Gargandsilverwind bcb4559c10 enhance(project): filter project milestones by linked issues (#39958)
The project milestone filter listed every milestone of the repo, or of
all accessible org repos for org projects, including ones with no issue
in the project. That made the dropdown noisy and unhelpful.

Both repo and org projects now list only milestones that have at least
one issue linked to the project, via a new `ProjectID` option on
`FindMilestoneOptions`.

Fixes: https://github.com/go-gitea/gitea/issues/39150
Co-authored-by: silverwind <me@silverwind.io>
2026-10-23 23:00:09 +00:00

518 lines
15 KiB
Go

// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"net/http"
"slices"
"strings"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
"gitea.dev/models/renderhelper"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/log"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/routers/web/shared/issue"
project_shared "gitea.dev/routers/web/shared/project"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/audit"
"gitea.dev/services/context"
"gitea.dev/services/forms"
issue_service "gitea.dev/services/issue"
project_service "gitea.dev/services/projects"
)
const (
tplProjects templates.TplName = "repo/projects/list"
tplProjectsNew templates.TplName = "repo/projects/new"
tplProjectsView templates.TplName = "repo/projects/view"
tplProjectWorkflows templates.TplName = "repo/projects/workflows"
)
// MustEnableRepoProjects check if repo projects are enabled in settings
func MustEnableRepoProjects(ctx *context.Context) {
if unit.TypeProjects.UnitGlobalDisabled() {
ctx.NotFound(nil)
return
}
if ctx.Repo.Repository != nil {
projectsUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeProjects)
if !ctx.Repo.Permission.CanRead(unit.TypeProjects) || !projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) {
ctx.NotFound(nil)
return
}
}
}
// Projects renders the home page of projects
func Projects(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.projects")
sortType := ctx.FormTrim("sort")
isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed"
keyword := ctx.FormTrim("q")
repo := ctx.Repo.Repository
page := max(ctx.FormInt("page"), 1)
ctx.Data["OpenCount"] = repo.NumOpenProjects
ctx.Data["ClosedCount"] = repo.NumClosedProjects
projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: db.ListOptions{
PageSize: setting.UI.IssuePagingNum,
Page: page,
},
RepoID: repo.ID,
IsClosed: optional.Some(isShowClosed),
OrderBy: project_model.GetSearchOrderByBySortType(sortType),
Type: project_model.TypeRepository,
Title: keyword,
})
if err != nil {
ctx.ServerError("GetProjects", err)
return
}
if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil {
ctx.ServerError("LoadIssueNumbersForProjects", err)
return
}
for i := range projects {
rctx := renderhelper.NewRenderContextRepoComment(ctx, repo)
projects[i].RenderedContent, err = markdown.RenderString(rctx, projects[i].Description)
if err != nil {
ctx.ServerError("RenderString", err)
return
}
}
ctx.Data["Projects"] = projects
if isShowClosed {
ctx.Data["State"] = "closed"
} else {
ctx.Data["State"] = "open"
}
pager := context.NewPagerBuilder(ctx).TotalCount(count).PerPageLimit(setting.UI.IssuePagingNum).CurPage(page).Build()
ctx.Data["Page"] = pager
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["IsShowClosed"] = isShowClosed
ctx.Data["IsProjectsPage"] = true
ctx.Data["SortType"] = sortType
ctx.HTML(http.StatusOK, tplProjects)
}
// RenderNewProject render creating a project page
func RenderNewProject(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.projects.new")
ctx.Data["TemplateConfigs"] = project_model.GetTemplateConfigs()
ctx.Data["CardTypes"] = project_model.GetCardConfig()
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["CancelLink"] = ctx.Repo.Repository.Link() + "/projects"
ctx.HTML(http.StatusOK, tplProjectsNew)
}
// NewProjectPost creates a new project
func NewProjectPost(ctx *context.Context) {
form := web.GetForm[*forms.CreateProjectForm](ctx)
ctx.Data["Title"] = ctx.Tr("repo.projects.new")
if ctx.HasError() {
RenderNewProject(ctx)
return
}
project := &project_model.Project{
RepoID: ctx.Repo.Repository.ID,
Title: form.Title,
Description: form.Content,
CreatorID: ctx.Doer.ID,
TemplateType: form.TemplateType,
CardType: form.CardType,
Type: project_model.TypeRepository,
}
if err := project_model.NewProject(ctx, project); err != nil {
ctx.ServerError("NewProject", err)
return
}
audit.Record(ctx, audit_model.ProjectCreate, ctx.Repo.Repository, "project", project.Title, "project_id", project.ID)
ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title))
ctx.Redirect(ctx.Repo.RepoLink + "/projects")
}
// ChangeProjectStatus updates the status of a project between "open" and "close"
func ChangeProjectStatus(ctx *context.Context) {
var toClose bool
switch ctx.PathParam("action") {
case "open":
toClose = false
case "close":
toClose = true
default:
ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects")
return
}
id := ctx.PathParamInt64("id")
if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, ctx.Repo.Repository.ID, id, toClose); err != nil {
ctx.ServerError("ChangeProjectStatusByRepoIDAndID", err)
return
}
ctx.JSONRedirect(project_model.ProjectLinkForRepo(ctx.Repo.Repository, id))
}
// DeleteProject delete a project
func DeleteProject(ctx *context.Context) {
p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetProjectByID", err)
}
return
}
if p.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil {
ctx.Flash.Error("DeleteProjectByID: " + err.Error())
} else {
audit.Record(ctx, audit_model.ProjectDelete, ctx.Repo.Repository, "project", p.Title, "project_id", p.ID)
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
}
ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects")
}
// RenderEditProject allows a project to be edited
func RenderEditProject(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.projects.edit")
ctx.Data["PageIsEditProjects"] = true
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["CardTypes"] = project_model.GetCardConfig()
p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetProjectByID", err)
}
return
}
if p.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
ctx.Data["projectID"] = p.ID
ctx.Data["title"] = p.Title
ctx.Data["content"] = p.Description
ctx.Data["card_type"] = p.CardType
ctx.Data["redirect"] = ctx.FormString("redirect")
ctx.Data["CancelLink"] = project_model.ProjectLinkForRepo(ctx.Repo.Repository, p.ID)
ctx.HTML(http.StatusOK, tplProjectsNew)
}
// EditProjectPost response for editing a project
func EditProjectPost(ctx *context.Context) {
form := web.GetForm[*forms.CreateProjectForm](ctx)
projectID := ctx.PathParamInt64("id")
ctx.Data["Title"] = ctx.Tr("repo.projects.edit")
ctx.Data["PageIsEditProjects"] = true
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["CardTypes"] = project_model.GetCardConfig()
ctx.Data["CancelLink"] = project_model.ProjectLinkForRepo(ctx.Repo.Repository, projectID)
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplProjectsNew)
return
}
p, err := project_model.GetProjectByID(ctx, projectID)
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetProjectByID", err)
}
return
}
if p.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
p.Title = form.Title
p.Description = form.Content
p.CardType = form.CardType
if err = project_model.UpdateProject(ctx, p); err != nil {
ctx.ServerError("UpdateProjects", err)
return
}
audit.Record(ctx, audit_model.ProjectUpdate, ctx.Repo.Repository, "project", p.Title, "project_id", p.ID)
ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title))
if ctx.FormString("redirect") == "project" {
ctx.Redirect(p.Link(ctx))
} else {
ctx.Redirect(ctx.Repo.RepoLink + "/projects")
}
}
// ViewProject renders the project with board view
func ViewProject(ctx *context.Context) {
project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetProjectByID", err)
}
return
}
if project.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
columns, err := project_model.GetColumns(ctx, project.ID, db.ListOptionsAll)
if err != nil {
ctx.ServerError("GetColumns", err)
return
}
preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner)
if ctx.Written() {
return
}
assigneeID := ctx.FormString("assignee")
milestoneID := ctx.FormInt64("milestone")
var milestoneIDs []int64
if milestoneID > 0 {
milestoneIDs = []int64{milestoneID}
} else if milestoneID == db.NoConditionID {
milestoneIDs = []int64{db.NoConditionID}
}
issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &issues_model.IssuesOptions{
RepoIDs: []int64{ctx.Repo.Repository.ID},
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
AssigneeID: assigneeID,
MilestoneIDs: milestoneIDs,
})
if err != nil {
ctx.ServerError("LoadIssuesOfColumns", err)
return
}
for _, column := range columns {
column.NumIssues = int64(len(issuesMap[column.ID]))
}
if project.CardType != project_model.CardTypeTextOnly {
issuesAttachmentMap := make(map[int64][]*repo_model.Attachment)
for _, issuesList := range issuesMap {
for _, issue := range issuesList {
if issueAttachment, err := repo_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil {
issuesAttachmentMap[issue.ID] = issueAttachment
}
}
}
ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap
}
linkedPrsMap := make(map[int64][]*issues_model.Issue)
for _, issuesList := range issuesMap {
for _, issue := range issuesList {
var referencedIDs []int64
for _, comment := range issue.Comments {
if comment.RefIssueID != 0 && comment.RefIsPull {
referencedIDs = append(referencedIDs, comment.RefIssueID)
}
}
if len(referencedIDs) > 0 {
if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{
IssueIDs: referencedIDs,
IsPull: optional.Some(true),
}); err == nil {
linkedPrsMap[issue.ID] = linkedPrs
}
}
}
}
ctx.Data["LinkedPRs"] = linkedPrsMap
labels, err := issues_model.GetLabelsByRepoID(ctx, project.RepoID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByRepoID", err)
return
}
if ctx.Repo.Owner.IsOrganization() {
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByOrgID", err)
return
}
labels = append(labels, orgLabels...)
}
// Get the exclusive scope for every label ID
labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs))
for _, labelID := range preparedLabelFilter.SelectedLabelIDs {
foundExclusiveScope := false
for _, label := range labels {
if label.ID == labelID || label.ID == -labelID {
labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
foundExclusiveScope = true
break
}
}
if !foundExclusiveScope {
labelExclusiveScopes = append(labelExclusiveScopes, "")
}
}
for _, l := range labels {
l.LoadSelectedLabelsAfterClick(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes)
}
ctx.Data["Labels"] = labels
ctx.Data["NumLabels"] = len(labels)
// Get assignees.
assigneeUsers, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository)
if err != nil {
ctx.ServerError("GetRepoAssignees", err)
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
ctx.Data["AssigneeID"] = assigneeID
renderMilestones(ctx, project.ID)
if ctx.Written() {
return
}
ctx.Data["MilestoneID"] = milestoneID
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository)
project.RenderedContent, err = markdown.RenderString(rctx, project.Description)
if err != nil {
ctx.ServerError("RenderString", err)
return
}
ctx.Data["Title"] = project.Title
ctx.Data["IsProjectsPage"] = true
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["Project"] = project
ctx.Data["IssuesMap"] = issuesMap
ctx.Data["Columns"] = columns
ctx.HTML(http.StatusOK, tplProjectsView)
}
// ProjectWorkflows renders the workflows page of a repository project
func ProjectWorkflows(ctx *context.Context) {
ctx.Data["IsProjectsPage"] = true
project_shared.RenderWorkflows(ctx, ctx.Repo.Permission.CanWrite(unit.TypeProjects) && !ctx.Repo.Repository.IsArchived, tplProjectWorkflows)
}
// UpdateIssueProject change an issue's project
func UpdateIssueProject(ctx *context.Context) {
issues := getActionIssues(ctx)
if ctx.Written() {
return
}
if err := issues.LoadProjects(ctx); err != nil {
ctx.ServerError("LoadProjects", err)
return
}
if _, err := issues.LoadRepositories(ctx); err != nil {
ctx.ServerError("LoadProjects", err)
return
}
projectIDs := ctx.FormStringInt64s("id")
var failedIssues []int64
for _, issue := range issues {
if err := issue_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, projectIDs); err != nil {
if errors.Is(err, util.ErrPermissionDenied) {
failedIssues = append(failedIssues, issue.ID)
continue
}
ctx.ServerError("IssueAssignOrRemoveProject", err)
return
}
}
if len(failedIssues) > 0 {
log.Warn("Failed to assign projects to %d issues due to permission denied: %v", len(failedIssues), failedIssues)
}
ctx.JSONOK()
}
// UpdateIssueProjectColumn moves an issue to a different column within its project
func UpdateIssueProjectColumn(ctx *context.Context) {
issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("issue_id"))
if err != nil {
ctx.ServerError("GetIssueByID", err)
return
}
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
ctx.NotFound(nil)
return
}
column, err := project_model.GetColumn(ctx, ctx.FormInt64("id"))
if err != nil {
ctx.ServerError("GetColumn", err)
return
}
if err := issue.LoadProjects(ctx); err != nil {
ctx.ServerError("LoadProjects", err)
return
}
// it must make sure the requested column is in this issue's projects
if !slices.ContainsFunc(issue.Projects, func(p *project_model.Project) bool { return p.ID == column.ProjectID }) {
ctx.NotFound(nil)
return
}
if err := project_service.MoveIssueToColumn(ctx, ctx.Doer, issue, column, optional.None[int64]()); err != nil {
ctx.ServerError("MoveIssueToColumn", err)
return
}
ctx.JSONOK()
}