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

issue.go 31 kB

11 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
9 years ago
9 years ago
10 years ago
10 years ago
10 years ago
10 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
10 years ago
10 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262
  1. // Copyright 2014 The Gogs Authors. All rights reserved.
  2. // Use of this source code is governed by a MIT-style
  3. // license that can be found in the LICENSE file.
  4. package repo
  5. import (
  6. "errors"
  7. "fmt"
  8. "io"
  9. "io/ioutil"
  10. "net/http"
  11. "net/url"
  12. "strings"
  13. "time"
  14. "github.com/Unknwon/com"
  15. "github.com/Unknwon/paginater"
  16. "github.com/gogits/gogs/models"
  17. "github.com/gogits/gogs/modules/auth"
  18. "github.com/gogits/gogs/modules/base"
  19. "github.com/gogits/gogs/modules/log"
  20. "github.com/gogits/gogs/modules/mailer"
  21. "github.com/gogits/gogs/modules/markdown"
  22. "github.com/gogits/gogs/modules/middleware"
  23. "github.com/gogits/gogs/modules/setting"
  24. )
  25. const (
  26. ISSUES base.TplName = "repo/issue/list"
  27. ISSUE_NEW base.TplName = "repo/issue/new"
  28. ISSUE_VIEW base.TplName = "repo/issue/view"
  29. LABELS base.TplName = "repo/issue/labels"
  30. MILESTONE base.TplName = "repo/issue/milestones"
  31. MILESTONE_NEW base.TplName = "repo/issue/milestone_new"
  32. MILESTONE_EDIT base.TplName = "repo/issue/milestone_edit"
  33. ISSUE_TEMPLATE_KEY = "IssueTemplate"
  34. )
  35. var (
  36. ErrFileTypeForbidden = errors.New("File type is not allowed")
  37. ErrTooManyFiles = errors.New("Maximum number of files to upload exceeded")
  38. IssueTemplateCandidates = []string{
  39. "ISSUE_TEMPLATE.md",
  40. ".gogs/ISSUE_TEMPLATE.md",
  41. ".github/ISSUE_TEMPLATE.md",
  42. }
  43. )
  44. func MustEnableIssues(ctx *middleware.Context) {
  45. if !ctx.Repo.Repository.EnableIssues {
  46. ctx.Handle(404, "MustEnableIssues", nil)
  47. }
  48. }
  49. func MustAllowPulls(ctx *middleware.Context) {
  50. if !ctx.Repo.Repository.AllowsPulls() {
  51. ctx.Handle(404, "MustAllowPulls", nil)
  52. }
  53. ctx.Data["HasForkedRepo"] = ctx.IsSigned && ctx.User.HasForkedRepo(ctx.Repo.Repository.ID)
  54. }
  55. func RetrieveLabels(ctx *middleware.Context) {
  56. labels, err := models.GetLabelsByRepoID(ctx.Repo.Repository.ID)
  57. if err != nil {
  58. ctx.Handle(500, "RetrieveLabels.GetLabels: %v", err)
  59. return
  60. }
  61. for _, l := range labels {
  62. l.CalOpenIssues()
  63. }
  64. ctx.Data["Labels"] = labels
  65. ctx.Data["NumLabels"] = len(labels)
  66. }
  67. func Issues(ctx *middleware.Context) {
  68. isPullList := ctx.Params(":type") == "pulls"
  69. if isPullList {
  70. MustAllowPulls(ctx)
  71. if ctx.Written() {
  72. return
  73. }
  74. ctx.Data["Title"] = ctx.Tr("repo.pulls")
  75. ctx.Data["PageIsPullList"] = true
  76. } else {
  77. MustEnableIssues(ctx)
  78. if ctx.Written() {
  79. return
  80. }
  81. ctx.Data["Title"] = ctx.Tr("repo.issues")
  82. ctx.Data["PageIsIssueList"] = true
  83. }
  84. viewType := ctx.Query("type")
  85. sortType := ctx.Query("sort")
  86. types := []string{"assigned", "created_by", "mentioned"}
  87. if !com.IsSliceContainsStr(types, viewType) {
  88. viewType = "all"
  89. }
  90. // Must sign in to see issues about you.
  91. if viewType != "all" && !ctx.IsSigned {
  92. ctx.SetCookie("redirect_to", "/"+url.QueryEscape(setting.AppSubUrl+ctx.Req.RequestURI), 0, setting.AppSubUrl)
  93. ctx.Redirect(setting.AppSubUrl + "/user/login")
  94. return
  95. }
  96. var (
  97. assigneeID = ctx.QueryInt64("assignee")
  98. posterID int64
  99. )
  100. filterMode := models.FM_ALL
  101. switch viewType {
  102. case "assigned":
  103. filterMode = models.FM_ASSIGN
  104. assigneeID = ctx.User.Id
  105. case "created_by":
  106. filterMode = models.FM_CREATE
  107. posterID = ctx.User.Id
  108. case "mentioned":
  109. filterMode = models.FM_MENTION
  110. }
  111. var uid int64 = -1
  112. if ctx.IsSigned {
  113. uid = ctx.User.Id
  114. }
  115. repo := ctx.Repo.Repository
  116. selectLabels := ctx.Query("labels")
  117. milestoneID := ctx.QueryInt64("milestone")
  118. isShowClosed := ctx.Query("state") == "closed"
  119. issueStats := models.GetIssueStats(&models.IssueStatsOptions{
  120. RepoID: repo.ID,
  121. UserID: uid,
  122. LabelID: com.StrTo(selectLabels).MustInt64(),
  123. MilestoneID: milestoneID,
  124. AssigneeID: assigneeID,
  125. FilterMode: filterMode,
  126. IsPull: isPullList,
  127. })
  128. page := ctx.QueryInt("page")
  129. if page <= 1 {
  130. page = 1
  131. }
  132. var total int
  133. if !isShowClosed {
  134. total = int(issueStats.OpenCount)
  135. } else {
  136. total = int(issueStats.ClosedCount)
  137. }
  138. pager := paginater.New(total, setting.IssuePagingNum, page, 5)
  139. ctx.Data["Page"] = pager
  140. // Get issues.
  141. issues, err := models.Issues(&models.IssuesOptions{
  142. UserID: uid,
  143. AssigneeID: assigneeID,
  144. RepoID: repo.ID,
  145. PosterID: posterID,
  146. MilestoneID: milestoneID,
  147. Page: pager.Current(),
  148. IsClosed: isShowClosed,
  149. IsMention: filterMode == models.FM_MENTION,
  150. IsPull: isPullList,
  151. Labels: selectLabels,
  152. SortType: sortType,
  153. })
  154. if err != nil {
  155. ctx.Handle(500, "Issues: %v", err)
  156. return
  157. }
  158. // Get issue-user relations.
  159. pairs, err := models.GetIssueUsers(repo.ID, posterID, isShowClosed)
  160. if err != nil {
  161. ctx.Handle(500, "GetIssueUsers: %v", err)
  162. return
  163. }
  164. // Get posters.
  165. for i := range issues {
  166. if err = issues[i].GetPoster(); err != nil {
  167. ctx.Handle(500, "GetPoster", fmt.Errorf("[#%d]%v", issues[i].ID, err))
  168. return
  169. }
  170. if err = issues[i].GetLabels(); err != nil {
  171. ctx.Handle(500, "GetLabels", fmt.Errorf("[#%d]%v", issues[i].ID, err))
  172. return
  173. }
  174. if !ctx.IsSigned {
  175. issues[i].IsRead = true
  176. continue
  177. }
  178. // Check read status.
  179. idx := models.PairsContains(pairs, issues[i].ID, ctx.User.Id)
  180. if idx > -1 {
  181. issues[i].IsRead = pairs[idx].IsRead
  182. } else {
  183. issues[i].IsRead = true
  184. }
  185. }
  186. ctx.Data["Issues"] = issues
  187. // Get milestones.
  188. ctx.Data["Milestones"], err = models.GetAllRepoMilestones(repo.ID)
  189. if err != nil {
  190. ctx.Handle(500, "GetAllRepoMilestones: %v", err)
  191. return
  192. }
  193. // Get assignees.
  194. ctx.Data["Assignees"], err = repo.GetAssignees()
  195. if err != nil {
  196. ctx.Handle(500, "GetAssignees: %v", err)
  197. return
  198. }
  199. ctx.Data["IssueStats"] = issueStats
  200. ctx.Data["SelectLabels"] = com.StrTo(selectLabels).MustInt64()
  201. ctx.Data["ViewType"] = viewType
  202. ctx.Data["SortType"] = sortType
  203. ctx.Data["MilestoneID"] = milestoneID
  204. ctx.Data["AssigneeID"] = assigneeID
  205. ctx.Data["IsShowClosed"] = isShowClosed
  206. if isShowClosed {
  207. ctx.Data["State"] = "closed"
  208. } else {
  209. ctx.Data["State"] = "open"
  210. }
  211. ctx.HTML(200, ISSUES)
  212. }
  213. func renderAttachmentSettings(ctx *middleware.Context) {
  214. ctx.Data["RequireDropzone"] = true
  215. ctx.Data["IsAttachmentEnabled"] = setting.AttachmentEnabled
  216. ctx.Data["AttachmentAllowedTypes"] = setting.AttachmentAllowedTypes
  217. ctx.Data["AttachmentMaxSize"] = setting.AttachmentMaxSize
  218. ctx.Data["AttachmentMaxFiles"] = setting.AttachmentMaxFiles
  219. }
  220. func RetrieveRepoMilestonesAndAssignees(ctx *middleware.Context, repo *models.Repository) {
  221. var err error
  222. ctx.Data["OpenMilestones"], err = models.GetMilestones(repo.ID, -1, false)
  223. if err != nil {
  224. ctx.Handle(500, "GetMilestones: %v", err)
  225. return
  226. }
  227. ctx.Data["ClosedMilestones"], err = models.GetMilestones(repo.ID, -1, true)
  228. if err != nil {
  229. ctx.Handle(500, "GetMilestones: %v", err)
  230. return
  231. }
  232. ctx.Data["Assignees"], err = repo.GetAssignees()
  233. if err != nil {
  234. ctx.Handle(500, "GetAssignees: %v", err)
  235. return
  236. }
  237. }
  238. func RetrieveRepoMetas(ctx *middleware.Context, repo *models.Repository) []*models.Label {
  239. if !ctx.Repo.IsWriter() {
  240. return nil
  241. }
  242. labels, err := models.GetLabelsByRepoID(repo.ID)
  243. if err != nil {
  244. ctx.Handle(500, "GetLabelsByRepoID: %v", err)
  245. return nil
  246. }
  247. ctx.Data["Labels"] = labels
  248. RetrieveRepoMilestonesAndAssignees(ctx, repo)
  249. if ctx.Written() {
  250. return nil
  251. }
  252. return labels
  253. }
  254. func getFileContentFromDefaultBranch(ctx *middleware.Context, filename string) (string, bool) {
  255. var r io.Reader
  256. var bytes []byte
  257. if ctx.Repo.Commit == nil {
  258. var err error
  259. ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
  260. if err != nil {
  261. return "", false
  262. }
  263. }
  264. entry, err := ctx.Repo.Commit.GetTreeEntryByPath(filename)
  265. if err != nil {
  266. return "", false
  267. }
  268. r, err = entry.Blob().Data()
  269. if err != nil {
  270. return "", false
  271. }
  272. bytes, err = ioutil.ReadAll(r)
  273. if err != nil {
  274. return "", false
  275. }
  276. return string(bytes), true
  277. }
  278. func setTemplateIfExists(ctx *middleware.Context, ctxDataKey string, possibleFiles []string) {
  279. for _, filename := range possibleFiles {
  280. content, found := getFileContentFromDefaultBranch(ctx, filename)
  281. if found {
  282. ctx.Data[ctxDataKey] = content
  283. return
  284. }
  285. }
  286. }
  287. func NewIssue(ctx *middleware.Context) {
  288. ctx.Data["Title"] = ctx.Tr("repo.issues.new")
  289. ctx.Data["PageIsIssueList"] = true
  290. setTemplateIfExists(ctx, ISSUE_TEMPLATE_KEY, IssueTemplateCandidates)
  291. renderAttachmentSettings(ctx)
  292. RetrieveRepoMetas(ctx, ctx.Repo.Repository)
  293. if ctx.Written() {
  294. return
  295. }
  296. ctx.Data["RequireHighlightJS"] = true
  297. ctx.HTML(200, ISSUE_NEW)
  298. }
  299. func ValidateRepoMetas(ctx *middleware.Context, form auth.CreateIssueForm) ([]int64, int64, int64) {
  300. var (
  301. repo = ctx.Repo.Repository
  302. err error
  303. )
  304. labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository)
  305. if ctx.Written() {
  306. return nil, 0, 0
  307. }
  308. if !ctx.Repo.IsWriter() {
  309. return nil, 0, 0
  310. }
  311. // Check labels.
  312. labelIDs := base.StringsToInt64s(strings.Split(form.LabelIDs, ","))
  313. labelIDMark := base.Int64sToMap(labelIDs)
  314. hasSelected := false
  315. for i := range labels {
  316. if labelIDMark[labels[i].ID] {
  317. labels[i].IsChecked = true
  318. hasSelected = true
  319. }
  320. }
  321. ctx.Data["HasSelectedLabel"] = hasSelected
  322. ctx.Data["label_ids"] = form.LabelIDs
  323. ctx.Data["Labels"] = labels
  324. // Check milestone.
  325. milestoneID := form.MilestoneID
  326. if milestoneID > 0 {
  327. ctx.Data["Milestone"], err = repo.GetMilestoneByID(milestoneID)
  328. if err != nil {
  329. ctx.Handle(500, "GetMilestoneByID: %v", err)
  330. return nil, 0, 0
  331. }
  332. ctx.Data["milestone_id"] = milestoneID
  333. }
  334. // Check assignee.
  335. assigneeID := form.AssigneeID
  336. if assigneeID > 0 {
  337. ctx.Data["Assignee"], err = repo.GetAssigneeByID(assigneeID)
  338. if err != nil {
  339. ctx.Handle(500, "GetAssigneeByID: %v", err)
  340. return nil, 0, 0
  341. }
  342. ctx.Data["assignee_id"] = assigneeID
  343. }
  344. return labelIDs, milestoneID, assigneeID
  345. }
  346. func notifyWatchersAndMentions(ctx *middleware.Context, issue *models.Issue) {
  347. // Update mentions
  348. mentions := markdown.MentionPattern.FindAllString(issue.Content, -1)
  349. if len(mentions) > 0 {
  350. for i := range mentions {
  351. mentions[i] = strings.TrimSpace(mentions[i])[1:]
  352. }
  353. if err := models.UpdateMentions(mentions, issue.ID); err != nil {
  354. ctx.Handle(500, "UpdateMentions", err)
  355. return
  356. }
  357. }
  358. repo := ctx.Repo.Repository
  359. // Mail watchers and mentions.
  360. if setting.Service.EnableNotifyMail {
  361. tos, err := mailer.SendIssueNotifyMail(ctx.User, ctx.Repo.Owner, repo, issue)
  362. if err != nil {
  363. ctx.Handle(500, "SendIssueNotifyMail", err)
  364. return
  365. }
  366. tos = append(tos, ctx.User.LowerName)
  367. newTos := make([]string, 0, len(mentions))
  368. for _, m := range mentions {
  369. if com.IsSliceContainsStr(tos, m) {
  370. continue
  371. }
  372. newTos = append(newTos, m)
  373. }
  374. if err = mailer.SendIssueMentionMail(ctx.Render, ctx.User, ctx.Repo.Owner,
  375. repo, issue, models.GetUserEmailsByNames(newTos)); err != nil {
  376. ctx.Handle(500, "SendIssueMentionMail", err)
  377. return
  378. }
  379. }
  380. }
  381. func NewIssuePost(ctx *middleware.Context, form auth.CreateIssueForm) {
  382. ctx.Data["Title"] = ctx.Tr("repo.issues.new")
  383. ctx.Data["PageIsIssueList"] = true
  384. renderAttachmentSettings(ctx)
  385. var (
  386. repo = ctx.Repo.Repository
  387. attachments []string
  388. )
  389. labelIDs, milestoneID, assigneeID := ValidateRepoMetas(ctx, form)
  390. if ctx.Written() {
  391. return
  392. }
  393. if setting.AttachmentEnabled {
  394. attachments = form.Attachments
  395. }
  396. if ctx.HasError() {
  397. ctx.HTML(200, ISSUE_NEW)
  398. return
  399. }
  400. issue := &models.Issue{
  401. RepoID: ctx.Repo.Repository.ID,
  402. Index: repo.NextIssueIndex(),
  403. Name: strings.TrimSpace(form.Title),
  404. PosterID: ctx.User.Id,
  405. Poster: ctx.User,
  406. MilestoneID: milestoneID,
  407. AssigneeID: assigneeID,
  408. Content: form.Content,
  409. }
  410. if err := models.NewIssue(repo, issue, labelIDs, attachments); err != nil {
  411. ctx.Handle(500, "NewIssue", err)
  412. return
  413. }
  414. notifyWatchersAndMentions(ctx, issue)
  415. if ctx.Written() {
  416. return
  417. }
  418. log.Trace("Issue created: %d/%d", repo.ID, issue.ID)
  419. ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + com.ToStr(issue.Index))
  420. }
  421. func UploadIssueAttachment(ctx *middleware.Context) {
  422. if !setting.AttachmentEnabled {
  423. ctx.Error(404, "attachment is not enabled")
  424. return
  425. }
  426. allowedTypes := strings.Split(setting.AttachmentAllowedTypes, ",")
  427. file, header, err := ctx.Req.FormFile("file")
  428. if err != nil {
  429. ctx.Error(500, fmt.Sprintf("FormFile: %v", err))
  430. return
  431. }
  432. defer file.Close()
  433. buf := make([]byte, 1024)
  434. n, _ := file.Read(buf)
  435. if n > 0 {
  436. buf = buf[:n]
  437. }
  438. fileType := http.DetectContentType(buf)
  439. allowed := false
  440. for _, t := range allowedTypes {
  441. t := strings.Trim(t, " ")
  442. if t == "*/*" || t == fileType {
  443. allowed = true
  444. break
  445. }
  446. }
  447. if !allowed {
  448. ctx.Error(400, ErrFileTypeForbidden.Error())
  449. return
  450. }
  451. attach, err := models.NewAttachment(header.Filename, buf, file)
  452. if err != nil {
  453. ctx.Error(500, fmt.Sprintf("NewAttachment: %v", err))
  454. return
  455. }
  456. log.Trace("New attachment uploaded: %s", attach.UUID)
  457. ctx.JSON(200, map[string]string{
  458. "uuid": attach.UUID,
  459. })
  460. }
  461. func ViewIssue(ctx *middleware.Context) {
  462. ctx.Data["RequireDropzone"] = true
  463. renderAttachmentSettings(ctx)
  464. issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
  465. if err != nil {
  466. if models.IsErrIssueNotExist(err) {
  467. ctx.Handle(404, "GetIssueByIndex", err)
  468. } else {
  469. ctx.Handle(500, "GetIssueByIndex", err)
  470. }
  471. return
  472. }
  473. ctx.Data["Title"] = issue.Name
  474. // Make sure type and URL matches.
  475. if ctx.Params(":type") == "issues" && issue.IsPull {
  476. ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index))
  477. return
  478. } else if ctx.Params(":type") == "pulls" && !issue.IsPull {
  479. ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + com.ToStr(issue.Index))
  480. return
  481. }
  482. if issue.IsPull {
  483. if err = issue.GetPullRequest(); err != nil {
  484. ctx.Handle(500, "GetPullRequest", err)
  485. return
  486. }
  487. ctx.Data["PageIsPullList"] = true
  488. ctx.Data["PageIsPullConversation"] = true
  489. ctx.Data["HasForkedRepo"] = ctx.IsSigned && ctx.User.HasForkedRepo(ctx.Repo.Repository.ID)
  490. } else {
  491. MustEnableIssues(ctx)
  492. if ctx.Written() {
  493. return
  494. }
  495. ctx.Data["PageIsIssueList"] = true
  496. }
  497. if err = issue.GetPoster(); err != nil {
  498. ctx.Handle(500, "GetPoster", err)
  499. return
  500. }
  501. issue.RenderedContent = string(markdown.Render([]byte(issue.Content), ctx.Repo.RepoLink,
  502. ctx.Repo.Repository.ComposeMetas()))
  503. repo := ctx.Repo.Repository
  504. // Get more information if it's a pull request.
  505. if issue.IsPull {
  506. if issue.HasMerged {
  507. ctx.Data["DisableStatusChange"] = issue.HasMerged
  508. PrepareMergedViewPullInfo(ctx, issue)
  509. } else {
  510. PrepareViewPullInfo(ctx, issue)
  511. }
  512. if ctx.Written() {
  513. return
  514. }
  515. }
  516. // Metas.
  517. // Check labels.
  518. if err = issue.GetLabels(); err != nil {
  519. ctx.Handle(500, "GetLabels", err)
  520. return
  521. }
  522. labelIDMark := make(map[int64]bool)
  523. for i := range issue.Labels {
  524. labelIDMark[issue.Labels[i].ID] = true
  525. }
  526. labels, err := models.GetLabelsByRepoID(repo.ID)
  527. if err != nil {
  528. ctx.Handle(500, "GetLabelsByRepoID: %v", err)
  529. return
  530. }
  531. hasSelected := false
  532. for i := range labels {
  533. if labelIDMark[labels[i].ID] {
  534. labels[i].IsChecked = true
  535. hasSelected = true
  536. }
  537. }
  538. ctx.Data["HasSelectedLabel"] = hasSelected
  539. ctx.Data["Labels"] = labels
  540. // Check milestone and assignee.
  541. if ctx.Repo.IsWriter() {
  542. RetrieveRepoMilestonesAndAssignees(ctx, repo)
  543. if ctx.Written() {
  544. return
  545. }
  546. }
  547. if ctx.IsSigned {
  548. // Update issue-user.
  549. if err = issue.ReadBy(ctx.User.Id); err != nil {
  550. ctx.Handle(500, "ReadBy", err)
  551. return
  552. }
  553. }
  554. var (
  555. tag models.CommentTag
  556. ok bool
  557. marked = make(map[int64]models.CommentTag)
  558. comment *models.Comment
  559. participants = make([]*models.User, 1, 10)
  560. )
  561. // Render comments and and fetch participants.
  562. participants[0] = issue.Poster
  563. for _, comment = range issue.Comments {
  564. if comment.Type == models.COMMENT_TYPE_COMMENT {
  565. comment.RenderedContent = string(markdown.Render([]byte(comment.Content), ctx.Repo.RepoLink,
  566. ctx.Repo.Repository.ComposeMetas()))
  567. // Check tag.
  568. tag, ok = marked[comment.PosterID]
  569. if ok {
  570. comment.ShowTag = tag
  571. continue
  572. }
  573. if repo.IsOwnedBy(comment.PosterID) ||
  574. (repo.Owner.IsOrganization() && repo.Owner.IsOwnedBy(comment.PosterID)) {
  575. comment.ShowTag = models.COMMENT_TAG_OWNER
  576. } else if comment.Poster.IsWriterOfRepo(repo) {
  577. comment.ShowTag = models.COMMENT_TAG_WRITER
  578. } else if comment.PosterID == issue.PosterID {
  579. comment.ShowTag = models.COMMENT_TAG_POSTER
  580. }
  581. marked[comment.PosterID] = comment.ShowTag
  582. isAdded := false
  583. for j := range participants {
  584. if comment.Poster == participants[j] {
  585. isAdded = true
  586. break
  587. }
  588. }
  589. if !isAdded && !issue.IsPoster(comment.Poster.Id) {
  590. participants = append(participants, comment.Poster)
  591. }
  592. }
  593. }
  594. ctx.Data["Participants"] = participants
  595. ctx.Data["NumParticipants"] = len(participants)
  596. ctx.Data["Issue"] = issue
  597. ctx.Data["IsIssueOwner"] = ctx.Repo.IsWriter() || (ctx.IsSigned && (issue.IsPoster(ctx.User.Id) || ctx.User.IsAdmin))
  598. ctx.Data["SignInLink"] = setting.AppSubUrl + "/user/login"
  599. ctx.Data["RequireHighlightJS"] = true
  600. ctx.HTML(200, ISSUE_VIEW)
  601. }
  602. func getActionIssue(ctx *middleware.Context) *models.Issue {
  603. issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
  604. if err != nil {
  605. if models.IsErrIssueNotExist(err) {
  606. ctx.Error(404, "GetIssueByIndex")
  607. } else {
  608. ctx.Handle(500, "GetIssueByIndex", err)
  609. }
  610. return nil
  611. }
  612. return issue
  613. }
  614. func UpdateIssueTitle(ctx *middleware.Context) {
  615. issue := getActionIssue(ctx)
  616. if ctx.Written() {
  617. return
  618. }
  619. if !ctx.IsSigned || (ctx.User.Id != issue.PosterID && !ctx.Repo.IsWriter() && !ctx.User.IsAdmin) {
  620. ctx.Error(403)
  621. return
  622. }
  623. issue.Name = ctx.QueryTrim("title")
  624. if len(issue.Name) == 0 {
  625. ctx.Error(204)
  626. return
  627. }
  628. if err := models.UpdateIssue(issue); err != nil {
  629. ctx.Handle(500, "UpdateIssue", err)
  630. return
  631. }
  632. ctx.JSON(200, map[string]interface{}{
  633. "title": issue.Name,
  634. })
  635. }
  636. func UpdateIssueContent(ctx *middleware.Context) {
  637. issue := getActionIssue(ctx)
  638. if ctx.Written() {
  639. return
  640. }
  641. if !ctx.IsSigned || (ctx.User.Id != issue.PosterID && !ctx.Repo.IsWriter() && !ctx.User.IsAdmin) {
  642. ctx.Error(403)
  643. return
  644. }
  645. issue.Content = ctx.Query("content")
  646. if err := models.UpdateIssue(issue); err != nil {
  647. ctx.Handle(500, "UpdateIssue", err)
  648. return
  649. }
  650. ctx.JSON(200, map[string]interface{}{
  651. "content": string(markdown.Render([]byte(issue.Content), ctx.Query("context"), ctx.Repo.Repository.ComposeMetas())),
  652. })
  653. }
  654. func UpdateIssueLabel(ctx *middleware.Context) {
  655. issue := getActionIssue(ctx)
  656. if ctx.Written() {
  657. return
  658. }
  659. if ctx.Query("action") == "clear" {
  660. if err := issue.ClearLabels(); err != nil {
  661. ctx.Handle(500, "ClearLabels", err)
  662. return
  663. }
  664. } else {
  665. isAttach := ctx.Query("action") == "attach"
  666. label, err := models.GetLabelByID(ctx.QueryInt64("id"))
  667. if err != nil {
  668. if models.IsErrLabelNotExist(err) {
  669. ctx.Error(404, "GetLabelByID")
  670. } else {
  671. ctx.Handle(500, "GetLabelByID", err)
  672. }
  673. return
  674. }
  675. if isAttach && !issue.HasLabel(label.ID) {
  676. if err = issue.AddLabel(label); err != nil {
  677. ctx.Handle(500, "AddLabel", err)
  678. return
  679. }
  680. } else if !isAttach && issue.HasLabel(label.ID) {
  681. if err = issue.RemoveLabel(label); err != nil {
  682. ctx.Handle(500, "RemoveLabel", err)
  683. return
  684. }
  685. }
  686. }
  687. ctx.JSON(200, map[string]interface{}{
  688. "ok": true,
  689. })
  690. }
  691. func UpdateIssueMilestone(ctx *middleware.Context) {
  692. issue := getActionIssue(ctx)
  693. if ctx.Written() {
  694. return
  695. }
  696. oldMid := issue.MilestoneID
  697. mid := ctx.QueryInt64("id")
  698. if oldMid == mid {
  699. ctx.JSON(200, map[string]interface{}{
  700. "ok": true,
  701. })
  702. return
  703. }
  704. // Not check for invalid milestone id and give responsibility to owners.
  705. issue.MilestoneID = mid
  706. if err := models.ChangeMilestoneAssign(oldMid, issue); err != nil {
  707. ctx.Handle(500, "ChangeMilestoneAssign", err)
  708. return
  709. }
  710. ctx.JSON(200, map[string]interface{}{
  711. "ok": true,
  712. })
  713. }
  714. func UpdateIssueAssignee(ctx *middleware.Context) {
  715. issue := getActionIssue(ctx)
  716. if ctx.Written() {
  717. return
  718. }
  719. aid := ctx.QueryInt64("id")
  720. if issue.AssigneeID == aid {
  721. ctx.JSON(200, map[string]interface{}{
  722. "ok": true,
  723. })
  724. return
  725. }
  726. // Not check for invalid assignee id and give responsibility to owners.
  727. issue.AssigneeID = aid
  728. if err := models.UpdateIssueUserByAssignee(issue); err != nil {
  729. ctx.Handle(500, "UpdateIssueUserByAssignee: %v", err)
  730. return
  731. }
  732. ctx.JSON(200, map[string]interface{}{
  733. "ok": true,
  734. })
  735. }
  736. func NewComment(ctx *middleware.Context, form auth.CreateCommentForm) {
  737. issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
  738. if err != nil {
  739. if models.IsErrIssueNotExist(err) {
  740. ctx.Handle(404, "GetIssueByIndex", err)
  741. } else {
  742. ctx.Handle(500, "GetIssueByIndex", err)
  743. }
  744. return
  745. }
  746. if issue.IsPull {
  747. if err = issue.GetPullRequest(); err != nil {
  748. ctx.Handle(500, "GetPullRequest", err)
  749. return
  750. }
  751. }
  752. var attachments []string
  753. if setting.AttachmentEnabled {
  754. attachments = form.Attachments
  755. }
  756. if ctx.HasError() {
  757. ctx.Flash.Error(ctx.Data["ErrorMsg"].(string))
  758. ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index))
  759. return
  760. }
  761. var comment *models.Comment
  762. defer func() {
  763. // Check if issue admin/poster changes the status of issue.
  764. if (ctx.Repo.IsWriter() || (ctx.IsSigned && issue.IsPoster(ctx.User.Id))) &&
  765. (form.Status == "reopen" || form.Status == "close") &&
  766. !(issue.IsPull && issue.HasMerged) {
  767. // Duplication and conflict check should apply to reopen pull request.
  768. var pr *models.PullRequest
  769. if form.Status == "reopen" && issue.IsPull {
  770. pull := issue.PullRequest
  771. pr, err = models.GetUnmergedPullRequest(pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch)
  772. if err != nil {
  773. if !models.IsErrPullRequestNotExist(err) {
  774. ctx.Handle(500, "GetUnmergedPullRequest", err)
  775. return
  776. }
  777. }
  778. // Regenerate patch and test conflict.
  779. if pr == nil {
  780. if err = issue.UpdatePatch(); err != nil {
  781. ctx.Handle(500, "UpdatePatch", err)
  782. return
  783. }
  784. issue.AddToTaskQueue()
  785. }
  786. }
  787. if pr != nil {
  788. ctx.Flash.Info(ctx.Tr("repo.pulls.open_unmerged_pull_exists", pr.Index))
  789. } else {
  790. if err = issue.ChangeStatus(ctx.User, ctx.Repo.Repository, form.Status == "close"); err != nil {
  791. log.Error(4, "ChangeStatus: %v", err)
  792. } else {
  793. log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed)
  794. }
  795. }
  796. }
  797. // Redirect to comment hashtag if there is any actual content.
  798. typeName := "issues"
  799. if issue.IsPull {
  800. typeName = "pulls"
  801. }
  802. if comment != nil {
  803. ctx.Redirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag()))
  804. } else {
  805. ctx.Redirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index))
  806. }
  807. }()
  808. // Fix #321: Allow empty comments, as long as we have attachments.
  809. if len(form.Content) == 0 && len(attachments) == 0 {
  810. return
  811. }
  812. comment, err = models.CreateIssueComment(ctx.User, ctx.Repo.Repository, issue, form.Content, attachments)
  813. if err != nil {
  814. ctx.Handle(500, "CreateIssueComment", err)
  815. return
  816. }
  817. notifyWatchersAndMentions(ctx, &models.Issue{
  818. ID: issue.ID,
  819. Index: issue.Index,
  820. Name: issue.Name,
  821. Content: form.Content,
  822. })
  823. if ctx.Written() {
  824. return
  825. }
  826. log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID)
  827. }
  828. func UpdateCommentContent(ctx *middleware.Context) {
  829. comment, err := models.GetCommentByID(ctx.ParamsInt64(":id"))
  830. if err != nil {
  831. if models.IsErrCommentNotExist(err) {
  832. ctx.Error(404, "GetCommentByID")
  833. } else {
  834. ctx.Handle(500, "GetCommentByID", err)
  835. }
  836. return
  837. }
  838. if !ctx.IsSigned || (ctx.User.Id != comment.PosterID && !ctx.Repo.IsAdmin()) {
  839. ctx.Error(403)
  840. return
  841. } else if comment.Type != models.COMMENT_TYPE_COMMENT {
  842. ctx.Error(204)
  843. return
  844. }
  845. comment.Content = ctx.Query("content")
  846. if len(comment.Content) == 0 {
  847. ctx.JSON(200, map[string]interface{}{
  848. "content": "",
  849. })
  850. return
  851. }
  852. if err := models.UpdateComment(comment); err != nil {
  853. ctx.Handle(500, "UpdateComment", err)
  854. return
  855. }
  856. ctx.JSON(200, map[string]interface{}{
  857. "content": string(markdown.Render([]byte(comment.Content), ctx.Query("context"), ctx.Repo.Repository.ComposeMetas())),
  858. })
  859. }
  860. func Labels(ctx *middleware.Context) {
  861. ctx.Data["Title"] = ctx.Tr("repo.labels")
  862. ctx.Data["PageIsIssueList"] = true
  863. ctx.Data["PageIsLabels"] = true
  864. ctx.Data["RequireMinicolors"] = true
  865. ctx.HTML(200, LABELS)
  866. }
  867. func NewLabel(ctx *middleware.Context, form auth.CreateLabelForm) {
  868. ctx.Data["Title"] = ctx.Tr("repo.labels")
  869. ctx.Data["PageIsLabels"] = true
  870. if ctx.HasError() {
  871. ctx.Flash.Error(ctx.Data["ErrorMsg"].(string))
  872. ctx.Redirect(ctx.Repo.RepoLink + "/labels")
  873. return
  874. }
  875. l := &models.Label{
  876. RepoID: ctx.Repo.Repository.ID,
  877. Name: form.Title,
  878. Color: form.Color,
  879. }
  880. if err := models.NewLabel(l); err != nil {
  881. ctx.Handle(500, "NewLabel", err)
  882. return
  883. }
  884. ctx.Redirect(ctx.Repo.RepoLink + "/labels")
  885. }
  886. func UpdateLabel(ctx *middleware.Context, form auth.CreateLabelForm) {
  887. l, err := models.GetLabelByID(form.ID)
  888. if err != nil {
  889. switch {
  890. case models.IsErrLabelNotExist(err):
  891. ctx.Error(404)
  892. default:
  893. ctx.Handle(500, "UpdateLabel", err)
  894. }
  895. return
  896. }
  897. fmt.Println(form.Title, form.Color)
  898. l.Name = form.Title
  899. l.Color = form.Color
  900. if err := models.UpdateLabel(l); err != nil {
  901. ctx.Handle(500, "UpdateLabel", err)
  902. return
  903. }
  904. ctx.Redirect(ctx.Repo.RepoLink + "/labels")
  905. }
  906. func DeleteLabel(ctx *middleware.Context) {
  907. if err := models.DeleteLabel(ctx.Repo.Repository.ID, ctx.QueryInt64("id")); err != nil {
  908. ctx.Flash.Error("DeleteLabel: " + err.Error())
  909. } else {
  910. ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success"))
  911. }
  912. ctx.JSON(200, map[string]interface{}{
  913. "redirect": ctx.Repo.RepoLink + "/labels",
  914. })
  915. return
  916. }
  917. func Milestones(ctx *middleware.Context) {
  918. ctx.Data["Title"] = ctx.Tr("repo.milestones")
  919. ctx.Data["PageIsIssueList"] = true
  920. ctx.Data["PageIsMilestones"] = true
  921. isShowClosed := ctx.Query("state") == "closed"
  922. openCount, closedCount := models.MilestoneStats(ctx.Repo.Repository.ID)
  923. ctx.Data["OpenCount"] = openCount
  924. ctx.Data["ClosedCount"] = closedCount
  925. page := ctx.QueryInt("page")
  926. if page <= 1 {
  927. page = 1
  928. }
  929. var total int
  930. if !isShowClosed {
  931. total = int(openCount)
  932. } else {
  933. total = int(closedCount)
  934. }
  935. ctx.Data["Page"] = paginater.New(total, setting.IssuePagingNum, page, 5)
  936. miles, err := models.GetMilestones(ctx.Repo.Repository.ID, page, isShowClosed)
  937. if err != nil {
  938. ctx.Handle(500, "GetMilestones", err)
  939. return
  940. }
  941. for _, m := range miles {
  942. m.RenderedContent = string(markdown.Render([]byte(m.Content), ctx.Repo.RepoLink, ctx.Repo.Repository.ComposeMetas()))
  943. m.CalOpenIssues()
  944. }
  945. ctx.Data["Milestones"] = miles
  946. if isShowClosed {
  947. ctx.Data["State"] = "closed"
  948. } else {
  949. ctx.Data["State"] = "open"
  950. }
  951. ctx.Data["IsShowClosed"] = isShowClosed
  952. ctx.HTML(200, MILESTONE)
  953. }
  954. func NewMilestone(ctx *middleware.Context) {
  955. ctx.Data["Title"] = ctx.Tr("repo.milestones.new")
  956. ctx.Data["PageIsIssueList"] = true
  957. ctx.Data["PageIsMilestones"] = true
  958. ctx.Data["RequireDatetimepicker"] = true
  959. ctx.Data["DateLang"] = setting.DateLang(ctx.Locale.Language())
  960. ctx.HTML(200, MILESTONE_NEW)
  961. }
  962. func NewMilestonePost(ctx *middleware.Context, form auth.CreateMilestoneForm) {
  963. ctx.Data["Title"] = ctx.Tr("repo.milestones.new")
  964. ctx.Data["PageIsIssueList"] = true
  965. ctx.Data["PageIsMilestones"] = true
  966. ctx.Data["RequireDatetimepicker"] = true
  967. ctx.Data["DateLang"] = setting.DateLang(ctx.Locale.Language())
  968. if ctx.HasError() {
  969. ctx.HTML(200, MILESTONE_NEW)
  970. return
  971. }
  972. if len(form.Deadline) == 0 {
  973. form.Deadline = "9999-12-31"
  974. }
  975. deadline, err := time.ParseInLocation("2006-01-02", form.Deadline, time.Local)
  976. if err != nil {
  977. ctx.Data["Err_Deadline"] = true
  978. ctx.RenderWithErr(ctx.Tr("repo.milestones.invalid_due_date_format"), MILESTONE_NEW, &form)
  979. return
  980. }
  981. if err = models.NewMilestone(&models.Milestone{
  982. RepoID: ctx.Repo.Repository.ID,
  983. Name: form.Title,
  984. Content: form.Content,
  985. Deadline: deadline,
  986. }); err != nil {
  987. ctx.Handle(500, "NewMilestone", err)
  988. return
  989. }
  990. ctx.Flash.Success(ctx.Tr("repo.milestones.create_success", form.Title))
  991. ctx.Redirect(ctx.Repo.RepoLink + "/milestones")
  992. }
  993. func EditMilestone(ctx *middleware.Context) {
  994. ctx.Data["Title"] = ctx.Tr("repo.milestones.edit")
  995. ctx.Data["PageIsMilestones"] = true
  996. ctx.Data["PageIsEditMilestone"] = true
  997. ctx.Data["RequireDatetimepicker"] = true
  998. ctx.Data["DateLang"] = setting.DateLang(ctx.Locale.Language())
  999. m, err := models.GetMilestoneByID(ctx.ParamsInt64(":id"))
  1000. if err != nil {
  1001. if models.IsErrMilestoneNotExist(err) {
  1002. ctx.Handle(404, "GetMilestoneByID", nil)
  1003. } else {
  1004. ctx.Handle(500, "GetMilestoneByID", err)
  1005. }
  1006. return
  1007. }
  1008. ctx.Data["title"] = m.Name
  1009. ctx.Data["content"] = m.Content
  1010. if len(m.DeadlineString) > 0 {
  1011. ctx.Data["deadline"] = m.DeadlineString
  1012. }
  1013. ctx.HTML(200, MILESTONE_NEW)
  1014. }
  1015. func EditMilestonePost(ctx *middleware.Context, form auth.CreateMilestoneForm) {
  1016. ctx.Data["Title"] = ctx.Tr("repo.milestones.edit")
  1017. ctx.Data["PageIsMilestones"] = true
  1018. ctx.Data["PageIsEditMilestone"] = true
  1019. ctx.Data["RequireDatetimepicker"] = true
  1020. ctx.Data["DateLang"] = setting.DateLang(ctx.Locale.Language())
  1021. if ctx.HasError() {
  1022. ctx.HTML(200, MILESTONE_NEW)
  1023. return
  1024. }
  1025. if len(form.Deadline) == 0 {
  1026. form.Deadline = "9999-12-31"
  1027. }
  1028. deadline, err := time.ParseInLocation("2006-01-02", form.Deadline, time.Local)
  1029. if err != nil {
  1030. ctx.Data["Err_Deadline"] = true
  1031. ctx.RenderWithErr(ctx.Tr("repo.milestones.invalid_due_date_format"), MILESTONE_NEW, &form)
  1032. return
  1033. }
  1034. m, err := models.GetMilestoneByID(ctx.ParamsInt64(":id"))
  1035. if err != nil {
  1036. if models.IsErrMilestoneNotExist(err) {
  1037. ctx.Handle(404, "GetMilestoneByID", nil)
  1038. } else {
  1039. ctx.Handle(500, "GetMilestoneByID", err)
  1040. }
  1041. return
  1042. }
  1043. m.Name = form.Title
  1044. m.Content = form.Content
  1045. m.Deadline = deadline
  1046. if err = models.UpdateMilestone(m); err != nil {
  1047. ctx.Handle(500, "UpdateMilestone", err)
  1048. return
  1049. }
  1050. ctx.Flash.Success(ctx.Tr("repo.milestones.edit_success", m.Name))
  1051. ctx.Redirect(ctx.Repo.RepoLink + "/milestones")
  1052. }
  1053. func ChangeMilestonStatus(ctx *middleware.Context) {
  1054. m, err := models.GetMilestoneByID(ctx.ParamsInt64(":id"))
  1055. if err != nil {
  1056. if models.IsErrMilestoneNotExist(err) {
  1057. ctx.Handle(404, "GetMilestoneByID", err)
  1058. } else {
  1059. ctx.Handle(500, "GetMilestoneByID", err)
  1060. }
  1061. return
  1062. }
  1063. switch ctx.Params(":action") {
  1064. case "open":
  1065. if m.IsClosed {
  1066. if err = models.ChangeMilestoneStatus(m, false); err != nil {
  1067. ctx.Handle(500, "ChangeMilestoneStatus", err)
  1068. return
  1069. }
  1070. }
  1071. ctx.Redirect(ctx.Repo.RepoLink + "/milestones?state=open")
  1072. case "close":
  1073. if !m.IsClosed {
  1074. m.ClosedDate = time.Now()
  1075. if err = models.ChangeMilestoneStatus(m, true); err != nil {
  1076. ctx.Handle(500, "ChangeMilestoneStatus", err)
  1077. return
  1078. }
  1079. }
  1080. ctx.Redirect(ctx.Repo.RepoLink + "/milestones?state=closed")
  1081. default:
  1082. ctx.Redirect(ctx.Repo.RepoLink + "/milestones")
  1083. }
  1084. }
  1085. func DeleteMilestone(ctx *middleware.Context) {
  1086. if err := models.DeleteMilestoneByID(ctx.QueryInt64("id")); err != nil {
  1087. ctx.Flash.Error("DeleteMilestoneByID: " + err.Error())
  1088. } else {
  1089. ctx.Flash.Success(ctx.Tr("repo.milestones.deletion_success"))
  1090. }
  1091. ctx.JSON(200, map[string]interface{}{
  1092. "redirect": ctx.Repo.RepoLink + "/milestones",
  1093. })
  1094. }