code.gitea.io/gitea@v1.21.7/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 "fmt" 9 "net/http" 10 "strconv" 11 "strings" 12 "time" 13 14 "code.gitea.io/gitea/models/db" 15 issues_model "code.gitea.io/gitea/models/issues" 16 "code.gitea.io/gitea/models/organization" 17 access_model "code.gitea.io/gitea/models/perm/access" 18 repo_model "code.gitea.io/gitea/models/repo" 19 "code.gitea.io/gitea/models/unit" 20 user_model "code.gitea.io/gitea/models/user" 21 "code.gitea.io/gitea/modules/context" 22 issue_indexer "code.gitea.io/gitea/modules/indexer/issues" 23 "code.gitea.io/gitea/modules/setting" 24 api "code.gitea.io/gitea/modules/structs" 25 "code.gitea.io/gitea/modules/timeutil" 26 "code.gitea.io/gitea/modules/util" 27 "code.gitea.io/gitea/modules/web" 28 "code.gitea.io/gitea/routers/api/v1/utils" 29 "code.gitea.io/gitea/services/convert" 30 issue_service "code.gitea.io/gitea/services/issue" 31 notify_service "code.gitea.io/gitea/services/notify" 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 util.OptionalBool 126 switch ctx.FormString("state") { 127 case "closed": 128 isClosed = util.OptionalBoolTrue 129 case "all": 130 isClosed = util.OptionalBoolNone 131 default: 132 isClosed = util.OptionalBoolFalse 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: util.OptionalBoolNone, 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 = true 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 = util.OptionalBoolFalse 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(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 util.OptionalBool 208 switch ctx.FormString("type") { 209 case "pulls": 210 isPull = util.OptionalBoolTrue 211 case "issues": 212 isPull = util.OptionalBoolFalse 213 default: 214 isPull = util.OptionalBoolNone 215 } 216 217 var includedAnyLabels []int64 218 { 219 220 labels := ctx.FormTrim("labels") 221 var includedLabelNames []string 222 if len(labels) > 0 { 223 includedLabelNames = strings.Split(labels, ",") 224 } 225 includedAnyLabels, err = issues_model.GetLabelIDsByNames(ctx, includedLabelNames) 226 if err != nil { 227 ctx.Error(http.StatusInternalServerError, "GetLabelIDsByNames", err) 228 return 229 } 230 } 231 232 var includedMilestones []int64 233 { 234 milestones := ctx.FormTrim("milestones") 235 var includedMilestoneNames []string 236 if len(milestones) > 0 { 237 includedMilestoneNames = strings.Split(milestones, ",") 238 } 239 includedMilestones, err = issues_model.GetMilestoneIDsByNames(ctx, includedMilestoneNames) 240 if err != nil { 241 ctx.Error(http.StatusInternalServerError, "GetMilestoneIDsByNames", err) 242 return 243 } 244 } 245 246 // this api is also used in UI, 247 // so the default limit is set to fit UI needs 248 limit := ctx.FormInt("limit") 249 if limit == 0 { 250 limit = setting.UI.IssuePagingNum 251 } else if limit > setting.API.MaxResponseItems { 252 limit = setting.API.MaxResponseItems 253 } 254 255 searchOpt := &issue_indexer.SearchOptions{ 256 Paginator: &db.ListOptions{ 257 PageSize: limit, 258 Page: ctx.FormInt("page"), 259 }, 260 Keyword: keyword, 261 RepoIDs: repoIDs, 262 AllPublic: allPublic, 263 IsPull: isPull, 264 IsClosed: isClosed, 265 IncludedAnyLabelIDs: includedAnyLabels, 266 MilestoneIDs: includedMilestones, 267 SortBy: issue_indexer.SortByCreatedDesc, 268 } 269 270 if since != 0 { 271 searchOpt.UpdatedAfterUnix = &since 272 } 273 if before != 0 { 274 searchOpt.UpdatedBeforeUnix = &before 275 } 276 277 if ctx.IsSigned { 278 ctxUserID := ctx.Doer.ID 279 if ctx.FormBool("created") { 280 searchOpt.PosterID = &ctxUserID 281 } 282 if ctx.FormBool("assigned") { 283 searchOpt.AssigneeID = &ctxUserID 284 } 285 if ctx.FormBool("mentioned") { 286 searchOpt.MentionID = &ctxUserID 287 } 288 if ctx.FormBool("review_requested") { 289 searchOpt.ReviewRequestedID = &ctxUserID 290 } 291 if ctx.FormBool("reviewed") { 292 searchOpt.ReviewedID = &ctxUserID 293 } 294 } 295 296 // FIXME: It's unsupported to sort by priority repo when searching by indexer, 297 // it's indeed an regression, but I think it is worth to support filtering by indexer first. 298 _ = ctx.FormInt64("priority_repo_id") 299 300 ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) 301 if err != nil { 302 ctx.Error(http.StatusInternalServerError, "SearchIssues", err) 303 return 304 } 305 issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) 306 if err != nil { 307 ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err) 308 return 309 } 310 311 ctx.SetLinkHeader(int(total), limit) 312 ctx.SetTotalCountHeader(total) 313 ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues)) 314 } 315 316 // ListIssues list the issues of a repository 317 func ListIssues(ctx *context.APIContext) { 318 // swagger:operation GET /repos/{owner}/{repo}/issues issue issueListIssues 319 // --- 320 // summary: List a repository's issues 321 // produces: 322 // - application/json 323 // parameters: 324 // - name: owner 325 // in: path 326 // description: owner of the repo 327 // type: string 328 // required: true 329 // - name: repo 330 // in: path 331 // description: name of the repo 332 // type: string 333 // required: true 334 // - name: state 335 // in: query 336 // description: whether issue is open or closed 337 // type: string 338 // enum: [closed, open, all] 339 // - name: labels 340 // in: query 341 // description: comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded 342 // type: string 343 // - name: q 344 // in: query 345 // description: search string 346 // type: string 347 // - name: type 348 // in: query 349 // description: filter by type (issues / pulls) if set 350 // type: string 351 // enum: [issues, pulls] 352 // - name: milestones 353 // in: query 354 // 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 355 // type: string 356 // - name: since 357 // in: query 358 // description: Only show items updated after the given time. This is a timestamp in RFC 3339 format 359 // type: string 360 // format: date-time 361 // required: false 362 // - name: before 363 // in: query 364 // description: Only show items updated before the given time. This is a timestamp in RFC 3339 format 365 // type: string 366 // format: date-time 367 // required: false 368 // - name: created_by 369 // in: query 370 // description: Only show items which were created by the the given user 371 // type: string 372 // - name: assigned_by 373 // in: query 374 // description: Only show items for which the given user is assigned 375 // type: string 376 // - name: mentioned_by 377 // in: query 378 // description: Only show items in which the given user was mentioned 379 // type: string 380 // - name: page 381 // in: query 382 // description: page number of results to return (1-based) 383 // type: integer 384 // - name: limit 385 // in: query 386 // description: page size of results 387 // type: integer 388 // responses: 389 // "200": 390 // "$ref": "#/responses/IssueList" 391 // "404": 392 // "$ref": "#/responses/notFound" 393 before, since, err := context.GetQueryBeforeSince(ctx.Base) 394 if err != nil { 395 ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) 396 return 397 } 398 399 var isClosed util.OptionalBool 400 switch ctx.FormString("state") { 401 case "closed": 402 isClosed = util.OptionalBoolTrue 403 case "all": 404 isClosed = util.OptionalBoolNone 405 default: 406 isClosed = util.OptionalBoolFalse 407 } 408 409 keyword := ctx.FormTrim("q") 410 if strings.IndexByte(keyword, 0) >= 0 { 411 keyword = "" 412 } 413 414 var labelIDs []int64 415 if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 { 416 labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, splitted) 417 if err != nil { 418 ctx.Error(http.StatusInternalServerError, "GetLabelIDsInRepoByNames", err) 419 return 420 } 421 } 422 423 var mileIDs []int64 424 if part := strings.Split(ctx.FormString("milestones"), ","); len(part) > 0 { 425 for i := range part { 426 // uses names and fall back to ids 427 // non existent milestones are discarded 428 mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, part[i]) 429 if err == nil { 430 mileIDs = append(mileIDs, mile.ID) 431 continue 432 } 433 if !issues_model.IsErrMilestoneNotExist(err) { 434 ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoIDANDName", err) 435 return 436 } 437 id, err := strconv.ParseInt(part[i], 10, 64) 438 if err != nil { 439 continue 440 } 441 mile, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, id) 442 if err == nil { 443 mileIDs = append(mileIDs, mile.ID) 444 continue 445 } 446 if issues_model.IsErrMilestoneNotExist(err) { 447 continue 448 } 449 ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoID", err) 450 } 451 } 452 453 listOptions := utils.GetListOptions(ctx) 454 455 var isPull util.OptionalBool 456 switch ctx.FormString("type") { 457 case "pulls": 458 isPull = util.OptionalBoolTrue 459 case "issues": 460 isPull = util.OptionalBoolFalse 461 default: 462 isPull = util.OptionalBoolNone 463 } 464 465 if isPull != util.OptionalBoolNone && !ctx.Repo.CanReadIssuesOrPulls(isPull.IsTrue()) { 466 ctx.NotFound() 467 return 468 } 469 470 if isPull == util.OptionalBoolNone { 471 canReadIssues := ctx.Repo.CanRead(unit.TypeIssues) 472 canReadPulls := ctx.Repo.CanRead(unit.TypePullRequests) 473 if !canReadIssues && !canReadPulls { 474 ctx.NotFound() 475 return 476 } else if !canReadIssues { 477 isPull = util.OptionalBoolTrue 478 } else if !canReadPulls { 479 isPull = util.OptionalBoolFalse 480 } 481 } 482 483 // FIXME: we should be more efficient here 484 createdByID := getUserIDForFilter(ctx, "created_by") 485 if ctx.Written() { 486 return 487 } 488 assignedByID := getUserIDForFilter(ctx, "assigned_by") 489 if ctx.Written() { 490 return 491 } 492 mentionedByID := getUserIDForFilter(ctx, "mentioned_by") 493 if ctx.Written() { 494 return 495 } 496 497 searchOpt := &issue_indexer.SearchOptions{ 498 Paginator: &listOptions, 499 Keyword: keyword, 500 RepoIDs: []int64{ctx.Repo.Repository.ID}, 501 IsPull: isPull, 502 IsClosed: isClosed, 503 SortBy: issue_indexer.SortByCreatedDesc, 504 } 505 if since != 0 { 506 searchOpt.UpdatedAfterUnix = &since 507 } 508 if before != 0 { 509 searchOpt.UpdatedBeforeUnix = &before 510 } 511 if len(labelIDs) == 1 && labelIDs[0] == 0 { 512 searchOpt.NoLabelOnly = true 513 } else { 514 for _, labelID := range labelIDs { 515 if labelID > 0 { 516 searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID) 517 } else { 518 searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID) 519 } 520 } 521 } 522 523 if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID { 524 searchOpt.MilestoneIDs = []int64{0} 525 } else { 526 searchOpt.MilestoneIDs = mileIDs 527 } 528 529 if createdByID > 0 { 530 searchOpt.PosterID = &createdByID 531 } 532 if assignedByID > 0 { 533 searchOpt.AssigneeID = &assignedByID 534 } 535 if mentionedByID > 0 { 536 searchOpt.MentionID = &mentionedByID 537 } 538 539 ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) 540 if err != nil { 541 ctx.Error(http.StatusInternalServerError, "SearchIssues", err) 542 return 543 } 544 issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) 545 if err != nil { 546 ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err) 547 return 548 } 549 550 ctx.SetLinkHeader(int(total), listOptions.PageSize) 551 ctx.SetTotalCountHeader(total) 552 ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues)) 553 } 554 555 func getUserIDForFilter(ctx *context.APIContext, queryName string) int64 { 556 userName := ctx.FormString(queryName) 557 if len(userName) == 0 { 558 return 0 559 } 560 561 user, err := user_model.GetUserByName(ctx, userName) 562 if user_model.IsErrUserNotExist(err) { 563 ctx.NotFound(err) 564 return 0 565 } 566 567 if err != nil { 568 ctx.InternalServerError(err) 569 return 0 570 } 571 572 return user.ID 573 } 574 575 // GetIssue get an issue of a repository 576 func GetIssue(ctx *context.APIContext) { 577 // swagger:operation GET /repos/{owner}/{repo}/issues/{index} issue issueGetIssue 578 // --- 579 // summary: Get an issue 580 // produces: 581 // - application/json 582 // parameters: 583 // - name: owner 584 // in: path 585 // description: owner of the repo 586 // type: string 587 // required: true 588 // - name: repo 589 // in: path 590 // description: name of the repo 591 // type: string 592 // required: true 593 // - name: index 594 // in: path 595 // description: index of the issue to get 596 // type: integer 597 // format: int64 598 // required: true 599 // responses: 600 // "200": 601 // "$ref": "#/responses/Issue" 602 // "404": 603 // "$ref": "#/responses/notFound" 604 605 issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) 606 if err != nil { 607 if issues_model.IsErrIssueNotExist(err) { 608 ctx.NotFound() 609 } else { 610 ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) 611 } 612 return 613 } 614 if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) { 615 ctx.NotFound() 616 return 617 } 618 ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, issue)) 619 } 620 621 // CreateIssue create an issue of a repository 622 func CreateIssue(ctx *context.APIContext) { 623 // swagger:operation POST /repos/{owner}/{repo}/issues issue issueCreateIssue 624 // --- 625 // summary: Create an issue. If using deadline only the date will be taken into account, and time of day ignored. 626 // consumes: 627 // - application/json 628 // produces: 629 // - application/json 630 // parameters: 631 // - name: owner 632 // in: path 633 // description: owner of the repo 634 // type: string 635 // required: true 636 // - name: repo 637 // in: path 638 // description: name of the repo 639 // type: string 640 // required: true 641 // - name: body 642 // in: body 643 // schema: 644 // "$ref": "#/definitions/CreateIssueOption" 645 // responses: 646 // "201": 647 // "$ref": "#/responses/Issue" 648 // "403": 649 // "$ref": "#/responses/forbidden" 650 // "404": 651 // "$ref": "#/responses/notFound" 652 // "412": 653 // "$ref": "#/responses/error" 654 // "422": 655 // "$ref": "#/responses/validationError" 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); err != nil { 711 if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { 712 ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) 713 return 714 } 715 ctx.Error(http.StatusInternalServerError, "NewIssue", err) 716 return 717 } 718 719 if form.Closed { 720 if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", true); err != nil { 721 if issues_model.IsErrDependenciesLeft(err) { 722 ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") 723 return 724 } 725 ctx.Error(http.StatusInternalServerError, "ChangeStatus", err) 726 return 727 } 728 } 729 730 // Refetch from database to assign some automatic values 731 issue, err = issues_model.GetIssueByID(ctx, issue.ID) 732 if err != nil { 733 ctx.Error(http.StatusInternalServerError, "GetIssueByID", err) 734 return 735 } 736 ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, issue)) 737 } 738 739 // EditIssue modify an issue of a repository 740 func EditIssue(ctx *context.APIContext) { 741 // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index} issue issueEditIssue 742 // --- 743 // summary: Edit an issue. If using deadline only the date will be taken into account, and time of day ignored. 744 // consumes: 745 // - application/json 746 // produces: 747 // - application/json 748 // parameters: 749 // - name: owner 750 // in: path 751 // description: owner of the repo 752 // type: string 753 // required: true 754 // - name: repo 755 // in: path 756 // description: name of the repo 757 // type: string 758 // required: true 759 // - name: index 760 // in: path 761 // description: index of the issue to edit 762 // type: integer 763 // format: int64 764 // required: true 765 // - name: body 766 // in: body 767 // schema: 768 // "$ref": "#/definitions/EditIssueOption" 769 // responses: 770 // "201": 771 // "$ref": "#/responses/Issue" 772 // "403": 773 // "$ref": "#/responses/forbidden" 774 // "404": 775 // "$ref": "#/responses/notFound" 776 // "412": 777 // "$ref": "#/responses/error" 778 779 form := web.GetForm(ctx).(*api.EditIssueOption) 780 issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) 781 if err != nil { 782 if issues_model.IsErrIssueNotExist(err) { 783 ctx.NotFound() 784 } else { 785 ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) 786 } 787 return 788 } 789 issue.Repo = ctx.Repo.Repository 790 canWrite := ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) 791 792 err = issue.LoadAttributes(ctx) 793 if err != nil { 794 ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) 795 return 796 } 797 798 if !issue.IsPoster(ctx.Doer.ID) && !canWrite { 799 ctx.Status(http.StatusForbidden) 800 return 801 } 802 803 oldTitle := issue.Title 804 if len(form.Title) > 0 { 805 issue.Title = form.Title 806 } 807 if form.Body != nil { 808 issue.Content = *form.Body 809 } 810 if form.Ref != nil { 811 err = issue_service.ChangeIssueRef(ctx, issue, ctx.Doer, *form.Ref) 812 if err != nil { 813 ctx.Error(http.StatusInternalServerError, "UpdateRef", err) 814 return 815 } 816 } 817 818 // Update or remove the deadline, only if set and allowed 819 if (form.Deadline != nil || form.RemoveDeadline != nil) && canWrite { 820 var deadlineUnix timeutil.TimeStamp 821 822 if (form.RemoveDeadline == nil || !*form.RemoveDeadline) && !form.Deadline.IsZero() { 823 deadline := time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(), 824 23, 59, 59, 0, form.Deadline.Location()) 825 deadlineUnix = timeutil.TimeStamp(deadline.Unix()) 826 } 827 828 if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { 829 ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err) 830 return 831 } 832 issue.DeadlineUnix = deadlineUnix 833 } 834 835 // Add/delete assignees 836 837 // Deleting is done the GitHub way (quote from their api documentation): 838 // https://developer.github.com/v3/issues/#edit-an-issue 839 // "assignees" (array): Logins for Users to assign to this issue. 840 // Pass one or more user logins to replace the set of assignees on this Issue. 841 // Send an empty array ([]) to clear all assignees from the Issue. 842 843 if canWrite && (form.Assignees != nil || form.Assignee != nil) { 844 oneAssignee := "" 845 if form.Assignee != nil { 846 oneAssignee = *form.Assignee 847 } 848 849 err = issue_service.UpdateAssignees(ctx, issue, oneAssignee, form.Assignees, ctx.Doer) 850 if err != nil { 851 ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err) 852 return 853 } 854 } 855 856 if canWrite && form.Milestone != nil && 857 issue.MilestoneID != *form.Milestone { 858 oldMilestoneID := issue.MilestoneID 859 issue.MilestoneID = *form.Milestone 860 if err = issue_service.ChangeMilestoneAssign(issue, ctx.Doer, oldMilestoneID); err != nil { 861 ctx.Error(http.StatusInternalServerError, "ChangeMilestoneAssign", err) 862 return 863 } 864 } 865 if form.State != nil { 866 if issue.IsPull { 867 if pr, err := issue.GetPullRequest(); err != nil { 868 ctx.Error(http.StatusInternalServerError, "GetPullRequest", err) 869 return 870 } else if pr.HasMerged { 871 ctx.Error(http.StatusPreconditionFailed, "MergedPRState", "cannot change state of this pull request, it was already merged") 872 return 873 } 874 } 875 issue.IsClosed = api.StateClosed == api.StateType(*form.State) 876 } 877 statusChangeComment, titleChanged, err := issues_model.UpdateIssueByAPI(ctx, issue, ctx.Doer) 878 if err != nil { 879 if issues_model.IsErrDependenciesLeft(err) { 880 ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") 881 return 882 } 883 ctx.Error(http.StatusInternalServerError, "UpdateIssueByAPI", err) 884 return 885 } 886 887 if titleChanged { 888 notify_service.IssueChangeTitle(ctx, ctx.Doer, issue, oldTitle) 889 } 890 891 if statusChangeComment != nil { 892 notify_service.IssueChangeStatus(ctx, ctx.Doer, "", issue, statusChangeComment, issue.IsClosed) 893 } 894 895 // Refetch from database to assign some automatic values 896 issue, err = issues_model.GetIssueByID(ctx, issue.ID) 897 if err != nil { 898 ctx.InternalServerError(err) 899 return 900 } 901 if err = issue.LoadMilestone(ctx); err != nil { 902 ctx.InternalServerError(err) 903 return 904 } 905 ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, issue)) 906 } 907 908 func DeleteIssue(ctx *context.APIContext) { 909 // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index} issue issueDelete 910 // --- 911 // summary: Delete an issue 912 // parameters: 913 // - name: owner 914 // in: path 915 // description: owner of the repo 916 // type: string 917 // required: true 918 // - name: repo 919 // in: path 920 // description: name of the repo 921 // type: string 922 // required: true 923 // - name: index 924 // in: path 925 // description: index of issue to delete 926 // type: integer 927 // format: int64 928 // required: true 929 // responses: 930 // "204": 931 // "$ref": "#/responses/empty" 932 // "403": 933 // "$ref": "#/responses/forbidden" 934 // "404": 935 // "$ref": "#/responses/notFound" 936 issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) 937 if err != nil { 938 if issues_model.IsErrIssueNotExist(err) { 939 ctx.NotFound(err) 940 } else { 941 ctx.Error(http.StatusInternalServerError, "GetIssueByID", err) 942 } 943 return 944 } 945 946 if err = issue_service.DeleteIssue(ctx, ctx.Doer, ctx.Repo.GitRepo, issue); err != nil { 947 ctx.Error(http.StatusInternalServerError, "DeleteIssueByID", err) 948 return 949 } 950 951 ctx.Status(http.StatusNoContent) 952 } 953 954 // UpdateIssueDeadline updates an issue deadline 955 func UpdateIssueDeadline(ctx *context.APIContext) { 956 // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/deadline issue issueEditIssueDeadline 957 // --- 958 // 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. 959 // consumes: 960 // - application/json 961 // produces: 962 // - application/json 963 // parameters: 964 // - name: owner 965 // in: path 966 // description: owner of the repo 967 // type: string 968 // required: true 969 // - name: repo 970 // in: path 971 // description: name of the repo 972 // type: string 973 // required: true 974 // - name: index 975 // in: path 976 // description: index of the issue to create or update a deadline on 977 // type: integer 978 // format: int64 979 // required: true 980 // - name: body 981 // in: body 982 // schema: 983 // "$ref": "#/definitions/EditDeadlineOption" 984 // responses: 985 // "201": 986 // "$ref": "#/responses/IssueDeadline" 987 // "403": 988 // "$ref": "#/responses/forbidden" 989 // "404": 990 // "$ref": "#/responses/notFound" 991 form := web.GetForm(ctx).(*api.EditDeadlineOption) 992 issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) 993 if err != nil { 994 if issues_model.IsErrIssueNotExist(err) { 995 ctx.NotFound() 996 } else { 997 ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) 998 } 999 return 1000 } 1001 1002 if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { 1003 ctx.Error(http.StatusForbidden, "", "Not repo writer") 1004 return 1005 } 1006 1007 var deadlineUnix timeutil.TimeStamp 1008 var deadline time.Time 1009 if form.Deadline != nil && !form.Deadline.IsZero() { 1010 deadline = time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(), 1011 23, 59, 59, 0, time.Local) 1012 deadlineUnix = timeutil.TimeStamp(deadline.Unix()) 1013 } 1014 1015 if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { 1016 ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err) 1017 return 1018 } 1019 1020 ctx.JSON(http.StatusCreated, api.IssueDeadline{Deadline: &deadline}) 1021 }