code.gitea.io/gitea@v1.22.3/routers/web/repo/projects.go (about) 1 // Copyright 2020 The Gitea Authors. All rights reserved. 2 // SPDX-License-Identifier: MIT 3 4 package repo 5 6 import ( 7 "errors" 8 "fmt" 9 "net/http" 10 "strings" 11 12 "code.gitea.io/gitea/models/db" 13 issues_model "code.gitea.io/gitea/models/issues" 14 "code.gitea.io/gitea/models/perm" 15 project_model "code.gitea.io/gitea/models/project" 16 repo_model "code.gitea.io/gitea/models/repo" 17 "code.gitea.io/gitea/models/unit" 18 "code.gitea.io/gitea/modules/base" 19 "code.gitea.io/gitea/modules/json" 20 "code.gitea.io/gitea/modules/markup" 21 "code.gitea.io/gitea/modules/markup/markdown" 22 "code.gitea.io/gitea/modules/optional" 23 "code.gitea.io/gitea/modules/setting" 24 "code.gitea.io/gitea/modules/util" 25 "code.gitea.io/gitea/modules/web" 26 "code.gitea.io/gitea/services/context" 27 "code.gitea.io/gitea/services/forms" 28 ) 29 30 const ( 31 tplProjects base.TplName = "repo/projects/list" 32 tplProjectsNew base.TplName = "repo/projects/new" 33 tplProjectsView base.TplName = "repo/projects/view" 34 ) 35 36 // MustEnableRepoProjects check if repo projects are enabled in settings 37 func MustEnableRepoProjects(ctx *context.Context) { 38 if unit.TypeProjects.UnitGlobalDisabled() { 39 ctx.NotFound("EnableKanbanBoard", nil) 40 return 41 } 42 43 if ctx.Repo.Repository != nil { 44 projectsUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeProjects) 45 if !ctx.Repo.CanRead(unit.TypeProjects) || !projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) { 46 ctx.NotFound("MustEnableRepoProjects", nil) 47 return 48 } 49 } 50 } 51 52 // Projects renders the home page of projects 53 func Projects(ctx *context.Context) { 54 ctx.Data["Title"] = ctx.Tr("repo.project_board") 55 56 sortType := ctx.FormTrim("sort") 57 58 isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" 59 keyword := ctx.FormTrim("q") 60 repo := ctx.Repo.Repository 61 page := ctx.FormInt("page") 62 if page <= 1 { 63 page = 1 64 } 65 66 ctx.Data["OpenCount"] = repo.NumOpenProjects 67 ctx.Data["ClosedCount"] = repo.NumClosedProjects 68 69 var total int 70 if !isShowClosed { 71 total = repo.NumOpenProjects 72 } else { 73 total = repo.NumClosedProjects 74 } 75 76 projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ 77 ListOptions: db.ListOptions{ 78 PageSize: setting.UI.IssuePagingNum, 79 Page: page, 80 }, 81 RepoID: repo.ID, 82 IsClosed: optional.Some(isShowClosed), 83 OrderBy: project_model.GetSearchOrderByBySortType(sortType), 84 Type: project_model.TypeRepository, 85 Title: keyword, 86 }) 87 if err != nil { 88 ctx.ServerError("GetProjects", err) 89 return 90 } 91 92 for i := range projects { 93 projects[i].RenderedContent, err = markdown.RenderString(&markup.RenderContext{ 94 Links: markup.Links{ 95 Base: ctx.Repo.RepoLink, 96 }, 97 Metas: ctx.Repo.Repository.ComposeMetas(ctx), 98 GitRepo: ctx.Repo.GitRepo, 99 Ctx: ctx, 100 }, projects[i].Description) 101 if err != nil { 102 ctx.ServerError("RenderString", err) 103 return 104 } 105 } 106 107 ctx.Data["Projects"] = projects 108 109 if isShowClosed { 110 ctx.Data["State"] = "closed" 111 } else { 112 ctx.Data["State"] = "open" 113 } 114 115 numPages := 0 116 if count > 0 { 117 numPages = (int(count) - 1/setting.UI.IssuePagingNum) 118 } 119 120 pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, numPages) 121 pager.AddParamString("state", fmt.Sprint(ctx.Data["State"])) 122 ctx.Data["Page"] = pager 123 124 ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) 125 ctx.Data["IsShowClosed"] = isShowClosed 126 ctx.Data["IsProjectsPage"] = true 127 ctx.Data["SortType"] = sortType 128 129 ctx.HTML(http.StatusOK, tplProjects) 130 } 131 132 // RenderNewProject render creating a project page 133 func RenderNewProject(ctx *context.Context) { 134 ctx.Data["Title"] = ctx.Tr("repo.projects.new") 135 ctx.Data["BoardTypes"] = project_model.GetBoardConfig() 136 ctx.Data["CardTypes"] = project_model.GetCardConfig() 137 ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) 138 ctx.Data["CancelLink"] = ctx.Repo.Repository.Link() + "/projects" 139 ctx.HTML(http.StatusOK, tplProjectsNew) 140 } 141 142 // NewProjectPost creates a new project 143 func NewProjectPost(ctx *context.Context) { 144 form := web.GetForm(ctx).(*forms.CreateProjectForm) 145 ctx.Data["Title"] = ctx.Tr("repo.projects.new") 146 147 if ctx.HasError() { 148 RenderNewProject(ctx) 149 return 150 } 151 152 if err := project_model.NewProject(ctx, &project_model.Project{ 153 RepoID: ctx.Repo.Repository.ID, 154 Title: form.Title, 155 Description: form.Content, 156 CreatorID: ctx.Doer.ID, 157 BoardType: form.BoardType, 158 CardType: form.CardType, 159 Type: project_model.TypeRepository, 160 }); err != nil { 161 ctx.ServerError("NewProject", err) 162 return 163 } 164 165 ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) 166 ctx.Redirect(ctx.Repo.RepoLink + "/projects") 167 } 168 169 // ChangeProjectStatus updates the status of a project between "open" and "close" 170 func ChangeProjectStatus(ctx *context.Context) { 171 var toClose bool 172 switch ctx.Params(":action") { 173 case "open": 174 toClose = false 175 case "close": 176 toClose = true 177 default: 178 ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects") 179 return 180 } 181 id := ctx.ParamsInt64(":id") 182 183 if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, ctx.Repo.Repository.ID, id, toClose); err != nil { 184 ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) 185 return 186 } 187 ctx.JSONRedirect(fmt.Sprintf("%s/projects/%d", ctx.Repo.RepoLink, id)) 188 } 189 190 // DeleteProject delete a project 191 func DeleteProject(ctx *context.Context) { 192 p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 193 if err != nil { 194 if project_model.IsErrProjectNotExist(err) { 195 ctx.NotFound("", nil) 196 } else { 197 ctx.ServerError("GetProjectByID", err) 198 } 199 return 200 } 201 if p.RepoID != ctx.Repo.Repository.ID { 202 ctx.NotFound("", nil) 203 return 204 } 205 206 if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { 207 ctx.Flash.Error("DeleteProjectByID: " + err.Error()) 208 } else { 209 ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) 210 } 211 212 ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects") 213 } 214 215 // RenderEditProject allows a project to be edited 216 func RenderEditProject(ctx *context.Context) { 217 ctx.Data["Title"] = ctx.Tr("repo.projects.edit") 218 ctx.Data["PageIsEditProjects"] = true 219 ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) 220 ctx.Data["CardTypes"] = project_model.GetCardConfig() 221 222 p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 223 if err != nil { 224 if project_model.IsErrProjectNotExist(err) { 225 ctx.NotFound("", nil) 226 } else { 227 ctx.ServerError("GetProjectByID", err) 228 } 229 return 230 } 231 if p.RepoID != ctx.Repo.Repository.ID { 232 ctx.NotFound("", nil) 233 return 234 } 235 236 ctx.Data["projectID"] = p.ID 237 ctx.Data["title"] = p.Title 238 ctx.Data["content"] = p.Description 239 ctx.Data["card_type"] = p.CardType 240 ctx.Data["redirect"] = ctx.FormString("redirect") 241 ctx.Data["CancelLink"] = fmt.Sprintf("%s/projects/%d", ctx.Repo.Repository.Link(), p.ID) 242 243 ctx.HTML(http.StatusOK, tplProjectsNew) 244 } 245 246 // EditProjectPost response for editing a project 247 func EditProjectPost(ctx *context.Context) { 248 form := web.GetForm(ctx).(*forms.CreateProjectForm) 249 projectID := ctx.ParamsInt64(":id") 250 251 ctx.Data["Title"] = ctx.Tr("repo.projects.edit") 252 ctx.Data["PageIsEditProjects"] = true 253 ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) 254 ctx.Data["CardTypes"] = project_model.GetCardConfig() 255 ctx.Data["CancelLink"] = fmt.Sprintf("%s/projects/%d", ctx.Repo.Repository.Link(), projectID) 256 257 if ctx.HasError() { 258 ctx.HTML(http.StatusOK, tplProjectsNew) 259 return 260 } 261 262 p, err := project_model.GetProjectByID(ctx, projectID) 263 if err != nil { 264 if project_model.IsErrProjectNotExist(err) { 265 ctx.NotFound("", nil) 266 } else { 267 ctx.ServerError("GetProjectByID", err) 268 } 269 return 270 } 271 if p.RepoID != ctx.Repo.Repository.ID { 272 ctx.NotFound("", nil) 273 return 274 } 275 276 p.Title = form.Title 277 p.Description = form.Content 278 p.CardType = form.CardType 279 if err = project_model.UpdateProject(ctx, p); err != nil { 280 ctx.ServerError("UpdateProjects", err) 281 return 282 } 283 284 ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) 285 if ctx.FormString("redirect") == "project" { 286 ctx.Redirect(p.Link(ctx)) 287 } else { 288 ctx.Redirect(ctx.Repo.RepoLink + "/projects") 289 } 290 } 291 292 // ViewProject renders the project board for a project 293 func ViewProject(ctx *context.Context) { 294 project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 295 if err != nil { 296 if project_model.IsErrProjectNotExist(err) { 297 ctx.NotFound("", nil) 298 } else { 299 ctx.ServerError("GetProjectByID", err) 300 } 301 return 302 } 303 if project.RepoID != ctx.Repo.Repository.ID { 304 ctx.NotFound("", nil) 305 return 306 } 307 308 boards, err := project.GetBoards(ctx) 309 if err != nil { 310 ctx.ServerError("GetProjectBoards", err) 311 return 312 } 313 314 issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards) 315 if err != nil { 316 ctx.ServerError("LoadIssuesOfBoards", err) 317 return 318 } 319 320 if project.CardType != project_model.CardTypeTextOnly { 321 issuesAttachmentMap := make(map[int64][]*repo_model.Attachment) 322 for _, issuesList := range issuesMap { 323 for _, issue := range issuesList { 324 if issueAttachment, err := repo_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { 325 issuesAttachmentMap[issue.ID] = issueAttachment 326 } 327 } 328 } 329 ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap 330 } 331 332 linkedPrsMap := make(map[int64][]*issues_model.Issue) 333 for _, issuesList := range issuesMap { 334 for _, issue := range issuesList { 335 var referencedIDs []int64 336 for _, comment := range issue.Comments { 337 if comment.RefIssueID != 0 && comment.RefIsPull { 338 referencedIDs = append(referencedIDs, comment.RefIssueID) 339 } 340 } 341 342 if len(referencedIDs) > 0 { 343 if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ 344 IssueIDs: referencedIDs, 345 IsPull: optional.Some(true), 346 }); err == nil { 347 linkedPrsMap[issue.ID] = linkedPrs 348 } 349 } 350 } 351 } 352 ctx.Data["LinkedPRs"] = linkedPrsMap 353 354 project.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ 355 Links: markup.Links{ 356 Base: ctx.Repo.RepoLink, 357 }, 358 Metas: ctx.Repo.Repository.ComposeMetas(ctx), 359 GitRepo: ctx.Repo.GitRepo, 360 Ctx: ctx, 361 }, project.Description) 362 if err != nil { 363 ctx.ServerError("RenderString", err) 364 return 365 } 366 367 ctx.Data["IsProjectsPage"] = true 368 ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) 369 ctx.Data["Project"] = project 370 ctx.Data["IssuesMap"] = issuesMap 371 ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend 372 373 ctx.HTML(http.StatusOK, tplProjectsView) 374 } 375 376 // UpdateIssueProject change an issue's project 377 func UpdateIssueProject(ctx *context.Context) { 378 issues := getActionIssues(ctx) 379 if ctx.Written() { 380 return 381 } 382 383 if err := issues.LoadProjects(ctx); err != nil { 384 ctx.ServerError("LoadProjects", err) 385 return 386 } 387 if _, err := issues.LoadRepositories(ctx); err != nil { 388 ctx.ServerError("LoadProjects", err) 389 return 390 } 391 392 projectID := ctx.FormInt64("id") 393 for _, issue := range issues { 394 if issue.Project != nil && issue.Project.ID == projectID { 395 continue 396 } 397 if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { 398 if errors.Is(err, util.ErrPermissionDenied) { 399 continue 400 } 401 ctx.ServerError("IssueAssignOrRemoveProject", err) 402 return 403 } 404 } 405 406 ctx.JSONOK() 407 } 408 409 // DeleteProjectBoard allows for the deletion of a project board 410 func DeleteProjectBoard(ctx *context.Context) { 411 if ctx.Doer == nil { 412 ctx.JSON(http.StatusForbidden, map[string]string{ 413 "message": "Only signed in users are allowed to perform this action.", 414 }) 415 return 416 } 417 418 if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { 419 ctx.JSON(http.StatusForbidden, map[string]string{ 420 "message": "Only authorized users are allowed to perform this action.", 421 }) 422 return 423 } 424 425 project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 426 if err != nil { 427 if project_model.IsErrProjectNotExist(err) { 428 ctx.NotFound("", nil) 429 } else { 430 ctx.ServerError("GetProjectByID", err) 431 } 432 return 433 } 434 435 pb, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) 436 if err != nil { 437 ctx.ServerError("GetProjectBoard", err) 438 return 439 } 440 if pb.ProjectID != ctx.ParamsInt64(":id") { 441 ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ 442 "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", pb.ID, project.ID), 443 }) 444 return 445 } 446 447 if project.RepoID != ctx.Repo.Repository.ID { 448 ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ 449 "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", pb.ID, ctx.Repo.Repository.ID), 450 }) 451 return 452 } 453 454 if err := project_model.DeleteBoardByID(ctx, ctx.ParamsInt64(":boardID")); err != nil { 455 ctx.ServerError("DeleteProjectBoardByID", err) 456 return 457 } 458 459 ctx.JSONOK() 460 } 461 462 // AddBoardToProjectPost allows a new board to be added to a project. 463 func AddBoardToProjectPost(ctx *context.Context) { 464 form := web.GetForm(ctx).(*forms.EditProjectBoardForm) 465 if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { 466 ctx.JSON(http.StatusForbidden, map[string]string{ 467 "message": "Only authorized users are allowed to perform this action.", 468 }) 469 return 470 } 471 472 project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) 473 if err != nil { 474 if project_model.IsErrProjectNotExist(err) { 475 ctx.NotFound("", nil) 476 } else { 477 ctx.ServerError("GetProjectByID", err) 478 } 479 return 480 } 481 482 if err := project_model.NewBoard(ctx, &project_model.Board{ 483 ProjectID: project.ID, 484 Title: form.Title, 485 Color: form.Color, 486 CreatorID: ctx.Doer.ID, 487 }); err != nil { 488 ctx.ServerError("NewProjectBoard", err) 489 return 490 } 491 492 ctx.JSONOK() 493 } 494 495 func checkProjectBoardChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Board) { 496 if ctx.Doer == nil { 497 ctx.JSON(http.StatusForbidden, map[string]string{ 498 "message": "Only signed in users are allowed to perform this action.", 499 }) 500 return nil, nil 501 } 502 503 if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { 504 ctx.JSON(http.StatusForbidden, map[string]string{ 505 "message": "Only authorized users are allowed to perform this action.", 506 }) 507 return nil, nil 508 } 509 510 project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 511 if err != nil { 512 if project_model.IsErrProjectNotExist(err) { 513 ctx.NotFound("", nil) 514 } else { 515 ctx.ServerError("GetProjectByID", err) 516 } 517 return nil, nil 518 } 519 520 board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) 521 if err != nil { 522 ctx.ServerError("GetProjectBoard", err) 523 return nil, nil 524 } 525 if board.ProjectID != ctx.ParamsInt64(":id") { 526 ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ 527 "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID), 528 }) 529 return nil, nil 530 } 531 532 if project.RepoID != ctx.Repo.Repository.ID { 533 ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ 534 "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, ctx.Repo.Repository.ID), 535 }) 536 return nil, nil 537 } 538 return project, board 539 } 540 541 // EditProjectBoard allows a project board's to be updated 542 func EditProjectBoard(ctx *context.Context) { 543 form := web.GetForm(ctx).(*forms.EditProjectBoardForm) 544 _, board := checkProjectBoardChangePermissions(ctx) 545 if ctx.Written() { 546 return 547 } 548 549 if form.Title != "" { 550 board.Title = form.Title 551 } 552 553 board.Color = form.Color 554 555 if form.Sorting != 0 { 556 board.Sorting = form.Sorting 557 } 558 559 if err := project_model.UpdateBoard(ctx, board); err != nil { 560 ctx.ServerError("UpdateProjectBoard", err) 561 return 562 } 563 564 ctx.JSONOK() 565 } 566 567 // SetDefaultProjectBoard set default board for uncategorized issues/pulls 568 func SetDefaultProjectBoard(ctx *context.Context) { 569 project, board := checkProjectBoardChangePermissions(ctx) 570 if ctx.Written() { 571 return 572 } 573 574 if err := project_model.SetDefaultBoard(ctx, project.ID, board.ID); err != nil { 575 ctx.ServerError("SetDefaultBoard", err) 576 return 577 } 578 579 ctx.JSONOK() 580 } 581 582 // MoveIssues moves or keeps issues in a column and sorts them inside that column 583 func MoveIssues(ctx *context.Context) { 584 if ctx.Doer == nil { 585 ctx.JSON(http.StatusForbidden, map[string]string{ 586 "message": "Only signed in users are allowed to perform this action.", 587 }) 588 return 589 } 590 591 if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { 592 ctx.JSON(http.StatusForbidden, map[string]string{ 593 "message": "Only authorized users are allowed to perform this action.", 594 }) 595 return 596 } 597 598 project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 599 if err != nil { 600 if project_model.IsErrProjectNotExist(err) { 601 ctx.NotFound("ProjectNotExist", nil) 602 } else { 603 ctx.ServerError("GetProjectByID", err) 604 } 605 return 606 } 607 if project.RepoID != ctx.Repo.Repository.ID { 608 ctx.NotFound("InvalidRepoID", nil) 609 return 610 } 611 612 board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) 613 if err != nil { 614 if project_model.IsErrProjectBoardNotExist(err) { 615 ctx.NotFound("ProjectBoardNotExist", nil) 616 } else { 617 ctx.ServerError("GetProjectBoard", err) 618 } 619 return 620 } 621 622 if board.ProjectID != project.ID { 623 ctx.NotFound("BoardNotInProject", nil) 624 return 625 } 626 627 type movedIssuesForm struct { 628 Issues []struct { 629 IssueID int64 `json:"issueID"` 630 Sorting int64 `json:"sorting"` 631 } `json:"issues"` 632 } 633 634 form := &movedIssuesForm{} 635 if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { 636 ctx.ServerError("DecodeMovedIssuesForm", err) 637 } 638 639 issueIDs := make([]int64, 0, len(form.Issues)) 640 sortedIssueIDs := make(map[int64]int64) 641 for _, issue := range form.Issues { 642 issueIDs = append(issueIDs, issue.IssueID) 643 sortedIssueIDs[issue.Sorting] = issue.IssueID 644 } 645 movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) 646 if err != nil { 647 if issues_model.IsErrIssueNotExist(err) { 648 ctx.NotFound("IssueNotExisting", nil) 649 } else { 650 ctx.ServerError("GetIssueByID", err) 651 } 652 return 653 } 654 655 if len(movedIssues) != len(form.Issues) { 656 ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) 657 return 658 } 659 660 for _, issue := range movedIssues { 661 if issue.RepoID != project.RepoID { 662 ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) 663 return 664 } 665 } 666 667 if err = project_model.MoveIssuesOnProjectBoard(ctx, board, sortedIssueIDs); err != nil { 668 ctx.ServerError("MoveIssuesOnProjectBoard", err) 669 return 670 } 671 672 ctx.JSONOK() 673 }