From 731334e973a7f4389b7f2994cd4bd186df2a242c Mon Sep 17 00:00:00 2001 From: Thomas Teixeira Date: Sat, 2 May 2026 01:29:40 +0200 Subject: [PATCH] fix(web): org projects assignment in issue view (#7999) Allows user to assign organization projects to their new issues, using the project sidebar selector, even when repository's projects are disabled. Moreover, the project sidebar selector is now hidden if no projects (repository-wide + organization-wide) are available. Fixes forgejo/forgejo#5666 Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7999 Reviewed-by: Gusted --- routers/web/repo/issue.go | 40 ++++-- routers/web/repo/issue_test.go | 3 + routers/web/repo/projects.go | 2 +- routers/web/web.go | 31 ++++- services/context/org.go | 43 +++--- templates/repo/issue/new_form.tmpl | 2 +- .../repo/issue/view_content/sidebar.tmpl | 6 +- .../test_assign_project_repo.git/HEAD | 1 + .../test_assign_project_repo.git/config | 6 + .../test_assign_project_repo.git/description | 1 + .../test_assign_project_repo.git/info/exclude | 6 + .../20/ade30d25e0ecaeec84e7f542a8456900858240 | Bin 0 -> 84 bytes .../27/74debeea6dc742cc4971a92db0e08b95b60588 | Bin 0 -> 51 bytes .../2a/47ca4b614a9f5a43abbd5ad851a54a616ffee6 | Bin 0 -> 760 bytes .../2f/9b22fd3159a43b7b4e5dd806fcd544edf8716f | Bin 0 -> 37 bytes .../d2/2b4d4daa5be07329fcef6ed458f00cf3392da0 | Bin 0 -> 814 bytes .../d5/6a3073c1dbb7b15963110a049d50cdb5db99fc | Bin 0 -> 42 bytes .../ec/f0db3c1ec806522de4b491fb9a3c7457398c61 | Bin 0 -> 62 bytes .../ee/16d127df463aa491e08958120f2108b02468df | Bin 0 -> 84 bytes .../refs/heads/master | 1 + .../refs/heads/test_branch | 1 + .../fixtures/TestAssignProject/access.yml | 5 + .../TestAssignProject/collaboration.yml | 5 + .../fixtures/TestAssignProject/project.yml | 51 ++++++++ .../fixtures/TestAssignProject/repo_unit.yml | 35 +++++ .../fixtures/TestAssignProject/repository.yml | 30 +++++ .../fixtures/TestAssignProject/team.yml | 32 +++++ .../fixtures/TestAssignProject/team_repo.yml | 11 ++ .../fixtures/TestAssignProject/team_unit.yml | 35 +++++ .../fixtures/TestAssignProject/team_user.yml | 11 ++ .../fixtures/TestAssignProject/user.yml | 37 ++++++ tests/integration/issue_test.go | 112 ++++++++++++++++ tests/integration/project_test.go | 122 ++++++++++++++++++ 33 files changed, 593 insertions(+), 36 deletions(-) create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/HEAD create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/config create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/description create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/info/exclude create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/20/ade30d25e0ecaeec84e7f542a8456900858240 create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/27/74debeea6dc742cc4971a92db0e08b95b60588 create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/2a/47ca4b614a9f5a43abbd5ad851a54a616ffee6 create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/2f/9b22fd3159a43b7b4e5dd806fcd544edf8716f create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0 create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/d5/6a3073c1dbb7b15963110a049d50cdb5db99fc create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/ec/f0db3c1ec806522de4b491fb9a3c7457398c61 create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/ee/16d127df463aa491e08958120f2108b02468df create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/refs/heads/master create mode 100644 tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/refs/heads/test_branch create mode 100644 tests/integration/fixtures/TestAssignProject/access.yml create mode 100644 tests/integration/fixtures/TestAssignProject/collaboration.yml create mode 100644 tests/integration/fixtures/TestAssignProject/project.yml create mode 100644 tests/integration/fixtures/TestAssignProject/repo_unit.yml create mode 100644 tests/integration/fixtures/TestAssignProject/repository.yml create mode 100644 tests/integration/fixtures/TestAssignProject/team.yml create mode 100644 tests/integration/fixtures/TestAssignProject/team_repo.yml create mode 100644 tests/integration/fixtures/TestAssignProject/team_unit.yml create mode 100644 tests/integration/fixtures/TestAssignProject/team_user.yml create mode 100644 tests/integration/fixtures/TestAssignProject/user.yml diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index bb4185f785..e1ee785ce4 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -604,7 +604,7 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { repoOwnerType = project_model.TypeOrganization } var err error - projects, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ + repositoryProjects, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ ListOptions: db.ListOptionsAll, RepoID: repo.ID, IsClosed: optional.Some(false), @@ -614,7 +614,7 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { ctx.ServerError("GetProjects", err) return } - projects2, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ownerProjects, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ ListOptions: db.ListOptionsAll, OwnerID: repo.OwnerID, IsClosed: optional.Some(false), @@ -625,9 +625,10 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { return } - ctx.Data["OpenProjects"] = append(projects, projects2...) + ownerHasOpenProjects := len(ownerProjects) > 0 + ctx.Data["OpenProjects"] = append(repositoryProjects, ownerProjects...) - projects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{ + repositoryProjects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{ ListOptions: db.ListOptionsAll, RepoID: repo.ID, IsClosed: optional.Some(true), @@ -637,7 +638,7 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { ctx.ServerError("GetProjects", err) return } - projects2, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ownerProjects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{ ListOptions: db.ListOptionsAll, OwnerID: repo.OwnerID, IsClosed: optional.Some(true), @@ -648,7 +649,8 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { return } - ctx.Data["ClosedProjects"] = append(projects, projects2...) + ctx.Data["OwnerHasProjects"] = ownerHasOpenProjects || len(ownerProjects) > 0 + ctx.Data["ClosedProjects"] = append(repositoryProjects, ownerProjects...) } // repoReviewerSelection items to bee shown @@ -968,6 +970,14 @@ func NewIssue(ctx *context.Context) { isProjectsEnabled := ctx.Repo.CanRead(unit.TypeProjects) ctx.Data["IsProjectsEnabled"] = isProjectsEnabled + + // Individuals always have projects unit enabled + isOwnerProjectsEnabled := true + if ctx.Repo.Owner.IsOrganization() { + isOwnerProjectsEnabled = ctx.Org.CanReadUnit(ctx, unit.TypeProjects) + } + ctx.Data["IsOwnerProjectsEnabled"] = isOwnerProjectsEnabled + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled upload.AddUploadContext(ctx, "comment") @@ -983,7 +993,7 @@ func NewIssue(ctx *context.Context) { } projectID := ctx.FormInt64("project") - if projectID > 0 && isProjectsEnabled { + if projectID > 0 && (isProjectsEnabled || isOwnerProjectsEnabled) { project, err := project_model.GetProjectByID(ctx, projectID) if err != nil { log.Error("GetProjectByID: %d: %v", projectID, err) @@ -1276,9 +1286,9 @@ func NewIssuePost(ctx *context.Context) { } if projectID > 0 { - if !ctx.Repo.CanRead(unit.TypeProjects) { + if !ctx.Repo.CanRead(unit.TypeProjects) || (ctx.ContextUser.IsOrganization() && !ctx.Org.CanReadUnit(ctx, unit.TypeProjects)) { // User must also be able to see the project. - ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects") + ctx.Error(http.StatusForbidden, "user doesn't have permissions to read projects") return } if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { @@ -1477,7 +1487,8 @@ func ViewIssue(ctx *context.Context) { } ctx.Data["IsModerationEnabled"] = setting.Moderation.Enabled - ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(unit.TypeProjects) + isProjectsEnabled := ctx.Repo.CanRead(unit.TypeProjects) + ctx.Data["IsProjectsEnabled"] = isProjectsEnabled ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled upload.AddUploadContext(ctx, "comment") @@ -1546,6 +1557,7 @@ func ViewIssue(ctx *context.Context) { } ctx.Data["Labels"] = labels + isOwnerProjectsEnabled := true if repo.Owner.IsOrganization() { orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}) if err != nil { @@ -1553,9 +1565,11 @@ func ViewIssue(ctx *context.Context) { return } ctx.Data["OrgLabels"] = orgLabels - labels = append(labels, orgLabels...) + + isOwnerProjectsEnabled = ctx.Org.CanReadUnit(ctx, unit.TypeProjects) } + ctx.Data["IsOwnerProjectsEnabled"] = isOwnerProjectsEnabled hasSelected := false for i := range labels { @@ -1569,7 +1583,9 @@ func ViewIssue(ctx *context.Context) { // Check milestone and assignee. if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { RetrieveRepoMilestonesAndAssignees(ctx, repo) - retrieveProjects(ctx, repo) + if isProjectsEnabled || isOwnerProjectsEnabled { + retrieveProjects(ctx, repo) + } if ctx.Written() { return diff --git a/routers/web/repo/issue_test.go b/routers/web/repo/issue_test.go index 3644dd131f..e2c812084d 100644 --- a/routers/web/repo/issue_test.go +++ b/routers/web/repo/issue_test.go @@ -74,6 +74,9 @@ func TestNewIssueValidateProject(t *testing.T) { contexttest.LoadUser(t, ctx, testCase.userID) contexttest.LoadRepo(t, ctx, testCase.repoID) contexttest.LoadGitRepo(t, ctx) + if ctx.Repo.Owner.IsOrganization() { + contexttest.LoadOrganization(t, ctx, ctx.Repo.Owner.ID) + } NewIssue(ctx) diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 98e4c35fa2..f007dfa7ba 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -400,7 +400,7 @@ func UpdateIssueProject(ctx *context.Context) { return } if _, err := issues.LoadRepositories(ctx); err != nil { - ctx.ServerError("LoadProjects", err) + ctx.ServerError("LoadRepositories", err) return } diff --git a/routers/web/web.go b/routers/web/web.go index 5839e7d30e..c77c8629b9 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -906,6 +906,29 @@ func registerRoutes(m *web.Route) { } } + reqRepoOrOwnerProjectReader := func(ctx *context.Context) { + unitType := unit.TypeProjects + if ctx.ContextUser == nil || ctx.Doer == nil { + ctx.NotFound(unitType.String(), nil) + return + } + + switch { + case ctx.ContextUser.IsIndividual(): + if ctx.Doer.ID == ctx.ContextUser.ID || ctx.Doer.IsAdmin { + return + } + case ctx.ContextUser.IsOrganization(): + if ctx.Org.Organization.UnitPermission(ctx, ctx.Doer, unitType) >= perm.AccessModeRead { + return + } + default: + ctx.NotFound(unitType.String(), nil) + return + } + reqRepoProjectsReader(ctx) + } + individualPermsChecker := func(ctx *context.Context) { // org permissions have been checked in context.OrgAssignment(), but individual permissions haven't been checked. if ctx.ContextUser.IsIndividual() { @@ -1255,7 +1278,7 @@ func registerRoutes(m *web.Route) { } m.Group("/issues", func() { m.Group("/new", func() { - m.Combo("").Get(context.RepoRef(), repo.NewIssue). + m.Combo("", context.EnsureOrg()).Get(context.RepoRef(), repo.NewIssue). Post(web.Bind(forms.CreateIssueForm{}), repo.NewIssuePost) m.Get("/choose", context.RepoRef(), repo.NewIssueChooseTemplate) }) @@ -1301,7 +1324,7 @@ func registerRoutes(m *web.Route) { m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone) - m.Post("/projects", reqRepoIssuesOrPullsWriter, reqRepoProjectsReader, repo.UpdateIssueProject) + m.Post("/projects", reqRepoIssuesOrPullsWriter, context.EnsureOrg(), reqRepoOrOwnerProjectReader, repo.UpdateIssueProject) m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee) m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest) m.Post("/dismiss_review", reqRepoAdmin, web.Bind(forms.DismissReviewForm{}), repo.DismissReview) @@ -1427,7 +1450,7 @@ func registerRoutes(m *web.Route) { m.Group("", func() { m.Get("/issues/posters", repo.IssuePosters) // it can't use {type:issues|pulls} because other routes like "/pulls/{index}" has higher priority m.Get("/{type:^(issues|pulls)$}", repo.Issues) - m.Get("/{type:^(issues|pulls)$}/{index}", repo.ViewIssue) + m.Get("/{type:^(issues|pulls)$}/{index}", context.EnsureOrg(), repo.ViewIssue) m.Group("/{type:^(issues|pulls)$}/{index}/content-history", func() { m.Get("/overview", repo.GetContentHistoryOverview) m.Get("/list", repo.GetContentHistoryList) @@ -1600,7 +1623,7 @@ func registerRoutes(m *web.Route) { m.Get("/pulls/posters", repo.PullPosters) m.Group("/pulls/{index}", func() { - m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue) + m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, context.EnsureOrg(), repo.ViewIssue) m.Get(".diff", repo.DownloadPullDiff) m.Get(".patch", repo.DownloadPullPatch) m.Group("/commits", func() { diff --git a/services/context/org.go b/services/context/org.go index c7d06b9bcc..adc6e33494 100644 --- a/services/context/org.go +++ b/services/context/org.go @@ -64,6 +64,32 @@ func GetOrganizationByParams(ctx *Context) { } } +func ensureIsOrg(ctx *Context) bool { + switch { + // Getting Organization from params + case ctx.ContextUser == nil: + if ctx.Org.Organization == nil { + GetOrganizationByParams(ctx) + } + return !ctx.Written() + // Getting Organization from ContextUser + case ctx.ContextUser.IsOrganization(): + if ctx.Org == nil { + ctx.Org = &Organization{} + } + ctx.Org.Organization = (*organization.Organization)(ctx.ContextUser) + return true + } + // ContextUser is an individual User + return false +} + +func EnsureOrg() func(*Context) { + return func(ctx *Context) { + ensureIsOrg(ctx) + } +} + // HandleOrgAssignment handles organization assignment func HandleOrgAssignment(ctx *Context, args ...bool) { var ( @@ -87,24 +113,9 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { var err error - if ctx.ContextUser == nil { - // if Organization is not defined, get it from params - if ctx.Org.Organization == nil { - GetOrganizationByParams(ctx) - if ctx.Written() { - return - } - } - } else if ctx.ContextUser.IsOrganization() { - if ctx.Org == nil { - ctx.Org = &Organization{} - } - ctx.Org.Organization = (*organization.Organization)(ctx.ContextUser) - } else { - // ContextUser is an individual User + if !ensureIsOrg(ctx) { return } - org := ctx.Org.Organization // Handle Visibility diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl index 10a5978d52..5f5820fb07 100644 --- a/templates/repo/issue/new_form.tmpl +++ b/templates/repo/issue/new_form.tmpl @@ -78,7 +78,7 @@ - {{if .IsProjectsEnabled}} + {{if or .IsProjectsEnabled (and .OwnerHasProjects .IsOwnerProjectsEnabled)}}
diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 72e214c5aa..9d8db413fa 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -14,8 +14,10 @@ {{template "repo/issue/view_content/sidebar/milestones" .}}
- {{template "repo/issue/view_content/sidebar/projects" .}} -
+ {{if or .IsProjectsEnabled (and .OwnerHasProjects .IsOwnerProjectsEnabled)}} + {{template "repo/issue/view_content/sidebar/projects" .}} +
+ {{end}} {{template "repo/issue/view_content/sidebar/assignees" dict "isExistingIssue" true "." .}}
diff --git a/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/HEAD b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/HEAD new file mode 100644 index 0000000000..cb089cd89a --- /dev/null +++ b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/config b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/config new file mode 100644 index 0000000000..e6da231579 --- /dev/null +++ b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true diff --git a/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/description b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/description new file mode 100644 index 0000000000..498b267a8c --- /dev/null +++ b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/info/exclude b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/info/exclude new file mode 100644 index 0000000000..a5196d1be8 --- /dev/null +++ b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/20/ade30d25e0ecaeec84e7f542a8456900858240 b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/20/ade30d25e0ecaeec84e7f542a8456900858240 new file mode 100644 index 0000000000000000000000000000000000000000..9f3ffe5f27eab55ddf77a4eef3620a63549a1a32 GIT binary patch literal 84 zcmV-a0IUCa0V^p=O;s>6XD~D{Ff%bx2y%6F@paY9O<}m2Wl((Z_V$gD$%0%ga|6z9 qy*=}fi2)EOq~s?vsF&Q^_bT_e(;3ggmAV@qbWh#J+5rIM${q^x(I*%H literal 0 HcmV?d00001 diff --git a/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/27/74debeea6dc742cc4971a92db0e08b95b60588 b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/27/74debeea6dc742cc4971a92db0e08b95b60588 new file mode 100644 index 0000000000000000000000000000000000000000..5d9226f7a1240630524be4a3f27f4f31b1972c4c GIT binary patch literal 51 zcmV-30L=e*0V^p=O;s>9VK6i>Ff%bxNXbvu%S~a>pRM%QFmj1?wO{NFwm(;0-u@`e J2LKmL5A5=Z6&nBm literal 0 HcmV?d00001 diff --git a/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/2a/47ca4b614a9f5a43abbd5ad851a54a616ffee6 b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/2a/47ca4b614a9f5a43abbd5ad851a54a616ffee6 new file mode 100644 index 0000000000000000000000000000000000000000..ca60d2314fcda692990183d659d83ee440c1b995 GIT binary patch literal 760 zcmVYOx;klMR72dt4;0R+*~O8>}``PUZ2-qtxFX@ zzcQJ_^G_pOnRY=y&KS-~OX8!%s4VQG3+deL$vl6&|{~De2HSfz`)_^n-GuEXHwgRaeKV9=$9fPOJ1R0w&iIoy1PPwU+nK$I{bkEo7A`*gjm6CvD_e$1?jz#N~O za$_(}1e~skAZ#g_cMrB?V4Go^>n-WyBM+oNMmAPyk=}4>>B#PS@I$%`bj(%?W#cq$vH`uuPA-Xd;t|(ab9y}n`qJEc`7=3+E55E z$e1f~*2Cub*`mHJ?;X>J)^ki#5tAz#O#)(g5k031yqQOfK2UuUvU(xh?s>+q2#7WU zPQ_aDQ+K*Pm0NN_6;C(xXkDE?Y6;G%0+#1q=Fn1)S{}>pfgYb)I$r|Fv`LJBTSVP( zb>$Z|pyzbAWM`N|X)pQxLk(E=9`8a`Kus_6?#{1_diM>Af6P%>n&^txCj_v@gI-fg qUZ|GWE4B7C{oDJ4oXQ&c&k4mk{mKZW?!Q*Q-a`ZP8Tki~F*%6l+q`%%--?Q3*9;fw#G`w_Am_K7;=uqdh2LKN*4QBuV literal 0 HcmV?d00001 diff --git a/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0 b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0 new file mode 100644 index 0000000000000000000000000000000000000000..e319f8ce3447ea6a2a5c36db6841d582ed4fd02c GIT binary patch literal 814 zcmV+}1JV3=0hNet()&&B`aCw{mU`%owRF4;Pzn% z@u^AqrtIh8?4O&RxUMO+3wpTC10PEVuvM4oI{lE(FLu}mbB_3ly;d=}h>m*a}70U@7KwMj5g?@Ptm9v&D}q^RpA z@wF3D?oP{r!GEMHLslWW$BeUIoJcp><1kNB*vr(AJ@%-}Fz^xFHPNcBHG?5);#}+; z1D?{03~4{OdsRKh*D*6wWz%hwL&b4r?-{wsyORf6To4#=$DLWSqszS^Ay!mpG!KQ( zllokZjEQ~eozR#B3YHdCylD01>Z<6TBD2jpsFS7EG2?TCo%v<+7hRJ%;1%Zm4 z!4Bh>S>!JXRe`GSi!Y>n7UybKW_iz4#)R@d!a!R*Z+7W>8irN-fAY=HhZmElw`VEGWs$&r?XtFM6XD~D{Ff%bx2y%6F@paY9O<{QR;kJ$33AP~JCtD`|o@G-K qZrPJ)VgLjRDf!6^>LvH~y~;iAbjGuArS66Y-BY)*b^rk6+8wjRASSs0 literal 0 HcmV?d00001 diff --git a/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/refs/heads/master b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/refs/heads/master new file mode 100644 index 0000000000..ccee722d52 --- /dev/null +++ b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/refs/heads/master @@ -0,0 +1 @@ +2a47ca4b614a9f5a43abbd5ad851a54a616ffee6 diff --git a/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/refs/heads/test_branch b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/refs/heads/test_branch new file mode 100644 index 0000000000..dfe0c6a128 --- /dev/null +++ b/tests/gitea-repositories-meta/test_assign_project_org/test_assign_project_repo.git/refs/heads/test_branch @@ -0,0 +1 @@ +d22b4d4daa5be07329fcef6ed458f00cf3392da0 diff --git a/tests/integration/fixtures/TestAssignProject/access.yml b/tests/integration/fixtures/TestAssignProject/access.yml new file mode 100644 index 0000000000..9dec50f0ac --- /dev/null +++ b/tests/integration/fixtures/TestAssignProject/access.yml @@ -0,0 +1,5 @@ +- + id: 1001 + user_id: 5 + repo_id: 1001 + mode: 2 diff --git a/tests/integration/fixtures/TestAssignProject/collaboration.yml b/tests/integration/fixtures/TestAssignProject/collaboration.yml new file mode 100644 index 0000000000..a26149d60e --- /dev/null +++ b/tests/integration/fixtures/TestAssignProject/collaboration.yml @@ -0,0 +1,5 @@ +- + id: 1001 + repo_id: 1001 + user_id: 5 + mode: 2 diff --git a/tests/integration/fixtures/TestAssignProject/project.yml b/tests/integration/fixtures/TestAssignProject/project.yml new file mode 100644 index 0000000000..20b75ddb69 --- /dev/null +++ b/tests/integration/fixtures/TestAssignProject/project.yml @@ -0,0 +1,51 @@ +# team 1001 projects +- + id: 1001 + title: project1001 + owner_id: 3 + repo_id: 0 + is_closed: false + board_type: 0 + type: 3 + creator_id: 5 + created_unix: 1688973030 + updated_unix: 1688973030 + +# team 1002 projects +- + id: 1002 + title: project1002 + owner_id: 0 + repo_id: 1001 + is_closed: false + board_type: 0 + type: 2 + creator_id: 5 + created_unix: 1688973030 + updated_unix: 1688973030 + +# user 5 project +- + id: 1003 + title: project1003 + owner_id: 5 + repo_id: 0 + is_closed: false + board_type: 0 + type: 1 + creator_id: 5 + created_unix: 1688973030 + updated_unix: 1688973030 + +# org 1001 disabled project +- + id: 1004 + title: project1004 + owner_id: 1001 + repo_id: 0 + is_closed: false + board_type: 0 + type: 3 + creator_id: 5 + created_unix: 1688973030 + updated_unix: 1688973030 diff --git a/tests/integration/fixtures/TestAssignProject/repo_unit.yml b/tests/integration/fixtures/TestAssignProject/repo_unit.yml new file mode 100644 index 0000000000..d3fad496aa --- /dev/null +++ b/tests/integration/fixtures/TestAssignProject/repo_unit.yml @@ -0,0 +1,35 @@ +- + id: 1001 + repo_id: 3 + type: 8 + created_unix: 946684810 + +- + id: 1002 + repo_id: 3 + type: 2 + created_unix: 946684810 + +- + id: 1003 + repo_id: 3 + type: 3 + created_unix: 946684810 + +- + id: 1004 + repo_id: 1001 + type: 8 + created_unix: 946684810 + +- + id: 1005 + repo_id: 1001 + type: 2 + created_unix: 946684810 + +- + id: 1006 + repo_id: 1001 + type: 3 + created_unix: 946684810 diff --git a/tests/integration/fixtures/TestAssignProject/repository.yml b/tests/integration/fixtures/TestAssignProject/repository.yml new file mode 100644 index 0000000000..3a2d6d637a --- /dev/null +++ b/tests/integration/fixtures/TestAssignProject/repository.yml @@ -0,0 +1,30 @@ +- + id: 1001 + owner_id: 1001 + owner_name: test_assign_project_org + lower_name: test_assign_project_repo + name: test_assign_project_repo + default_branch: master + num_watches: 4 + num_stars: 0 + num_forks: 0 + num_milestones: 3 + num_closed_milestones: 1 + num_projects: 1 + num_closed_projects: 0 + is_private: false + is_empty: false + is_archived: false + is_mirror: false + status: 0 + is_fork: false + fork_id: 0 + is_template: false + template_id: 0 + size: 7597 + is_fsck_enabled: true + close_issues_via_commit_in_any_branch: false + created_unix: 1731254961 + updated_unix: 1731254961 + topics: '[]' + diff --git a/tests/integration/fixtures/TestAssignProject/team.yml b/tests/integration/fixtures/TestAssignProject/team.yml new file mode 100644 index 0000000000..6f295a3c65 --- /dev/null +++ b/tests/integration/fixtures/TestAssignProject/team.yml @@ -0,0 +1,32 @@ +- + id: 1001 + org_id: 3 + lower_name: writers + name: Writers + authorize: 2 # writer + num_repos: 3 + num_members: 1 + includes_all_repositories: false + can_create_org_repo: true + +- + id: 1002 + org_id: 1001 + lower_name: writers + name: Writers + authorize: 2 # writer + num_repos: 1 + num_members: 1 + includes_all_repositories: false + can_create_org_repo: true + +- + id: 1003 + org_id: 1001 + lower_name: owners + name: Owners + authorize: 4 + num_repos: 0 + num_members: 1 + includes_all_repositories: false + can_create_org_repo: true diff --git a/tests/integration/fixtures/TestAssignProject/team_repo.yml b/tests/integration/fixtures/TestAssignProject/team_repo.yml new file mode 100644 index 0000000000..9b34a817d9 --- /dev/null +++ b/tests/integration/fixtures/TestAssignProject/team_repo.yml @@ -0,0 +1,11 @@ +- + id: 1001 + org_id: 3 + team_id: 1001 + repo_id: 3 + +- + id: 1002 + org_id: 1001 + team_id: 1002 + repo_id: 1001 diff --git a/tests/integration/fixtures/TestAssignProject/team_unit.yml b/tests/integration/fixtures/TestAssignProject/team_unit.yml new file mode 100644 index 0000000000..d156983090 --- /dev/null +++ b/tests/integration/fixtures/TestAssignProject/team_unit.yml @@ -0,0 +1,35 @@ +- + id: 1001 + team_id: 1001 + type: 8 + access_mode: 2 + +- + id: 1002 + team_id: 1002 + type: 8 + access_mode: 1 + +- + id: 1003 + team_id: 1001 + type: 2 + access_mode: 2 + +- + id: 1004 + team_id: 1001 + type: 3 + access_mode: 2 + +- + id: 1005 + team_id: 1002 + type: 2 + access_mode: 2 + +- + id: 1006 + team_id: 1002 + type: 3 + access_mode: 2 diff --git a/tests/integration/fixtures/TestAssignProject/team_user.yml b/tests/integration/fixtures/TestAssignProject/team_user.yml new file mode 100644 index 0000000000..8061f1b9d8 --- /dev/null +++ b/tests/integration/fixtures/TestAssignProject/team_user.yml @@ -0,0 +1,11 @@ +- + id: 1001 + org_id: 3 + team_id: 1001 + uid: 5 + +- + id: 1002 + org_id: 23 + team_id: 1002 + uid: 5 diff --git a/tests/integration/fixtures/TestAssignProject/user.yml b/tests/integration/fixtures/TestAssignProject/user.yml new file mode 100644 index 0000000000..8ad7bd0e67 --- /dev/null +++ b/tests/integration/fixtures/TestAssignProject/user.yml @@ -0,0 +1,37 @@ +- + id: 1001 + lower_name: test_assign_project_org + name: test_assign_project_org + full_name: ' <<<< >> >> > >> > >>> >> ' + email: org1001@example.com + keep_email_private: false + email_notifications_preference: onmention + passwd: ZogKvWdyEx:password + passwd_hash_algo: dummy + must_change_password: false + login_source: 0 + login_name: test_assign_project_org + type: 1 + salt: ZogKvWdyEx + max_repo_creation: 1000 + is_active: false + is_admin: false + is_restricted: false + allow_git_hook: false + allow_import_local: false + allow_create_organization: true + prohibit_login: false + avatar: "" + avatar_email: org1001@example.com + use_custom_avatar: true + num_followers: 0 + num_following: 0 + num_stars: 0 + num_repos: 1 + num_teams: 1 + num_members: 1 + visibility: 0 + repo_admin_change_team_access: false + theme: "" + keep_activity_private: false + created_unix: 1672578020 diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go index d764870bab..c0ecf6b3bc 100644 --- a/tests/integration/issue_test.go +++ b/tests/integration/issue_test.go @@ -19,6 +19,7 @@ import ( auth_model "forgejo.org/models/auth" "forgejo.org/models/db" issues_model "forgejo.org/models/issues" + org_model "forgejo.org/models/organization" project_model "forgejo.org/models/project" repo_model "forgejo.org/models/repo" unit_model "forgejo.org/models/unit" @@ -31,6 +32,7 @@ import ( api "forgejo.org/modules/structs" "forgejo.org/modules/test" "forgejo.org/modules/translation" + repo_service "forgejo.org/services/repository" files_service "forgejo.org/services/repository/files" user_service "forgejo.org/services/user" "forgejo.org/tests" @@ -1661,3 +1663,113 @@ func TestIssueUrlHandling(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) }) } + +func TestIssueProjectSidebarMissing(t *testing.T) { + const ( + repoID = 4 + userID = 5 + ) + defer unittest.OverrideFixtures("tests/integration/fixtures/TestAssignProject/")() + defer tests.PrepareTestEnv(t)() + + ctx := t.Context() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) + session := loginUser(t, user.Name) + + issueURL := testNewIssue(t, session, user.Name, repo.Name, "Hello", "World") + t.Run("Sidebar showing - user project available", func(tt *testing.T) { + defer tests.PrintCurrentTest(tt)() + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".select-project.dropdown", true) + }) + + // Enable repository's project unit + projectUnit := repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeProjects, + } + require.NoError(t, repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{projectUnit}, nil)) + + t.Run("Sidebar showing - repository project unit on", func(tt *testing.T) { + defer tests.PrintCurrentTest(tt)() + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".select-project.dropdown", true) + }) + + project_model.DeleteProjectByID(ctx, 1003) + // Disable repository's project unit + require.NoError(t, repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil, []unit_model.Type{unit_model.TypeProjects})) + t.Run("Sidebar missing", func(tt *testing.T) { + defer tests.PrintCurrentTest(tt)() + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".select-project.dropdown", false) + }) + + // Team with project available + team := unittest.AssertExistsAndLoadBean(t, &org_model.Team{ID: 1001}) + require.NoError(t, team.LoadMembers(ctx)) + require.NoError(t, team.LoadRepositories(ctx)) + + user = team.Members[0] + repo = team.Repos[0] + org := team.GetOrg(ctx) + session = loginUser(t, user.Name) + + issueURL = testNewIssue(t, session, org.Name, repo.Name, "Hello", "World") + t.Run("Sidebar showing - org on & repo on", func(tt *testing.T) { + defer tests.PrintCurrentTest(tt)() + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".select-project.dropdown", true) + }) + + // Disable repository project unit + require.NoError(t, repo_service.UpdateRepositoryUnits(ctx, repo, nil, []unit_model.Type{unit_model.TypeProjects})) + t.Run("Sidebar showing - org on & repo off", func(tt *testing.T) { + defer tests.PrintCurrentTest(tt)() + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".select-project.dropdown", true) + }) + + // Team with project disabled + team = unittest.AssertExistsAndLoadBean(t, &org_model.Team{ID: 1002}) + require.NoError(t, team.LoadMembers(ctx)) + require.NoError(t, team.LoadRepositories(ctx)) + + user = team.Members[0] + repo = team.Repos[0] + org = team.GetOrg(ctx) + session = loginUser(t, user.Name) + + require.NoError(t, project_model.DeleteProjectByID(db.DefaultContext, 1004)) + + issueURL = testNewIssue(t, session, org.Name, repo.Name, "Hello", "World") + t.Run("Sidebar showing - org off & repo on", func(tt *testing.T) { + defer tests.PrintCurrentTest(tt)() + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".select-project.dropdown", true) + }) + + // Disable repository project unit + require.NoError(t, repo_service.UpdateRepositoryUnits(ctx, repo, nil, []unit_model.Type{unit_model.TypeProjects})) + t.Run("Sidebar missing - org off & repo off", func(tt *testing.T) { + defer tests.PrintCurrentTest(tt)() + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".select-project.dropdown", false) + }) +} diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index 629793602b..7583075b5f 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -7,12 +7,20 @@ package integration import ( "fmt" "net/http" + "path" + "strconv" + "strings" "testing" "forgejo.org/models/db" + issues_model "forgejo.org/models/issues" + org_model "forgejo.org/models/organization" project_model "forgejo.org/models/project" repo_model "forgejo.org/models/repo" + unit_model "forgejo.org/models/unit" "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + repo_service "forgejo.org/services/repository" "forgejo.org/tests" "github.com/stretchr/testify/assert" @@ -160,3 +168,117 @@ func TestChangeStatusProject(t *testing.T) { }) }) } + +func TestAssignProject(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestAssignProject/")() + defer tests.PrepareTestEnv(t)() + + ctx := t.Context() + + newTestIssue := func(t *testing.T, session *TestSession, owner *user_model.User, repo *repo_model.Repository) (*issues_model.Issue, string, string) { + t.Helper() + + issueURL := testNewIssue(t, session, owner.Name, repo.Name, "Hello", "World") + indexStr := issueURL[strings.LastIndexByte(issueURL, '/')+1:] + index, err := strconv.Atoi(indexStr) + require.NoError(t, err, "Invalid issue href: %s", issueURL) + + issue := &issues_model.Issue{RepoID: repo.ID, Index: int64(index)} + unittest.AssertExistsAndLoadBean(t, issue) + + issueID := strconv.FormatInt(issue.ID, 10) + return issue, indexStr, issueID + } + + updateIssueProject := func(t *testing.T, session *TestSession, projectID, issueID, owner, repo string, expectedStatus int) { + t.Helper() + + req := NewRequestWithValues(t, "POST", path.Join(owner, repo, "issues", "projects"), map[string]string{ + "issue_ids": issueID, + "id": projectID, + }) + session.MakeRequest(t, req, expectedStatus) + } + + // User + t.Run("UserProjectOn+RepoProjectOff", func(tt *testing.T) { + defer tests.PrintCurrentTest(tt)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + + session := loginUser(tt, user.Name) + issue, _, issueID := newTestIssue(tt, session, user, repo) + + updateIssueProject(tt, session, "1003", issueID, user.Name, repo.Name, http.StatusOK) + require.NoError(tt, issue.LoadProject(db.DefaultContext)) + require.NotNil(tt, issue.Project) + require.Equal(tt, int64(1003), issue.Project.ID) + }) + + // Team 1001 - enabled project unit + team := unittest.AssertExistsAndLoadBean(t, &org_model.Team{ID: 1001}) + require.NoError(t, team.LoadMembers(ctx)) + require.NoError(t, team.LoadRepositories(ctx)) + + user := team.Members[0] + repo := team.Repos[0] + org := team.GetOrg(ctx) + + session := loginUser(t, user.Name) + + t.Run("OrgProjectOn+RepoProjectOn", func(tt *testing.T) { + defer tests.PrintCurrentTest(tt)() + issue, _, issueID := newTestIssue(tt, session, org.AsUser(), repo) + + updateIssueProject(tt, session, "1001", issueID, org.Name, repo.Name, http.StatusOK) + + require.NoError(tt, issue.LoadProject(db.DefaultContext)) + require.NotNil(tt, issue.Project) + require.Equal(tt, int64(1001), issue.Project.ID) + }) + + // Disable repository project unit + require.NoError(t, repo_service.UpdateRepositoryUnits(ctx, repo, nil, []unit_model.Type{unit_model.TypeProjects})) + t.Run("OrgProjectOn+RepoProjectOff", func(tt *testing.T) { + defer tests.PrintCurrentTest(tt)() + issue, _, issueID := newTestIssue(tt, session, org.AsUser(), repo) + + updateIssueProject(tt, session, "1001", issueID, org.Name, repo.Name, http.StatusOK) + require.NoError(tt, issue.LoadProject(db.DefaultContext)) + require.NotNil(tt, issue.Project) + require.Equal(tt, int64(1001), issue.Project.ID) + }) + + // Team 1002 - disabled project unit + team = unittest.AssertExistsAndLoadBean(t, &org_model.Team{ID: 1002}) + require.NoError(t, team.LoadMembers(ctx)) + require.NoError(t, team.LoadRepositories(ctx)) + + user = team.Members[0] + repo = team.Repos[0] + org = team.GetOrg(ctx) + + session = loginUser(t, user.Name) + + t.Run("OrgProjectOff+RepoProjectOn", func(tt *testing.T) { + defer tests.PrintCurrentTest(tt)() + issue, _, issueID := newTestIssue(tt, session, org.AsUser(), repo) + + updateIssueProject(tt, session, "1002", issueID, org.Name, repo.Name, http.StatusOK) + require.NoError(tt, issue.LoadProject(db.DefaultContext)) + require.NotNil(tt, issue.Project) + require.Equal(tt, int64(1002), issue.Project.ID) + }) + + // Disable repository project unit + require.NoError(t, repo_service.UpdateRepositoryUnits(ctx, repo, nil, []unit_model.Type{unit_model.TypeProjects})) + t.Run("OrgProjectOff+RepoProjectOff", func(tt *testing.T) { + defer tests.PrintCurrentTest(tt)() + issue, _, issueID := newTestIssue(tt, session, org.AsUser(), repo) + + updateIssueProject(tt, session, "1002", issueID, org.Name, repo.Name, http.StatusOK) + require.NoError(tt, issue.LoadProject(db.DefaultContext)) + require.NotNil(tt, issue.Project) + require.Equal(tt, int64(1002), issue.Project.ID) + }) +}