gitea源码

issue_new.go 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. // Copyright 2024 The Gitea Authors. All rights reserved.
  2. // SPDX-License-Identifier: MIT
  3. package repo
  4. import (
  5. "errors"
  6. "fmt"
  7. "html/template"
  8. "maps"
  9. "net/http"
  10. "slices"
  11. "sort"
  12. "strconv"
  13. "strings"
  14. issues_model "code.gitea.io/gitea/models/issues"
  15. "code.gitea.io/gitea/models/organization"
  16. project_model "code.gitea.io/gitea/models/project"
  17. repo_model "code.gitea.io/gitea/models/repo"
  18. "code.gitea.io/gitea/models/unit"
  19. user_model "code.gitea.io/gitea/models/user"
  20. "code.gitea.io/gitea/modules/base"
  21. "code.gitea.io/gitea/modules/container"
  22. "code.gitea.io/gitea/modules/git"
  23. issue_template "code.gitea.io/gitea/modules/issue/template"
  24. "code.gitea.io/gitea/modules/log"
  25. "code.gitea.io/gitea/modules/setting"
  26. api "code.gitea.io/gitea/modules/structs"
  27. "code.gitea.io/gitea/modules/util"
  28. "code.gitea.io/gitea/modules/web"
  29. "code.gitea.io/gitea/routers/utils"
  30. "code.gitea.io/gitea/services/context"
  31. "code.gitea.io/gitea/services/context/upload"
  32. "code.gitea.io/gitea/services/forms"
  33. issue_service "code.gitea.io/gitea/services/issue"
  34. )
  35. // Tries to load and set an issue template. The first return value indicates if a template was loaded.
  36. func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, metaData *IssuePageMetaData) (bool, map[string]error) {
  37. commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
  38. if err != nil {
  39. return false, nil
  40. }
  41. templateCandidates := make([]string, 0, 1+len(possibleFiles))
  42. if t := ctx.FormString("template"); t != "" {
  43. templateCandidates = append(templateCandidates, t)
  44. }
  45. templateCandidates = append(templateCandidates, possibleFiles...) // Append files to the end because they should be fallback
  46. templateErrs := map[string]error{}
  47. for _, filename := range templateCandidates {
  48. if ok, _ := commit.HasFile(filename); !ok {
  49. continue
  50. }
  51. template, err := issue_template.UnmarshalFromCommit(commit, filename)
  52. if err != nil {
  53. templateErrs[filename] = err
  54. continue
  55. }
  56. ctx.Data[issueTemplateTitleKey] = template.Title
  57. ctx.Data[ctxDataKey] = template.Content
  58. if template.Type() == api.IssueTemplateTypeYaml {
  59. // Replace field default values by values from query
  60. for _, field := range template.Fields {
  61. fieldValue := ctx.FormString("field:" + field.ID)
  62. if fieldValue != "" {
  63. field.Attributes["value"] = fieldValue
  64. }
  65. }
  66. ctx.Data["Fields"] = template.Fields
  67. ctx.Data["TemplateFile"] = template.FileName
  68. }
  69. metaData.LabelsData.SetSelectedLabelNames(template.Labels)
  70. selectedAssigneeIDStrings := make([]string, 0, len(template.Assignees))
  71. if userIDs, err := user_model.GetUserIDsByNames(ctx, template.Assignees, true); err == nil {
  72. for _, userID := range userIDs {
  73. selectedAssigneeIDStrings = append(selectedAssigneeIDStrings, strconv.FormatInt(userID, 10))
  74. }
  75. }
  76. metaData.AssigneesData.SelectedAssigneeIDs = strings.Join(selectedAssigneeIDStrings, ",")
  77. if template.Ref != "" && !strings.HasPrefix(template.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/<ref>
  78. template.Ref = git.BranchPrefix + template.Ref
  79. }
  80. ctx.Data["Reference"] = template.Ref
  81. ctx.Data["RefEndName"] = git.RefName(template.Ref).ShortName()
  82. return true, templateErrs
  83. }
  84. return false, templateErrs
  85. }
  86. // NewIssue render creating issue page
  87. func NewIssue(ctx *context.Context) {
  88. issueConfig, _ := issue_service.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
  89. hasTemplates := issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
  90. ctx.Data["Title"] = ctx.Tr("repo.issues.new")
  91. ctx.Data["PageIsIssueList"] = true
  92. ctx.Data["NewIssueChooseTemplate"] = hasTemplates
  93. ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
  94. title := ctx.FormString("title")
  95. ctx.Data["TitleQuery"] = title
  96. body := ctx.FormString("body")
  97. ctx.Data["BodyQuery"] = body
  98. isProjectsEnabled := ctx.Repo.CanRead(unit.TypeProjects)
  99. ctx.Data["IsProjectsEnabled"] = isProjectsEnabled
  100. ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
  101. upload.AddUploadContext(ctx, "comment")
  102. pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, false)
  103. if ctx.Written() {
  104. return
  105. }
  106. pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone")
  107. pageMetaData.ProjectsData.SelectedProjectID = ctx.FormInt64("project")
  108. if pageMetaData.ProjectsData.SelectedProjectID > 0 {
  109. if len(ctx.Req.URL.Query().Get("project")) > 0 {
  110. ctx.Data["redirect_after_creation"] = "project"
  111. }
  112. }
  113. tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
  114. if err != nil {
  115. ctx.ServerError("GetTagNamesByRepoID", err)
  116. return
  117. }
  118. ctx.Data["Tags"] = tags
  119. ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
  120. templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData)
  121. maps.Copy(ret.TemplateErrors, errs)
  122. if ctx.Written() {
  123. return
  124. }
  125. if len(ret.TemplateErrors) > 0 {
  126. ctx.Flash.Warning(renderErrorOfTemplates(ctx, ret.TemplateErrors), true)
  127. }
  128. ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypeIssues)
  129. if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded {
  130. // The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters.
  131. ctx.Redirect(fmt.Sprintf("%s/issues/new/choose?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther)
  132. return
  133. }
  134. ctx.HTML(http.StatusOK, tplIssueNew)
  135. }
  136. func renderErrorOfTemplates(ctx *context.Context, errs map[string]error) template.HTML {
  137. var files []string
  138. for k := range errs {
  139. files = append(files, k)
  140. }
  141. sort.Strings(files) // keep the output stable
  142. var lines []string
  143. for _, file := range files {
  144. lines = append(lines, fmt.Sprintf("%s: %v", file, errs[file]))
  145. }
  146. flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{
  147. "Message": ctx.Tr("repo.issues.choose.ignore_invalid_templates"),
  148. "Summary": ctx.Tr("repo.issues.choose.invalid_templates", len(errs)),
  149. "Details": utils.SanitizeFlashErrorString(strings.Join(lines, "\n")),
  150. })
  151. if err != nil {
  152. log.Debug("render flash error: %v", err)
  153. flashError = ctx.Locale.Tr("repo.issues.choose.ignore_invalid_templates")
  154. }
  155. return flashError
  156. }
  157. // NewIssueChooseTemplate render creating issue from template page
  158. func NewIssueChooseTemplate(ctx *context.Context) {
  159. ctx.Data["Title"] = ctx.Tr("repo.issues.new")
  160. ctx.Data["PageIsIssueList"] = true
  161. ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
  162. ctx.Data["IssueTemplates"] = ret.IssueTemplates
  163. if len(ret.TemplateErrors) > 0 {
  164. ctx.Flash.Warning(renderErrorOfTemplates(ctx, ret.TemplateErrors), true)
  165. }
  166. if !issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) {
  167. // The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if no template here, just redirect to the "issues/new" page with these parameters.
  168. ctx.Redirect(fmt.Sprintf("%s/issues/new?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther)
  169. return
  170. }
  171. issueConfig, err := issue_service.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
  172. ctx.Data["IssueConfig"] = issueConfig
  173. ctx.Data["IssueConfigError"] = err // ctx.Flash.Err makes problems here
  174. ctx.Data["milestone"] = ctx.FormInt64("milestone")
  175. ctx.Data["project"] = ctx.FormInt64("project")
  176. ctx.HTML(http.StatusOK, tplIssueChoose)
  177. }
  178. // DeleteIssue deletes an issue
  179. func DeleteIssue(ctx *context.Context) {
  180. issue := GetActionIssue(ctx)
  181. if ctx.Written() {
  182. return
  183. }
  184. if err := issue_service.DeleteIssue(ctx, ctx.Doer, issue); err != nil {
  185. ctx.ServerError("DeleteIssueByID", err)
  186. return
  187. }
  188. if issue.IsPull {
  189. ctx.Redirect(ctx.Repo.Repository.Link()+"/pulls", http.StatusSeeOther)
  190. return
  191. }
  192. ctx.Redirect(ctx.Repo.Repository.Link()+"/issues", http.StatusSeeOther)
  193. }
  194. func toSet[ItemType any, KeyType comparable](slice []ItemType, keyFunc func(ItemType) KeyType) container.Set[KeyType] {
  195. s := make(container.Set[KeyType])
  196. for _, item := range slice {
  197. s.Add(keyFunc(item))
  198. }
  199. return s
  200. }
  201. // ValidateRepoMetasForNewIssue check and returns repository's meta information
  202. func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct {
  203. LabelIDs, AssigneeIDs []int64
  204. MilestoneID, ProjectID int64
  205. Reviewers []*user_model.User
  206. TeamReviewers []*organization.Team
  207. },
  208. ) {
  209. pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, isPull)
  210. if ctx.Written() {
  211. return ret
  212. }
  213. inputLabelIDs, _ := base.StringsToInt64s(strings.Split(form.LabelIDs, ","))
  214. candidateLabels := toSet(pageMetaData.LabelsData.AllLabels, func(label *issues_model.Label) int64 { return label.ID })
  215. if len(inputLabelIDs) > 0 && !candidateLabels.Contains(inputLabelIDs...) {
  216. ctx.NotFound(nil)
  217. return ret
  218. }
  219. pageMetaData.LabelsData.SetSelectedLabelIDs(inputLabelIDs)
  220. allMilestones := append(slices.Clone(pageMetaData.MilestonesData.OpenMilestones), pageMetaData.MilestonesData.ClosedMilestones...)
  221. candidateMilestones := toSet(allMilestones, func(milestone *issues_model.Milestone) int64 { return milestone.ID })
  222. if form.MilestoneID > 0 && !candidateMilestones.Contains(form.MilestoneID) {
  223. ctx.NotFound(nil)
  224. return ret
  225. }
  226. pageMetaData.MilestonesData.SelectedMilestoneID = form.MilestoneID
  227. allProjects := append(slices.Clone(pageMetaData.ProjectsData.OpenProjects), pageMetaData.ProjectsData.ClosedProjects...)
  228. candidateProjects := toSet(allProjects, func(project *project_model.Project) int64 { return project.ID })
  229. if form.ProjectID > 0 && !candidateProjects.Contains(form.ProjectID) {
  230. ctx.NotFound(nil)
  231. return ret
  232. }
  233. pageMetaData.ProjectsData.SelectedProjectID = form.ProjectID
  234. // prepare assignees
  235. candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID })
  236. inputAssigneeIDs, _ := base.StringsToInt64s(strings.Split(form.AssigneeIDs, ","))
  237. var assigneeIDStrings []string
  238. for _, inputAssigneeID := range inputAssigneeIDs {
  239. if candidateAssignees.Contains(inputAssigneeID) {
  240. assigneeIDStrings = append(assigneeIDStrings, strconv.FormatInt(inputAssigneeID, 10))
  241. }
  242. }
  243. pageMetaData.AssigneesData.SelectedAssigneeIDs = strings.Join(assigneeIDStrings, ",")
  244. // Check if the passed reviewers (user/team) actually exist
  245. var reviewers []*user_model.User
  246. var teamReviewers []*organization.Team
  247. reviewerIDs, _ := base.StringsToInt64s(strings.Split(form.ReviewerIDs, ","))
  248. if isPull && len(reviewerIDs) > 0 {
  249. userReviewersMap := map[int64]*user_model.User{}
  250. teamReviewersMap := map[int64]*organization.Team{}
  251. for _, r := range pageMetaData.ReviewersData.Reviewers {
  252. userReviewersMap[r.User.ID] = r.User
  253. }
  254. for _, r := range pageMetaData.ReviewersData.TeamReviewers {
  255. teamReviewersMap[r.Team.ID] = r.Team
  256. }
  257. for _, rID := range reviewerIDs {
  258. if rID < 0 { // negative reviewIDs represent team requests
  259. team, ok := teamReviewersMap[-rID]
  260. if !ok {
  261. ctx.NotFound(nil)
  262. return ret
  263. }
  264. teamReviewers = append(teamReviewers, team)
  265. } else {
  266. user, ok := userReviewersMap[rID]
  267. if !ok {
  268. ctx.NotFound(nil)
  269. return ret
  270. }
  271. reviewers = append(reviewers, user)
  272. }
  273. }
  274. }
  275. ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectID = inputLabelIDs, inputAssigneeIDs, form.MilestoneID, form.ProjectID
  276. ret.Reviewers, ret.TeamReviewers = reviewers, teamReviewers
  277. return ret
  278. }
  279. // NewIssuePost response for creating new issue
  280. func NewIssuePost(ctx *context.Context) {
  281. form := web.GetForm(ctx).(*forms.CreateIssueForm)
  282. ctx.Data["Title"] = ctx.Tr("repo.issues.new")
  283. ctx.Data["PageIsIssueList"] = true
  284. ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
  285. ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
  286. ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
  287. upload.AddUploadContext(ctx, "comment")
  288. var (
  289. repo = ctx.Repo.Repository
  290. attachments []string
  291. )
  292. validateRet := ValidateRepoMetasForNewIssue(ctx, *form, false)
  293. if ctx.Written() {
  294. return
  295. }
  296. labelIDs, assigneeIDs, milestoneID, projectID := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectID
  297. if projectID > 0 {
  298. if !ctx.Repo.CanRead(unit.TypeProjects) {
  299. // User must also be able to see the project.
  300. ctx.HTTPError(http.StatusBadRequest, "user hasn't permissions to read projects")
  301. return
  302. }
  303. }
  304. if setting.Attachment.Enabled {
  305. attachments = form.Files
  306. }
  307. if ctx.HasError() {
  308. ctx.JSONError(ctx.GetErrMsg())
  309. return
  310. }
  311. if util.IsEmptyString(form.Title) {
  312. ctx.JSONError(ctx.Tr("repo.issues.new.title_empty"))
  313. return
  314. }
  315. content := form.Content
  316. if filename := ctx.Req.Form.Get("template-file"); filename != "" {
  317. if template, err := issue_template.UnmarshalFromRepo(ctx.Repo.GitRepo, ctx.Repo.Repository.DefaultBranch, filename); err == nil {
  318. content = issue_template.RenderToMarkdown(template, ctx.Req.Form)
  319. }
  320. }
  321. issue := &issues_model.Issue{
  322. RepoID: repo.ID,
  323. Repo: repo,
  324. Title: form.Title,
  325. PosterID: ctx.Doer.ID,
  326. Poster: ctx.Doer,
  327. MilestoneID: milestoneID,
  328. Content: content,
  329. Ref: form.Ref,
  330. }
  331. if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectID); err != nil {
  332. if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
  333. ctx.HTTPError(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
  334. } else if errors.Is(err, user_model.ErrBlockedUser) {
  335. ctx.JSONError(ctx.Tr("repo.issues.new.blocked_user"))
  336. } else {
  337. ctx.ServerError("NewIssue", err)
  338. }
  339. return
  340. }
  341. log.Trace("Issue created: %d/%d", repo.ID, issue.ID)
  342. if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 {
  343. project, err := project_model.GetProjectByID(ctx, projectID)
  344. if err == nil {
  345. if project.Type == project_model.TypeOrganization {
  346. ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.Repo.Owner, project.ID))
  347. } else {
  348. ctx.JSONRedirect(project_model.ProjectLinkForRepo(repo, project.ID))
  349. }
  350. return
  351. }
  352. }
  353. ctx.JSONRedirect(issue.Link())
  354. }