You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

issue.go 22KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777
  1. // Copyright 2016 The Gogs Authors. All rights reserved.
  2. // Copyright 2018 The Gitea Authors. All rights reserved.
  3. // Use of this source code is governed by a MIT-style
  4. // license that can be found in the LICENSE file.
  5. package repo
  6. import (
  7. "fmt"
  8. "net/http"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "code.gitea.io/gitea/models"
  13. "code.gitea.io/gitea/modules/context"
  14. "code.gitea.io/gitea/modules/convert"
  15. issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
  16. "code.gitea.io/gitea/modules/notification"
  17. "code.gitea.io/gitea/modules/setting"
  18. api "code.gitea.io/gitea/modules/structs"
  19. "code.gitea.io/gitea/modules/timeutil"
  20. "code.gitea.io/gitea/modules/util"
  21. "code.gitea.io/gitea/modules/web"
  22. "code.gitea.io/gitea/routers/api/v1/utils"
  23. issue_service "code.gitea.io/gitea/services/issue"
  24. )
  25. // SearchIssues searches for issues across the repositories that the user has access to
  26. func SearchIssues(ctx *context.APIContext) {
  27. // swagger:operation GET /repos/issues/search issue issueSearchIssues
  28. // ---
  29. // summary: Search for issues across the repositories that the user has access to
  30. // produces:
  31. // - application/json
  32. // parameters:
  33. // - name: state
  34. // in: query
  35. // description: whether issue is open or closed
  36. // type: string
  37. // - name: labels
  38. // in: query
  39. // description: comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded
  40. // type: string
  41. // - name: q
  42. // in: query
  43. // description: search string
  44. // type: string
  45. // - name: priority_repo_id
  46. // in: query
  47. // description: repository to prioritize in the results
  48. // type: integer
  49. // format: int64
  50. // - name: type
  51. // in: query
  52. // description: filter by type (issues / pulls) if set
  53. // type: string
  54. // - name: since
  55. // in: query
  56. // description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format
  57. // type: string
  58. // format: date-time
  59. // required: false
  60. // - name: before
  61. // in: query
  62. // description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format
  63. // type: string
  64. // format: date-time
  65. // required: false
  66. // - name: assigned
  67. // in: query
  68. // description: filter (issues / pulls) assigned to you, default is false
  69. // type: boolean
  70. // - name: created
  71. // in: query
  72. // description: filter (issues / pulls) created by you, default is false
  73. // type: boolean
  74. // - name: mentioned
  75. // in: query
  76. // description: filter (issues / pulls) mentioning you, default is false
  77. // type: boolean
  78. // - name: review_requested
  79. // in: query
  80. // description: filter pulls requesting your review, default is false
  81. // type: boolean
  82. // - name: page
  83. // in: query
  84. // description: page number of results to return (1-based)
  85. // type: integer
  86. // - name: limit
  87. // in: query
  88. // description: page size of results
  89. // type: integer
  90. // responses:
  91. // "200":
  92. // "$ref": "#/responses/IssueList"
  93. before, since, err := utils.GetQueryBeforeSince(ctx)
  94. if err != nil {
  95. ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err)
  96. return
  97. }
  98. var isClosed util.OptionalBool
  99. switch ctx.Query("state") {
  100. case "closed":
  101. isClosed = util.OptionalBoolTrue
  102. case "all":
  103. isClosed = util.OptionalBoolNone
  104. default:
  105. isClosed = util.OptionalBoolFalse
  106. }
  107. // find repos user can access (for issue search)
  108. opts := &models.SearchRepoOptions{
  109. Private: false,
  110. AllPublic: true,
  111. TopicOnly: false,
  112. Collaborate: util.OptionalBoolNone,
  113. // This needs to be a column that is not nil in fixtures or
  114. // MySQL will return different results when sorting by null in some cases
  115. OrderBy: models.SearchOrderByAlphabetically,
  116. Actor: ctx.User,
  117. }
  118. if ctx.IsSigned {
  119. opts.Private = true
  120. opts.AllLimited = true
  121. }
  122. repoIDs, _, err := models.SearchRepositoryIDs(opts)
  123. if err != nil {
  124. ctx.Error(http.StatusInternalServerError, "SearchRepositoryByName", err)
  125. return
  126. }
  127. var issues []*models.Issue
  128. var filteredCount int64
  129. keyword := strings.Trim(ctx.Query("q"), " ")
  130. if strings.IndexByte(keyword, 0) >= 0 {
  131. keyword = ""
  132. }
  133. var issueIDs []int64
  134. if len(keyword) > 0 && len(repoIDs) > 0 {
  135. if issueIDs, err = issue_indexer.SearchIssuesByKeyword(repoIDs, keyword); err != nil {
  136. ctx.Error(http.StatusInternalServerError, "SearchIssuesByKeyword", err)
  137. return
  138. }
  139. }
  140. var isPull util.OptionalBool
  141. switch ctx.Query("type") {
  142. case "pulls":
  143. isPull = util.OptionalBoolTrue
  144. case "issues":
  145. isPull = util.OptionalBoolFalse
  146. default:
  147. isPull = util.OptionalBoolNone
  148. }
  149. labels := strings.TrimSpace(ctx.Query("labels"))
  150. var includedLabelNames []string
  151. if len(labels) > 0 {
  152. includedLabelNames = strings.Split(labels, ",")
  153. }
  154. // this api is also used in UI,
  155. // so the default limit is set to fit UI needs
  156. limit := ctx.QueryInt("limit")
  157. if limit == 0 {
  158. limit = setting.UI.IssuePagingNum
  159. } else if limit > setting.API.MaxResponseItems {
  160. limit = setting.API.MaxResponseItems
  161. }
  162. // Only fetch the issues if we either don't have a keyword or the search returned issues
  163. // This would otherwise return all issues if no issues were found by the search.
  164. if len(keyword) == 0 || len(issueIDs) > 0 || len(includedLabelNames) > 0 {
  165. issuesOpt := &models.IssuesOptions{
  166. ListOptions: models.ListOptions{
  167. Page: ctx.QueryInt("page"),
  168. PageSize: limit,
  169. },
  170. RepoIDs: repoIDs,
  171. IsClosed: isClosed,
  172. IssueIDs: issueIDs,
  173. IncludedLabelNames: includedLabelNames,
  174. SortType: "priorityrepo",
  175. PriorityRepoID: ctx.QueryInt64("priority_repo_id"),
  176. IsPull: isPull,
  177. UpdatedBeforeUnix: before,
  178. UpdatedAfterUnix: since,
  179. }
  180. // Filter for: Created by User, Assigned to User, Mentioning User, Review of User Requested
  181. if ctx.QueryBool("created") {
  182. issuesOpt.PosterID = ctx.User.ID
  183. }
  184. if ctx.QueryBool("assigned") {
  185. issuesOpt.AssigneeID = ctx.User.ID
  186. }
  187. if ctx.QueryBool("mentioned") {
  188. issuesOpt.MentionedID = ctx.User.ID
  189. }
  190. if ctx.QueryBool("review_requested") {
  191. issuesOpt.ReviewRequestedID = ctx.User.ID
  192. }
  193. if issues, err = models.Issues(issuesOpt); err != nil {
  194. ctx.Error(http.StatusInternalServerError, "Issues", err)
  195. return
  196. }
  197. issuesOpt.ListOptions = models.ListOptions{
  198. Page: -1,
  199. }
  200. if filteredCount, err = models.CountIssues(issuesOpt); err != nil {
  201. ctx.Error(http.StatusInternalServerError, "CountIssues", err)
  202. return
  203. }
  204. }
  205. ctx.SetLinkHeader(int(filteredCount), setting.UI.IssuePagingNum)
  206. ctx.Header().Set("X-Total-Count", fmt.Sprintf("%d", filteredCount))
  207. ctx.Header().Set("Access-Control-Expose-Headers", "X-Total-Count, Link")
  208. ctx.JSON(http.StatusOK, convert.ToAPIIssueList(issues))
  209. }
  210. // ListIssues list the issues of a repository
  211. func ListIssues(ctx *context.APIContext) {
  212. // swagger:operation GET /repos/{owner}/{repo}/issues issue issueListIssues
  213. // ---
  214. // summary: List a repository's issues
  215. // produces:
  216. // - application/json
  217. // parameters:
  218. // - name: owner
  219. // in: path
  220. // description: owner of the repo
  221. // type: string
  222. // required: true
  223. // - name: repo
  224. // in: path
  225. // description: name of the repo
  226. // type: string
  227. // required: true
  228. // - name: state
  229. // in: query
  230. // description: whether issue is open or closed
  231. // type: string
  232. // enum: [closed, open, all]
  233. // - name: labels
  234. // in: query
  235. // description: comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded
  236. // type: string
  237. // - name: q
  238. // in: query
  239. // description: search string
  240. // type: string
  241. // - name: type
  242. // in: query
  243. // description: filter by type (issues / pulls) if set
  244. // type: string
  245. // enum: [issues, pulls]
  246. // - name: milestones
  247. // in: query
  248. // 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
  249. // type: string
  250. // - name: page
  251. // in: query
  252. // description: page number of results to return (1-based)
  253. // type: integer
  254. // - name: limit
  255. // in: query
  256. // description: page size of results
  257. // type: integer
  258. // responses:
  259. // "200":
  260. // "$ref": "#/responses/IssueList"
  261. var isClosed util.OptionalBool
  262. switch ctx.Query("state") {
  263. case "closed":
  264. isClosed = util.OptionalBoolTrue
  265. case "all":
  266. isClosed = util.OptionalBoolNone
  267. default:
  268. isClosed = util.OptionalBoolFalse
  269. }
  270. var issues []*models.Issue
  271. var filteredCount int64
  272. keyword := strings.Trim(ctx.Query("q"), " ")
  273. if strings.IndexByte(keyword, 0) >= 0 {
  274. keyword = ""
  275. }
  276. var issueIDs []int64
  277. var labelIDs []int64
  278. var err error
  279. if len(keyword) > 0 {
  280. issueIDs, err = issue_indexer.SearchIssuesByKeyword([]int64{ctx.Repo.Repository.ID}, keyword)
  281. if err != nil {
  282. ctx.Error(http.StatusInternalServerError, "SearchIssuesByKeyword", err)
  283. return
  284. }
  285. }
  286. if splitted := strings.Split(ctx.Query("labels"), ","); len(splitted) > 0 {
  287. labelIDs, err = models.GetLabelIDsInRepoByNames(ctx.Repo.Repository.ID, splitted)
  288. if err != nil {
  289. ctx.Error(http.StatusInternalServerError, "GetLabelIDsInRepoByNames", err)
  290. return
  291. }
  292. }
  293. var mileIDs []int64
  294. if part := strings.Split(ctx.Query("milestones"), ","); len(part) > 0 {
  295. for i := range part {
  296. // uses names and fall back to ids
  297. // non existent milestones are discarded
  298. mile, err := models.GetMilestoneByRepoIDANDName(ctx.Repo.Repository.ID, part[i])
  299. if err == nil {
  300. mileIDs = append(mileIDs, mile.ID)
  301. continue
  302. }
  303. if !models.IsErrMilestoneNotExist(err) {
  304. ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoIDANDName", err)
  305. return
  306. }
  307. id, err := strconv.ParseInt(part[i], 10, 64)
  308. if err != nil {
  309. continue
  310. }
  311. mile, err = models.GetMilestoneByRepoID(ctx.Repo.Repository.ID, id)
  312. if err == nil {
  313. mileIDs = append(mileIDs, mile.ID)
  314. continue
  315. }
  316. if models.IsErrMilestoneNotExist(err) {
  317. continue
  318. }
  319. ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoID", err)
  320. }
  321. }
  322. listOptions := utils.GetListOptions(ctx)
  323. var isPull util.OptionalBool
  324. switch ctx.Query("type") {
  325. case "pulls":
  326. isPull = util.OptionalBoolTrue
  327. case "issues":
  328. isPull = util.OptionalBoolFalse
  329. default:
  330. isPull = util.OptionalBoolNone
  331. }
  332. // Only fetch the issues if we either don't have a keyword or the search returned issues
  333. // This would otherwise return all issues if no issues were found by the search.
  334. if len(keyword) == 0 || len(issueIDs) > 0 || len(labelIDs) > 0 {
  335. issuesOpt := &models.IssuesOptions{
  336. ListOptions: listOptions,
  337. RepoIDs: []int64{ctx.Repo.Repository.ID},
  338. IsClosed: isClosed,
  339. IssueIDs: issueIDs,
  340. LabelIDs: labelIDs,
  341. MilestoneIDs: mileIDs,
  342. IsPull: isPull,
  343. }
  344. if issues, err = models.Issues(issuesOpt); err != nil {
  345. ctx.Error(http.StatusInternalServerError, "Issues", err)
  346. return
  347. }
  348. issuesOpt.ListOptions = models.ListOptions{
  349. Page: -1,
  350. }
  351. if filteredCount, err = models.CountIssues(issuesOpt); err != nil {
  352. ctx.Error(http.StatusInternalServerError, "CountIssues", err)
  353. return
  354. }
  355. }
  356. ctx.SetLinkHeader(int(filteredCount), listOptions.PageSize)
  357. ctx.Header().Set("X-Total-Count", fmt.Sprintf("%d", filteredCount))
  358. ctx.Header().Set("Access-Control-Expose-Headers", "X-Total-Count, Link")
  359. ctx.JSON(http.StatusOK, convert.ToAPIIssueList(issues))
  360. }
  361. // GetIssue get an issue of a repository
  362. func GetIssue(ctx *context.APIContext) {
  363. // swagger:operation GET /repos/{owner}/{repo}/issues/{index} issue issueGetIssue
  364. // ---
  365. // summary: Get an issue
  366. // produces:
  367. // - application/json
  368. // parameters:
  369. // - name: owner
  370. // in: path
  371. // description: owner of the repo
  372. // type: string
  373. // required: true
  374. // - name: repo
  375. // in: path
  376. // description: name of the repo
  377. // type: string
  378. // required: true
  379. // - name: index
  380. // in: path
  381. // description: index of the issue to get
  382. // type: integer
  383. // format: int64
  384. // required: true
  385. // responses:
  386. // "200":
  387. // "$ref": "#/responses/Issue"
  388. // "404":
  389. // "$ref": "#/responses/notFound"
  390. issue, err := models.GetIssueWithAttrsByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
  391. if err != nil {
  392. if models.IsErrIssueNotExist(err) {
  393. ctx.NotFound()
  394. } else {
  395. ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
  396. }
  397. return
  398. }
  399. ctx.JSON(http.StatusOK, convert.ToAPIIssue(issue))
  400. }
  401. // CreateIssue create an issue of a repository
  402. func CreateIssue(ctx *context.APIContext) {
  403. // swagger:operation POST /repos/{owner}/{repo}/issues issue issueCreateIssue
  404. // ---
  405. // summary: Create an issue. If using deadline only the date will be taken into account, and time of day ignored.
  406. // consumes:
  407. // - application/json
  408. // produces:
  409. // - application/json
  410. // parameters:
  411. // - name: owner
  412. // in: path
  413. // description: owner of the repo
  414. // type: string
  415. // required: true
  416. // - name: repo
  417. // in: path
  418. // description: name of the repo
  419. // type: string
  420. // required: true
  421. // - name: body
  422. // in: body
  423. // schema:
  424. // "$ref": "#/definitions/CreateIssueOption"
  425. // responses:
  426. // "201":
  427. // "$ref": "#/responses/Issue"
  428. // "403":
  429. // "$ref": "#/responses/forbidden"
  430. // "412":
  431. // "$ref": "#/responses/error"
  432. // "422":
  433. // "$ref": "#/responses/validationError"
  434. form := web.GetForm(ctx).(*api.CreateIssueOption)
  435. var deadlineUnix timeutil.TimeStamp
  436. if form.Deadline != nil && ctx.Repo.CanWrite(models.UnitTypeIssues) {
  437. deadlineUnix = timeutil.TimeStamp(form.Deadline.Unix())
  438. }
  439. issue := &models.Issue{
  440. RepoID: ctx.Repo.Repository.ID,
  441. Repo: ctx.Repo.Repository,
  442. Title: form.Title,
  443. PosterID: ctx.User.ID,
  444. Poster: ctx.User,
  445. Content: form.Body,
  446. Ref: form.Ref,
  447. DeadlineUnix: deadlineUnix,
  448. }
  449. var assigneeIDs = make([]int64, 0)
  450. var err error
  451. if ctx.Repo.CanWrite(models.UnitTypeIssues) {
  452. issue.MilestoneID = form.Milestone
  453. assigneeIDs, err = models.MakeIDsFromAPIAssigneesToAdd(form.Assignee, form.Assignees)
  454. if err != nil {
  455. if models.IsErrUserNotExist(err) {
  456. ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err))
  457. } else {
  458. ctx.Error(http.StatusInternalServerError, "AddAssigneeByName", err)
  459. }
  460. return
  461. }
  462. // Check if the passed assignees is assignable
  463. for _, aID := range assigneeIDs {
  464. assignee, err := models.GetUserByID(aID)
  465. if err != nil {
  466. ctx.Error(http.StatusInternalServerError, "GetUserByID", err)
  467. return
  468. }
  469. valid, err := models.CanBeAssigned(assignee, ctx.Repo.Repository, false)
  470. if err != nil {
  471. ctx.Error(http.StatusInternalServerError, "canBeAssigned", err)
  472. return
  473. }
  474. if !valid {
  475. ctx.Error(http.StatusUnprocessableEntity, "canBeAssigned", models.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: ctx.Repo.Repository.Name})
  476. return
  477. }
  478. }
  479. } else {
  480. // setting labels is not allowed if user is not a writer
  481. form.Labels = make([]int64, 0)
  482. }
  483. if err := issue_service.NewIssue(ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs); err != nil {
  484. if models.IsErrUserDoesNotHaveAccessToRepo(err) {
  485. ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err)
  486. return
  487. }
  488. ctx.Error(http.StatusInternalServerError, "NewIssue", err)
  489. return
  490. }
  491. if form.Closed {
  492. if err := issue_service.ChangeStatus(issue, ctx.User, true); err != nil {
  493. if models.IsErrDependenciesLeft(err) {
  494. ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies")
  495. return
  496. }
  497. ctx.Error(http.StatusInternalServerError, "ChangeStatus", err)
  498. return
  499. }
  500. }
  501. // Refetch from database to assign some automatic values
  502. issue, err = models.GetIssueByID(issue.ID)
  503. if err != nil {
  504. ctx.Error(http.StatusInternalServerError, "GetIssueByID", err)
  505. return
  506. }
  507. ctx.JSON(http.StatusCreated, convert.ToAPIIssue(issue))
  508. }
  509. // EditIssue modify an issue of a repository
  510. func EditIssue(ctx *context.APIContext) {
  511. // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index} issue issueEditIssue
  512. // ---
  513. // summary: Edit an issue. If using deadline only the date will be taken into account, and time of day ignored.
  514. // consumes:
  515. // - application/json
  516. // produces:
  517. // - application/json
  518. // parameters:
  519. // - name: owner
  520. // in: path
  521. // description: owner of the repo
  522. // type: string
  523. // required: true
  524. // - name: repo
  525. // in: path
  526. // description: name of the repo
  527. // type: string
  528. // required: true
  529. // - name: index
  530. // in: path
  531. // description: index of the issue to edit
  532. // type: integer
  533. // format: int64
  534. // required: true
  535. // - name: body
  536. // in: body
  537. // schema:
  538. // "$ref": "#/definitions/EditIssueOption"
  539. // responses:
  540. // "201":
  541. // "$ref": "#/responses/Issue"
  542. // "403":
  543. // "$ref": "#/responses/forbidden"
  544. // "404":
  545. // "$ref": "#/responses/notFound"
  546. // "412":
  547. // "$ref": "#/responses/error"
  548. form := web.GetForm(ctx).(*api.EditIssueOption)
  549. issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
  550. if err != nil {
  551. if models.IsErrIssueNotExist(err) {
  552. ctx.NotFound()
  553. } else {
  554. ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
  555. }
  556. return
  557. }
  558. issue.Repo = ctx.Repo.Repository
  559. canWrite := ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)
  560. err = issue.LoadAttributes()
  561. if err != nil {
  562. ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
  563. return
  564. }
  565. if !issue.IsPoster(ctx.User.ID) && !canWrite {
  566. ctx.Status(http.StatusForbidden)
  567. return
  568. }
  569. oldTitle := issue.Title
  570. if len(form.Title) > 0 {
  571. issue.Title = form.Title
  572. }
  573. if form.Body != nil {
  574. issue.Content = *form.Body
  575. }
  576. if form.Ref != nil {
  577. err = issue_service.ChangeIssueRef(issue, ctx.User, *form.Ref)
  578. if err != nil {
  579. ctx.Error(http.StatusInternalServerError, "UpdateRef", err)
  580. return
  581. }
  582. }
  583. // Update or remove the deadline, only if set and allowed
  584. if (form.Deadline != nil || form.RemoveDeadline != nil) && canWrite {
  585. var deadlineUnix timeutil.TimeStamp
  586. if (form.RemoveDeadline == nil || !*form.RemoveDeadline) && !form.Deadline.IsZero() {
  587. deadline := time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(),
  588. 23, 59, 59, 0, form.Deadline.Location())
  589. deadlineUnix = timeutil.TimeStamp(deadline.Unix())
  590. }
  591. if err := models.UpdateIssueDeadline(issue, deadlineUnix, ctx.User); err != nil {
  592. ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err)
  593. return
  594. }
  595. issue.DeadlineUnix = deadlineUnix
  596. }
  597. // Add/delete assignees
  598. // Deleting is done the GitHub way (quote from their api documentation):
  599. // https://developer.github.com/v3/issues/#edit-an-issue
  600. // "assignees" (array): Logins for Users to assign to this issue.
  601. // Pass one or more user logins to replace the set of assignees on this Issue.
  602. // Send an empty array ([]) to clear all assignees from the Issue.
  603. if canWrite && (form.Assignees != nil || form.Assignee != nil) {
  604. oneAssignee := ""
  605. if form.Assignee != nil {
  606. oneAssignee = *form.Assignee
  607. }
  608. err = issue_service.UpdateAssignees(issue, oneAssignee, form.Assignees, ctx.User)
  609. if err != nil {
  610. ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err)
  611. return
  612. }
  613. }
  614. if canWrite && form.Milestone != nil &&
  615. issue.MilestoneID != *form.Milestone {
  616. oldMilestoneID := issue.MilestoneID
  617. issue.MilestoneID = *form.Milestone
  618. if err = issue_service.ChangeMilestoneAssign(issue, ctx.User, oldMilestoneID); err != nil {
  619. ctx.Error(http.StatusInternalServerError, "ChangeMilestoneAssign", err)
  620. return
  621. }
  622. }
  623. if form.State != nil {
  624. issue.IsClosed = api.StateClosed == api.StateType(*form.State)
  625. }
  626. statusChangeComment, titleChanged, err := models.UpdateIssueByAPI(issue, ctx.User)
  627. if err != nil {
  628. if models.IsErrDependenciesLeft(err) {
  629. ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies")
  630. return
  631. }
  632. ctx.Error(http.StatusInternalServerError, "UpdateIssueByAPI", err)
  633. return
  634. }
  635. if titleChanged {
  636. notification.NotifyIssueChangeTitle(ctx.User, issue, oldTitle)
  637. }
  638. if statusChangeComment != nil {
  639. notification.NotifyIssueChangeStatus(ctx.User, issue, statusChangeComment, issue.IsClosed)
  640. }
  641. // Refetch from database to assign some automatic values
  642. issue, err = models.GetIssueByID(issue.ID)
  643. if err != nil {
  644. ctx.InternalServerError(err)
  645. return
  646. }
  647. if err = issue.LoadMilestone(); err != nil {
  648. ctx.InternalServerError(err)
  649. return
  650. }
  651. ctx.JSON(http.StatusCreated, convert.ToAPIIssue(issue))
  652. }
  653. // UpdateIssueDeadline updates an issue deadline
  654. func UpdateIssueDeadline(ctx *context.APIContext) {
  655. // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/deadline issue issueEditIssueDeadline
  656. // ---
  657. // 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.
  658. // consumes:
  659. // - application/json
  660. // produces:
  661. // - application/json
  662. // parameters:
  663. // - name: owner
  664. // in: path
  665. // description: owner of the repo
  666. // type: string
  667. // required: true
  668. // - name: repo
  669. // in: path
  670. // description: name of the repo
  671. // type: string
  672. // required: true
  673. // - name: index
  674. // in: path
  675. // description: index of the issue to create or update a deadline on
  676. // type: integer
  677. // format: int64
  678. // required: true
  679. // - name: body
  680. // in: body
  681. // schema:
  682. // "$ref": "#/definitions/EditDeadlineOption"
  683. // responses:
  684. // "201":
  685. // "$ref": "#/responses/IssueDeadline"
  686. // "403":
  687. // "$ref": "#/responses/forbidden"
  688. // "404":
  689. // "$ref": "#/responses/notFound"
  690. form := web.GetForm(ctx).(*api.EditDeadlineOption)
  691. issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
  692. if err != nil {
  693. if models.IsErrIssueNotExist(err) {
  694. ctx.NotFound()
  695. } else {
  696. ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
  697. }
  698. return
  699. }
  700. if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
  701. ctx.Error(http.StatusForbidden, "", "Not repo writer")
  702. return
  703. }
  704. var deadlineUnix timeutil.TimeStamp
  705. var deadline time.Time
  706. if form.Deadline != nil && !form.Deadline.IsZero() {
  707. deadline = time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(),
  708. 23, 59, 59, 0, time.Local)
  709. deadlineUnix = timeutil.TimeStamp(deadline.Unix())
  710. }
  711. if err := models.UpdateIssueDeadline(issue, deadlineUnix, ctx.User); err != nil {
  712. ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err)
  713. return
  714. }
  715. ctx.JSON(http.StatusCreated, api.IssueDeadline{Deadline: &deadline})
  716. }