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 <gusted@noreply.codeberg.org>
This commit is contained in:
Thomas Teixeira 2026-05-02 01:29:40 +02:00 committed by Gusted
commit 731334e973
33 changed files with 593 additions and 36 deletions

View file

@ -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

View file

@ -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)

View file

@ -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
}

View file

@ -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() {

View file

@ -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

View file

@ -78,7 +78,7 @@
</div>
</div>
{{if .IsProjectsEnabled}}
{{if or .IsProjectsEnabled (and .OwnerHasProjects .IsOwnerProjectsEnabled)}}
<div class="divider"></div>
<input id="project_id" name="project_id" type="hidden" value="{{.project_id}}">

View file

@ -14,8 +14,10 @@
{{template "repo/issue/view_content/sidebar/milestones" .}}
<div class="divider"></div>
{{template "repo/issue/view_content/sidebar/projects" .}}
<div class="divider"></div>
{{if or .IsProjectsEnabled (and .OwnerHasProjects .IsOwnerProjectsEnabled)}}
{{template "repo/issue/view_content/sidebar/projects" .}}
<div class="divider"></div>
{{end}}
{{template "repo/issue/view_content/sidebar/assignees" dict "isExistingIssue" true "." .}}
<div class="divider"></div>

View file

@ -0,0 +1 @@
ref: refs/heads/master

View file

@ -0,0 +1,6 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
ignorecase = true
precomposeunicode = true

View file

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View file

@ -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]
# *~

View file

@ -0,0 +1 @@
2a47ca4b614a9f5a43abbd5ad851a54a616ffee6

View file

@ -0,0 +1 @@
d22b4d4daa5be07329fcef6ed458f00cf3392da0

View file

@ -0,0 +1,5 @@
-
id: 1001
user_id: 5
repo_id: 1001
mode: 2

View file

@ -0,0 +1,5 @@
-
id: 1001
repo_id: 1001
user_id: 5
mode: 2

View file

@ -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

View file

@ -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

View file

@ -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: '[]'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,11 @@
-
id: 1001
org_id: 3
team_id: 1001
uid: 5
-
id: 1002
org_id: 23
team_id: 1002
uid: 5

View file

@ -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

View file

@ -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)
})
}

View file

@ -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)
})
}