diff options
39 files changed, 1627 insertions, 315 deletions
diff --git a/integrations/api_issue_label_test.go b/integrations/api_issue_label_test.go index 6cdb3a0dad..ddcfdd6615 100644 --- a/integrations/api_issue_label_test.go +++ b/integrations/api_issue_label_test.go @@ -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) + +} diff --git a/integrations/api_issue_test.go b/integrations/api_issue_test.go index 30fbda173c..2a6f137747 100644 --- a/integrations/api_issue_test.go +++ b/integrations/api_issue_test.go @@ -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) +} diff --git a/models/error.go b/models/error.go index f53479fac8..f54df37330 100644 --- a/models/error.go +++ b/models/error.go @@ -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) } // _____ .__.__ __ diff --git a/models/fixtures/issue_label.yml b/models/fixtures/issue_label.yml index 49d5a95d02..f4ecb1f923 100644 --- a/models/fixtures/issue_label.yml +++ b/models/fixtures/issue_label.yml @@ -12,3 +12,8 @@ id: 3 issue_id: 2 label_id: 1 + +- + id: 4 + issue_id: 2 + label_id: 4 diff --git a/models/fixtures/label.yml b/models/fixtures/label.yml index 5336342b1c..3ad82eebed 100644 --- a/models/fixtures/label.yml +++ b/models/fixtures/label.yml @@ -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 + diff --git a/models/issue.go b/models/issue.go index 8aa02873a1..db8991095d 100644 --- a/models/issue.go +++ b/models/issue.go @@ -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 } diff --git a/models/issue_label.go b/models/issue_label.go index 3b516a7aed..cb9c307e2b 100644 --- a/models/issue_label.go +++ b/models/issue_label.go @@ -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() -} - // .___ .____ ___. .__ // | | ______ ________ __ ____ | | _____ \_ |__ ____ | | // | |/ ___// ___/ | \_/ __ \| | \__ \ | __ \_/ __ \| | diff --git a/models/issue_label_test.go b/models/issue_label_test.go index 6f51473fcb..8afba779e0 100644 --- a/models/issue_label_test.go +++ b/models/issue_label_test.go @@ -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{}) diff --git a/models/issue_list_test.go b/models/issue_list_test.go index f5a91702f2..c9c39332c7 100644 --- a/models/issue_list_test.go +++ b/models/issue_list_test.go @@ -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), } diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 49b34861d6..847cd75d52 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 diff --git a/models/migrations/v135.go b/models/migrations/v135.go new file mode 100644 index 0000000000..8d859d42c0 --- /dev/null +++ b/models/migrations/v135.go @@ -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 +} diff --git a/modules/repository/create.go b/modules/repository/create.go index 255bf09731..5c0aae30da 100644 --- a/modules/repository/create.go +++ b/modules/repository/create.go @@ -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) } } diff --git a/modules/test/context_tests.go b/modules/test/context_tests.go index f9f0ec5d42..af47369ee1 100644 --- a/modules/test/context_tests.go +++ b/modules/test/context_tests.go @@ -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) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 2fb2e21a5c..ed6d74d35a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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 diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index eee9440574..e5bb98033b 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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) diff --git a/routers/api/v1/org/label.go b/routers/api/v1/org/label.go new file mode 100644 index 0000000000..c5fb262a30 --- /dev/null +++ b/routers/api/v1/org/label.go @@ -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) +} diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 25664e45a9..217c97c69b 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -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, diff --git a/routers/api/v1/repo/issue_label.go b/routers/api/v1/repo/issue_label.go index 8089891265..8b2a1988fa 100644 --- a/routers/api/v1/repo/issue_label.go +++ b/routers/api/v1/repo/issue_label.go @@ -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 } diff --git a/routers/api/v1/repo/label.go b/routers/api/v1/repo/label.go index 95dbbc9551..5f70e74407 100644 --- a/routers/api/v1/repo/label.go +++ b/routers/api/v1/repo/label.go @@ -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) diff --git a/routers/org/org_labels.go b/routers/org/org_labels.go new file mode 100644 index 0000000000..e5b9d9ddee --- /dev/null +++ b/routers/org/org_labels.go @@ -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") +} diff --git a/routers/org/setting.go b/routers/org/setting.go index 3b6e124587..348d8cc8d8 100644 --- a/routers/org/setting.go +++ b/routers/org/setting.go @@ -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) +} diff --git a/routers/repo/compare.go b/routers/repo/compare.go index 815ec35650..87b66dc7fb 100644 --- a/routers/repo/compare.go +++ b/routers/repo/compare.go @@ -335,7 +335,6 @@ func PrepareCompareDiff( } else { title = headBranch } - ctx.Data["title"] = title ctx.Data["Username"] = headUser.Name ctx.Data["Reponame"] = headRepo.Name diff --git a/routers/repo/issue.go b/routers/repo/issue.go index 9cc6ea1dfa..6dbf9cf5c8 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -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) { diff --git a/routers/repo/issue_label.go b/routers/repo/issue_label.go index 8ac9b8d336..16638404e3 100644 --- a/routers/repo/issue_label.go +++ b/routers/repo/issue_label.go @@ -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) diff --git a/routers/routes/routes.go b/routers/routes/routes.go index e92f8a60b6..4409830dfe 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -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)) diff --git a/services/issue/label.go b/services/issue/label.go index d2c1cd6ec5..c8ef9e9536 100644 --- a/services/issue/label.go +++ b/services/issue/label.go @@ -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 { diff --git a/templates/org/settings/labels.tmpl b/templates/org/settings/labels.tmpl new file mode 100644 index 0000000000..ed31890df5 --- /dev/null +++ b/templates/org/settings/labels.tmpl @@ -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" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 09fca5d7f6..63114b056e 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -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> diff --git a/templates/repo/issue/labels.tmpl b/templates/repo/issue/labels.tmpl index 4719c8f1fb..d3df3b5944 100644 --- a/templates/repo/issue/labels.tmpl +++ b/templates/repo/issue/labels.tmpl @@ -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" .}} diff --git a/templates/repo/issue/labels/edit_delete_label.tmpl b/templates/repo/issue/labels/edit_delete_label.tmpl new file mode 100644 index 0000000000..1ddfd39ee4 --- /dev/null +++ b/templates/repo/issue/labels/edit_delete_label.tmpl @@ -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> + diff --git a/templates/repo/issue/labels/label_list.tmpl b/templates/repo/issue/labels/label_list.tmpl new file mode 100644 index 0000000000..ce33d76fc1 --- /dev/null +++ b/templates/repo/issue/labels/label_list.tmpl @@ -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> + diff --git a/templates/repo/issue/labels/label_load_template.tmpl b/templates/repo/issue/labels/label_load_template.tmpl new file mode 100644 index 0000000000..76ee77658a --- /dev/null +++ b/templates/repo/issue/labels/label_load_template.tmpl @@ -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> diff --git a/templates/repo/issue/labels/label_new.tmpl b/templates/repo/issue/labels/label_new.tmpl new file mode 100644 index 0000000000..15a6c029a3 --- /dev/null +++ b/templates/repo/issue/labels/label_new.tmpl @@ -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> diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl index 524b849c14..a53bbdc685 100644 --- a/templates/repo/issue/new_form.tmpl +++ b/templates/repo/issue/new_form.tmpl @@ -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> diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 1d1f1916da..d0275c23f4 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -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> diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index c564f8739b..6e5086d507 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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": [ diff --git a/web_src/js/index.js b/web_src/js/index.js index 2db5b08b8b..a0e410c133 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -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() { diff --git a/web_src/less/_organization.less b/web_src/less/_organization.less index 6071604cbc..5a72017c2f 100644 --- a/web_src/less/_organization.less +++ b/web_src/less/_organization.less @@ -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; + } + + } + } } diff --git a/web_src/less/_repository.less b/web_src/less/_repository.less index bc7344ba0b..3b2467b500 100644 --- a/web_src/less/_repository.less +++ b/web_src/less/_repository.less @@ -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 { |