Add Organization Wide Labels (#10814)

* Add organization wide labels

Implement organization wide labels similar to organization wide
webhooks. This lets you create individual labels for organizations that can be used
for all repos under that organization (so being able to reuse the same
label across multiple repos).

This makes it possible for small organizations with many repos to use
labels effectively.

Fixes #7406

* Add migration

* remove comments

* fix tests

* Update options/locale/locale_en-US.ini

Removed unused translation string

* show org labels in issue search label filter

* Use more clear var name

* rename migration after merge from master

* comment typo

* update migration again after rebase with master

* check for orgID <=0 per guillep2k review

* fmt

* Apply suggestions from code review

Co-Authored-By: guillep2k <18600385+guillep2k@users.noreply.github.com>

* remove unused code

* Make sure RepoID is 0 when searching orgID per code review

* more changes/code review requests

* More descriptive translation var per code review

* func description/delete comment when issue label deleted instead of hiding it

* remove comment

* only use issues in that repo when calculating number of open issues for org label on repo label page

* Add integration test for IssuesSearch API with labels

* remove unused function

* Update models/issue_label.go

Co-Authored-By: guillep2k <18600385+guillep2k@users.noreply.github.com>

* Use subquery in GetLabelIDsInReposByNames

* Fix tests to use correct orgID

* fix more tests

* IssuesSearch api now uses new BuildLabelNamesIssueIDsCondition. Add a few more tests as well

* update comment for clarity

* Revert previous code change now that we can use the new BuildLabelNamesIssueIDsCondition

* Don't sort repos by date in IssuesSearch API

After much debugging I've found a strange issue where in some cases MySQL will return a different result than other enigines if a query is sorted by a null collumn. For example with our integration test data where we don't set updated_unix in repository fixtures:

SELECT `id`, `owner_id`, `owner_name`, `lower_name`, `name`, `description`, `website`, `original_service_type`, `original_url`, `default_branch`, `num_watches`, `num_stars`, `num_forks`, `num_issues`, `num_closed_issues`, `num_pulls`, `num_closed_pulls`, `num_milestones`, `num_closed_milestones`, `is_private`, `is_empty`, `is_archived`, `is_mirror`, `status`, `is_fork`, `fork_id`, `is_template`, `template_id`, `size`, `is_fsck_enabled`, `close_issues_via_commit_in_any_branch`, `topics`, `avatar`, `created_unix`, `updated_unix` FROM `repository` ORDER BY updated_unix DESC LIMIT 15 OFFSET 45

Returns different results for MySQL than other engines. However, the similar query:

SELECT `id`, `owner_id`, `owner_name`, `lower_name`, `name`, `description`, `website`, `original_service_type`, `original_url`, `default_branch`, `num_watches`, `num_stars`, `num_forks`, `num_issues`, `num_closed_issues`, `num_pulls`, `num_closed_pulls`, `num_milestones`, `num_closed_milestones`, `is_private`, `is_empty`, `is_archived`, `is_mirror`, `status`, `is_fork`, `fork_id`, `is_template`, `template_id`, `size`, `is_fsck_enabled`, `close_issues_via_commit_in_any_branch`, `topics`, `avatar`, `created_unix`, `updated_unix` FROM `repository` ORDER BY updated_unix DESC LIMIT 15 OFFSET 30

Returns the same results.

This causes integration tests to fail on MySQL in certain cases but would never show up in a real installation. Since this API call always returns issues based on the optionally provided repo_priority_id or the issueID itself, there is no change to results by changing the repo sorting method used to get ids earlier in the function.

* linter is back!

* code review

* remove now unused option

* Fix newline at end of files

* more unused code

* update to master

* check for matching ids before query

* Update models/issue_label.go

Co-Authored-By: 6543 <6543@obermui.de>

* Update models/issue_label.go

* update comments

* Update routers/org/setting.go

Co-authored-by: Lauris BH <lauris@nix.lv>
Co-authored-by: guillep2k <18600385+guillep2k@users.noreply.github.com>
Co-authored-by: 6543 <6543@obermui.de>
pull/10911/head^2
mrsdizzie 2020-04-01 00:14:46 -04:00 committed by GitHub
parent 36d9237e60
commit 8d99ee2773
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1627 additions and 315 deletions

View File

@ -134,3 +134,74 @@ func TestAPIReplaceIssueLabels(t *testing.T) {
models.AssertCount(t, &models.IssueLabel{IssueID: issue.ID}, 1)
models.AssertExistsAndLoadBean(t, &models.IssueLabel{IssueID: issue.ID, LabelID: label.ID})
}
func TestAPIModifyOrgLabels(t *testing.T) {
assert.NoError(t, models.LoadFixtures())
repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository)
owner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User)
user := "user1"
session := loginUser(t, user)
token := getTokenForLoggedInUser(t, session)
urlStr := fmt.Sprintf("/api/v1/orgs/%s/labels?token=%s", owner.Name, token)
// CreateLabel
req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{
Name: "TestL 1",
Color: "abcdef",
Description: "test label",
})
resp := session.MakeRequest(t, req, http.StatusCreated)
apiLabel := new(api.Label)
DecodeJSON(t, resp, &apiLabel)
dbLabel := models.AssertExistsAndLoadBean(t, &models.Label{ID: apiLabel.ID, OrgID: owner.ID}).(*models.Label)
assert.EqualValues(t, dbLabel.Name, apiLabel.Name)
assert.EqualValues(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color)
req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{
Name: "TestL 2",
Color: "#123456",
Description: "jet another test label",
})
session.MakeRequest(t, req, http.StatusCreated)
req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{
Name: "WrongTestL",
Color: "#12345g",
})
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
//ListLabels
req = NewRequest(t, "GET", urlStr)
resp = session.MakeRequest(t, req, http.StatusOK)
var apiLabels []*api.Label
DecodeJSON(t, resp, &apiLabels)
assert.Len(t, apiLabels, 4)
//GetLabel
singleURLStr := fmt.Sprintf("/api/v1/orgs/%s/labels/%d?token=%s", owner.Name, dbLabel.ID, token)
req = NewRequest(t, "GET", singleURLStr)
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiLabel)
assert.EqualValues(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color)
//EditLabel
newName := "LabelNewName"
newColor := "09876a"
newColorWrong := "09g76a"
req = NewRequestWithJSON(t, "PATCH", singleURLStr, &api.EditLabelOption{
Name: &newName,
Color: &newColor,
})
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiLabel)
assert.EqualValues(t, newColor, apiLabel.Color)
req = NewRequestWithJSON(t, "PATCH", singleURLStr, &api.EditLabelOption{
Color: &newColorWrong,
})
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
//DeleteLabel
req = NewRequest(t, "DELETE", singleURLStr)
resp = session.MakeRequest(t, req, http.StatusNoContent)
}

View File

@ -130,7 +130,7 @@ func TestAPIEditIssue(t *testing.T) {
assert.Equal(t, title, issueAfter.Title)
}
func TestAPISearchIssue(t *testing.T) {
func TestAPISearchIssues(t *testing.T) {
defer prepareTestEnv(t)()
session := loginUser(t, "user2")
@ -173,3 +173,66 @@ func TestAPISearchIssue(t *testing.T) {
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 1)
}
func TestAPISearchIssuesWithLabels(t *testing.T) {
defer prepareTestEnv(t)()
session := loginUser(t, "user1")
token := getTokenForLoggedInUser(t, session)
link, _ := url.Parse("/api/v1/repos/issues/search")
req := NewRequest(t, "GET", link.String())
resp := session.MakeRequest(t, req, http.StatusOK)
var apiIssues []*api.Issue
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 9)
query := url.Values{}
query.Add("token", token)
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 9)
query.Add("labels", "label1")
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 2)
// multiple labels
query.Set("labels", "label1,label2")
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 2)
// an org label
query.Set("labels", "orglabel4")
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 1)
// org and repo label
query.Set("labels", "label2,orglabel4")
query.Add("state", "all")
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 2)
// org and repo label which share the same issue
query.Set("labels", "label1,orglabel4")
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 2)
}

View File

@ -1502,10 +1502,41 @@ func (err ErrTrackedTimeNotExist) Error() string {
// |_______ (____ /___ /\___ >____/
// \/ \/ \/ \/
// ErrRepoLabelNotExist represents a "RepoLabelNotExist" kind of error.
type ErrRepoLabelNotExist struct {
LabelID int64
RepoID int64
}
// IsErrRepoLabelNotExist checks if an error is a RepoErrLabelNotExist.
func IsErrRepoLabelNotExist(err error) bool {
_, ok := err.(ErrRepoLabelNotExist)
return ok
}
func (err ErrRepoLabelNotExist) Error() string {
return fmt.Sprintf("label does not exist [label_id: %d, repo_id: %d]", err.LabelID, err.RepoID)
}
// ErrOrgLabelNotExist represents a "OrgLabelNotExist" kind of error.
type ErrOrgLabelNotExist struct {
LabelID int64
OrgID int64
}
// IsErrOrgLabelNotExist checks if an error is a OrgErrLabelNotExist.
func IsErrOrgLabelNotExist(err error) bool {
_, ok := err.(ErrOrgLabelNotExist)
return ok
}
func (err ErrOrgLabelNotExist) Error() string {
return fmt.Sprintf("label does not exist [label_id: %d, org_id: %d]", err.LabelID, err.OrgID)
}
// ErrLabelNotExist represents a "LabelNotExist" kind of error.
type ErrLabelNotExist struct {
LabelID int64
RepoID int64
}
// IsErrLabelNotExist checks if an error is a ErrLabelNotExist.
@ -1515,7 +1546,7 @@ func IsErrLabelNotExist(err error) bool {
}
func (err ErrLabelNotExist) Error() string {
return fmt.Sprintf("label does not exist [label_id: %d, repo_id: %d]", err.LabelID, err.RepoID)
return fmt.Sprintf("label does not exist [label_id: %d]", err.LabelID)
}
// _____ .__.__ __

View File

@ -12,3 +12,8 @@
id: 3
issue_id: 2
label_id: 1
-
id: 4
issue_id: 2
label_id: 4

View File

@ -1,6 +1,7 @@
-
id: 1
repo_id: 1
org_id: 0
name: label1
color: '#abcdef'
num_issues: 2
@ -9,7 +10,26 @@
-
id: 2
repo_id: 1
org_id: 0
name: label2
color: '#000000'
num_issues: 1
num_closed_issues: 1
-
id: 3
repo_id: 0
org_id: 3
name: orglabel3
color: '#abcdef'
num_issues: 0
num_closed_issues: 0
-
id: 4
repo_id: 0
org_id: 3
name: orglabel4
color: '#000000'
num_issues: 1
num_closed_issues: 0

View File

@ -459,7 +459,7 @@ func (issue *Issue) ClearLabels(doer *User) (err error) {
return err
}
if !perm.CanWriteIssuesOrPulls(issue.IsPull) {
return ErrLabelNotExist{}
return ErrRepoLabelNotExist{}
}
if err = issue.clearLabels(sess, doer); err != nil {
@ -894,7 +894,7 @@ func newIssue(e *xorm.Session, doer *User, opts NewIssueOptions) (err error) {
for _, label := range labels {
// Silently drop invalid labels.
if label.RepoID != opts.Repo.ID {
if label.RepoID != opts.Repo.ID && label.OrgID != opts.Repo.OwnerID {
continue
}

View File

@ -21,18 +21,20 @@ var LabelColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")
// Label represents a label of repository for issues.
type Label struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
Name string
Description string
Color string `xorm:"VARCHAR(7)"`
NumIssues int
NumClosedIssues int
NumOpenIssues int `xorm:"-"`
IsChecked bool `xorm:"-"`
QueryString string `xorm:"-"`
IsSelected bool `xorm:"-"`
IsExcluded bool `xorm:"-"`
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
OrgID int64 `xorm:"INDEX"`
Name string
Description string
Color string `xorm:"VARCHAR(7)"`
NumIssues int
NumClosedIssues int
NumOpenIssues int `xorm:"-"`
NumOpenRepoIssues int64 `xorm:"-"`
IsChecked bool `xorm:"-"`
QueryString string `xorm:"-"`
IsSelected bool `xorm:"-"`
IsExcluded bool `xorm:"-"`
}
// GetLabelTemplateFile loads the label template file by given name,
@ -79,11 +81,26 @@ func GetLabelTemplateFile(name string) ([][3]string, error) {
return list, nil
}
// CalOpenIssues calculates the open issues of label.
// CalOpenIssues sets the number of open issues of a label based on the already stored number of closed issues.
func (label *Label) CalOpenIssues() {
label.NumOpenIssues = label.NumIssues - label.NumClosedIssues
}
// CalOpenOrgIssues calculates the open issues of a label for a specific repo
func (label *Label) CalOpenOrgIssues(repoID, labelID int64) {
repoIDs := []int64{repoID}
labelIDs := []int64{labelID}
counts, _ := CountIssuesByRepo(&IssuesOptions{
RepoIDs: repoIDs,
LabelIDs: labelIDs,
})
for _, count := range counts {
label.NumOpenRepoIssues += count
}
}
// LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked
func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64) {
var labelQuerySlice []string
@ -106,6 +123,16 @@ func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64)
label.QueryString = strings.Join(labelQuerySlice, ",")
}
// BelongsToOrg returns true if label is an organization label
func (label *Label) BelongsToOrg() bool {
return label.OrgID > 0
}
// BelongsToRepo returns true if label is a repository label
func (label *Label) BelongsToRepo() bool {
return label.RepoID > 0
}
// ForegroundColor calculates the text color for labels based
// on their background color.
func (label *Label) ForegroundColor() template.CSS {
@ -126,6 +153,12 @@ func (label *Label) ForegroundColor() template.CSS {
return template.CSS("#000")
}
// .____ ___. .__
// | | _____ \_ |__ ____ | |
// | | \__ \ | __ \_/ __ \| |
// | |___ / __ \| \_\ \ ___/| |__
// >_______ (____ /___ /\___ >____/
func loadLabels(labelTemplate string) ([]string, error) {
list, err := GetLabelTemplateFile(labelTemplate)
if err != nil {
@ -145,7 +178,7 @@ func LoadLabelsFormatted(labelTemplate string) (string, error) {
return strings.Join(labels, ", "), err
}
func initializeLabels(e Engine, repoID int64, labelTemplate string) error {
func initializeLabels(e Engine, id int64, labelTemplate string, isOrg bool) error {
list, err := GetLabelTemplateFile(labelTemplate)
if err != nil {
return ErrIssueLabelTemplateLoad{labelTemplate, err}
@ -154,11 +187,15 @@ func initializeLabels(e Engine, repoID int64, labelTemplate string) error {
labels := make([]*Label, len(list))
for i := 0; i < len(list); i++ {
labels[i] = &Label{
RepoID: repoID,
Name: list[i][0],
Description: list[i][2],
Color: list[i][1],
}
if isOrg {
labels[i].OrgID = id
} else {
labels[i].RepoID = id
}
}
for _, label := range labels {
if err = newLabel(e, label); err != nil {
@ -169,8 +206,8 @@ func initializeLabels(e Engine, repoID int64, labelTemplate string) error {
}
// InitializeLabels adds a label set to a repository using a template
func InitializeLabels(ctx DBContext, repoID int64, labelTemplate string) error {
return initializeLabels(ctx.e, repoID, labelTemplate)
func InitializeLabels(ctx DBContext, repoID int64, labelTemplate string, isOrg bool) error {
return initializeLabels(ctx.e, repoID, labelTemplate, isOrg)
}
func newLabel(e Engine, label *Label) error {
@ -178,7 +215,7 @@ func newLabel(e Engine, label *Label) error {
return err
}
// NewLabel creates a new label for a repository
// NewLabel creates a new label
func NewLabel(label *Label) error {
if !LabelColorPattern.MatchString(label.Color) {
return fmt.Errorf("bad color code: %s", label.Color)
@ -186,7 +223,7 @@ func NewLabel(label *Label) error {
return newLabel(x, label)
}
// NewLabels creates new labels for a repository.
// NewLabels creates new labels
func NewLabels(labels ...*Label) error {
sess := x.NewSession()
defer sess.Close()
@ -204,12 +241,98 @@ func NewLabels(labels ...*Label) error {
return sess.Commit()
}
// UpdateLabel updates label information.
func UpdateLabel(l *Label) error {
if !LabelColorPattern.MatchString(l.Color) {
return fmt.Errorf("bad color code: %s", l.Color)
}
return updateLabel(x, l)
}
// DeleteLabel delete a label
func DeleteLabel(id, labelID int64) error {
label, err := GetLabelByID(labelID)
if err != nil {
if IsErrLabelNotExist(err) {
return nil
}
return err
}
sess := x.NewSession()
defer sess.Close()
if err = sess.Begin(); err != nil {
return err
}
if label.BelongsToOrg() && label.OrgID != id {
return nil
}
if label.BelongsToRepo() && label.RepoID != id {
return nil
}
if _, err = sess.ID(labelID).Delete(new(Label)); err != nil {
return err
} else if _, err = sess.
Where("label_id = ?", labelID).
Delete(new(IssueLabel)); err != nil {
return err
}
// delete comments about now deleted label_id
if _, err = sess.Where("label_id = ?", labelID).Cols("label_id").Delete(&Comment{}); err != nil {
return err
}
return sess.Commit()
}
// getLabelByID returns a label by label id
func getLabelByID(e Engine, labelID int64) (*Label, error) {
if labelID <= 0 {
return nil, ErrLabelNotExist{labelID}
}
l := &Label{
ID: labelID,
}
has, err := e.Get(l)
if err != nil {
return nil, err
} else if !has {
return nil, ErrLabelNotExist{l.ID}
}
return l, nil
}
// GetLabelByID returns a label by given ID.
func GetLabelByID(id int64) (*Label, error) {
return getLabelByID(x, id)
}
// GetLabelsByIDs returns a list of labels by IDs
func GetLabelsByIDs(labelIDs []int64) ([]*Label, error) {
labels := make([]*Label, 0, len(labelIDs))
return labels, x.Table("label").
In("id", labelIDs).
Asc("name").
Cols("id").
Find(&labels)
}
// __________ .__ __
// \______ \ ____ ______ ____ _____|__|/ |_ ___________ ___.__.
// | _// __ \\____ \ / _ \/ ___/ \ __\/ _ \_ __ < | |
// | | \ ___/| |_> > <_> )___ \| || | ( <_> ) | \/\___ |
// |____|_ /\___ > __/ \____/____ >__||__| \____/|__| / ____|
// \/ \/|__| \/ \/
// getLabelInRepoByName returns a label by Name in given repository.
// If pass repoID as 0, then ORM will ignore limitation of repository
// and can return arbitrary label with any valid ID.
func getLabelInRepoByName(e Engine, repoID int64, labelName string) (*Label, error) {
if len(labelName) == 0 {
return nil, ErrLabelNotExist{0, repoID}
if len(labelName) == 0 || repoID <= 0 {
return nil, ErrRepoLabelNotExist{0, repoID}
}
l := &Label{
@ -220,17 +343,15 @@ func getLabelInRepoByName(e Engine, repoID int64, labelName string) (*Label, err
if err != nil {
return nil, err
} else if !has {
return nil, ErrLabelNotExist{0, l.RepoID}
return nil, ErrRepoLabelNotExist{0, l.RepoID}
}
return l, nil
}
// getLabelInRepoByID returns a label by ID in given repository.
// If pass repoID as 0, then ORM will ignore limitation of repository
// and can return arbitrary label with any valid ID.
func getLabelInRepoByID(e Engine, repoID, labelID int64) (*Label, error) {
if labelID <= 0 {
return nil, ErrLabelNotExist{labelID, repoID}
if labelID <= 0 || repoID <= 0 {
return nil, ErrRepoLabelNotExist{labelID, repoID}
}
l := &Label{
@ -241,16 +362,11 @@ func getLabelInRepoByID(e Engine, repoID, labelID int64) (*Label, error) {
if err != nil {
return nil, err
} else if !has {
return nil, ErrLabelNotExist{l.ID, l.RepoID}
return nil, ErrRepoLabelNotExist{l.ID, l.RepoID}
}
return l, nil
}
// GetLabelByID returns a label by given ID.
func GetLabelByID(id int64) (*Label, error) {
return getLabelInRepoByID(x, 0, id)
}
// GetLabelInRepoByName returns a label by name in given repository.
func GetLabelInRepoByName(repoID int64, labelName string) (*Label, error) {
return getLabelInRepoByName(x, repoID, labelName)
@ -280,19 +396,6 @@ func BuildLabelNamesIssueIDsCondition(labelNames []string) *builder.Builder {
GroupBy("issue_label.issue_id")
}
// GetLabelIDsInReposByNames returns a list of labelIDs by names in one of the given
// repositories.
// it silently ignores label names that do not belong to the repository.
func GetLabelIDsInReposByNames(repoIDs []int64, labelNames []string) ([]int64, error) {
labelIDs := make([]int64, 0, len(labelNames))
return labelIDs, x.Table("label").
In("repo_id", repoIDs).
In("name", labelNames).
Asc("name").
Cols("id").
Find(&labelIDs)
}
// GetLabelInRepoByID returns a label by ID in given repository.
func GetLabelInRepoByID(repoID, labelID int64) (*Label, error) {
return getLabelInRepoByID(x, repoID, labelID)
@ -310,6 +413,9 @@ func GetLabelsInRepoByIDs(repoID int64, labelIDs []int64) ([]*Label, error) {
}
func getLabelsByRepoID(e Engine, repoID int64, sortType string, listOptions ListOptions) ([]*Label, error) {
if repoID <= 0 {
return nil, ErrRepoLabelNotExist{0, repoID}
}
labels := make([]*Label, 0, 10)
sess := e.Where("repo_id = ?", repoID)
@ -336,6 +442,138 @@ func GetLabelsByRepoID(repoID int64, sortType string, listOptions ListOptions) (
return getLabelsByRepoID(x, repoID, sortType, listOptions)
}
// ________
// \_____ \_______ ____
// / | \_ __ \/ ___\
// / | \ | \/ /_/ >
// \_______ /__| \___ /
// \/ /_____/
// getLabelInOrgByName returns a label by Name in given organization
func getLabelInOrgByName(e Engine, orgID int64, labelName string) (*Label, error) {
if len(labelName) == 0 || orgID <= 0 {
return nil, ErrOrgLabelNotExist{0, orgID}
}
l := &Label{
Name: labelName,
OrgID: orgID,
}
has, err := e.Get(l)
if err != nil {
return nil, err
} else if !has {
return nil, ErrOrgLabelNotExist{0, l.OrgID}
}
return l, nil
}
// getLabelInOrgByID returns a label by ID in given organization.
func getLabelInOrgByID(e Engine, orgID, labelID int64) (*Label, error) {
if labelID <= 0 || orgID <= 0 {
return nil, ErrOrgLabelNotExist{labelID, orgID}
}
l := &Label{
ID: labelID,
OrgID: orgID,
}
has, err := e.Get(l)
if err != nil {
return nil, err
} else if !has {
return nil, ErrOrgLabelNotExist{l.ID, l.OrgID}
}
return l, nil
}
// GetLabelInOrgByName returns a label by name in given organization.
func GetLabelInOrgByName(orgID int64, labelName string) (*Label, error) {
return getLabelInOrgByName(x, orgID, labelName)
}
// GetLabelIDsInOrgByNames returns a list of labelIDs by names in a given
// organization.
func GetLabelIDsInOrgByNames(orgID int64, labelNames []string) ([]int64, error) {
if orgID <= 0 {
return nil, ErrOrgLabelNotExist{0, orgID}
}
labelIDs := make([]int64, 0, len(labelNames))
return labelIDs, x.Table("label").
Where("org_id = ?", orgID).
In("name", labelNames).
Asc("name").
Cols("id").
Find(&labelIDs)
}
// GetLabelIDsInOrgsByNames returns a list of labelIDs by names in one of the given
// organization.
// it silently ignores label names that do not belong to the organization.
func GetLabelIDsInOrgsByNames(orgIDs []int64, labelNames []string) ([]int64, error) {
labelIDs := make([]int64, 0, len(labelNames))
return labelIDs, x.Table("label").
In("org_id", orgIDs).
In("name", labelNames).
Asc("name").
Cols("id").
Find(&labelIDs)
}
// GetLabelInOrgByID returns a label by ID in given organization.
func GetLabelInOrgByID(orgID, labelID int64) (*Label, error) {
return getLabelInOrgByID(x, orgID, labelID)
}
// GetLabelsInOrgByIDs returns a list of labels by IDs in given organization,
// it silently ignores label IDs that do not belong to the organization.
func GetLabelsInOrgByIDs(orgID int64, labelIDs []int64) ([]*Label, error) {
labels := make([]*Label, 0, len(labelIDs))
return labels, x.
Where("org_id = ?", orgID).
In("id", labelIDs).
Asc("name").
Find(&labels)
}
func getLabelsByOrgID(e Engine, orgID int64, sortType string, listOptions ListOptions) ([]*Label, error) {
if orgID <= 0 {
return nil, ErrOrgLabelNotExist{0, orgID}
}
labels := make([]*Label, 0, 10)
sess := e.Where("org_id = ?", orgID)
switch sortType {
case "reversealphabetically":
sess.Desc("name")
case "leastissues":
sess.Asc("num_issues")
case "mostissues":
sess.Desc("num_issues")
default:
sess.Asc("name")
}
if listOptions.Page != 0 {
sess = listOptions.setSessionPagination(sess)
}
return labels, sess.Find(&labels)
}
// GetLabelsByOrgID returns all labels that belong to given organization by ID.
func GetLabelsByOrgID(orgID int64, sortType string, listOptions ListOptions) ([]*Label, error) {
return getLabelsByOrgID(x, orgID, sortType, listOptions)
}
// .___
// | | ______ ________ __ ____
// | |/ ___// ___/ | \_/ __ \
// | |\___ \ \___ \| | /\ ___/
// |___/____ >____ >____/ \___ |
// \/ \/ \/
func getLabelsByIssueID(e Engine, issueID int64) ([]*Label, error) {
var labels []*Label
return labels, e.Where("issue_label.issue_id = ?", issueID).
@ -367,46 +605,6 @@ func updateLabel(e Engine, l *Label) error {
return err
}
// UpdateLabel updates label information.
func UpdateLabel(l *Label) error {
if !LabelColorPattern.MatchString(l.Color) {
return fmt.Errorf("bad color code: %s", l.Color)
}
return updateLabel(x, l)
}
// DeleteLabel delete a label of given repository.
func DeleteLabel(repoID, labelID int64) error {
_, err := GetLabelInRepoByID(repoID, labelID)
if err != nil {
if IsErrLabelNotExist(err) {
return nil
}
return err
}
sess := x.NewSession()
defer sess.Close()
if err = sess.Begin(); err != nil {
return err
}
if _, err = sess.ID(labelID).Delete(new(Label)); err != nil {
return err
} else if _, err = sess.
Where("label_id = ?", labelID).
Delete(new(IssueLabel)); err != nil {
return err
}
// Clear label id in comment table
if _, err = sess.Where("label_id = ?", labelID).Cols("label_id").Update(&Comment{}); err != nil {
return err
}
return sess.Commit()
}
// .___ .____ ___. .__
// | | ______ ________ __ ____ | | _____ \_ |__ ____ | |
// | |/ ___// ___/ | \_/ __ \| | \__ \ | __ \_/ __ \| |

View File

@ -66,10 +66,10 @@ func TestGetLabelInRepoByName(t *testing.T) {
assert.Equal(t, "label1", label.Name)
_, err = GetLabelInRepoByName(1, "")
assert.True(t, IsErrLabelNotExist(err))
assert.True(t, IsErrRepoLabelNotExist(err))
_, err = GetLabelInRepoByName(NonexistentID, "nonexistent")
assert.True(t, IsErrLabelNotExist(err))
assert.True(t, IsErrRepoLabelNotExist(err))
}
func TestGetLabelInRepoByNames(t *testing.T) {
@ -103,10 +103,10 @@ func TestGetLabelInRepoByID(t *testing.T) {
assert.EqualValues(t, 1, label.ID)
_, err = GetLabelInRepoByID(1, -1)
assert.True(t, IsErrLabelNotExist(err))
assert.True(t, IsErrRepoLabelNotExist(err))
_, err = GetLabelInRepoByID(NonexistentID, NonexistentID)
assert.True(t, IsErrLabelNotExist(err))
assert.True(t, IsErrRepoLabelNotExist(err))
}
func TestGetLabelsInRepoByIDs(t *testing.T) {
@ -135,6 +135,107 @@ func TestGetLabelsByRepoID(t *testing.T) {
testSuccess(1, "default", []int64{1, 2})
}
// Org vrsions
func TestGetLabelInOrgByName(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())
label, err := GetLabelInOrgByName(3, "orglabel3")
assert.NoError(t, err)
assert.EqualValues(t, 3, label.ID)
assert.Equal(t, "orglabel3", label.Name)
_, err = GetLabelInOrgByName(3, "")
assert.True(t, IsErrOrgLabelNotExist(err))
_, err = GetLabelInOrgByName(0, "orglabel3")
assert.True(t, IsErrOrgLabelNotExist(err))
_, err = GetLabelInOrgByName(-1, "orglabel3")
assert.True(t, IsErrOrgLabelNotExist(err))
_, err = GetLabelInOrgByName(NonexistentID, "nonexistent")
assert.True(t, IsErrOrgLabelNotExist(err))
}
func TestGetLabelInOrgByNames(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())
labelIDs, err := GetLabelIDsInOrgByNames(3, []string{"orglabel3", "orglabel4"})
assert.NoError(t, err)
assert.Len(t, labelIDs, 2)
assert.Equal(t, int64(3), labelIDs[0])
assert.Equal(t, int64(4), labelIDs[1])
}
func TestGetLabelInOrgByNamesDiscardsNonExistentLabels(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())
// orglabel99 doesn't exists.. See labels.yml
labelIDs, err := GetLabelIDsInOrgByNames(3, []string{"orglabel3", "orglabel4", "orglabel99"})
assert.NoError(t, err)
assert.Len(t, labelIDs, 2)
assert.Equal(t, int64(3), labelIDs[0])
assert.Equal(t, int64(4), labelIDs[1])
assert.NoError(t, err)
}
func TestGetLabelInOrgByID(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())
label, err := GetLabelInOrgByID(3, 3)
assert.NoError(t, err)
assert.EqualValues(t, 3, label.ID)
_, err = GetLabelInOrgByID(3, -1)
assert.True(t, IsErrOrgLabelNotExist(err))
_, err = GetLabelInOrgByID(0, 3)
assert.True(t, IsErrOrgLabelNotExist(err))
_, err = GetLabelInOrgByID(-1, 3)
assert.True(t, IsErrOrgLabelNotExist(err))
_, err = GetLabelInOrgByID(NonexistentID, NonexistentID)
assert.True(t, IsErrOrgLabelNotExist(err))
}
func TestGetLabelsInOrgByIDs(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())
labels, err := GetLabelsInOrgByIDs(3, []int64{3, 4, NonexistentID})
assert.NoError(t, err)
if assert.Len(t, labels, 2) {
assert.EqualValues(t, 3, labels[0].ID)
assert.EqualValues(t, 4, labels[1].ID)
}
}
func TestGetLabelsByOrgID(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())
testSuccess := func(orgID int64, sortType string, expectedIssueIDs []int64) {
labels, err := GetLabelsByOrgID(orgID, sortType, ListOptions{})
assert.NoError(t, err)
assert.Len(t, labels, len(expectedIssueIDs))
for i, label := range labels {
assert.EqualValues(t, expectedIssueIDs[i], label.ID)
}
}
testSuccess(3, "leastissues", []int64{3, 4})
testSuccess(3, "mostissues", []int64{4, 3})
testSuccess(3, "reversealphabetically", []int64{4, 3})
testSuccess(3, "default", []int64{3, 4})
var err error
_, err = GetLabelsByOrgID(0, "leastissues", ListOptions{})
assert.True(t, IsErrOrgLabelNotExist(err))
_, err = GetLabelsByOrgID(-1, "leastissues", ListOptions{})
assert.True(t, IsErrOrgLabelNotExist(err))
}
//
func TestGetLabelsByIssueID(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())
labels, err := GetLabelsByIssueID(1)
@ -166,7 +267,7 @@ func TestDeleteLabel(t *testing.T) {
AssertNotExistsBean(t, &Label{ID: label.ID, RepoID: label.RepoID})
assert.NoError(t, DeleteLabel(label.RepoID, label.ID))
AssertNotExistsBean(t, &Label{ID: label.ID, RepoID: label.RepoID})
AssertNotExistsBean(t, &Label{ID: label.ID})
assert.NoError(t, DeleteLabel(NonexistentID, NonexistentID))
CheckConsistencyFor(t, &Label{}, &Repository{})

View File

@ -34,7 +34,6 @@ func TestIssueList_LoadAttributes(t *testing.T) {
setting.Service.EnableTimetracking = true
issueList := IssueList{
AssertExistsAndLoadBean(t, &Issue{ID: 1}).(*Issue),
AssertExistsAndLoadBean(t, &Issue{ID: 2}).(*Issue),
AssertExistsAndLoadBean(t, &Issue{ID: 4}).(*Issue),
}

View File

@ -202,6 +202,8 @@ var migrations = []Migration{
NewMigration("Add EmailHash Table", addEmailHashTable),
// v134 -> v135
NewMigration("Refix merge base for merged pull requests", refixMergeBase),
// v135 -> 136
NewMigration("Add OrgID column to Labels table", addOrgIDLabelColumn),
}
// Migrate database to current version

22
models/migrations/v135.go Normal file
View File

@ -0,0 +1,22 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package migrations
import (
"fmt"
"xorm.io/xorm"
)
func addOrgIDLabelColumn(x *xorm.Engine) error {
type Label struct {
OrgID int64 `xorm:"INDEX"`
}
if err := x.Sync2(new(Label)); err != nil {
return fmt.Errorf("Sync2: %v", err)
}
return nil
}

View File

@ -58,7 +58,7 @@ func CreateRepository(doer, u *models.User, opts models.CreateRepoOptions) (_ *m
// Initialize Issue Labels if selected
if len(opts.IssueLabels) > 0 {
if err = models.InitializeLabels(ctx, repo.ID, opts.IssueLabels); err != nil {
if err = models.InitializeLabels(ctx, repo.ID, opts.IssueLabels, false); err != nil {
return fmt.Errorf("InitializeLabels: %v", err)
}
}

View File

@ -45,8 +45,10 @@ func MockContext(t *testing.T, path string) *context.Context {
func LoadRepo(t *testing.T, ctx *context.Context, repoID int64) {
ctx.Repo = &context.Repository{}
ctx.Repo.Repository = models.AssertExistsAndLoadBean(t, &models.Repository{ID: repoID}).(*models.Repository)
ctx.Repo.RepoLink = ctx.Repo.Repository.Link()
var err error
ctx.Repo.Owner, err = models.GetUserByID(ctx.Repo.Repository.OwnerID)
assert.NoError(t, err)
ctx.Repo.RepoLink = ctx.Repo.Repository.Link()
ctx.Repo.Permission, err = models.GetUserRepoPermission(ctx.Repo.Repository, ctx.User)
assert.NoError(t, err)
}

View File

@ -725,6 +725,9 @@ tags = Tags
issues = Issues
pulls = Pull Requests
labels = Labels
org_labels_desc = Organization level labels that can be used with <strong>all repositories</strong> under this organization
org_labels_desc_manage = manage
milestones = Milestones
commits = Commits
commit = Commit
@ -1699,6 +1702,8 @@ settings.delete_org_title = Delete Organization
settings.delete_org_desc = This organization will be deleted permanently. Continue?
settings.hooks_desc = Add webhooks which will be triggered for <strong>all repositories</strong> under this organization.
settings.labels_desc = Add labels which can be used on issues for <strong>all repositories</strong> under this organization.
members.membership_visibility = Membership Visibility:
members.public = Visible
members.public_helper = make hidden

View File

@ -861,6 +861,13 @@ func RegisterRoutes(m *macaron.Macaron) {
Post(reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam)
m.Get("/search", org.SearchTeam)
}, reqOrgMembership())
m.Group("/labels", func() {
m.Get("", org.ListLabels)
m.Post("", reqToken(), reqOrgOwnership(), bind(api.CreateLabelOption{}), org.CreateLabel)
m.Combo("/:id").Get(org.GetLabel).
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
})
m.Group("/hooks", func() {
m.Combo("").Get(org.ListHooks).
Post(bind(api.CreateHookOption{}), org.CreateHook)

237
routers/api/v1/org/label.go Normal file
View File

@ -0,0 +1,237 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package org
import (
"fmt"
"net/http"
"strconv"
"strings"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/convert"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/routers/api/v1/utils"
)
// ListLabels list all the labels of an organization
func ListLabels(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/labels organization orgListLabels
// ---
// summary: List an organization's labels
// 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, maximum page size is 50
// type: integer
// responses:
// "200":
// "$ref": "#/responses/LabelList"
labels, err := models.GetLabelsByOrgID(ctx.Org.Organization.ID, ctx.Query("sort"), utils.GetListOptions(ctx))
if err != nil {
ctx.Error(http.StatusInternalServerError, "GetLabelsByOrgID", err)
return
}
ctx.JSON(http.StatusOK, convert.ToLabelList(labels))
}
// CreateLabel create a label for a repository
func CreateLabel(ctx *context.APIContext, form api.CreateLabelOption) {
// swagger:operation POST /orgs/{org}/labels organization orgCreateLabel
// ---
// summary: Create a label for an organization
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateLabelOption"
// responses:
// "201":
// "$ref": "#/responses/Label"
// "422":
// "$ref": "#/responses/validationError"
form.Color = strings.Trim(form.Color, " ")
if len(form.Color) == 6 {
form.Color = "#" + form.Color
}
if !models.LabelColorPattern.MatchString(form.Color) {
ctx.Error(http.StatusUnprocessableEntity, "ColorPattern", fmt.Errorf("bad color code: %s", form.Color))
return
}
label := &models.Label{
Name: form.Name,
Color: form.Color,
OrgID: ctx.Org.Organization.ID,
Description: form.Description,
}
if err := models.NewLabel(label); err != nil {
ctx.Error(http.StatusInternalServerError, "NewLabel", err)
return
}
ctx.JSON(http.StatusCreated, convert.ToLabel(label))
}
// GetLabel get label by organization and label id
func GetLabel(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/labels/{id} organization orgGetLabel
// ---
// summary: Get a single label
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the label to get
// type: integer
// format: int64
// required: true
// responses:
// "200":
// "$ref": "#/responses/Label"
var (
label *models.Label
err error
)
strID := ctx.Params(":id")
if intID, err2 := strconv.ParseInt(strID, 10, 64); err2 != nil {
label, err = models.GetLabelInOrgByName(ctx.Org.Organization.ID, strID)
} else {
label, err = models.GetLabelInOrgByID(ctx.Org.Organization.ID, intID)
}
if err != nil {
if models.IsErrOrgLabelNotExist(err) {
ctx.NotFound()
} else {
ctx.Error(http.StatusInternalServerError, "GetLabelByOrgID", err)
}
return
}
ctx.JSON(http.StatusOK, convert.ToLabel(label))
}
// EditLabel modify a label for an Organization
func EditLabel(ctx *context.APIContext, form api.EditLabelOption) {
// swagger:operation PATCH /orgs/{org}/labels/{id} organization orgEditLabel
// ---
// summary: Update a label
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the label to edit
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditLabelOption"
// responses:
// "200":
// "$ref": "#/responses/Label"
// "422":
// "$ref": "#/responses/validationError"
label, err := models.GetLabelInOrgByID(ctx.Org.Organization.ID, ctx.ParamsInt64(":id"))
if err != nil {
if models.IsErrOrgLabelNotExist(err) {
ctx.NotFound()
} else {
ctx.Error(http.StatusInternalServerError, "GetLabelByRepoID", err)
}
return
}
if form.Name != nil {
label.Name = *form.Name
}
if form.Color != nil {
label.Color = strings.Trim(*form.Color, " ")
if len(label.Color) == 6 {
label.Color = "#" + label.Color
}
if !models.LabelColorPattern.MatchString(label.Color) {
ctx.Error(http.StatusUnprocessableEntity, "ColorPattern", fmt.Errorf("bad color code: %s", label.Color))
return
}
}
if form.Description != nil {
label.Description = *form.Description
}
if err := models.UpdateLabel(label); err != nil {
ctx.ServerError("UpdateLabel", err)
return
}
ctx.JSON(http.StatusOK, convert.ToLabel(label))
}
// DeleteLabel delete a label for an organization
func DeleteLabel(ctx *context.APIContext) {
// swagger:operation DELETE /orgs/{org}/labels/{id} organization orgDeleteLabel
// ---
// summary: Delete a label
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the label to delete
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
if err := models.DeleteLabel(ctx.Org.Organization.ID, ctx.ParamsInt64(":id")); err != nil {
ctx.Error(http.StatusInternalServerError, "DeleteLabel", err)
return
}
ctx.Status(http.StatusNoContent)
}

View File

@ -81,8 +81,10 @@ func SearchIssues(ctx *context.APIContext) {
AllPublic: true,
TopicOnly: false,
Collaborate: util.OptionalBoolNone,
OrderBy: models.SearchOrderByRecentUpdated,
Actor: ctx.User,
// This needs to be a column that is not nil in fixtures or
// MySQL will return different results when sorting by null in some cases
OrderBy: models.SearchOrderByAlphabetically,
Actor: ctx.User,
}
if ctx.IsSigned {
opts.Private = true
@ -152,6 +154,7 @@ func SearchIssues(ctx *context.APIContext) {
Page: ctx.QueryInt("page"),
PageSize: setting.UI.IssuePagingNum,
},
RepoIDs: repoIDs,
IsClosed: isClosed,
IssueIDs: issueIDs,

View File

@ -171,12 +171,12 @@ func DeleteIssueLabel(ctx *context.APIContext) {
return
}
label, err := models.GetLabelInRepoByID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
label, err := models.GetLabelByID(ctx.ParamsInt64(":id"))
if err != nil {
if models.IsErrLabelNotExist(err) {
ctx.Error(http.StatusUnprocessableEntity, "", err)
} else {
ctx.Error(http.StatusInternalServerError, "GetLabelInRepoByID", err)
ctx.Error(http.StatusInternalServerError, "GetLabelByID", err)
}
return
}
@ -308,9 +308,9 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption)
return
}
labels, err = models.GetLabelsInRepoByIDs(ctx.Repo.Repository.ID, form.Labels)
labels, err = models.GetLabelsByIDs(form.Labels)
if err != nil {
ctx.Error(http.StatusInternalServerError, "GetLabelsInRepoByIDs", err)
ctx.Error(http.StatusInternalServerError, "GetLabelsByIDs", err)
return
}

View File

@ -96,7 +96,7 @@ func GetLabel(ctx *context.APIContext) {
label, err = models.GetLabelInRepoByID(ctx.Repo.Repository.ID, intID)
}
if err != nil {
if models.IsErrLabelNotExist(err) {
if models.IsErrRepoLabelNotExist(err) {
ctx.NotFound()
} else {
ctx.Error(http.StatusInternalServerError, "GetLabelByRepoID", err)
@ -197,7 +197,7 @@ func EditLabel(ctx *context.APIContext, form api.EditLabelOption) {
label, err := models.GetLabelInRepoByID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
if err != nil {
if models.IsErrLabelNotExist(err) {
if models.IsErrRepoLabelNotExist(err) {
ctx.NotFound()
} else {
ctx.Error(http.StatusInternalServerError, "GetLabelByRepoID", err)

106
routers/org/org_labels.go Normal file
View File

@ -0,0 +1,106 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package org
import (
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/auth"
"code.gitea.io/gitea/modules/context"
)
// RetrieveLabels find all the labels of an organization
func RetrieveLabels(ctx *context.Context) {
labels, err := models.GetLabelsByOrgID(ctx.Org.Organization.ID, ctx.Query("sort"), models.ListOptions{})
if err != nil {
ctx.ServerError("RetrieveLabels.GetLabels", err)
return
}
for _, l := range labels {
l.CalOpenIssues()
}
ctx.Data["Labels"] = labels
ctx.Data["NumLabels"] = len(labels)
ctx.Data["SortType"] = ctx.Query("sort")
}
// NewLabel create new label for organization
func NewLabel(ctx *context.Context, form auth.CreateLabelForm) {
ctx.Data["Title"] = ctx.Tr("repo.labels")
ctx.Data["PageIsLabels"] = true
if ctx.HasError() {
ctx.Flash.Error(ctx.Data["ErrorMsg"].(string))
ctx.Redirect(ctx.Org.OrgLink + "/settings/labels")
return
}
l := &models.Label{
OrgID: ctx.Org.Organization.ID,
Name: form.Title,
Description: form.Description,
Color: form.Color,
}
if err := models.NewLabel(l); err != nil {
ctx.ServerError("NewLabel", err)
return
}
ctx.Redirect(ctx.Org.OrgLink + "/settings/labels")
}
// UpdateLabel update a label's name and color
func UpdateLabel(ctx *context.Context, form auth.CreateLabelForm) {
l, err := models.GetLabelInOrgByID(ctx.Org.Organization.ID, form.ID)
if err != nil {
switch {
case models.IsErrOrgLabelNotExist(err):
ctx.Error(404)
default:
ctx.ServerError("UpdateLabel", err)
}
return
}
l.Name = form.Title
l.Description = form.Description
l.Color = form.Color
if err := models.UpdateLabel(l); err != nil {
ctx.ServerError("UpdateLabel", err)
return
}
ctx.Redirect(ctx.Org.OrgLink + "/settings/labels")
}
// DeleteLabel delete a label
func DeleteLabel(ctx *context.Context) {
if err := models.DeleteLabel(ctx.Org.Organization.ID, ctx.QueryInt64("id")); err != nil {
ctx.Flash.Error("DeleteLabel: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success"))
}
ctx.JSON(200, map[string]interface{}{
"redirect": ctx.Org.OrgLink + "/settings/labels",
})
}
// InitializeLabels init labels for an organization
func InitializeLabels(ctx *context.Context, form auth.InitializeLabelsForm) {
if ctx.HasError() {
ctx.Redirect(ctx.Repo.RepoLink + "/labels")
return
}
if err := models.InitializeLabels(models.DefaultDBContext(), ctx.Org.Organization.ID, form.TemplateName, true); err != nil {
if models.IsErrIssueLabelTemplateLoad(err) {
originalErr := err.(models.ErrIssueLabelTemplateLoad).OriginalError
ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr))
ctx.Redirect(ctx.Org.OrgLink + "/settings/labels")
return
}
ctx.ServerError("InitializeLabels", err)
return
}
ctx.Redirect(ctx.Org.OrgLink + "/settings/labels")
}

View File

@ -24,6 +24,8 @@ const (
tplSettingsDelete base.TplName = "org/settings/delete"
// tplSettingsHooks template path for render hook settings
tplSettingsHooks base.TplName = "org/settings/hooks"
// tplSettingsLabels template path for render labels settings
tplSettingsLabels base.TplName = "org/settings/labels"
)
// Settings render the main settings page
@ -177,3 +179,13 @@ func DeleteWebhook(ctx *context.Context) {
"redirect": ctx.Org.OrgLink + "/settings/hooks",
})
}
// Labels render organization labels page
func Labels(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.labels")
ctx.Data["PageIsOrgSettingsLabels"] = true
ctx.Data["RequireMinicolors"] = true
ctx.Data["RequireTribute"] = true
ctx.Data["LabelTemplates"] = models.LabelTemplates
ctx.HTML(200, tplSettingsLabels)
}

View File

@ -335,7 +335,6 @@ func PrepareCompareDiff(
} else {
title = headBranch
}
ctx.Data["title"] = title
ctx.Data["Username"] = headUser.Name
ctx.Data["Reponame"] = headRepo.Name

View File

@ -259,6 +259,18 @@ func issues(ctx *context.Context, milestoneID int64, isPullOption util.OptionalB
ctx.ServerError("GetLabelsByRepoID", err)
return
}
if repo.Owner.IsOrganization() {
orgLabels, err := models.GetLabelsByOrgID(repo.Owner.ID, ctx.Query("sort"), models.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByOrgID", err)
return
}
ctx.Data["OrgLabels"] = orgLabels
labels = append(labels, orgLabels...)
}
for _, l := range labels {
l.LoadSelectedLabelsAfterClick(labelIDs)
}
@ -377,6 +389,15 @@ func RetrieveRepoMetas(ctx *context.Context, repo *models.Repository, isPull boo
return nil
}
ctx.Data["Labels"] = labels
if repo.Owner.IsOrganization() {
orgLabels, err := models.GetLabelsByOrgID(repo.Owner.ID, ctx.Query("sort"), models.ListOptions{})
if err != nil {
return nil
}
ctx.Data["OrgLabels"] = orgLabels
labels = append(labels, orgLabels...)
}
RetrieveRepoMilestonesAndAssignees(ctx, repo)
if ctx.Written() {
@ -593,6 +614,7 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) {
Content: form.Content,
Ref: form.Ref,
}
if err := issue_service.NewIssue(repo, issue, labelIDs, attachments, assigneeIDs); err != nil {
if models.IsErrUserDoesNotHaveAccessToRepo(err) {
ctx.Error(400, "UserDoesNotHaveAccessToRepo", err.Error())
@ -761,6 +783,19 @@ func ViewIssue(ctx *context.Context) {
ctx.ServerError("GetLabelsByRepoID", err)
return
}
ctx.Data["Labels"] = labels
if repo.Owner.IsOrganization() {
orgLabels, err := models.GetLabelsByOrgID(repo.Owner.ID, ctx.Query("sort"), models.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByOrgID", err)
return
}
ctx.Data["OrgLabels"] = orgLabels
labels = append(labels, orgLabels...)
}
hasSelected := false
for i := range labels {
if labelIDMark[labels[i].ID] {
@ -769,7 +804,6 @@ func ViewIssue(ctx *context.Context) {
}
}
ctx.Data["HasSelectedLabel"] = hasSelected
ctx.Data["Labels"] = labels
// Check milestone and assignee.
if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {

View File

@ -10,6 +10,7 @@ import (
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
issue_service "code.gitea.io/gitea/services/issue"
)
@ -35,7 +36,7 @@ func InitializeLabels(ctx *context.Context, form auth.InitializeLabelsForm) {
return
}
if err := models.InitializeLabels(models.DefaultDBContext(), ctx.Repo.Repository.ID, form.TemplateName); err != nil {
if err := models.InitializeLabels(models.DefaultDBContext(), ctx.Repo.Repository.ID, form.TemplateName, false); err != nil {
if models.IsErrIssueLabelTemplateLoad(err) {
originalErr := err.(models.ErrIssueLabelTemplateLoad).OriginalError
ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr))
@ -48,17 +49,47 @@ func InitializeLabels(ctx *context.Context, form auth.InitializeLabelsForm) {
ctx.Redirect(ctx.Repo.RepoLink + "/labels")
}
// RetrieveLabels find all the labels of a repository
// RetrieveLabels find all the labels of a repository and organization
func RetrieveLabels(ctx *context.Context) {
labels, err := models.GetLabelsByRepoID(ctx.Repo.Repository.ID, ctx.Query("sort"), models.ListOptions{})
if err != nil {
ctx.ServerError("RetrieveLabels.GetLabels", err)
return
}
for _, l := range labels {
l.CalOpenIssues()
}
ctx.Data["Labels"] = labels
if ctx.Repo.Owner.IsOrganization() {
orgLabels, err := models.GetLabelsByOrgID(ctx.Repo.Owner.ID, ctx.Query("sort"), models.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByOrgID", err)
return
}
for _, l := range orgLabels {
l.CalOpenOrgIssues(ctx.Repo.Repository.ID, l.ID)
}
ctx.Data["OrgLabels"] = orgLabels
org, err := models.GetOrgByName(ctx.Repo.Owner.LowerName)
if err != nil {
ctx.ServerError("GetOrgByName", err)
return
}
if ctx.User != nil {
ctx.Org.IsOwner, err = org.IsOwnedBy(ctx.User.ID)
if err != nil {
ctx.ServerError("org.IsOwnedBy", err)
return
}
ctx.Org.OrgLink = setting.AppSubURL + "/org/" + org.LowerName
ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner
ctx.Data["OrganizationLink"] = ctx.Org.OrgLink
}
}
ctx.Data["NumLabels"] = len(labels)
ctx.Data["SortType"] = ctx.Query("sort")
}
@ -89,10 +120,10 @@ func NewLabel(ctx *context.Context, form auth.CreateLabelForm) {
// UpdateLabel update a label's name and color
func UpdateLabel(ctx *context.Context, form auth.CreateLabelForm) {
l, err := models.GetLabelByID(form.ID)
l, err := models.GetLabelInRepoByID(ctx.Repo.Repository.ID, form.ID)
if err != nil {
switch {
case models.IsErrLabelNotExist(err):
case models.IsErrRepoLabelNotExist(err):
ctx.Error(404)
default:
ctx.ServerError("UpdateLabel", err)
@ -141,7 +172,7 @@ func UpdateIssueLabel(ctx *context.Context) {
case "attach", "detach", "toggle":
label, err := models.GetLabelByID(ctx.QueryInt64("id"))
if err != nil {
if models.IsErrLabelNotExist(err) {
if models.IsErrRepoLabelNotExist(err) {
ctx.Error(404, "GetLabelByID")
} else {
ctx.ServerError("GetLabelByID", err)

View File

@ -594,6 +594,14 @@ func RegisterRoutes(m *macaron.Macaron) {
m.Post("/feishu/:id", bindIgnErr(auth.NewFeishuHookForm{}), repo.FeishuHooksEditPost)
})
m.Group("/labels", func() {
m.Get("", org.RetrieveLabels, org.Labels)
m.Post("/new", bindIgnErr(auth.CreateLabelForm{}), org.NewLabel)
m.Post("/edit", bindIgnErr(auth.CreateLabelForm{}), org.UpdateLabel)
m.Post("/delete", org.DeleteLabel)
m.Post("/initialize", bindIgnErr(auth.InitializeLabelsForm{}), org.InitializeLabels)
})
m.Route("/delete", "GET,POST", org.SettingsDelete)
})
}, context.OrgAssignment(true, true))

View File

@ -51,7 +51,10 @@ func RemoveLabel(issue *models.Issue, doer *models.User, label *models.Label) er
return err
}
if !perm.CanWriteIssuesOrPulls(issue.IsPull) {
return models.ErrLabelNotExist{}
if label.OrgID > 0 {
return models.ErrOrgLabelNotExist{}
}
return models.ErrRepoLabelNotExist{}
}
if err := models.DeleteIssueLabel(issue, label, doer); err != nil {

View File

@ -0,0 +1,29 @@
{{template "base/head" .}}
<div class="organization settings labels">
{{template "org/header" .}}
<div class="ui container">
<div class="ui grid">
{{template "org/settings/navbar" .}}
<div class="ui twelve wide column content">
<div class="ui grid">
<div class="left floated twelve wide column">
{{$.i18n.Tr "org.settings.labels_desc" | Str2html}}
</div>
<div class="right floated three wide column">
<div class="ui right">
<div class="ui green new-label button">{{.i18n.Tr "repo.issues.new_label"}}</div>
</div>
</div>
</div>
<div class="ui divider"></div>
{{template "repo/issue/labels/label_new" .}}
{{template "base/alert" .}}
{{template "repo/issue/labels/label_list" .}}
</div>
</div>
</div>
</div>
</div>
{{template "repo/issue/labels/edit_delete_label" .}}
{{template "base/footer" .}}

View File

@ -7,6 +7,9 @@
<a class="{{if .PageIsSettingsHooks}}active{{end}} item" href="{{.OrgLink}}/settings/hooks">
{{.i18n.Tr "repo.settings.hooks"}}
</a>
<a class="{{if .PageIsOrgSettingsLabels}}active{{end}} item" href="{{.OrgLink}}/settings/labels">
{{.i18n.Tr "repo.labels"}}
</a>
<a class="{{if .PageIsSettingsDelete}}active{{end}} item" href="{{.OrgLink}}/settings/delete">
{{.i18n.Tr "org.settings.delete"}}
</a>

View File

@ -10,173 +10,17 @@
</div>
{{end}}
</div>
{{if not .Repository.IsArchived}}
<div class="ui new-label segment hide">
<form class="ui form" action="{{$.RepoLink}}/labels/new" method="post">
{{.CsrfTokenHtml}}
<div class="ui grid">
<div class="three wide column">
<div class="ui small input">
<input class="new-label-input emoji-input" name="title" placeholder="{{.i18n.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
</div>
</div>
<div class="five wide column">
<div class="ui small fluid input">
<input class="new-label-desc-input" name="description" placeholder="{{.i18n.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
</div>
</div>
<div class="color picker column">
<input class="color-picker" name="color" value="#70c24a" required maxlength="7">
</div>
<div class="column precolors">
{{template "repo/issue/label_precolors"}}
</div>
<div class="buttons">
<div class="ui blue small basic cancel button">{{.i18n.Tr "repo.milestones.cancel"}}</div>
<button class="ui green small button">{{.i18n.Tr "repo.issues.create_label"}}</button>
</div>
</div>
</form>
</div>
{{end}}
<div class="ui divider"></div>
<div class="ui right floated secondary filter menu">
<!-- Sort -->
<div class="ui dropdown type jump item">
<span class="text">
{{.i18n.Tr "repo.issues.filter_sort"}}
<i class="dropdown icon"></i>
</span>
<div class="menu">
<a class="{{if or (eq .SortType "alphabetically") (not .SortType)}}active{{end}} item" href="{{$.Link}}?sort=alphabetically&state={{$.State}}">{{.i18n.Tr "repo.issues.label.filter_sort.alphabetically"}}</a>
<a class="{{if eq .SortType "reversealphabetically"}}active{{end}} item" href="{{$.Link}}?sort=reversealphabetically&state={{$.State}}">{{.i18n.Tr "repo.issues.label.filter_sort.reverse_alphabetically"}}</a>
<a class="{{if eq .SortType "leastissues"}}active{{end}} item" href="{{$.Link}}?sort=leastissues&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.least_issues"}}</a>
<a class="{{if eq .SortType "mostissues"}}active{{end}} item" href="{{$.Link}}?sort=mostissues&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.most_issues"}}</a>
</div>
</div>
</div>
{{if and (or .CanWriteIssues .CanWritePulls) (not .Repository.IsArchived)}}
{{template "repo/issue/labels/label_new" .}}
{{end}}
{{template "base/alert" .}}
<div class="ui black basic label">{{.i18n.Tr "repo.issues.label_count" .NumLabels}}</div>
<div class="label list">
{{if and (or $.CanWriteIssues $.CanWritePulls) (eq .NumLabels 0) (not $.Repository.IsArchived) }}
<div class="ui centered grid">
<div class="twelve wide column eight wide computer column">
<div class="ui attached left aligned segment">
<!-- <h4 class="ui header">
{{.i18n.Tr "repo.issues.label_templates.title"}}
<a target="_blank" rel="noopener noreferrer"
href="https://discuss.gogs.io/t/how-to-use-predefined-label-templates/599">
<span class="octicon octicon-question"></span>
</a>
</h4> -->
<p>{{.i18n.Tr "repo.issues.label_templates.info"}}</p>
<br/>
<form class="ui form center" action="{{.Link}}/initialize" method="post">
{{.CsrfTokenHtml}}
<div class="field">
<div class="ui selection dropdown">
<input type="hidden" name="template_name" value="Default">
<div class="default text">{{.i18n.Tr "repo.issues.label_templates.helper"}}</div>
<div class="menu">
{{range $template, $labels := .LabelTemplates}}
<div class="item" data-value="{{$template}}">{{$template}}<br/><i>({{$labels}})</i></div>
{{end}}
</div>
</div>
</div>
<button type="submit" class="ui blue button">{{.i18n.Tr "repo.issues.label_templates.use"}}</button>
</form>
</div>
</div>
</div>
{{end}}
<div class="ui divider"></div>
{{range .Labels}}
<li class="item">
<div class="ui grid">
<div class="three wide column">
<div class="ui label has-emoji" style="color: {{.ForegroundColor}}; background-color: {{.Color}}">{{svg "octicon-tag" 16}} {{.Name}}</div>
</div>
<div class="seven wide column">
{{.Description}}
</div>
<div class="three wide column">
<a class="ui right open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened" 16}} {{$.i18n.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a>
</div>
<div class="three wide column">
{{if and (not $.Repository.IsArchived) (or $.CanWriteIssues $.CanWritePulls)}}
<a class="ui right delete-button" href="#" data-url="{{$.RepoLink}}/labels/delete" data-id="{{.ID}}">{{svg "octicon-trashcan" 16}} {{$.i18n.Tr "repo.issues.label_delete"}}</a>
<a class="ui right edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil" 16}} {{$.i18n.Tr "repo.issues.label_edit"}}</a>
{{end}}
</div>
</div>
</li>
{{end}}
</div>
{{template "repo/issue/labels/label_list" .}}
</div>
</div>
{{if and (or .CanWriteIssues .CanWritePulls) (not .Repository.IsArchived)}}
<div class="ui small basic delete modal">
<div class="ui icon header">
<i class="trash icon"></i>
{{.i18n.Tr "repo.issues.label_deletion"}}
</div>
<div class="content">
<p>{{.i18n.Tr "repo.issues.label_deletion_desc"}}</p>
</div>
<div class="actions">
<div class="ui red basic inverted cancel button">
<i class="remove icon"></i>
{{.i18n.Tr "modal.no"}}
</div>
<div class="ui green basic inverted ok button">
<i class="checkmark icon"></i>
{{.i18n.Tr "modal.yes"}}
</div>
</div>
</div>
<div class="ui small edit-label modal">
<div class="header">
{{.i18n.Tr "repo.issues.label_modify"}}
</div>
<div class="content">
<form class="ui edit-label form" action="{{$.RepoLink}}/labels/edit" method="post">
{{.CsrfTokenHtml}}
<input id="label-modal-id" name="id" type="hidden">
<div class="ui grid">
<div class="three wide column">
<div class="ui small input">
<input class="new-label-input emoji-input" name="title" placeholder="{{.i18n.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
</div>
</div>
<div class="five wide column">
<div class="ui small fluid input">
<input class="new-label-desc-input" name="description" placeholder="{{.i18n.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
</div>
</div>
<div class="color picker column">
<input class="color-picker" name="color" value="#70c24a" required maxlength="7">
</div>
<div class="column precolors">
{{template "repo/issue/label_precolors"}}
</div>
</div>
</form>
</div>
<div class="actions">
<div class="ui negative button">
{{.i18n.Tr "modal.no"}}
</div>
<div class="ui positive right labeled icon button">
{{.i18n.Tr "modal.modify"}}
<i class="checkmark icon"></i>
</div>
</div>
</div>
{{if and (or .CanWriteIssues .CanWritePulls) (not .Repository.IsArchived) }}
{{template "repo/issue/labels/edit_delete_label" .}}
{{end}}
</div>
{{template "base/footer" .}}

View File

@ -0,0 +1,59 @@
<div class="ui small basic delete modal">
<div class="ui icon header">
<i class="trash icon"></i>
{{.i18n.Tr "repo.issues.label_deletion"}}
</div>
<div class="content">
<p>{{.i18n.Tr "repo.issues.label_deletion_desc"}}</p>
</div>
<div class="actions">
<div class="ui red basic inverted cancel button">
<i class="remove icon"></i>
{{.i18n.Tr "modal.no"}}
</div>
<div class="ui green basic inverted ok button">
<i class="checkmark icon"></i>
{{.i18n.Tr "modal.yes"}}
</div>
</div>
</div>
<div class="ui small edit-label modal">
<div class="header">
{{.i18n.Tr "repo.issues.label_modify"}}
</div>
<div class="content">
<form class="ui edit-label form" action="{{$.Link}}/edit" method="post">
{{.CsrfTokenHtml}}
<input id="label-modal-id" name="id" type="hidden">
<div class="ui grid">
<div class="three wide column">
<div class="ui small input">
<input class="new-label-input emoji-input" name="title" placeholder="{{.i18n.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
</div>
</div>
<div class="five wide column">
<div class="ui small fluid input">
<input class="new-label-desc-input" name="description" placeholder="{{.i18n.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
</div>
</div>
<div class="color picker column">
<input class="color-picker" name="color" value="#70c24a" required maxlength="7">
</div>
<div class="column precolors">
{{template "repo/issue/label_precolors"}}
</div>
</div>
</form>
</div>
<div class="actions">
<div class="ui negative button">
{{.i18n.Tr "modal.no"}}
</div>
<div class="ui positive right labeled icon button">
{{.i18n.Tr "modal.modify"}}
<i class="checkmark icon"></i>
</div>
</div>
</div>

View File

@ -0,0 +1,97 @@
<h4 class="ui top attached header">
{{.i18n.Tr "repo.issues.label_count" .NumLabels}}
<div class="ui right">
<div class="ui right floated secondary filter menu">
<!-- Sort -->
<div class="ui dropdown type jump item">
<span class="text">
{{.i18n.Tr "repo.issues.filter_sort"}}
<i class="dropdown icon"></i>
</span>
<div class="menu">
<a class="{{if or (eq .SortType "alphabetically") (not .SortType)}}active{{end}} item" href="{{$.Link}}?sort=alphabetically&state={{$.State}}">{{.i18n.Tr "repo.issues.label.filter_sort.alphabetically"}}</a>
<a class="{{if eq .SortType "reversealphabetically"}}active{{end}} item" href="{{$.Link}}?sort=reversealphabetically&state={{$.State}}">{{.i18n.Tr "repo.issues.label.filter_sort.reverse_alphabetically"}}</a>
<a class="{{if eq .SortType "leastissues"}}active{{end}} item" href="{{$.Link}}?sort=leastissues&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.least_issues"}}</a>
<a class="{{if eq .SortType "mostissues"}}active{{end}} item" href="{{$.Link}}?sort=mostissues&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.most_issues"}}</a>
</div>
</div>
</div>
</div> <!-- filter menu -->
</h4>
<div class="ui attached segment">
<div class="labelspage">
{{if and (not $.PageIsOrgSettingsLabels) (or $.CanWriteIssues $.CanWritePulls) (eq .NumLabels 0) (not $.Repository.IsArchived) }}
{{template "repo/issue/labels/label_load_template" .}}
<div class="ui divider"></div>
{{else if and ($.PageIsOrgSettingsLabels) (eq .NumLabels 0)}}
{{template "repo/issue/labels/label_load_template" .}}
{{end}}
{{range .Labels}}
<li class="item">
<div class="ui grid middle aligned">
<div class="four wide column">
<div class="ui label has-emoji" style="color: {{.ForegroundColor}}; background-color: {{.Color}}">{{svg "octicon-tag" 16}} {{.Name}}</div>
</div>
<div class="six wide column">
<div class="ui has-emoji">
{{.Description}}
</div>
</div>
<div class="three wide column">
{{if $.PageIsOrgSettingsLabels}}
<a class="ui right open-issues" href="/issues?labels={{.ID}}">{{svg "octicon-issue-opened" 16}} {{$.i18n.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a>
{{else}}
<a class="ui right open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened" 16}} {{$.i18n.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a>
{{end}}
</div>
<div class="three wide column">
{{if and (not $.PageIsOrgSettingsLabels ) (not $.Repository.IsArchived) (or $.CanWriteIssues $.CanWritePulls)}}
<a class="ui right delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.ID}}">{{svg "octicon-trashcan" 16}} {{$.i18n.Tr "repo.issues.label_delete"}}</a>
<a class="ui right edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil" 16}} {{$.i18n.Tr "repo.issues.label_edit"}}</a>
{{else if $.PageIsOrgSettingsLabels}}
<a class="ui right delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.ID}}">{{svg "octicon-trashcan" 16}} {{$.i18n.Tr "repo.issues.label_delete"}}</a>
<a class="ui right edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil" 16}} {{$.i18n.Tr "repo.issues.label_edit"}}</a>
{{end}}
</div>
</div>
</li>
{{end}}
{{if and (not .PageIsOrgSettingsLabels) (.OrgLabels) }}
<li class="item">
<div class="ui grid middle aligned">
<div class="ten wide column">
{{$.i18n.Tr "repo.org_labels_desc" | Str2html}}
{{if .IsOrganizationOwner}}
<a class="ui" href="{{.OrganizationLink}}/settings/labels">({{$.i18n.Tr "repo.org_labels_desc_manage"}})</a>:
{{end}}
</div>
</div>
</li>
{{if (not $.PageIsOrgSettingsLabels)}}
<div class="orglabel">
{{range .OrgLabels}}
<li class="item">
<div class="ui grid middle aligned">
<div class="three wide column">
<div class="ui label has-emoji" style="color: {{.ForegroundColor}}; background-color: {{.Color}}">{{svg "octicon-tag" 16}} {{.Name}}</div>
</div>
<div class="seven wide column">
<div class="ui has-emoji">
{{.Description}}
</div>
</div>
<div class="three wide column">
<a class="ui right open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened" 16}} {{$.i18n.Tr "repo.issues.label_open_issues" .NumOpenRepoIssues}}</a>
</div>
<div class="three wide column">
</div>
</div>
</li>
{{end}}
</div>
{{end}}
{{end}}
</div>
</div>

View File

@ -0,0 +1,30 @@
<div class="ui centered grid">
<div class="twelve wide computer column">
<div class="ui attached left aligned segment">
<!-- <h4 class="ui header">
{{.i18n.Tr "repo.issues.label_templates.title"}}
<a target="_blank" rel="noopener noreferrer"
href="https://discuss.gogs.io/t/how-to-use-predefined-label-templates/599">
<span class="octicon octicon-question"></span>
</a>
</h4> -->
<p>{{.i18n.Tr "repo.issues.label_templates.info"}}</p>
<br/>
<form class="ui form center" action="{{.Link}}/initialize" method="post">
{{.CsrfTokenHtml}}
<div class="field">
<div class="ui selection dropdown">
<input type="hidden" name="template_name" value="Default">
<div class="default text">{{.i18n.Tr "repo.issues.label_templates.helper"}}</div>
<div class="menu">
{{range $template, $labels := .LabelTemplates}}
<div class="item" data-value="{{$template}}">{{$template}}<br/><i>({{$labels}})</i></div>
{{end}}
</div>
</div>
</div>
<button type="submit" class="ui blue button">{{.i18n.Tr "repo.issues.label_templates.use"}}</button>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,27 @@
<div class="ui new-label segment hide">
<form class="ui form" action="{{$.Link}}/new" method="post">
{{.CsrfTokenHtml}}
<div class="ui grid">
<div class="three wide column">
<div class="ui small input">
<input class="new-label-input emoji-input" name="title" placeholder="{{.i18n.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
</div>
</div>
<div class="three wide column">
<div class="ui small fluid input">
<input class="new-label-desc-input" name="description" placeholder="{{.i18n.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
</div>
</div>
<div class="color picker column">
<input class="color-picker" name="color" value="#70c24a" required maxlength="7">
</div>
<div class="column precolors">
{{template "repo/issue/label_precolors"}}
</div>
<div class="buttons">
<div class="ui blue small basic cancel button">{{.i18n.Tr "repo.milestones.cancel"}}</div>
<button class="ui green small button">{{.i18n.Tr "repo.issues.create_label"}}</button>
</div>
</div>
</form>
</div>

View File

@ -38,7 +38,7 @@
{{template "repo/issue/branch_selector_field" .}}
<input id="label_ids" name="label_ids" type="hidden" value="{{.label_ids}}">
<div class="ui {{if not .Labels}}disabled{{end}} floating jump select-label dropdown">
<div class="ui {{if and (not .Labels) (not .OrgLabels)}}disabled{{end}} floating jump select-label dropdown">
<span class="text">
<strong>{{.i18n.Tr "repo.issues.new.labels"}}</strong>
{{svg "octicon-gear" 16}}
@ -49,6 +49,11 @@
<a class="{{if .IsChecked}}checked{{end}} item has-emoji" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{svg "octicon-check" 16}}</span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name}}
{{if .Description }}<br><small class="desc">{{.Description}}</small>{{end}}</a>
{{end}}
<div class="ui divider"></div>
{{range .OrgLabels}}
<a class="{{if .IsChecked}}checked{{end}} item has-emoji" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{svg "octicon-check" 16}}</span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name}}
{{if .Description }}<br><small class="desc">{{.Description}}</small>{{end}}</a>
{{end}}
</div>
</div>
<div class="ui labels list">
@ -56,6 +61,9 @@
{{range .Labels}}
<a class="{{if not .IsChecked}}hide{{end}} item" id="label_{{.ID}}" href="{{$.RepoLink}}/issues?labels={{.ID}}"><span class="label color" style="background-color: {{.Color}}"></span> <span class="text has-emoji">{{.Name}}</span></a>
{{end}}
{{range .OrgLabels}}
<a class="{{if not .IsChecked}}hide{{end}} item" id="label_{{.ID}}" href="/issues?labels={{.ID}}"><span class="label color" style="background-color: {{.Color}}"></span> <span class="text has-emoji">{{.Name}}</span></a>
{{end}}
</div>
<div class="ui divider"></div>

View File

@ -15,6 +15,11 @@
<a class="{{if .IsChecked}}checked{{end}} item has-emoji" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{svg "octicon-check" 16}}</span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name}}
{{if .Description }}<br><small class="desc">{{.Description}}</small>{{end}}</a>
{{end}}
<div class="ui divider"></div>
{{range .OrgLabels}}
<a class="{{if .IsChecked}}checked{{end}} item has-emoji" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{svg "octicon-check" 16}}</span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name}}
{{if .Description }}<br><small class="desc">{{.Description}}</small>{{end}}</a>
{{end}}
</div>
</div>
<div class="ui labels list">
@ -23,6 +28,11 @@
<div class="item">
<a class="ui label has-emoji {{if not .IsChecked}}hide{{end}}" id="label_{{.ID}}" href="{{$.RepoLink}}/issues?labels={{.ID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}}" title="{{.Description}}">{{.Name}}</a>
</div>
{{end}}
{{range .OrgLabels}}
<div class="item">
<a class="ui label has-emoji {{if not .IsChecked}}hide{{end}}" id="label_{{.ID}}" href="{{$.RepoLink}}/issues?labels={{.ID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}}" title="{{.Description}}">{{.Name}}</a>
</div>
{{end}}
</div>

View File

@ -981,6 +981,189 @@
}
}
},
"/orgs/{org}/labels": {
"get": {
"produces": [
"application/json"
],
"tags": [
"organization"
],
"summary": "List an organization's labels",
"operationId": "orgListLabels",
"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, maximum page size is 50",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/LabelList"
}
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"organization"
],
"summary": "Create a label for an organization",
"operationId": "orgCreateLabel",
"parameters": [
{
"type": "string",
"description": "name of the organization",
"name": "org",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/CreateLabelOption"
}
}
],
"responses": {
"201": {
"$ref": "#/responses/Label"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}
},
"/orgs/{org}/labels/{id}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"organization"
],
"summary": "Get a single label",
"operationId": "orgGetLabel",
"parameters": [
{
"type": "string",
"description": "name of the organization",
"name": "org",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "id of the label to get",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/Label"
}
}
},
"delete": {
"tags": [
"organization"
],
"summary": "Delete a label",
"operationId": "orgDeleteLabel",
"parameters": [
{
"type": "string",
"description": "name of the organization",
"name": "org",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "id of the label to delete",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
}
}
},
"patch": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"organization"
],
"summary": "Update a label",
"operationId": "orgEditLabel",
"parameters": [
{
"type": "string",
"description": "name of the organization",
"name": "org",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "id of the label to edit",
"name": "id",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/EditLabelOption"
}
}
],
"responses": {
"200": {
"$ref": "#/responses/Label"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}
},
"/orgs/{org}/members": {
"get": {
"produces": [

View File

@ -125,6 +125,39 @@ function initBranchSelector() {
});
}
function initLabelEdit() {
// Create label
const $newLabelPanel = $('.new-label.segment');
$('.new-label.button').click(() => {
$newLabelPanel.show();
});
$('.new-label.segment .cancel').click(() => {
$newLabelPanel.hide();
});
$('.color-picker').each(function () {
$(this).minicolors();
});
$('.precolors .color').click(function () {
const color_hex = $(this).data('color-hex');
$('.color-picker').val(color_hex);
$('.minicolors-swatch-color').css('background-color', color_hex);
});
$('.edit-label-button').click(function () {
$('#label-modal-id').val($(this).data('id'));
$('.edit-label .new-label-input').val($(this).data('title'));
$('.edit-label .new-label-desc-input').val($(this).data('description'));
$('.edit-label .color-picker').val($(this).data('color'));
$('.minicolors-swatch-color').css('background-color', $(this).data('color'));
$('.edit-label.modal').modal({
onApprove() {
$('.edit-label.form').submit();
}
}).modal('show');
return false;
});
}
function updateIssuesMeta(url, action, issueIds, elementId) {
return new Promise(((resolve) => {
$.ajax({
@ -697,36 +730,7 @@ async function initRepository() {
// Labels
if ($('.repository.labels').length > 0) {
// Create label
const $newLabelPanel = $('.new-label.segment');
$('.new-label.button').click(() => {
$newLabelPanel.show();
});
$('.new-label.segment .cancel').click(() => {
$newLabelPanel.hide();
});
$('.color-picker').each(function () {
$(this).minicolors();
});
$('.precolors .color').click(function () {
const color_hex = $(this).data('color-hex');
$('.color-picker').val(color_hex);
$('.minicolors-swatch-color').css('background-color', color_hex);
});
$('.edit-label-button').click(function () {
$('#label-modal-id').val($(this).data('id'));
$('.edit-label .new-label-input').val($(this).data('title'));
$('.edit-label .new-label-desc-input').val($(this).data('description'));
$('.edit-label .color-picker').val($(this).data('color'));
$('.minicolors-swatch-color').css('background-color', $(this).data('color'));
$('.edit-label.modal').modal({
onApprove() {
$('.edit-label.form').submit();
}
}).modal('show');
return false;
});
initLabelEdit();
}
// Milestones
@ -1757,6 +1761,11 @@ function initOrganization() {
}
});
}
// Labels
if ($('.organization.settings.labels').length > 0) {
initLabelEdit();
}
}
function initUserSettings() {

View File

@ -168,4 +168,45 @@
height: 60px;
}
}
&.settings {
.labelspage {
list-style: none;
padding-top: 0;
.item {
margin-top: 0;
margin-right: -14px;
margin-left: -14px !important;
padding: 10px;
border-bottom: 1px solid #e1e4e8;
border-top: none;
a {
font-size: 15px;
padding-top: 5px;
padding-right: 10px;
color: #666666;
&:hover {
color: #000000;
}
&.open-issues {
margin-right: 30px;
}
}
.ui.label {
font-size: 1em;
}
}
.item:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
}
}

View File

@ -1045,14 +1045,17 @@
}
}
.label.list {
.labelspage {
list-style: none;
padding-top: 15px;
padding-top: 0;
.item {
padding-top: 10px;
padding-bottom: 10px;
border-bottom: 1px dashed #aaaaaa;
margin-top: 0;
margin-right: -14px;
margin-left: -14px;
padding: 10px;
border-bottom: 1px solid #e1e4e8;
border-top: none;
a {
font-size: 15px;
@ -1073,6 +1076,16 @@
font-size: 1em;
}
}
.item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.orglabel {
opacity: .7;
}
}
.milestone.list {