diff options
author | Lanre Adelowo <yo@lanre.wtf> | 2020-08-17 04:07:38 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-08-16 23:07:38 -0400 |
commit | 4027c5dd7c11c1256094e202be591ec1b86a011e (patch) | |
tree | 49e361a50395f0496c49d52bfd571ee47c1ebf44 /routers | |
parent | d285b5d35a44bf9fde0682532aeef9550f78cf83 (diff) | |
download | gitea-4027c5dd7c11c1256094e202be591ec1b86a011e.tar.gz gitea-4027c5dd7c11c1256094e202be591ec1b86a011e.zip |
Kanban board (#8346)
Co-authored-by: 6543 <6543@obermui.de>
Co-authored-by: jaqra <48099350+jaqra@users.noreply.github.com>
Co-authored-by: Kerry <flatline-studios@users.noreply.github.com>
Co-authored-by: Jaqra <jaqra@hotmail.com>
Co-authored-by: Kyle Evans <kevans91@users.noreply.github.com>
Co-authored-by: Tsakiridis Ilias <TsakiDev@users.noreply.github.com>
Co-authored-by: Ilias Tsakiridis <ilias.tsakiridis@outlook.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: zeripath <art27@cantab.net>
Co-authored-by: techknowlogick <techknowlogick@gitea.io>
Diffstat (limited to 'routers')
-rw-r--r-- | routers/api/v1/repo/repo.go | 11 | ||||
-rw-r--r-- | routers/repo/issue.go | 122 | ||||
-rw-r--r-- | routers/repo/milestone.go | 45 | ||||
-rw-r--r-- | routers/repo/projects.go | 591 | ||||
-rw-r--r-- | routers/repo/pull.go | 2 | ||||
-rw-r--r-- | routers/repo/setting.go | 9 | ||||
-rw-r--r-- | routers/routes/routes.go | 27 | ||||
-rw-r--r-- | routers/user/home.go | 2 | ||||
-rw-r--r-- | routers/user/profile.go | 10 |
9 files changed, 775 insertions, 44 deletions
diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 93898cd7ef..5ebc7f251b 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -719,6 +719,17 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error { } } + if opts.HasProjects != nil && !models.UnitTypeProjects.UnitGlobalDisabled() { + if *opts.HasProjects { + units = append(units, models.RepoUnit{ + RepoID: repo.ID, + Type: models.UnitTypeProjects, + }) + } else { + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeProjects) + } + } + if err := models.UpdateRepositoryUnits(repo, units, deleteUnitTypes); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateRepositoryUnits", err) return err diff --git a/routers/repo/issue.go b/routers/repo/issue.go index 1f8f484132..2eabd6ab6c 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -104,7 +104,7 @@ func MustAllowPulls(ctx *context.Context) { } } -func issues(ctx *context.Context, milestoneID int64, isPullOption util.OptionalBool) { +func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption util.OptionalBool) { var err error viewType := ctx.Query("type") sortType := ctx.Query("sort") @@ -215,6 +215,7 @@ func issues(ctx *context.Context, milestoneID int64, isPullOption util.OptionalB PosterID: posterID, MentionedID: mentionedID, MilestoneIDs: mileIDs, + ProjectID: projectID, IsClosed: util.OptionalBoolOf(isShowClosed), IsPull: isPullOption, LabelIDs: labelIDs, @@ -357,7 +358,7 @@ func Issues(ctx *context.Context) { ctx.Data["PageIsIssueList"] = true } - issues(ctx, ctx.QueryInt64("milestone"), util.OptionalBoolOf(isPullList)) + issues(ctx, ctx.QueryInt64("milestone"), ctx.QueryInt64("project"), util.OptionalBoolOf(isPullList)) var err error // Get milestones @@ -402,6 +403,33 @@ func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *models.Repos } } +func retrieveProjects(ctx *context.Context, repo *models.Repository) { + + var err error + + ctx.Data["OpenProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{ + RepoID: repo.ID, + Page: -1, + IsClosed: util.OptionalBoolFalse, + Type: models.ProjectTypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + ctx.Data["ClosedProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{ + RepoID: repo.ID, + Page: -1, + IsClosed: util.OptionalBoolTrue, + Type: models.ProjectTypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } +} + // RetrieveRepoReviewers find all reviewers of a repository func RetrieveRepoReviewers(ctx *context.Context, repo *models.Repository, issuePosterID int64) { var err error @@ -439,6 +467,11 @@ func RetrieveRepoMetas(ctx *context.Context, repo *models.Repository, isPull boo return nil } + retrieveProjects(ctx, repo) + if ctx.Written() { + return nil + } + brs, err := ctx.Repo.GitRepo.GetBranches() if err != nil { ctx.ServerError("GetBranches", err) @@ -502,6 +535,7 @@ func NewIssue(ctx *context.Context) { ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes body := ctx.Query("body") ctx.Data["BodyQuery"] = body + ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(models.UnitTypeProjects) milestoneID := ctx.QueryInt64("milestone") if milestoneID > 0 { @@ -514,6 +548,20 @@ func NewIssue(ctx *context.Context) { } } + projectID := ctx.QueryInt64("project") + if projectID > 0 { + project, err := models.GetProjectByID(projectID) + if err != nil { + log.Error("GetProjectByID: %d: %v", projectID, err) + } else if project.RepoID != ctx.Repo.Repository.ID { + log.Error("GetProjectByID: %d: %v", projectID, fmt.Errorf("project[%d] not in repo [%d]", project.ID, ctx.Repo.Repository.ID)) + } else { + ctx.Data["project_id"] = projectID + ctx.Data["Project"] = project + } + + } + setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates) renderAttachmentSettings(ctx) @@ -528,7 +576,7 @@ func NewIssue(ctx *context.Context) { } // ValidateRepoMetas check and returns repository's meta informations -func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull bool) ([]int64, []int64, int64) { +func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull bool) ([]int64, []int64, int64, int64) { var ( repo = ctx.Repo.Repository err error @@ -536,7 +584,7 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull b labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository, isPull) if ctx.Written() { - return nil, nil, 0 + return nil, nil, 0, 0 } var labelIDs []int64 @@ -545,7 +593,7 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull b if len(form.LabelIDs) > 0 { labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) if err != nil { - return nil, nil, 0 + return nil, nil, 0, 0 } labelIDMark := base.Int64sToMap(labelIDs) @@ -567,17 +615,32 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull b ctx.Data["Milestone"], err = repo.GetMilestoneByID(milestoneID) if err != nil { ctx.ServerError("GetMilestoneByID", err) - return nil, nil, 0 + return nil, nil, 0, 0 } ctx.Data["milestone_id"] = milestoneID } + if form.ProjectID > 0 { + p, err := models.GetProjectByID(form.ProjectID) + if err != nil { + ctx.ServerError("GetProjectByID", err) + return nil, nil, 0, 0 + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return nil, nil, 0, 0 + } + + ctx.Data["Project"] = p + ctx.Data["project_id"] = form.ProjectID + } + // Check assignees var assigneeIDs []int64 if len(form.AssigneeIDs) > 0 { assigneeIDs, err = base.StringsToInt64s(strings.Split(form.AssigneeIDs, ",")) if err != nil { - return nil, nil, 0 + return nil, nil, 0, 0 } // Check if the passed assignees actually exists and is assignable @@ -585,17 +648,18 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull b assignee, err := models.GetUserByID(aID) if err != nil { ctx.ServerError("GetUserByID", err) - return nil, nil, 0 + return nil, nil, 0, 0 } valid, err := models.CanBeAssigned(assignee, repo, isPull) if err != nil { - ctx.ServerError("canBeAssigned", err) - return nil, nil, 0 + ctx.ServerError("CanBeAssigned", err) + return nil, nil, 0, 0 } + if !valid { ctx.ServerError("canBeAssigned", models.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: repo.Name}) - return nil, nil, 0 + return nil, nil, 0, 0 } } } @@ -605,7 +669,7 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull b assigneeIDs = append(assigneeIDs, form.AssigneeID) } - return labelIDs, assigneeIDs, milestoneID + return labelIDs, assigneeIDs, milestoneID, form.ProjectID } // NewIssuePost response for creating new issue @@ -623,7 +687,7 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) { attachments []string ) - labelIDs, assigneeIDs, milestoneID := ValidateRepoMetas(ctx, form, false) + labelIDs, assigneeIDs, milestoneID, projectID := ValidateRepoMetas(ctx, form, false) if ctx.Written() { return } @@ -661,6 +725,13 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) { return } + if projectID > 0 { + if err := models.ChangeProjectAssign(issue, ctx.User, projectID); err != nil { + ctx.ServerError("ChangeProjectAssign", err) + return + } + } + log.Trace("Issue created: %d/%d", repo.ID, issue.ID) ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + com.ToStr(issue.Index)) } @@ -758,6 +829,8 @@ func ViewIssue(ctx *context.Context) { ctx.Data["RequireHighlightJS"] = true ctx.Data["RequireTribute"] = true ctx.Data["RequireSimpleMDE"] = true + ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(models.UnitTypeProjects) + renderAttachmentSettings(ctx) if err = issue.LoadAttributes(); err != nil { @@ -839,6 +912,8 @@ func ViewIssue(ctx *context.Context) { // Check milestone and assignee. if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { RetrieveRepoMilestonesAndAssignees(ctx, repo) + retrieveProjects(ctx, repo) + if ctx.Written() { return } @@ -977,6 +1052,26 @@ func ViewIssue(ctx *context.Context) { if comment.MilestoneID > 0 && comment.Milestone == nil { comment.Milestone = ghostMilestone } + } else if comment.Type == models.CommentTypeProject { + + if err = comment.LoadProject(); err != nil { + ctx.ServerError("LoadProject", err) + return + } + + ghostProject := &models.Project{ + ID: -1, + Title: ctx.Tr("repo.issues.deleted_project"), + } + + if comment.OldProjectID > 0 && comment.OldProject == nil { + comment.OldProject = ghostProject + } + + if comment.ProjectID > 0 && comment.Project == nil { + comment.Project = ghostProject + } + } else if comment.Type == models.CommentTypeAssignees || comment.Type == models.CommentTypeReviewRequest { if err = comment.LoadAssigneeUser(); err != nil { ctx.ServerError("LoadAssigneeUser", err) @@ -1149,6 +1244,7 @@ func ViewIssue(ctx *context.Context) { ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login?redirect_to=" + ctx.Data["Link"].(string) ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.User.ID) ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.Data["HasProjectsWritePermission"] = ctx.Repo.CanWrite(models.UnitTypeProjects) ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.User.IsAdmin) ctx.Data["LockReasons"] = setting.Repository.Issue.LockReasons ctx.Data["RefEndName"] = git.RefEndName(issue.Ref) diff --git a/routers/repo/milestone.go b/routers/repo/milestone.go index 0bd7344878..f48c5de12e 100644 --- a/routers/repo/milestone.go +++ b/routers/repo/milestone.go @@ -207,39 +207,28 @@ func EditMilestonePost(ctx *context.Context, form auth.CreateMilestoneForm) { ctx.Redirect(ctx.Repo.RepoLink + "/milestones") } -// ChangeMilestonStatus response for change a milestone's status -func ChangeMilestonStatus(ctx *context.Context) { - m, err := models.GetMilestoneByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) - if err != nil { - if models.IsErrMilestoneNotExist(err) { - ctx.NotFound("", err) - } else { - ctx.ServerError("GetMilestoneByRepoID", err) - } - return - } - +// ChangeMilestoneStatus response for change a milestone's status +func ChangeMilestoneStatus(ctx *context.Context) { + toClose := false switch ctx.Params(":action") { case "open": - if m.IsClosed { - if err = models.ChangeMilestoneStatus(m, false); err != nil { - ctx.ServerError("ChangeMilestoneStatus", err) - return - } - } - ctx.Redirect(ctx.Repo.RepoLink + "/milestones?state=open") + toClose = false case "close": - if !m.IsClosed { - m.ClosedDateUnix = timeutil.TimeStampNow() - if err = models.ChangeMilestoneStatus(m, true); err != nil { - ctx.ServerError("ChangeMilestoneStatus", err) - return - } - } - ctx.Redirect(ctx.Repo.RepoLink + "/milestones?state=closed") + toClose = true default: ctx.Redirect(ctx.Repo.RepoLink + "/milestones") } + id := ctx.ParamsInt64(":id") + + if err := models.ChangeMilestoneStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil { + if models.IsErrMilestoneNotExist(err) { + ctx.NotFound("", err) + } else { + ctx.ServerError("ChangeMilestoneStatusByIDAndRepoID", err) + } + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/milestones?state=" + ctx.Params(":action")) } // DeleteMilestone delete a milestone @@ -274,7 +263,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) { ctx.Data["Title"] = milestone.Name ctx.Data["Milestone"] = milestone - issues(ctx, milestoneID, util.OptionalBoolNone) + issues(ctx, milestoneID, 0, util.OptionalBoolNone) ctx.Data["CanWriteIssues"] = ctx.Repo.CanWriteIssuesOrPulls(false) ctx.Data["CanWritePulls"] = ctx.Repo.CanWriteIssuesOrPulls(true) diff --git a/routers/repo/projects.go b/routers/repo/projects.go new file mode 100644 index 0000000000..daa94a308d --- /dev/null +++ b/routers/repo/projects.go @@ -0,0 +1,591 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "fmt" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +const ( + tplProjects base.TplName = "repo/projects/list" + tplProjectsNew base.TplName = "repo/projects/new" + tplProjectsView base.TplName = "repo/projects/view" + tplGenericProjectsNew base.TplName = "user/project" +) + +// MustEnableProjects check if projects are enabled in settings +func MustEnableProjects(ctx *context.Context) { + if models.UnitTypeProjects.UnitGlobalDisabled() { + ctx.NotFound("EnableKanbanBoard", nil) + return + } + + if ctx.Repo.Repository != nil { + if !ctx.Repo.CanRead(models.UnitTypeProjects) { + ctx.NotFound("MustEnableProjects", nil) + return + } + } +} + +// Projects renders the home page of projects +func Projects(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.project_board") + + sortType := ctx.QueryTrim("sort") + + isShowClosed := strings.ToLower(ctx.QueryTrim("state")) == "closed" + repo := ctx.Repo.Repository + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + + ctx.Data["OpenCount"] = repo.NumOpenProjects + ctx.Data["ClosedCount"] = repo.NumClosedProjects + + var total int + if !isShowClosed { + total = repo.NumOpenProjects + } else { + total = repo.NumClosedProjects + } + + projects, count, err := models.GetProjects(models.ProjectSearchOptions{ + RepoID: repo.ID, + Page: page, + IsClosed: util.OptionalBoolOf(isShowClosed), + SortType: sortType, + Type: models.ProjectTypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + for i := range projects { + projects[i].RenderedContent = string(markdown.Render([]byte(projects[i].Description), ctx.Repo.RepoLink, ctx.Repo.Repository.ComposeMetas())) + } + + ctx.Data["Projects"] = projects + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + numPages := 0 + if count > 0 { + numPages = int((int(count) - 1) / setting.UI.IssuePagingNum) + } + + pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, numPages) + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["IsProjectsPage"] = true + ctx.Data["SortType"] = sortType + + ctx.HTML(200, tplProjects) +} + +// NewProject render creating a project page +func NewProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + ctx.Data["ProjectTypes"] = models.GetProjectsConfig() + + ctx.HTML(200, tplProjectsNew) +} + +// NewRepoProjectPost creates a new project +func NewRepoProjectPost(ctx *context.Context, form auth.CreateProjectForm) { + + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + + if ctx.HasError() { + ctx.HTML(200, tplProjectsNew) + return + } + + if err := models.NewProject(&models.Project{ + RepoID: ctx.Repo.Repository.ID, + Title: form.Title, + Description: form.Content, + CreatorID: ctx.User.ID, + BoardType: form.BoardType, + Type: models.ProjectTypeRepository, + }); err != nil { + ctx.ServerError("NewProject", err) + return + } + + 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) { + toClose := false + switch ctx.Params(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.Redirect(ctx.Repo.RepoLink + "/projects") + } + id := ctx.ParamsInt64(":id") + + if err := models.ChangeProjectStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", err) + } else { + ctx.ServerError("ChangeProjectStatusByIDAndRepoID", err) + } + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/projects?state=" + ctx.Params(":action")) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.Context) { + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + if err := models.DeleteProjectByID(p.ID); err != nil { + ctx.Flash.Error("DeleteProjectByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) + } + + ctx.JSON(200, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/projects", + }) +} + +// EditProject allows a project to be edited +func EditProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsProjects"] = true + ctx.Data["PageIsEditProjects"] = true + + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + ctx.Data["title"] = p.Title + ctx.Data["content"] = p.Description + + ctx.HTML(200, tplProjectsNew) +} + +// EditProjectPost response for editing a project +func EditProjectPost(ctx *context.Context, form auth.CreateProjectForm) { + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsProjects"] = true + ctx.Data["PageIsEditProjects"] = true + + if ctx.HasError() { + ctx.HTML(200, tplMilestoneNew) + return + } + + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.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 + if err = models.UpdateProject(p); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) + ctx.Redirect(ctx.Repo.RepoLink + "/projects") +} + +// ViewProject renders the project board for a project +func ViewProject(ctx *context.Context) { + + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if project.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + uncategorizedBoard, err := models.GetUncategorizedBoard(project.ID) + uncategorizedBoard.Title = ctx.Tr("repo.projects.type.uncategorized") + if err != nil { + ctx.ServerError("GetUncategorizedBoard", err) + return + } + + boards, err := models.GetProjectBoards(project.ID) + if err != nil { + ctx.ServerError("GetProjectBoards", err) + return + } + + allBoards := models.ProjectBoardList{uncategorizedBoard} + allBoards = append(allBoards, boards...) + + if ctx.Data["Issues"], err = allBoards.LoadIssues(); err != nil { + ctx.ServerError("LoadIssuesOfBoards", err) + return + } + + ctx.Data["Project"] = project + ctx.Data["Boards"] = allBoards + ctx.Data["PageIsProjects"] = true + ctx.Data["RequiresDraggable"] = true + + ctx.HTML(200, tplProjectsView) +} + +// UpdateIssueProject change an issue's project +func UpdateIssueProject(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + projectID := ctx.QueryInt64("id") + for _, issue := range issues { + oldProjectID := issue.ProjectID() + if oldProjectID == projectID { + continue + } + + if err := models.ChangeProjectAssign(issue, ctx.User, projectID); err != nil { + ctx.ServerError("ChangeProjectAssign", err) + return + } + } + + ctx.JSON(200, map[string]interface{}{ + "ok": true, + }) +} + +// DeleteProjectBoard allows for the deletion of a project board +func DeleteProjectBoard(ctx *context.Context) { + if ctx.User == nil { + ctx.JSON(403, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { + ctx.JSON(403, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + pb, err := models.GetProjectBoard(ctx.ParamsInt64(":boardID")) + if err != nil { + ctx.InternalServerError(err) + return + } + if pb.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(422, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", pb.ID, project.ID), + }) + return + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(422, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", pb.ID, ctx.Repo.Repository.ID), + }) + return + } + + if err := models.DeleteProjectBoardByID(ctx.ParamsInt64(":boardID")); err != nil { + ctx.ServerError("DeleteProjectBoardByID", err) + return + } + + ctx.JSON(200, map[string]interface{}{ + "ok": true, + }) +} + +// AddBoardToProjectPost allows a new board to be added to a project. +func AddBoardToProjectPost(ctx *context.Context, form auth.EditProjectBoardTitleForm) { + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { + ctx.JSON(403, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + if err := models.NewProjectBoard(&models.ProjectBoard{ + ProjectID: project.ID, + Title: form.Title, + CreatorID: ctx.User.ID, + }); err != nil { + ctx.ServerError("NewProjectBoard", err) + return + } + + ctx.JSON(200, map[string]interface{}{ + "ok": true, + }) +} + +// EditProjectBoardTitle allows a project board's title to be updated +func EditProjectBoardTitle(ctx *context.Context, form auth.EditProjectBoardTitleForm) { + + if ctx.User == nil { + ctx.JSON(403, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { + ctx.JSON(403, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + board, err := models.GetProjectBoard(ctx.ParamsInt64(":boardID")) + if err != nil { + ctx.InternalServerError(err) + return + } + if board.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(422, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID), + }) + return + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(422, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, ctx.Repo.Repository.ID), + }) + return + } + + if form.Title != "" { + board.Title = form.Title + } + + if err := models.UpdateProjectBoard(board); err != nil { + ctx.ServerError("UpdateProjectBoard", err) + return + } + + ctx.JSON(200, map[string]interface{}{ + "ok": true, + }) +} + +// MoveIssueAcrossBoards move a card from one board to another in a project +func MoveIssueAcrossBoards(ctx *context.Context) { + + if ctx.User == nil { + ctx.JSON(403, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { + ctx.JSON(403, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + var board *models.ProjectBoard + + if ctx.ParamsInt64(":boardID") == 0 { + + board = &models.ProjectBoard{ + ID: 0, + ProjectID: 0, + Title: ctx.Tr("repo.projects.type.uncategorized"), + } + + } else { + board, err = models.GetProjectBoard(ctx.ParamsInt64(":boardID")) + if err != nil { + if models.IsErrProjectBoardNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectBoard", err) + } + return + } + if board.ProjectID != p.ID { + ctx.NotFound("", nil) + return + } + } + + issue, err := models.GetIssueByID(ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrIssueNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetIssueByID", err) + } + + return + } + + if err := models.MoveIssueAcrossProjectBoards(issue, board); err != nil { + ctx.ServerError("MoveIssueAcrossProjectBoards", err) + return + } + + ctx.JSON(200, map[string]interface{}{ + "ok": true, + }) +} + +// CreateProject renders the generic project creation page +func CreateProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + ctx.Data["ProjectTypes"] = models.GetProjectsConfig() + + ctx.HTML(200, tplGenericProjectsNew) +} + +// CreateProjectPost creates an individual and/or organization project +func CreateProjectPost(ctx *context.Context, form auth.UserCreateProjectForm) { + + user := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + + ctx.Data["ContextUser"] = user + + if ctx.HasError() { + ctx.HTML(200, tplGenericProjectsNew) + return + } + + var projectType = models.ProjectTypeIndividual + if user.IsOrganization() { + projectType = models.ProjectTypeOrganization + } + + if err := models.NewProject(&models.Project{ + Title: form.Title, + Description: form.Content, + CreatorID: user.ID, + BoardType: form.BoardType, + Type: projectType, + }); err != nil { + ctx.ServerError("NewProject", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) + ctx.Redirect(setting.AppSubURL + "/") +} diff --git a/routers/repo/pull.go b/routers/repo/pull.go index cfe30a1a19..ed70ec13a8 100644 --- a/routers/repo/pull.go +++ b/routers/repo/pull.go @@ -906,7 +906,7 @@ func CompareAndPullRequestPost(ctx *context.Context, form auth.CreateIssueForm) } defer headGitRepo.Close() - labelIDs, assigneeIDs, milestoneID := ValidateRepoMetas(ctx, form, true) + labelIDs, assigneeIDs, milestoneID, _ := ValidateRepoMetas(ctx, form, true) if ctx.Written() { return } diff --git a/routers/repo/setting.go b/routers/repo/setting.go index e03bf556be..8d07bf09a4 100644 --- a/routers/repo/setting.go +++ b/routers/repo/setting.go @@ -284,6 +284,15 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) { } } + if form.EnableProjects && !models.UnitTypeProjects.UnitGlobalDisabled() { + units = append(units, models.RepoUnit{ + RepoID: repo.ID, + Type: models.UnitTypeProjects, + }) + } else if !models.UnitTypeProjects.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeProjects) + } + if form.EnablePulls && !models.UnitTypePullRequests.UnitGlobalDisabled() { units = append(units, models.RepoUnit{ RepoID: repo.ID, diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 34157ea5ba..27af9275ed 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -275,6 +275,7 @@ func RegisterRoutes(m *macaron.Macaron) { ctx.Data["UnitWikiGlobalDisabled"] = models.UnitTypeWiki.UnitGlobalDisabled() ctx.Data["UnitIssuesGlobalDisabled"] = models.UnitTypeIssues.UnitGlobalDisabled() ctx.Data["UnitPullsGlobalDisabled"] = models.UnitTypePullRequests.UnitGlobalDisabled() + ctx.Data["UnitProjectsGlobalDisabled"] = models.UnitTypeProjects.UnitGlobalDisabled() }) // FIXME: not all routes need go through same middlewares. @@ -533,6 +534,7 @@ func RegisterRoutes(m *macaron.Macaron) { reqRepoPullsReader := context.RequireRepoReader(models.UnitTypePullRequests) reqRepoIssuesOrPullsWriter := context.RequireRepoWriterOr(models.UnitTypeIssues, models.UnitTypePullRequests) reqRepoIssuesOrPullsReader := context.RequireRepoReaderOr(models.UnitTypeIssues, models.UnitTypePullRequests) + reqRepoProjectsReader := context.RequireRepoReader(models.UnitTypeProjects) // ***** START: Organization ***** m.Group("/org", func() { @@ -750,6 +752,7 @@ func RegisterRoutes(m *macaron.Macaron) { m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone) + m.Post("/projects", reqRepoIssuesOrPullsWriter, repo.UpdateIssueProject) m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee) m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest) m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) @@ -772,7 +775,7 @@ func RegisterRoutes(m *macaron.Macaron) { Post(bindIgnErr(auth.CreateMilestoneForm{}), repo.NewMilestonePost) m.Get("/:id/edit", repo.EditMilestone) m.Post("/:id/edit", bindIgnErr(auth.CreateMilestoneForm{}), repo.EditMilestonePost) - m.Post("/:id/:action", repo.ChangeMilestonStatus) + m.Post("/:id/:action", repo.ChangeMilestoneStatus) m.Post("/delete", repo.DeleteMilestone) }, context.RepoMustNotBeArchived(), reqRepoIssuesOrPullsWriter, context.RepoRef()) m.Group("/pull", func() { @@ -853,6 +856,28 @@ func RegisterRoutes(m *macaron.Macaron) { m.Get("/milestones", reqRepoIssuesOrPullsReader, repo.Milestones) }, context.RepoRef()) + m.Group("/projects", func() { + m.Get("", repo.Projects) + m.Get("/new", repo.NewProject) + m.Post("/new", bindIgnErr(auth.CreateProjectForm{}), repo.NewRepoProjectPost) + m.Group("/:id", func() { + m.Get("", repo.ViewProject) + m.Post("", bindIgnErr(auth.EditProjectBoardTitleForm{}), repo.AddBoardToProjectPost) + m.Post("/delete", repo.DeleteProject) + + m.Get("/edit", repo.EditProject) + m.Post("/edit", bindIgnErr(auth.CreateProjectForm{}), repo.EditProjectPost) + m.Post("/^:action(open|close)$", repo.ChangeProjectStatus) + + m.Group("/:boardID", func() { + m.Put("", bindIgnErr(auth.EditProjectBoardTitleForm{}), repo.EditProjectBoardTitle) + m.Delete("", repo.DeleteProjectBoard) + + m.Post("/:index", repo.MoveIssueAcrossBoards) + }) + }) + }, reqRepoProjectsReader, repo.MustEnableProjects) + m.Group("/wiki", func() { m.Get("/?:page", repo.Wiki) m.Get("/_pages", repo.WikiPages) diff --git a/routers/user/home.go b/routers/user/home.go index 4e5fc3e4df..f7f1786b33 100644 --- a/routers/user/home.go +++ b/routers/user/home.go @@ -101,7 +101,7 @@ func retrieveFeeds(ctx *context.Context, options models.GetFeedsOptions) { ctx.Data["Feeds"] = actions } -// Dashboard render the dashborad page +// Dashboard render the dashboard page func Dashboard(ctx *context.Context) { ctxUser := getDashboardContextUser(ctx) if ctx.Written() { diff --git a/routers/user/profile.go b/routers/user/profile.go index 653d3cea22..8bf5cacc56 100644 --- a/routers/user/profile.go +++ b/routers/user/profile.go @@ -216,6 +216,16 @@ func Profile(ctx *context.Context) { } total = int(count) + case "projects": + ctx.Data["OpenProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{ + Page: -1, + IsClosed: util.OptionalBoolFalse, + Type: models.ProjectTypeIndividual, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } default: repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ ListOptions: models.ListOptions{ |