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

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