diff --git a/models/issues/label.go b/models/issues/label.go index 70906efb47d75..aa1b5237a2c70 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -88,6 +88,7 @@ type Label struct { Color string `xorm:"VARCHAR(7)"` NumIssues int NumClosedIssues int + NumMilestones int CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` @@ -523,6 +524,13 @@ func updateLabelCols(ctx context.Context, l *Label, cols ...string) error { "issue.is_closed": true, }), ). + SetExpr("num_milestones", + builder.Select("count(*)").From("milestone_label"). + InnerJoin("milestone", "milestone_label.milestone_id = milestone.id"). + Where(builder.Eq{ + "milestone_label.label_id": l.ID, + }), + ). Cols(cols...).Update(l) return err } diff --git a/models/issues/milestone.go b/models/issues/milestone.go index 1418e0869d376..ddb9341593da0 100644 --- a/models/issues/milestone.go +++ b/models/issues/milestone.go @@ -10,6 +10,8 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" @@ -47,6 +49,7 @@ type Milestone struct { ID int64 `xorm:"pk autoincr"` RepoID int64 `xorm:"INDEX"` Repo *repo_model.Repository `xorm:"-"` + Labels []*Label `xorm:"-"` Name string Content string `xorm:"TEXT"` RenderedContent string `xorm:"-"` @@ -117,6 +120,19 @@ func NewMilestone(m *Milestone) (err error) { return err } + if len(m.Labels) > 0 { + for _, label := range m.Labels { + // Silently drop invalid labels. + if label.RepoID != m.RepoID && label.OrgID != m.Repo.OwnerID { + continue + } + + if err = m.addLabel(ctx, label, nil); err != nil { + return fmt.Errorf("addLabel [id: %d]: %v", label.ID, err) + } + } + } + if _, err = db.Exec(ctx, "UPDATE `repository` SET num_milestones = num_milestones + 1 WHERE id = ?", m.RepoID); err != nil { return err } @@ -142,15 +158,15 @@ func GetMilestoneByRepoID(ctx context.Context, repoID, id int64) (*Milestone, er // GetMilestoneByRepoIDANDName return a milestone if one exist by name and repo func GetMilestoneByRepoIDANDName(repoID int64, name string) (*Milestone, error) { - var mile Milestone - has, err := db.GetEngine(db.DefaultContext).Where("repo_id=? AND name=?", repoID, name).Get(&mile) + var m Milestone + has, err := db.GetEngine(db.DefaultContext).Where("repo_id=? AND name=?", repoID, name).Get(&m) if err != nil { return nil, err } if !has { return nil, ErrMilestoneNotExist{Name: name, RepoID: repoID} } - return &mile, nil + return &m, nil } // UpdateMilestone updates information of given milestone. @@ -176,6 +192,44 @@ func UpdateMilestone(m *Milestone, oldIsClosed bool) error { } } + if err = m.LoadLabels(ctx); err != nil { + return err + } + dbLabels, err := GetLabelsByMilestoneID(ctx, m.ID) + if err != nil { + return err + } + // delete missing labels associated with repo and milestone from db + for _, dbLabel := range dbLabels { + labelOnMilestone := false + for _, msLabel := range m.Labels { + if msLabel.ID == dbLabel.ID { + labelOnMilestone = true + break + } + } + if !labelOnMilestone { + if err = deleteMilestoneLabel(ctx, m, dbLabel, nil); err != nil { + return fmt.Errorf("deleteMilestoneLabel [id: %d]: %v", dbLabel.ID, err) + } + } + } + // add new labels associated with repo and milestone to db + for _, msLabel := range m.Labels { + labelInDatabase := false + for _, dbLabel := range dbLabels { + if msLabel.ID == dbLabel.ID { + labelInDatabase = true + break + } + } + if !labelInDatabase { + if err = m.addLabel(ctx, msLabel, nil); err != nil { + return fmt.Errorf("addLabel [id: %d]: %v", msLabel.ID, err) + } + } + } + return committer.Commit() } @@ -269,7 +323,7 @@ func changeMilestoneStatus(ctx context.Context, m *Milestone, isClosed bool) err return updateRepoMilestoneNum(ctx, m.RepoID) } -// DeleteMilestoneByRepoID deletes a milestone from a repository. +// DeleteMilestoneByRepoID deletes a milestone and associated labels from a repository. func DeleteMilestoneByRepoID(repoID, id int64) error { m, err := GetMilestoneByRepoID(db.DefaultContext, repoID, id) if err != nil { @@ -320,6 +374,15 @@ func DeleteMilestoneByRepoID(repoID, id int64) error { if _, err = db.Exec(ctx, "UPDATE `issue` SET milestone_id = 0 WHERE milestone_id = ?", m.ID); err != nil { return err } + + if err = m.LoadLabels(ctx); err != nil { + return err + } + for _, label := range m.Labels { + if err = deleteMilestoneLabel(ctx, m, label, nil); err != nil { + return err + } + } return committer.Commit() } @@ -341,6 +404,7 @@ type GetMilestonesOption struct { State api.StateType Name string SortType string + Labels string } func (opts GetMilestonesOption) toCond() builder.Cond { @@ -374,6 +438,22 @@ func GetMilestones(opts GetMilestonesOption) (MilestoneList, int64, error) { sess = db.SetSessionPagination(sess, &opts) } + if len(opts.Labels) > 0 && opts.Labels != "0" { + labelIDs, err := base.StringsToInt64s(strings.Split(opts.Labels, ",")) + if err != nil { + log.Warn("Malformed Labels argument: %s", opts.Labels) + } else { + for i, labelID := range labelIDs { + if labelID > 0 { + sess.Join("INNER", fmt.Sprintf("milestone_label il%d", i), + fmt.Sprintf("milestone.id = il%[1]d.milestone_id AND il%[1]d.label_id = %[2]d", i, labelID)) + } else { + sess.Where("milestone.id NOT IN (SELECT milestone_id FROM milestone_label WHERE milestone_id = ?)", -labelID) + } + } + } + } + switch opts.SortType { case "furthestduedate": sess.Desc("deadline_unix") @@ -393,6 +473,7 @@ func GetMilestones(opts GetMilestonesOption) (MilestoneList, int64, error) { miles := make([]*Milestone, 0, opts.PageSize) total, err := sess.FindAndCount(&miles) + return miles, total, err } diff --git a/models/issues/milestone_label.go b/models/issues/milestone_label.go new file mode 100644 index 0000000000000..39e53b8fc1e5d --- /dev/null +++ b/models/issues/milestone_label.go @@ -0,0 +1,318 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "context" + "fmt" + "sort" + + "code.gitea.io/gitea/models/db" + access_model "code.gitea.io/gitea/models/perm/access" + user_model "code.gitea.io/gitea/models/user" + + "xorm.io/builder" +) + +// MilestoneLabel represents an milestone-label relation. +type MilestoneLabel struct { + ID int64 `xorm:"pk autoincr"` + MilestoneID int64 `xorm:"UNIQUE(milestoneid_labelid)"` + LabelID int64 `xorm:"UNIQUE(milestoneid_labelid)"` +} + +func init() { + db.RegisterModel(new(MilestoneLabel)) +} + +// HasMilestoneLabel returns true if milestone has been labeled. +func HasMilestoneLabel(ctx context.Context, milestoneID, labelID int64) (bool, error) { + has, err := db.GetEngine(ctx).Where("milestone_id = ? AND label_id = ?", milestoneID, labelID).Exist(new(MilestoneLabel)) + if err != nil { + return false, err + } + return has, nil +} + +// GetLabelsByMilestoneID returns all labels that belong to given milestone by ID. +func GetLabelsByMilestoneID(ctx context.Context, milestoneID int64) ([]*Label, error) { + var labels []*Label + return labels, db.GetEngine(ctx).Where("milestone_label.milestone_id = ?", milestoneID). + Join("LEFT", "milestone_label", "milestone_label.label_id = label.id"). + Asc("label.name"). + Find(&labels) +} + +// newMilestoneLabel creates a new label, but it does NOT check if the label is valid for the specified milestone +// YOU MUST CHECK THIS BEFORE EXECUTING THIS FUNCTION +func newMilestoneLabel(ctx context.Context, m *Milestone, label *Label, doer *user_model.User) (err error) { + if err = db.Insert(ctx, &MilestoneLabel{ + MilestoneID: m.ID, + LabelID: label.ID, + }); err != nil { + return err + } + + return updateLabelCols(ctx, label, "num_issues", "num_closed_issues", "num_milestones") +} + +// NewMilestoneLabel creates a new milestone-label relation. +func NewMilestoneLabel(m *Milestone, label *Label, doer *user_model.User) (err error) { + hasLabel, err := HasMilestoneLabel(db.DefaultContext, m.ID, label.ID) + if hasLabel { + return nil + } + if err != nil { + return err + } + + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + // Do NOT add invalid labels + if m.RepoID != label.RepoID && m.Repo.OwnerID != label.OrgID { + return nil + } + + if err = newMilestoneLabel(ctx, m, label, doer); err != nil { + return err + } + + m.Labels = nil + if err = m.LoadLabels(ctx); err != nil { + return err + } + + return committer.Commit() +} + +// newMilestoneLabels add labels to an milestone. It will check if the labels are valid for the milestone +func newMilestoneLabels(ctx context.Context, m *Milestone, labels []*Label, doer *user_model.User) (err error) { + for _, label := range labels { + hasLabel, err := HasMilestoneLabel(ctx, m.ID, label.ID) + if err != nil { + return err + } + // Don't add already present labels and invalid labels + if hasLabel || + (label.RepoID != m.RepoID && label.OrgID != m.Repo.OwnerID) { + continue + } + + if err = newMilestoneLabel(ctx, m, label, doer); err != nil { + return fmt.Errorf("newMilestoneLabel: %v", err) + } + } + + return nil +} + +// NewMilestoneLabels creates a list of milestone-label relations. +func NewMilestoneLabels(m *Milestone, labels []*Label, doer *user_model.User) (err error) { + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + if err = newMilestoneLabels(ctx, m, labels, doer); err != nil { + return err + } + + m.Labels = nil + if err = m.LoadLabels(ctx); err != nil { + return err + } + + return committer.Commit() +} + +func deleteMilestoneLabel(ctx context.Context, m *Milestone, label *Label, doer *user_model.User) (err error) { + if count, err := db.DeleteByBean(ctx, &MilestoneLabel{ + MilestoneID: m.ID, + LabelID: label.ID, + }); err != nil { + return err + } else if count == 0 { + return nil + } + return updateLabelCols(ctx, label, "num_issues", "num_closed_issues", "num_milestones") +} + +// DeleteMilestoneLabel deletes milestone-label relation. +func DeleteMilestoneLabel(m *Milestone, label *Label, doer *user_model.User) (err error) { + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + if err = deleteMilestoneLabel(ctx, m, label, doer); err != nil { + return err + } + + return committer.Commit() +} + +// DeleteMilestoneLabelsByRepoID deletes Milestone Labels +func DeleteMilestoneLabelsByRepoID(ctx context.Context, repoID int64) error { + deleteCond := builder.Select("id").From("label").Where(builder.Eq{"label.repo_id": repoID}) + + if _, err := db.GetEngine(ctx).In("label_id", deleteCond). + Delete(&MilestoneLabel{}); err != nil { + return err + } + return nil +} + +// LoadLabels loads labels +func (m *Milestone) LoadLabels(ctx context.Context) (err error) { + if m.Labels == nil { + m.Labels, err = GetLabelsByMilestoneID(ctx, m.ID) + if err != nil { + return fmt.Errorf("GetLabelsByMilestoneID [%d]: %v", m.ID, err) + } + } + return nil +} + +func (m *Milestone) hasLabel(ctx context.Context, labelID int64) (bool, error) { + hasLabel, err := HasMilestoneLabel(ctx, m.ID, labelID) + if err != nil { + return false, err + } + return hasLabel, nil +} + +// HasLabel returns true if milestone has been labeled by given ID. +func (m *Milestone) HasLabel(labelID int64) (bool, error) { + hasLabel, err := m.hasLabel(db.DefaultContext, labelID) + return hasLabel, err +} + +func (m *Milestone) addLabel(ctx context.Context, label *Label, doer *user_model.User) error { + return newMilestoneLabel(ctx, m, label, doer) +} + +// AddLabels adds a list of new labels to the milestone. +func (m *Milestone) AddLabels(doer *user_model.User, labels []*Label) error { + return NewMilestoneLabels(m, labels, doer) +} + +func (m *Milestone) addLabels(ctx context.Context, labels []*Label, doer *user_model.User) error { + return newMilestoneLabels(ctx, m, labels, doer) +} + +func (m *Milestone) removeLabel(ctx context.Context, doer *user_model.User, label *Label) error { + return deleteMilestoneLabel(ctx, m, label, doer) +} + +func (m *Milestone) clearLabels(ctx context.Context, e db.Engine, doer *user_model.User) (err error) { + if err = m.LoadLabels(ctx); err != nil { + return fmt.Errorf("getLabels: %v", err) + } + + for i := range m.Labels { + if err = m.removeLabel(ctx, doer, m.Labels[i]); err != nil { + return fmt.Errorf("removeLabel: %v", err) + } + } + + return nil +} + +// ClearLabels removes all milestone labels as the given user. +// Triggers appropriate WebHooks, if any. +func (m *Milestone) ClearLabels(doer *user_model.User) (err error) { + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + perm, err := access_model.GetUserRepoPermission(ctx, m.Repo, doer) + if err != nil { + return err + } + if !perm.CanWriteIssuesOrPulls(true) { + return ErrRepoLabelNotExist{} + } + + if err = m.clearLabels(ctx, db.GetEngine(ctx), doer); err != nil { + return err + } + + if err = committer.Commit(); err != nil { + return fmt.Errorf("Commit: %v", err) + } + + return nil +} + +// ReplaceLabels removes all current labels and adds the given labels to the milestone. +// Triggers appropriate WebHooks, if any. +func (m *Milestone) ReplaceLabels(labels []*Label, doer *user_model.User) (err error) { + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + if err = m.LoadLabels(ctx); err != nil { + return err + } + + sort.Sort(labelSorter(labels)) + sort.Sort(labelSorter(m.Labels)) + + var toAdd, toRemove []*Label + + addIndex, removeIndex := 0, 0 + for addIndex < len(labels) && removeIndex < len(m.Labels) { + addLabel := labels[addIndex] + removeLabel := m.Labels[removeIndex] + if addLabel.ID == removeLabel.ID { + // Silently drop invalid labels + if removeLabel.RepoID != m.RepoID && removeLabel.OrgID != m.Repo.OwnerID { + toRemove = append(toRemove, removeLabel) + } + + addIndex++ + removeIndex++ + } else if addLabel.ID < removeLabel.ID { + // Only add if the label is valid + if addLabel.RepoID == m.RepoID || addLabel.OrgID == m.Repo.OwnerID { + toAdd = append(toAdd, addLabel) + } + addIndex++ + } else { + toRemove = append(toRemove, removeLabel) + removeIndex++ + } + } + toAdd = append(toAdd, labels[addIndex:]...) + toRemove = append(toRemove, m.Labels[removeIndex:]...) + + if len(toAdd) > 0 { + if err = m.addLabels(ctx, toAdd, doer); err != nil { + return fmt.Errorf("addLabels: %v", err) + } + } + + for _, l := range toRemove { + if err = m.removeLabel(ctx, doer, l); err != nil { + return fmt.Errorf("removeLabel: %v", err) + } + } + + m.Labels = nil + if err = m.LoadLabels(ctx); err != nil { + return err + } + + return committer.Commit() +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 7a126593d1458..78b1ee6a66ae3 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -524,6 +524,8 @@ var migrations = []Migration{ NewMigration("Fix PackageProperty typo", v1_21.FixPackagePropertyTypo), // v271 -> v272 NewMigration("Allow archiving labels", v1_21.AddArchivedUnixColumInLabelTable), + // v272 -> v273 + NewMigration("Add milestone labels", v1_21.AddMilestoneLabels), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_21/v272.go b/models/migrations/v1_21/v272.go new file mode 100644 index 0000000000000..928c0bf877a45 --- /dev/null +++ b/models/migrations/v1_21/v272.go @@ -0,0 +1,23 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_21 //nolint + +import ( + "fmt" + + "xorm.io/xorm" +) + +func AddMilestoneLabels(x *xorm.Engine) error { + type MilestoneLabel struct { + ID int64 `xorm:"pk autoincr"` + MilestoneID int64 `xorm:"UNIQUE(s)"` + LabelID int64 `xorm:"UNIQUE(s)"` + } + + if err := x.Sync2(new(MilestoneLabel)); err != nil { + return fmt.Errorf("Sync2: %v", err) + } + return nil +} diff --git a/models/repo.go b/models/repo.go index 7579d2ad7348b..8ed3d33e7eea9 100644 --- a/models/repo.go +++ b/models/repo.go @@ -175,6 +175,11 @@ func DeleteRepository(doer *user_model.User, uid, repoID int64) error { return fmt.Errorf("deleteBeans: %w", err) } + // Delete Milestone Labels + if err := issues_model.DeleteMilestoneLabelsByRepoID(ctx, repoID); err != nil { + return err + } + // Delete Labels and related objects if err := issues_model.DeleteLabelsByRepoID(ctx, repoID); err != nil { return err diff --git a/modules/structs/issue_milestone.go b/modules/structs/issue_milestone.go index a840cf1820c76..bc3ada3a27396 100644 --- a/modules/structs/issue_milestone.go +++ b/modules/structs/issue_milestone.go @@ -15,6 +15,7 @@ type Milestone struct { State StateType `json:"state"` OpenIssues int `json:"open_issues"` ClosedIssues int `json:"closed_issues"` + Labels []*Label `json:"labels"` // swagger:strfmt date-time Created time.Time `json:"created_at"` // swagger:strfmt date-time diff --git a/modules/structs/milestone_label.go b/modules/structs/milestone_label.go new file mode 100644 index 0000000000000..269909074065b --- /dev/null +++ b/modules/structs/milestone_label.go @@ -0,0 +1,10 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// MilestoneLabelsOption a collection of labels +type MilestoneLabelsOption struct { + // list of label IDs + Labels []int64 `json:"labels"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index ccae83a940b07..12947658969b1 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1265,6 +1265,13 @@ func Routes() *web.Route { m.Combo("/{id}").Get(repo.GetMilestone). Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone). Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone) + m.Group("/labels", func() { + m.Combo("").Get(repo.ListMilestoneLabels). + Post(reqToken(), bind(api.MilestoneLabelsOption{}), repo.AddMilestoneLabels). + Put(reqToken(), bind(api.MilestoneLabelsOption{}), repo.ReplaceMilestoneLabels). + Delete(reqToken(), repo.ClearMilestoneLabels) + m.Delete("/{labelId}", reqToken(), repo.DeleteMilestoneLabel) + }) }) }, repoAssignment()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue)) diff --git a/routers/api/v1/repo/milestone.go b/routers/api/v1/repo/milestone.go index b77fe8aca8a94..7cb35e0142a4a 100644 --- a/routers/api/v1/repo/milestone.go +++ b/routers/api/v1/repo/milestone.go @@ -147,6 +147,7 @@ func CreateMilestone(ctx *context.APIContext) { milestone := &issues_model.Milestone{ RepoID: ctx.Repo.Repository.ID, + Repo: ctx.Repo.Repository, Name: form.Title, Content: form.Description, DeadlineUnix: timeutil.TimeStamp(form.Deadline.Unix()), diff --git a/routers/api/v1/repo/milestone_label.go b/routers/api/v1/repo/milestone_label.go new file mode 100644 index 0000000000000..887b405101bc9 --- /dev/null +++ b/routers/api/v1/repo/milestone_label.go @@ -0,0 +1,303 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/convert" +) + +// ListMilestoneLabels list all the labels of a milestones +func ListMilestoneLabels(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/milestones/{id}/labels milestones milestonesGetLabels + // --- + // summary: Get a milestone's labels + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: milestone ID + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/LabelList" + // "404": + // "$ref": "#/responses/notFound" + + m := getMilestoneByIDOrName(ctx) + if err := m.LoadLabels(db.DefaultContext); err != nil { + return + } + if ctx.Written() { + return + } + + ctx.JSON(http.StatusOK, convert.ToLabelList(m.Labels, ctx.Repo.Repository, ctx.Repo.Owner)) +} + +// AddMilestoneLabels adds labels to a milestone +func AddMilestoneLabels(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/milestones/{id}/labels milestone milestoneAddLabel + // --- + // summary: Add a label to a milestone + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: milestone ID + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MilestoneLabelsOption" + // responses: + // "200": + // "$ref": "#/responses/LabelList" + // "403": + // "$ref": "#/responses/forbidden" + + form := web.GetForm(ctx).(*api.MilestoneLabelsOption) + m, selectLabels, err := prepareMilestoneForReplaceOrAdd(ctx, *form) + if err != nil { + return + } + + if err = m.AddLabels(ctx.ContextUser, selectLabels); err != nil { + ctx.Error(http.StatusInternalServerError, "AddLabels", err) + return + } + + selectLabels, err = issues_model.GetLabelsByMilestoneID(ctx, m.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelsByMilestoneID", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToLabelList(selectLabels, ctx.Repo.Repository, ctx.Repo.Owner)) +} + +// DeleteMilestoneLabel removes a label from a milestone +func DeleteMilestoneLabel(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/milestones/{id}/labels/{labelId} milestone milestoneRemoveLabel + // --- + // summary: Remove a label from a milestone + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: milestone ID + // type: integer + // format: int64 + // required: true + // - name: labelId + // in: path + // description: id of the label to remove + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + + m := getMilestoneByIDOrName(ctx) + if ctx.Written() { + return + } + + if !ctx.Repo.CanWriteIssuesOrPulls(true) { + ctx.Status(http.StatusForbidden) + return + } + + label, err := issues_model.GetLabelByID(ctx, ctx.ParamsInt64(":labelId")) + if err != nil { + if issues_model.IsErrLabelNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetLabelByID", err) + } + return + } + + if err := m.ReplaceLabels([]*issues_model.Label{label}, ctx.ContextUser); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteIssueLabel", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// ReplaceMilestoneLabels replaces labels on a milestone +func ReplaceMilestoneLabels(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/milestones/{id}/labels milestone milestoneReplaceLabels + // --- + // summary: Drop all previous milestone labels and replace them with new labels + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: milestone ID + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MilestoneLabelsOption" + // responses: + // "200": + // "$ref": "#/responses/LabelList" + // "403": + // "$ref": "#/responses/forbidden" + + form := web.GetForm(ctx).(*api.MilestoneLabelsOption) + m, selectLabels, err := prepareMilestoneForReplaceOrAdd(ctx, *form) + if err != nil { + return + } + + if err := m.ReplaceLabels(selectLabels, ctx.ContextUser); err != nil { + ctx.Error(http.StatusInternalServerError, "ReplaceLabels", err) + return + } + + selectLabels, err = issues_model.GetLabelsByMilestoneID(ctx, m.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelsByMilestoneID", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToLabelList(selectLabels, ctx.Repo.Repository, ctx.Repo.Owner)) +} + +// ClearMilestoneLabels removes all labels from a milestone +func ClearMilestoneLabels(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/milestones/{id}/labels milestone milestoneClearLabels + // --- + // summary: Remove all labels from an milestone + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: milestone ID + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + + m := getMilestoneByIDOrName(ctx) + if ctx.Written() { + return + } + + if !ctx.Repo.CanWriteIssuesOrPulls(true) { + ctx.Status(http.StatusForbidden) + return + } + + if err := m.ClearLabels(ctx.ContextUser); err != nil { + ctx.Error(http.StatusInternalServerError, "ClearLabels", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func prepareMilestoneForReplaceOrAdd(ctx *context.APIContext, form api.MilestoneLabelsOption) (milestone *issues_model.Milestone, labels []*issues_model.Label, err error) { + milestone = getMilestoneByIDOrName(ctx) + if milestone == nil { + ctx.NotFound() + return + } + + labels, err = issues_model.GetLabelsByIDs(form.Labels) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelsByIDs", err) + return + } + + if !ctx.Repo.CanWriteIssuesOrPulls(true) { + ctx.Status(http.StatusForbidden) + return + } + + return milestone, labels, err +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 073d9a19f7d30..9413f806f5d3c 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -67,6 +67,8 @@ type swaggerParameterBodies struct { CreateMilestoneOption api.CreateMilestoneOption // in:body EditMilestoneOption api.EditMilestoneOption + // in:body + MilestoneLabelsOption api.MilestoneLabelsOption // in:body CreateOrgOption api.CreateOrgOption diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 488c97b0eb6ea..70da5cea7d7c6 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1925,6 +1925,12 @@ func ViewIssue(ctx *context.Context) { ctx.Data["Participants"] = participants ctx.Data["NumParticipants"] = len(participants) + if issue.Milestone != nil { + if err = issue.Milestone.LoadLabels(db.DefaultContext); err != nil { + ctx.ServerError("issue.Milestone.LoadLabels", err) + return + } + } ctx.Data["Issue"] = issue ctx.Data["Reference"] = issue.Ref ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login?redirect_to=" + url.QueryEscape(ctx.Data["Link"].(string)) diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go index ad355ce5d7d92..c0a9cb5a7ec3a 100644 --- a/routers/web/repo/milestone.go +++ b/routers/web/repo/milestone.go @@ -6,11 +6,14 @@ package repo import ( "net/http" "net/url" + "strconv" + "strings" "time" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" @@ -50,6 +53,8 @@ func Milestones(ctx *context.Context) { state = structs.StateClosed } + selectLabels := ctx.FormString("labels") + miles, total, err := issues_model.GetMilestones(issues_model.GetMilestonesOption{ ListOptions: db.ListOptions{ Page: page, @@ -59,6 +64,7 @@ func Milestones(ctx *context.Context) { State: state, SortType: sortType, Name: keyword, + Labels: selectLabels, }) if err != nil { ctx.ServerError("GetMilestones", err) @@ -90,6 +96,9 @@ func Milestones(ctx *context.Context) { ctx.ServerError("RenderString", err) return } + if err = m.LoadLabels(db.DefaultContext); err != nil { + return + } } ctx.Data["Milestones"] = miles @@ -111,11 +120,69 @@ func Milestones(ctx *context.Context) { ctx.HTML(http.StatusOK, tplMilestone) } +// GetLabels returns labels, labelIDs, labelExclusiveScopes +func GetLabels(ctx *context.Context) (labels []*issues_model.Label, labelIDs []int64, selectLabels string) { + var labelExclusiveScopes []string + var err error + selectLabels = ctx.FormString("labels") + labels, err = issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByRepoID", err) + return + } + + if len(selectLabels) > 0 && selectLabels != "0" { + labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) + if err != nil { + ctx.ServerError("StringsToInt64s", err) + return + } + // Get the exclusive scope for every label ID + labelExclusiveScopes = make([]string, 0, len(labelIDs)) + for _, labelID := range labelIDs { + foundExclusiveScope := false + for _, label := range labels { + if label.ID == labelID || label.ID == -labelID { + labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope()) + foundExclusiveScope = true + break + } + } + if !foundExclusiveScope { + labelExclusiveScopes = append(labelExclusiveScopes, "") + } + } + } + + if ctx.Repo.Owner.IsOrganization() { + orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + + ctx.Data["OrgLabels"] = orgLabels + labels = append(labels, orgLabels...) + } + + for _, l := range labels { + l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes) + } + return labels, labelIDs, selectLabels +} + // NewMilestone render creating milestone page func NewMilestone(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.milestones.new") ctx.Data["PageIsIssueList"] = true ctx.Data["PageIsMilestones"] = true + + labels, labelIDs, selectLabels := GetLabels(ctx) + + ctx.Data["Labels"] = labels + ctx.Data["NumLabels"] = len(labels) + ctx.Data["SelectLabels"] = selectLabels + ctx.Data["HasSelectedLabel"] = len(labelIDs) > 0 ctx.HTML(http.StatusOK, tplMilestoneNew) } @@ -141,11 +208,43 @@ func NewMilestonePost(ctx *context.Context) { return } + labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository, true) + if ctx.Written() { + return + } + var labelIDs []int64 + var selectLabels []*issues_model.Label + hasSelected := false + // Check labels. + if len(form.LabelIDs) > 0 { + labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) + if err != nil { + return + } + labelIDMark := make(container.Set[int64], len(labelIDs)) + for _, labelID := range labelIDs { + labelIDMark.Add(labelID) + } + + for i := range labels { + if labelIDMark.Contains(labels[i].ID) { + labels[i].IsChecked = true + hasSelected = true + selectLabels = append(selectLabels, labels[i]) + } + } + } + + ctx.Data["Labels"] = labels + ctx.Data["HasSelectedLabel"] = hasSelected + ctx.Data["label_ids"] = form.LabelIDs + deadline = time.Date(deadline.Year(), deadline.Month(), deadline.Day(), 23, 59, 59, 0, deadline.Location()) if err = issues_model.NewMilestone(&issues_model.Milestone{ RepoID: ctx.Repo.Repository.ID, Name: form.Title, Content: form.Content, + Labels: selectLabels, DeadlineUnix: timeutil.TimeStamp(deadline.Unix()), }); err != nil { ctx.ServerError("NewMilestone", err) @@ -173,6 +272,34 @@ func EditMilestone(ctx *context.Context) { } ctx.Data["title"] = m.Name ctx.Data["content"] = m.Content + + labels, labelIDs, selectLabels := GetLabels(ctx) + hasSelected := len(labelIDs) > 0 + + labelIDsString := "" + if err = m.LoadLabels(db.DefaultContext); err != nil { + return + } + for index, selectL := range m.Labels { + if index > 0 { + labelIDsString += "," + } + labelIDsString += strconv.FormatInt(selectL.ID, 10) + + for _, l := range labels { + if l.ID == selectL.ID { + l.IsChecked = true + hasSelected = true + } + } + } + + ctx.Data["Labels"] = labels + ctx.Data["NumLabels"] = len(labels) + ctx.Data["SelectLabels"] = selectLabels + ctx.Data["HasSelectedLabel"] = hasSelected + ctx.Data["label_ids"] = labelIDsString + if len(m.DeadlineString) > 0 { ctx.Data["deadline"] = m.DeadlineString } @@ -191,6 +318,39 @@ func EditMilestonePost(ctx *context.Context) { return } + labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository, true) + if ctx.Written() { + return + } + var labelIDs []int64 + var selectLabels []*issues_model.Label + var err error + hasSelected := false + // Check labels. + if len(form.LabelIDs) > 0 { + labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) + if err != nil { + return + } + labelIDMark := make(container.Set[int64], len(labelIDs)) + for _, labelID := range labelIDs { + labelIDMark.Add(labelID) + } + + for i := range labels { + if labelIDMark.Contains(labels[i].ID) { + labels[i].IsChecked = true + hasSelected = true + selectLabels = append(selectLabels, labels[i]) + } + } + } + + ctx.Data["Labels"] = labels + ctx.Data["HasSelectedLabel"] = hasSelected + ctx.Data["SelectLabels"] = selectLabels + ctx.Data["label_ids"] = form.LabelIDs + if len(form.Deadline) == 0 { form.Deadline = "9999-12-31" } @@ -211,6 +371,7 @@ func EditMilestonePost(ctx *context.Context) { } return } + m.Labels = selectLabels m.Name = form.Title m.Content = form.Content m.DeadlineUnix = timeutil.TimeStamp(deadline.Unix()) @@ -283,6 +444,10 @@ func MilestoneIssuesAndPulls(ctx *context.Context) { ctx.ServerError("RenderString", err) return } + if err = milestone.LoadLabels(ctx); err != nil { + ctx.ServerError("RenderString", err) + return + } ctx.Data["Title"] = milestone.Name ctx.Data["Milestone"] = milestone diff --git a/services/convert/issue.go b/services/convert/issue.go index 33fad31d48ae1..09baecfe155eb 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -240,6 +240,16 @@ func ToLabelList(labels []*issues_model.Label, repo *repo_model.Repository, org // ToAPIMilestone converts Milestone into API Format func ToAPIMilestone(m *issues_model.Milestone) *api.Milestone { + var repo *repo_model.Repository + var user *user_model.User + if m.Repo != nil { + repo = m.Repo + user = m.Repo.Owner + } + if err := m.LoadLabels(db.DefaultContext); err != nil { + log.Error("ToAPIMilestone cannot LoadLabels for milestone with id '%d': %v", m.ID, err) + return nil + } apiMilestone := &api.Milestone{ ID: m.ID, State: m.State(), @@ -247,6 +257,7 @@ func ToAPIMilestone(m *issues_model.Milestone) *api.Milestone { Description: m.Content, OpenIssues: m.NumOpenIssues, ClosedIssues: m.NumClosedIssues, + Labels: ToLabelList(m.Labels, repo, user), Created: m.CreatedUnix.AsTime(), Updated: m.UpdatedUnix.AsTimePtr(), } diff --git a/services/convert/issue_test.go b/services/convert/issue_test.go index 4d780f3f00905..b071f11d55906 100644 --- a/services/convert/issue_test.go +++ b/services/convert/issue_test.go @@ -39,6 +39,7 @@ func TestMilestone_APIFormat(t *testing.T) { IsClosed: false, NumOpenIssues: 5, NumClosedIssues: 6, + Labels: make([]*issues_model.Label, 0, 1), CreatedUnix: timeutil.TimeStamp(time.Date(1999, time.January, 1, 0, 0, 0, 0, time.UTC).Unix()), UpdatedUnix: timeutil.TimeStamp(time.Date(1999, time.March, 1, 0, 0, 0, 0, time.UTC).Unix()), DeadlineUnix: timeutil.TimeStamp(time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC).Unix()), @@ -50,6 +51,7 @@ func TestMilestone_APIFormat(t *testing.T) { Description: milestone.Content, OpenIssues: milestone.NumOpenIssues, ClosedIssues: milestone.NumClosedIssues, + Labels: make([]*api.Label, 0, 1), Created: milestone.CreatedUnix.AsTime(), Updated: milestone.UpdatedUnix.AsTimePtr(), Deadline: milestone.DeadlineUnix.AsTimePtr(), diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index b36c8cc9b6613..6e4be4a8f1f7b 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -548,6 +548,7 @@ type EditProjectBoardForm struct { type CreateMilestoneForm struct { Title string `binding:"Required;MaxSize(50)"` Content string + LabelIDs string `form:"label_ids"` Deadline string } diff --git a/templates/repo/issue/labels/label.tmpl b/templates/repo/issue/labels/label.tmpl index 01016281ad3ce..9a50a923814b9 100644 --- a/templates/repo/issue/labels/label.tmpl +++ b/templates/repo/issue/labels/label.tmpl @@ -1,7 +1,11 @@ {{RenderLabel $.Context .label}} diff --git a/templates/repo/issue/milestone_issues.tmpl b/templates/repo/issue/milestone_issues.tmpl index dab6ef3de6529..9ee406aa3843c 100644 --- a/templates/repo/issue/milestone_issues.tmpl +++ b/templates/repo/issue/milestone_issues.tmpl @@ -46,6 +46,14 @@ {{end}}
{{.locale.Tr "repo.milestones.completeness" .Milestone.Completeness | Safe}}
+    + {{if .Milestone.Labels}} + + {{range .Milestone.Labels}} + {{RenderLabel $.Context .}} + {{end}} + + {{end}}
diff --git a/templates/repo/issue/milestone_new.tmpl b/templates/repo/issue/milestone_new.tmpl index 56450108509fc..729628a634a9e 100644 --- a/templates/repo/issue/milestone_new.tmpl +++ b/templates/repo/issue/milestone_new.tmpl @@ -21,7 +21,7 @@ {{end}} {{template "base/alert" .}} -
+ {{.CsrfTokenHtml}}
@@ -38,6 +38,40 @@
+
+ + + {{template "repo/issue/labels/labels_sidebar" dict "root" $ "ctx" .}} +
{{if .PageIsEditMilestone}} diff --git a/templates/repo/issue/milestones.tmpl b/templates/repo/issue/milestones.tmpl index b887a555aad9c..488e3df2a80bd 100644 --- a/templates/repo/issue/milestones.tmpl +++ b/templates/repo/issue/milestones.tmpl @@ -60,6 +60,13 @@

{{svg "octicon-milestone" 16}} {{.Name}} + {{if .Labels}} + + {{range .Labels}} + {{RenderLabel $.Context .}} + {{end}} + + {{end}}

{{.Completeness}}% diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index da43c530af1f5..40219fa8ed6f3 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -9368,6 +9368,251 @@ } } }, + "/repos/{owner}/{repo}/milestones/{id}/labels": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "milestones" + ], + "summary": "Get a milestone's labels", + "operationId": "milestonesGetLabels", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "milestone ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/LabelList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "milestone" + ], + "summary": "Drop all previous milestone labels and replace them with new labels", + "operationId": "milestoneReplaceLabels", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "milestone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MilestoneLabelsOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/LabelList" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "milestone" + ], + "summary": "Add a label to a milestone", + "operationId": "milestoneAddLabel", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "milestone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MilestoneLabelsOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/LabelList" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "milestone" + ], + "summary": "Remove all labels from an milestone", + "operationId": "milestoneClearLabels", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "milestone ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + } + }, + "/repos/{owner}/{repo}/milestones/{id}/labels/{labelId}": { + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "milestone" + ], + "summary": "Remove a label from a milestone", + "operationId": "milestoneRemoveLabel", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "milestone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the label to remove", + "name": "labelId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/repos/{owner}/{repo}/mirror-sync": { "post": { "produces": [ @@ -19847,6 +20092,13 @@ "format": "int64", "x-go-name": "ID" }, + "labels": { + "type": "array", + "items": { + "$ref": "#/definitions/Label" + }, + "x-go-name": "Labels" + }, "open_issues": { "type": "integer", "format": "int64", @@ -19867,6 +20119,22 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "MilestoneLabelsOption": { + "description": "MilestoneLabelsOption a collection of labels", + "type": "object", + "properties": { + "labels": { + "description": "list of label IDs", + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "Labels" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "NewIssuePinsAllowed": { "description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed", "type": "object", diff --git a/web_src/css/base.css b/web_src/css/base.css index bdc1234bc9a8d..1e9a3a443ecc1 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -2306,3 +2306,11 @@ table th[data-sortt-desc] .svg { flex-wrap: wrap; gap: .25rem; } + +.milestone-label-color { + width: 16px; + height: 16px; + display: inline-block; + border-radius: var(--border-radius); + margin: 0 10px; +}