code.gitea.io/gitea@v1.22.3/routers/api/v1/repo/issue.go (about) 1 // Copyright 2016 The Gogs Authors. All rights reserved. 2 // Copyright 2018 The Gitea Authors. All rights reserved. 3 // SPDX-License-Identifier: MIT 4 5 package repo 6 7 import ( 8 "errors" 9 "fmt" 10 "net/http" 11 "strconv" 12 "strings" 13 "time" 14 15 "code.gitea.io/gitea/models/db" 16 issues_model "code.gitea.io/gitea/models/issues" 17 "code.gitea.io/gitea/models/organization" 18 access_model "code.gitea.io/gitea/models/perm/access" 19 repo_model "code.gitea.io/gitea/models/repo" 20 "code.gitea.io/gitea/models/unit" 21 user_model "code.gitea.io/gitea/models/user" 22 issue_indexer "code.gitea.io/gitea/modules/indexer/issues" 23 "code.gitea.io/gitea/modules/optional" 24 "code.gitea.io/gitea/modules/setting" 25 api "code.gitea.io/gitea/modules/structs" 26 "code.gitea.io/gitea/modules/timeutil" 27 "code.gitea.io/gitea/modules/web" 28 "code.gitea.io/gitea/routers/api/v1/utils" 29 "code.gitea.io/gitea/services/context" 30 "code.gitea.io/gitea/services/convert" 31 issue_service "code.gitea.io/gitea/services/issue" 32 ) 33 34 // SearchIssues searches for issues across the repositories that the user has access to 35 func SearchIssues(ctx *context.APIContext) { 36 // swagger:operation GET /repos/issues/search issue issueSearchIssues 37 // --- 38 // summary: Search for issues across the repositories that the user has access to 39 // produces: 40 // - application/json 41 // parameters: 42 // - name: state 43 // in: query 44 // description: whether issue is open or closed 45 // type: string 46 // - name: labels 47 // in: query 48 // description: comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded 49 // type: string 50 // - name: milestones 51 // in: query 52 // description: comma separated list of milestone names. Fetch only issues that have any of this milestones. Non existent are discarded 53 // type: string 54 // - name: q 55 // in: query 56 // description: search string 57 // type: string 58 // - name: priority_repo_id 59 // in: query 60 // description: repository to prioritize in the results 61 // type: integer 62 // format: int64 63 // - name: type 64 // in: query 65 // description: filter by type (issues / pulls) if set 66 // type: string 67 // - name: since 68 // in: query 69 // description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format 70 // type: string 71 // format: date-time 72 // required: false 73 // - name: before 74 // in: query 75 // description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format 76 // type: string 77 // format: date-time 78 // required: false 79 // - name: assigned 80 // in: query 81 // description: filter (issues / pulls) assigned to you, default is false 82 // type: boolean 83 // - name: created 84 // in: query 85 // description: filter (issues / pulls) created by you, default is false 86 // type: boolean 87 // - name: mentioned 88 // in: query 89 // description: filter (issues / pulls) mentioning you, default is false 90 // type: boolean 91 // - name: review_requested 92 // in: query 93 // description: filter pulls requesting your review, default is false 94 // type: boolean 95 // - name: reviewed 96 // in: query 97 // description: filter pulls reviewed by you, default is false 98 // type: boolean 99 // - name: owner 100 // in: query 101 // description: filter by owner 102 // type: string 103 // - name: team 104 // in: query 105 // description: filter by team (requires organization owner parameter to be provided) 106 // type: string 107 // - name: page 108 // in: query 109 // description: page number of results to return (1-based) 110 // type: integer 111 // - name: limit 112 // in: query 113 // description: page size of results 114 // type: integer 115 // responses: 116 // "200": 117 // "$ref": "#/responses/IssueList" 118 119 before, since, err := context.GetQueryBeforeSince(ctx.Base) 120 if err != nil { 121 ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) 122 return 123 } 124 125 var isClosed optional.Option[bool] 126 switch ctx.FormString("state") { 127 case "closed": 128 isClosed = optional.Some(true) 129 case "all": 130 isClosed = optional.None[bool]() 131 default: 132 isClosed = optional.Some(false) 133 } 134 135 var ( 136 repoIDs []int64 137 allPublic bool 138 ) 139 { 140 // find repos user can access (for issue search) 141 opts := &repo_model.SearchRepoOptions{ 142 Private: false, 143 AllPublic: true, 144 TopicOnly: false, 145 Collaborate: optional.None[bool](), 146 // This needs to be a column that is not nil in fixtures or 147 // MySQL will return different results when sorting by null in some cases 148 OrderBy: db.SearchOrderByAlphabetically, 149 Actor: ctx.Doer, 150 } 151 if ctx.IsSigned { 152 opts.Private = !ctx.PublicOnly 153 opts.AllLimited = true 154 } 155 if ctx.FormString("owner") != "" { 156 owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) 157 if err != nil { 158 if user_model.IsErrUserNotExist(err) { 159 ctx.Error(http.StatusBadRequest, "Owner not found", err) 160 } else { 161 ctx.Error(http.StatusInternalServerError, "GetUserByName", err) 162 } 163 return 164 } 165 opts.OwnerID = owner.ID 166 opts.AllLimited = false 167 opts.AllPublic = false 168 opts.Collaborate = optional.Some(false) 169 } 170 if ctx.FormString("team") != "" { 171 if ctx.FormString("owner") == "" { 172 ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") 173 return 174 } 175 team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team")) 176 if err != nil { 177 if organization.IsErrTeamNotExist(err) { 178 ctx.Error(http.StatusBadRequest, "Team not found", err) 179 } else { 180 ctx.Error(http.StatusInternalServerError, "GetUserByName", err) 181 } 182 return 183 } 184 opts.TeamID = team.ID 185 } 186 187 if opts.AllPublic { 188 allPublic = true 189 opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer 190 } 191 repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts) 192 if err != nil { 193 ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err) 194 return 195 } 196 if len(repoIDs) == 0 { 197 // no repos found, don't let the indexer return all repos 198 repoIDs = []int64{0} 199 } 200 } 201 202 keyword := ctx.FormTrim("q") 203 if strings.IndexByte(keyword, 0) >= 0 { 204 keyword = "" 205 } 206 207 var isPull optional.Option[bool] 208 switch ctx.FormString("type") { 209 case "pulls": 210 isPull = optional.Some(true) 211 case "issues": 212 isPull = optional.Some(false) 213 default: 214 isPull = optional.None[bool]() 215 } 216 217 var includedAnyLabels []int64 218 { 219 labels := ctx.FormTrim("labels") 220 var includedLabelNames []string 221 if len(labels) > 0 { 222 includedLabelNames = strings.Split(labels, ",") 223 } 224 includedAnyLabels, err = issues_model.GetLabelIDsByNames(ctx, includedLabelNames) 225 if err != nil { 226 ctx.Error(http.StatusInternalServerError, "GetLabelIDsByNames", err) 227 return 228 } 229 } 230 231 var includedMilestones []int64 232 { 233 milestones := ctx.FormTrim("milestones") 234 var includedMilestoneNames []string 235 if len(milestones) > 0 { 236 includedMilestoneNames = strings.Split(milestones, ",") 237 } 238 includedMilestones, err = issues_model.GetMilestoneIDsByNames(ctx, includedMilestoneNames) 239 if err != nil { 240 ctx.Error(http.StatusInternalServerError, "GetMilestoneIDsByNames", err) 241 return 242 } 243 } 244 245 // this api is also used in UI, 246 // so the default limit is set to fit UI needs 247 limit := ctx.FormInt("limit") 248 if limit == 0 { 249 limit = setting.UI.IssuePagingNum 250 } else if limit > setting.API.MaxResponseItems { 251 limit = setting.API.MaxResponseItems 252 } 253 254 searchOpt := &issue_indexer.SearchOptions{ 255 Paginator: &db.ListOptions{ 256 PageSize: limit, 257 Page: ctx.FormInt("page"), 258 }, 259 Keyword: keyword, 260 RepoIDs: repoIDs, 261 AllPublic: allPublic, 262 IsPull: isPull, 263 IsClosed: isClosed, 264 IncludedAnyLabelIDs: includedAnyLabels, 265 MilestoneIDs: includedMilestones, 266 SortBy: issue_indexer.SortByCreatedDesc, 267 } 268 269 if since != 0 { 270 searchOpt.UpdatedAfterUnix = optional.Some(since) 271 } 272 if before != 0 { 273 searchOpt.UpdatedBeforeUnix = optional.Some(before) 274 } 275 276 if ctx.IsSigned { 277 ctxUserID := ctx.Doer.ID 278 if ctx.FormBool("created") { 279 searchOpt.PosterID = optional.Some(ctxUserID) 280 } 281 if ctx.FormBool("assigned") { 282 searchOpt.AssigneeID = optional.Some(ctxUserID) 283 } 284 if ctx.FormBool("mentioned") { 285 searchOpt.MentionID = optional.Some(ctxUserID) 286 } 287 if ctx.FormBool("review_requested") { 288 searchOpt.ReviewRequestedID = optional.Some(ctxUserID) 289 } 290 if ctx.FormBool("reviewed") { 291 searchOpt.ReviewedID = optional.Some(ctxUserID) 292 } 293 } 294 295 // FIXME: It's unsupported to sort by priority repo when searching by indexer, 296 // it's indeed an regression, but I think it is worth to support filtering by indexer first. 297 _ = ctx.FormInt64("priority_repo_id") 298 299 ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) 300 if err != nil { 301 ctx.Error(http.StatusInternalServerError, "SearchIssues", err) 302 return 303 } 304 issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) 305 if err != nil { 306 ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err) 307 return 308 } 309 310 ctx.SetLinkHeader(int(total), limit) 311 ctx.SetTotalCountHeader(total) 312 ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) 313 } 314 315 // ListIssues list the issues of a repository 316 func ListIssues(ctx *context.APIContext) { 317 // swagger:operation GET /repos/{owner}/{repo}/issues issue issueListIssues 318 // --- 319 // summary: List a repository's issues 320 // produces: 321 // - application/json 322 // parameters: 323 // - name: owner 324 // in: path 325 // description: owner of the repo 326 // type: string 327 // required: true 328 // - name: repo 329 // in: path 330 // description: name of the repo 331 // type: string 332 // required: true 333 // - name: state 334 // in: query 335 // description: whether issue is open or closed 336 // type: string 337 // enum: [closed, open, all] 338 // - name: labels 339 // in: query 340 // description: comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded 341 // type: string 342 // - name: q 343 // in: query 344 // description: search string 345 // type: string 346 // - name: type 347 // in: query 348 // description: filter by type (issues / pulls) if set 349 // type: string 350 // enum: [issues, pulls] 351 // - name: milestones 352 // in: query 353 // description: comma separated list of milestone names or ids. It uses names and fall back to ids. Fetch only issues that have any of this milestones. Non existent milestones are discarded 354 // type: string 355 // - name: since 356 // in: query 357 // description: Only show items updated after the given time. This is a timestamp in RFC 3339 format 358 // type: string 359 // format: date-time 360 // required: false 361 // - name: before 362 // in: query 363 // description: Only show items updated before the given time. This is a timestamp in RFC 3339 format 364 // type: string 365 // format: date-time 366 // required: false 367 // - name: created_by 368 // in: query 369 // description: Only show items which were created by the given user 370 // type: string 371 // - name: assigned_by 372 // in: query 373 // description: Only show items for which the given user is assigned 374 // type: string 375 // - name: mentioned_by 376 // in: query 377 // description: Only show items in which the given user was mentioned 378 // type: string 379 // - name: page 380 // in: query 381 // description: page number of results to return (1-based) 382 // type: integer 383 // - name: limit 384 // in: query 385 // description: page size of results 386 // type: integer 387 // responses: 388 // "200": 389 // "$ref": "#/responses/IssueList" 390 // "404": 391 // "$ref": "#/responses/notFound" 392 before, since, err := context.GetQueryBeforeSince(ctx.Base) 393 if err != nil { 394 ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) 395 return 396 } 397 398 var isClosed optional.Option[bool] 399 switch ctx.FormString("state") { 400 case "closed": 401 isClosed = optional.Some(true) 402 case "all": 403 isClosed = optional.None[bool]() 404 default: 405 isClosed = optional.Some(false) 406 } 407 408 keyword := ctx.FormTrim("q") 409 if strings.IndexByte(keyword, 0) >= 0 { 410 keyword = "" 411 } 412 413 var labelIDs []int64 414 if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 { 415 labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, splitted) 416 if err != nil { 417 ctx.Error(http.StatusInternalServerError, "GetLabelIDsInRepoByNames", err) 418 return 419 } 420 } 421 422 var mileIDs []int64 423 if part := strings.Split(ctx.FormString("milestones"), ","); len(part) > 0 { 424 for i := range part { 425 // uses names and fall back to ids 426 // non existent milestones are discarded 427 mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, part[i]) 428 if err == nil { 429 mileIDs = append(mileIDs, mile.ID) 430 continue 431 } 432 if !issues_model.IsErrMilestoneNotExist(err) { 433 ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoIDANDName", err) 434 return 435 } 436 id, err := strconv.ParseInt(part[i], 10, 64) 437 if err != nil { 438 continue 439 } 440 mile, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, id) 441 if err == nil { 442 mileIDs = append(mileIDs, mile.ID) 443 continue 444 } 445 if issues_model.IsErrMilestoneNotExist(err) { 446 continue 447 } 448 ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoID", err) 449 } 450 } 451 452 listOptions := utils.GetListOptions(ctx) 453 454 isPull := optional.None[bool]() 455 switch ctx.FormString("type") { 456 case "pulls": 457 isPull = optional.Some(true) 458 case "issues": 459 isPull = optional.Some(false) 460 } 461 462 if isPull.Has() && !ctx.Repo.CanReadIssuesOrPulls(isPull.Value()) { 463 ctx.NotFound() 464 return 465 } 466 467 if !isPull.Has() { 468 canReadIssues := ctx.Repo.CanRead(unit.TypeIssues) 469 canReadPulls := ctx.Repo.CanRead(unit.TypePullRequests) 470 if !canReadIssues && !canReadPulls { 471 ctx.NotFound() 472 return 473 } else if !canReadIssues { 474 isPull = optional.Some(true) 475 } else if !canReadPulls { 476 isPull = optional.Some(false) 477 } 478 } 479 480 // FIXME: we should be more efficient here 481 createdByID := getUserIDForFilter(ctx, "created_by") 482 if ctx.Written() { 483 return 484 } 485 assignedByID := getUserIDForFilter(ctx, "assigned_by") 486 if ctx.Written() { 487 return 488 } 489 mentionedByID := getUserIDForFilter(ctx, "mentioned_by") 490 if ctx.Written() { 491 return 492 } 493 494 searchOpt := &issue_indexer.SearchOptions{ 495 Paginator: &listOptions, 496 Keyword: keyword, 497 RepoIDs: []int64{ctx.Repo.Repository.ID}, 498 IsPull: isPull, 499 IsClosed: isClosed, 500 SortBy: issue_indexer.SortByCreatedDesc, 501 } 502 if since != 0 { 503 searchOpt.UpdatedAfterUnix = optional.Some(since) 504 } 505 if before != 0 { 506 searchOpt.UpdatedBeforeUnix = optional.Some(before) 507 } 508 if len(labelIDs) == 1 && labelIDs[0] == 0 { 509 searchOpt.NoLabelOnly = true 510 } else { 511 for _, labelID := range labelIDs { 512 if labelID > 0 { 513 searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID) 514 } else { 515 searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID) 516 } 517 } 518 } 519 520 if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID { 521 searchOpt.MilestoneIDs = []int64{0} 522 } else { 523 searchOpt.MilestoneIDs = mileIDs 524 } 525 526 if createdByID > 0 { 527 searchOpt.PosterID = optional.Some(createdByID) 528 } 529 if assignedByID > 0 { 530 searchOpt.AssigneeID = optional.Some(assignedByID) 531 } 532 if mentionedByID > 0 { 533 searchOpt.MentionID = optional.Some(mentionedByID) 534 } 535 536 ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) 537 if err != nil { 538 ctx.Error(http.StatusInternalServerError, "SearchIssues", err) 539 return 540 } 541 issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) 542 if err != nil { 543 ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err) 544 return 545 } 546 547 ctx.SetLinkHeader(int(total), listOptions.PageSize) 548 ctx.SetTotalCountHeader(total) 549 ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) 550 } 551 552 func getUserIDForFilter(ctx *context.APIContext, queryName string) int64 { 553 userName := ctx.FormString(queryName) 554 if len(userName) == 0 { 555 return 0 556 } 557 558 user, err := user_model.GetUserByName(ctx, userName) 559 if user_model.IsErrUserNotExist(err) { 560 ctx.NotFound(err) 561 return 0 562 } 563 564 if err != nil { 565 ctx.InternalServerError(err) 566 return 0 567 } 568 569 return user.ID 570 } 571 572 // GetIssue get an issue of a repository 573 func GetIssue(ctx *context.APIContext) { 574 // swagger:operation GET /repos/{owner}/{repo}/issues/{index} issue issueGetIssue 575 // --- 576 // summary: Get an issue 577 // produces: 578 // - application/json 579 // parameters: 580 // - name: owner 581 // in: path 582 // description: owner of the repo 583 // type: string 584 // required: true 585 // - name: repo 586 // in: path 587 // description: name of the repo 588 // type: string 589 // required: true 590 // - name: index 591 // in: path 592 // description: index of the issue to get 593 // type: integer 594 // format: int64 595 // required: true 596 // responses: 597 // "200": 598 // "$ref": "#/responses/Issue" 599 // "404": 600 // "$ref": "#/responses/notFound" 601 602 issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) 603 if err != nil { 604 if issues_model.IsErrIssueNotExist(err) { 605 ctx.NotFound() 606 } else { 607 ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) 608 } 609 return 610 } 611 if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) { 612 ctx.NotFound() 613 return 614 } 615 ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue)) 616 } 617 618 // CreateIssue create an issue of a repository 619 func CreateIssue(ctx *context.APIContext) { 620 // swagger:operation POST /repos/{owner}/{repo}/issues issue issueCreateIssue 621 // --- 622 // summary: Create an issue. If using deadline only the date will be taken into account, and time of day ignored. 623 // consumes: 624 // - application/json 625 // produces: 626 // - application/json 627 // parameters: 628 // - name: owner 629 // in: path 630 // description: owner of the repo 631 // type: string 632 // required: true 633 // - name: repo 634 // in: path 635 // description: name of the repo 636 // type: string 637 // required: true 638 // - name: body 639 // in: body 640 // schema: 641 // "$ref": "#/definitions/CreateIssueOption" 642 // responses: 643 // "201": 644 // "$ref": "#/responses/Issue" 645 // "403": 646 // "$ref": "#/responses/forbidden" 647 // "404": 648 // "$ref": "#/responses/notFound" 649 // "412": 650 // "$ref": "#/responses/error" 651 // "422": 652 // "$ref": "#/responses/validationError" 653 // "423": 654 // "$ref": "#/responses/repoArchivedError" 655 656 form := web.GetForm(ctx).(*api.CreateIssueOption) 657 var deadlineUnix timeutil.TimeStamp 658 if form.Deadline != nil && ctx.Repo.CanWrite(unit.TypeIssues) { 659 deadlineUnix = timeutil.TimeStamp(form.Deadline.Unix()) 660 } 661 662 issue := &issues_model.Issue{ 663 RepoID: ctx.Repo.Repository.ID, 664 Repo: ctx.Repo.Repository, 665 Title: form.Title, 666 PosterID: ctx.Doer.ID, 667 Poster: ctx.Doer, 668 Content: form.Body, 669 Ref: form.Ref, 670 DeadlineUnix: deadlineUnix, 671 } 672 673 assigneeIDs := make([]int64, 0) 674 var err error 675 if ctx.Repo.CanWrite(unit.TypeIssues) { 676 issue.MilestoneID = form.Milestone 677 assigneeIDs, err = issues_model.MakeIDsFromAPIAssigneesToAdd(ctx, form.Assignee, form.Assignees) 678 if err != nil { 679 if user_model.IsErrUserNotExist(err) { 680 ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err)) 681 } else { 682 ctx.Error(http.StatusInternalServerError, "AddAssigneeByName", err) 683 } 684 return 685 } 686 687 // Check if the passed assignees is assignable 688 for _, aID := range assigneeIDs { 689 assignee, err := user_model.GetUserByID(ctx, aID) 690 if err != nil { 691 ctx.Error(http.StatusInternalServerError, "GetUserByID", err) 692 return 693 } 694 695 valid, err := access_model.CanBeAssigned(ctx, assignee, ctx.Repo.Repository, false) 696 if err != nil { 697 ctx.Error(http.StatusInternalServerError, "canBeAssigned", err) 698 return 699 } 700 if !valid { 701 ctx.Error(http.StatusUnprocessableEntity, "canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: ctx.Repo.Repository.Name}) 702 return 703 } 704 } 705 } else { 706 // setting labels is not allowed if user is not a writer 707 form.Labels = make([]int64, 0) 708 } 709 710 if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, 0); err != nil { 711 if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { 712 ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) 713 } else if errors.Is(err, user_model.ErrBlockedUser) { 714 ctx.Error(http.StatusForbidden, "NewIssue", err) 715 } else { 716 ctx.Error(http.StatusInternalServerError, "NewIssue", err) 717 } 718 return 719 } 720 721 if form.Closed { 722 if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", true); err != nil { 723 if issues_model.IsErrDependenciesLeft(err) { 724 ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") 725 return 726 } 727 ctx.Error(http.StatusInternalServerError, "ChangeStatus", err) 728 return 729 } 730 } 731 732 // Refetch from database to assign some automatic values 733 issue, err = issues_model.GetIssueByID(ctx, issue.ID) 734 if err != nil { 735 ctx.Error(http.StatusInternalServerError, "GetIssueByID", err) 736 return 737 } 738 ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, issue)) 739 } 740 741 // EditIssue modify an issue of a repository 742 func EditIssue(ctx *context.APIContext) { 743 // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index} issue issueEditIssue 744 // --- 745 // summary: Edit an issue. If using deadline only the date will be taken into account, and time of day ignored. 746 // consumes: 747 // - application/json 748 // produces: 749 // - application/json 750 // parameters: 751 // - name: owner 752 // in: path 753 // description: owner of the repo 754 // type: string 755 // required: true 756 // - name: repo 757 // in: path 758 // description: name of the repo 759 // type: string 760 // required: true 761 // - name: index 762 // in: path 763 // description: index of the issue to edit 764 // type: integer 765 // format: int64 766 // required: true 767 // - name: body 768 // in: body 769 // schema: 770 // "$ref": "#/definitions/EditIssueOption" 771 // responses: 772 // "201": 773 // "$ref": "#/responses/Issue" 774 // "403": 775 // "$ref": "#/responses/forbidden" 776 // "404": 777 // "$ref": "#/responses/notFound" 778 // "412": 779 // "$ref": "#/responses/error" 780 781 form := web.GetForm(ctx).(*api.EditIssueOption) 782 issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) 783 if err != nil { 784 if issues_model.IsErrIssueNotExist(err) { 785 ctx.NotFound() 786 } else { 787 ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) 788 } 789 return 790 } 791 issue.Repo = ctx.Repo.Repository 792 canWrite := ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) 793 794 err = issue.LoadAttributes(ctx) 795 if err != nil { 796 ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) 797 return 798 } 799 800 if !issue.IsPoster(ctx.Doer.ID) && !canWrite { 801 ctx.Status(http.StatusForbidden) 802 return 803 } 804 805 if len(form.Title) > 0 { 806 err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title) 807 if err != nil { 808 ctx.Error(http.StatusInternalServerError, "ChangeTitle", err) 809 return 810 } 811 } 812 if form.Body != nil { 813 err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body) 814 if err != nil { 815 ctx.Error(http.StatusInternalServerError, "ChangeContent", err) 816 return 817 } 818 } 819 if form.Ref != nil { 820 err = issue_service.ChangeIssueRef(ctx, issue, ctx.Doer, *form.Ref) 821 if err != nil { 822 ctx.Error(http.StatusInternalServerError, "UpdateRef", err) 823 return 824 } 825 } 826 827 // Update or remove the deadline, only if set and allowed 828 if (form.Deadline != nil || form.RemoveDeadline != nil) && canWrite { 829 var deadlineUnix timeutil.TimeStamp 830 831 if form.RemoveDeadline == nil || !*form.RemoveDeadline { 832 if form.Deadline == nil { 833 ctx.Error(http.StatusBadRequest, "", "The due_date cannot be empty") 834 return 835 } 836 if !form.Deadline.IsZero() { 837 deadline := time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(), 838 23, 59, 59, 0, form.Deadline.Location()) 839 deadlineUnix = timeutil.TimeStamp(deadline.Unix()) 840 } 841 } 842 843 if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { 844 ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err) 845 return 846 } 847 issue.DeadlineUnix = deadlineUnix 848 } 849 850 // Add/delete assignees 851 852 // Deleting is done the GitHub way (quote from their api documentation): 853 // https://developer.github.com/v3/issues/#edit-an-issue 854 // "assignees" (array): Logins for Users to assign to this issue. 855 // Pass one or more user logins to replace the set of assignees on this Issue. 856 // Send an empty array ([]) to clear all assignees from the Issue. 857 858 if canWrite && (form.Assignees != nil || form.Assignee != nil) { 859 oneAssignee := "" 860 if form.Assignee != nil { 861 oneAssignee = *form.Assignee 862 } 863 864 err = issue_service.UpdateAssignees(ctx, issue, oneAssignee, form.Assignees, ctx.Doer) 865 if err != nil { 866 if errors.Is(err, user_model.ErrBlockedUser) { 867 ctx.Error(http.StatusForbidden, "UpdateAssignees", err) 868 } else { 869 ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err) 870 } 871 return 872 } 873 } 874 875 if canWrite && form.Milestone != nil && 876 issue.MilestoneID != *form.Milestone { 877 oldMilestoneID := issue.MilestoneID 878 issue.MilestoneID = *form.Milestone 879 if err = issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil { 880 ctx.Error(http.StatusInternalServerError, "ChangeMilestoneAssign", err) 881 return 882 } 883 } 884 if form.State != nil { 885 if issue.IsPull { 886 if err := issue.LoadPullRequest(ctx); err != nil { 887 ctx.Error(http.StatusInternalServerError, "GetPullRequest", err) 888 return 889 } 890 if issue.PullRequest.HasMerged { 891 ctx.Error(http.StatusPreconditionFailed, "MergedPRState", "cannot change state of this pull request, it was already merged") 892 return 893 } 894 } 895 896 var isClosed bool 897 switch state := api.StateType(*form.State); state { 898 case api.StateOpen: 899 isClosed = false 900 case api.StateClosed: 901 isClosed = true 902 default: 903 ctx.Error(http.StatusPreconditionFailed, "UnknownIssueStateError", fmt.Sprintf("unknown state: %s", state)) 904 return 905 } 906 907 if issue.IsClosed != isClosed { 908 if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil { 909 if issues_model.IsErrDependenciesLeft(err) { 910 ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") 911 return 912 } 913 ctx.Error(http.StatusInternalServerError, "ChangeStatus", err) 914 return 915 } 916 } 917 } 918 919 // Refetch from database to assign some automatic values 920 issue, err = issues_model.GetIssueByID(ctx, issue.ID) 921 if err != nil { 922 ctx.InternalServerError(err) 923 return 924 } 925 if err = issue.LoadMilestone(ctx); err != nil { 926 ctx.InternalServerError(err) 927 return 928 } 929 ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, issue)) 930 } 931 932 func DeleteIssue(ctx *context.APIContext) { 933 // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index} issue issueDelete 934 // --- 935 // summary: Delete an issue 936 // parameters: 937 // - name: owner 938 // in: path 939 // description: owner of the repo 940 // type: string 941 // required: true 942 // - name: repo 943 // in: path 944 // description: name of the repo 945 // type: string 946 // required: true 947 // - name: index 948 // in: path 949 // description: index of issue to delete 950 // type: integer 951 // format: int64 952 // required: true 953 // responses: 954 // "204": 955 // "$ref": "#/responses/empty" 956 // "403": 957 // "$ref": "#/responses/forbidden" 958 // "404": 959 // "$ref": "#/responses/notFound" 960 issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) 961 if err != nil { 962 if issues_model.IsErrIssueNotExist(err) { 963 ctx.NotFound(err) 964 } else { 965 ctx.Error(http.StatusInternalServerError, "GetIssueByID", err) 966 } 967 return 968 } 969 970 if err = issue_service.DeleteIssue(ctx, ctx.Doer, ctx.Repo.GitRepo, issue); err != nil { 971 ctx.Error(http.StatusInternalServerError, "DeleteIssueByID", err) 972 return 973 } 974 975 ctx.Status(http.StatusNoContent) 976 } 977 978 // UpdateIssueDeadline updates an issue deadline 979 func UpdateIssueDeadline(ctx *context.APIContext) { 980 // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/deadline issue issueEditIssueDeadline 981 // --- 982 // summary: Set an issue deadline. If set to null, the deadline is deleted. If using deadline only the date will be taken into account, and time of day ignored. 983 // consumes: 984 // - application/json 985 // produces: 986 // - application/json 987 // parameters: 988 // - name: owner 989 // in: path 990 // description: owner of the repo 991 // type: string 992 // required: true 993 // - name: repo 994 // in: path 995 // description: name of the repo 996 // type: string 997 // required: true 998 // - name: index 999 // in: path 1000 // description: index of the issue to create or update a deadline on 1001 // type: integer 1002 // format: int64 1003 // required: true 1004 // - name: body 1005 // in: body 1006 // schema: 1007 // "$ref": "#/definitions/EditDeadlineOption" 1008 // responses: 1009 // "201": 1010 // "$ref": "#/responses/IssueDeadline" 1011 // "403": 1012 // "$ref": "#/responses/forbidden" 1013 // "404": 1014 // "$ref": "#/responses/notFound" 1015 form := web.GetForm(ctx).(*api.EditDeadlineOption) 1016 issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) 1017 if err != nil { 1018 if issues_model.IsErrIssueNotExist(err) { 1019 ctx.NotFound() 1020 } else { 1021 ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) 1022 } 1023 return 1024 } 1025 1026 if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { 1027 ctx.Error(http.StatusForbidden, "", "Not repo writer") 1028 return 1029 } 1030 1031 var deadlineUnix timeutil.TimeStamp 1032 var deadline time.Time 1033 if form.Deadline != nil && !form.Deadline.IsZero() { 1034 deadline = time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(), 1035 23, 59, 59, 0, time.Local) 1036 deadlineUnix = timeutil.TimeStamp(deadline.Unix()) 1037 } 1038 1039 if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { 1040 ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err) 1041 return 1042 } 1043 1044 ctx.JSON(http.StatusCreated, api.IssueDeadline{Deadline: &deadline}) 1045 }