diff --git a/.dockerignore b/.dockerignore index 5cec84c9a3..b299c7313d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,7 +14,7 @@ _test # MS VSCode .vscode -__debug_bin +__debug_bin* # Architecture specific extensions/prefixes *.[568vq] @@ -78,7 +78,6 @@ cpu.out /public/assets/css /public/assets/fonts /public/assets/img/avatar -/public/assets/img/webpack /vendor /web_src/fomantic/node_modules /web_src/fomantic/build/* diff --git a/.gitignore b/.gitignore index abf9565cff..501fef7dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -77,7 +77,6 @@ cpu.out /public/assets/css /public/assets/fonts /public/assets/licenses.txt -/public/assets/img/webpack /vendor /web_src/fomantic/node_modules /web_src/fomantic/build/* diff --git a/Makefile b/Makefile index b4fa62e05e..8489520920 100644 --- a/Makefile +++ b/Makefile @@ -119,7 +119,7 @@ FOMANTIC_WORK_DIR := web_src/fomantic WEBPACK_SOURCES := $(shell find web_src/js web_src/css -type f) WEBPACK_CONFIGS := webpack.config.js tailwind.config.js WEBPACK_DEST := public/assets/js/index.js public/assets/css/index.css -WEBPACK_DEST_ENTRIES := public/assets/js public/assets/css public/assets/fonts public/assets/img/webpack +WEBPACK_DEST_ENTRIES := public/assets/js public/assets/css public/assets/fonts BINDATA_DEST := modules/public/bindata.go modules/options/bindata.go modules/templates/bindata.go BINDATA_HASH := $(addsuffix .hash,$(BINDATA_DEST)) diff --git a/models/actions/variable.go b/models/actions/variable.go index 14ded60fac..b0a455e675 100644 --- a/models/actions/variable.go +++ b/models/actions/variable.go @@ -6,13 +6,11 @@ package actions import ( "context" "errors" - "fmt" "strings" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" "xorm.io/builder" ) @@ -55,24 +53,24 @@ type FindVariablesOpts struct { db.ListOptions OwnerID int64 RepoID int64 + Name string } func (opts FindVariablesOpts) ToConds() builder.Cond { cond := builder.NewCond() + // Since we now support instance-level variables, + // there is no need to check for null values for `owner_id` and `repo_id` cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) + + if opts.Name != "" { + cond = cond.And(builder.Eq{"name": strings.ToUpper(opts.Name)}) + } return cond } -func GetVariableByID(ctx context.Context, variableID int64) (*ActionVariable, error) { - var variable ActionVariable - has, err := db.GetEngine(ctx).Where("id=?", variableID).Get(&variable) - if err != nil { - return nil, err - } else if !has { - return nil, fmt.Errorf("variable with id %d: %w", variableID, util.ErrNotExist) - } - return &variable, nil +func FindVariables(ctx context.Context, opts FindVariablesOpts) ([]*ActionVariable, error) { + return db.Find[ActionVariable](ctx, opts) } func UpdateVariable(ctx context.Context, variable *ActionVariable) (bool, error) { @@ -84,6 +82,13 @@ func UpdateVariable(ctx context.Context, variable *ActionVariable) (bool, error) return count != 0, err } +func DeleteVariable(ctx context.Context, id int64) error { + if _, err := db.DeleteByID[ActionVariable](ctx, id); err != nil { + return err + } + return nil +} + func GetVariablesOfRun(ctx context.Context, run *ActionRun) (map[string]string, error) { variables := map[string]string{} diff --git a/models/issues/review.go b/models/issues/review.go index 455bcda50a..92764db4d1 100644 --- a/models/issues/review.go +++ b/models/issues/review.go @@ -66,6 +66,23 @@ func (err ErrNotValidReviewRequest) Unwrap() error { return util.ErrInvalidArgument } +// ErrReviewRequestOnClosedPR represents an error when an user tries to request a re-review on a closed or merged PR. +type ErrReviewRequestOnClosedPR struct{} + +// IsErrReviewRequestOnClosedPR checks if an error is an ErrReviewRequestOnClosedPR. +func IsErrReviewRequestOnClosedPR(err error) bool { + _, ok := err.(ErrReviewRequestOnClosedPR) + return ok +} + +func (err ErrReviewRequestOnClosedPR) Error() string { + return "cannot request a re-review on a closed or merged PR" +} + +func (err ErrReviewRequestOnClosedPR) Unwrap() error { + return util.ErrPermissionDenied +} + // ReviewType defines the sort of feedback a review gives type ReviewType int @@ -618,9 +635,24 @@ func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_mo return nil, err } - // skip it when reviewer hase been request to review - if review != nil && review.Type == ReviewTypeRequest { - return nil, committer.Commit() // still commit the transaction, or committer.Close() will rollback it, even if it's a reused transaction. + if review != nil { + // skip it when reviewer hase been request to review + if review.Type == ReviewTypeRequest { + return nil, committer.Commit() // still commit the transaction, or committer.Close() will rollback it, even if it's a reused transaction. + } + + if issue.IsClosed { + return nil, ErrReviewRequestOnClosedPR{} + } + + if issue.IsPull { + if err := issue.LoadPullRequest(ctx); err != nil { + return nil, err + } + if issue.PullRequest.HasMerged { + return nil, ErrReviewRequestOnClosedPR{} + } + } } // if the reviewer is an official reviewer, diff --git a/models/issues/review_test.go b/models/issues/review_test.go index 1868cb1bfa..ac1b84adeb 100644 --- a/models/issues/review_test.go +++ b/models/issues/review_test.go @@ -288,3 +288,33 @@ func TestDeleteDismissedReview(t *testing.T) { assert.NoError(t, issues_model.DeleteReview(db.DefaultContext, review)) unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: comment.ID}) } + +func TestAddReviewRequest(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}) + assert.NoError(t, pull.LoadIssue(db.DefaultContext)) + issue := pull.Issue + assert.NoError(t, issue.LoadRepo(db.DefaultContext)) + reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + _, err := issues_model.CreateReview(db.DefaultContext, issues_model.CreateReviewOptions{ + Issue: issue, + Reviewer: reviewer, + Type: issues_model.ReviewTypeReject, + }) + + assert.NoError(t, err) + pull.HasMerged = false + assert.NoError(t, pull.UpdateCols(db.DefaultContext, "has_merged")) + issue.IsClosed = true + _, err = issues_model.AddReviewRequest(db.DefaultContext, issue, reviewer, &user_model.User{}) + assert.Error(t, err) + assert.True(t, issues_model.IsErrReviewRequestOnClosedPR(err)) + + pull.HasMerged = true + assert.NoError(t, pull.UpdateCols(db.DefaultContext, "has_merged")) + issue.IsClosed = false + _, err = issues_model.AddReviewRequest(db.DefaultContext, issue, reviewer, &user_model.User{}) + assert.Error(t, err) + assert.True(t, issues_model.IsErrReviewRequestOnClosedPR(err)) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 62d63bb46a..15d2d1783e 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -569,8 +569,10 @@ var migrations = []Migration{ // v291 -> v292 NewMigration("Add Index to attachment.comment_id", v1_22.AddCommentIDIndexofAttachment), // v292 -> v293 + NewMigration("Ensure every project has exactly one default column - No Op", noopMigration), + // v293 -> v294 NewMigration("Ensure every project has exactly one default column", v1_22.CheckProjectColumnsConsistency), - // v283 -> v294 + // v284 -> v295 NewMigration("Add TimeEstimate to issue table", v1_22.AddTimeEstimateColumnToIssueTable), } diff --git a/models/migrations/v1_22/v292.go b/models/migrations/v1_22/v292.go index 7c051a2b75..beca556aee 100644 --- a/models/migrations/v1_22/v292.go +++ b/models/migrations/v1_22/v292.go @@ -3,83 +3,7 @@ package v1_22 //nolint -import ( - "code.gitea.io/gitea/models/project" - "code.gitea.io/gitea/modules/setting" - - "xorm.io/builder" - "xorm.io/xorm" -) - -// CheckProjectColumnsConsistency ensures there is exactly one default board per project present -func CheckProjectColumnsConsistency(x *xorm.Engine) error { - sess := x.NewSession() - defer sess.Close() - - if err := sess.Begin(); err != nil { - return err - } - - limit := setting.Database.IterateBufferSize - if limit <= 0 { - limit = 50 - } - - start := 0 - - for { - var projects []project.Project - if err := sess.SQL("SELECT DISTINCT `p`.`id`, `p`.`creator_id` FROM `project` `p` WHERE (SELECT COUNT(*) FROM `project_board` `pb` WHERE `pb`.`project_id` = `p`.`id` AND `pb`.`default` = ?) != 1", true). - Limit(limit, start). - Find(&projects); err != nil { - return err - } - - if len(projects) == 0 { - break - } - start += len(projects) - - for _, p := range projects { - var boards []project.Board - if err := sess.Where("project_id=? AND `default` = ?", p.ID, true).OrderBy("sorting").Find(&boards); err != nil { - return err - } - - if len(boards) == 0 { - if _, err := sess.Insert(project.Board{ - ProjectID: p.ID, - Default: true, - Title: "Uncategorized", - CreatorID: p.CreatorID, - }); err != nil { - return err - } - continue - } - - var boardsToUpdate []int64 - for id, b := range boards { - if id > 0 { - boardsToUpdate = append(boardsToUpdate, b.ID) - } - } - - if _, err := sess.Where(builder.Eq{"project_id": p.ID}.And(builder.In("id", boardsToUpdate))). - Cols("`default`").Update(&project.Board{Default: false}); err != nil { - return err - } - } - - if start%1000 == 0 { - if err := sess.Commit(); err != nil { - return err - } - if err := sess.Begin(); err != nil { - return err - } - } - } - - return sess.Commit() -} +// NOTE: noop the original migration has bug which some projects will be skip, so +// these projects will have no default board. +// So that this migration will be skipped and go to v293.go +// This file is a placeholder so that readers can know what happened diff --git a/models/migrations/v1_22/v293.go b/models/migrations/v1_22/v293.go index 4acc2f687d..53cc719294 100644 --- a/models/migrations/v1_22/v293.go +++ b/models/migrations/v1_22/v293.go @@ -1,16 +1,108 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package v1_22 //nolint import ( + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "xorm.io/xorm" ) -func AddTimeEstimateColumnToIssueTable(x *xorm.Engine) error { - type Issue struct { - TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"` +// CheckProjectColumnsConsistency ensures there is exactly one default board per project present +func CheckProjectColumnsConsistency(x *xorm.Engine) error { + sess := x.NewSession() + defer sess.Close() + + limit := setting.Database.IterateBufferSize + if limit <= 0 { + limit = 50 } - return x.Sync(new(Issue)) + type Project struct { + ID int64 + CreatorID int64 + BoardID int64 + } + + type ProjectBoard struct { + ID int64 `xorm:"pk autoincr"` + Title string + Default bool `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board + Sorting int8 `xorm:"NOT NULL DEFAULT 0"` + Color string `xorm:"VARCHAR(7)"` + + ProjectID int64 `xorm:"INDEX NOT NULL"` + CreatorID int64 `xorm:"NOT NULL"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + } + + for { + if err := sess.Begin(); err != nil { + return err + } + + // all these projects without defaults will be fixed in the same loop, so + // we just need to always get projects without defaults until no such project + var projects []*Project + if err := sess.Select("project.id as id, project.creator_id, project_board.id as board_id"). + Join("LEFT", "project_board", "project_board.project_id = project.id AND project_board.`default`=?", true). + Where("project_board.id is NULL OR project_board.id = 0"). + Limit(limit). + Find(&projects); err != nil { + return err + } + + for _, p := range projects { + if _, err := sess.Insert(ProjectBoard{ + ProjectID: p.ID, + Default: true, + Title: "Uncategorized", + CreatorID: p.CreatorID, + }); err != nil { + return err + } + } + if err := sess.Commit(); err != nil { + return err + } + + if len(projects) == 0 { + break + } + } + sess.Close() + + return removeDuplicatedBoardDefault(x) +} + +func removeDuplicatedBoardDefault(x *xorm.Engine) error { + type ProjectInfo struct { + ProjectID int64 + DefaultNum int + } + var projects []ProjectInfo + if err := x.Select("project_id, count(*) AS default_num"). + Table("project_board"). + Where("`default` = ?", true). + GroupBy("project_id"). + Having("count(*) > 1"). + Find(&projects); err != nil { + return err + } + + for _, project := range projects { + if _, err := x.Where("project_id=?", project.ProjectID). + Table("project_board"). + Limit(project.DefaultNum - 1). + Update(map[string]bool{ + "`default`": false, + }); err != nil { + return err + } + } + return nil } diff --git a/models/migrations/v1_22/v292_test.go b/models/migrations/v1_22/v293_test.go similarity index 87% rename from models/migrations/v1_22/v292_test.go rename to models/migrations/v1_22/v293_test.go index 5e32e0220f..ccc92f39a6 100644 --- a/models/migrations/v1_22/v292_test.go +++ b/models/migrations/v1_22/v293_test.go @@ -31,14 +31,14 @@ func Test_CheckProjectColumnsConsistency(t *testing.T) { assert.Equal(t, int64(1), defaultBoard.ProjectID) assert.True(t, defaultBoard.Default) - // check if multiple defaults were removed + // check if multiple defaults, previous were removed and last will be kept expectDefaultBoard, err := project.GetBoard(db.DefaultContext, 2) assert.NoError(t, err) assert.Equal(t, int64(2), expectDefaultBoard.ProjectID) - assert.True(t, expectDefaultBoard.Default) + assert.False(t, expectDefaultBoard.Default) expectNonDefaultBoard, err := project.GetBoard(db.DefaultContext, 3) assert.NoError(t, err) assert.Equal(t, int64(2), expectNonDefaultBoard.ProjectID) - assert.False(t, expectNonDefaultBoard.Default) + assert.True(t, expectNonDefaultBoard.Default) } diff --git a/models/migrations/v1_22/v294.go b/models/migrations/v1_22/v294.go new file mode 100644 index 0000000000..4acc2f687d --- /dev/null +++ b/models/migrations/v1_22/v294.go @@ -0,0 +1,16 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_22 //nolint + +import ( + "xorm.io/xorm" +) + +func AddTimeEstimateColumnToIssueTable(x *xorm.Engine) error { + type Issue struct { + TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"` + } + + return x.Sync(new(Issue)) +} diff --git a/models/project/board.go b/models/project/board.go index 5605f259b5..5f142a356c 100644 --- a/models/project/board.go +++ b/models/project/board.go @@ -209,7 +209,6 @@ func deleteBoardByProjectID(ctx context.Context, projectID int64) error { // GetBoard fetches the current board of a project func GetBoard(ctx context.Context, boardID int64) (*Board, error) { board := new(Board) - has, err := db.GetEngine(ctx).ID(boardID).Get(board) if err != nil { return nil, err @@ -260,71 +259,62 @@ func (p *Project) GetBoards(ctx context.Context) (BoardList, error) { // getDefaultBoard return default board and ensure only one exists func (p *Project) getDefaultBoard(ctx context.Context) (*Board, error) { - var boards []Board - if err := db.GetEngine(ctx).Where("project_id=? AND `default` = ?", p.ID, true).OrderBy("sorting").Find(&boards); err != nil { + var board Board + has, err := db.GetEngine(ctx). + Where("project_id=? AND `default` = ?", p.ID, true). + Desc("id").Get(&board) + if err != nil { return nil, err } - // create a default board if none is found - if len(boards) == 0 { - board := Board{ - ProjectID: p.ID, - Default: true, - Title: "Uncategorized", - CreatorID: p.CreatorID, - } - if _, err := db.GetEngine(ctx).Insert(); err != nil { - return nil, err - } + if has { return &board, nil } - // unset default boards where too many default boards exist - if len(boards) > 1 { - var boardsToUpdate []int64 - for id, b := range boards { - if id > 0 { - boardsToUpdate = append(boardsToUpdate, b.ID) - } - } - - if _, err := db.GetEngine(ctx).Where(builder.Eq{"project_id": p.ID}.And(builder.In("id", boardsToUpdate))). - Cols("`default`").Update(&Board{Default: false}); err != nil { - return nil, err - } + // create a default board if none is found + board = Board{ + ProjectID: p.ID, + Default: true, + Title: "Uncategorized", + CreatorID: p.CreatorID, } - - return &boards[0], nil + if _, err := db.GetEngine(ctx).Insert(&board); err != nil { + return nil, err + } + return &board, nil } // SetDefaultBoard represents a board for issues not assigned to one func SetDefaultBoard(ctx context.Context, projectID, boardID int64) error { - if _, err := GetBoard(ctx, boardID); err != nil { + return db.WithTx(ctx, func(ctx context.Context) error { + if _, err := GetBoard(ctx, boardID); err != nil { + return err + } + + if _, err := db.GetEngine(ctx).Where(builder.Eq{ + "project_id": projectID, + "`default`": true, + }).Cols("`default`").Update(&Board{Default: false}); err != nil { + return err + } + + _, err := db.GetEngine(ctx).ID(boardID). + Where(builder.Eq{"project_id": projectID}). + Cols("`default`").Update(&Board{Default: true}) return err - } - - if _, err := db.GetEngine(ctx).Where(builder.Eq{ - "project_id": projectID, - "`default`": true, - }).Cols("`default`").Update(&Board{Default: false}); err != nil { - return err - } - - _, err := db.GetEngine(ctx).ID(boardID).Where(builder.Eq{"project_id": projectID}). - Cols("`default`").Update(&Board{Default: true}) - - return err + }) } // UpdateBoardSorting update project board sorting func UpdateBoardSorting(ctx context.Context, bs BoardList) error { - for i := range bs { - _, err := db.GetEngine(ctx).ID(bs[i].ID).Cols( - "sorting", - ).Update(bs[i]) - if err != nil { - return err + return db.WithTx(ctx, func(ctx context.Context) error { + for i := range bs { + if _, err := db.GetEngine(ctx).ID(bs[i].ID).Cols( + "sorting", + ).Update(bs[i]); err != nil { + return err + } } - } - return nil + return nil + }) } diff --git a/models/project/board_test.go b/models/project/board_test.go index c1c6f0180b..71ba29a589 100644 --- a/models/project/board_test.go +++ b/models/project/board_test.go @@ -31,8 +31,12 @@ func TestGetDefaultBoard(t *testing.T) { board, err = projectWithMultipleDefaults.getDefaultBoard(db.DefaultContext) assert.NoError(t, err) assert.Equal(t, int64(6), board.ProjectID) - assert.Equal(t, int64(8), board.ID) + assert.Equal(t, int64(9), board.ID) + // set 8 as default board + assert.NoError(t, SetDefaultBoard(db.DefaultContext, board.ProjectID, 8)) + + // then 9 will become a non-default board board, err = GetBoard(db.DefaultContext, 9) assert.NoError(t, err) assert.Equal(t, int64(6), board.ProjectID) diff --git a/models/repo/topic.go b/models/repo/topic.go index 79b13e320d..430a60f603 100644 --- a/models/repo/topic.go +++ b/models/repo/topic.go @@ -178,7 +178,7 @@ type FindTopicOptions struct { Keyword string } -func (opts *FindTopicOptions) toConds() builder.Cond { +func (opts *FindTopicOptions) ToConds() builder.Cond { cond := builder.NewCond() if opts.RepoID > 0 { cond = cond.And(builder.Eq{"repo_topic.repo_id": opts.RepoID}) @@ -191,29 +191,24 @@ func (opts *FindTopicOptions) toConds() builder.Cond { return cond } -// FindTopics retrieves the topics via FindTopicOptions -func FindTopics(ctx context.Context, opts *FindTopicOptions) ([]*Topic, int64, error) { - sess := db.GetEngine(ctx).Select("topic.*").Where(opts.toConds()) +func (opts *FindTopicOptions) ToOrders() string { orderBy := "topic.repo_count DESC" if opts.RepoID > 0 { - sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id") orderBy = "topic.name" // when render topics for a repo, it's better to sort them by name, to get consistent result } - if opts.PageSize != 0 && opts.Page != 0 { - sess = db.SetSessionPagination(sess, opts) - } - topics := make([]*Topic, 0, 10) - total, err := sess.OrderBy(orderBy).FindAndCount(&topics) - return topics, total, err + return orderBy } -// CountTopics counts the number of topics matching the FindTopicOptions -func CountTopics(ctx context.Context, opts *FindTopicOptions) (int64, error) { - sess := db.GetEngine(ctx).Where(opts.toConds()) - if opts.RepoID > 0 { - sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id") +func (opts *FindTopicOptions) ToJoins() []db.JoinFunc { + if opts.RepoID <= 0 { + return nil + } + return []db.JoinFunc{ + func(e db.Engine) error { + e.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id") + return nil + }, } - return sess.Count(new(Topic)) } // GetRepoTopicByName retrieves topic from name for a repo if it exist @@ -283,7 +278,7 @@ func DeleteTopic(ctx context.Context, repoID int64, topicName string) (*Topic, e // SaveTopics save topics to a repository func SaveTopics(ctx context.Context, repoID int64, topicNames ...string) error { - topics, _, err := FindTopics(ctx, &FindTopicOptions{ + topics, err := db.Find[Topic](ctx, &FindTopicOptions{ RepoID: repoID, }) if err != nil { diff --git a/models/repo/topic_test.go b/models/repo/topic_test.go index 2b609e6d66..1600896b6e 100644 --- a/models/repo/topic_test.go +++ b/models/repo/topic_test.go @@ -19,18 +19,18 @@ func TestAddTopic(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - topics, _, err := repo_model.FindTopics(db.DefaultContext, &repo_model.FindTopicOptions{}) + topics, err := db.Find[repo_model.Topic](db.DefaultContext, &repo_model.FindTopicOptions{}) assert.NoError(t, err) assert.Len(t, topics, totalNrOfTopics) - topics, total, err := repo_model.FindTopics(db.DefaultContext, &repo_model.FindTopicOptions{ + topics, total, err := db.FindAndCount[repo_model.Topic](db.DefaultContext, &repo_model.FindTopicOptions{ ListOptions: db.ListOptions{Page: 1, PageSize: 2}, }) assert.NoError(t, err) assert.Len(t, topics, 2) assert.EqualValues(t, 6, total) - topics, _, err = repo_model.FindTopics(db.DefaultContext, &repo_model.FindTopicOptions{ + topics, err = db.Find[repo_model.Topic](db.DefaultContext, &repo_model.FindTopicOptions{ RepoID: 1, }) assert.NoError(t, err) @@ -38,11 +38,11 @@ func TestAddTopic(t *testing.T) { assert.NoError(t, repo_model.SaveTopics(db.DefaultContext, 2, "golang")) repo2NrOfTopics := 1 - topics, _, err = repo_model.FindTopics(db.DefaultContext, &repo_model.FindTopicOptions{}) + topics, err = db.Find[repo_model.Topic](db.DefaultContext, &repo_model.FindTopicOptions{}) assert.NoError(t, err) assert.Len(t, topics, totalNrOfTopics) - topics, _, err = repo_model.FindTopics(db.DefaultContext, &repo_model.FindTopicOptions{ + topics, err = db.Find[repo_model.Topic](db.DefaultContext, &repo_model.FindTopicOptions{ RepoID: 2, }) assert.NoError(t, err) @@ -55,11 +55,11 @@ func TestAddTopic(t *testing.T) { assert.NoError(t, err) assert.EqualValues(t, 1, topic.RepoCount) - topics, _, err = repo_model.FindTopics(db.DefaultContext, &repo_model.FindTopicOptions{}) + topics, err = db.Find[repo_model.Topic](db.DefaultContext, &repo_model.FindTopicOptions{}) assert.NoError(t, err) assert.Len(t, topics, totalNrOfTopics) - topics, _, err = repo_model.FindTopics(db.DefaultContext, &repo_model.FindTopicOptions{ + topics, err = db.Find[repo_model.Topic](db.DefaultContext, &repo_model.FindTopicOptions{ RepoID: 2, }) assert.NoError(t, err) diff --git a/modules/structs/variable.go b/modules/structs/variable.go new file mode 100644 index 0000000000..cc846cf0ec --- /dev/null +++ b/modules/structs/variable.go @@ -0,0 +1,37 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// CreateVariableOption the option when creating variable +// swagger:model +type CreateVariableOption struct { + // Value of the variable to create + // + // required: true + Value string `json:"value" binding:"Required"` +} + +// UpdateVariableOption the option when updating variable +// swagger:model +type UpdateVariableOption struct { + // New name for the variable. If the field is empty, the variable name won't be updated. + Name string `json:"name"` + // Value of the variable to update + // + // required: true + Value string `json:"value" binding:"Required"` +} + +// ActionVariable return value of the query API +// swagger:model +type ActionVariable struct { + // the owner to which the variable belongs + OwnerID int64 `json:"owner_id"` + // the repository to which the variable belongs + RepoID int64 `json:"repo_id"` + // the name of the variable + Name string `json:"name"` + // the value of the variable + Data string `json:"data"` +} diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go index 7ed3a8b9b4..d1c9b082fa 100644 --- a/modules/templates/util_render.go +++ b/modules/templates/util_render.go @@ -41,7 +41,7 @@ func RenderCommitMessage(ctx context.Context, msg string, metas map[string]strin if len(msgLines) == 0 { return template.HTML("") } - return template.HTML(msgLines[0]) + return RenderCodeBlock(template.HTML(msgLines[0])) } // RenderCommitMessageLinkSubject renders commit message as a XSS-safe link to @@ -68,7 +68,7 @@ func RenderCommitMessageLinkSubject(ctx context.Context, msg, urlDefault string, log.Error("RenderCommitMessageSubject: %v", err) return template.HTML("") } - return template.HTML(renderedMessage) + return RenderCodeBlock(template.HTML(renderedMessage)) } // RenderCommitBody extracts the body of a commit message without its title. diff --git a/modules/util/util.go b/modules/util/util.go index c94fb91047..b6e730eb54 100644 --- a/modules/util/util.go +++ b/modules/util/util.go @@ -221,3 +221,12 @@ func IfZero[T comparable](v, def T) T { } return v } + +func ReserveLineBreakForTextarea(input string) string { + // Since the content is from a form which is a textarea, the line endings are \r\n. + // It's a standard behavior of HTML. + // But we want to store them as \n like what GitHub does. + // And users are unlikely to really need to keep the \r. + // Other than this, we should respect the original content, even leading or trailing spaces. + return strings.ReplaceAll(input, "\r\n", "\n") +} diff --git a/modules/util/util_test.go b/modules/util/util_test.go index 819e12ee91..5c5b13d04b 100644 --- a/modules/util/util_test.go +++ b/modules/util/util_test.go @@ -235,3 +235,8 @@ func TestToPointer(t *testing.T) { val123 := 123 assert.False(t, &val123 == ToPointer(val123)) } + +func TestReserveLineBreakForTextarea(t *testing.T) { + assert.Equal(t, ReserveLineBreakForTextarea("test\r\ndata"), "test\ndata") + assert.Equal(t, ReserveLineBreakForTextarea("test\r\ndata\r\n"), "test\ndata\n") +} diff --git a/package-lock.json b/package-lock.json index 1c169c5636..25fe14e1a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,11 @@ "@citation-js/plugin-bibtex": "0.7.9", "@citation-js/plugin-csl": "0.7.9", "@citation-js/plugin-software-formats": "0.6.1", - "@claviska/jquery-minicolors": "2.3.6", "@github/markdown-toolbar-element": "2.2.3", - "@github/relative-time-element": "4.3.1", + "@github/relative-time-element": "4.4.0", "@github/text-expander-element": "2.6.1", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", + "@melloware/coloris": "0.23.0", "@primer/octicons": "19.9.0", "add-asset-webpack-plugin": "2.0.1", "ansi_up": "6.0.2", @@ -394,14 +394,6 @@ "node": ">=14.0.0" } }, - "node_modules/@claviska/jquery-minicolors": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@claviska/jquery-minicolors/-/jquery-minicolors-2.3.6.tgz", - "integrity": "sha512-8Ro6D4GCrmOl41+6w4NFhEOpx8vjxwVRI69bulXsFDt49uVRKhLU5TnzEV7AmOJrylkVq+ugnYNMiGHBieeKUQ==", - "peerDependencies": { - "jquery": ">= 1.7.x" - } - }, "node_modules/@csstools/css-parser-algorithms": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.6.1.tgz", @@ -1004,9 +996,9 @@ "integrity": "sha512-AlquKGee+IWiAMYVB0xyHFZRMnu4n3X4HTvJHu79GiVJ1ojTukCWyxMlF5NMsecoLcBKsuBhx3QPv2vkE/zQ0A==" }, "node_modules/@github/relative-time-element": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-4.3.1.tgz", - "integrity": "sha512-zL79nlhZVCg7x2Pf/HT5MB0mowmErE71VXpF10/3Wy8dQwkninNO1M9aOizh2wKC5LkSpDXqNYjDZwbH0/bcSg==" + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-4.4.0.tgz", + "integrity": "sha512-CrI6oAecoahG7PF5dsgjdvlF5kCtusVMjg810EULD81TvnDsP+k/FRi/ClFubWLgBo4EGpr2EfvmumtqQFo7ow==" }, "node_modules/@github/text-expander-element": { "version": "2.6.1", @@ -1297,6 +1289,11 @@ "@mcaptcha/core-glue": "^0.1.0-alpha-5" } }, + "node_modules/@melloware/coloris": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@melloware/coloris/-/coloris-0.23.0.tgz", + "integrity": "sha512-VGIjI9+IQwg6BHjIE10yl0K2ARYz5bsjn6BgFEs1y1ErPAQymgdoxwVcSVL4Ai5t9OVs8xaCB7JKHqFu2N96Ow==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 1eeb4d196c..d5a1d46056 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ "@citation-js/plugin-bibtex": "0.7.9", "@citation-js/plugin-csl": "0.7.9", "@citation-js/plugin-software-formats": "0.6.1", - "@claviska/jquery-minicolors": "2.3.6", "@github/markdown-toolbar-element": "2.2.3", - "@github/relative-time-element": "4.3.1", + "@github/relative-time-element": "4.4.0", "@github/text-expander-element": "2.6.1", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", + "@melloware/coloris": "0.23.0", "@primer/octicons": "19.9.0", "add-asset-webpack-plugin": "2.0.1", "ansi_up": "6.0.2", diff --git a/public/assets/img/webpack/jquery.minicolors.0e614115.png b/public/assets/img/webpack/jquery.minicolors.0e614115.png new file mode 100644 index 0000000000..bccc2012af Binary files /dev/null and b/public/assets/img/webpack/jquery.minicolors.0e614115.png differ diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index c65650c388..e870378c4b 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -955,6 +955,15 @@ func Routes() *web.Route { Delete(user.DeleteSecret) }) + m.Group("/variables", func() { + m.Get("", user.ListVariables) + m.Combo("/{variablename}"). + Get(user.GetVariable). + Delete(user.DeleteVariable). + Post(bind(api.CreateVariableOption{}), user.CreateVariable). + Put(bind(api.UpdateVariableOption{}), user.UpdateVariable) + }) + m.Group("/runners", func() { m.Get("/registration-token", reqToken(), user.GetRegistrationToken) }) @@ -1073,6 +1082,15 @@ func Routes() *web.Route { Delete(reqToken(), reqOwner(), repo.DeleteSecret) }) + m.Group("/variables", func() { + m.Get("", reqToken(), reqOwner(), repo.ListVariables) + m.Combo("/{variablename}"). + Get(reqToken(), reqOwner(), repo.GetVariable). + Delete(reqToken(), reqOwner(), repo.DeleteVariable). + Post(reqToken(), reqOwner(), bind(api.CreateVariableOption{}), repo.CreateVariable). + Put(reqToken(), reqOwner(), bind(api.UpdateVariableOption{}), repo.UpdateVariable) + }) + m.Group("/runners", func() { m.Get("/registration-token", reqToken(), reqOwner(), repo.GetRegistrationToken) }) @@ -1452,6 +1470,15 @@ func Routes() *web.Route { Delete(reqToken(), reqOrgOwnership(), org.DeleteSecret) }) + m.Group("/variables", func() { + m.Get("", reqToken(), reqOrgOwnership(), org.ListVariables) + m.Combo("/{variablename}"). + Get(reqToken(), reqOrgOwnership(), org.GetVariable). + Delete(reqToken(), reqOrgOwnership(), org.DeleteVariable). + Post(reqToken(), reqOrgOwnership(), bind(api.CreateVariableOption{}), org.CreateVariable). + Put(reqToken(), reqOrgOwnership(), bind(api.UpdateVariableOption{}), org.UpdateVariable) + }) + m.Group("/runners", func() { m.Get("/registration-token", reqToken(), reqOrgOwnership(), org.GetRegistrationToken) }) diff --git a/routers/api/v1/org/variables.go b/routers/api/v1/org/variables.go new file mode 100644 index 0000000000..eaf7bdc45b --- /dev/null +++ b/routers/api/v1/org/variables.go @@ -0,0 +1,291 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "errors" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" +) + +// ListVariables list org-level variables +func ListVariables(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/variables organization getOrgVariablesList + // --- + // summary: Get an org-level variables list + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/VariableList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{ + OwnerID: ctx.Org.Organization.ID, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindVariables", err) + return + } + + variables := make([]*api.ActionVariable, len(vars)) + for i, v := range vars { + variables[i] = &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, variables) +} + +// GetVariable get an org-level variable +func GetVariable(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/variables/{variablename} organization getOrgVariable + // --- + // summary: Get an org-level variable + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ctx.Org.Organization.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + variable := &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + + ctx.JSON(http.StatusOK, variable) +} + +// DeleteVariable delete an org-level variable +func DeleteVariable(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/actions/variables/{variablename} organization deleteOrgVariable + // --- + // summary: Delete an org-level variable + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "201": + // description: response when deleting a variable + // "204": + // description: response when deleting a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + if err := actions_service.DeleteVariableByName(ctx, ctx.Org.Organization.ID, 0, ctx.Params("variablename")); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteVariableByName", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteVariableByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteVariableByName", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// CreateVariable create an org-level variable +func CreateVariable(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/actions/variables/{variablename} organization createOrgVariable + // --- + // summary: Create an org-level variable + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateVariableOption" + // responses: + // "201": + // description: response when creating an org-level variable + // "204": + // description: response when creating an org-level variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.CreateVariableOption) + + ownerID := ctx.Org.Organization.ID + variableName := ctx.Params("variablename") + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ownerID, + Name: variableName, + }) + if err != nil && !errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + return + } + if v != nil && v.ID > 0 { + ctx.Error(http.StatusConflict, "VariableNameAlreadyExists", util.NewAlreadyExistErrorf("variable name %s already exists", variableName)) + return + } + + if _, err := actions_service.CreateVariable(ctx, ownerID, 0, variableName, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateVariable update an org-level variable +func UpdateVariable(ctx *context.APIContext) { + // swagger:operation PUT /orgs/{org}/actions/variables/{variablename} organization updateOrgVariable + // --- + // summary: Update an org-level variable + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateVariableOption" + // responses: + // "201": + // description: response when updating an org-level variable + // "204": + // description: response when updating an org-level variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.UpdateVariableOption) + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ctx.Org.Organization.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + if opt.Name == "" { + opt.Name = ctx.Params("variablename") + } + if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "UpdateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index e0af276c71..03321d956d 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -7,9 +7,13 @@ import ( "errors" "net/http" + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + actions_service "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/context" secret_service "code.gitea.io/gitea/services/secrets" ) @@ -127,3 +131,295 @@ func DeleteSecret(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } + +// GetVariable get a repo-level variable +func GetVariable(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/variables/{variablename} repository getRepoVariable + // --- + // summary: Get a repo-level variable + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + RepoID: ctx.Repo.Repository.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + variable := &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + + ctx.JSON(http.StatusOK, variable) +} + +// DeleteVariable delete a repo-level variable +func DeleteVariable(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/actions/variables/{variablename} repository deleteRepoVariable + // --- + // summary: Delete a repo-level variable + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "201": + // description: response when deleting a variable + // "204": + // description: response when deleting a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + if err := actions_service.DeleteVariableByName(ctx, 0, ctx.Repo.Repository.ID, ctx.Params("variablename")); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteVariableByName", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteVariableByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteVariableByName", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// CreateVariable create a repo-level variable +func CreateVariable(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/variables/{variablename} repository createRepoVariable + // --- + // summary: Create a repo-level variable + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateVariableOption" + // responses: + // "201": + // description: response when creating a repo-level variable + // "204": + // description: response when creating a repo-level variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.CreateVariableOption) + + repoID := ctx.Repo.Repository.ID + variableName := ctx.Params("variablename") + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + RepoID: repoID, + Name: variableName, + }) + if err != nil && !errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + return + } + if v != nil && v.ID > 0 { + ctx.Error(http.StatusConflict, "VariableNameAlreadyExists", util.NewAlreadyExistErrorf("variable name %s already exists", variableName)) + return + } + + if _, err := actions_service.CreateVariable(ctx, 0, repoID, variableName, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateVariable update a repo-level variable +func UpdateVariable(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/actions/variables/{variablename} repository updateRepoVariable + // --- + // summary: Update a repo-level variable + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateVariableOption" + // responses: + // "201": + // description: response when updating a repo-level variable + // "204": + // description: response when updating a repo-level variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.UpdateVariableOption) + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + RepoID: ctx.Repo.Repository.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + if opt.Name == "" { + opt.Name = ctx.Params("variablename") + } + if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "UpdateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// ListVariables list repo-level variables +func ListVariables(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/variables repository getRepoVariablesList + // --- + // summary: Get repo-level variables list + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/VariableList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{ + RepoID: ctx.Repo.Repository.ID, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindVariables", err) + return + } + + variables := make([]*api.ActionVariable, len(vars)) + for i, v := range vars { + variables[i] = &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, variables) +} diff --git a/routers/api/v1/repo/pull_review.go b/routers/api/v1/repo/pull_review.go index d314c4e7f7..17bb2085b6 100644 --- a/routers/api/v1/repo/pull_review.go +++ b/routers/api/v1/repo/pull_review.go @@ -640,6 +640,8 @@ func DeleteReviewRequests(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "422": // "$ref": "#/responses/validationError" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" opts := web.GetForm(ctx).(*api.PullReviewRequestOptions) @@ -708,6 +710,10 @@ func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions for _, reviewer := range reviewers { comment, err := issue_service.ReviewRequest(ctx, pr.Issue, ctx.Doer, reviewer, isAdd) if err != nil { + if issues_model.IsErrReviewRequestOnClosedPR(err) { + ctx.Error(http.StatusForbidden, "", err) + return + } ctx.Error(http.StatusInternalServerError, "ReviewRequest", err) return } @@ -874,7 +880,7 @@ func dismissReview(ctx *context.APIContext, msg string, isDismiss, dismissPriors ctx.Error(http.StatusForbidden, "", "Must be repo admin") return } - review, pr, isWrong := prepareSingleReview(ctx) + review, _, isWrong := prepareSingleReview(ctx) if isWrong { return } @@ -884,13 +890,12 @@ func dismissReview(ctx *context.APIContext, msg string, isDismiss, dismissPriors return } - if pr.Issue.IsClosed { - ctx.Error(http.StatusForbidden, "", "not need to dismiss this review because this pr is closed") - return - } - _, err := pull_service.DismissReview(ctx, review.ID, ctx.Repo.Repository.ID, msg, ctx.Doer, isDismiss, dismissPriors) if err != nil { + if pull_service.IsErrDismissRequestOnClosedPR(err) { + ctx.Error(http.StatusForbidden, "", err) + return + } ctx.Error(http.StatusInternalServerError, "pull_service.DismissReview", err) return } diff --git a/routers/api/v1/repo/topic.go b/routers/api/v1/repo/topic.go index 1d8e675bde..9852caa989 100644 --- a/routers/api/v1/repo/topic.go +++ b/routers/api/v1/repo/topic.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" @@ -53,7 +54,7 @@ func ListTopics(ctx *context.APIContext) { RepoID: ctx.Repo.Repository.ID, } - topics, total, err := repo_model.FindTopics(ctx, opts) + topics, total, err := db.FindAndCount[repo_model.Topic](ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -172,7 +173,7 @@ func AddTopic(ctx *context.APIContext) { } // Prevent adding more topics than allowed to repo - count, err := repo_model.CountTopics(ctx, &repo_model.FindTopicOptions{ + count, err := db.Count[repo_model.Topic](ctx, &repo_model.FindTopicOptions{ RepoID: ctx.Repo.Repository.ID, }) if err != nil { @@ -287,7 +288,7 @@ func TopicSearch(ctx *context.APIContext) { ListOptions: utils.GetListOptions(ctx), } - topics, total, err := repo_model.FindTopics(ctx, opts) + topics, total, err := db.FindAndCount[repo_model.Topic](ctx, opts) if err != nil { ctx.InternalServerError(err) return diff --git a/routers/api/v1/swagger/action.go b/routers/api/v1/swagger/action.go index 3771780718..665f4d0b85 100644 --- a/routers/api/v1/swagger/action.go +++ b/routers/api/v1/swagger/action.go @@ -18,3 +18,17 @@ type swaggerResponseSecret struct { // in:body Body api.Secret `json:"body"` } + +// ActionVariable +// swagger:response ActionVariable +type swaggerResponseActionVariable struct { + // in:body + Body api.ActionVariable `json:"body"` +} + +// VariableList +// swagger:response VariableList +type swaggerResponseVariableList struct { + // in:body + Body []api.ActionVariable `json:"body"` +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 471e7d9c4e..cd551cbdfa 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -193,4 +193,10 @@ type swaggerParameterBodies struct { // in:body UserBadgeOption api.UserBadgeOption + + // in:body + CreateVariableOption api.CreateVariableOption + + // in:body + UpdateVariableOption api.UpdateVariableOption } diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go index babb8c0cf7..bf78c2c864 100644 --- a/routers/api/v1/user/action.go +++ b/routers/api/v1/user/action.go @@ -7,9 +7,13 @@ import ( "errors" "net/http" + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + actions_service "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/context" secret_service "code.gitea.io/gitea/services/secrets" ) @@ -101,3 +105,249 @@ func DeleteSecret(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } + +// CreateVariable create a user-level variable +func CreateVariable(ctx *context.APIContext) { + // swagger:operation POST /user/actions/variables/{variablename} user createUserVariable + // --- + // summary: Create a user-level variable + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateVariableOption" + // responses: + // "201": + // description: response when creating a variable + // "204": + // description: response when creating a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.CreateVariableOption) + + ownerID := ctx.Doer.ID + variableName := ctx.Params("variablename") + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ownerID, + Name: variableName, + }) + if err != nil && !errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + return + } + if v != nil && v.ID > 0 { + ctx.Error(http.StatusConflict, "VariableNameAlreadyExists", util.NewAlreadyExistErrorf("variable name %s already exists", variableName)) + return + } + + if _, err := actions_service.CreateVariable(ctx, ownerID, 0, variableName, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateVariable update a user-level variable which is created by current doer +func UpdateVariable(ctx *context.APIContext) { + // swagger:operation PUT /user/actions/variables/{variablename} user updateUserVariable + // --- + // summary: Update a user-level variable which is created by current doer + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateVariableOption" + // responses: + // "201": + // description: response when updating a variable + // "204": + // description: response when updating a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.UpdateVariableOption) + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ctx.Doer.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + if opt.Name == "" { + opt.Name = ctx.Params("variablename") + } + if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "UpdateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteVariable delete a user-level variable which is created by current doer +func DeleteVariable(ctx *context.APIContext) { + // swagger:operation DELETE /user/actions/variables/{variablename} user deleteUserVariable + // --- + // summary: Delete a user-level variable which is created by current doer + // produces: + // - application/json + // parameters: + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "201": + // description: response when deleting a variable + // "204": + // description: response when deleting a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + if err := actions_service.DeleteVariableByName(ctx, ctx.Doer.ID, 0, ctx.Params("variablename")); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteVariableByName", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteVariableByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteVariableByName", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// GetVariable get a user-level variable which is created by current doer +func GetVariable(ctx *context.APIContext) { + // swagger:operation GET /user/actions/variables/{variablename} user getUserVariable + // --- + // summary: Get a user-level variable which is created by current doer + // produces: + // - application/json + // parameters: + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ctx.Doer.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + variable := &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + + ctx.JSON(http.StatusOK, variable) +} + +// ListVariables list user-level variables +func ListVariables(ctx *context.APIContext) { + // swagger:operation GET /user/actions/variables user getUserVariablesList + // --- + // summary: Get the user-level list of variables which is created by current doer + // produces: + // - application/json + // parameters: + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/VariableList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{ + OwnerID: ctx.Doer.ID, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindVariables", err) + return + } + + variables := make([]*api.ActionVariable, len(vars)) + for i, v := range vars { + variables[i] = &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, variables) +} diff --git a/routers/web/explore/topic.go b/routers/web/explore/topic.go index 95fecfe2b8..b4507ba28d 100644 --- a/routers/web/explore/topic.go +++ b/routers/web/explore/topic.go @@ -23,7 +23,7 @@ func TopicSearch(ctx *context.Context) { }, } - topics, total, err := repo_model.FindTopics(ctx, opts) + topics, total, err := db.FindAndCount[repo_model.Topic](ctx, opts) if err != nil { ctx.Error(http.StatusInternalServerError) return diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 5c603688e9..159a5cb9cb 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -2553,6 +2553,10 @@ func UpdatePullReviewRequest(ctx *context.Context) { _, err = issue_service.ReviewRequest(ctx, issue, ctx.Doer, reviewer, action == "attach") if err != nil { + if issues_model.IsErrReviewRequestOnClosedPR(err) { + ctx.Status(http.StatusForbidden) + return + } ctx.ServerError("ReviewRequest", err) return } diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index 5385ebfc97..c8d149a482 100644 --- a/routers/web/repo/pull_review.go +++ b/routers/web/repo/pull_review.go @@ -277,6 +277,10 @@ func DismissReview(ctx *context.Context) { form := web.GetForm(ctx).(*forms.DismissReviewForm) comm, err := pull_service.DismissReview(ctx, form.ReviewID, ctx.Repo.Repository.ID, form.Message, ctx.Doer, true, true) if err != nil { + if pull_service.IsErrDismissRequestOnClosedPR(err) { + ctx.Status(http.StatusForbidden) + return + } ctx.ServerError("pull_service.DismissReview", err) return } diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 73a7be4e89..93e0f5bcbd 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -899,7 +899,7 @@ func renderLanguageStats(ctx *context.Context) { } func renderRepoTopics(ctx *context.Context) { - topics, _, err := repo_model.FindTopics(ctx, &repo_model.FindTopicOptions{ + topics, err := db.Find[repo_model.Topic](ctx, &repo_model.FindTopicOptions{ RepoID: ctx.Repo.Repository.ID, }) if err != nil { diff --git a/routers/web/shared/actions/variables.go b/routers/web/shared/actions/variables.go index 0f705399c9..79c03e4e8c 100644 --- a/routers/web/shared/actions/variables.go +++ b/routers/web/shared/actions/variables.go @@ -4,17 +4,13 @@ package actions import ( - "errors" - "regexp" - "strings" - actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/web" + actions_service "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" - secret_service "code.gitea.io/gitea/services/secrets" ) func SetVariablesContext(ctx *context.Context, ownerID, repoID int64) { @@ -29,41 +25,16 @@ func SetVariablesContext(ctx *context.Context, ownerID, repoID int64) { ctx.Data["Variables"] = variables } -// some regular expression of `variables` and `secrets` -// reference to: -// https://docs.github.com/en/actions/learn-github-actions/variables#naming-conventions-for-configuration-variables -// https://docs.github.com/en/actions/security-guides/encrypted-secrets#naming-your-secrets -var ( - forbiddenEnvNameCIRx = regexp.MustCompile("(?i)^CI") -) - -func envNameCIRegexMatch(name string) error { - if forbiddenEnvNameCIRx.MatchString(name) { - log.Error("Env Name cannot be ci") - return errors.New("env name cannot be ci") - } - return nil -} - func CreateVariable(ctx *context.Context, ownerID, repoID int64, redirectURL string) { form := web.GetForm(ctx).(*forms.EditVariableForm) - if err := secret_service.ValidateName(form.Name); err != nil { - ctx.JSONError(err.Error()) - return - } - - if err := envNameCIRegexMatch(form.Name); err != nil { - ctx.JSONError(err.Error()) - return - } - - v, err := actions_model.InsertVariable(ctx, ownerID, repoID, form.Name, ReserveLineBreakForTextarea(form.Data)) + v, err := actions_service.CreateVariable(ctx, ownerID, repoID, form.Name, form.Data) if err != nil { - log.Error("InsertVariable error: %v", err) + log.Error("CreateVariable: %v", err) ctx.JSONError(ctx.Tr("actions.variables.creation.failed")) return } + ctx.Flash.Success(ctx.Tr("actions.variables.creation.success", v.Name)) ctx.JSONRedirect(redirectURL) } @@ -72,23 +43,8 @@ func UpdateVariable(ctx *context.Context, redirectURL string) { id := ctx.ParamsInt64(":variable_id") form := web.GetForm(ctx).(*forms.EditVariableForm) - if err := secret_service.ValidateName(form.Name); err != nil { - ctx.JSONError(err.Error()) - return - } - - if err := envNameCIRegexMatch(form.Name); err != nil { - ctx.JSONError(err.Error()) - return - } - - ok, err := actions_model.UpdateVariable(ctx, &actions_model.ActionVariable{ - ID: id, - Name: strings.ToUpper(form.Name), - Data: ReserveLineBreakForTextarea(form.Data), - }) - if err != nil || !ok { - log.Error("UpdateVariable error: %v", err) + if ok, err := actions_service.UpdateVariable(ctx, id, form.Name, form.Data); err != nil || !ok { + log.Error("UpdateVariable: %v", err) ctx.JSONError(ctx.Tr("actions.variables.update.failed")) return } @@ -99,7 +55,7 @@ func UpdateVariable(ctx *context.Context, redirectURL string) { func DeleteVariable(ctx *context.Context, redirectURL string) { id := ctx.ParamsInt64(":variable_id") - if _, err := db.DeleteByBean(ctx, &actions_model.ActionVariable{ID: id}); err != nil { + if err := actions_service.DeleteVariableByID(ctx, id); err != nil { log.Error("Delete variable [%d] failed: %v", id, err) ctx.JSONError(ctx.Tr("actions.variables.deletion.failed")) return @@ -107,12 +63,3 @@ func DeleteVariable(ctx *context.Context, redirectURL string) { ctx.Flash.Success(ctx.Tr("actions.variables.deletion.success")) ctx.JSONRedirect(redirectURL) } - -func ReserveLineBreakForTextarea(input string) string { - // Since the content is from a form which is a textarea, the line endings are \r\n. - // It's a standard behavior of HTML. - // But we want to store them as \n like what GitHub does. - // And users are unlikely to really need to keep the \r. - // Other than this, we should respect the original content, even leading or trailing spaces. - return strings.ReplaceAll(input, "\r\n", "\n") -} diff --git a/routers/web/shared/secrets/secrets.go b/routers/web/shared/secrets/secrets.go index 73505ec372..3bd421f86a 100644 --- a/routers/web/shared/secrets/secrets.go +++ b/routers/web/shared/secrets/secrets.go @@ -7,8 +7,8 @@ import ( "code.gitea.io/gitea/models/db" secret_model "code.gitea.io/gitea/models/secret" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" - "code.gitea.io/gitea/routers/web/shared/actions" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" secret_service "code.gitea.io/gitea/services/secrets" @@ -27,7 +27,7 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) { func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) { form := web.GetForm(ctx).(*forms.AddSecretForm) - s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, actions.ReserveLineBreakForTextarea(form.Data)) + s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.ReserveLineBreakForTextarea(form.Data)) if err != nil { log.Error("CreateOrUpdateSecret failed: %v", err) ctx.JSONError(ctx.Tr("secrets.creation.failed")) diff --git a/services/actions/variables.go b/services/actions/variables.go new file mode 100644 index 0000000000..8dde9c4af5 --- /dev/null +++ b/services/actions/variables.go @@ -0,0 +1,100 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "regexp" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + secret_service "code.gitea.io/gitea/services/secrets" +) + +func CreateVariable(ctx context.Context, ownerID, repoID int64, name, data string) (*actions_model.ActionVariable, error) { + if err := secret_service.ValidateName(name); err != nil { + return nil, err + } + + if err := envNameCIRegexMatch(name); err != nil { + return nil, err + } + + v, err := actions_model.InsertVariable(ctx, ownerID, repoID, name, util.ReserveLineBreakForTextarea(data)) + if err != nil { + return nil, err + } + + return v, nil +} + +func UpdateVariable(ctx context.Context, variableID int64, name, data string) (bool, error) { + if err := secret_service.ValidateName(name); err != nil { + return false, err + } + + if err := envNameCIRegexMatch(name); err != nil { + return false, err + } + + return actions_model.UpdateVariable(ctx, &actions_model.ActionVariable{ + ID: variableID, + Name: strings.ToUpper(name), + Data: util.ReserveLineBreakForTextarea(data), + }) +} + +func DeleteVariableByID(ctx context.Context, variableID int64) error { + return actions_model.DeleteVariable(ctx, variableID) +} + +func DeleteVariableByName(ctx context.Context, ownerID, repoID int64, name string) error { + if err := secret_service.ValidateName(name); err != nil { + return err + } + + if err := envNameCIRegexMatch(name); err != nil { + return err + } + + v, err := GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ownerID, + RepoID: repoID, + Name: name, + }) + if err != nil { + return err + } + + return actions_model.DeleteVariable(ctx, v.ID) +} + +func GetVariable(ctx context.Context, opts actions_model.FindVariablesOpts) (*actions_model.ActionVariable, error) { + vars, err := actions_model.FindVariables(ctx, opts) + if err != nil { + return nil, err + } + if len(vars) != 1 { + return nil, util.NewNotExistErrorf("variable not found") + } + return vars[0], nil +} + +// some regular expression of `variables` and `secrets` +// reference to: +// https://docs.github.com/en/actions/learn-github-actions/variables#naming-conventions-for-configuration-variables +// https://docs.github.com/en/actions/security-guides/encrypted-secrets#naming-your-secrets +var ( + forbiddenEnvNameCIRx = regexp.MustCompile("(?i)^CI") +) + +func envNameCIRegexMatch(name string) error { + if forbiddenEnvNameCIRx.MatchString(name) { + log.Error("Env Name cannot be ci") + return util.NewInvalidArgumentErrorf("env name cannot be ci") + } + return nil +} diff --git a/services/pull/review.go b/services/pull/review.go index de1021c5c0..5bf1991d13 100644 --- a/services/pull/review.go +++ b/services/pull/review.go @@ -20,11 +20,29 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" ) var notEnoughLines = regexp.MustCompile(`fatal: file .* has only \d+ lines?`) +// ErrDismissRequestOnClosedPR represents an error when an user tries to dismiss a review associated to a closed or merged PR. +type ErrDismissRequestOnClosedPR struct{} + +// IsErrDismissRequestOnClosedPR checks if an error is an ErrDismissRequestOnClosedPR. +func IsErrDismissRequestOnClosedPR(err error) bool { + _, ok := err.(ErrDismissRequestOnClosedPR) + return ok +} + +func (err ErrDismissRequestOnClosedPR) Error() string { + return "can't dismiss a review associated to a closed or merged PR" +} + +func (err ErrDismissRequestOnClosedPR) Unwrap() error { + return util.ErrPermissionDenied +} + // checkInvalidation checks if the line of code comment got changed by another commit. // If the line got changed the comment is going to be invalidated. func checkInvalidation(ctx context.Context, c *issues_model.Comment, doer *user_model.User, repo *git.Repository, branch string) error { @@ -382,6 +400,21 @@ func DismissReview(ctx context.Context, reviewID, repoID int64, message string, return nil, fmt.Errorf("reviews's repository is not the same as the one we expect") } + issue := review.Issue + + if issue.IsClosed { + return nil, ErrDismissRequestOnClosedPR{} + } + + if issue.IsPull { + if err := issue.LoadPullRequest(ctx); err != nil { + return nil, err + } + if issue.PullRequest.HasMerged { + return nil, ErrDismissRequestOnClosedPR{} + } + } + if err := issues_model.DismissReview(ctx, review, isDismiss); err != nil { return nil, err } diff --git a/services/pull/review_test.go b/services/pull/review_test.go new file mode 100644 index 0000000000..3bce1e523d --- /dev/null +++ b/services/pull/review_test.go @@ -0,0 +1,48 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pull_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + pull_service "code.gitea.io/gitea/services/pull" + + "github.com/stretchr/testify/assert" +) + +func TestDismissReview(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{}) + assert.NoError(t, pull.LoadIssue(db.DefaultContext)) + issue := pull.Issue + assert.NoError(t, issue.LoadRepo(db.DefaultContext)) + reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + review, err := issues_model.CreateReview(db.DefaultContext, issues_model.CreateReviewOptions{ + Issue: issue, + Reviewer: reviewer, + Type: issues_model.ReviewTypeReject, + }) + + assert.NoError(t, err) + issue.IsClosed = true + pull.HasMerged = false + assert.NoError(t, issues_model.UpdateIssueCols(db.DefaultContext, issue, "is_closed")) + assert.NoError(t, pull.UpdateCols(db.DefaultContext, "has_merged")) + _, err = pull_service.DismissReview(db.DefaultContext, review.ID, issue.RepoID, "", &user_model.User{}, false, false) + assert.Error(t, err) + assert.True(t, pull_service.IsErrDismissRequestOnClosedPR(err)) + + pull.HasMerged = true + pull.Issue.IsClosed = false + assert.NoError(t, issues_model.UpdateIssueCols(db.DefaultContext, issue, "is_closed")) + assert.NoError(t, pull.UpdateCols(db.DefaultContext, "has_merged")) + _, err = pull_service.DismissReview(db.DefaultContext, review.ID, issue.RepoID, "", &user_model.User{}, false, false) + assert.Error(t, err) + assert.True(t, pull_service.IsErrDismissRequestOnClosedPR(err)) +} diff --git a/templates/admin/config_settings.tmpl b/templates/admin/config_settings.tmpl index d7fb022274..02ab5fd0fb 100644 --- a/templates/admin/config_settings.tmpl +++ b/templates/admin/config_settings.tmpl @@ -7,14 +7,14 @@
{{ctx.Locale.Tr "admin.config.disable_gravatar"}}
- +
{{ctx.Locale.Tr "admin.config.enable_federated_avatar"}}
- +
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index b45174b086..33dd758c79 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -42,8 +42,8 @@
-
- +
+ {{template "repo/issue/label_precolors"}}
@@ -114,8 +114,8 @@
-
- +
+ {{template "repo/issue/label_precolors"}}
diff --git a/templates/repo/commits_list.tmpl b/templates/repo/commits_list.tmpl index bae9924141..53052333fa 100644 --- a/templates/repo/commits_list.tmpl +++ b/templates/repo/commits_list.tmpl @@ -16,7 +16,7 @@ {{$userName := .Author.Name}} {{if .User}} - {{if .User.FullName}} + {{if and .User.FullName DefaultShowFullName}} {{$userName = .User.FullName}} {{end}} {{ctx.AvatarUtils.Avatar .User 28 "tw-mr-2"}}{{$userName}} diff --git a/templates/repo/graph/commits.tmpl b/templates/repo/graph/commits.tmpl index 96d09072da..f141dbeada 100644 --- a/templates/repo/graph/commits.tmpl +++ b/templates/repo/graph/commits.tmpl @@ -61,7 +61,7 @@ {{$userName := $commit.Commit.Author.Name}} {{if $commit.User}} - {{if $commit.User.FullName}} + {{if and $commit.User.FullName DefaultShowFullName}} {{$userName = $commit.User.FullName}} {{end}} {{ctx.AvatarUtils.Avatar $commit.User}} diff --git a/templates/repo/issue/labels/edit_delete_label.tmpl b/templates/repo/issue/labels/edit_delete_label.tmpl index 98e0f47020..fcf69217ea 100644 --- a/templates/repo/issue/labels/edit_delete_label.tmpl +++ b/templates/repo/issue/labels/edit_delete_label.tmpl @@ -52,8 +52,8 @@
-
- +
+ {{template "repo/issue/label_precolors"}}
diff --git a/templates/repo/issue/labels/label_new.tmpl b/templates/repo/issue/labels/label_new.tmpl index 2b2b2336c4..32fd8e76d7 100644 --- a/templates/repo/issue/labels/label_new.tmpl +++ b/templates/repo/issue/labels/label_new.tmpl @@ -27,8 +27,8 @@
-
- +
+ {{template "repo/issue/label_precolors"}}
diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index c16080c6f5..969df2704a 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -59,7 +59,7 @@ {{end}}
- {{if (and $.Permission.IsAdmin (or (eq .Review.Type 1) (eq .Review.Type 3)) (not $.Issue.IsClosed))}} + {{if (and $.Permission.IsAdmin (or (eq .Review.Type 1) (eq .Review.Type 3)) (not $.Issue.IsClosed) (not $.Issue.PullRequest.HasMerged))}} {{svg "octicon-x" 20}} @@ -91,7 +91,7 @@ {{svg "octicon-hourglass" 16}} {{end}} - {{if .CanChange}} + {{if and .CanChange (or .Checked (and (not $.Issue.IsClosed) (not $.Issue.PullRequest.HasMerged)))}} {{if .Checked}}{{svg "octicon-trash"}}{{else}}{{svg "octicon-sync"}}{{end}} {{end}} {{svg (printf "octicon-%s" .Review.Type.Icon) 16 (printf "text %s" (.Review.HTMLTypeColorName))}} @@ -691,7 +691,7 @@ {{if and (not (eq .Issue.PullRequest.HeadRepo.FullName .Issue.PullRequest.BaseRepo.FullName)) .CanWriteToHeadRepo}}
-
{{.LatestCommitUser.FullName}} {{else}} {{if .LatestCommit.Author}}{{.LatestCommit.Author.Name}}{{else}}{{.LatestCommitUser.Name}}{{end}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index be3bd27f43..492a619a33 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1844,6 +1844,232 @@ } } }, + "/orgs/{org}/actions/variables": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Get an org-level variables list", + "operationId": "getOrgVariablesList", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/VariableList" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/orgs/{org}/actions/variables/{variablename}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Get an org-level variable", + "operationId": "getOrgVariable", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the variable", + "name": "variablename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActionVariable" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Update an org-level variable", + "operationId": "updateOrgVariable", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the variable", + "name": "variablename", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateVariableOption" + } + } + ], + "responses": { + "201": { + "description": "response when updating an org-level variable" + }, + "204": { + "description": "response when updating an org-level variable" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Create an org-level variable", + "operationId": "createOrgVariable", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the variable", + "name": "variablename", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateVariableOption" + } + } + ], + "responses": { + "201": { + "description": "response when creating an org-level variable" + }, + "204": { + "description": "response when creating an org-level variable" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Delete an org-level variable", + "operationId": "deleteOrgVariable", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the variable", + "name": "variablename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActionVariable" + }, + "201": { + "description": "response when deleting a variable" + }, + "204": { + "description": "response when deleting a variable" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/orgs/{org}/activities/feeds": { "get": { "produces": [ @@ -3723,6 +3949,261 @@ } } }, + "/repos/{owner}/{repo}/actions/variables": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get repo-level variables list", + "operationId": "getRepoVariablesList", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/VariableList" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/actions/variables/{variablename}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get a repo-level variable", + "operationId": "getRepoVariable", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the variable", + "name": "variablename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActionVariable" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Update a repo-level variable", + "operationId": "updateRepoVariable", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the variable", + "name": "variablename", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateVariableOption" + } + } + ], + "responses": { + "201": { + "description": "response when updating a repo-level variable" + }, + "204": { + "description": "response when updating a repo-level variable" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Create a repo-level variable", + "operationId": "createRepoVariable", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the variable", + "name": "variablename", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateVariableOption" + } + } + ], + "responses": { + "201": { + "description": "response when creating a repo-level variable" + }, + "204": { + "description": "response when creating a repo-level variable" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Delete a repo-level variable", + "operationId": "deleteRepoVariable", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the variable", + "name": "variablename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActionVariable" + }, + "201": { + "description": "response when deleting a variable" + }, + "204": { + "description": "response when deleting a variable" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/activities/feeds": { "get": { "produces": [ @@ -11276,6 +11757,9 @@ "204": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" }, @@ -15047,6 +15531,194 @@ } } }, + "/user/actions/variables": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get the user-level list of variables which is created by current doer", + "operationId": "getUserVariablesList", + "parameters": [ + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/VariableList" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/user/actions/variables/{variablename}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get a user-level variable which is created by current doer", + "operationId": "getUserVariable", + "parameters": [ + { + "type": "string", + "description": "name of the variable", + "name": "variablename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActionVariable" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update a user-level variable which is created by current doer", + "operationId": "updateUserVariable", + "parameters": [ + { + "type": "string", + "description": "name of the variable", + "name": "variablename", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateVariableOption" + } + } + ], + "responses": { + "201": { + "description": "response when updating a variable" + }, + "204": { + "description": "response when updating a variable" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Create a user-level variable", + "operationId": "createUserVariable", + "parameters": [ + { + "type": "string", + "description": "name of the variable", + "name": "variablename", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateVariableOption" + } + } + ], + "responses": { + "201": { + "description": "response when creating a variable" + }, + "204": { + "description": "response when creating a variable" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete a user-level variable which is created by current doer", + "operationId": "deleteUserVariable", + "parameters": [ + { + "type": "string", + "description": "name of the variable", + "name": "variablename", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "response when deleting a variable" + }, + "204": { + "description": "response when deleting a variable" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/user/applications/oauth2": { "get": { "produces": [ @@ -17190,6 +17862,35 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "ActionVariable": { + "description": "ActionVariable return value of the query API", + "type": "object", + "properties": { + "data": { + "description": "the value of the variable", + "type": "string", + "x-go-name": "Data" + }, + "name": { + "description": "the name of the variable", + "type": "string", + "x-go-name": "Name" + }, + "owner_id": { + "description": "the owner to which the variable belongs", + "type": "integer", + "format": "int64", + "x-go-name": "OwnerID" + }, + "repo_id": { + "description": "the repository to which the variable belongs", + "type": "integer", + "format": "int64", + "x-go-name": "RepoID" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Activity": { "type": "object", "properties": { @@ -19076,6 +19777,21 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "CreateVariableOption": { + "description": "CreateVariableOption the option when creating variable", + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "description": "Value of the variable to create", + "type": "string", + "x-go-name": "Value" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "CreateWikiPageOptions": { "description": "CreateWikiPageOptions form for creating wiki", "type": "object", @@ -23377,6 +24093,26 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "UpdateVariableOption": { + "description": "UpdateVariableOption the option when updating variable", + "type": "object", + "required": [ + "value" + ], + "properties": { + "name": { + "description": "New name for the variable. If the field is empty, the variable name won't be updated.", + "type": "string", + "x-go-name": "Name" + }, + "value": { + "description": "Value of the variable to update", + "type": "string", + "x-go-name": "Value" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "User": { "description": "User represents a user", "type": "object", @@ -23758,6 +24494,12 @@ } } }, + "ActionVariable": { + "description": "ActionVariable", + "schema": { + "$ref": "#/definitions/ActionVariable" + } + }, "ActivityFeedsList": { "description": "ActivityFeedsList", "schema": { @@ -24641,6 +25383,15 @@ } } }, + "VariableList": { + "description": "VariableList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ActionVariable" + } + } + }, "WatchInfo": { "description": "WatchInfo", "schema": { @@ -24716,7 +25467,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/UserBadgeOption" + "$ref": "#/definitions/UpdateVariableOption" } }, "redirect": { diff --git a/tests/integration/api_repo_topic_test.go b/tests/integration/api_repo_topic_test.go index c41bc4abb6..a10e159b78 100644 --- a/tests/integration/api_repo_topic_test.go +++ b/tests/integration/api_repo_topic_test.go @@ -26,14 +26,34 @@ func TestAPITopicSearch(t *testing.T) { TopicNames []*api.TopicResponse `json:"topics"` } + // search all topics + res := MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 6) + assert.EqualValues(t, "6", res.Header().Get("x-total-count")) + + // pagination search topics first page + topics.TopicNames = nil query := url.Values{"page": []string{"1"}, "limit": []string{"4"}} searchURL.RawQuery = query.Encode() - res := MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) DecodeJSON(t, res, &topics) assert.Len(t, topics.TopicNames, 4) assert.EqualValues(t, "6", res.Header().Get("x-total-count")) + // pagination search topics second page + topics.TopicNames = nil + query = url.Values{"page": []string{"2"}, "limit": []string{"4"}} + + searchURL.RawQuery = query.Encode() + res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 2) + assert.EqualValues(t, "6", res.Header().Get("x-total-count")) + + // add keyword search + query = url.Values{"page": []string{"1"}, "limit": []string{"4"}} query.Add("q", "topic") searchURL.RawQuery = query.Encode() res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) diff --git a/tests/integration/api_repo_variables_test.go b/tests/integration/api_repo_variables_test.go new file mode 100644 index 0000000000..7847962b07 --- /dev/null +++ b/tests/integration/api_repo_variables_test.go @@ -0,0 +1,149 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" +) + +func TestAPIRepoVariables(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + t.Run("CreateRepoVariable", func(t *testing.T) { + cases := []struct { + Name string + ExpectedStatus int + }{ + { + Name: "-", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "_", + ExpectedStatus: http.StatusNoContent, + }, + { + Name: "TEST_VAR", + ExpectedStatus: http.StatusNoContent, + }, + { + Name: "test_var", + ExpectedStatus: http.StatusConflict, + }, + { + Name: "ci", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "123var", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "var@test", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "github_var", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "gitea_var", + ExpectedStatus: http.StatusBadRequest, + }, + } + + for _, c := range cases { + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/variables/%s", repo.FullName(), c.Name), api.CreateVariableOption{ + Value: "value", + }).AddTokenAuth(token) + MakeRequest(t, req, c.ExpectedStatus) + } + }) + + t.Run("UpdateRepoVariable", func(t *testing.T) { + variableName := "test_update_var" + url := fmt.Sprintf("/api/v1/repos/%s/actions/variables/%s", repo.FullName(), variableName) + req := NewRequestWithJSON(t, "POST", url, api.CreateVariableOption{ + Value: "initial_val", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + cases := []struct { + Name string + UpdateName string + ExpectedStatus int + }{ + { + Name: "not_found_var", + ExpectedStatus: http.StatusNotFound, + }, + { + Name: variableName, + UpdateName: "1invalid", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: variableName, + UpdateName: "invalid@name", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: variableName, + UpdateName: "ci", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: variableName, + UpdateName: "updated_var_name", + ExpectedStatus: http.StatusNoContent, + }, + { + Name: variableName, + ExpectedStatus: http.StatusNotFound, + }, + { + Name: "updated_var_name", + ExpectedStatus: http.StatusNoContent, + }, + } + + for _, c := range cases { + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/actions/variables/%s", repo.FullName(), c.Name), api.UpdateVariableOption{ + Name: c.UpdateName, + Value: "updated_val", + }).AddTokenAuth(token) + MakeRequest(t, req, c.ExpectedStatus) + } + }) + + t.Run("DeleteRepoVariable", func(t *testing.T) { + variableName := "test_delete_var" + url := fmt.Sprintf("/api/v1/repos/%s/actions/variables/%s", repo.FullName(), variableName) + + req := NewRequestWithJSON(t, "POST", url, api.CreateVariableOption{ + Value: "initial_val", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", url).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", url).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) +} diff --git a/tests/integration/api_user_variables_test.go b/tests/integration/api_user_variables_test.go new file mode 100644 index 0000000000..dd5501f0b9 --- /dev/null +++ b/tests/integration/api_user_variables_test.go @@ -0,0 +1,144 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" +) + +func TestAPIUserVariables(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + t.Run("CreateRepoVariable", func(t *testing.T) { + cases := []struct { + Name string + ExpectedStatus int + }{ + { + Name: "-", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "_", + ExpectedStatus: http.StatusNoContent, + }, + { + Name: "TEST_VAR", + ExpectedStatus: http.StatusNoContent, + }, + { + Name: "test_var", + ExpectedStatus: http.StatusConflict, + }, + { + Name: "ci", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "123var", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "var@test", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "github_var", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "gitea_var", + ExpectedStatus: http.StatusBadRequest, + }, + } + + for _, c := range cases { + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/user/actions/variables/%s", c.Name), api.CreateVariableOption{ + Value: "value", + }).AddTokenAuth(token) + MakeRequest(t, req, c.ExpectedStatus) + } + }) + + t.Run("UpdateRepoVariable", func(t *testing.T) { + variableName := "test_update_var" + url := fmt.Sprintf("/api/v1/user/actions/variables/%s", variableName) + req := NewRequestWithJSON(t, "POST", url, api.CreateVariableOption{ + Value: "initial_val", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + cases := []struct { + Name string + UpdateName string + ExpectedStatus int + }{ + { + Name: "not_found_var", + ExpectedStatus: http.StatusNotFound, + }, + { + Name: variableName, + UpdateName: "1invalid", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: variableName, + UpdateName: "invalid@name", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: variableName, + UpdateName: "ci", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: variableName, + UpdateName: "updated_var_name", + ExpectedStatus: http.StatusNoContent, + }, + { + Name: variableName, + ExpectedStatus: http.StatusNotFound, + }, + { + Name: "updated_var_name", + ExpectedStatus: http.StatusNoContent, + }, + } + + for _, c := range cases { + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/user/actions/variables/%s", c.Name), api.UpdateVariableOption{ + Name: c.UpdateName, + Value: "updated_val", + }).AddTokenAuth(token) + MakeRequest(t, req, c.ExpectedStatus) + } + }) + + t.Run("DeleteRepoVariable", func(t *testing.T) { + variableName := "test_delete_var" + url := fmt.Sprintf("/api/v1/user/actions/variables/%s", variableName) + + req := NewRequestWithJSON(t, "POST", url, api.CreateVariableOption{ + Value: "initial_val", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", url).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", url).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) +} diff --git a/tests/integration/repo_topic_test.go b/tests/integration/repo_topic_test.go index 58fee8418f..f198397007 100644 --- a/tests/integration/repo_topic_test.go +++ b/tests/integration/repo_topic_test.go @@ -21,20 +21,42 @@ func TestTopicSearch(t *testing.T) { TopicNames []*api.TopicResponse `json:"topics"` } + // search all topics + res := MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 6) + assert.EqualValues(t, "6", res.Header().Get("x-total-count")) + + // pagination search topics + topics.TopicNames = nil query := url.Values{"page": []string{"1"}, "limit": []string{"4"}} searchURL.RawQuery = query.Encode() - res := MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) DecodeJSON(t, res, &topics) assert.Len(t, topics.TopicNames, 4) assert.EqualValues(t, "6", res.Header().Get("x-total-count")) + // second page + topics.TopicNames = nil + query = url.Values{"page": []string{"2"}, "limit": []string{"4"}} + + searchURL.RawQuery = query.Encode() + res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 2) + assert.EqualValues(t, "6", res.Header().Get("x-total-count")) + + // add keyword search + topics.TopicNames = nil + query = url.Values{"page": []string{"1"}, "limit": []string{"4"}} query.Add("q", "topic") searchURL.RawQuery = query.Encode() res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) DecodeJSON(t, res, &topics) assert.Len(t, topics.TopicNames, 2) + topics.TopicNames = nil query.Set("q", "database") searchURL.RawQuery = query.Encode() res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) diff --git a/web_src/css/base.css b/web_src/css/base.css index 18db136bc9..cd0f883138 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -23,6 +23,7 @@ --height-loading: 16rem; --min-height-textarea: 132px; /* padding + 6 lines + border = calc(1.57142em + 6lh + 2px), but lh is not fully supported */ --tab-size: 4; + --checkbox-size: 16px; /* height and width of checkbox and radio inputs */ } :root * { @@ -300,8 +301,8 @@ a.label, .inline-code-block { padding: 2px 4px; - border-radius: var(--border-radius-medium); - background-color: var(--color-markup-code-block); + border-radius: .24em; + background-color: var(--color-label-bg); } /* fix Fomantic's line-height cutting off "g" on Windows Chrome with Segoe UI */ @@ -1436,11 +1437,6 @@ table th[data-sortt-desc] .svg { vertical-align: -0.15em; } -/* for the jquery.minicolors plugin */ -.minicolors-panel { - background: var(--color-secondary-dark-1) !important; -} - .ui.tabular.menu { border-color: var(--color-secondary); } diff --git a/web_src/css/features/colorpicker.css b/web_src/css/features/colorpicker.css new file mode 100644 index 0000000000..0c651cfeb3 --- /dev/null +++ b/web_src/css/features/colorpicker.css @@ -0,0 +1,164 @@ +/* This is a stripped-down version of coloris's CSS tailored to our needs. It does only include + opaqua colors, and if more features like opacity are needed, the CSS needs to be extended + based on upstream: https://github.com/mdbassit/Coloris/blob/main/src/coloris.css. */ + +.js-color-picker-input { + display: flex; + flex-wrap: wrap; +} + +.js-color-picker-input input { + padding-top: 8px !important; + padding-bottom: 8px !important; + padding-left: 32px !important; +} + +.clr-picker { + display: none; + flex-wrap: wrap; + position: absolute; + width: 200px; + z-index: 1002; /* above .ui.modal which has 1001 */ + border-radius: var(--border-radius); + background-color: var(--color-menu); + justify-content: flex-end; + direction: ltr; + box-shadow: 0 5px 20px var(--color-shadow); + user-select: none; +} + +.clr-picker.clr-open { + display: flex; +} + +.clr-gradient { + position: relative; + width: 100%; + height: 100px; + border-radius: 3px 3px 0 0; + background: linear-gradient(rgba(0,0,0,0), #000), linear-gradient(90deg, #fff, currentcolor); /* stylelint-disable-line scale-unlimited/declaration-strict-value */ + cursor: pointer; +} + +.clr-marker { + position: absolute; + width: 12px; + height: 12px; + margin: -6px 0 0 -6px; + border: 1px solid var(--color-white); + border-radius: 50%; + background-color: currentcolor; + cursor: pointer; +} + +.clr-picker input[type="range"]::-webkit-slider-runnable-track { + width: 100%; + height: 16px; +} + +.clr-picker input[type="range"]::-webkit-slider-thumb { + width: 16px; + height: 16px; + -webkit-appearance: none; +} + +.clr-picker input[type="range"]::-moz-range-track { + width: 100%; + height: 16px; + border: 0; +} + +.clr-picker input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + border: 0; +} + +.clr-hue { + background: linear-gradient(to right, #f00 0%, #ff0 16.66%, #0f0 33.33%, #0ff 50%, #00f 66.66%, #f0f 83.33%, #f00 100%); /* stylelint-disable-line scale-unlimited/declaration-strict-value */ + position: relative; + width: calc(100% - 40px); + height: 10px; + margin: 10px 20px; + border-radius: 4px; +} + +.clr-hue input[type="range"] { + position: absolute; + width: calc(100% + 32px); + margin: 0; + background-color: transparent; + opacity: 0; + cursor: pointer; + appearance: none; +} + +.clr-hue div { + position: absolute; + width: 16px; + height: 16px; + left: 0; + top: 50%; + transform: translate(-50%, -50%); + border: 2px solid var(--color-white); + border-radius: 50%; + background-color: currentcolor; + box-shadow: 0 0 1px var(--color-shadow); + pointer-events: none; +} + +.clr-field { + flex: 1; + position: relative; + color: transparent; +} + +.clr-field button { + position: absolute; + aspect-ratio: 1; + height: 16px; + left: 10px; + top: 50%; + transform: translateY(-50%); + margin: 0; + padding: 0; + border: 0; + color: inherit; + pointer-events: none; + border-radius: 2px; + background: repeating-linear-gradient(45deg, #aaa 25%, transparent 25%, transparent 75%, #aaa 75%, #aaa), repeating-linear-gradient(45deg, #aaa 25%, #fff 25%, #fff 75%, #aaa 75%, #aaa); /* stylelint-disable-line scale-unlimited/declaration-strict-value */ + background-position: 0 0, 4px 4px; + background-size: 8px 8px; +} + +.clr-field button::after { + content: ""; + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + border-radius: inherit; + background-color: currentcolor; +} + +.clr-marker:focus { + outline: none; +} + +.clr-keyboard-nav .clr-marker:focus, +.clr-keyboard-nav .clr-hue input:focus + div, +.clr-keyboard-nav .clr-alpha input:focus + div { + outline: none; + box-shadow: 0 0 2px 2px var(--color-white); +} + +.clr-picker .clr-preview, +.clr-picker .clr-clear, +.clr-picker .clr-swatches, +.clr-picker .clr-format, +.clr-picker .clr-alpha, +.clr-picker .clr-color { + display: none; +} diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css index 30df994c38..cec5e6fc64 100644 --- a/web_src/css/features/projects.css +++ b/web_src/css/features/projects.css @@ -102,26 +102,3 @@ .card-ghost * { opacity: 0; } - -.color-field .minicolors.minicolors-theme-default { - display: block; -} - -.color-field .minicolors.minicolors-theme-default .minicolors-input { - height: 38px; - padding-left: 2rem; -} - -.color-field .minicolors.minicolors-theme-default .minicolors-swatch { - top: 10px; -} - -.edit-project-column-modal .color.picker.column, -.new-project-column-modal .color.picker.column { - display: flex; -} - -.edit-project-column-modal .color.picker.column .minicolors, -.new-project-column-modal .color.picker.column .minicolors { - flex: 1; -} diff --git a/web_src/css/form.css b/web_src/css/form.css index ca65b677d7..2a976c056d 100644 --- a/web_src/css/form.css +++ b/web_src/css/form.css @@ -32,10 +32,7 @@ textarea, .ui.form input[type="text"], .ui.form input[type="time"], .ui.form input[type="url"], -.ui.selection.dropdown, -.ui.checkbox label::before, -.ui.checkbox input:checked ~ label::before, -.ui.checkbox input:not([type="radio"]):indeterminate ~ label::before { +.ui.selection.dropdown { background: var(--color-input-background); border-color: var(--color-input-border); color: var(--color-input-text); @@ -63,12 +60,7 @@ textarea:hover, .ui.form input[type="text"]:hover, .ui.form input[type="time"]:hover, .ui.form input[type="url"]:hover, -.ui.selection.dropdown:hover, -.ui.checkbox label:hover::before, -.ui.checkbox label:active::before, -.ui.radio.checkbox label::after, -.ui.radio.checkbox input:focus ~ label::before, -.ui.radio.checkbox input:checked ~ label::before { +.ui.selection.dropdown:hover { background: var(--color-input-background); border-color: var(--color-input-border-hover); color: var(--color-input-text); @@ -91,11 +83,7 @@ textarea:focus, .ui.form input[type="text"]:focus, .ui.form input[type="time"]:focus, .ui.form input[type="url"]:focus, -.ui.selection.dropdown:focus, -.ui.checkbox input:focus ~ label::before, -.ui.checkbox input:not([type="radio"]):indeterminate:focus ~ label::before, -.ui.checkbox input:checked:focus ~ label::before, -.ui.radio.checkbox input:focus:checked ~ label::before { +.ui.selection.dropdown:focus { background: var(--color-input-background); border-color: var(--color-primary); color: var(--color-input-text); @@ -106,58 +94,21 @@ textarea:focus, .ui.form .inline.fields .field > label, .ui.form .inline.fields .field > p, .ui.form .inline.field > label, -.ui.form .inline.field > p, -.ui.checkbox label, -.ui.checkbox + label, -.ui.checkbox label:hover, -.ui.checkbox + label:hover, -.ui.checkbox input:focus ~ label, -.ui.checkbox input:active ~ label { +.ui.form .inline.field > p { color: var(--color-text); } .ui.form .required.fields:not(.grouped) > .field > label::after, .ui.form .required.fields.grouped > label::after, .ui.form .required.field > label::after, -.ui.form .required.fields:not(.grouped) > .field > .checkbox::after, -.ui.form .required.field > .checkbox::after, .ui.form label.required::after { color: var(--color-red); } -.ui.input, -.ui.checkbox input:focus ~ label::after, -.ui.checkbox input:checked ~ label::after, -.ui.checkbox label:active::after, -.ui.checkbox input:not([type="radio"]):indeterminate ~ label::after, -.ui.checkbox input:not([type="radio"]):indeterminate:focus ~ label::after, -.ui.checkbox input:checked:focus ~ label::after, -.ui.disabled.checkbox label, -.ui.checkbox input[disabled] ~ label { +.ui.input { color: var(--color-input-text); } -.ui.radio.checkbox input:focus ~ label::after, -.ui.radio.checkbox input:checked ~ label::after, -.ui.radio.checkbox input:focus:checked ~ label::after { - background: var(--color-input-text); -} - -.ui.toggle.checkbox label::before { - background: var(--color-input-toggle-background); -} - -.ui.toggle.checkbox label, -.ui.toggle.checkbox input:checked ~ label, -.ui.toggle.checkbox input:focus:checked ~ label { - color: var(--color-text) !important; -} - -.ui.toggle.checkbox input:checked ~ label::before, -.ui.toggle.checkbox input:focus:checked ~ label::before { - background: var(--color-primary) !important; -} - /* match */ .ui.form select { padding: 0.67857143em 1em; diff --git a/web_src/css/index.css b/web_src/css/index.css index aa3f6ac48e..373a84cf6a 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -12,6 +12,7 @@ @import "./modules/message.css"; @import "./modules/table.css"; @import "./modules/card.css"; +@import "./modules/checkbox.css"; @import "./modules/modal.css"; @import "./modules/select.css"; diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css index 788a4ed6ed..0f78ad25cb 100644 --- a/web_src/css/modules/animations.css +++ b/web_src/css/modules/animations.css @@ -6,7 +6,6 @@ .is-loading { pointer-events: none !important; position: relative !important; - overflow: hidden !important; } .is-loading > * { diff --git a/web_src/css/modules/checkbox.css b/web_src/css/modules/checkbox.css new file mode 100644 index 0000000000..fc44a7c115 --- /dev/null +++ b/web_src/css/modules/checkbox.css @@ -0,0 +1,120 @@ +/* based on Fomantic UI checkbox module, with just the parts extracted that we use. If you find any + unused rules here after refactoring, please remove them. */ + +input[type="checkbox"], +input[type="radio"] { + width: var(--checkbox-size); + height: var(--checkbox-size); +} + +.ui.checkbox { + position: relative; + display: inline-block; + vertical-align: baseline; + min-height: var(--checkbox-size); + line-height: var(--checkbox-size); + min-width: var(--checkbox-size); + padding: 1px; +} + +.ui.checkbox input[type="checkbox"], +.ui.checkbox input[type="radio"] { + position: absolute; + top: 0; + left: 0; + width: var(--checkbox-size); + height: var(--checkbox-size); +} + +.ui.checkbox input[type="checkbox"]:enabled, +.ui.checkbox input[type="radio"]:enabled, +.ui.checkbox label:enabled { + cursor: pointer; +} + +.ui.checkbox label { + cursor: auto; + position: relative; + display: block; + user-select: none; +} + +.ui.checkbox label, +.ui.radio.checkbox label { + padding-left: 1.85714em; +} + +.ui.checkbox + label { + vertical-align: middle; +} + +.ui.disabled.checkbox label, +.ui.checkbox input[disabled] ~ label { + cursor: default !important; + opacity: 0.5; + pointer-events: none; +} + +.ui.radio.checkbox { + min-height: var(--checkbox-size); +} + +/* "switch" styled checkbox */ + +.ui.toggle.checkbox { + min-height: 1.5rem; +} +.ui.toggle.checkbox input { + width: 3.5rem; + height: 1.5rem; + opacity: 0; + z-index: 3; +} +.ui.toggle.checkbox label { + min-height: 1.5rem; + padding-left: 4.5rem; + padding-top: 0.15em; +} +.ui.toggle.checkbox label::before { + display: block; + position: absolute; + content: ""; + z-index: 1; + top: 0; + width: 3.5rem; + height: 1.5rem; + border-radius: 500rem; + left: 0; +} +.ui.toggle.checkbox label::after { + background: var(--color-white); + position: absolute; + content: ""; + opacity: 1; + z-index: 2; + width: 1.5rem; + height: 1.5rem; + top: 0; + left: 0; + border-radius: 500rem; + transition: background 0.3s ease, left 0.3s ease; +} +.ui.toggle.checkbox input ~ label::after { + left: -0.05rem; +} +.ui.toggle.checkbox input:checked ~ label::after { + left: 2.15rem; +} +.ui.toggle.checkbox input:focus ~ label::before, +.ui.toggle.checkbox label::before { + background: var(--color-input-toggle-background); +} +.ui.toggle.checkbox label, +.ui.toggle.checkbox input:checked ~ label, +.ui.toggle.checkbox input:focus:checked ~ label { + color: var(--color-text) !important; +} +.ui.toggle.checkbox input:checked ~ label::before, +.ui.toggle.checkbox input:focus:checked ~ label::before { + background: var(--color-primary) !important; +} diff --git a/web_src/css/org.css b/web_src/css/org.css index 8b3684d0c0..32e8a914fa 100644 --- a/web_src/css/org.css +++ b/web_src/css/org.css @@ -89,10 +89,6 @@ text-align: center; } -.organization.options input { - min-width: 300px; -} - .page-content.organization .org-avatar { margin-right: 15px; } diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 18f28dc4a6..780093fb7f 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -2260,24 +2260,6 @@ padding-top: 15px; } -.edit-label.modal .form .color.picker.column, -.new-label.modal .form .color.picker.column { - display: flex; -} - -.edit-label.modal .form .color.picker.column .minicolors, -.new-label.modal .form .color.picker.column .minicolors { - flex: 1; -} - -.edit-label.modal .form .minicolors-swatch.minicolors-sprite, -.new-label.modal .form .minicolors-swatch.minicolors-sprite { - top: 10px; - left: 10px; - width: 15px; - height: 15px; -} - .tab-size-1 { tab-size: 1 !important; -moz-tab-size: 1 !important; diff --git a/web_src/css/repo/issue-list.css b/web_src/css/repo/issue-list.css index 1421577af2..fe8231d718 100644 --- a/web_src/css/repo/issue-list.css +++ b/web_src/css/repo/issue-list.css @@ -9,6 +9,7 @@ .issue-list-toolbar-left { display: flex; + align-items: center; } .issue-list-toolbar-right .filter.menu { diff --git a/web_src/fomantic/build/semantic.css b/web_src/fomantic/build/semantic.css index 21c41a6161..525a3af8c6 100644 --- a/web_src/fomantic/build/semantic.css +++ b/web_src/fomantic/build/semantic.css @@ -2323,715 +2323,6 @@ Theme Overrides *******************************/ -/******************************* - Site Overrides -*******************************/ -/*! - * # Fomantic-UI - Checkbox - * http://github.com/fomantic/Fomantic-UI/ - * - * - * Released under the MIT license - * http://opensource.org/licenses/MIT - * - */ - -/******************************* - Checkbox -*******************************/ - -/*-------------- - Content ----------------*/ - -.ui.checkbox { - position: relative; - display: inline-block; - backface-visibility: hidden; - outline: none; - vertical-align: baseline; - font-style: normal; - min-height: 17px; - font-size: 1em; - line-height: 17px; - min-width: 17px; -} - -/* HTML Checkbox */ - -.ui.checkbox input[type="checkbox"], -.ui.checkbox input[type="radio"] { - cursor: pointer; - position: absolute; - top: 0; - left: 0; - opacity: 0 !important; - outline: none; - z-index: 3; - width: 17px; - height: 17px; -} - -.ui.checkbox label { - cursor: auto; - position: relative; - display: block; - padding-left: 1.85714em; - outline: none; - font-size: 1em; -} - -.ui.checkbox label:before { - position: absolute; - top: 0; - left: 0; - width: 17px; - height: 17px; - content: ''; - background: #FFFFFF; - border-radius: 0.21428571rem; - transition: border 0.1s ease, opacity 0.1s ease, transform 0.1s ease, box-shadow 0.1s ease; - border: 1px solid #D4D4D5; -} - -/*-------------- - Checkmark ----------------*/ - -.ui.checkbox label:after { - position: absolute; - font-size: 14px; - top: 0; - left: 0; - width: 17px; - height: 17px; - text-align: center; - opacity: 0; - color: rgba(0, 0, 0, 0.87); - transition: border 0.1s ease, opacity 0.1s ease, transform 0.1s ease, box-shadow 0.1s ease; -} - -/*-------------- - Label ----------------*/ - -/* Inside */ - -.ui.checkbox label, -.ui.checkbox + label { - color: rgba(0, 0, 0, 0.87); - transition: color 0.1s ease; -} - -/* Outside */ - -.ui.checkbox + label { - vertical-align: middle; -} - -/******************************* - States -*******************************/ - -/*-------------- - Hover ----------------*/ - -.ui.checkbox label:hover::before { - background: #FFFFFF; - border-color: rgba(34, 36, 38, 0.35); -} - -.ui.checkbox label:hover, -.ui.checkbox + label:hover { - color: rgba(0, 0, 0, 0.8); -} - -/*-------------- - Down ----------------*/ - -.ui.checkbox label:active::before { - background: #F9FAFB; - border-color: rgba(34, 36, 38, 0.35); -} - -.ui.checkbox label:active::after { - color: rgba(0, 0, 0, 0.95); -} - -.ui.checkbox input:active ~ label { - color: rgba(0, 0, 0, 0.95); -} - -/*-------------- - Focus ----------------*/ - -.ui.checkbox input:focus ~ label:before { - background: #FFFFFF; - border-color: #96C8DA; -} - -.ui.checkbox input:focus ~ label:after { - color: rgba(0, 0, 0, 0.95); -} - -.ui.checkbox input:focus ~ label { - color: rgba(0, 0, 0, 0.95); -} - -/*-------------- - Active ----------------*/ - -.ui.checkbox input:checked ~ label:before { - background: #FFFFFF; - border-color: rgba(34, 36, 38, 0.35); -} - -.ui.checkbox input:checked ~ label:after { - opacity: 1; - color: rgba(0, 0, 0, 0.95); -} - -/*-------------- - Indeterminate - ---------------*/ - -.ui.checkbox input:not([type=radio]):indeterminate ~ label:before { - background: #FFFFFF; - border-color: rgba(34, 36, 38, 0.35); -} - -.ui.checkbox input:not([type=radio]):indeterminate ~ label:after { - opacity: 1; - color: rgba(0, 0, 0, 0.95); -} - -.ui.indeterminate.toggle.checkbox input:not([type=radio]):indeterminate ~ label:before { - background: rgba(0, 0, 0, 0.15); -} - -.ui.indeterminate.toggle.checkbox input:not([type=radio]) ~ label:after { - left: 1.075rem; -} - -/*-------------- - Active Focus ----------------*/ - -.ui.checkbox input:not([type=radio]):indeterminate:focus ~ label:before, -.ui.checkbox input:checked:focus ~ label:before { - background: #FFFFFF; - border-color: #96C8DA; -} - -.ui.checkbox input:not([type=radio]):indeterminate:focus ~ label:after, -.ui.checkbox input:checked:focus ~ label:after { - color: rgba(0, 0, 0, 0.95); -} - -/*-------------- - Read-Only ----------------*/ - -.ui.read-only.checkbox, -.ui.read-only.checkbox label { - cursor: default; -} - -/*-------------- - Disabled - ---------------*/ - -.ui.disabled.checkbox label, -.ui.checkbox input[disabled] ~ label { - cursor: default !important; - opacity: 0.5; - color: #000000; - pointer-events: none; -} - -/*-------------- - Hidden ----------------*/ - -/* Initialized checkbox moves input below element - to prevent manually triggering */ - -.ui.checkbox input.hidden { - z-index: -1; -} - -/* Selectable Label */ - -.ui.checkbox input.hidden + label { - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; -} - -/******************************* - Types -*******************************/ - -/*-------------- - Radio - ---------------*/ - -.ui.radio.checkbox { - min-height: 15px; -} - -.ui.radio.checkbox label { - padding-left: 1.85714em; -} - -/* Box */ - -.ui.radio.checkbox label:before { - content: ''; - transform: none; - width: 15px; - height: 15px; - border-radius: 500rem; - top: 1px; - left: 0; -} - -/* Bullet */ - -.ui.radio.checkbox label:after { - border: none; - content: '' !important; - line-height: 15px; - top: 1px; - left: 0; - width: 15px; - height: 15px; - border-radius: 500rem; - transform: scale(0.46666667); - background-color: rgba(0, 0, 0, 0.87); -} - -/* Focus */ - -.ui.radio.checkbox input:focus ~ label:before { - background-color: #FFFFFF; -} - -.ui.radio.checkbox input:focus ~ label:after { - background-color: rgba(0, 0, 0, 0.95); -} - -/* Indeterminate */ - -.ui.radio.checkbox input:indeterminate ~ label:after { - opacity: 0; -} - -/* Active */ - -.ui.radio.checkbox input:checked ~ label:before { - background-color: #FFFFFF; -} - -.ui.radio.checkbox input:checked ~ label:after { - background-color: rgba(0, 0, 0, 0.95); -} - -/* Active Focus */ - -.ui.radio.checkbox input:focus:checked ~ label:before { - background-color: #FFFFFF; -} - -.ui.radio.checkbox input:focus:checked ~ label:after { - background-color: rgba(0, 0, 0, 0.95); -} - -/*-------------- - Slider - ---------------*/ - -.ui.slider.checkbox { - min-height: 1.25rem; -} - -/* Input */ - -.ui.slider.checkbox input { - width: 3.5rem; - height: 1.25rem; -} - -/* Label */ - -.ui.slider.checkbox label { - padding-left: 4.5rem; - line-height: 1rem; - color: rgba(0, 0, 0, 0.4); -} - -/* Line */ - -.ui.slider.checkbox label:before { - display: block; - position: absolute; - content: ''; - transform: none; - border: none !important; - left: 0; - z-index: 1; - top: 0.4rem; - background-color: rgba(0, 0, 0, 0.05); - width: 3.5rem; - height: 0.21428571rem; - border-radius: 500rem; - transition: background 0.3s ease; -} - -/* Handle */ - -.ui.slider.checkbox label:after { - background: #FFFFFF linear-gradient(transparent, rgba(0, 0, 0, 0.05)); - position: absolute; - content: '' !important; - opacity: 1; - z-index: 2; - border: none; - box-shadow: 0 1px 2px 0 rgba(34, 36, 38, 0.15), 0 0 0 1px rgba(34, 36, 38, 0.15) inset; - width: 1.5rem; - height: 1.5rem; - top: -0.25rem; - left: 0; - transform: none; - border-radius: 500rem; - transition: left 0.3s ease; -} - -/* Focus */ - -.ui.slider.checkbox input:focus ~ label:before { - background-color: rgba(0, 0, 0, 0.15); - border: none; -} - -/* Hover */ - -.ui.slider.checkbox label:hover { - color: rgba(0, 0, 0, 0.8); -} - -.ui.slider.checkbox label:hover::before { - background: rgba(0, 0, 0, 0.15); -} - -/* Active */ - -.ui.slider.checkbox input:checked ~ label { - color: rgba(0, 0, 0, 0.95) !important; -} - -.ui.slider.checkbox input:checked ~ label:before { - background-color: #545454 !important; -} - -.ui.slider.checkbox input:checked ~ label:after { - left: 2rem; -} - -/* Active Focus */ - -.ui.slider.checkbox input:focus:checked ~ label { - color: rgba(0, 0, 0, 0.95) !important; -} - -.ui.slider.checkbox input:focus:checked ~ label:before { - background-color: #000000 !important; -} - -/*-------------- - Toggle - ---------------*/ - -.ui.toggle.checkbox { - min-height: 1.5rem; -} - -/* Input */ - -.ui.toggle.checkbox input { - width: 3.5rem; - height: 1.5rem; -} - -/* Label */ - -.ui.toggle.checkbox label { - min-height: 1.5rem; - padding-left: 4.5rem; - color: rgba(0, 0, 0, 0.87); -} - -.ui.toggle.checkbox label { - padding-top: 0.15em; -} - -/* Switch */ - -.ui.toggle.checkbox label:before { - display: block; - position: absolute; - content: ''; - z-index: 1; - transform: none; - border: none; - top: 0; - background: rgba(0, 0, 0, 0.05); - box-shadow: none; - width: 3.5rem; - height: 1.5rem; - border-radius: 500rem; -} - -/* Handle */ - -.ui.toggle.checkbox label:after { - background: #FFFFFF linear-gradient(transparent, rgba(0, 0, 0, 0.05)); - position: absolute; - content: '' !important; - opacity: 1; - z-index: 2; - border: none; - box-shadow: 0 1px 2px 0 rgba(34, 36, 38, 0.15), 0 0 0 1px rgba(34, 36, 38, 0.15) inset; - width: 1.5rem; - height: 1.5rem; - top: 0; - left: 0; - border-radius: 500rem; - transition: background 0.3s ease, left 0.3s ease; -} - -.ui.toggle.checkbox input ~ label:after { - left: -0.05rem; - box-shadow: 0 1px 2px 0 rgba(34, 36, 38, 0.15), 0 0 0 1px rgba(34, 36, 38, 0.15) inset; -} - -/* Focus */ - -.ui.toggle.checkbox input:focus ~ label:before { - background-color: rgba(0, 0, 0, 0.15); - border: none; -} - -/* Hover */ - -.ui.toggle.checkbox label:hover::before { - background-color: rgba(0, 0, 0, 0.15); - border: none; -} - -/* Active */ - -.ui.toggle.checkbox input:checked ~ label { - color: rgba(0, 0, 0, 0.95) !important; -} - -.ui.toggle.checkbox input:checked ~ label:before { - background-color: #2185D0 !important; -} - -.ui.toggle.checkbox input:checked ~ label:after { - left: 2.15rem; - box-shadow: 0 1px 2px 0 rgba(34, 36, 38, 0.15), 0 0 0 1px rgba(34, 36, 38, 0.15) inset; -} - -/* Active Focus */ - -.ui.toggle.checkbox input:focus:checked ~ label { - color: rgba(0, 0, 0, 0.95) !important; -} - -.ui.toggle.checkbox input:focus:checked ~ label:before { - background-color: #0d71bb !important; -} - -/******************************* - Variations -*******************************/ - -/*-------------- - Fitted - ---------------*/ - -.ui.fitted.checkbox label { - padding-left: 0 !important; -} - -.ui.fitted.toggle.checkbox { - width: 3.5rem; -} - -.ui.fitted.slider.checkbox { - width: 3.5rem; -} - -/*-------------------- - Size ----------------------*/ - -.ui.mini.checkbox { - font-size: 0.78571429em; -} - -.ui.tiny.checkbox { - font-size: 0.85714286em; -} - -.ui.small.checkbox { - font-size: 0.92857143em; -} - -.ui.large.checkbox { - font-size: 1.14285714em; -} - -.ui.large.form .checkbox:not(.slider):not(.toggle):not(.radio) label:after, -.ui.large.checkbox:not(.slider):not(.toggle):not(.radio) label:after, -.ui.large.form .checkbox:not(.slider):not(.toggle):not(.radio) label:before, -.ui.large.checkbox:not(.slider):not(.toggle):not(.radio) label:before { - transform: scale(1.14285714); - transform-origin: left; -} - -.ui.large.form .checkbox.radio label:before, -.ui.large.checkbox.radio label:before { - transform: scale(1.14285714); - transform-origin: left; -} - -.ui.large.form .checkbox.radio label:after, -.ui.large.checkbox.radio label:after { - transform: scale(0.57142857); - transform-origin: left; - left: 0.33571429em; -} - -.ui.big.checkbox { - font-size: 1.28571429em; -} - -.ui.big.form .checkbox:not(.slider):not(.toggle):not(.radio) label:after, -.ui.big.checkbox:not(.slider):not(.toggle):not(.radio) label:after, -.ui.big.form .checkbox:not(.slider):not(.toggle):not(.radio) label:before, -.ui.big.checkbox:not(.slider):not(.toggle):not(.radio) label:before { - transform: scale(1.28571429); - transform-origin: left; -} - -.ui.big.form .checkbox.radio label:before, -.ui.big.checkbox.radio label:before { - transform: scale(1.28571429); - transform-origin: left; -} - -.ui.big.form .checkbox.radio label:after, -.ui.big.checkbox.radio label:after { - transform: scale(0.64285714); - transform-origin: left; - left: 0.37142857em; -} - -.ui.huge.checkbox { - font-size: 1.42857143em; -} - -.ui.huge.form .checkbox:not(.slider):not(.toggle):not(.radio) label:after, -.ui.huge.checkbox:not(.slider):not(.toggle):not(.radio) label:after, -.ui.huge.form .checkbox:not(.slider):not(.toggle):not(.radio) label:before, -.ui.huge.checkbox:not(.slider):not(.toggle):not(.radio) label:before { - transform: scale(1.42857143); - transform-origin: left; -} - -.ui.huge.form .checkbox.radio label:before, -.ui.huge.checkbox.radio label:before { - transform: scale(1.42857143); - transform-origin: left; -} - -.ui.huge.form .checkbox.radio label:after, -.ui.huge.checkbox.radio label:after { - transform: scale(0.71428571); - transform-origin: left; - left: 0.40714286em; -} - -.ui.massive.checkbox { - font-size: 1.71428571em; -} - -.ui.massive.form .checkbox:not(.slider):not(.toggle):not(.radio) label:after, -.ui.massive.checkbox:not(.slider):not(.toggle):not(.radio) label:after, -.ui.massive.form .checkbox:not(.slider):not(.toggle):not(.radio) label:before, -.ui.massive.checkbox:not(.slider):not(.toggle):not(.radio) label:before { - transform: scale(1.71428571); - transform-origin: left; -} - -.ui.massive.form .checkbox.radio label:before, -.ui.massive.checkbox.radio label:before { - transform: scale(1.71428571); - transform-origin: left; -} - -.ui.massive.form .checkbox.radio label:after, -.ui.massive.checkbox.radio label:after { - transform: scale(0.85714286); - transform-origin: left; - left: 0.47857143em; -} - -/******************************* - Theme Overrides -*******************************/ - -@font-face { - font-family: 'Checkbox'; - src: url(data:application/x-font-ttf;charset=utf-8;base64,AAEAAAALAIAAAwAwT1MvMg8SBD8AAAC8AAAAYGNtYXAYVtCJAAABHAAAAFRnYXNwAAAAEAAAAXAAAAAIZ2x5Zn4huwUAAAF4AAABYGhlYWQGPe1ZAAAC2AAAADZoaGVhB30DyAAAAxAAAAAkaG10eBBKAEUAAAM0AAAAHGxvY2EAmgESAAADUAAAABBtYXhwAAkALwAAA2AAAAAgbmFtZSC8IugAAAOAAAABknBvc3QAAwAAAAAFFAAAACAAAwMTAZAABQAAApkCzAAAAI8CmQLMAAAB6wAzAQkAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAAAAAAAAAAAABAAADoAgPA/8AAQAPAAEAAAAABAAAAAAAAAAAAAAAgAAAAAAADAAAAAwAAABwAAQADAAAAHAADAAEAAAAcAAQAOAAAAAoACAACAAIAAQAg6AL//f//AAAAAAAg6AD//f//AAH/4xgEAAMAAQAAAAAAAAAAAAAAAQAB//8ADwABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAEUAUQO7AvgAGgAAARQHAQYjIicBJjU0PwE2MzIfAQE2MzIfARYVA7sQ/hQQFhcQ/uMQEE4QFxcQqAF2EBcXEE4QAnMWEP4UEBABHRAXFhBOEBCoAXcQEE4QFwAAAAABAAABbgMlAkkAFAAAARUUBwYjISInJj0BNDc2MyEyFxYVAyUQEBf9SRcQEBAQFwK3FxAQAhJtFxAQEBAXbRcQEBAQFwAAAAABAAAASQMlA24ALAAAARUUBwYrARUUBwYrASInJj0BIyInJj0BNDc2OwE1NDc2OwEyFxYdATMyFxYVAyUQEBfuEBAXbhYQEO4XEBAQEBfuEBAWbhcQEO4XEBACEm0XEBDuFxAQEBAX7hAQF20XEBDuFxAQEBAX7hAQFwAAAQAAAAIAAHRSzT9fDzz1AAsEAAAAAADRsdR3AAAAANGx1HcAAAAAA7sDbgAAAAgAAgAAAAAAAAABAAADwP/AAAAEAAAAAAADuwABAAAAAAAAAAAAAAAAAAAABwQAAAAAAAAAAAAAAAIAAAAEAABFAyUAAAMlAAAAAAAAAAoAFAAeAE4AcgCwAAEAAAAHAC0AAQAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAOAK4AAQAAAAAAAQAIAAAAAQAAAAAAAgAHAGkAAQAAAAAAAwAIADkAAQAAAAAABAAIAH4AAQAAAAAABQALABgAAQAAAAAABgAIAFEAAQAAAAAACgAaAJYAAwABBAkAAQAQAAgAAwABBAkAAgAOAHAAAwABBAkAAwAQAEEAAwABBAkABAAQAIYAAwABBAkABQAWACMAAwABBAkABgAQAFkAAwABBAkACgA0ALBDaGVja2JveABDAGgAZQBjAGsAYgBvAHhWZXJzaW9uIDIuMABWAGUAcgBzAGkAbwBuACAAMgAuADBDaGVja2JveABDAGgAZQBjAGsAYgBvAHhDaGVja2JveABDAGgAZQBjAGsAYgBvAHhSZWd1bGFyAFIAZQBnAHUAbABhAHJDaGVja2JveABDAGgAZQBjAGsAYgBvAHhGb250IGdlbmVyYXRlZCBieSBJY29Nb29uLgBGAG8AbgB0ACAAZwBlAG4AZQByAGEAdABlAGQAIABiAHkAIABJAGMAbwBNAG8AbwBuAC4AAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) format('truetype'); -} - -/* Checkmark */ - -.ui.checkbox label:after, -.ui.checkbox .box:after { - font-family: 'Checkbox'; -} - -/* Checked */ - -.ui.checkbox input:checked ~ .box:after, -.ui.checkbox input:checked ~ label:after { - content: '\e800'; -} - -/* Indeterminate */ - -.ui.checkbox input:indeterminate ~ .box:after, -.ui.checkbox input:indeterminate ~ label:after { - font-size: 12px; - content: '\e801'; -} - -/* UTF Reference -.check:before { content: '\e800'; } -.dash:before { content: '\e801'; } -.plus:before { content: '\e802'; } -*/ - /******************************* Site Overrides *******************************/ diff --git a/web_src/fomantic/build/semantic.js b/web_src/fomantic/build/semantic.js index 1199e9c82f..c150c8d9db 100644 --- a/web_src/fomantic/build/semantic.js +++ b/web_src/fomantic/build/semantic.js @@ -1184,883 +1184,6 @@ $.api.settings = { -})( jQuery, window, document ); - -/*! - * # Fomantic-UI - Checkbox - * http://github.com/fomantic/Fomantic-UI/ - * - * - * Released under the MIT license - * http://opensource.org/licenses/MIT - * - */ - -;(function ($, window, document, undefined) { - -'use strict'; - -$.isFunction = $.isFunction || function(obj) { - return typeof obj === "function" && typeof obj.nodeType !== "number"; -}; - -window = (typeof window != 'undefined' && window.Math == Math) - ? window - : (typeof self != 'undefined' && self.Math == Math) - ? self - : Function('return this')() -; - -$.fn.checkbox = function(parameters) { - var - $allModules = $(this), - moduleSelector = $allModules.selector || '', - - time = new Date().getTime(), - performance = [], - - query = arguments[0], - methodInvoked = (typeof query == 'string'), - queryArguments = [].slice.call(arguments, 1), - returnedValue - ; - - $allModules - .each(function() { - var - settings = $.extend(true, {}, $.fn.checkbox.settings, parameters), - - className = settings.className, - namespace = settings.namespace, - selector = settings.selector, - error = settings.error, - - eventNamespace = '.' + namespace, - moduleNamespace = 'module-' + namespace, - - $module = $(this), - $label = $(this).children(selector.label), - $input = $(this).children(selector.input), - input = $input[0], - - initialLoad = false, - shortcutPressed = false, - instance = $module.data(moduleNamespace), - - observer, - element = this, - module - ; - - module = { - - initialize: function() { - module.verbose('Initializing checkbox', settings); - - module.create.label(); - module.bind.events(); - - module.set.tabbable(); - module.hide.input(); - - module.observeChanges(); - module.instantiate(); - module.setup(); - }, - - instantiate: function() { - module.verbose('Storing instance of module', module); - instance = module; - $module - .data(moduleNamespace, module) - ; - }, - - destroy: function() { - module.verbose('Destroying module'); - module.unbind.events(); - module.show.input(); - $module.removeData(moduleNamespace); - }, - - fix: { - reference: function() { - if( $module.is(selector.input) ) { - module.debug('Behavior called on adjusting invoked element'); - $module = $module.closest(selector.checkbox); - module.refresh(); - } - } - }, - - setup: function() { - module.set.initialLoad(); - if( module.is.indeterminate() ) { - module.debug('Initial value is indeterminate'); - module.indeterminate(); - } - else if( module.is.checked() ) { - module.debug('Initial value is checked'); - module.check(); - } - else { - module.debug('Initial value is unchecked'); - module.uncheck(); - } - module.remove.initialLoad(); - }, - - refresh: function() { - $label = $module.children(selector.label); - $input = $module.children(selector.input); - input = $input[0]; - }, - - hide: { - input: function() { - module.verbose('Modifying z-index to be unselectable'); - $input.addClass(className.hidden); - } - }, - show: { - input: function() { - module.verbose('Modifying z-index to be selectable'); - $input.removeClass(className.hidden); - } - }, - - observeChanges: function() { - if('MutationObserver' in window) { - observer = new MutationObserver(function(mutations) { - module.debug('DOM tree modified, updating selector cache'); - module.refresh(); - }); - observer.observe(element, { - childList : true, - subtree : true - }); - module.debug('Setting up mutation observer', observer); - } - }, - - attachEvents: function(selector, event) { - var - $element = $(selector) - ; - event = $.isFunction(module[event]) - ? module[event] - : module.toggle - ; - if($element.length > 0) { - module.debug('Attaching checkbox events to element', selector, event); - $element - .on('click' + eventNamespace, event) - ; - } - else { - module.error(error.notFound); - } - }, - - preventDefaultOnInputTarget: function() { - if(typeof event !== 'undefined' && event !== null && $(event.target).is(selector.input)) { - module.verbose('Preventing default check action after manual check action'); - event.preventDefault(); - } - }, - - event: { - change: function(event) { - if( !module.should.ignoreCallbacks() ) { - settings.onChange.call(input); - } - }, - click: function(event) { - var - $target = $(event.target) - ; - if( $target.is(selector.input) ) { - module.verbose('Using default check action on initialized checkbox'); - return; - } - if( $target.is(selector.link) ) { - module.debug('Clicking link inside checkbox, skipping toggle'); - return; - } - module.toggle(); - $input.focus(); - event.preventDefault(); - }, - keydown: function(event) { - var - key = event.which, - keyCode = { - enter : 13, - space : 32, - escape : 27, - left : 37, - up : 38, - right : 39, - down : 40 - } - ; - - var r = module.get.radios(), - rIndex = r.index($module), - rLen = r.length, - checkIndex = false; - - if(key == keyCode.left || key == keyCode.up) { - checkIndex = (rIndex === 0 ? rLen : rIndex) - 1; - } else if(key == keyCode.right || key == keyCode.down) { - checkIndex = rIndex === rLen-1 ? 0 : rIndex+1; - } - - if (!module.should.ignoreCallbacks() && checkIndex !== false) { - if(settings.beforeUnchecked.apply(input)===false) { - module.verbose('Option not allowed to be unchecked, cancelling key navigation'); - return false; - } - if (settings.beforeChecked.apply($(r[checkIndex]).children(selector.input)[0])===false) { - module.verbose('Next option should not allow check, cancelling key navigation'); - return false; - } - } - - if(key == keyCode.escape) { - module.verbose('Escape key pressed blurring field'); - $input.blur(); - shortcutPressed = true; - } - else if(!event.ctrlKey && ( key == keyCode.space || (key == keyCode.enter && settings.enableEnterKey)) ) { - module.verbose('Enter/space key pressed, toggling checkbox'); - module.toggle(); - shortcutPressed = true; - } - else { - shortcutPressed = false; - } - }, - keyup: function(event) { - if(shortcutPressed) { - event.preventDefault(); - } - } - }, - - check: function() { - if( !module.should.allowCheck() ) { - return; - } - module.debug('Checking checkbox', $input); - module.set.checked(); - if( !module.should.ignoreCallbacks() ) { - settings.onChecked.call(input); - module.trigger.change(); - } - module.preventDefaultOnInputTarget(); - }, - - uncheck: function() { - if( !module.should.allowUncheck() ) { - return; - } - module.debug('Unchecking checkbox'); - module.set.unchecked(); - if( !module.should.ignoreCallbacks() ) { - settings.onUnchecked.call(input); - module.trigger.change(); - } - module.preventDefaultOnInputTarget(); - }, - - indeterminate: function() { - if( module.should.allowIndeterminate() ) { - module.debug('Checkbox is already indeterminate'); - return; - } - module.debug('Making checkbox indeterminate'); - module.set.indeterminate(); - if( !module.should.ignoreCallbacks() ) { - settings.onIndeterminate.call(input); - module.trigger.change(); - } - }, - - determinate: function() { - if( module.should.allowDeterminate() ) { - module.debug('Checkbox is already determinate'); - return; - } - module.debug('Making checkbox determinate'); - module.set.determinate(); - if( !module.should.ignoreCallbacks() ) { - settings.onDeterminate.call(input); - module.trigger.change(); - } - }, - - enable: function() { - if( module.is.enabled() ) { - module.debug('Checkbox is already enabled'); - return; - } - module.debug('Enabling checkbox'); - module.set.enabled(); - if( !module.should.ignoreCallbacks() ) { - settings.onEnable.call(input); - // preserve legacy callbacks - settings.onEnabled.call(input); - module.trigger.change(); - } - }, - - disable: function() { - if( module.is.disabled() ) { - module.debug('Checkbox is already disabled'); - return; - } - module.debug('Disabling checkbox'); - module.set.disabled(); - if( !module.should.ignoreCallbacks() ) { - settings.onDisable.call(input); - // preserve legacy callbacks - settings.onDisabled.call(input); - module.trigger.change(); - } - }, - - get: { - radios: function() { - var - name = module.get.name() - ; - return $('input[name="' + name + '"]').closest(selector.checkbox); - }, - otherRadios: function() { - return module.get.radios().not($module); - }, - name: function() { - return $input.attr('name'); - } - }, - - is: { - initialLoad: function() { - return initialLoad; - }, - radio: function() { - return ($input.hasClass(className.radio) || $input.attr('type') == 'radio'); - }, - indeterminate: function() { - return $input.prop('indeterminate') !== undefined && $input.prop('indeterminate'); - }, - checked: function() { - return $input.prop('checked') !== undefined && $input.prop('checked'); - }, - disabled: function() { - return $input.prop('disabled') !== undefined && $input.prop('disabled'); - }, - enabled: function() { - return !module.is.disabled(); - }, - determinate: function() { - return !module.is.indeterminate(); - }, - unchecked: function() { - return !module.is.checked(); - } - }, - - should: { - allowCheck: function() { - if(module.is.determinate() && module.is.checked() && !module.is.initialLoad() ) { - module.debug('Should not allow check, checkbox is already checked'); - return false; - } - if(!module.should.ignoreCallbacks() && settings.beforeChecked.apply(input) === false) { - module.debug('Should not allow check, beforeChecked cancelled'); - return false; - } - return true; - }, - allowUncheck: function() { - if(module.is.determinate() && module.is.unchecked() && !module.is.initialLoad() ) { - module.debug('Should not allow uncheck, checkbox is already unchecked'); - return false; - } - if(!module.should.ignoreCallbacks() && settings.beforeUnchecked.apply(input) === false) { - module.debug('Should not allow uncheck, beforeUnchecked cancelled'); - return false; - } - return true; - }, - allowIndeterminate: function() { - if(module.is.indeterminate() && !module.is.initialLoad() ) { - module.debug('Should not allow indeterminate, checkbox is already indeterminate'); - return false; - } - if(!module.should.ignoreCallbacks() && settings.beforeIndeterminate.apply(input) === false) { - module.debug('Should not allow indeterminate, beforeIndeterminate cancelled'); - return false; - } - return true; - }, - allowDeterminate: function() { - if(module.is.determinate() && !module.is.initialLoad() ) { - module.debug('Should not allow determinate, checkbox is already determinate'); - return false; - } - if(!module.should.ignoreCallbacks() && settings.beforeDeterminate.apply(input) === false) { - module.debug('Should not allow determinate, beforeDeterminate cancelled'); - return false; - } - return true; - }, - ignoreCallbacks: function() { - return (initialLoad && !settings.fireOnInit); - } - }, - - can: { - change: function() { - return !( $module.hasClass(className.disabled) || $module.hasClass(className.readOnly) || $input.prop('disabled') || $input.prop('readonly') ); - }, - uncheck: function() { - return (typeof settings.uncheckable === 'boolean') - ? settings.uncheckable - : !module.is.radio() - ; - } - }, - - set: { - initialLoad: function() { - initialLoad = true; - }, - checked: function() { - module.verbose('Setting class to checked'); - $module - .removeClass(className.indeterminate) - .addClass(className.checked) - ; - if( module.is.radio() ) { - module.uncheckOthers(); - } - if(!module.is.indeterminate() && module.is.checked()) { - module.debug('Input is already checked, skipping input property change'); - return; - } - module.verbose('Setting state to checked', input); - $input - .prop('indeterminate', false) - .prop('checked', true) - ; - }, - unchecked: function() { - module.verbose('Removing checked class'); - $module - .removeClass(className.indeterminate) - .removeClass(className.checked) - ; - if(!module.is.indeterminate() && module.is.unchecked() ) { - module.debug('Input is already unchecked'); - return; - } - module.debug('Setting state to unchecked'); - $input - .prop('indeterminate', false) - .prop('checked', false) - ; - }, - indeterminate: function() { - module.verbose('Setting class to indeterminate'); - $module - .addClass(className.indeterminate) - ; - if( module.is.indeterminate() ) { - module.debug('Input is already indeterminate, skipping input property change'); - return; - } - module.debug('Setting state to indeterminate'); - $input - .prop('indeterminate', true) - ; - }, - determinate: function() { - module.verbose('Removing indeterminate class'); - $module - .removeClass(className.indeterminate) - ; - if( module.is.determinate() ) { - module.debug('Input is already determinate, skipping input property change'); - return; - } - module.debug('Setting state to determinate'); - $input - .prop('indeterminate', false) - ; - }, - disabled: function() { - module.verbose('Setting class to disabled'); - $module - .addClass(className.disabled) - ; - if( module.is.disabled() ) { - module.debug('Input is already disabled, skipping input property change'); - return; - } - module.debug('Setting state to disabled'); - $input - .prop('disabled', 'disabled') - ; - }, - enabled: function() { - module.verbose('Removing disabled class'); - $module.removeClass(className.disabled); - if( module.is.enabled() ) { - module.debug('Input is already enabled, skipping input property change'); - return; - } - module.debug('Setting state to enabled'); - $input - .prop('disabled', false) - ; - }, - tabbable: function() { - module.verbose('Adding tabindex to checkbox'); - if( $input.attr('tabindex') === undefined) { - $input.attr('tabindex', 0); - } - } - }, - - remove: { - initialLoad: function() { - initialLoad = false; - } - }, - - trigger: { - change: function() { - var - inputElement = $input[0] - ; - if(inputElement) { - var events = document.createEvent('HTMLEvents'); - module.verbose('Triggering native change event'); - events.initEvent('change', true, false); - inputElement.dispatchEvent(events); - } - } - }, - - - create: { - label: function() { - if($input.prevAll(selector.label).length > 0) { - $input.prev(selector.label).detach().insertAfter($input); - module.debug('Moving existing label', $label); - } - else if( !module.has.label() ) { - $label = $('