From 8d99ee27734b15199e5f620f67e81857d4cddfca Mon Sep 17 00:00:00 2001 From: mrsdizzie Date: Wed, 1 Apr 2020 00:14:46 -0400 Subject: [PATCH] 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 Co-authored-by: guillep2k <18600385+guillep2k@users.noreply.github.com> Co-authored-by: 6543 <6543@obermui.de> --- integrations/api_issue_label_test.go | 71 ++++ integrations/api_issue_test.go | 65 ++- models/error.go | 35 +- models/fixtures/issue_label.yml | 5 + models/fixtures/label.yml | 20 + models/issue.go | 4 +- models/issue_label.go | 372 ++++++++++++++---- models/issue_label_test.go | 111 +++++- models/issue_list_test.go | 1 - models/migrations/migrations.go | 2 + models/migrations/v135.go | 22 ++ modules/repository/create.go | 2 +- modules/test/context_tests.go | 4 +- options/locale/locale_en-US.ini | 5 + routers/api/v1/api.go | 7 + routers/api/v1/org/label.go | 237 +++++++++++ routers/api/v1/repo/issue.go | 7 +- routers/api/v1/repo/issue_label.go | 8 +- routers/api/v1/repo/label.go | 4 +- routers/org/org_labels.go | 106 +++++ routers/org/setting.go | 12 + routers/repo/compare.go | 1 - routers/repo/issue.go | 36 +- routers/repo/issue_label.go | 41 +- routers/routes/routes.go | 8 + services/issue/label.go | 5 +- templates/org/settings/labels.tmpl | 29 ++ templates/org/settings/navbar.tmpl | 3 + templates/repo/issue/labels.tmpl | 170 +------- .../repo/issue/labels/edit_delete_label.tmpl | 59 +++ templates/repo/issue/labels/label_list.tmpl | 97 +++++ .../issue/labels/label_load_template.tmpl | 30 ++ templates/repo/issue/labels/label_new.tmpl | 27 ++ templates/repo/issue/new_form.tmpl | 10 +- .../repo/issue/view_content/sidebar.tmpl | 10 + templates/swagger/v1_json.tmpl | 183 +++++++++ web_src/js/index.js | 69 ++-- web_src/less/_organization.less | 41 ++ web_src/less/_repository.less | 23 +- 39 files changed, 1627 insertions(+), 315 deletions(-) create mode 100644 models/migrations/v135.go create mode 100644 routers/api/v1/org/label.go create mode 100644 routers/org/org_labels.go create mode 100644 templates/org/settings/labels.tmpl create mode 100644 templates/repo/issue/labels/edit_delete_label.tmpl create mode 100644 templates/repo/issue/labels/label_list.tmpl create mode 100644 templates/repo/issue/labels/label_load_template.tmpl create mode 100644 templates/repo/issue/labels/label_new.tmpl diff --git a/integrations/api_issue_label_test.go b/integrations/api_issue_label_test.go index 6cdb3a0da..ddcfdd661 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 30fbda173..2a6f13774 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 f53479fac..f54df3733 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 49d5a95d0..f4ecb1f92 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 5336342b1..3ad82eebe 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 8aa02873a..db8991095 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 3b516a7ae..cb9c307e2 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 6f51473fc..8afba779e 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 f5a91702f..c9c39332c 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 49b34861d..847cd75d5 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 000000000..8d859d42c --- /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 255bf0973..5c0aae30d 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 f9f0ec5d4..af47369ee 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 2fb2e21a5..ed6d74d35 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 all repositories 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 all repositories under this organization. +settings.labels_desc = Add labels which can be used on issues for all repositories 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 eee944057..e5bb98033 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 000000000..c5fb262a3 --- /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 25664e45a..217c97c69 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 808989126..8b2a1988f 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 95dbbc955..5f70e7440 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 000000000..e5b9d9dde --- /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 3b6e12458..348d8cc8d 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 815ec3565..87b66dc7f 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 9cc6ea1df..6dbf9cf5c 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 8ac9b8d33..16638404e 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 e92f8a60b..4409830df 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 d2c1cd6ec..c8ef9e953 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 000000000..ed31890df --- /dev/null +++ b/templates/org/settings/labels.tmpl @@ -0,0 +1,29 @@ +{{template "base/head" .}} +
+ {{template "org/header" .}} +
+
+ {{template "org/settings/navbar" .}} +
+
+
+ {{$.i18n.Tr "org.settings.labels_desc" | Str2html}} +
+
+
+
{{.i18n.Tr "repo.issues.new_label"}}
+
+
+
+
+ {{template "repo/issue/labels/label_new" .}} + {{template "base/alert" .}} + {{template "repo/issue/labels/label_list" .}} +
+
+
+
+ + +{{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 09fca5d7f..63114b056 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -7,6 +7,9 @@ {{.i18n.Tr "repo.settings.hooks"}} + + {{.i18n.Tr "repo.labels"}} + {{.i18n.Tr "org.settings.delete"}} diff --git a/templates/repo/issue/labels.tmpl b/templates/repo/issue/labels.tmpl index 4719c8f1f..d3df3b594 100644 --- a/templates/repo/issue/labels.tmpl +++ b/templates/repo/issue/labels.tmpl @@ -10,173 +10,17 @@ {{end}} - {{if not .Repository.IsArchived}} -
-
- {{.CsrfTokenHtml}} -
-
-
- -
-
-
-
- -
-
-
- -
-
- {{template "repo/issue/label_precolors"}} -
-
-
{{.i18n.Tr "repo.milestones.cancel"}}
- -
-
-
-
- {{end}}
- - + {{if and (or .CanWriteIssues .CanWritePulls) (not .Repository.IsArchived)}} + {{template "repo/issue/labels/label_new" .}} + {{end}} {{template "base/alert" .}} -
{{.i18n.Tr "repo.issues.label_count" .NumLabels}}
-
- {{if and (or $.CanWriteIssues $.CanWritePulls) (eq .NumLabels 0) (not $.Repository.IsArchived) }} -
-
-
- -

{{.i18n.Tr "repo.issues.label_templates.info"}}

-
-
- {{.CsrfTokenHtml}} -
- -
- -
-
-
-
- {{end}} - -
- - {{range .Labels}} -
  • -
    -
    -
    {{svg "octicon-tag" 16}} {{.Name}}
    -
    -
    - {{.Description}} -
    - -
    - {{if and (not $.Repository.IsArchived) (or $.CanWriteIssues $.CanWritePulls)}} - {{svg "octicon-trashcan" 16}} {{$.i18n.Tr "repo.issues.label_delete"}} - {{svg "octicon-pencil" 16}} {{$.i18n.Tr "repo.issues.label_edit"}} - {{end}} -
    -
    -
  • - {{end}} -
    + {{template "repo/issue/labels/label_list" .}} -{{if and (or .CanWriteIssues .CanWritePulls) (not .Repository.IsArchived)}} - - - +{{if and (or .CanWriteIssues .CanWritePulls) (not .Repository.IsArchived) }} +{{template "repo/issue/labels/edit_delete_label" .}} {{end}} + {{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 000000000..1ddfd39ee --- /dev/null +++ b/templates/repo/issue/labels/edit_delete_label.tmpl @@ -0,0 +1,59 @@ + + + + diff --git a/templates/repo/issue/labels/label_list.tmpl b/templates/repo/issue/labels/label_list.tmpl new file mode 100644 index 000000000..ce33d76fc --- /dev/null +++ b/templates/repo/issue/labels/label_list.tmpl @@ -0,0 +1,97 @@ +

    + {{.i18n.Tr "repo.issues.label_count" .NumLabels}} + +

    + +
    +
    + {{if and (not $.PageIsOrgSettingsLabels) (or $.CanWriteIssues $.CanWritePulls) (eq .NumLabels 0) (not $.Repository.IsArchived) }} + {{template "repo/issue/labels/label_load_template" .}} +
    + {{else if and ($.PageIsOrgSettingsLabels) (eq .NumLabels 0)}} + {{template "repo/issue/labels/label_load_template" .}} + {{end}} + {{range .Labels}} +
  • +
    +
    +
    {{svg "octicon-tag" 16}} {{.Name}}
    +
    +
    +
    + {{.Description}} +
    +
    + + +
    +
  • + {{end}} + {{if and (not .PageIsOrgSettingsLabels) (.OrgLabels) }} +
  • +
    +
    + {{$.i18n.Tr "repo.org_labels_desc" | Str2html}} + {{if .IsOrganizationOwner}} + ({{$.i18n.Tr "repo.org_labels_desc_manage"}}): + {{end}} +
    +
    +
  • + {{if (not $.PageIsOrgSettingsLabels)}} +
    + {{range .OrgLabels}} +
  • +
    +
    +
    {{svg "octicon-tag" 16}} {{.Name}}
    +
    +
    +
    + {{.Description}} +
    +
    + +
    +
    +
    +
  • + {{end}} +
    + {{end}} + {{end}} +
    +
    + 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 000000000..76ee77658 --- /dev/null +++ b/templates/repo/issue/labels/label_load_template.tmpl @@ -0,0 +1,30 @@ +
    +
    +
    + +

    {{.i18n.Tr "repo.issues.label_templates.info"}}

    +
    +
    + {{.CsrfTokenHtml}} +
    + +
    + +
    +
    +
    +
    diff --git a/templates/repo/issue/labels/label_new.tmpl b/templates/repo/issue/labels/label_new.tmpl new file mode 100644 index 000000000..15a6c029a --- /dev/null +++ b/templates/repo/issue/labels/label_new.tmpl @@ -0,0 +1,27 @@ +
    +
    + {{.CsrfTokenHtml}} +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    + {{template "repo/issue/label_precolors"}} +
    +
    +
    {{.i18n.Tr "repo.milestones.cancel"}}
    + +
    +
    +
    +
    diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl index 524b849c1..a53bbdc68 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" .}} -
    @@ -56,6 +61,9 @@ {{range .Labels}} {{.Name}} {{end}} + {{range .OrgLabels}} + {{.Name}} + {{end}}
    diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 1d1f1916d..d0275c23f 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -15,6 +15,11 @@ {{svg "octicon-check" 16}} {{.Name}} {{if .Description }}
    {{.Description}}{{end}}
    {{end}} +
    + {{range .OrgLabels}} + {{svg "octicon-check" 16}} {{.Name}} + {{if .Description }}
    {{.Description}}{{end}}
    + {{end}}
    @@ -23,6 +28,11 @@ + {{end}} + {{range .OrgLabels}} +
    + {{.Name}} +
    {{end}}
    diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index c564f8739..6e5086d50 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 2db5b08b8..a0e410c13 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 6071604cb..5a72017c2 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 bc7344ba0..3b2467b50 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 {