mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-02 13:44:16 +09:00
Project board cards list the pull requests that reference their issue, but loaded them without checking the viewer's access. A private pull request mentioning a public issue showed its title, number and state on that issue's board to anyone who can view the board, anonymous visitors included. Repository and organization boards now skip linked pull requests in repositories where the viewer cannot read pull requests, matching how the issue page already hides such references. --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: bircni <bircni@icloud.com> Co-authored-by: Giteabot <teabot@gitea.io>
477 lines
14 KiB
Go
477 lines
14 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package org
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
audit_model "gitea.dev/models/audit"
|
|
"gitea.dev/models/db"
|
|
issues_model "gitea.dev/models/issues"
|
|
project_model "gitea.dev/models/project"
|
|
repo_model "gitea.dev/models/repo"
|
|
"gitea.dev/models/unit"
|
|
"gitea.dev/modules/optional"
|
|
"gitea.dev/modules/setting"
|
|
"gitea.dev/modules/templates"
|
|
"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"
|
|
project_service "gitea.dev/services/projects"
|
|
|
|
"xorm.io/builder"
|
|
)
|
|
|
|
const (
|
|
tplProjects templates.TplName = "org/projects/list"
|
|
tplProjectsNew templates.TplName = "org/projects/new"
|
|
tplProjectsView templates.TplName = "org/projects/view"
|
|
tplProjectWorkflows templates.TplName = "org/projects/workflows"
|
|
)
|
|
|
|
// Projects renders the home page of projects
|
|
func Projects(ctx *context.Context) {
|
|
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
|
ctx.ServerError("RenderUserOrgHeader", err)
|
|
return
|
|
}
|
|
ctx.Data["Title"] = ctx.Tr("repo.projects")
|
|
|
|
sortType := ctx.FormTrim("sort")
|
|
|
|
isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed"
|
|
keyword := ctx.FormTrim("q")
|
|
page := max(ctx.FormInt("page"), 1)
|
|
|
|
var projectType project_model.Type
|
|
if ctx.ContextUser.IsOrganization() {
|
|
projectType = project_model.TypeOrganization
|
|
} else {
|
|
projectType = project_model.TypeIndividual
|
|
}
|
|
projects, total, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{
|
|
ListOptions: db.ListOptions{
|
|
Page: page,
|
|
PageSize: setting.UI.IssuePagingNum,
|
|
},
|
|
OwnerID: ctx.ContextUser.ID,
|
|
IsClosed: optional.Some(isShowClosed),
|
|
OrderBy: project_model.GetSearchOrderByBySortType(sortType),
|
|
Type: projectType,
|
|
Title: keyword,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("FindProjects", err)
|
|
return
|
|
}
|
|
|
|
if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil {
|
|
ctx.ServerError("LoadIssueNumbersForProjects", err)
|
|
return
|
|
}
|
|
|
|
opTotal, err := db.Count[project_model.Project](ctx, project_model.SearchOptions{
|
|
OwnerID: ctx.ContextUser.ID,
|
|
IsClosed: optional.Some(!isShowClosed),
|
|
Type: projectType,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("CountProjects", err)
|
|
return
|
|
}
|
|
|
|
if isShowClosed {
|
|
ctx.Data["OpenCount"] = opTotal
|
|
ctx.Data["ClosedCount"] = total
|
|
} else {
|
|
ctx.Data["OpenCount"] = total
|
|
ctx.Data["ClosedCount"] = opTotal
|
|
}
|
|
|
|
ctx.Data["Projects"] = projects
|
|
|
|
if isShowClosed {
|
|
ctx.Data["State"] = "closed"
|
|
} else {
|
|
ctx.Data["State"] = "open"
|
|
}
|
|
renderUtils := templates.NewRenderUtils(ctx)
|
|
for _, project := range projects {
|
|
project.RenderedContent = renderUtils.MarkdownToHtml(project.Description)
|
|
}
|
|
|
|
pager := context.NewPagerBuilder(ctx).TotalCount(total).PerPageLimit(setting.UI.IssuePagingNum).CurPage(page).Build()
|
|
ctx.Data["Page"] = pager
|
|
|
|
ctx.Data["CanWriteProjects"] = canWriteProjects(ctx)
|
|
ctx.Data["IsShowClosed"] = isShowClosed
|
|
ctx.Data["PageIsViewProjects"] = true
|
|
ctx.Data["SortType"] = sortType
|
|
|
|
ctx.HTML(http.StatusOK, tplProjects)
|
|
}
|
|
|
|
func canWriteProjects(ctx *context.Context) bool {
|
|
if ctx.ContextUser.IsOrganization() {
|
|
return ctx.Org.CanWriteAnyRepoUnit(ctx, unit.TypeProjects)
|
|
}
|
|
return ctx.Doer != nil && ctx.ContextUser.ID == ctx.Doer.ID
|
|
}
|
|
|
|
// 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"] = canWriteProjects(ctx)
|
|
ctx.Data["PageIsViewProjects"] = true
|
|
ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink()
|
|
ctx.Data["CancelLink"] = ctx.ContextUser.HomeLink() + "/-/projects"
|
|
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
|
ctx.ServerError("RenderUserOrgHeader", err)
|
|
return
|
|
}
|
|
|
|
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 _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
|
ctx.ServerError("RenderUserOrgHeader", err)
|
|
return
|
|
}
|
|
|
|
if ctx.HasError() {
|
|
RenderNewProject(ctx)
|
|
return
|
|
}
|
|
|
|
newProject := project_model.Project{
|
|
OwnerID: ctx.ContextUser.ID,
|
|
Title: form.Title,
|
|
Description: form.Content,
|
|
CreatorID: ctx.Doer.ID,
|
|
TemplateType: form.TemplateType,
|
|
CardType: form.CardType,
|
|
}
|
|
|
|
if ctx.ContextUser.IsOrganization() {
|
|
newProject.Type = project_model.TypeOrganization
|
|
} else {
|
|
newProject.Type = project_model.TypeIndividual
|
|
}
|
|
|
|
if err := project_model.NewProject(ctx, &newProject); err != nil {
|
|
ctx.ServerError("NewProject", err)
|
|
return
|
|
}
|
|
audit.Record(ctx, audit_model.ProjectCreate, ctx.ContextUser, "project", newProject.Title, "project_id", newProject.ID)
|
|
|
|
ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title))
|
|
ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/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.ContextUser.HomeLink() + "/-/projects")
|
|
return
|
|
}
|
|
id := ctx.PathParamInt64("id")
|
|
|
|
project, err := project_model.GetProjectByIDAndOwner(ctx, id, ctx.ContextUser.ID)
|
|
if err != nil {
|
|
ctx.ServerError("GetProjectByID", err)
|
|
return
|
|
}
|
|
|
|
if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, project.ID, toClose); err != nil {
|
|
ctx.ServerError("ChangeProjectStatusByRepoIDAndID", err)
|
|
return
|
|
}
|
|
ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.ContextUser, project.ID))
|
|
}
|
|
|
|
// DeleteProject delete a project
|
|
func DeleteProject(ctx *context.Context) {
|
|
p, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
|
|
if err != nil {
|
|
ctx.ServerError("GetProjectByID", err)
|
|
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.ContextUser, "project", p.Title, "project_id", p.ID)
|
|
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
|
|
}
|
|
|
|
ctx.JSONRedirect(ctx.ContextUser.HomeLink() + "/-/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["PageIsViewProjects"] = true
|
|
ctx.Data["CanWriteProjects"] = canWriteProjects(ctx)
|
|
ctx.Data["CardTypes"] = project_model.GetCardConfig()
|
|
|
|
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
|
ctx.ServerError("RenderUserOrgHeader", err)
|
|
return
|
|
}
|
|
|
|
p, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
|
|
if err != nil {
|
|
ctx.ServerError("GetProjectByID", err)
|
|
return
|
|
}
|
|
|
|
ctx.Data["projectID"] = p.ID
|
|
ctx.Data["title"] = p.Title
|
|
ctx.Data["content"] = p.Description
|
|
ctx.Data["redirect"] = ctx.FormString("redirect")
|
|
ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink()
|
|
ctx.Data["card_type"] = p.CardType
|
|
ctx.Data["CancelLink"] = project_model.ProjectLinkForOrg(ctx.ContextUser, 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["PageIsViewProjects"] = true
|
|
ctx.Data["CanWriteProjects"] = canWriteProjects(ctx)
|
|
ctx.Data["CardTypes"] = project_model.GetCardConfig()
|
|
ctx.Data["CancelLink"] = project_model.ProjectLinkForOrg(ctx.ContextUser, projectID)
|
|
|
|
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
|
ctx.ServerError("RenderUserOrgHeader", err)
|
|
return
|
|
}
|
|
|
|
if ctx.HasError() {
|
|
ctx.HTML(http.StatusOK, tplProjectsNew)
|
|
return
|
|
}
|
|
|
|
p, err := project_model.GetProjectByIDAndOwner(ctx, projectID, ctx.ContextUser.ID)
|
|
if err != nil {
|
|
ctx.ServerError("GetProjectByID", err)
|
|
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.ContextUser, "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.ContextUser.HomeLink() + "/-/projects")
|
|
}
|
|
}
|
|
|
|
// ViewProject renders the project with board view for a project
|
|
func ViewProject(ctx *context.Context) {
|
|
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
|
|
if err != nil {
|
|
ctx.ServerError("GetProjectByID", err)
|
|
return
|
|
}
|
|
|
|
if err := project.LoadOwner(ctx); err != nil {
|
|
ctx.ServerError("LoadOwner", err)
|
|
return
|
|
}
|
|
|
|
columns, err := project_model.GetColumns(ctx, project.ID, db.ListOptionsAll)
|
|
if err != nil {
|
|
ctx.ServerError("GetColumns", err)
|
|
return
|
|
}
|
|
|
|
preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
assigneeID := ctx.FormString("assignee")
|
|
milestoneID := ctx.FormInt64("milestone")
|
|
|
|
// Prepare milestone IDs for filtering
|
|
var milestoneIDs []int64
|
|
if milestoneID > 0 {
|
|
milestoneIDs = []int64{milestoneID}
|
|
} else if milestoneID == db.NoConditionID {
|
|
milestoneIDs = []int64{db.NoConditionID}
|
|
}
|
|
|
|
opts := issues_model.IssuesOptions{
|
|
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
|
AssigneeID: assigneeID,
|
|
MilestoneIDs: milestoneIDs,
|
|
Owner: project.Owner,
|
|
}
|
|
if ctx.Doer != nil {
|
|
opts.Doer = ctx.Doer
|
|
} else {
|
|
opts.AllPublic = true
|
|
}
|
|
|
|
issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &opts)
|
|
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, err := project_service.LoadLinkedPullRequests(ctx, issuesMap, ctx.Doer)
|
|
if err != nil {
|
|
ctx.ServerError("LoadLinkedPullRequests", err)
|
|
return
|
|
}
|
|
|
|
// TODO: Add option to filter also by repository specific labels
|
|
labels, err := issues_model.GetLabelsByOrgID(ctx, project.OwnerID, "", db.ListOptions{})
|
|
if err != nil {
|
|
ctx.ServerError("GetLabelsByOrgID", err)
|
|
return
|
|
}
|
|
|
|
// 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 milestones for filtering
|
|
// For organization projects, we need to get milestones from all repos the user has access to
|
|
var milestones issues_model.MilestoneList
|
|
if project.RepoID > 0 {
|
|
// Repo-specific project
|
|
milestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
|
|
RepoID: project.RepoID,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("GetRepoMilestones", err)
|
|
return
|
|
}
|
|
} else {
|
|
// Organization-wide project - get milestones from all organization repos
|
|
// but only from repositories the current user can access.
|
|
// Use RepoCond with a subquery to avoid materializing all repo IDs in memory
|
|
// which can hit SQL parameter limits for orgs with many repos.
|
|
accessCond := repo_model.AccessibleRepositoryCondition(ctx.Doer, unit.TypeIssues)
|
|
repoCond := builder.And(
|
|
builder.Eq{"owner_id": project.OwnerID},
|
|
accessCond,
|
|
)
|
|
milestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
|
|
RepoCond: repoCond,
|
|
ProjectID: project.ID,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("GetOrgMilestones", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
openMilestones, closedMilestones := milestones.SplitByOpenClosed()
|
|
ctx.Data["OpenMilestones"] = openMilestones
|
|
ctx.Data["ClosedMilestones"] = closedMilestones
|
|
ctx.Data["MilestoneID"] = milestoneID
|
|
|
|
// Get assignees.
|
|
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, project.ID)
|
|
if err != nil {
|
|
ctx.ServerError("LoadIssuesAssigneesForProject", err)
|
|
return
|
|
}
|
|
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
|
|
ctx.Data["AssigneeID"] = assigneeID
|
|
|
|
project.RenderedContent = templates.NewRenderUtils(ctx).MarkdownToHtml(project.Description)
|
|
ctx.Data["LinkedPRs"] = linkedPrsMap
|
|
ctx.Data["PageIsViewProjects"] = true
|
|
ctx.Data["CanWriteProjects"] = canWriteProjects(ctx)
|
|
ctx.Data["Project"] = project
|
|
ctx.Data["IssuesMap"] = issuesMap
|
|
ctx.Data["Columns"] = columns
|
|
ctx.Data["Title"] = fmt.Sprintf("%s - %s", project.Title, ctx.ContextUser.DisplayName())
|
|
|
|
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
|
ctx.ServerError("RenderUserOrgHeader", err)
|
|
return
|
|
}
|
|
|
|
ctx.HTML(http.StatusOK, tplProjectsView)
|
|
}
|
|
|
|
// ProjectWorkflows renders the workflows page of an owner-level project
|
|
func ProjectWorkflows(ctx *context.Context) {
|
|
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
|
ctx.ServerError("RenderUserOrgHeader", err)
|
|
return
|
|
}
|
|
ctx.Data["PageIsViewProjects"] = true
|
|
project_shared.RenderWorkflows(ctx, canWriteProjects(ctx), tplProjectWorkflows)
|
|
}
|