Merge pull request '[gitea] week 2024-20-v7.0 cherry pick (release/v1.22 -> v7.0/forgejo)' (#3772) from earl-warren/wcp/2024-20-v7.0 into v7.0/forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/3772
Reviewed-by: Beowulf <beowulf@noreply.codeberg.org>
This commit is contained in:
Earl Warren 2024-05-16 15:36:08 +00:00
commit 4ecbb2ef1b
53 changed files with 948 additions and 509 deletions

View file

@ -179,7 +179,6 @@ package "code.gitea.io/gitea/modules/git"
func (ErrExecTimeout).Error func (ErrExecTimeout).Error
func (ErrUnsupportedVersion).Error func (ErrUnsupportedVersion).Error
func SetUpdateHook func SetUpdateHook
func GetObjectFormatOfRepo
func openRepositoryWithDefaultContext func openRepositoryWithDefaultContext
func IsTagExist func IsTagExist
func ToEntryMode func ToEntryMode
@ -315,8 +314,6 @@ package "code.gitea.io/gitea/routers/web"
package "code.gitea.io/gitea/routers/web/org" package "code.gitea.io/gitea/routers/web/org"
func MustEnableProjects func MustEnableProjects
func getActionIssues
func UpdateIssueProject
package "code.gitea.io/gitea/services/context" package "code.gitea.io/gitea/services/context"
func GetPrivateContext func GetPrivateContext

View file

@ -366,6 +366,7 @@ Forgejo or set your environment appropriately.`, "")
isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki)) isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
repoName := os.Getenv(repo_module.EnvRepoName) repoName := os.Getenv(repo_module.EnvRepoName)
pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
pusherName := os.Getenv(repo_module.EnvPusherName) pusherName := os.Getenv(repo_module.EnvPusherName)
hookOptions := private.HookOptions{ hookOptions := private.HookOptions{
@ -375,6 +376,8 @@ Forgejo or set your environment appropriately.`, "")
GitObjectDirectory: os.Getenv(private.GitObjectDirectory), GitObjectDirectory: os.Getenv(private.GitObjectDirectory),
GitQuarantinePath: os.Getenv(private.GitQuarantinePath), GitQuarantinePath: os.Getenv(private.GitQuarantinePath),
GitPushOptions: pushOptions(), GitPushOptions: pushOptions(),
PullRequestID: prID,
PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)),
} }
oldCommitIDs := make([]string, hookBatchSize) oldCommitIDs := make([]string, hookBatchSize)
newCommitIDs := make([]string, hookBatchSize) newCommitIDs := make([]string, hookBatchSize)

View file

@ -34,7 +34,7 @@ var CmdMigrateStorage = &cli.Command{
Name: "type", Name: "type",
Aliases: []string{"t"}, Aliases: []string{"t"},
Value: "", Value: "",
Usage: "Type of stored files to copy. Allowed types: 'attachments', 'lfs', 'avatars', 'repo-avatars', 'repo-archivers', 'packages', 'actions-log'", Usage: "Type of stored files to copy. Allowed types: 'attachments', 'lfs', 'avatars', 'repo-avatars', 'repo-archivers', 'packages', 'actions-log', 'actions-artifacts",
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: "storage", Name: "storage",
@ -160,6 +160,13 @@ func migrateActionsLog(ctx context.Context, dstStorage storage.ObjectStorage) er
}) })
} }
func migrateActionsArtifacts(ctx context.Context, dstStorage storage.ObjectStorage) error {
return db.Iterate(ctx, nil, func(ctx context.Context, artifact *actions_model.ActionArtifact) error {
_, err := storage.Copy(dstStorage, artifact.ArtifactPath, storage.ActionsArtifacts, artifact.ArtifactPath)
return err
})
}
func runMigrateStorage(ctx *cli.Context) error { func runMigrateStorage(ctx *cli.Context) error {
stdCtx, cancel := installSignals() stdCtx, cancel := installSignals()
defer cancel() defer cancel()
@ -230,6 +237,7 @@ func runMigrateStorage(ctx *cli.Context) error {
"repo-archivers": migrateRepoArchivers, "repo-archivers": migrateRepoArchivers,
"packages": migratePackages, "packages": migratePackages,
"actions-log": migrateActionsLog, "actions-log": migrateActionsLog,
"actions-artifacts": migrateActionsArtifacts,
} }
tp := strings.ToLower(ctx.String("type")) tp := strings.ToLower(ctx.String("type"))

View file

@ -59,6 +59,7 @@ type Engine interface {
SumInt(bean any, columnName string) (res int64, err error) SumInt(bean any, columnName string) (res int64, err error)
Sync(...any) error Sync(...any) error
Select(string) *xorm.Session Select(string) *xorm.Session
SetExpr(string, any) *xorm.Session
NotIn(string, ...any) *xorm.Session NotIn(string, ...any) *xorm.Session
OrderBy(any, ...any) *xorm.Session OrderBy(any, ...any) *xorm.Session
Exist(...any) (bool, error) Exist(...any) (bool, error)

View file

@ -5,11 +5,11 @@ package issues
import ( import (
"context" "context"
"fmt"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
project_model "code.gitea.io/gitea/models/project" project_model "code.gitea.io/gitea/models/project"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/util"
) )
// LoadProject load the project the issue was assigned to // LoadProject load the project the issue was assigned to
@ -90,22 +90,10 @@ func LoadIssuesFromBoardList(ctx context.Context, bs project_model.BoardList) (m
return issuesMap, nil return issuesMap, nil
} }
// ChangeProjectAssign changes the project associated with an issue // IssueAssignOrRemoveProject changes the project associated with an issue
func ChangeProjectAssign(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error { // If newProjectID is 0, the issue is removed from the project
ctx, committer, err := db.TxContext(ctx) func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error {
if err != nil { return db.WithTx(ctx, func(ctx context.Context) error {
return err
}
defer committer.Close()
if err := addUpdateIssueProject(ctx, issue, doer, newProjectID); err != nil {
return err
}
return committer.Commit()
}
func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error {
oldProjectID := issue.projectID(ctx) oldProjectID := issue.projectID(ctx)
if err := issue.LoadRepo(ctx); err != nil { if err := issue.LoadRepo(ctx); err != nil {
@ -118,8 +106,15 @@ func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.U
if err != nil { if err != nil {
return err return err
} }
if newProject.RepoID != issue.RepoID && newProject.OwnerID != issue.Repo.OwnerID { if !newProject.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) {
return fmt.Errorf("issue's repository is not the same as project's repository") return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID)
}
if newColumnID == 0 {
newDefaultColumn, err := newProject.GetDefaultBoard(ctx)
if err != nil {
return err
}
newColumnID = newDefaultColumn.ID
} }
} }
@ -139,9 +134,29 @@ func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.U
return err return err
} }
} }
if newProjectID == 0 {
return nil
}
if newColumnID == 0 {
panic("newColumnID must not be zero") // shouldn't happen
}
res := struct {
MaxSorting int64
IssueCount int64
}{}
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue").
Where("project_id=?", newProjectID).
And("project_board_id=?", newColumnID).
Get(&res); err != nil {
return err
}
newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
return db.Insert(ctx, &project_model.ProjectIssue{ return db.Insert(ctx, &project_model.ProjectIssue{
IssueID: issue.ID, IssueID: issue.ID,
ProjectID: newProjectID, ProjectID: newProjectID,
ProjectBoardID: newColumnID,
Sorting: newSorting,
})
}) })
} }

View file

@ -5,12 +5,14 @@ package project
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"regexp" "regexp"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder" "xorm.io/builder"
) )
@ -82,6 +84,17 @@ func (b *Board) NumIssues(ctx context.Context) int {
return int(c) return int(c)
} }
func (b *Board) GetIssues(ctx context.Context) ([]*ProjectIssue, error) {
issues := make([]*ProjectIssue, 0, 5)
if err := db.GetEngine(ctx).Where("project_id=?", b.ProjectID).
And("project_board_id=?", b.ID).
OrderBy("sorting, id").
Find(&issues); err != nil {
return nil, err
}
return issues, nil
}
func init() { func init() {
db.RegisterModel(new(Board)) db.RegisterModel(new(Board))
} }
@ -152,12 +165,27 @@ func createBoardsForProjectsType(ctx context.Context, project *Project) error {
return db.Insert(ctx, boards) return db.Insert(ctx, boards)
} }
// maxProjectColumns max columns allowed in a project, this should not bigger than 127
// because sorting is int8 in database
const maxProjectColumns = 20
// NewBoard adds a new project board to a given project // NewBoard adds a new project board to a given project
func NewBoard(ctx context.Context, board *Board) error { func NewBoard(ctx context.Context, board *Board) error {
if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) { if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
return fmt.Errorf("bad color code: %s", board.Color) return fmt.Errorf("bad color code: %s", board.Color)
} }
res := struct {
MaxSorting int64
ColumnCount int64
}{}
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as column_count").Table("project_board").
Where("project_id=?", board.ProjectID).Get(&res); err != nil {
return err
}
if res.ColumnCount >= maxProjectColumns {
return fmt.Errorf("NewBoard: maximum number of columns reached")
}
board.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0))
_, err := db.GetEngine(ctx).Insert(board) _, err := db.GetEngine(ctx).Insert(board)
return err return err
} }
@ -191,7 +219,17 @@ func deleteBoardByID(ctx context.Context, boardID int64) error {
return fmt.Errorf("deleteBoardByID: cannot delete default board") return fmt.Errorf("deleteBoardByID: cannot delete default board")
} }
if err = board.removeIssues(ctx); err != nil { // move all issues to the default column
project, err := GetProjectByID(ctx, board.ProjectID)
if err != nil {
return err
}
defaultColumn, err := project.GetDefaultBoard(ctx)
if err != nil {
return err
}
if err = board.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil {
return err return err
} }
@ -244,21 +282,15 @@ func UpdateBoard(ctx context.Context, board *Board) error {
// GetBoards fetches all boards related to a project // GetBoards fetches all boards related to a project
func (p *Project) GetBoards(ctx context.Context) (BoardList, error) { func (p *Project) GetBoards(ctx context.Context) (BoardList, error) {
boards := make([]*Board, 0, 5) boards := make([]*Board, 0, 5)
if err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Find(&boards); err != nil {
if err := db.GetEngine(ctx).Where("project_id=? AND `default`=?", p.ID, false).OrderBy("sorting").Find(&boards); err != nil {
return nil, err return nil, err
} }
defaultB, err := p.getDefaultBoard(ctx) return boards, nil
if err != nil {
return nil, err
}
return append([]*Board{defaultB}, boards...), nil
} }
// getDefaultBoard return default board and ensure only one exists // GetDefaultBoard return default board and ensure only one exists
func (p *Project) getDefaultBoard(ctx context.Context) (*Board, error) { func (p *Project) GetDefaultBoard(ctx context.Context) (*Board, error) {
var board Board var board Board
has, err := db.GetEngine(ctx). has, err := db.GetEngine(ctx).
Where("project_id=? AND `default` = ?", p.ID, true). Where("project_id=? AND `default` = ?", p.ID, true).
@ -318,3 +350,42 @@ func UpdateBoardSorting(ctx context.Context, bs BoardList) error {
return nil return nil
}) })
} }
func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (BoardList, error) {
columns := make([]*Board, 0, 5)
if err := db.GetEngine(ctx).
Where("project_id =?", projectID).
In("id", columnsIDs).
OrderBy("sorting").Find(&columns); err != nil {
return nil, err
}
return columns, nil
}
// MoveColumnsOnProject sorts columns in a project
func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
sess := db.GetEngine(ctx)
columnIDs := util.ValuesOfMap(sortedColumnIDs)
movedColumns, err := GetColumnsByIDs(ctx, project.ID, columnIDs)
if err != nil {
return err
}
if len(movedColumns) != len(sortedColumnIDs) {
return errors.New("some columns do not exist")
}
for _, column := range movedColumns {
if column.ProjectID != project.ID {
return fmt.Errorf("column[%d]'s projectID is not equal to project's ID [%d]", column.ProjectID, project.ID)
}
}
for sorting, columnID := range sortedColumnIDs {
if _, err := sess.Exec("UPDATE `project_board` SET sorting=? WHERE id=?", sorting, columnID); err != nil {
return err
}
}
return nil
})
}

View file

@ -4,6 +4,8 @@
package project package project
import ( import (
"fmt"
"strings"
"testing" "testing"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
@ -19,7 +21,7 @@ func TestGetDefaultBoard(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// check if default board was added // check if default board was added
board, err := projectWithoutDefault.getDefaultBoard(db.DefaultContext) board, err := projectWithoutDefault.GetDefaultBoard(db.DefaultContext)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(5), board.ProjectID) assert.Equal(t, int64(5), board.ProjectID)
assert.Equal(t, "Uncategorized", board.Title) assert.Equal(t, "Uncategorized", board.Title)
@ -28,7 +30,7 @@ func TestGetDefaultBoard(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// check if multiple defaults were removed // check if multiple defaults were removed
board, err = projectWithMultipleDefaults.getDefaultBoard(db.DefaultContext) board, err = projectWithMultipleDefaults.GetDefaultBoard(db.DefaultContext)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(6), board.ProjectID) assert.Equal(t, int64(6), board.ProjectID)
assert.Equal(t, int64(9), board.ID) assert.Equal(t, int64(9), board.ID)
@ -42,3 +44,84 @@ func TestGetDefaultBoard(t *testing.T) {
assert.Equal(t, int64(6), board.ProjectID) assert.Equal(t, int64(6), board.ProjectID)
assert.False(t, board.Default) assert.False(t, board.Default)
} }
func Test_moveIssuesToAnotherColumn(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
column1 := unittest.AssertExistsAndLoadBean(t, &Board{ID: 1, ProjectID: 1})
issues, err := column1.GetIssues(db.DefaultContext)
assert.NoError(t, err)
assert.Len(t, issues, 1)
assert.EqualValues(t, 1, issues[0].ID)
column2 := unittest.AssertExistsAndLoadBean(t, &Board{ID: 2, ProjectID: 1})
issues, err = column2.GetIssues(db.DefaultContext)
assert.NoError(t, err)
assert.Len(t, issues, 1)
assert.EqualValues(t, 3, issues[0].ID)
err = column1.moveIssuesToAnotherColumn(db.DefaultContext, column2)
assert.NoError(t, err)
issues, err = column1.GetIssues(db.DefaultContext)
assert.NoError(t, err)
assert.Len(t, issues, 0)
issues, err = column2.GetIssues(db.DefaultContext)
assert.NoError(t, err)
assert.Len(t, issues, 2)
assert.EqualValues(t, 3, issues[0].ID)
assert.EqualValues(t, 0, issues[0].Sorting)
assert.EqualValues(t, 1, issues[1].ID)
assert.EqualValues(t, 1, issues[1].Sorting)
}
func Test_MoveColumnsOnProject(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
columns, err := project1.GetBoards(db.DefaultContext)
assert.NoError(t, err)
assert.Len(t, columns, 3)
assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work
assert.EqualValues(t, 0, columns[1].Sorting)
assert.EqualValues(t, 0, columns[2].Sorting)
err = MoveColumnsOnProject(db.DefaultContext, project1, map[int64]int64{
0: columns[1].ID,
1: columns[2].ID,
2: columns[0].ID,
})
assert.NoError(t, err)
columnsAfter, err := project1.GetBoards(db.DefaultContext)
assert.NoError(t, err)
assert.Len(t, columnsAfter, 3)
assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID)
assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID)
assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID)
}
func Test_NewBoard(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
columns, err := project1.GetBoards(db.DefaultContext)
assert.NoError(t, err)
assert.Len(t, columns, 3)
for i := 0; i < maxProjectColumns-3; i++ {
err := NewBoard(db.DefaultContext, &Board{
Title: fmt.Sprintf("board-%d", i+4),
ProjectID: project1.ID,
})
assert.NoError(t, err)
}
err = NewBoard(db.DefaultContext, &Board{
Title: "board-21",
ProjectID: project1.ID,
})
assert.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "maximum number of columns reached"))
}

View file

@ -9,6 +9,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
) )
// ProjectIssue saves relation from issue to a project // ProjectIssue saves relation from issue to a project
@ -17,7 +18,7 @@ type ProjectIssue struct { //revive:disable-line:exported
IssueID int64 `xorm:"INDEX"` IssueID int64 `xorm:"INDEX"`
ProjectID int64 `xorm:"INDEX"` ProjectID int64 `xorm:"INDEX"`
// If 0, then it has not been added to a specific board in the project // ProjectBoardID should not be zero since 1.22. If it's zero, the issue will not be displayed on UI and it might result in errors.
ProjectBoardID int64 `xorm:"INDEX"` ProjectBoardID int64 `xorm:"INDEX"`
// the sorting order on the board // the sorting order on the board
@ -79,11 +80,8 @@ func (p *Project) NumOpenIssues(ctx context.Context) int {
func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs map[int64]int64) error { func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs map[int64]int64) error {
return db.WithTx(ctx, func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error {
sess := db.GetEngine(ctx) sess := db.GetEngine(ctx)
issueIDs := util.ValuesOfMap(sortedIssueIDs)
issueIDs := make([]int64, 0, len(sortedIssueIDs))
for _, issueID := range sortedIssueIDs {
issueIDs = append(issueIDs, issueID)
}
count, err := sess.Table(new(ProjectIssue)).Where("project_id=?", board.ProjectID).In("issue_id", issueIDs).Count() count, err := sess.Table(new(ProjectIssue)).Where("project_id=?", board.ProjectID).In("issue_id", issueIDs).Count()
if err != nil { if err != nil {
return err return err
@ -102,7 +100,44 @@ func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs
}) })
} }
func (b *Board) removeIssues(ctx context.Context) error { func (b *Board) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Board) error {
_, err := db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id = 0 WHERE project_board_id = ? ", b.ID) if b.ProjectID != newColumn.ProjectID {
return fmt.Errorf("columns have to be in the same project")
}
if b.ID == newColumn.ID {
return nil
}
res := struct {
MaxSorting int64
IssueCount int64
}{}
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").
Table("project_issue").
Where("project_id=?", newColumn.ProjectID).
And("project_board_id=?", newColumn.ID).
Get(&res); err != nil {
return err return err
}
issues, err := b.GetIssues(ctx)
if err != nil {
return err
}
if len(issues) == 0 {
return nil
}
nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
return db.WithTx(ctx, func(ctx context.Context) error {
for i, issue := range issues {
issue.ProjectBoardID = newColumn.ID
issue.Sorting = nextSorting + int64(i)
if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil {
return err
}
}
return nil
})
} }

View file

@ -161,6 +161,13 @@ func (p *Project) IsRepositoryProject() bool {
return p.Type == TypeRepository return p.Type == TypeRepository
} }
func (p *Project) CanBeAccessedByOwnerRepo(ownerID int64, repo *repo_model.Repository) bool {
if p.Type == TypeRepository {
return repo != nil && p.RepoID == repo.ID // if a project belongs to a repository, then its OwnerID is 0 and can be ignored
}
return p.OwnerID == ownerID && p.RepoID == 0
}
func init() { func init() {
db.RegisterModel(new(Project)) db.RegisterModel(new(Project))
} }

View file

@ -8,14 +8,14 @@ import "code.gitea.io/gitea/models/db"
// SearchOrderByMap represents all possible search order // SearchOrderByMap represents all possible search order
var SearchOrderByMap = map[string]map[string]db.SearchOrderBy{ var SearchOrderByMap = map[string]map[string]db.SearchOrderBy{
"asc": { "asc": {
"alpha": db.SearchOrderByAlphabetically, "alpha": "owner_name ASC, name ASC",
"created": db.SearchOrderByOldest, "created": db.SearchOrderByOldest,
"updated": db.SearchOrderByLeastUpdated, "updated": db.SearchOrderByLeastUpdated,
"size": db.SearchOrderBySize, "size": db.SearchOrderBySize,
"id": db.SearchOrderByID, "id": db.SearchOrderByID,
}, },
"desc": { "desc": {
"alpha": db.SearchOrderByAlphabeticallyReverse, "alpha": "owner_name DESC, name DESC",
"created": db.SearchOrderByNewest, "created": db.SearchOrderByNewest,
"updated": db.SearchOrderByRecentUpdated, "updated": db.SearchOrderByRecentUpdated,
"size": db.SearchOrderBySizeReverse, "size": db.SearchOrderBySizeReverse,

View file

@ -7,7 +7,6 @@ package git
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"net/url" "net/url"
@ -63,32 +62,6 @@ func IsRepoURLAccessible(ctx context.Context, url string) bool {
return err == nil return err == nil
} }
// GetObjectFormatOfRepo returns the hash type of repository at a given path
func GetObjectFormatOfRepo(ctx context.Context, repoPath string) (ObjectFormat, error) {
var stdout, stderr strings.Builder
err := NewCommand(ctx, "hash-object", "--stdin").Run(&RunOpts{
Dir: repoPath,
Stdout: &stdout,
Stderr: &stderr,
Stdin: &strings.Reader{},
})
if err != nil {
return nil, err
}
if stderr.Len() > 0 {
return nil, errors.New(stderr.String())
}
h, err := NewIDFromString(strings.TrimRight(stdout.String(), "\n"))
if err != nil {
return nil, err
}
return h.Type(), nil
}
// InitRepository initializes a new Git repository. // InitRepository initializes a new Git repository.
func InitRepository(ctx context.Context, repoPath string, bare bool, objectFormatName string) error { func InitRepository(ctx context.Context, repoPath string, bare bool, objectFormatName string) error {
err := os.MkdirAll(repoPath, os.ModePerm) err := os.MkdirAll(repoPath, os.ModePerm)

View file

@ -11,6 +11,7 @@ import (
"time" "time"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
) )
@ -53,6 +54,7 @@ type HookOptions struct {
GitQuarantinePath string GitQuarantinePath string
GitPushOptions GitPushOptions GitPushOptions GitPushOptions
PullRequestID int64 PullRequestID int64
PushTrigger repository.PushTrigger
DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user. DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user.
IsWiki bool IsWiki bool
ActionPerm int ActionPerm int

View file

@ -5,6 +5,7 @@ package repository
import ( import (
"context" "context"
"fmt"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
@ -36,6 +37,15 @@ func SyncRepoBranches(ctx context.Context, repoID, doerID int64) (int64, error)
} }
func SyncRepoBranchesWithRepo(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doerID int64) (int64, error) { func SyncRepoBranchesWithRepo(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doerID int64) (int64, error) {
objFmt, err := gitRepo.GetObjectFormat()
if err != nil {
return 0, fmt.Errorf("GetObjectFormat: %w", err)
}
_, err = db.GetEngine(ctx).ID(repo.ID).Update(&repo_model.Repository{ObjectFormatName: objFmt.Name()})
if err != nil {
return 0, fmt.Errorf("UpdateRepository: %w", err)
}
allBranches := container.Set[string]{} allBranches := container.Set[string]{}
{ {
branches, _, err := gitRepo.GetBranchNames(0, 0) branches, _, err := gitRepo.GetBranchNames(0, 0)

View file

@ -0,0 +1,31 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repository
import (
"testing"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"github.com/stretchr/testify/assert"
)
func TestSyncRepoBranches(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
_, err := db.GetEngine(db.DefaultContext).ID(1).Update(&repo_model.Repository{ObjectFormatName: "bad-fmt"})
assert.NoError(t, db.TruncateBeans(db.DefaultContext, &git_model.Branch{}))
assert.NoError(t, err)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
assert.Equal(t, "bad-fmt", repo.ObjectFormatName)
_, err = SyncRepoBranches(db.DefaultContext, 1, 0)
assert.NoError(t, err)
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
assert.Equal(t, "sha1", repo.ObjectFormatName)
branch, err := git_model.GetBranch(db.DefaultContext, 1, "master")
assert.NoError(t, err)
assert.EqualValues(t, "master", branch.Name)
}

View file

@ -25,11 +25,19 @@ const (
EnvKeyID = "GITEA_KEY_ID" // public key ID EnvKeyID = "GITEA_KEY_ID" // public key ID
EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID" EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID"
EnvPRID = "GITEA_PR_ID" EnvPRID = "GITEA_PR_ID"
EnvPushTrigger = "GITEA_PUSH_TRIGGER"
EnvIsInternal = "GITEA_INTERNAL_PUSH" EnvIsInternal = "GITEA_INTERNAL_PUSH"
EnvAppURL = "GITEA_ROOT_URL" EnvAppURL = "GITEA_ROOT_URL"
EnvActionPerm = "GITEA_ACTION_PERM" EnvActionPerm = "GITEA_ACTION_PERM"
) )
type PushTrigger string
const (
PushTriggerPRMergeToBase PushTrigger = "pr-merge-to-base"
PushTriggerPRUpdateWithBase PushTrigger = "pr-update-with-base"
)
// InternalPushingEnvironment returns an os environment to switch off hooks on push // InternalPushingEnvironment returns an os environment to switch off hooks on push
// It is recommended to avoid using this unless you are pushing within a transaction // It is recommended to avoid using this unless you are pushing within a transaction
// or if you absolutely are sure that post-receive and pre-receive will do nothing // or if you absolutely are sure that post-receive and pre-receive will do nothing

View file

@ -53,13 +53,13 @@ func NewFuncMap() template.FuncMap {
"JsonUtils": NewJsonUtils, "JsonUtils": NewJsonUtils,
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// svg / avatar / icon // svg / avatar / icon / color
"svg": svg.RenderHTML, "svg": svg.RenderHTML,
"EntryIcon": base.EntryIcon, "EntryIcon": base.EntryIcon,
"MigrationIcon": MigrationIcon, "MigrationIcon": MigrationIcon,
"ActionIcon": ActionIcon, "ActionIcon": ActionIcon,
"SortArrow": SortArrow, "SortArrow": SortArrow,
"ContrastColor": util.ContrastColor,
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// time / number / format // time / number / format

View file

@ -135,16 +135,9 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string)
func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML { func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
var ( var (
archivedCSSClass string archivedCSSClass string
textColor = "#111" textColor = util.ContrastColor(label.Color)
labelScope = label.ExclusiveScope() labelScope = label.ExclusiveScope()
) )
r, g, b := util.HexToRBGColor(label.Color)
// Determine if label text should be light or dark to be readable on background color
// this doesn't account for saturation or transparency
if util.UseLightTextOnBackground(r, g, b) {
textColor = "#eee"
}
description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
@ -168,7 +161,7 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m
// Make scope and item background colors slightly darker and lighter respectively. // Make scope and item background colors slightly darker and lighter respectively.
// More contrast needed with higher luminance, empirically tweaked. // More contrast needed with higher luminance, empirically tweaked.
luminance := util.GetLuminance(r, g, b) luminance := util.GetRelativeLuminance(label.Color)
contrast := 0.01 + luminance*0.03 contrast := 0.01 + luminance*0.03
// Ensure we add the same amount of contrast also near 0 and 1. // Ensure we add the same amount of contrast also near 0 and 1.
darken := contrast + math.Max(luminance+contrast-1.0, 0.0) darken := contrast + math.Max(luminance+contrast-1.0, 0.0)
@ -178,6 +171,7 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m
lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0) lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0)
opacity := GetLabelOpacityByte(label.IsArchived()) opacity := GetLabelOpacityByte(label.IsArchived())
r, g, b := util.HexToRBGColor(label.Color)
scopeBytes := []byte{ scopeBytes := []byte{
uint8(math.Min(math.Round(r*darkenFactor), 255)), uint8(math.Min(math.Round(r*darkenFactor), 255)),
uint8(math.Min(math.Round(g*darkenFactor), 255)), uint8(math.Min(math.Round(g*darkenFactor), 255)),

View file

@ -4,22 +4,10 @@ package util
import ( import (
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
) )
// Check similar implementation in web_src/js/utils/color.js and keep synchronization
// Return R, G, B values defined in reletive luminance
func getLuminanceRGB(channel float64) float64 {
sRGB := channel / 255
if sRGB <= 0.03928 {
return sRGB / 12.92
}
return math.Pow((sRGB+0.055)/1.055, 2.4)
}
// Get color as RGB values in 0..255 range from the hex color string (with or without #) // Get color as RGB values in 0..255 range from the hex color string (with or without #)
func HexToRBGColor(colorString string) (float64, float64, float64) { func HexToRBGColor(colorString string) (float64, float64, float64) {
hexString := colorString hexString := colorString
@ -47,19 +35,23 @@ func HexToRBGColor(colorString string) (float64, float64, float64) {
return r, g, b return r, g, b
} }
// return luminance given RGB channels // Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
// Reference from: https://www.w3.org/WAI/GL/wiki/Relative_luminance // Keep this in sync with web_src/js/utils/color.js
func GetLuminance(r, g, b float64) float64 { func GetRelativeLuminance(color string) float64 {
R := getLuminanceRGB(r) r, g, b := HexToRBGColor(color)
G := getLuminanceRGB(g) return (0.2126729*r + 0.7151522*g + 0.0721750*b) / 255
B := getLuminanceRGB(b)
luminance := 0.2126*R + 0.7152*G + 0.0722*B
return luminance
} }
// Reference from: https://firsching.ch/github_labels.html func UseLightText(backgroundColor string) bool {
// In the future WCAG 3 APCA may be a better solution. return GetRelativeLuminance(backgroundColor) < 0.453
// Check if text should use light color based on RGB of background }
func UseLightTextOnBackground(r, g, b float64) bool {
return GetLuminance(r, g, b) < 0.453 // Given a background color, returns a black or white foreground color that the highest
// contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better.
// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
func ContrastColor(backgroundColor string) string {
if UseLightText(backgroundColor) {
return "#fff"
}
return "#000"
} }

View file

@ -33,33 +33,31 @@ func Test_HexToRBGColor(t *testing.T) {
} }
} }
func Test_UseLightTextOnBackground(t *testing.T) { func Test_UseLightText(t *testing.T) {
cases := []struct { cases := []struct {
r float64 color string
g float64 expected string
b float64
expected bool
}{ }{
{215, 58, 74, true}, {"#d73a4a", "#fff"},
{0, 117, 202, true}, {"#0075ca", "#fff"},
{207, 211, 215, false}, {"#cfd3d7", "#000"},
{162, 238, 239, false}, {"#a2eeef", "#000"},
{112, 87, 255, true}, {"#7057ff", "#fff"},
{0, 134, 114, true}, {"#008672", "#fff"},
{228, 230, 105, false}, {"#e4e669", "#000"},
{216, 118, 227, true}, {"#d876e3", "#000"},
{255, 255, 255, false}, {"#ffffff", "#000"},
{43, 134, 133, true}, {"#2b8684", "#fff"},
{43, 135, 134, true}, {"#2b8786", "#fff"},
{44, 135, 134, true}, {"#2c8786", "#000"},
{59, 182, 179, true}, {"#3bb6b3", "#000"},
{124, 114, 104, true}, {"#7c7268", "#fff"},
{126, 113, 108, true}, {"#7e716c", "#fff"},
{129, 112, 109, true}, {"#81706d", "#fff"},
{128, 112, 112, true}, {"#807070", "#fff"},
{"#84b6eb", "#000"},
} }
for n, c := range cases { for n, c := range cases {
result := UseLightTextOnBackground(c.r, c.g, c.b) assert.Equal(t, c.expected, ContrastColor(c.color), "case %d: error should match", n)
assert.Equal(t, c.expected, result, "case %d: error should match", n)
} }
} }

View file

@ -4,20 +4,26 @@
package private package private
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
pull_model "code.gitea.io/gitea/models/pull"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/private" "code.gitea.io/gitea/modules/private"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
timeutil "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
gitea_context "code.gitea.io/gitea/services/context" gitea_context "code.gitea.io/gitea/services/context"
@ -157,6 +163,14 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
} }
} }
// handle pull request merging, a pull request action should push at least 1 commit
if opts.PushTrigger == repo_module.PushTriggerPRMergeToBase {
handlePullRequestMerging(ctx, opts, ownerName, repoName, updates)
if ctx.Written() {
return
}
}
// Handle Push Options // Handle Push Options
if len(opts.GitPushOptions) > 0 { if len(opts.GitPushOptions) > 0 {
// load the repository // load the repository
@ -304,3 +318,52 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
RepoWasEmpty: wasEmpty, RepoWasEmpty: wasEmpty,
}) })
} }
func loadContextCacheUser(ctx context.Context, id int64) (*user_model.User, error) {
return cache.GetWithContextCache(ctx, "hook_post_receive_user", id, func() (*user_model.User, error) {
return user_model.GetUserByID(ctx, id)
})
}
// handlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit
func handlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, ownerName, repoName string, updates []*repo_module.PushUpdateOptions) {
if len(updates) == 0 {
ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
Err: fmt.Sprintf("Pushing a merged PR (pr:%d) no commits pushed ", opts.PullRequestID),
})
return
}
pr, err := issues_model.GetPullRequestByID(ctx, opts.PullRequestID)
if err != nil {
log.Error("GetPullRequestByID[%d]: %v", opts.PullRequestID, err)
ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "GetPullRequestByID failed"})
return
}
pusher, err := loadContextCacheUser(ctx, opts.UserID)
if err != nil {
log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err)
ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Load pusher user failed"})
return
}
pr.MergedCommitID = updates[len(updates)-1].NewCommitID
pr.MergedUnix = timeutil.TimeStampNow()
pr.Merger = pusher
pr.MergerID = pusher.ID
err = db.WithTx(ctx, func(ctx context.Context) error {
// Removing an auto merge pull and ignore if not exist
if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) {
return fmt.Errorf("DeleteScheduledAutoMerge[%d]: %v", opts.PullRequestID, err)
}
if _, err := pr.SetMerged(ctx); err != nil {
return fmt.Errorf("SetMerged failed: %s/%s Error: %v", ownerName, repoName, err)
}
return nil
})
if err != nil {
log.Error("Failed to update PR to merged: %v", err)
ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Failed to update PR to merged"})
}
}

View file

@ -0,0 +1,49 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package private
import (
"testing"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
pull_model "code.gitea.io/gitea/models/pull"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/private"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestHandlePullRequestMerging(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
pr, err := issues_model.GetUnmergedPullRequest(db.DefaultContext, 1, 1, "branch2", "master", issues_model.PullRequestFlowGithub)
assert.NoError(t, err)
assert.NoError(t, pr.LoadBaseRepo(db.DefaultContext))
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
err = pull_model.ScheduleAutoMerge(db.DefaultContext, user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr")
assert.NoError(t, err)
autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID})
ctx, resp := contexttest.MockPrivateContext(t, "/")
handlePullRequestMerging(ctx, &private.HookOptions{
PullRequestID: pr.ID,
UserID: 2,
}, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, []*repo_module.PushUpdateOptions{
{NewCommitID: "01234567"},
})
assert.Equal(t, 0, len(resp.Body.String()))
pr, err = issues_model.GetPullRequestByID(db.DefaultContext, pr.ID)
assert.NoError(t, err)
assert.True(t, pr.HasMerged)
assert.EqualValues(t, "01234567", pr.MergedCommitID)
unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{ID: autoMerge.ID})
}

View file

@ -71,9 +71,9 @@ func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) {
case "leastupdate": case "leastupdate":
orderBy = db.SearchOrderByLeastUpdated orderBy = db.SearchOrderByLeastUpdated
case "reversealphabetically": case "reversealphabetically":
orderBy = db.SearchOrderByAlphabeticallyReverse orderBy = "owner_name DESC, name DESC"
case "alphabetically": case "alphabetically":
orderBy = db.SearchOrderByAlphabetically orderBy = "owner_name ASC, name ASC"
case "reversesize": case "reversesize":
orderBy = db.SearchOrderBySizeReverse orderBy = db.SearchOrderBySizeReverse
case "size": case "size":

View file

@ -7,7 +7,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
@ -390,74 +389,6 @@ func ViewProject(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplProjectsView) ctx.HTML(http.StatusOK, tplProjectsView)
} }
func getActionIssues(ctx *context.Context) issues_model.IssueList {
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
}
if err := issues.LoadProjects(ctx); err != nil {
ctx.ServerError("LoadProjects", err)
return
}
projectID := ctx.FormInt64("id")
for _, issue := range issues {
if issue.Project != nil {
if issue.Project.ID == projectID {
continue
}
}
if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil {
ctx.ServerError("ChangeProjectAssign", err)
return
}
}
ctx.JSONOK()
}
// DeleteProjectBoard allows for the deletion of a project board // DeleteProjectBoard allows for the deletion of a project board
func DeleteProjectBoard(ctx *context.Context) { func DeleteProjectBoard(ctx *context.Context) {
if ctx.Doer == nil { if ctx.Doer == nil {

View file

@ -1267,8 +1267,8 @@ func NewIssuePost(ctx *context.Context) {
ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects") ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects")
return return
} }
if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil { if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil {
ctx.ServerError("ChangeProjectAssign", err) ctx.ServerError("IssueAssignOrRemoveProject", err)
return return
} }
} }

View file

@ -21,6 +21,7 @@ import (
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
@ -382,17 +383,21 @@ func UpdateIssueProject(ctx *context.Context) {
ctx.ServerError("LoadProjects", err) ctx.ServerError("LoadProjects", err)
return return
} }
if _, err := issues.LoadRepositories(ctx); err != nil {
ctx.ServerError("LoadProjects", err)
return
}
projectID := ctx.FormInt64("id") projectID := ctx.FormInt64("id")
for _, issue := range issues { for _, issue := range issues {
if issue.Project != nil { if issue.Project != nil && issue.Project.ID == projectID {
if issue.Project.ID == projectID {
continue continue
} }
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil {
if errors.Is(err, util.ErrPermissionDenied) {
continue
} }
ctx.ServerError("IssueAssignOrRemoveProject", err)
if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil {
ctx.ServerError("ChangeProjectAssign", err)
return return
} }
} }

View file

@ -1541,14 +1541,12 @@ func CompareAndPullRequestPost(ctx *context.Context) {
return return
} }
if projectID > 0 { if projectID > 0 && ctx.Repo.CanWrite(unit.TypeProjects) {
if !ctx.Repo.CanWrite(unit.TypeProjects) { if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0); err != nil {
ctx.Error(http.StatusBadRequest, "user hasn't the permission to write to projects") if !errors.Is(err, util.ErrPermissionDenied) {
ctx.ServerError("IssueAssignOrRemoveProject", err)
return return
} }
if err := issues_model.ChangeProjectAssign(ctx, pullIssue, ctx.Doer, projectID); err != nil {
ctx.ServerError("ChangeProjectAssign", err)
return
} }
} }

View file

@ -798,6 +798,7 @@ func SettingsPost(ctx *context.Context) {
ctx.Repo.GitRepo = nil ctx.Repo.GitRepo = nil
} }
oldFullname := repo.FullName()
if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, repo, nil); err != nil { if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, repo, nil); err != nil {
if errors.Is(err, user_model.ErrBlockedByUser) { if errors.Is(err, user_model.ErrBlockedByUser) {
ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_blocked_doer"), tplSettingsOptions, nil) ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_blocked_doer"), tplSettingsOptions, nil)
@ -812,8 +813,13 @@ func SettingsPost(ctx *context.Context) {
return return
} }
if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer {
log.Trace("Repository transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner) log.Trace("Repository transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner)
ctx.Flash.Success(ctx.Tr("repo.settings.transfer_started", newOwner.DisplayName())) ctx.Flash.Success(ctx.Tr("repo.settings.transfer_started", newOwner.DisplayName()))
} else {
log.Trace("Repository transferred: %s -> %s", oldFullname, ctx.Repo.Repository.FullName())
ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed"))
}
ctx.Redirect(repo.Link() + "/settings") ctx.Redirect(repo.Link() + "/settings")
case "cancel_transfer": case "cancel_transfer":

View file

@ -0,0 +1,48 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/services/context"
)
// MoveColumns moves or keeps columns in a project and sorts them inside that project
func MoveColumns(ctx *context.Context) {
project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) {
ctx.NotFound("CanBeAccessedByOwnerRepo", nil)
return
}
type movedColumnsForm struct {
Columns []struct {
ColumnID int64 `json:"columnID"`
Sorting int64 `json:"sorting"`
} `json:"columns"`
}
form := &movedColumnsForm{}
if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
ctx.ServerError("DecodeMovedColumnsForm", err)
return
}
sortedColumnIDs := make(map[int64]int64)
for _, column := range form.Columns {
sortedColumnIDs[column.Sorting] = column.ColumnID
}
if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil {
ctx.ServerError("MoveColumnsOnProject", err)
return
}
ctx.JSONOK()
}

View file

@ -39,6 +39,7 @@ import (
"code.gitea.io/gitea/routers/web/repo/badges" "code.gitea.io/gitea/routers/web/repo/badges"
repo_flags "code.gitea.io/gitea/routers/web/repo/flags" repo_flags "code.gitea.io/gitea/routers/web/repo/flags"
repo_setting "code.gitea.io/gitea/routers/web/repo/setting" repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
"code.gitea.io/gitea/routers/web/shared/project"
"code.gitea.io/gitea/routers/web/user" "code.gitea.io/gitea/routers/web/user"
user_setting "code.gitea.io/gitea/routers/web/user/setting" user_setting "code.gitea.io/gitea/routers/web/user/setting"
"code.gitea.io/gitea/routers/web/user/setting/security" "code.gitea.io/gitea/routers/web/user/setting/security"
@ -97,14 +98,14 @@ func optionsCorsHandler() func(next http.Handler) http.Handler {
// The Session plugin is expected to be executed second, in order to skip authentication // The Session plugin is expected to be executed second, in order to skip authentication
// for users that have already signed in. // for users that have already signed in.
func buildAuthGroup() *auth_service.Group { func buildAuthGroup() *auth_service.Group {
group := auth_service.NewGroup( group := auth_service.NewGroup()
&auth_service.OAuth2{}, // FIXME: this should be removed and only applied in download and oauth related routers group.Add(&auth_service.OAuth2{}) // FIXME: this should be removed and only applied in download and oauth related routers
&auth_service.Basic{}, // FIXME: this should be removed and only applied in download and git/lfs routers group.Add(&auth_service.Basic{}) // FIXME: this should be removed and only applied in download and git/lfs routers
&auth_service.Session{},
)
if setting.Service.EnableReverseProxyAuth { if setting.Service.EnableReverseProxyAuth {
group.Add(&auth_service.ReverseProxy{}) group.Add(&auth_service.ReverseProxy{}) // reverseproxy should before Session, otherwise the header will be ignored if user has login
} }
group.Add(&auth_service.Session{})
if setting.IsWindows && auth_model.IsSSPIEnabled(db.DefaultContext) { if setting.IsWindows && auth_model.IsSSPIEnabled(db.DefaultContext) {
group.Add(&auth_service.SSPI{}) // it MUST be the last, see the comment of SSPI group.Add(&auth_service.SSPI{}) // it MUST be the last, see the comment of SSPI
@ -976,6 +977,7 @@ func registerRoutes(m *web.Route) {
m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
m.Group("/{id}", func() { m.Group("/{id}", func() {
m.Post("", web.Bind(forms.EditProjectBoardForm{}), org.AddBoardToProjectPost) m.Post("", web.Bind(forms.EditProjectBoardForm{}), org.AddBoardToProjectPost)
m.Post("/move", project.MoveColumns)
m.Post("/delete", org.DeleteProject) m.Post("/delete", org.DeleteProject)
m.Get("/edit", org.RenderEditProject) m.Get("/edit", org.RenderEditProject)
@ -1349,6 +1351,7 @@ func registerRoutes(m *web.Route) {
m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost) m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost)
m.Group("/{id}", func() { m.Group("/{id}", func() {
m.Post("", web.Bind(forms.EditProjectBoardForm{}), repo.AddBoardToProjectPost) m.Post("", web.Bind(forms.EditProjectBoardForm{}), repo.AddBoardToProjectPost)
m.Post("/move", project.MoveColumns)
m.Post("/delete", repo.DeleteProject) m.Post("/delete", repo.DeleteProject)
m.Get("/edit", repo.RenderEditProject) m.Get("/edit", repo.RenderEditProject)

View file

@ -86,6 +86,19 @@ func MockAPIContext(t *testing.T, reqPath string) (*context.APIContext, *httptes
return ctx, resp return ctx, resp
} }
func MockPrivateContext(t *testing.T, reqPath string) (*context.PrivateContext, *httptest.ResponseRecorder) {
resp := httptest.NewRecorder()
req := mockRequest(t, reqPath)
base, baseCleanUp := context.NewBaseContext(resp, req)
base.Data = middleware.GetContextData(req.Context())
base.Locale = &translation.MockLocale{}
ctx := &context.PrivateContext{Base: base}
_ = baseCleanUp // during test, it doesn't need to do clean up. TODO: this can be improved later
chiCtx := chi.NewRouteContext()
ctx.Base.AppendContextValue(chi.RouteCtxKey, chiCtx)
return ctx, resp
}
// LoadRepo load a repo into a test context. // LoadRepo load a repo into a test context.
func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) { func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) {
var doer *user_model.User var doer *user_model.User

View file

@ -152,3 +152,19 @@ func (r *indexerNotifier) IssueChangeLabels(ctx context.Context, doer *user_mode
func (r *indexerNotifier) IssueClearLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) { func (r *indexerNotifier) IssueClearLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) {
issue_indexer.UpdateIssueIndexer(ctx, issue.ID) issue_indexer.UpdateIssueIndexer(ctx, issue.ID)
} }
func (r *indexerNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
if err := pr.LoadIssue(ctx); err != nil {
log.Error("LoadIssue: %v", err)
return
}
issue_indexer.UpdateIssueIndexer(ctx, pr.Issue.ID)
}
func (r *indexerNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
if err := pr.LoadIssue(ctx); err != nil {
log.Error("LoadIssue: %v", err)
return
}
issue_indexer.UpdateIssueIndexer(ctx, pr.Issue.ID)
}

View file

@ -18,7 +18,6 @@ import (
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
pull_model "code.gitea.io/gitea/models/pull"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@ -168,12 +167,6 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U
pullWorkingPool.CheckIn(fmt.Sprint(pr.ID)) pullWorkingPool.CheckIn(fmt.Sprint(pr.ID))
defer pullWorkingPool.CheckOut(fmt.Sprint(pr.ID)) defer pullWorkingPool.CheckOut(fmt.Sprint(pr.ID))
// Removing an auto merge pull and ignore if not exist
// FIXME: is this the correct point to do this? Shouldn't this be after IsMergeStyleAllowed?
if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) {
return err
}
prUnit, err := pr.BaseRepo.GetUnit(ctx, unit.TypePullRequests) prUnit, err := pr.BaseRepo.GetUnit(ctx, unit.TypePullRequests)
if err != nil { if err != nil {
log.Error("pr.BaseRepo.GetUnit(unit.TypePullRequests): %v", err) log.Error("pr.BaseRepo.GetUnit(unit.TypePullRequests): %v", err)
@ -190,17 +183,15 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U
AddTestPullRequestTask(ctx, doer, pr.BaseRepo.ID, pr.BaseBranch, false, "", "", 0) AddTestPullRequestTask(ctx, doer, pr.BaseRepo.ID, pr.BaseBranch, false, "", "", 0)
}() }()
pr.MergedCommitID, err = doMergeAndPush(ctx, pr, doer, mergeStyle, expectedHeadCommitID, message) _, err = doMergeAndPush(ctx, pr, doer, mergeStyle, expectedHeadCommitID, message, repo_module.PushTriggerPRMergeToBase)
if err != nil { if err != nil {
return err return err
} }
pr.MergedUnix = timeutil.TimeStampNow() // reload pull request because it has been updated by post receive hook
pr.Merger = doer pr, err = issues_model.GetPullRequestByID(ctx, pr.ID)
pr.MergerID = doer.ID if err != nil {
return err
if _, err := pr.SetMerged(ctx); err != nil {
log.Error("SetMerged %-v: %v", pr, err)
} }
if err := pr.LoadIssue(ctx); err != nil { if err := pr.LoadIssue(ctx); err != nil {
@ -251,7 +242,7 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U
} }
// doMergeAndPush performs the merge operation without changing any pull information in database and pushes it up to the base repository // doMergeAndPush performs the merge operation without changing any pull information in database and pushes it up to the base repository
func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string) (string, error) { func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, pushTrigger repo_module.PushTrigger) (string, error) {
// Clone base repo. // Clone base repo.
mergeCtx, cancel, err := createTemporaryRepoForMerge(ctx, pr, doer, expectedHeadCommitID) mergeCtx, cancel, err := createTemporaryRepoForMerge(ctx, pr, doer, expectedHeadCommitID)
if err != nil { if err != nil {
@ -324,11 +315,13 @@ func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *use
pr.BaseRepo.Name, pr.BaseRepo.Name,
pr.ID, pr.ID,
) )
mergeCtx.env = append(mergeCtx.env, repo_module.EnvPushTrigger+"="+string(pushTrigger))
pushCmd := git.NewCommand(ctx, "push", "origin").AddDynamicArguments(baseBranch + ":" + git.BranchPrefix + pr.BaseBranch) pushCmd := git.NewCommand(ctx, "push", "origin").AddDynamicArguments(baseBranch + ":" + git.BranchPrefix + pr.BaseBranch)
// Push back to upstream. // Push back to upstream.
// TODO: this cause an api call to "/api/internal/hook/post-receive/...", // This cause an api call to "/api/internal/hook/post-receive/...",
// that prevents us from doint the whole merge in one db transaction // If it's merge, all db transaction and operations should be there but not here to prevent deadlock.
if err := pushCmd.Run(mergeCtx.RunOpts()); err != nil { if err := pushCmd.Run(mergeCtx.RunOpts()); err != nil {
if strings.Contains(mergeCtx.errbuf.String(), "non-fast-forward") { if strings.Contains(mergeCtx.errbuf.String(), "non-fast-forward") {
return "", &git.ErrPushOutOfDate{ return "", &git.ErrPushOutOfDate{

View file

@ -15,6 +15,7 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/repository"
) )
// Update updates pull request with base branch. // Update updates pull request with base branch.
@ -72,7 +73,7 @@ func Update(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.
BaseBranch: pr.HeadBranch, BaseBranch: pr.HeadBranch,
} }
_, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", message) _, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", message, repository.PushTriggerPRUpdateWithBase)
defer func() { defer func() {
AddTestPullRequestTask(ctx, doer, reversePR.HeadRepo.ID, reversePR.HeadBranch, false, "", "", 0) AddTestPullRequestTask(ctx, doer, reversePR.HeadRepo.ID, reversePR.HeadBranch, false, "", "", 0)

View file

@ -36,10 +36,6 @@ func AdoptRepository(ctx context.Context, doer, u *user_model.User, opts CreateR
} }
} }
if len(opts.DefaultBranch) == 0 {
opts.DefaultBranch = setting.Repository.DefaultBranch
}
repo := &repo_model.Repository{ repo := &repo_model.Repository{
OwnerID: u.ID, OwnerID: u.ID,
Owner: u, Owner: u,
@ -80,8 +76,8 @@ func AdoptRepository(ctx context.Context, doer, u *user_model.User, opts CreateR
return fmt.Errorf("getRepositoryByID: %w", err) return fmt.Errorf("getRepositoryByID: %w", err)
} }
if err := adoptRepository(ctx, repoPath, doer, repo, opts.DefaultBranch); err != nil { if err := adoptRepository(ctx, repoPath, repo, opts.DefaultBranch); err != nil {
return fmt.Errorf("createDelegateHooks: %w", err) return fmt.Errorf("adoptRepository: %w", err)
} }
if err := repo_module.CheckDaemonExportOK(ctx, repo); err != nil { if err := repo_module.CheckDaemonExportOK(ctx, repo); err != nil {
@ -111,7 +107,7 @@ func AdoptRepository(ctx context.Context, doer, u *user_model.User, opts CreateR
return repo, nil return repo, nil
} }
func adoptRepository(ctx context.Context, repoPath string, u *user_model.User, repo *repo_model.Repository, defaultBranch string) (err error) { func adoptRepository(ctx context.Context, repoPath string, repo *repo_model.Repository, defaultBranch string) (err error) {
isExist, err := util.IsExist(repoPath) isExist, err := util.IsExist(repoPath)
if err != nil { if err != nil {
log.Error("Unable to check if %s exists. Error: %v", repoPath, err) log.Error("Unable to check if %s exists. Error: %v", repoPath, err)
@ -143,6 +139,21 @@ func adoptRepository(ctx context.Context, repoPath string, u *user_model.User, r
} }
} }
// Don't bother looking this repo in the context it won't be there
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
return fmt.Errorf("openRepository: %w", err)
}
defer gitRepo.Close()
if _, err = repo_module.SyncRepoBranchesWithRepo(ctx, repo, gitRepo, 0); err != nil {
return fmt.Errorf("SyncRepoBranchesWithRepo: %w", err)
}
if err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil {
return fmt.Errorf("SyncReleasesWithTags: %w", err)
}
branches, _ := git_model.FindBranchNames(ctx, git_model.FindBranchOptions{ branches, _ := git_model.FindBranchNames(ctx, git_model.FindBranchOptions{
RepoID: repo.ID, RepoID: repo.ID,
ListOptions: db.ListOptionsAll, ListOptions: db.ListOptionsAll,
@ -183,22 +194,10 @@ func adoptRepository(ctx context.Context, repoPath string, u *user_model.User, r
return fmt.Errorf("setDefaultBranch: %w", err) return fmt.Errorf("setDefaultBranch: %w", err)
} }
} }
if err = repo_module.UpdateRepository(ctx, repo, false); err != nil { if err = repo_module.UpdateRepository(ctx, repo, false); err != nil {
return fmt.Errorf("updateRepository: %w", err) return fmt.Errorf("updateRepository: %w", err)
} }
// Don't bother looking this repo in the context it won't be there
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
return fmt.Errorf("openRepository: %w", err)
}
defer gitRepo.Close()
if err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil {
return fmt.Errorf("SyncReleasesWithTags: %w", err)
}
return nil return nil
} }

View file

@ -64,15 +64,15 @@
</div> </div>
<div id="project-board"> <div id="project-board">
<div class="board {{if .CanWriteProjects}}sortable{{end}}"> <div class="board {{if .CanWriteProjects}}sortable{{end}}"{{if .CanWriteProjects}} data-url="{{$.Link}}/move"{{end}}>
{{range .Columns}} {{range .Columns}}
<div class="ui segment project-column" style="background: {{.Color}} !important;" data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}"> <div class="ui segment project-column"{{if .Color}} style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}"> <div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
<div class="ui large label project-column-title tw-py-1"> <div class="ui large label project-column-title tw-py-1">
<div class="ui small circular grey label project-column-issue-count"> <div class="ui small circular grey label project-column-issue-count">
{{.NumIssues ctx}} {{.NumIssues ctx}}
</div> </div>
{{.Title}} <span class="project-column-title-label">{{.Title}}</span>
</div> </div>
{{if $canWriteProject}} {{if $canWriteProject}}
<div class="ui dropdown jump item"> <div class="ui dropdown jump item">
@ -153,9 +153,7 @@
</div> </div>
{{end}} {{end}}
</div> </div>
<div class="divider"{{if .Color}} style="color: {{ContrastColor .Color}} !important"{{end}}></div>
<div class="divider"></div>
<div class="ui cards" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}"> <div class="ui cards" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}">
{{range (index $.IssuesMap .ID)}} {{range (index $.IssuesMap .ID)}}
<div class="issue-card gt-word-break {{if $canWriteProject}}tw-cursor-grab{{end}}" data-issue="{{.ID}}"> <div class="issue-card gt-word-break {{if $canWriteProject}}tw-cursor-grab{{end}}" data-issue="{{.ID}}">

View file

@ -6,14 +6,22 @@
<div class="singular-commit" id="{{$tag}}"> <div class="singular-commit" id="{{$tag}}">
<span class="badge badge-commit">{{svg "octicon-git-commit"}}</span> <span class="badge badge-commit">{{svg "octicon-git-commit"}}</span>
{{if .User}} {{if .User}}
<a class="avatar" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User}}</a> <a class="avatar" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20}}</a>
{{else}} {{else}}
{{ctx.AvatarUtils.AvatarByEmail .Author.Email .Author.Name}} {{ctx.AvatarUtils.AvatarByEmail .Author.Email .Author.Name 20}}
{{end}} {{end}}
{{$commitLink:= printf "%s/commit/%s" $.comment.Issue.PullRequest.BaseRepo.Link (PathEscape .ID.String)}} {{$commitLink:= printf "%s/commit/%s" $.comment.Issue.PullRequest.BaseRepo.Link (PathEscape .ID.String)}}
<span class="shabox tw-flex tw-items-center tw-float-right"> <span class="tw-flex-1 tw-font-mono gt-ellipsis" title="{{.Summary}}">
{{- RenderCommitMessageLinkSubject $.root.Context .Message $commitLink ($.comment.Issue.PullRequest.BaseRepo.ComposeMetas ctx) -}}
</span>
{{if IsMultilineCommitMessage .Message}}
<button class="ui button ellipsis-button show-panel toggle" data-panel="[data-singular-commit-body-for='{{$tag}}']">...</button>
{{end}}
<span class="shabox tw-flex tw-items-center">
{{template "repo/commit_statuses" dict "Status" .Status "Statuses" .Statuses}} {{template "repo/commit_statuses" dict "Status" .Status "Statuses" .Statuses}}
{{$class := "ui sha label"}} {{$class := "ui sha label"}}
{{if .Signature}} {{if .Signature}}
@ -37,14 +45,11 @@
{{end}} {{end}}
</a> </a>
</span> </span>
<span class="tw-font-mono commit-summary {{if gt .ParentCount 1}} grey text{{end}}" title="{{.Summary}}">{{RenderCommitMessageLinkSubject $.root.Context .Message $commitLink ($.comment.Issue.PullRequest.BaseRepo.ComposeMetas ctx)}}</span>
{{if IsMultilineCommitMessage .Message}}
<button class="ui button js-toggle-commit-body ellipsis-button" aria-expanded="false">...</button>
{{end}}
{{if IsMultilineCommitMessage .Message}}
<pre class="commit-body tw-hidden">{{RenderCommitBody $.root.Context .Message ($.comment.Issue.PullRequest.BaseRepo.ComposeMetas ctx)}}</pre>
{{end}}
</div> </div>
{{if IsMultilineCommitMessage .Message}}
<pre class="commit-body tw-ml-[33px] tw-hidden" data-singular-commit-body-for="{{$tag}}">
{{- RenderCommitBody $.root.Context .Message ($.comment.Issue.PullRequest.BaseRepo.ComposeMetas ctx) -}}
</pre>
{{end}}
{{end}} {{end}}
</div> </div>

View file

@ -5,17 +5,17 @@ package integration
import ( import (
"net/http" "net/http"
"slices"
"testing" "testing"
unit_model "code.gitea.io/gitea/models/unit" unit_model "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
) )
func TestOrgProjectAccess(t *testing.T) { func TestOrgProjectAccess(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&unit_model.DisabledRepoUnits, append(slices.Clone(unit_model.DisabledRepoUnits), unit_model.TypeProjects))()
// disable repo project unit
unit_model.DisabledRepoUnits = []unit_model.Type{unit_model.TypeProjects}
// repo project, 404 // repo project, 404
req := NewRequest(t, "GET", "/user2/repo1/projects") req := NewRequest(t, "GET", "/user2/repo1/projects")

View file

@ -4,10 +4,17 @@
package integration package integration
import ( import (
"fmt"
"net/http" "net/http"
"testing" "testing"
"code.gitea.io/gitea/models/db"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
) )
func TestPrivateRepoProject(t *testing.T) { func TestPrivateRepoProject(t *testing.T) {
@ -21,3 +28,56 @@ func TestPrivateRepoProject(t *testing.T) {
req = NewRequest(t, "GET", "/user31/-/projects") req = NewRequest(t, "GET", "/user31/-/projects")
sess.MakeRequest(t, req, http.StatusOK) sess.MakeRequest(t, req, http.StatusOK)
} }
func TestMoveRepoProjectColumns(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
project1 := project_model.Project{
Title: "new created project",
RepoID: repo2.ID,
Type: project_model.TypeRepository,
BoardType: project_model.BoardTypeNone,
}
err := project_model.NewProject(db.DefaultContext, &project1)
assert.NoError(t, err)
for i := 0; i < 3; i++ {
err = project_model.NewBoard(db.DefaultContext, &project_model.Board{
Title: fmt.Sprintf("column %d", i+1),
ProjectID: project1.ID,
})
assert.NoError(t, err)
}
columns, err := project1.GetBoards(db.DefaultContext)
assert.NoError(t, err)
assert.Len(t, columns, 3)
assert.EqualValues(t, 0, columns[0].Sorting)
assert.EqualValues(t, 1, columns[1].Sorting)
assert.EqualValues(t, 2, columns[2].Sorting)
sess := loginUser(t, "user1")
req := NewRequest(t, "GET", fmt.Sprintf("/%s/projects/%d", repo2.FullName(), project1.ID))
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/projects/%d/move?_csrf="+htmlDoc.GetCSRF(), repo2.FullName(), project1.ID), map[string]any{
"columns": []map[string]any{
{"columnID": columns[1].ID, "sorting": 0},
{"columnID": columns[2].ID, "sorting": 1},
{"columnID": columns[0].ID, "sorting": 2},
},
})
sess.MakeRequest(t, req, http.StatusOK)
columnsAfter, err := project1.GetBoards(db.DefaultContext)
assert.NoError(t, err)
assert.Len(t, columns, 3)
assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID)
assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID)
assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID)
assert.NoError(t, project_model.DeleteProjectByID(db.DefaultContext, project1.ID))
}

View file

@ -27,6 +27,7 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/hostmatcher" "code.gitea.io/gitea/modules/hostmatcher"
"code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
@ -600,3 +601,63 @@ func TestPullDontRetargetChildOnWrongRepo(t *testing.T) {
assert.EqualValues(t, "Closed", prStatus) assert.EqualValues(t, "Closed", prStatus)
}) })
} }
func TestPullMergeIndexerNotifier(t *testing.T) {
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
// create a pull request
session := loginUser(t, "user1")
testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
createPullResp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "Indexer notifier test pull")
assert.NoError(t, queue.GetManager().FlushAll(context.Background(), 0))
time.Sleep(time.Second)
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{
OwnerName: "user2",
Name: "repo1",
})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{
RepoID: repo1.ID,
Title: "Indexer notifier test pull",
IsPull: true,
IsClosed: false,
})
// build the request for searching issues
link, _ := url.Parse("/api/v1/repos/issues/search")
query := url.Values{}
query.Add("state", "closed")
query.Add("type", "pulls")
query.Add("q", "notifier")
link.RawQuery = query.Encode()
// search issues
searchIssuesResp := session.MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK)
var apiIssuesBefore []*api.Issue
DecodeJSON(t, searchIssuesResp, &apiIssuesBefore)
assert.Len(t, apiIssuesBefore, 0)
// merge the pull request
elem := strings.Split(test.RedirectURL(createPullResp), "/")
assert.EqualValues(t, "pulls", elem[3])
testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false)
// check if the issue is closed
issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{
ID: issue.ID,
})
assert.True(t, issue.IsClosed)
assert.NoError(t, queue.GetManager().FlushAll(context.Background(), 0))
time.Sleep(time.Second)
// search issues again
searchIssuesResp = session.MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK)
var apiIssuesAfter []*api.Issue
DecodeJSON(t, searchIssuesResp, &apiIssuesAfter)
if assert.Len(t, apiIssuesAfter, 1) {
assert.Equal(t, issue.ID, apiIssuesAfter[0].ID)
}
})
}

View file

@ -976,6 +976,7 @@ input:-webkit-autofill:active,
font-weight: var(--font-weight-normal); font-weight: var(--font-weight-normal);
margin: 0 6px; margin: 0 6px;
padding: 5px 10px; padding: 5px 10px;
flex-shrink: 0;
} }
.ui .sha.label .shortsha { .ui .sha.label .shortsha {

View file

@ -22,34 +22,27 @@
cursor: default; cursor: default;
} }
.project-column .issue-card {
color: var(--color-text);
}
.project-column-header { .project-column-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.project-column-header.dark-label {
color: var(--color-project-board-dark-label) !important;
}
.project-column-header.dark-label .project-column-title {
color: var(--color-project-board-dark-label) !important;
}
.project-column-header.light-label {
color: var(--color-project-board-light-label) !important;
}
.project-column-header.light-label .project-column-title {
color: var(--color-project-board-light-label) !important;
}
.project-column-title { .project-column-title {
background: none !important; background: none !important;
line-height: 1.25 !important; line-height: 1.25 !important;
cursor: inherit; cursor: inherit;
} }
.project-column-title,
.project-column-issue-count {
color: inherit !important;
}
.project-column > .cards { .project-column > .cards {
flex: 1; flex: 1;
display: flex; display: flex;
@ -64,6 +57,8 @@
.project-column > .divider { .project-column > .divider {
margin: 5px 0; margin: 5px 0;
border-color: currentcolor;
opacity: .5;
} }
.project-column:first-child { .project-column:first-child {

View file

@ -844,55 +844,53 @@ td .commit-summary {
margin-right: 0.25em; margin-right: 0.25em;
} }
.repository.view.issue .comment-list .timeline-item.commits-list .singular-commit { .singular-commit {
line-height: 34px; /* this must be same as .badge height, to avoid overflow */ display: flex;
clear: both; /* reset the "float right shabox", in the future, use flexbox instead */ align-items: center;
} }
.repository.view.issue .comment-list .timeline-item.commits-list .singular-commit > img.avatar, .singular-commit .badge {
.repository.view.issue .comment-list .timeline-item.commits-list .singular-commit > .avatar img { height: 30px !important;
position: relative;
top: -2px;
} }
.repository.view.issue .comment-list .timeline-item.commits-list .singular-commit .shabox .sha.label { .singular-commit .shabox .sha.label {
margin: 0; margin: 0;
border: 1px solid var(--color-light-border); border: 1px solid var(--color-light-border);
} }
.repository.view.issue .comment-list .timeline-item.commits-list .singular-commit .shabox .sha.label.isSigned.isWarning { .singular-commit .shabox .sha.label.isSigned.isWarning {
border: 1px solid var(--color-red-badge); border: 1px solid var(--color-red-badge);
background: var(--color-red-badge-bg); background: var(--color-red-badge-bg);
} }
.repository.view.issue .comment-list .timeline-item.commits-list .singular-commit .shabox .sha.label.isSigned.isWarning:hover { .singular-commit .shabox .sha.label.isSigned.isWarning:hover {
background: var(--color-red-badge-hover-bg) !important; background: var(--color-red-badge-hover-bg) !important;
} }
.repository.view.issue .comment-list .timeline-item.commits-list .singular-commit .shabox .sha.label.isSigned.isVerified { .singular-commit .shabox .sha.label.isSigned.isVerified {
border: 1px solid var(--color-green-badge); border: 1px solid var(--color-green-badge);
background: var(--color-green-badge-bg); background: var(--color-green-badge-bg);
} }
.repository.view.issue .comment-list .timeline-item.commits-list .singular-commit .shabox .sha.label.isSigned.isVerified:hover { .singular-commit .shabox .sha.label.isSigned.isVerified:hover {
background: var(--color-green-badge-hover-bg) !important; background: var(--color-green-badge-hover-bg) !important;
} }
.repository.view.issue .comment-list .timeline-item.commits-list .singular-commit .shabox .sha.label.isSigned.isVerifiedUntrusted { .singular-commit .shabox .sha.label.isSigned.isVerifiedUntrusted {
border: 1px solid var(--color-yellow-badge); border: 1px solid var(--color-yellow-badge);
background: var(--color-yellow-badge-bg); background: var(--color-yellow-badge-bg);
} }
.repository.view.issue .comment-list .timeline-item.commits-list .singular-commit .shabox .sha.label.isSigned.isVerifiedUntrusted:hover { .singular-commit .shabox .sha.label.isSigned.isVerifiedUntrusted:hover {
background: var(--color-yellow-badge-hover-bg) !important; background: var(--color-yellow-badge-hover-bg) !important;
} }
.repository.view.issue .comment-list .timeline-item.commits-list .singular-commit .shabox .sha.label.isSigned.isVerifiedUnmatched { .singular-commit .shabox .sha.label.isSigned.isVerifiedUnmatched {
border: 1px solid var(--color-orange-badge); border: 1px solid var(--color-orange-badge);
background: var(--color-orange-badge-bg); background: var(--color-orange-badge-bg);
} }
.repository.view.issue .comment-list .timeline-item.commits-list .singular-commit .shabox .sha.label.isSigned.isVerifiedUnmatched:hover { .singular-commit .shabox .sha.label.isSigned.isVerifiedUnmatched:hover {
background: var(--color-orange-badge-hover-bg) !important; background: var(--color-orange-badge-hover-bg) !important;
} }
@ -2461,8 +2459,21 @@ td .commit-summary {
height: 0.5em; height: 0.5em;
} }
.labels-list {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
}
.labels-list a {
display: flex;
text-decoration: none;
}
.labels-list .label { .labels-list .label {
margin: 2px 0; padding: 0 6px;
margin: 0 !important;
min-height: 20px;
display: inline-flex !important; display: inline-flex !important;
line-height: 1.3; /* there is a `font-size: 1.25em` for inside emoji, so here the line-height needs to be larger slightly */ line-height: 1.3; /* there is a `font-size: 1.25em` for inside emoji, so here the line-height needs to be larger slightly */
} }
@ -2577,14 +2588,10 @@ tbody.commit-list {
.commit-body { .commit-body {
margin: 0.25em 0; margin: 0.25em 0;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: initial; line-height: initial;
} }
/* PR-comment */
.repository .timeline-item .commit-body {
margin-left: 45px;
}
.git-notes.top { .git-notes.top {
text-align: left; text-align: left;
} }

View file

@ -69,23 +69,6 @@
} }
} }
#issue-list .flex-item-title .labels-list {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
}
#issue-list .flex-item-title .labels-list a {
display: flex;
text-decoration: none;
}
#issue-list .flex-item-title .labels-list .label {
padding: 0 6px;
margin: 0;
min-height: 20px;
}
#issue-list .flex-item-body .branches { #issue-list .flex-item-body .branches {
display: inline-flex; display: inline-flex;
} }

View file

@ -215,8 +215,6 @@
--color-placeholder-text: var(--color-text-light-3); --color-placeholder-text: var(--color-text-light-3);
--color-editor-line-highlight: var(--color-primary-light-5); --color-editor-line-highlight: var(--color-primary-light-5);
--color-project-board-bg: var(--color-secondary-light-2); --color-project-board-bg: var(--color-secondary-light-2);
--color-project-board-dark-label: #0e1011;
--color-project-board-light-label: #dde0e2;
--color-caret: var(--color-text); /* should ideally be --color-text-dark, see #15651 */ --color-caret: var(--color-text); /* should ideally be --color-text-dark, see #15651 */
--color-reaction-bg: #e8e8ff12; --color-reaction-bg: #e8e8ff12;
--color-reaction-hover-bg: var(--color-primary-light-4); --color-reaction-hover-bg: var(--color-primary-light-4);

View file

@ -215,8 +215,6 @@
--color-placeholder-text: var(--color-text-light-3); --color-placeholder-text: var(--color-text-light-3);
--color-editor-line-highlight: var(--color-primary-light-6); --color-editor-line-highlight: var(--color-primary-light-6);
--color-project-board-bg: var(--color-secondary-light-4); --color-project-board-bg: var(--color-secondary-light-4);
--color-project-board-dark-label: #0e1114;
--color-project-board-light-label: #eaeef2;
--color-caret: var(--color-text-dark); --color-caret: var(--color-text-dark);
--color-reaction-bg: #0000170a; --color-reaction-bg: #0000170a;
--color-reaction-hover-bg: var(--color-primary-light-5); --color-reaction-hover-bg: var(--color-primary-light-5);

View file

@ -1,7 +1,6 @@
<script> <script>
import {SvgIcon} from '../svg.js'; import {SvgIcon} from '../svg.js';
import {useLightTextOnBackground} from '../utils/color.js'; import {contrastColor} from '../utils/color.js';
import tinycolor from 'tinycolor2';
import {GET} from '../modules/fetch.js'; import {GET} from '../modules/fetch.js';
import {emojiHTML} from '../features/emoji.js'; import {emojiHTML} from '../features/emoji.js';
import {htmlEscape} from 'escape-goat'; import {htmlEscape} from 'escape-goat';
@ -61,20 +60,13 @@ export default {
}, },
labels() { labels() {
return this.issue.labels.map((label) => { return this.issue.labels.map((label) => ({
let textColor; name: htmlEscape(label.name).replaceAll(/:[-+\w]+:/g, (emoji) => {
const {r, g, b} = tinycolor(label.color).toRgb();
if (useLightTextOnBackground(r, g, b)) {
textColor = '#eeeeee';
} else {
textColor = '#111111';
}
label.name = htmlEscape(label.name);
label.name = label.name.replaceAll(/:[-+\w]+:/g, (emoji) => {
return emojiHTML(emoji.substring(1, emoji.length - 1)); return emojiHTML(emoji.substring(1, emoji.length - 1));
}); }),
return {name: label.name, color: `#${label.color}`, textColor}; color: `#${label.color}`,
}); textColor: contrastColor(`#${label.color}`),
}));
}, },
}, },
mounted() { mounted() {
@ -114,7 +106,7 @@ export default {
<p><small>{{ issue.repository.full_name }} on {{ createdAt }}</small></p> <p><small>{{ issue.repository.full_name }} on {{ createdAt }}</small></p>
<p><svg-icon :name="icon" :class="['text', color]"/> <strong>{{ issue.title }}</strong> #{{ issue.number }}</p> <p><svg-icon :name="icon" :class="['text', color]"/> <strong>{{ issue.title }}</strong> #{{ issue.number }}</p>
<p>{{ body }}</p> <p>{{ body }}</p>
<div> <div class="labels-list">
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="label in labels" :key="label.name" class="ui label" :style="{ color: label.textColor, backgroundColor: label.color }" v-html="label.name"/> <div v-for="label in labels" :key="label.name" class="ui label" :style="{ color: label.textColor, backgroundColor: label.color }" v-html="label.name"/>
</div> </div>

View file

@ -67,7 +67,7 @@ export default {
const weekValues = Object.values(this.data); const weekValues = Object.values(this.data);
const start = weekValues[0].week; const start = weekValues[0].week;
const end = firstStartDateAfterDate(new Date()); const end = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(new Date(start), new Date(end)); const startDays = startDaysBetween(start, end);
this.data = fillEmptyStartDaysWithZeroes(startDays, this.data); this.data = fillEmptyStartDaysWithZeroes(startDays, this.data);
this.errorText = ''; this.errorText = '';
} else { } else {

View file

@ -114,7 +114,7 @@ export default {
const weekValues = Object.values(total.weeks); const weekValues = Object.values(total.weeks);
this.xAxisStart = weekValues[0].week; this.xAxisStart = weekValues[0].week;
this.xAxisEnd = firstStartDateAfterDate(new Date()); this.xAxisEnd = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(new Date(this.xAxisStart), new Date(this.xAxisEnd)); const startDays = startDaysBetween(this.xAxisStart, this.xAxisEnd);
total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks); total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
this.xAxisMin = this.xAxisStart; this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd; this.xAxisMax = this.xAxisEnd;

View file

@ -62,7 +62,7 @@ export default {
const data = await response.json(); const data = await response.json();
const start = Object.values(data)[0].week; const start = Object.values(data)[0].week;
const end = firstStartDateAfterDate(new Date()); const end = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(new Date(start), new Date(end)); const startDays = startDaysBetween(start, end);
this.data = fillEmptyStartDaysWithZeroes(startDays, data).slice(-52); this.data = fillEmptyStartDaysWithZeroes(startDays, data).slice(-52);
this.errorText = ''; this.errorText = '';
} else { } else {

View file

@ -1,6 +1,5 @@
import $ from 'jquery'; import $ from 'jquery';
import {useLightTextOnBackground} from '../utils/color.js'; import {contrastColor} from '../utils/color.js';
import tinycolor from 'tinycolor2';
import {createSortable} from '../modules/sortable.js'; import {createSortable} from '../modules/sortable.js';
import {POST, DELETE, PUT} from '../modules/fetch.js'; import {POST, DELETE, PUT} from '../modules/fetch.js';
@ -63,21 +62,21 @@ async function initRepoProjectSortable() {
delay: 500, delay: 500,
onSort: async () => { onSort: async () => {
boardColumns = mainBoard.getElementsByClassName('project-column'); boardColumns = mainBoard.getElementsByClassName('project-column');
for (let i = 0; i < boardColumns.length; i++) {
const column = boardColumns[i]; const columnSorting = {
if (parseInt($(column).data('sorting')) !== i) { columns: Array.from(boardColumns, (column, i) => ({
try { columnID: parseInt(column.getAttribute('data-id')),
await PUT($(column).data('url'), {
data: {
sorting: i, sorting: i,
color: rgbToHex(window.getComputedStyle($(column)[0]).backgroundColor), })),
}, };
try {
await POST(mainBoard.getAttribute('data-url'), {
data: columnSorting,
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
}
}
}, },
}); });
@ -94,47 +93,51 @@ async function initRepoProjectSortable() {
} }
export function initRepoProject() { export function initRepoProject() {
if (!$('.repository.projects').length) { if (!document.querySelector('.repository.projects')) {
return; return;
} }
const _promise = initRepoProjectSortable(); const _promise = initRepoProjectSortable();
$('.edit-project-column-modal').each(function () { for (const modal of document.getElementsByClassName('edit-project-column-modal')) {
const $projectHeader = $(this).closest('.project-column-header'); const projectHeader = modal.closest('.project-column-header');
const $projectTitleLabel = $projectHeader.find('.project-column-title'); const projectTitleLabel = projectHeader?.querySelector('.project-column-title-label');
const $projectTitleInput = $(this).find('.project-column-title-input'); const projectTitleInput = modal.querySelector('.project-column-title-input');
const $projectColorInput = $(this).find('#new_project_column_color'); const projectColorInput = modal.querySelector('#new_project_column_color');
const $boardColumn = $(this).closest('.project-column'); const boardColumn = modal.closest('.project-column');
modal.querySelector('.edit-project-column-button')?.addEventListener('click', async function (e) {
const bgColor = $boardColumn[0].style.backgroundColor;
if (bgColor) {
setLabelColor($projectHeader, rgbToHex(bgColor));
}
$(this).find('.edit-project-column-button').on('click', async function (e) {
e.preventDefault(); e.preventDefault();
try { try {
await PUT($(this).data('url'), { await PUT(this.getAttribute('data-url'), {
data: { data: {
title: $projectTitleInput.val(), title: projectTitleInput?.value,
color: $projectColorInput.val(), color: projectColorInput?.value,
}, },
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {
$projectTitleLabel.text($projectTitleInput.val()); projectTitleLabel.textContent = projectTitleInput?.value;
$projectTitleInput.closest('form').removeClass('dirty'); projectTitleInput.closest('form')?.classList.remove('dirty');
if ($projectColorInput.val()) { const dividers = boardColumn.querySelectorAll(':scope > .divider');
setLabelColor($projectHeader, $projectColorInput.val()); if (projectColorInput.value) {
const color = contrastColor(projectColorInput.value);
boardColumn.style.setProperty('background', projectColorInput.value, 'important');
boardColumn.style.setProperty('color', color, 'important');
for (const divider of dividers) {
divider.style.setProperty('color', color);
}
} else {
boardColumn.style.removeProperty('background');
boardColumn.style.removeProperty('color');
for (const divider of dividers) {
divider.style.removeProperty('color');
}
} }
$boardColumn[0].style = `background: ${$projectColorInput.val()} !important`;
$('.ui.modal').modal('hide'); $('.ui.modal').modal('hide');
} }
}); });
}); }
$('.default-project-column-modal').each(function () { $('.default-project-column-modal').each(function () {
const $boardColumn = $(this).closest('.project-column'); const $boardColumn = $(this).closest('.project-column');
@ -183,22 +186,3 @@ export function initRepoProject() {
createNewColumn(url, $columnTitle, $projectColorInput); createNewColumn(url, $columnTitle, $projectColorInput);
}); });
} }
function setLabelColor(label, color) {
const {r, g, b} = tinycolor(color).toRgb();
if (useLightTextOnBackground(r, g, b)) {
label.removeClass('dark-label').addClass('light-label');
} else {
label.removeClass('light-label').addClass('dark-label');
}
}
function rgbToHex(rgb) {
rgb = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+).*\)$/);
return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`;
}
function hex(x) {
const hexDigits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'];
return Number.isNaN(x) ? '00' : hexDigits[(x - x % 16) / 16] + hexDigits[x % 16];
}

View file

@ -1,23 +1,21 @@
// Check similar implementation in modules/util/color.go and keep synchronization import tinycolor from 'tinycolor2';
// Return R, G, B values defined in reletive luminance
function getLuminanceRGB(channel) { // Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
const sRGB = channel / 255; // Keep this in sync with modules/util/color.go
return (sRGB <= 0.03928) ? sRGB / 12.92 : ((sRGB + 0.055) / 1.055) ** 2.4; function getRelativeLuminance(color) {
const {r, g, b} = tinycolor(color).toRgb();
return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255;
} }
// Reference from: https://www.w3.org/WAI/GL/wiki/Relative_luminance function useLightText(backgroundColor) {
function getLuminance(r, g, b) { return getRelativeLuminance(backgroundColor) < 0.453;
const R = getLuminanceRGB(r);
const G = getLuminanceRGB(g);
const B = getLuminanceRGB(b);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
} }
// Reference from: https://firsching.ch/github_labels.html // Given a background color, returns a black or white foreground color that the highest
// In the future WCAG 3 APCA may be a better solution. // contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better.
// Check if text should use light color based on RGB of background // https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
export function useLightTextOnBackground(r, g, b) { export function contrastColor(backgroundColor) {
return getLuminance(r, g, b) < 0.453; return useLightText(backgroundColor) ? '#fff' : '#000';
} }
function resolveColors(obj) { function resolveColors(obj) {

View file

@ -1,21 +1,22 @@
import {useLightTextOnBackground} from './color.js'; import {contrastColor} from './color.js';
test('useLightTextOnBackground', () => { test('contrastColor', () => {
expect(useLightTextOnBackground(215, 58, 74)).toBe(true); expect(contrastColor('#d73a4a')).toBe('#fff');
expect(useLightTextOnBackground(0, 117, 202)).toBe(true); expect(contrastColor('#0075ca')).toBe('#fff');
expect(useLightTextOnBackground(207, 211, 215)).toBe(false); expect(contrastColor('#cfd3d7')).toBe('#000');
expect(useLightTextOnBackground(162, 238, 239)).toBe(false); expect(contrastColor('#a2eeef')).toBe('#000');
expect(useLightTextOnBackground(112, 87, 255)).toBe(true); expect(contrastColor('#7057ff')).toBe('#fff');
expect(useLightTextOnBackground(0, 134, 114)).toBe(true); expect(contrastColor('#008672')).toBe('#fff');
expect(useLightTextOnBackground(228, 230, 105)).toBe(false); expect(contrastColor('#e4e669')).toBe('#000');
expect(useLightTextOnBackground(216, 118, 227)).toBe(true); expect(contrastColor('#d876e3')).toBe('#000');
expect(useLightTextOnBackground(255, 255, 255)).toBe(false); expect(contrastColor('#ffffff')).toBe('#000');
expect(useLightTextOnBackground(43, 134, 133)).toBe(true); expect(contrastColor('#2b8684')).toBe('#fff');
expect(useLightTextOnBackground(43, 135, 134)).toBe(true); expect(contrastColor('#2b8786')).toBe('#fff');
expect(useLightTextOnBackground(44, 135, 134)).toBe(true); expect(contrastColor('#2c8786')).toBe('#000');
expect(useLightTextOnBackground(59, 182, 179)).toBe(true); expect(contrastColor('#3bb6b3')).toBe('#000');
expect(useLightTextOnBackground(124, 114, 104)).toBe(true); expect(contrastColor('#7c7268')).toBe('#fff');
expect(useLightTextOnBackground(126, 113, 108)).toBe(true); expect(contrastColor('#7e716c')).toBe('#fff');
expect(useLightTextOnBackground(129, 112, 109)).toBe(true); expect(contrastColor('#81706d')).toBe('#fff');
expect(useLightTextOnBackground(128, 112, 112)).toBe(true); expect(contrastColor('#807070')).toBe('#fff');
expect(contrastColor('#84b6eb')).toBe('#000');
}); });

View file

@ -1,25 +1,30 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import {getCurrentLocale} from '../utils.js'; import {getCurrentLocale} from '../utils.js';
// Returns an array of millisecond-timestamps of start-of-week days (Sundays) dayjs.extend(utc);
export function startDaysBetween(startDate, endDate) {
// Ensure the start date is a Sunday
while (startDate.getDay() !== 0) {
startDate.setDate(startDate.getDate() + 1);
}
const start = dayjs(startDate); /**
const end = dayjs(endDate); * Returns an array of millisecond-timestamps of start-of-week days (Sundays)
const startDays = []; *
* @param startConfig The start date. Can take any type that `Date` accepts.
* @param endConfig The end date. Can take any type that `Date` accepts.
*/
export function startDaysBetween(startDate, endDate) {
const start = dayjs.utc(startDate);
const end = dayjs.utc(endDate);
let current = start; let current = start;
// Ensure the start date is a Sunday
while (current.day() !== 0) {
current = current.add(1, 'day');
}
const startDays = [];
while (current.isBefore(end)) { while (current.isBefore(end)) {
startDays.push(current.valueOf()); startDays.push(current.valueOf());
// we are adding 7 * 24 hours instead of 1 week because we don't want current = current.add(1, 'week');
// date library to use local time zone to calculate 1 week from now.
// local time zone is problematic because of daylight saving time (dst)
// used on some countries
current = current.add(7 * 24, 'hour');
} }
return startDays; return startDays;
@ -29,10 +34,10 @@ export function firstStartDateAfterDate(inputDate) {
if (!(inputDate instanceof Date)) { if (!(inputDate instanceof Date)) {
throw new Error('Invalid date'); throw new Error('Invalid date');
} }
const dayOfWeek = inputDate.getDay(); const dayOfWeek = inputDate.getUTCDay();
const daysUntilSunday = 7 - dayOfWeek; const daysUntilSunday = 7 - dayOfWeek;
const resultDate = new Date(inputDate.getTime()); const resultDate = new Date(inputDate.getTime());
resultDate.setDate(resultDate.getDate() + daysUntilSunday); resultDate.setUTCDate(resultDate.getUTCDate() + daysUntilSunday);
return resultDate.valueOf(); return resultDate.valueOf();
} }