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 29KB

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