summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLunny Xiao <xiaolunwen@gmail.com>2023-01-20 19:42:33 +0800
committerGitHub <noreply@github.com>2023-01-20 19:42:33 +0800
commit6fe3c8b3980f850c9789f9fa62bdfee7b2708ff0 (patch)
tree5e11864dd63c60564080506291ca93e465c19940
parent0c048e554ba42d99bd66c07447de5f35cf6c981b (diff)
downloadgitea-6fe3c8b3980f850c9789f9fa62bdfee7b2708ff0.tar.gz
gitea-6fe3c8b3980f850c9789f9fa62bdfee7b2708ff0.zip
Support org/user level projects (#22235)
Fix #13405 <img width="1151" alt="image" src="https://user-images.githubusercontent.com/81045/209442911-7baa3924-c389-47b6-b63b-a740803e640e.png"> Co-authored-by: 6543 <6543@obermui.de>
-rw-r--r--models/fixtures/project.yml9
-rw-r--r--models/fixtures/project_board.yml8
-rw-r--r--models/issues/issue.go2
-rw-r--r--models/issues/issue_project.go10
-rw-r--r--models/organization/team.go65
-rw-r--r--models/organization/team_list.go128
-rw-r--r--models/organization/team_user.go20
-rw-r--r--models/project/project.go94
-rw-r--r--models/project/project_test.go6
-rw-r--r--modules/context/org.go28
-rw-r--r--routers/web/org/main_test.go17
-rw-r--r--routers/web/org/projects.go670
-rw-r--r--routers/web/org/projects_test.go28
-rw-r--r--routers/web/repo/issue.go33
-rw-r--r--routers/web/repo/projects.go48
-rw-r--r--routers/web/shared/user/header.go14
-rw-r--r--routers/web/user/package.go17
-rw-r--r--routers/web/user/profile.go2
-rw-r--r--routers/web/web.go42
-rw-r--r--services/context/user.go9
-rw-r--r--templates/org/menu.tmpl3
-rw-r--r--templates/org/projects/list.tmpl6
-rw-r--r--templates/org/projects/new.tmpl6
-rw-r--r--templates/org/projects/view.tmpl6
-rw-r--r--templates/projects/list.tmpl98
-rw-r--r--templates/projects/new.tmpl66
-rw-r--r--templates/projects/view.tmpl279
-rw-r--r--templates/repo/issue/view_content/sidebar.tmpl12
-rw-r--r--templates/user/overview/header.tmpl3
-rw-r--r--templates/user/profile.tmpl3
30 files changed, 1556 insertions, 176 deletions
diff --git a/models/fixtures/project.yml b/models/fixtures/project.yml
index 3d42597c5e..f38b5344bb 100644
--- a/models/fixtures/project.yml
+++ b/models/fixtures/project.yml
@@ -24,3 +24,12 @@
creator_id: 5
board_type: 1
type: 2
+
+-
+ id: 4
+ title: project on user2
+ owner_id: 2
+ is_closed: false
+ creator_id: 2
+ board_type: 1
+ type: 2
diff --git a/models/fixtures/project_board.yml b/models/fixtures/project_board.yml
index 9e06e8c239..dc4f9cf565 100644
--- a/models/fixtures/project_board.yml
+++ b/models/fixtures/project_board.yml
@@ -21,3 +21,11 @@
creator_id: 2
created_unix: 1588117528
updated_unix: 1588117528
+
+-
+ id: 4
+ project_id: 4
+ title: Done
+ creator_id: 2
+ created_unix: 1588117528
+ updated_unix: 1588117528
diff --git a/models/issues/issue.go b/models/issues/issue.go
index 4a8ab06824..dc9e5c5acd 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -1098,7 +1098,7 @@ func GetIssueWithAttrsByID(id int64) (*Issue, error) {
}
// GetIssuesByIDs return issues with the given IDs.
-func GetIssuesByIDs(ctx context.Context, issueIDs []int64) ([]*Issue, error) {
+func GetIssuesByIDs(ctx context.Context, issueIDs []int64) (IssueList, error) {
issues := make([]*Issue, 0, 10)
return issues, db.GetEngine(ctx).In("id", issueIDs).Find(&issues)
}
diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go
index 8e559f13c9..c9f4c9f533 100644
--- a/models/issues/issue_project.go
+++ b/models/issues/issue_project.go
@@ -125,13 +125,17 @@ func ChangeProjectAssign(issue *Issue, doer *user_model.User, newProjectID int64
func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error {
oldProjectID := issue.projectID(ctx)
+ if err := issue.LoadRepo(ctx); err != nil {
+ return err
+ }
+
// Only check if we add a new project and not remove it.
if newProjectID > 0 {
newProject, err := project_model.GetProjectByID(ctx, newProjectID)
if err != nil {
return err
}
- if newProject.RepoID != issue.RepoID {
+ if newProject.RepoID != issue.RepoID && newProject.OwnerID != issue.Repo.OwnerID {
return fmt.Errorf("issue's repository is not the same as project's repository")
}
}
@@ -140,10 +144,6 @@ func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.U
return err
}
- if err := issue.LoadRepo(ctx); err != nil {
- return err
- }
-
if oldProjectID > 0 || newProjectID > 0 {
if _, err := CreateComment(ctx, &CreateCommentOptions{
Type: CommentTypeProject,
diff --git a/models/organization/team.go b/models/organization/team.go
index 55d3f17276..0c2577dab1 100644
--- a/models/organization/team.go
+++ b/models/organization/team.go
@@ -16,8 +16,6 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
-
- "xorm.io/builder"
)
// ___________
@@ -96,59 +94,6 @@ func init() {
db.RegisterModel(new(TeamInvite))
}
-// SearchTeamOptions holds the search options
-type SearchTeamOptions struct {
- db.ListOptions
- UserID int64
- Keyword string
- OrgID int64
- IncludeDesc bool
-}
-
-func (opts *SearchTeamOptions) toCond() builder.Cond {
- cond := builder.NewCond()
-
- if len(opts.Keyword) > 0 {
- lowerKeyword := strings.ToLower(opts.Keyword)
- var keywordCond builder.Cond = builder.Like{"lower_name", lowerKeyword}
- if opts.IncludeDesc {
- keywordCond = keywordCond.Or(builder.Like{"LOWER(description)", lowerKeyword})
- }
- cond = cond.And(keywordCond)
- }
-
- if opts.OrgID > 0 {
- cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID})
- }
-
- if opts.UserID > 0 {
- cond = cond.And(builder.Eq{"team_user.uid": opts.UserID})
- }
-
- return cond
-}
-
-// SearchTeam search for teams. Caller is responsible to check permissions.
-func SearchTeam(opts *SearchTeamOptions) ([]*Team, int64, error) {
- sess := db.GetEngine(db.DefaultContext)
-
- opts.SetDefaultValues()
- cond := opts.toCond()
-
- if opts.UserID > 0 {
- sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id")
- }
- sess = db.SetSessionPagination(sess, opts)
-
- teams := make([]*Team, 0, opts.PageSize)
- count, err := sess.Where(cond).OrderBy("lower_name").FindAndCount(&teams)
- if err != nil {
- return nil, 0, err
- }
-
- return teams, count, nil
-}
-
// ColorFormat provides a basic color format for a Team
func (t *Team) ColorFormat(s fmt.State) {
if t == nil {
@@ -335,16 +280,6 @@ func GetTeamNamesByID(teamIDs []int64) ([]string, error) {
return teamNames, err
}
-// GetRepoTeams gets the list of teams that has access to the repository
-func GetRepoTeams(ctx context.Context, repo *repo_model.Repository) (teams []*Team, err error) {
- return teams, db.GetEngine(ctx).
- Join("INNER", "team_repo", "team_repo.team_id = team.id").
- Where("team.org_id = ?", repo.OwnerID).
- And("team_repo.repo_id=?", repo.ID).
- OrderBy("CASE WHEN name LIKE '" + OwnerTeamName + "' THEN '' ELSE name END").
- Find(&teams)
-}
-
// IncrTeamRepoNum increases the number of repos for the given team by 1
func IncrTeamRepoNum(ctx context.Context, teamID int64) error {
_, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team))
diff --git a/models/organization/team_list.go b/models/organization/team_list.go
new file mode 100644
index 0000000000..5d3bd555cc
--- /dev/null
+++ b/models/organization/team_list.go
@@ -0,0 +1,128 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package organization
+
+import (
+ "context"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+
+ "xorm.io/builder"
+)
+
+type TeamList []*Team
+
+func (t TeamList) LoadUnits(ctx context.Context) error {
+ for _, team := range t {
+ if err := team.getUnits(ctx); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (t TeamList) UnitMaxAccess(tp unit.Type) perm.AccessMode {
+ maxAccess := perm.AccessModeNone
+ for _, team := range t {
+ if team.IsOwnerTeam() {
+ return perm.AccessModeOwner
+ }
+ for _, teamUnit := range team.Units {
+ if teamUnit.Type != tp {
+ continue
+ }
+ if teamUnit.AccessMode > maxAccess {
+ maxAccess = teamUnit.AccessMode
+ }
+ }
+ }
+ return maxAccess
+}
+
+// SearchTeamOptions holds the search options
+type SearchTeamOptions struct {
+ db.ListOptions
+ UserID int64
+ Keyword string
+ OrgID int64
+ IncludeDesc bool
+}
+
+func (opts *SearchTeamOptions) toCond() builder.Cond {
+ cond := builder.NewCond()
+
+ if len(opts.Keyword) > 0 {
+ lowerKeyword := strings.ToLower(opts.Keyword)
+ var keywordCond builder.Cond = builder.Like{"lower_name", lowerKeyword}
+ if opts.IncludeDesc {
+ keywordCond = keywordCond.Or(builder.Like{"LOWER(description)", lowerKeyword})
+ }
+ cond = cond.And(keywordCond)
+ }
+
+ if opts.OrgID > 0 {
+ cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID})
+ }
+
+ if opts.UserID > 0 {
+ cond = cond.And(builder.Eq{"team_user.uid": opts.UserID})
+ }
+
+ return cond
+}
+
+// SearchTeam search for teams. Caller is responsible to check permissions.
+func SearchTeam(opts *SearchTeamOptions) (TeamList, int64, error) {
+ sess := db.GetEngine(db.DefaultContext)
+
+ opts.SetDefaultValues()
+ cond := opts.toCond()
+
+ if opts.UserID > 0 {
+ sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id")
+ }
+ sess = db.SetSessionPagination(sess, opts)
+
+ teams := make([]*Team, 0, opts.PageSize)
+ count, err := sess.Where(cond).OrderBy("lower_name").FindAndCount(&teams)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ return teams, count, nil
+}
+
+// GetRepoTeams gets the list of teams that has access to the repository
+func GetRepoTeams(ctx context.Context, repo *repo_model.Repository) (teams TeamList, err error) {
+ return teams, db.GetEngine(ctx).
+ Join("INNER", "team_repo", "team_repo.team_id = team.id").
+ Where("team.org_id = ?", repo.OwnerID).
+ And("team_repo.repo_id=?", repo.ID).
+ OrderBy("CASE WHEN name LIKE '" + OwnerTeamName + "' THEN '' ELSE name END").
+ Find(&teams)
+}
+
+// GetUserOrgTeams returns all teams that user belongs to in given organization.
+func GetUserOrgTeams(ctx context.Context, orgID, userID int64) (teams TeamList, err error) {
+ return teams, db.GetEngine(ctx).
+ Join("INNER", "team_user", "team_user.team_id = team.id").
+ Where("team.org_id = ?", orgID).
+ And("team_user.uid=?", userID).
+ Find(&teams)
+}
+
+// GetUserRepoTeams returns user repo's teams
+func GetUserRepoTeams(ctx context.Context, orgID, userID, repoID int64) (teams TeamList, err error) {
+ return teams, db.GetEngine(ctx).
+ Join("INNER", "team_user", "team_user.team_id = team.id").
+ Join("INNER", "team_repo", "team_repo.team_id = team.id").
+ Where("team.org_id = ?", orgID).
+ And("team_user.uid=?", userID).
+ And("team_repo.repo_id=?", repoID).
+ Find(&teams)
+}
diff --git a/models/organization/team_user.go b/models/organization/team_user.go
index 7a024f1c6d..816daf3d34 100644
--- a/models/organization/team_user.go
+++ b/models/organization/team_user.go
@@ -72,26 +72,6 @@ func GetTeamMembers(ctx context.Context, opts *SearchMembersOptions) ([]*user_mo
return members, nil
}
-// GetUserOrgTeams returns all teams that user belongs to in given organization.
-func GetUserOrgTeams(ctx context.Context, orgID, userID int64) (teams []*Team, err error) {
- return teams, db.GetEngine(ctx).
- Join("INNER", "team_user", "team_user.team_id = team.id").
- Where("team.org_id = ?", orgID).
- And("team_user.uid=?", userID).
- Find(&teams)
-}
-
-// GetUserRepoTeams returns user repo's teams
-func GetUserRepoTeams(ctx context.Context, orgID, userID, repoID int64) (teams []*Team, err error) {
- return teams, db.GetEngine(ctx).
- Join("INNER", "team_user", "team_user.team_id = team.id").
- Join("INNER", "team_repo", "team_repo.team_id = team.id").
- Where("team.org_id = ?", orgID).
- And("team_user.uid=?", userID).
- And("team_repo.repo_id=?", repoID).
- Find(&teams)
-}
-
// IsUserInTeams returns if a user in some teams
func IsUserInTeams(ctx context.Context, userID int64, teamIDs []int64) (bool, error) {
return db.GetEngine(ctx).Where("uid=?", userID).In("team_id", teamIDs).Exist(new(TeamUser))
diff --git a/models/project/project.go b/models/project/project.go
index f432d0bc4c..8bac9115ba 100644
--- a/models/project/project.go
+++ b/models/project/project.go
@@ -8,6 +8,9 @@ import (
"fmt"
"code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
@@ -78,12 +81,15 @@ func (err ErrProjectBoardNotExist) Unwrap() error {
// Project represents a project board
type Project struct {
- ID int64 `xorm:"pk autoincr"`
- Title string `xorm:"INDEX NOT NULL"`
- Description string `xorm:"TEXT"`
- RepoID int64 `xorm:"INDEX"`
- CreatorID int64 `xorm:"NOT NULL"`
- IsClosed bool `xorm:"INDEX"`
+ ID int64 `xorm:"pk autoincr"`
+ Title string `xorm:"INDEX NOT NULL"`
+ Description string `xorm:"TEXT"`
+ OwnerID int64 `xorm:"INDEX"`
+ Owner *user_model.User `xorm:"-"`
+ RepoID int64 `xorm:"INDEX"`
+ Repo *repo_model.Repository `xorm:"-"`
+ CreatorID int64 `xorm:"NOT NULL"`
+ IsClosed bool `xorm:"INDEX"`
BoardType BoardType
Type Type
@@ -94,6 +100,46 @@ type Project struct {
ClosedDateUnix timeutil.TimeStamp
}
+func (p *Project) LoadOwner(ctx context.Context) (err error) {
+ if p.Owner != nil {
+ return nil
+ }
+ p.Owner, err = user_model.GetUserByID(ctx, p.OwnerID)
+ return err
+}
+
+func (p *Project) LoadRepo(ctx context.Context) (err error) {
+ if p.RepoID == 0 || p.Repo != nil {
+ return nil
+ }
+ p.Repo, err = repo_model.GetRepositoryByID(ctx, p.RepoID)
+ return err
+}
+
+func (p *Project) Link() string {
+ if p.OwnerID > 0 {
+ err := p.LoadOwner(db.DefaultContext)
+ if err != nil {
+ log.Error("LoadOwner: %v", err)
+ return ""
+ }
+ return fmt.Sprintf("/%s/-/projects/%d", p.Owner.Name, p.ID)
+ }
+ if p.RepoID > 0 {
+ err := p.LoadRepo(db.DefaultContext)
+ if err != nil {
+ log.Error("LoadRepo: %v", err)
+ return ""
+ }
+ return fmt.Sprintf("/%s/projects/%d", p.Repo.RepoPath(), p.ID)
+ }
+ return ""
+}
+
+func (p *Project) IsOrganizationProject() bool {
+ return p.Type == TypeOrganization
+}
+
func init() {
db.RegisterModel(new(Project))
}
@@ -110,7 +156,7 @@ func GetProjectsConfig() []ProjectsConfig {
// IsTypeValid checks if a project type is valid
func IsTypeValid(p Type) bool {
switch p {
- case TypeRepository:
+ case TypeRepository, TypeOrganization:
return true
default:
return false
@@ -119,6 +165,7 @@ func IsTypeValid(p Type) bool {
// SearchOptions are options for GetProjects
type SearchOptions struct {
+ OwnerID int64
RepoID int64
Page int
IsClosed util.OptionalBool
@@ -126,12 +173,11 @@ type SearchOptions struct {
Type Type
}
-// GetProjects returns a list of all projects that have been created in the repository
-func GetProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, error) {
- e := db.GetEngine(ctx)
- projects := make([]*Project, 0, setting.UI.IssuePagingNum)
-
- var cond builder.Cond = builder.Eq{"repo_id": opts.RepoID}
+func (opts *SearchOptions) toConds() builder.Cond {
+ cond := builder.NewCond()
+ if opts.RepoID > 0 {
+ cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
+ }
switch opts.IsClosed {
case util.OptionalBoolTrue:
cond = cond.And(builder.Eq{"is_closed": true})
@@ -142,6 +188,22 @@ func GetProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, er
if opts.Type > 0 {
cond = cond.And(builder.Eq{"type": opts.Type})
}
+ if opts.OwnerID > 0 {
+ cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
+ }
+ return cond
+}
+
+// CountProjects counts projects
+func CountProjects(ctx context.Context, opts SearchOptions) (int64, error) {
+ return db.GetEngine(ctx).Where(opts.toConds()).Count(new(Project))
+}
+
+// FindProjects returns a list of all projects that have been created in the repository
+func FindProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, error) {
+ e := db.GetEngine(ctx)
+ projects := make([]*Project, 0, setting.UI.IssuePagingNum)
+ cond := opts.toConds()
count, err := e.Where(cond).Count(new(Project))
if err != nil {
@@ -188,8 +250,10 @@ func NewProject(p *Project) error {
return err
}
- if _, err := db.Exec(ctx, "UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); err != nil {
- return err
+ if p.RepoID > 0 {
+ if _, err := db.Exec(ctx, "UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); err != nil {
+ return err
+ }
}
if err := createBoardsForProjectsType(ctx, p); err != nil {
diff --git a/models/project/project_test.go b/models/project/project_test.go
index 4fde0fc7ce..c2d9005c43 100644
--- a/models/project/project_test.go
+++ b/models/project/project_test.go
@@ -22,7 +22,7 @@ func TestIsProjectTypeValid(t *testing.T) {
}{
{TypeIndividual, false},
{TypeRepository, true},
- {TypeOrganization, false},
+ {TypeOrganization, true},
{UnknownType, false},
}
@@ -34,13 +34,13 @@ func TestIsProjectTypeValid(t *testing.T) {
func TestGetProjects(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
- projects, _, err := GetProjects(db.DefaultContext, SearchOptions{RepoID: 1})
+ projects, _, err := FindProjects(db.DefaultContext, SearchOptions{RepoID: 1})
assert.NoError(t, err)
// 1 value for this repo exists in the fixtures
assert.Len(t, projects, 1)
- projects, _, err = GetProjects(db.DefaultContext, SearchOptions{RepoID: 3})
+ projects, _, err = FindProjects(db.DefaultContext, SearchOptions{RepoID: 3})
assert.NoError(t, err)
// 1 value for this repo exists in the fixtures
diff --git a/modules/context/org.go b/modules/context/org.go
index 39df29a860..ff3a5ae7ec 100644
--- a/modules/context/org.go
+++ b/modules/context/org.go
@@ -9,7 +9,9 @@ import (
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
+ "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
)
@@ -28,6 +30,32 @@ type Organization struct {
Teams []*organization.Team
}
+func (org *Organization) CanWriteUnit(ctx *Context, unitType unit.Type) bool {
+ if ctx.Doer == nil {
+ return false
+ }
+ return org.UnitPermission(ctx, ctx.Doer.ID, unitType) >= perm.AccessModeWrite
+}
+
+func (org *Organization) UnitPermission(ctx *Context, doerID int64, unitType unit.Type) perm.AccessMode {
+ if doerID > 0 {
+ teams, err := organization.GetUserOrgTeams(ctx, org.Organization.ID, doerID)
+ if err != nil {
+ log.Error("GetUserOrgTeams: %v", err)
+ return perm.AccessModeNone
+ }
+ if len(teams) > 0 {
+ return teams.UnitMaxAccess(unitType)
+ }
+ }
+
+ if org.Organization.Visibility == structs.VisibleTypePublic {
+ return perm.AccessModeRead
+ }
+
+ return perm.AccessModeNone
+}
+
// HandleOrgAssignment handles organization assignment
func HandleOrgAssignment(ctx *Context, args ...bool) {
var (
diff --git a/routers/web/org/main_test.go b/routers/web/org/main_test.go
new file mode 100644
index 0000000000..41323a3601
--- /dev/null
+++ b/routers/web/org/main_test.go
@@ -0,0 +1,17 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package org_test
+
+import (
+ "path/filepath"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m, &unittest.TestOptions{
+ GiteaRootPath: filepath.Join("..", "..", ".."),
+ })
+}
diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go
new file mode 100644
index 0000000000..1ce44d4866
--- /dev/null
+++ b/routers/web/org/projects.go
@@ -0,0 +1,670 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package org
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ project_model "code.gitea.io/gitea/models/project"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ shared_user "code.gitea.io/gitea/routers/web/shared/user"
+ "code.gitea.io/gitea/services/forms"
+)
+
+const (
+ tplProjects base.TplName = "org/projects/list"
+ tplProjectsNew base.TplName = "org/projects/new"
+ tplProjectsView base.TplName = "org/projects/view"
+ tplGenericProjectsNew base.TplName = "user/project"
+)
+
+// MustEnableProjects check if projects are enabled in settings
+func MustEnableProjects(ctx *context.Context) {
+ if unit.TypeProjects.UnitGlobalDisabled() {
+ ctx.NotFound("EnableKanbanBoard", nil)
+ return
+ }
+}
+
+// Projects renders the home page of projects
+func Projects(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.project_board")
+
+ sortType := ctx.FormTrim("sort")
+
+ isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed"
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+
+ projects, total, err := project_model.FindProjects(ctx, project_model.SearchOptions{
+ OwnerID: ctx.ContextUser.ID,
+ Page: page,
+ IsClosed: util.OptionalBoolOf(isShowClosed),
+ SortType: sortType,
+ Type: project_model.TypeOrganization,
+ })
+ if err != nil {
+ ctx.ServerError("FindProjects", err)
+ return
+ }
+
+ opTotal, err := project_model.CountProjects(ctx, project_model.SearchOptions{
+ OwnerID: ctx.ContextUser.ID,
+ IsClosed: util.OptionalBoolOf(!isShowClosed),
+ Type: project_model.TypeOrganization,
+ })
+ if err != nil {
+ ctx.ServerError("CountProjects", err)
+ return
+ }
+
+ if isShowClosed {
+ ctx.Data["OpenCount"] = opTotal
+ ctx.Data["ClosedCount"] = total
+ } else {
+ ctx.Data["OpenCount"] = total
+ ctx.Data["ClosedCount"] = opTotal
+ }
+
+ ctx.Data["Projects"] = projects
+ shared_user.RenderUserHeader(ctx)
+
+ if isShowClosed {
+ ctx.Data["State"] = "closed"
+ } else {
+ ctx.Data["State"] = "open"
+ }
+
+ for _, project := range projects {
+ project.RenderedContent = project.Description
+ }
+
+ numPages := 0
+ if total > 0 {
+ numPages = (int(total) - 1/setting.UI.IssuePagingNum)
+ }
+
+ pager := context.NewPagination(int(total), setting.UI.IssuePagingNum, page, numPages)
+ pager.AddParam(ctx, "state", "State")
+ ctx.Data["Page"] = pager
+
+ ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
+ ctx.Data["IsShowClosed"] = isShowClosed
+ ctx.Data["PageIsViewProjects"] = true
+ ctx.Data["SortType"] = sortType
+
+ ctx.HTML(http.StatusOK, tplProjects)
+}
+
+func canWriteUnit(ctx *context.Context) bool {
+ if ctx.ContextUser.IsOrganization() {
+ return ctx.Org.CanWriteUnit(ctx, unit.TypeProjects)
+ }
+ return ctx.Doer != nil && ctx.ContextUser.ID == ctx.Doer.ID
+}
+
+// NewProject render creating a project page
+func NewProject(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.projects.new")
+ ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig()
+ ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
+ ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink()
+ shared_user.RenderUserHeader(ctx)
+ ctx.HTML(http.StatusOK, tplProjectsNew)
+}
+
+// NewProjectPost creates a new project
+func NewProjectPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.CreateProjectForm)
+ ctx.Data["Title"] = ctx.Tr("repo.projects.new")
+ shared_user.RenderUserHeader(ctx)
+
+ if ctx.HasError() {
+ ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
+ ctx.Data["PageIsViewProjects"] = true
+ ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig()
+ ctx.HTML(http.StatusOK, tplProjectsNew)
+ return
+ }
+
+ if err := project_model.NewProject(&project_model.Project{
+ OwnerID: ctx.ContextUser.ID,
+ Title: form.Title,
+ Description: form.Content,
+ CreatorID: ctx.Doer.ID,
+ BoardType: form.BoardType,
+ Type: project_model.TypeOrganization,
+ }); err != nil {
+ ctx.ServerError("NewProject", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title))
+ ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects")
+}
+
+// ChangeProjectStatus updates the status of a project between "open" and "close"
+func ChangeProjectStatus(ctx *context.Context) {
+ toClose := false
+ switch ctx.Params(":action") {
+ case "open":
+ toClose = false
+ case "close":
+ toClose = true
+ default:
+ ctx.Redirect(ctx.Repo.RepoLink + "/projects")
+ }
+ id := ctx.ParamsInt64(":id")
+
+ if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil {
+ if project_model.IsErrProjectNotExist(err) {
+ ctx.NotFound("", err)
+ } else {
+ ctx.ServerError("ChangeProjectStatusByIDAndRepoID", err)
+ }
+ return
+ }
+ ctx.Redirect(ctx.Repo.RepoLink + "/projects?state=" + url.QueryEscape(ctx.Params(":action")))
+}
+
+// DeleteProject delete a project
+func DeleteProject(ctx *context.Context) {
+ p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if project_model.IsErrProjectNotExist(err) {
+ ctx.NotFound("", nil)
+ } else {
+ ctx.ServerError("GetProjectByID", err)
+ }
+ return
+ }
+ if p.RepoID != ctx.Repo.Repository.ID {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil {
+ ctx.Flash.Error("DeleteProjectByID: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
+ }
+
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "redirect": ctx.Repo.RepoLink + "/projects",
+ })
+}
+
+// EditProject allows a project to be edited
+func EditProject(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.projects.edit")
+ ctx.Data["PageIsEditProjects"] = true
+ ctx.Data["PageIsViewProjects"] = true
+ ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
+ shared_user.RenderUserHeader(ctx)
+
+ p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if project_model.IsErrProjectNotExist(err) {
+ ctx.NotFound("", nil)
+ } else {
+ ctx.ServerError("GetProjectByID", err)
+ }
+ return
+ }
+ if p.RepoID != ctx.Repo.Repository.ID {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ ctx.Data["title"] = p.Title
+ ctx.Data["content"] = p.Description
+
+ ctx.HTML(http.StatusOK, tplProjectsNew)
+}
+
+// EditProjectPost response for editing a project
+func EditProjectPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.CreateProjectForm)
+ ctx.Data["Title"] = ctx.Tr("repo.projects.edit")
+ ctx.Data["PageIsEditProjects"] = true
+ ctx.Data["PageIsViewProjects"] = true
+ ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
+ shared_user.RenderUserHeader(ctx)
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplProjectsNew)
+ return
+ }
+
+ p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if project_model.IsErrProjectNotExist(err) {
+ ctx.NotFound("", nil)
+ } else {
+ ctx.ServerError("GetProjectByID", err)
+ }
+ return
+ }
+ if p.RepoID != ctx.Repo.Repository.ID {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ p.Title = form.Title
+ p.Description = form.Content
+ if err = project_model.UpdateProject(ctx, p); err != nil {
+ ctx.ServerError("UpdateProjects", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title))
+ ctx.Redirect(ctx.Repo.RepoLink + "/projects")
+}
+
+// ViewProject renders the project board for a project
+func ViewProject(ctx *context.Context) {
+ project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if project_model.IsErrProjectNotExist(err) {
+ ctx.NotFound("", nil)
+ } else {
+ ctx.ServerError("GetProjectByID", err)
+ }
+ return
+ }
+ if project.OwnerID != ctx.ContextUser.ID {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ boards, err := project_model.GetBoards(ctx, project.ID)
+ if err != nil {
+ ctx.ServerError("GetProjectBoards", err)
+ return
+ }
+
+ if boards[0].ID == 0 {
+ boards[0].Title = ctx.Tr("repo.projects.type.uncategorized")
+ }
+
+ issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards)
+ if err != nil {
+ ctx.ServerError("LoadIssuesOfBoards", err)
+ return
+ }
+
+ linkedPrsMap := make(map[int64][]*issues_model.Issue)
+ for _, issuesList := range issuesMap {
+ for _, issue := range issuesList {
+ var referencedIds []int64
+ for _, comment := range issue.Comments {
+ if comment.RefIssueID != 0 && comment.RefIsPull {
+ referencedIds = append(referencedIds, comment.RefIssueID)
+ }
+ }
+
+ if len(referencedIds) > 0 {
+ if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{
+ IssueIDs: referencedIds,
+ IsPull: util.OptionalBoolTrue,
+ }); err == nil {
+ linkedPrsMap[issue.ID] = linkedPrs
+ }
+ }
+ }
+ }
+
+ project.RenderedContent = project.Description
+ ctx.Data["LinkedPRs"] = linkedPrsMap
+ ctx.Data["PageIsViewProjects"] = true
+ ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
+ ctx.Data["Project"] = project
+ ctx.Data["IssuesMap"] = issuesMap
+ ctx.Data["Boards"] = boards
+ shared_user.RenderUserHeader(ctx)
+
+ ctx.HTML(http.StatusOK, tplProjectsView)
+}
+
+func getActionIssues(ctx *context.Context) []*issues_model.Issue {
+ commaSeparatedIssueIDs := ctx.FormString("issue_ids")
+ if len(commaSeparatedIssueIDs) == 0 {
+ return nil
+ }
+ issueIDs := make([]int64, 0, 10)
+ for _, stringIssueID := range strings.Split(commaSeparatedIssueIDs, ",") {
+ issueID, err := strconv.ParseInt(stringIssueID, 10, 64)
+ if err != nil {
+ ctx.ServerError("ParseInt", err)
+ return nil
+ }
+ issueIDs = append(issueIDs, issueID)
+ }
+ issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
+ if err != nil {
+ ctx.ServerError("GetIssuesByIDs", err)
+ return nil
+ }
+ // Check access rights for all issues
+ issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues)
+ prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests)
+ for _, issue := range issues {
+ if issue.RepoID != ctx.Repo.Repository.ID {
+ ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect"))
+ return nil
+ }
+ if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled {
+ ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil)
+ return nil
+ }
+ if err = issue.LoadAttributes(ctx); err != nil {
+ ctx.ServerError("LoadAttributes", err)
+ return nil
+ }
+ }
+ return issues
+}
+
+// UpdateIssueProject change an issue's project
+func UpdateIssueProject(ctx *context.Context) {
+ issues := getActionIssues(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ projectID := ctx.FormInt64("id")
+ for _, issue := range issues {
+ oldProjectID := issue.ProjectID()
+ if oldProjectID == projectID {
+ continue
+ }
+
+ if err := issues_model.ChangeProjectAssign(issue, ctx.Doer, projectID); err != nil {
+ ctx.ServerError("ChangeProjectAssign", err)
+ return
+ }
+ }
+
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "ok": true,
+ })
+}
+
+// DeleteProjectBoard allows for the deletion of a project board
+func DeleteProjectBoard(ctx *context.Context) {
+ if ctx.Doer == nil {
+ ctx.JSON(http.StatusForbidden, map[string]string{
+ "message": "Only signed in users are allowed to perform this action.",
+ })
+ return
+ }
+
+ project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if project_model.IsErrProjectNotExist(err) {
+ ctx.NotFound("", nil)
+ } else {
+ ctx.ServerError("GetProjectByID", err)
+ }
+ return
+ }
+
+ pb, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID"))
+ if err != nil {
+ ctx.ServerError("GetProjectBoard", err)
+ return
+ }
+ if pb.ProjectID != ctx.ParamsInt64(":id") {
+ ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
+ "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", pb.ID, project.ID),
+ })
+ return
+ }
+
+ if project.OwnerID != ctx.ContextUser.ID {
+ ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
+ "message": fmt.Sprintf("ProjectBoard[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID),
+ })
+ return
+ }
+
+ if err := project_model.DeleteBoardByID(ctx.ParamsInt64(":boardID")); err != nil {
+ ctx.ServerError("DeleteProjectBoardByID", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "ok": true,
+ })
+}
+
+// AddBoardToProjectPost allows a new board to be added to a project.
+func AddBoardToProjectPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.EditProjectBoardForm)
+
+ project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if project_model.IsErrProjectNotExist(err) {
+ ctx.NotFound("", nil)
+ } else {
+ ctx.ServerError("GetProjectByID", err)
+ }
+ return
+ }
+
+ if err := project_model.NewBoard(&project_model.Board{
+ ProjectID: project.ID,
+ Title: form.Title,
+ Color: form.Color,
+ CreatorID: ctx.Doer.ID,
+ }); err != nil {
+ ctx.ServerError("NewProjectBoard", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "ok": true,
+ })
+}
+
+// CheckProjectBoardChangePermissions check permission
+func CheckProjectBoardChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Board) {
+ if ctx.Doer == nil {
+ ctx.JSON(http.StatusForbidden, map[string]string{
+ "message": "Only signed in users are allowed to perform this action.",
+ })
+ return nil, nil
+ }
+
+ project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if project_model.IsErrProjectNotExist(err) {
+ ctx.NotFound("", nil)
+ } else {
+ ctx.ServerError("GetProjectByID", err)
+ }
+ return nil, nil
+ }
+
+ board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID"))
+ if err != nil {
+ ctx.ServerError("GetProjectBoard", err)
+ return nil, nil
+ }
+ if board.ProjectID != ctx.ParamsInt64(":id") {
+ ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
+ "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID),
+ })
+ return nil, nil
+ }
+
+ if project.OwnerID != ctx.ContextUser.ID {
+ ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
+ "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, project.ID),
+ })
+ return nil, nil
+ }
+ return project, board
+}
+
+// EditProjectBoard allows a project board's to be updated
+func EditProjectBoard(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.EditProjectBoardForm)
+ _, board := CheckProjectBoardChangePermissions(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ if form.Title != "" {
+ board.Title = form.Title
+ }
+
+ board.Color = form.Color
+
+ if form.Sorting != 0 {
+ board.Sorting = form.Sorting
+ }
+
+ if err := project_model.UpdateBoard(ctx, board); err != nil {
+ ctx.ServerError("UpdateProjectBoard", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "ok": true,
+ })
+}
+
+// SetDefaultProjectBoard set default board for uncategorized issues/pulls
+func SetDefaultProjectBoard(ctx *context.Context) {
+ project, board := CheckProjectBoardChangePermissions(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ if err := project_model.SetDefaultBoard(project.ID, board.ID); err != nil {
+ ctx.ServerError("SetDefaultBoard", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "ok": true,
+ })
+}
+
+// MoveIssues moves or keeps issues in a column and sorts them inside that column
+func MoveIssues(ctx *context.Context) {
+ if ctx.Doer == nil {
+ ctx.JSON(http.StatusForbidden, map[string]string{
+ "message": "Only signed in users are allowed to perform this action.",
+ })
+ return
+ }
+
+ project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+ if err != nil {
+ if project_model.IsErrProjectNotExist(err) {
+ ctx.NotFound("ProjectNotExist", nil)
+ } else {
+ ctx.ServerError("GetProjectByID", err)
+ }
+ return
+ }
+ if project.OwnerID != ctx.ContextUser.ID {
+ ctx.NotFound("InvalidRepoID", nil)
+ return
+ }
+
+ var board *project_model.Board
+
+ if ctx.ParamsInt64(":boardID") == 0 {
+ board = &project_model.Board{
+ ID: 0,
+ ProjectID: project.ID,
+ Title: ctx.Tr("repo.projects.type.uncategorized"),
+ }
+ } else {
+ board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID"))
+ if err != nil {
+ if project_model.IsErrProjectBoardNotExist(err) {
+ ctx.NotFound("ProjectBoardNotExist", nil)
+ } else {
+ ctx.ServerError("GetProjectBoard", err)
+ }
+ return
+ }
+ if board.ProjectID != project.ID {
+ ctx.NotFound("BoardNotInProject", nil)
+ return
+ }
+ }
+
+ type movedIssuesForm struct {
+ Issues []struct {
+ IssueID int64 `json:"issueID"`
+ Sorting int64 `json:"sorting"`
+ } `json:"issues"`
+ }
+
+ form := &movedIssuesForm{}
+ if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
+ ctx.ServerError("DecodeMovedIssuesForm", err)
+ }
+
+ issueIDs := make([]int64, 0, len(form.Issues))
+ sortedIssueIDs := make(map[int64]int64)
+ for _, issue := range form.Issues {
+ issueIDs = append(issueIDs, issue.IssueID)
+ sortedIssueIDs[issue.Sorting] = issue.IssueID
+ }
+ movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.NotFound("IssueNotExisting", nil)
+ } else {
+ ctx.ServerError("GetIssueByID", err)
+ }
+ return
+ }
+
+ if len(movedIssues) != len(form.Issues) {
+ ctx.ServerError("some issues do not exist", errors.New("some issues do not exist"))
+ return
+ }
+
+ if _, err = movedIssues.LoadRepositories(ctx); err != nil {
+ ctx.ServerError("LoadRepositories", err)
+ return
+ }
+
+ for _, issue := range movedIssues {
+ if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID {
+ ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID"))
+ return
+ }
+ }
+
+ if err = project_model.MoveIssuesOnProjectBoard(board, sortedIssueIDs); err != nil {
+ ctx.ServerError("MoveIssuesOnProjectBoard", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "ok": true,
+ })
+}
diff --git a/routers/web/org/projects_test.go b/routers/web/org/projects_test.go
new file mode 100644
index 0000000000..3450fa8e72
--- /dev/null
+++ b/routers/web/org/projects_test.go
@@ -0,0 +1,28 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package org_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers/web/org"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCheckProjectBoardChangePermissions(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ ctx := test.MockContext(t, "user2/-/projects/4/4")
+ test.LoadUser(t, ctx, 2)
+ ctx.ContextUser = ctx.Doer // user2
+ ctx.SetParams(":id", "4")
+ ctx.SetParams(":boardID", "4")
+
+ project, board := org.CheckProjectBoardChangePermissions(ctx)
+ assert.NotNil(t, project)
+ assert.NotNil(t, board)
+ assert.False(t, ctx.Written())
+}
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 59ab717a1d..44ac81f65d 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -363,7 +363,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
}
if ctx.Repo.CanWriteIssuesOrPulls(ctx.Params(":type") == "pulls") {
- projects, _, err := project_model.GetProjects(ctx, project_model.SearchOptions{
+ projects, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{
RepoID: repo.ID,
Type: project_model.TypeRepository,
IsClosed: util.OptionalBoolOf(isShowClosed),
@@ -474,8 +474,7 @@ func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *repo_model.R
func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) {
var err error
-
- ctx.Data["OpenProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{
+ projects, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{
RepoID: repo.ID,
Page: -1,
IsClosed: util.OptionalBoolFalse,
@@ -485,8 +484,20 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) {
ctx.ServerError("GetProjects", err)
return
}
+ projects2, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{
+ OwnerID: repo.OwnerID,
+ Page: -1,
+ IsClosed: util.OptionalBoolFalse,
+ Type: project_model.TypeOrganization,
+ })
+ if err != nil {
+ ctx.ServerError("GetProjects", err)
+ return
+ }
+
+ ctx.Data["OpenProjects"] = append(projects, projects2...)
- ctx.Data["ClosedProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{
+ projects, _, err = project_model.FindProjects(ctx, project_model.SearchOptions{
RepoID: repo.ID,
Page: -1,
IsClosed: util.OptionalBoolTrue,
@@ -496,6 +507,18 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) {
ctx.ServerError("GetProjects", err)
return
}
+ projects2, _, err = project_model.FindProjects(ctx, project_model.SearchOptions{
+ OwnerID: repo.OwnerID,
+ Page: -1,
+ IsClosed: util.OptionalBoolTrue,
+ Type: project_model.TypeOrganization,
+ })
+ if err != nil {
+ ctx.ServerError("GetProjects", err)
+ return
+ }
+
+ ctx.Data["ClosedProjects"] = append(projects, projects2...)
}
// repoReviewerSelection items to bee shown
@@ -988,7 +1011,7 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
ctx.ServerError("GetProjectByID", err)
return nil, nil, 0, 0
}
- if p.RepoID != ctx.Repo.Repository.ID {
+ if p.RepoID != ctx.Repo.Repository.ID && p.OwnerID != ctx.Repo.Repository.OwnerID {
ctx.NotFound("", nil)
return nil, nil, 0, 0
}
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 75cd290b8f..3becf799c5 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -70,7 +70,7 @@ func Projects(ctx *context.Context) {
total = repo.NumClosedProjects
}
- projects, count, err := project_model.GetProjects(ctx, project_model.SearchOptions{
+ projects, count, err := project_model.FindProjects(ctx, project_model.SearchOptions{
RepoID: repo.ID,
Page: page,
IsClosed: util.OptionalBoolOf(isShowClosed),
@@ -112,7 +112,7 @@ func Projects(ctx *context.Context) {
pager.AddParam(ctx, "state", "State")
ctx.Data["Page"] = pager
- ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
+ ctx.Data["CanWriteProjects"] = true
ctx.Data["IsShowClosed"] = isShowClosed
ctx.Data["IsProjectsPage"] = true
ctx.Data["SortType"] = sortType
@@ -653,47 +653,3 @@ func MoveIssues(ctx *context.Context) {
"ok": true,
})
}
-
-// CreateProject renders the generic project creation page
-func CreateProject(ctx *context.Context) {
- ctx.Data["Title"] = ctx.Tr("repo.projects.new")
- ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig()
- ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
-
- ctx.HTML(http.StatusOK, tplGenericProjectsNew)
-}
-
-// CreateProjectPost creates an individual and/or organization project
-func CreateProjectPost(ctx *context.Context, form forms.UserCreateProjectForm) {
- user := checkContextUser(ctx, form.UID)
- if ctx.Written() {
- return
- }
-
- ctx.Data["ContextUser"] = user
-
- if ctx.HasError() {
- ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
- ctx.HTML(http.StatusOK, tplGenericProjectsNew)
- return
- }
-
- projectType := project_model.TypeIndividual
- if user.IsOrganization() {
- projectType = project_model.TypeOrganization
- }
-
- if err := project_model.NewProject(&project_model.Project{
- Title: form.Title,
- Description: form.Content,
- CreatorID: user.ID,
- BoardType: form.BoardType,
- Type: projectType,
- }); err != nil {
- ctx.ServerError("NewProject", err)
- return
- }
-
- ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title))
- ctx.Redirect(setting.AppSubURL + "/")
-}
diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go
new file mode 100644
index 0000000000..94e59e2a49
--- /dev/null
+++ b/routers/web/shared/user/header.go
@@ -0,0 +1,14 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func RenderUserHeader(ctx *context.Context) {
+ ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
+ ctx.Data["ContextUser"] = ctx.ContextUser
+}
diff --git a/routers/web/user/package.go b/routers/web/user/package.go
index c0aba7583f..ed4f0dd797 100644
--- a/routers/web/user/package.go
+++ b/routers/web/user/package.go
@@ -19,6 +19,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
+ shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/forms"
packages_service "code.gitea.io/gitea/services/packages"
)
@@ -83,10 +84,10 @@ func ListPackages(ctx *context.Context) {
return
}
+ shared_user.RenderUserHeader(ctx)
+
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["IsPackagesPage"] = true
- ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
- ctx.Data["ContextUser"] = ctx.ContextUser
ctx.Data["Query"] = query
ctx.Data["PackageType"] = packageType
ctx.Data["AvailableTypes"] = packages_model.TypeList
@@ -156,10 +157,10 @@ func RedirectToLastVersion(ctx *context.Context) {
func ViewPackageVersion(ctx *context.Context) {
pd := ctx.Package.Descriptor
+ shared_user.RenderUserHeader(ctx)
+
ctx.Data["Title"] = pd.Package.Name
ctx.Data["IsPackagesPage"] = true
- ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
- ctx.Data["ContextUser"] = ctx.ContextUser
ctx.Data["PackageDescriptor"] = pd
var (
@@ -235,10 +236,10 @@ func ListPackageVersions(ctx *context.Context) {
query := ctx.FormTrim("q")
sort := ctx.FormTrim("sort")
+ shared_user.RenderUserHeader(ctx)
+
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["IsPackagesPage"] = true
- ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
- ctx.Data["ContextUser"] = ctx.ContextUser
ctx.Data["PackageDescriptor"] = &packages_model.PackageDescriptor{
Package: p,
Owner: ctx.Package.Owner,
@@ -311,10 +312,10 @@ func ListPackageVersions(ctx *context.Context) {
func PackageSettings(ctx *context.Context) {
pd := ctx.Package.Descriptor
+ shared_user.RenderUserHeader(ctx)
+
ctx.Data["Title"] = pd.Package.Name
ctx.Data["IsPackagesPage"] = true
- ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
- ctx.Data["ContextUser"] = ctx.ContextUser
ctx.Data["PackageDescriptor"] = pd
repos, _, _ := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
index 0002d56de0..0e342991d6 100644
--- a/routers/web/user/profile.go
+++ b/routers/web/user/profile.go
@@ -224,7 +224,7 @@ func Profile(ctx *context.Context) {
total = int(count)
case "projects":
- ctx.Data["OpenProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{
+ ctx.Data["OpenProjects"], _, err = project_model.FindProjects(ctx, project_model.SearchOptions{
Page: -1,
IsClosed: util.OptionalBoolFalse,
Type: project_model.TypeIndividual,
diff --git a/routers/web/web.go b/routers/web/web.go
index f0fedd0715..d37d82820d 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -835,6 +835,46 @@ func RegisterRoutes(m *web.Route) {
})
}, ignSignIn, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead))
}
+
+ m.Group("/projects", func() {
+ m.Get("", org.Projects)
+ m.Get("/{id}", org.ViewProject)
+ m.Group("", func() { //nolint:dupl
+ m.Get("/new", org.NewProject)
+ m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
+ m.Group("/{id}", func() {
+ m.Post("", web.Bind(forms.EditProjectBoardForm{}), org.AddBoardToProjectPost)
+ m.Post("/delete", org.DeleteProject)
+
+ m.Get("/edit", org.EditProject)
+ m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost)
+ m.Post("/{action:open|close}", org.ChangeProjectStatus)
+
+ m.Group("/{boardID}", func() {
+ m.Put("", web.Bind(forms.EditProjectBoardForm{}), org.EditProjectBoard)
+ m.Delete("", org.DeleteProjectBoard)
+ m.Post("/default", org.SetDefaultProjectBoard)
+
+ m.Post("/move", org.MoveIssues)
+ })
+ })
+ }, reqSignIn, func(ctx *context.Context) {
+ if ctx.ContextUser == nil {
+ ctx.NotFound("NewProject", nil)
+ return
+ }
+ if ctx.ContextUser.IsOrganization() {
+ if !ctx.Org.CanWriteUnit(ctx, unit.TypeProjects) {
+ ctx.NotFound("NewProject", nil)
+ return
+ }
+ } else if ctx.ContextUser.ID != ctx.Doer.ID {
+ ctx.NotFound("NewProject", nil)
+ return
+ }
+ })
+ }, repo.MustEnableProjects)
+
m.Get("/code", user.CodeSearch)
}, context_service.UserAssignmentWeb())
@@ -1168,7 +1208,7 @@ func RegisterRoutes(m *web.Route) {
m.Group("/projects", func() {
m.Get("", repo.Projects)
m.Get("/{id}", repo.ViewProject)
- m.Group("", func() {
+ m.Group("", func() { //nolint:dupl
m.Get("/new", repo.NewProject)
m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost)
m.Group("/{id}", func() {
diff --git a/services/context/user.go b/services/context/user.go
index 9dc84c3ac1..7642cba4e1 100644
--- a/services/context/user.go
+++ b/services/context/user.go
@@ -8,6 +8,7 @@ import (
"net/http"
"strings"
+ org_model "code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/context"
)
@@ -56,6 +57,14 @@ func userAssignment(ctx *context.Context, errCb func(int, string, interface{}))
} else {
errCb(http.StatusInternalServerError, "GetUserByName", err)
}
+ } else {
+ if ctx.ContextUser.IsOrganization() {
+ if ctx.Org == nil {
+ ctx.Org = &context.Organization{}
+ }
+ ctx.Org.Organization = (*org_model.Organization)(ctx.ContextUser)
+ ctx.Data["Org"] = ctx.Org.Organization
+ }
}
}
}
diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl
index 87242b94d3..5f543424fc 100644
--- a/templates/org/menu.tmpl
+++ b/templates/org/menu.tmpl
@@ -3,6 +3,9 @@
<a class="{{if .PageIsViewRepositories}}active {{end}}item" href="{{$.Org.HomeLink}}">
{{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}}
</a>
+ <a class="{{if .PageIsViewProjects}}active {{end}}item" href="{{$.Org.HomeLink}}/-/projects">
+ {{svg "octicon-project"}} {{.locale.Tr "user.projects"}}
+ </a>
{{if .IsPackageEnabled}}
<a class="item" href="{{$.Org.HomeLink}}/-/packages">
{{svg "octicon-package"}} {{.locale.Tr "packages.title"}}
diff --git a/templates/org/projects/list.tmpl b/templates/org/projects/list.tmpl
new file mode 100644
index 0000000000..544ed38742
--- /dev/null
+++ b/templates/org/projects/list.tmpl
@@ -0,0 +1,6 @@
+{{template "base/head" .}}
+<div class="page-content repository packages">
+ {{template "user/overview/header" .}}
+ {{template "projects/list" .}}
+</div>
+{{template "base/footer" .}}
diff --git a/templates/org/projects/new.tmpl b/templates/org/projects/new.tmpl
new file mode 100644
index 0000000000..b3d6c6001e
--- /dev/null
+++ b/templates/org/projects/new.tmpl
@@ -0,0 +1,6 @@
+{{template "base/head" .}}
+<div class="page-content repository packages">
+ {{template "user/overview/header" .}}
+ {{template "projects/new" .}}
+</div>
+{{template "base/footer" .}}
diff --git a/templates/org/projects/view.tmpl b/templates/org/projects/view.tmpl
new file mode 100644
index 0000000000..03327e2530
--- /dev/null
+++ b/templates/org/projects/view.tmpl
@@ -0,0 +1,6 @@
+{{template "base/head" .}}
+<div class="page-content repository packages">
+ {{template "user/overview/header" .}}
+ {{template "projects/view" .}}
+</div>
+{{template "base/footer" .}}
diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl
new file mode 100644
index 0000000000..ae2eaec6ea
--- /dev/null
+++ b/templates/projects/list.tmpl
@@ -0,0 +1,98 @@
+<div class="page-content repository projects">
+ <div class="ui container">
+ {{if .CanWriteProjects}}
+ <div class="navbar">
+ <div class="ui right">
+ <a class="ui green button" href="{{$.Link}}/new">{{.locale.Tr "repo.projects.new"}}</a>
+ </div>
+ </div>
+ <div class="ui divider"></div>
+ {{end}}
+
+ {{template "base/alert" .}}
+ <div class="ui compact tiny menu">
+ <a class="item{{if not .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=open">
+ {{svg "octicon-project" 16 "mr-3"}}
+ {{JsPrettyNumber .OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}}
+ </a>
+ <a class="item{{if .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=closed">
+ {{svg "octicon-check" 16 "mr-3"}}
+ {{JsPrettyNumber .ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}}
+ </a>
+ </div>
+
+ <div class="ui right floated secondary filter menu">
+ <!-- Sort -->
+ <div class="ui dropdown type jump item">
+ <span class="text">
+ {{.locale.Tr "repo.issues.filter_sort"}}
+ {{svg "octicon-triangle-down" 14 "dropdown icon"}}
+ </span>
+ <div class="menu">
+ <a class="{{if eq .SortType "oldest"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&sort=oldest&state={{$.State}}">{{.locale.Tr "repo.issues.filter_sort.oldest"}}</a>
+ <a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&sort=recentupdate&state={{$.State}}">{{.locale.Tr "repo.issues.filter_sort.recentupdate"}}</a>
+ <a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&sort=leastupdate&state={{$.State}}">{{.locale.Tr "repo.issues.filter_sort.leastupdate"}}</a>
+ </div>
+ </div>
+ </div>
+ <div class="milestone list">
+ {{range .Projects}}
+ <li class="item">
+ {{svg "octicon-project"}} <a href="{{$.Link}}/{{.ID}}">{{.Title}}</a>
+ <div class="meta">
+ {{$closedDate:= TimeSinceUnix .ClosedDateUnix $.locale}}
+ {{if .IsClosed}}
+ {{svg "octicon-clock"}} {{$.locale.Tr "repo.milestones.closed" $closedDate|Str2html}}
+ {{end}}
+ <span class="issue-stats">
+ {{svg "octicon-issue-opened" 16 "mr-3"}}
+ {{JsPrettyNumber .NumOpenIssues}}&nbsp;{{$.locale.Tr "repo.issues.open_title"}}
+ {{svg "octicon-check" 16 "mr-3"}}
+ {{JsPrettyNumber .NumClosedIssues}}&nbsp;{{$.locale.Tr "repo.issues.closed_title"}}
+ </span>
+ </div>
+ {{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}}
+ <div class="ui right operate">
+ <a href="{{$.Link}}/{{.ID}}/edit" data-id={{.ID}} data-title={{.Title}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a>
+ {{if .IsClosed}}
+ <a class="link-action" href data-url="{{$.Link}}/{{.ID}}/open">{{svg "octicon-check"}} {{$.locale.Tr "repo.projects.open"}}</a>
+ {{else}}
+ <a class="link-action" href data-url="{{$.Link}}/{{.ID}}/close">{{svg "octicon-skip"}} {{$.locale.Tr "repo.projects.close"}}</a>
+ {{end}}
+ <a class="delete-button" href="#" data-url="{{$.RepoLink}}/projects/{{.ID}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a>
+ </div>
+ {{end}}
+ {{if .Description}}
+ <div class="content">
+ {{.RenderedContent|Str2html}}
+ </div>
+ {{end}}
+ </li>
+ {{end}}
+
+ {{template "base/paginate" .}}
+ </div>
+ </div>
+</div>
+
+{{if or .CanWriteIssues .CanWritePulls}}
+<div class="ui small basic delete modal">
+ <div class="ui icon header">
+ {{svg "octicon-trash"}}
+ {{.locale.Tr "repo.projects.deletion"}}
+ </div>
+ <div class="content">
+ <p>{{.locale.Tr "repo.projects.deletion_desc"}}</p>
+ </div>
+ <div class="actions">
+ <div class="ui red basic inverted cancel button">
+ <i class="remove icon"></i>
+ {{.locale.Tr "modal.no"}}
+ </div>
+ <div class="ui green basic inverted ok button">
+ <i class="checkmark icon"></i>
+ {{.locale.Tr "modal.yes"}}
+ </div>
+ </div>
+</div>
+{{end}}
diff --git a/templates/projects/new.tmpl b/templates/projects/new.tmpl
new file mode 100644
index 0000000000..1069102792
--- /dev/null
+++ b/templates/projects/new.tmpl
@@ -0,0 +1,66 @@
+<div class="page-content repository projects edit-project new milestone">
+ <div class="ui container">
+ <div class="navbar">
+ {{if and .CanWriteProjects .PageIsEditProject}}
+ <div class="ui right floated secondary menu">
+ <a class="ui green button" href="{{$.HomeLink}}/-/projects/new">{{.locale.Tr "repo.milestones.new"}}</a>
+ </div>
+ {{end}}
+ </div>
+ <div class="ui divider"></div>
+ <h2 class="ui dividing header">
+ {{if .PageIsEditProjects}}
+ {{.locale.Tr "repo.projects.edit"}}
+ <div class="sub header">{{.locale.Tr "repo.projects.edit_subheader"}}</div>
+ {{else}}
+ {{.locale.Tr "repo.projects.new"}}
+ <div class="sub header">{{.locale.Tr "repo.projects.new_subheader"}}</div>
+ {{end}}
+ </h2>
+ {{template "base/alert" .}}
+ <form class="ui form grid" action="{{.Link}}" method="post">
+ {{.CsrfTokenHtml}}
+ <div class="eleven wide column">
+ <div class="field {{if .Err_Title}}error{{end}}">
+ <label>{{.locale.Tr "repo.projects.title"}}</label>
+ <input name="title" placeholder="{{.locale.Tr "repo.projects.title"}}" value="{{.title}}" autofocus required>
+ </div>
+ <div class="field">
+ <label>{{.locale.Tr "repo.projects.description"}}</label>
+ <textarea name="content" placeholder="{{.locale.Tr "repo.projects.description_placeholder"}}">{{.content}}</textarea>
+ </div>
+
+ {{if not .PageIsEditProjects}}
+ <label>{{.locale.Tr "repo.projects.template.desc"}}</label>
+ <div class="ui selection dropdown">
+ <input type="hidden" name="board_type" value="{{.type}}">
+ <div class="default text">{{.locale.Tr "repo.projects.template.desc_helper"}}</div>
+ <div class="menu">
+ {{range $element := .ProjectTypes}}
+ <div class="item" data-id="{{$element.BoardType}}" data-value="{{$element.BoardType}}">{{$.locale.Tr $element.Translation}}</div>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ </div>
+ <div class="ui container">
+ <div class="ui divider"></div>
+ <div class="ui left">
+ {{if .PageIsEditProjects}}
+ <a class="ui primary basic button" href="{{.RepoLink}}/projects">
+ {{.locale.Tr "repo.milestones.cancel"}}
+ </a>
+ <button class="ui green button">
+ {{.locale.Tr "repo.projects.modify"}}
+ </button>
+ {{else}}
+ <button class="ui green button">
+ {{.locale.Tr "repo.projects.create"}}
+ </button>
+ {{end}}
+ </div>
+ </div>
+
+ </form>
+ </div>
+</div>
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
new file mode 100644
index 0000000000..ac72acb82b
--- /dev/null
+++ b/templates/projects/view.tmpl
@@ -0,0 +1,279 @@
+<div class="page-content repository projects view-project">
+ <div class="ui container">
+ <div class="ui two column stackable grid">
+ <div class="column">
+ </div>
+ <div class="column right aligned">
+ {{if .CanWriteProjects}}
+ <a class="ui green button show-modal item" data-modal="#new-board-item">{{.locale.Tr "new_project_board"}}</a>
+ {{end}}
+ <div class="ui small modal new-board-modal" id="new-board-item">
+ <div class="header">
+ {{$.locale.Tr "repo.projects.board.new"}}
+ </div>
+ <div class="content">
+ <form class="ui form">
+ <div class="required field">
+ <label for="new_board">{{$.locale.Tr "repo.projects.board.new_title"}}</label>
+ <input class="new-board" id="new_board" name="title" required>
+ </div>
+
+ <div class="field color-field">
+ <label for="new_board_color">{{$.locale.Tr "repo.projects.board.color"}}</label>
+ <div class="color picker column">
+ <input class="color-picker" maxlength="7" placeholder="#c320f6" id="new_board_color_picker" name="color">
+ <div class="column precolors">
+ {{template "repo/issue/label_precolors"}}
+ </div>
+ </div>
+ </div>
+
+ <div class="text right actions">
+ <div class="ui cancel button">{{$.locale.Tr "settings.cancel"}}</div>
+ <button data-url="{{$.Link}}" class="ui green button" id="new_board_submit">{{$.locale.Tr "repo.projects.board.new_submit"}}</button>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="ui divider"></div>
+ <div class="ui two column stackable grid">
+ <div class="column">
+ <h2 class="project-title">{{$.Project.Title}}</h2>
+ <div class="content project-description">{{$.Project.RenderedContent|Str2html}}</div>
+ </div>
+ {{if or $.CanWriteIssues $.CanWritePulls}}
+ <div class="column right aligned">
+ <div class="ui compact right small menu">
+ <a class="item" href="{{$.Link}}/edit" data-id={{$.Project.ID}} data-title={{$.Project.Title}}>
+ {{svg "octicon-pencil"}}
+ <span class="mx-3">{{$.locale.Tr "repo.issues.label_edit"}}</span>
+ </a>
+ {{if .Project.IsClosed}}
+ <a class="item link-action" href data-url="{{$.Link}}/open">
+ {{svg "octicon-check"}}
+ <span class="mx-3">{{$.locale.Tr "repo.projects.open"}}</span>
+ </a>
+ {{else}}
+ <a class="item link-action" href data-url="{{$.Link}}/close">
+ {{svg "octicon-skip"}}
+ <span class="mx-3">{{$.locale.Tr "repo.projects.close"}}</span>
+ </a>
+ {{end}}
+ <a class="item delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.Project.ID}}">
+ {{svg "octicon-trash"}}
+ <span class="mx-3">{{$.locale.Tr "repo.issues.label_delete"}}</span>
+ </a>
+ </div>
+ </div>
+ {{end}}
+ </div>
+ <div class="ui divider"></div>
+ </div>
+ <div class="ui container fluid padded" id="project-board">
+
+ <div class="board">
+ {{range $board := .Boards}}
+
+ <div class="ui segment board-column" style="background: {{.Color}} !important;" data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
+ <div class="board-column-header df ac sb">
+ <div class="ui large label board-label py-2">
+ <div class="ui small circular grey label board-card-cnt">
+ {{.NumIssues}}
+ </div>
+ {{.Title}}
+ </div>
+ {{if and $.CanWriteProjects (ne .ID 0)}}
+ <div class="ui dropdown jump item tooltip">
+ <div class="not-mobile px-3" tabindex="-1">
+ {{svg "octicon-kebab-horizontal"}}
+ </div>
+ <div class="menu user-menu" tabindex="-1">
+ <a class="item show-modal button" data-modal="#edit-project-board-modal-{{.ID}}">
+ {{svg "octicon-pencil"}}
+ {{$.locale.Tr "repo.projects.board.edit"}}
+ </a>
+ {{if not .Default}}
+ <a class="item show-modal button" data-modal="#set-default-project-board-modal-{{.ID}}">
+ {{svg "octicon-pin"}}
+ {{$.locale.Tr "repo.projects.board.set_default"}}
+ </a>
+ {{end}}
+ <a class="item show-modal button" data-modal="#delete-board-modal-{{.ID}}">
+ {{svg "octicon-trash"}}
+ {{$.locale.Tr "repo.projects.board.delete"}}
+ </a>
+
+ <div class="ui small modal edit-project-board" id="edit-project-board-modal-{{.ID}}">
+ <div class="header">
+ {{$.locale.Tr "repo.projects.board.edit"}}
+ </div>
+ <div class="content">
+ <form class="ui form">
+ <div class="required field">
+ <label for="new_board_title">{{$.locale.Tr "repo.projects.board.edit_title"}}</label>
+ <input class="project-board-title" id="new_board_title" name="title" value="{{.Title}}" required>
+ </div>
+
+ <div class="field color-field">
+ <label for="new_board_color">{{$.locale.Tr "repo.projects.board.color"}}</label>
+ <div class="color picker column">
+ <input class="color-picker" maxlength="7" placeholder="#c320f6" id="new_board_color" name="color" value="{{.Color}}">
+ <div class="column precolors">
+ {{template "repo/issue/label_precolors"}}
+ </div>
+ </div>
+ </div>
+
+ <div class="text right actions">
+ <div class="ui cancel button">{{$.locale.Tr "settings.cancel"}}</div>
+ <button data-url="{{$.Link}}/{{.ID}}" class="ui red button">{{$.locale.Tr "repo.projects.board.edit"}}</button>
+ </div>
+ </form>
+ </div>
+ </div>
+
+ <div class="ui basic modal" id="set-default-project-board-modal-{{.ID}}">
+ <div class="ui icon header">
+ {{$.locale.Tr "repo.projects.board.set_default"}}
+ </div>
+ <div class="content center">
+ <label>
+ {{$.locale.Tr "repo.projects.board.set_default_desc"}}
+ </label>
+ </div>
+ <div class="text right actions">
+ <div class="ui cancel button">{{$.locale.Tr "settings.cancel"}}</div>
+ <button class="ui red button set-default-project-board" data-url="{{$.Link}}/{{.ID}}/default">{{$.locale.Tr "repo.projects.board.set_default"}}</button>
+ </div>
+ </div>
+
+ <div class="ui basic modal" id="delete-board-modal-{{.ID}}">
+ <div class="ui icon header">
+ {{$.locale.Tr "repo.projects.board.delete"}}
+ </div>
+ <div class="content center">
+ <label>
+ {{$.locale.Tr "repo.projects.board.deletion_desc"}}
+ </label>
+ </div>
+ <div class="text right actions">
+ <div class="ui cancel button">{{$.locale.Tr "settings.cancel"}}</div>
+ <button class="ui red button delete-project-board" data-url="{{$.Link}}/{{.ID}}">{{$.locale.Tr "repo.projects.board.delete"}}</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ {{end}}
+ </div>
+ <div class="ui divider"></div>
+
+ <div class="ui cards board" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}">
+
+ {{range (index $.IssuesMap .ID)}}
+
+ <!-- start issue card -->
+ <div class="card board-card" data-issue="{{.ID}}">
+ <div class="content p-0">
+ <div class="header">
+ <span class="dif ac vm {{if .IsClosed}}red{{else}}green{{end}}">
+ {{if .IsPull}}
+ {{if .PullRequest.HasMerged}}
+ {{svg "octicon-git-merge" 16 "text purple"}}
+ {{else}}
+ {{if .IsClosed}}
+ {{svg "octicon-git-pull-request" 16 "text red"}}
+ {{else}}
+ {{svg "octicon-git-pull-request" 16 "text green"}}
+ {{end}}
+ {{end}}
+ {{else}}
+ {{if .IsClosed}}
+ {{svg "octicon-issue-closed" 16 "text red"}}
+ {{else}}
+ {{svg "octicon-issue-opened" 16 "text green"}}
+ {{end}}
+ {{end}}
+ </span>
+ <a class="project-board-title vm" href="{{.Link}}">
+ {{.Title}}
+ </a>
+ </div>
+ <div class="meta my-2">
+ <span class="text light grey">
+ {{.Repo.FullName}}#{{.Index}}
+ {{$timeStr := TimeSinceUnix .GetLastEventTimestamp $.locale}}
+ {{if .OriginalAuthor}}
+ {{$.locale.Tr .GetLastEventLabelFake $timeStr (.OriginalAuthor|Escape) | Safe}}
+ {{else if gt .Poster.ID 0}}
+ {{$.locale.Tr .GetLastEventLabel $timeStr (.Poster.HomeLink|Escape) (.Poster.GetDisplayName | Escape) | Safe}}
+ {{else}}
+ {{$.locale.Tr .GetLastEventLabelFake $timeStr (.Poster.GetDisplayName | Escape) | Safe}}
+ {{end}}
+ </span>
+ </div>
+ {{- if .MilestoneID}}
+ <div class="meta my-2">
+ <a class="milestone" href="{{$.RepoLink}}/milestone/{{.MilestoneID}}">
+ {{svg "octicon-milestone" 16 "mr-2 vm"}}
+ <span class="vm">{{.Milestone.Name}}</span>
+ </a>
+ </div>
+ {{- end}}
+ {{- range index $.LinkedPRs .ID}}
+ <div class="meta my-2">
+ <a href="{{$.RepoLink}}/pulls/{{.Index}}">
+ <span class="m-0 {{if .PullRequest.HasMerged}}purple{{else if .IsClosed}}red{{else}}green{{end}}">{{svg "octicon-git-merge" 16 "mr-2 vm"}}</span>
+ <span class="vm">{{.Title}} <span class="text light grey">#{{.Index}}</span></span>
+ </a>
+ </div>
+ {{- end}}
+ </div>
+
+ {{if or .Labels .Assignees}}
+ <div class="extra content labels-list p-0 pt-2">
+ {{range .Labels}}
+ <a class="ui label" target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}};" title="{{.Description | RenderEmojiPlain}}">{{.Name | RenderEmoji}}</a>
+ {{end}}
+ <div class="right floated">
+ {{range .Assignees}}
+ <a class="tooltip" target="_blank" href="{{.HTMLURL}}" data-content="{{$.locale.Tr "repo.projects.board.assigned_to"}} {{.Name}}">{{avatar . 28 "mini mr-3"}}</a>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ </div>
+ <!-- stop issue card -->
+
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ </div>
+
+ </div>
+
+</div>
+
+{{if or .CanWriteIssues .CanWritePulls}}
+ <div class="ui small basic delete modal">
+ <div class="ui icon header">
+ {{svg "octicon-trash"}}
+ {{.locale.Tr "repo.projects.deletion"}}
+ </div>
+ <div class="content">
+ <p>{{.locale.Tr "repo.projects.deletion_desc"}}</p>
+ </div>
+ <div class="actions">
+ <div class="ui red basic inverted cancel button">
+ <i class="remove icon"></i>
+ {{.locale.Tr "modal.no"}}
+ </div>
+ <div class="ui green basic inverted ok button">
+ <i class="checkmark icon"></i>
+ {{.locale.Tr "modal.yes"}}
+ </div>
+ </div>
+ </div>
+{{end}}
diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl
index 6cb00fdd1d..ca947e3612 100644
--- a/templates/repo/issue/view_content/sidebar.tmpl
+++ b/templates/repo/issue/view_content/sidebar.tmpl
@@ -219,8 +219,8 @@
{{.locale.Tr "repo.issues.new.open_projects"}}
</div>
{{range .OpenProjects}}
- <a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{$.RepoLink}}/projects/{{.ID}}">
- {{svg "octicon-project" 18 "mr-3"}}
+ <a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link}}">
+ {{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}}
{{.Title}}
</a>
{{end}}
@@ -231,8 +231,8 @@
{{.locale.Tr "repo.issues.new.closed_projects"}}
</div>
{{range .ClosedProjects}}
- <a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{$.RepoLink}}/projects/{{.ID}}">
- {{svg "octicon-project" 18 "mr-3"}}
+ <a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link}}">
+ {{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}}
{{.Title}}
</a>
{{end}}
@@ -243,8 +243,8 @@
<span class="no-select item {{if .Issue.ProjectID}}hide{{end}}">{{.locale.Tr "repo.issues.new.no_projects"}}</span>
<div class="selected">
{{if .Issue.ProjectID}}
- <a class="item muted sidebar-item-link" href="{{.RepoLink}}/projects/{{.Issue.ProjectID}}">
- {{svg "octicon-project" 18 "mr-3"}}
+ <a class="item muted sidebar-item-link" href="{{.Issue.Project.Link}}">
+ {{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}}
{{.Issue.Project.Title}}
</a>
{{end}}
diff --git a/templates/user/overview/header.tmpl b/templates/user/overview/header.tmpl
index 61b19c6032..8fb882718c 100644
--- a/templates/user/overview/header.tmpl
+++ b/templates/user/overview/header.tmpl
@@ -22,6 +22,9 @@
<a class="item" href="{{.ContextUser.HomeLink}}">
{{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}}
</a>
+ <a href="{{.ContextUser.HomeLink}}/-/projects" class="{{if .PageIsViewProjects}}active {{end}}item">
+ {{svg "octicon-project"}} {{.locale.Tr "user.projects"}}
+ </a>
{{if (not .UnitPackagesGlobalDisabled)}}
<a href="{{.ContextUser.HomeLink}}/-/packages" class="{{if .IsPackagesPage}}active {{end}}item">
{{svg "octicon-package"}} {{.locale.Tr "packages.title"}}
diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl
index 6c31723e0f..74211eb67b 100644
--- a/templates/user/profile.tmpl
+++ b/templates/user/profile.tmpl
@@ -106,6 +106,9 @@
<a class='{{if and (ne .TabName "activity") (ne .TabName "following") (ne .TabName "followers") (ne .TabName "stars") (ne .TabName "watching") (ne .TabName "projects") (ne .TabName "code")}}active {{end}}item' href="{{.Owner.HomeLink}}">
{{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}}
</a>
+ <a href="{{.Owner.HomeLink}}/-/projects" class="{{if eq .TabName "projects"}}active {{end}}item">
+ {{svg "octicon-project"}} {{.locale.Tr "user.projects"}}
+ </a>
{{if .IsPackageEnabled}}
<a class='{{if eq .TabName "packages"}}active {{end}}item' href="{{.Owner.HomeLink}}/-/packages">
{{svg "octicon-package"}} {{.locale.Tr "packages.title"}}