From 31626de2a6cdd3e1fe93216e4ac80f511297f1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Vr=C3=A1na?= Date: Sat, 17 Oct 2026 12:58:33 +0200 Subject: [PATCH] enhance(repo): prefill new issue and pull request sidebars from query parameters (#39804) Links to the new issue and new pull request pages can preselect sidebar values with the same query parameters as the issue list filters: `labels`, `milestone`, `project` and `assignee`, each taking comma-separated IDs, e.g. `compare/main...branch?labels=1,2&assignee=3`. Previously, pull requests supported none of them and issues only `milestone` and `project`. On the compare page they also expand the new pull request form, like `title` and `body`. Query values take precedence over issue template labels and assignees. * Fix https://github.com/go-gitea/gitea/issues/27341 --------- Co-authored-by: wxiaoguang --- modules/base/tool.go | 5 + routers/web/repo/compare.go | 9 +- routers/web/repo/issue_new.go | 32 +++--- routers/web/repo/issue_page_meta.go | 90 +++++++++++++++++ routers/web/repo/issue_page_meta_test.go | 122 +++++++++++++++++++++++ routers/web/repo/pull.go | 2 +- templates/repo/issue/choose.tmpl | 4 +- 7 files changed, 240 insertions(+), 24 deletions(-) create mode 100644 routers/web/repo/issue_page_meta_test.go diff --git a/modules/base/tool.go b/modules/base/tool.go index 64c74e9ef8b..c776a793125 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -12,6 +12,7 @@ import ( "fmt" "hash" "strconv" + "strings" "time" "gitea.dev/modules/setting" @@ -117,3 +118,7 @@ func Int64sToStrings(ints []int64) []string { } return strs } + +func JoinInt64s(ints []int64, sep string) string { + return strings.Join(Int64sToStrings(ints), sep) +} diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 49027ea2e25..8be9d059345 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -384,13 +384,16 @@ func (cpi *comparePageInfoType) prepareCompareDiff(ctx *context.Context, whitesp ctx.Data["BeforeCommitID"] = ci.CompareBase ctx.Data["AfterCommitID"] = headCommitID - // follow GitHub's behavior: autofill the form and expand newPrFormTitle := ctx.FormTrim("title") newPrFormBody := ctx.FormTrim("body") - ctx.Data["ExpandNewPrForm"] = ctx.FormBool("expand") || ctx.FormBool("quick_pull") || newPrFormTitle != "" || newPrFormBody != "" ctx.Data["TitleQuery"] = newPrFormTitle ctx.Data["BodyQuery"] = newPrFormBody + // follow GitHub's behavior: autofill the form and expand + prefillParams := issuePrefillQuery(ctx) + ctx.Data["ExpandNewPrForm"] = ctx.FormBool("expand") || ctx.FormBool("quick_pull") || + newPrFormTitle != "" || newPrFormBody != "" || prefillParams != "" + if headCommitID == ci.CompareBase { config := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig() // if auto-detect manual merge, an empty PR will be closed immediately because it is already on base branch @@ -623,7 +626,7 @@ func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Contex if !cpi.nothingToCompare { // Setup information for new form. - pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, true) + pageMetaData := retrieveRepoIssueMetaDataWithPrefill(ctx, ctx.Repo.Repository, true) if ctx.Written() { return } diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index d3c401df16d..b3372860968 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -75,14 +75,14 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles ctx.Data["TemplateFile"] = template.FileName } - metaData.LabelsData.SetSelectedLabelNames(template.Labels) - - selectedAssigneeIDStrings := make([]string, 0, len(template.Assignees)) - selectedAssigneeIDs, _ := user_model.GetDoerUserIDsByNames(ctx, template.Assignees) - for _, userID := range selectedAssigneeIDs { - selectedAssigneeIDStrings = append(selectedAssigneeIDStrings, strconv.FormatInt(userID, 10)) + // the query parameters take precedence + if metaData.LabelsData.SelectedLabelIDs == "" { + metaData.LabelsData.SetSelectedLabelNames(template.Labels) + } + if metaData.AssigneesData.SelectedAssigneeIDs == "" { + selectedAssigneeIDs, _ := user_model.GetDoerUserIDsByNames(ctx, template.Assignees) + metaData.AssigneesData.SelectedAssigneeIDs = base.JoinInt64s(selectedAssigneeIDs, ",") } - metaData.AssigneesData.SelectedAssigneeIDs = strings.Join(selectedAssigneeIDStrings, ",") if template.Ref != "" && !strings.HasPrefix(template.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/ template.Ref = git.BranchPrefix + template.Ref @@ -114,14 +114,11 @@ func NewIssue(ctx *context.Context) { ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled upload.AddUploadContext(ctx, "comment") - pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, false) + pageMetaData := retrieveRepoIssueMetaDataWithPrefill(ctx, ctx.Repo.Repository, false) if ctx.Written() { return } - pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone") - - pageMetaData.SetSelectedProjectIDs(parseProjectIDsFromQuery(ctx)) if len(pageMetaData.ProjectsData.SelectedProjectIDs) == 1 { ctx.Data["redirect_after_creation"] = "project" } @@ -148,7 +145,7 @@ func NewIssue(ctx *context.Context) { ctx.Data["IsIssuePoster"] = true // the current user will be the poster of the new issue if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded { - // 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. + // The "issues/new" and "issues/new/choose" share the same prefill query parameters, if blank issues are disabled, just redirect to the "issues/choose" page with these parameters. ctx.Redirect(fmt.Sprintf("%s/issues/new/choose?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther) return } @@ -193,7 +190,7 @@ func NewIssueChooseTemplate(ctx *context.Context) { } if !issue_service.HasTemplatesOrContactLinks(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo) { - // 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. + // The "issues/new" and "issues/new/choose" share the same prefill query parameters, if no template here, just redirect to the "issues/new" page with these parameters. ctx.Redirect(fmt.Sprintf("%s/issues/new?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther) return } @@ -202,8 +199,7 @@ func NewIssueChooseTemplate(ctx *context.Context) { ctx.Data["IssueConfig"] = issueConfig ctx.Data["IssueConfigError"] = err // ctx.Flash.Err makes problems here - ctx.Data["milestone"] = ctx.FormInt64("milestone") - ctx.Data["project"] = ctx.FormInt64("project") + ctx.Data["PrefillQuery"] = issuePrefillQuery(ctx) ctx.HTML(http.StatusOK, tplIssueChoose) } @@ -236,8 +232,8 @@ func toSet[ItemType any, KeyType comparable](slice []ItemType, keyFunc func(Item return s } -// ValidateRepoMetasForNewIssue check and returns repository's meta information -func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct { +// ValidateRepoMetasForCreateIssue check and returns repository's meta information +func ValidateRepoMetasForCreateIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct { LabelIDs, AssigneeIDs []int64 MilestoneID int64 ProjectIDs []int64 @@ -325,7 +321,7 @@ func NewIssuePost(ctx *context.Context) { repo := ctx.Repo.Repository - validateRet := ValidateRepoMetasForNewIssue(ctx, *form, false) + validateRet := ValidateRepoMetasForCreateIssue(ctx, *form, false) if ctx.Written() { return } diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 76382f18aed..abd97328443 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -4,6 +4,9 @@ package repo import ( + "html/template" + "net/url" + "slices" "sort" "strconv" "strings" @@ -14,6 +17,7 @@ import ( project_model "gitea.dev/models/project" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" + "gitea.dev/modules/base" "gitea.dev/modules/container" "gitea.dev/modules/optional" shared_user "gitea.dev/routers/web/shared/user" @@ -129,6 +133,92 @@ func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository return data } +const ( + newIssuePrefillLabelFallback = "labels" // "labels" was introduced long time ago + newIssuePrefillLabel = "label" // to be consistent with other prefill params, we use "label" instead of "labels" + newIssuePrefillMilestone = "milestone" + newIssuePrefillProject = "project" + newIssuePrefillAssignee = "assignee" +) + +func issuePrefillQuery(ctx *context.Context) template.URL { + keys := []string{newIssuePrefillLabel, newIssuePrefillLabelFallback, newIssuePrefillMilestone, newIssuePrefillProject, newIssuePrefillAssignee} + query := url.Values{} + for _, key := range keys { + if value := ctx.FormString(key); value != "" { + query.Set(key, value) + } + } + return template.URL(query.Encode()) +} + +func makeItemIdNameMap[T any](items []T, getKey func(item T) (int64, string)) map[string]T { + m := make(map[string]T) + for _, item := range items { + id, name := getKey(item) + m[strconv.FormatInt(id, 10)] = item + lowerName := strings.ToLower(name) + if _, exists := m[lowerName]; !exists { + m[lowerName] = item + } + } + return m +} + +func pickItemIDsFromQueryParam[T any](items []T, param string, getKey func(item T) (int64, string)) (ret []int64) { + if param == "" { + return nil + } + m := makeItemIdNameMap(items, getKey) + for key := range strings.SplitSeq(param, ",") { + if item, ok := m[strings.ToLower(key)]; ok { + id, _ := getKey(item) + ret = append(ret, id) + } + } + return ret +} + +func retrieveRepoIssueMetaDataWithPrefill(ctx *context.Context, repo *repo_model.Repository, isPull bool) *IssuePageMetaData { + data := retrieveRepoIssueMetaData(ctx, repo, nil, isPull) + if ctx.Written() { + return data + } + + // label + paramLabel := ctx.FormString(newIssuePrefillLabel, ctx.FormString(newIssuePrefillLabelFallback)) + labelIDs := pickItemIDsFromQueryParam(data.LabelsData.AllLabels, paramLabel, func(label *issues_model.Label) (int64, string) { + return label.ID, label.Name + }) + data.LabelsData.SetSelectedLabelIDs(labelIDs) + + // milestone + paramMilestone := ctx.FormString(newIssuePrefillMilestone) + allMilestones := slices.Concat(data.MilestonesData.OpenMilestones, data.MilestonesData.ClosedMilestones) + milestoneIDs := pickItemIDsFromQueryParam(allMilestones, paramMilestone, func(milestone *issues_model.Milestone) (int64, string) { + return milestone.ID, milestone.Name + }) + if len(milestoneIDs) > 0 { + data.MilestonesData.SelectedMilestoneID = milestoneIDs[0] + } + + // project + paramProject := ctx.FormString(newIssuePrefillProject) + allProjects := slices.Concat(data.ProjectsData.OpenProjects, data.ProjectsData.ClosedProjects) + projectOpenIDs := pickItemIDsFromQueryParam(allProjects, paramProject, func(project *project_model.Project) (int64, string) { + return project.ID, project.Title + }) + data.SetSelectedProjectIDs(projectOpenIDs) + + // assignee + paramAssignee := ctx.FormString(newIssuePrefillAssignee) + assigneeIDs := pickItemIDsFromQueryParam(data.AssigneesData.CandidateAssignees, paramAssignee, func(user *user_model.User) (int64, string) { + return user.ID, user.Name + }) + data.AssigneesData.SelectedAssigneeIDs = base.JoinInt64s(assigneeIDs, ",") + return data +} + func (d *IssuePageMetaData) retrieveMilestonesDataForIssueWriter(ctx *context.Context) { var err error if d.Issue != nil { diff --git a/routers/web/repo/issue_page_meta_test.go b/routers/web/repo/issue_page_meta_test.go new file mode 100644 index 00000000000..40b82d9bc67 --- /dev/null +++ b/routers/web/repo/issue_page_meta_test.go @@ -0,0 +1,122 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/url" + "slices" + "testing" + + "gitea.dev/models/db" + issues_model "gitea.dev/models/issues" + "gitea.dev/models/perm" + access_model "gitea.dev/models/perm/access" + project_model "gitea.dev/models/project" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + "gitea.dev/modules/base" + "gitea.dev/services/contexttest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRetrieveRepoIssueMetaDataWithPrefill(t *testing.T) { + unittest.PrepareTestEnv(t) + require.NoError(t, db.Insert(t.Context(), + []*issues_model.Label{ + {ID: 100, RepoID: 1, Name: "Bug"}, + {ID: 101, RepoID: 1, Name: "Feature Request"}, + {ID: 102, RepoID: 2, Name: "Other Label"}, + }, + []*issues_model.Milestone{ + {ID: 100, RepoID: 1, Name: "Release One"}, + {ID: 101, RepoID: 1, Name: "Release Two"}, + {ID: 102, RepoID: 1, Name: "Closed Milestone", IsClosed: true}, + {ID: 103, RepoID: 2, Name: "Other Milestone"}, + }, + []*project_model.Project{ + {ID: 100, RepoID: 1, Title: "Open Project", Type: project_model.TypeRepository}, + {ID: 101, RepoID: 1, Title: "Closed Project", Type: project_model.TypeRepository, IsClosed: true}, + {ID: 102, RepoID: 2, Title: "Other Project", Type: project_model.TypeRepository}, + }, + []*user_model.User{ + {ID: 100, Name: "Alice", LowerName: "alice", IsActive: true}, + {ID: 101, Name: "Bob", LowerName: "bob", IsActive: true}, + {ID: 102, Name: "OtherUser", LowerName: "otheruser", IsActive: true}, + }, + []*access_model.Access{ + {RepoID: 1, UserID: 100, Mode: perm.AccessModeWrite}, + {RepoID: 1, UserID: 101, Mode: perm.AccessModeWrite}, + }, + )) + + type testCase struct { + name string + query url.Values + labelIDs []int64 + milestoneID int64 + projectIDs []int64 + assigneeIDs []int64 + } + for _, tc := range []testCase{ + { + name: "empty", + }, + { + name: "case-insensitive names", + query: url.Values{ + "label": {"bUG,fEATURE rEQUEST"}, + "milestone": {"rELEASE oNE"}, + "project": {"cLOSED pROJECT,oPEN pROJECT"}, + "assignee": {"bOB,aLICE"}, + }, + labelIDs: []int64{100, 101}, + milestoneID: 100, + projectIDs: []int64{101, 100}, + assigneeIDs: []int64{101, 100}, + }, + { + name: "mixed IDs and names with invalid values", + query: url.Values{ + "label": {"missing,102,Other Label,101,Bug,100,999"}, + "milestone": {"missing,102,Closed Milestone,103,Other Milestone,101,Release One,999"}, + "project": {"missing,102,Other Project,101,Open Project,999"}, + "assignee": {"missing,102,OtherUser,101,Alice,999"}, + }, + labelIDs: []int64{100, 101}, + milestoneID: 102, + projectIDs: []int64{101, 100}, + assigneeIDs: []int64{101, 100}, + }, + { + name: "labels fallback", + query: url.Values{"labels": {"101,Bug"}}, + labelIDs: []int64{100, 101}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, _ := contexttest.MockContext(t, "/dummy?"+tc.query.Encode()) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + + data := retrieveRepoIssueMetaDataWithPrefill(ctx, ctx.Repo.Repository, false) + require.False(t, ctx.Written()) + assert.Same(t, data, ctx.Data["IssuePageMetaData"]) + assert.True(t, data.CanModifyIssueOrPull) + assert.Equal(t, base.JoinInt64s(tc.labelIDs, ","), data.LabelsData.SelectedLabelIDs) + for _, label := range data.LabelsData.AllLabels { + assert.Equal(t, slices.Contains(tc.labelIDs, label.ID), label.IsChecked, label.Name) + } + assert.Equal(t, tc.milestoneID, data.MilestonesData.SelectedMilestoneID) + assert.Equal(t, tc.projectIDs, data.ProjectsData.SelectedProjectIDs) + var projectIDs []int64 + for _, card := range data.ProjectsData.ProjectCards { + projectIDs = append(projectIDs, card.Project.ID) + } + assert.Equal(t, tc.projectIDs, projectIDs) + assert.Equal(t, base.JoinInt64s(tc.assigneeIDs, ","), data.AssigneesData.SelectedAssigneeIDs) + }) + } +} diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 469e09169ab..9ab71c121bb 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -1326,7 +1326,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { ctx.JSONError(ctx.Tr("repo.pulls.no_common_history")) return } - validateRet := ValidateRepoMetasForNewIssue(ctx, *form, true) + validateRet := ValidateRepoMetasForCreateIssue(ctx, *form, true) if ctx.Written() { return } diff --git a/templates/repo/issue/choose.tmpl b/templates/repo/issue/choose.tmpl index e3314f42607..60290e90842 100644 --- a/templates/repo/issue/choose.tmpl +++ b/templates/repo/issue/choose.tmpl @@ -15,7 +15,7 @@
{{.About}} @@ -41,7 +41,7 @@
{{ctx.Locale.Tr "repo.issues.choose.blank_about"}}