Allow to archive labels (#26478)

## Archived labels 

This adds the structure to allow for archived labels.
Archived labels are, just like closed milestones or projects, a medium to hide information without deleting it.
It is especially useful if there are outdated labels that should no longer be used without deleting the label entirely.

## Changes

1. UI and API have been equipped with the support to mark a label as archived
2. The time when a label has been archived will be stored in the DB

## Outsourced for the future

There's no special handling for archived labels at the moment.
This will be done in the future.

## Screenshots

![image](https://github.com/go-gitea/gitea/assets/80308335/208f95cd-42e4-4ed7-9a1f-cd2050a645d4)

![image](https://github.com/go-gitea/gitea/assets/80308335/746428e0-40bb-45b3-b992-85602feb371d)

Part of https://github.com/go-gitea/gitea/issues/25237

---------

Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
pull/26483/head^2
puni9869 1 year ago committed by GitHub
parent db7b0a1a4e
commit cafce3b4b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      models/fixtures/label.yml
  2. 18
      models/issues/label.go
  3. 13
      models/issues/label_test.go
  4. 2
      models/migrations/migrations.go
  5. 16
      models/migrations/v1_21/v271.go
  6. 6
      modules/structs/issue_label.go
  7. 2
      options/locale/locale_en-US.ini
  8. 1
      routers/api/v1/org/label.go
  9. 3
      routers/api/v1/repo/label.go
  10. 1
      routers/web/org/org_labels.go
  11. 15
      routers/web/repo/issue_label.go
  12. 7
      routers/web/repo/issue_label_test.go
  13. 1
      services/convert/issue.go
  14. 1
      services/forms/repo_form.go
  15. 10
      templates/repo/issue/labels/edit_delete_label.tmpl
  16. 4
      templates/repo/issue/labels/label_list.tmpl
  17. 15
      templates/swagger/v1_json.tmpl
  18. 7
      web_src/js/features/comp/LabelEdit.js

@ -7,6 +7,7 @@
exclusive: false exclusive: false
num_issues: 2 num_issues: 2
num_closed_issues: 0 num_closed_issues: 0
archived_unix: 0
- -
id: 2 id: 2
@ -17,6 +18,7 @@
exclusive: false exclusive: false
num_issues: 1 num_issues: 1
num_closed_issues: 1 num_closed_issues: 1
archived_unix: 0
- -
id: 3 id: 3
@ -27,6 +29,7 @@
exclusive: false exclusive: false
num_issues: 0 num_issues: 0
num_closed_issues: 0 num_closed_issues: 0
archived_unix: 0
- -
id: 4 id: 4
@ -37,6 +40,7 @@
exclusive: false exclusive: false
num_issues: 1 num_issues: 1
num_closed_issues: 0 num_closed_issues: 0
archived_unix: 0
- -
id: 5 id: 5
@ -47,6 +51,7 @@
exclusive: false exclusive: false
num_issues: 0 num_issues: 0
num_closed_issues: 0 num_closed_issues: 0
archived_unix: 0
- -
id: 6 id: 6
@ -57,6 +62,7 @@
exclusive: false exclusive: false
num_issues: 0 num_issues: 0
num_closed_issues: 0 num_closed_issues: 0
archived_unix: 0
- -
id: 7 id: 7
@ -67,6 +73,7 @@
exclusive: true exclusive: true
num_issues: 0 num_issues: 0
num_closed_issues: 0 num_closed_issues: 0
archived_unix: 0
- -
id: 8 id: 8
@ -77,6 +84,7 @@
exclusive: true exclusive: true
num_issues: 0 num_issues: 0
num_closed_issues: 0 num_closed_issues: 0
archived_unix: 0
- -
id: 9 id: 9
@ -87,3 +95,4 @@
exclusive: true exclusive: true
num_issues: 0 num_issues: 0
num_closed_issues: 0 num_closed_issues: 0
archived_unix: 0

@ -97,6 +97,8 @@ type Label struct {
QueryString string `xorm:"-"` QueryString string `xorm:"-"`
IsSelected bool `xorm:"-"` IsSelected bool `xorm:"-"`
IsExcluded bool `xorm:"-"` IsExcluded bool `xorm:"-"`
ArchivedUnix timeutil.TimeStamp `xorm:"DEFAULT NULL"`
} }
func init() { func init() {
@ -109,6 +111,15 @@ func (l *Label) CalOpenIssues() {
l.NumOpenIssues = l.NumIssues - l.NumClosedIssues l.NumOpenIssues = l.NumIssues - l.NumClosedIssues
} }
// SetArchived set the label as archived
func (l *Label) SetArchived(isArchived bool) {
if isArchived && l.ArchivedUnix.IsZero() {
l.ArchivedUnix = timeutil.TimeStampNow()
} else {
l.ArchivedUnix = timeutil.TimeStamp(0)
}
}
// CalOpenOrgIssues calculates the open issues of a label for a specific repo // CalOpenOrgIssues calculates the open issues of a label for a specific repo
func (l *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) { func (l *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) {
counts, _ := CountIssuesByRepo(ctx, &IssuesOptions{ counts, _ := CountIssuesByRepo(ctx, &IssuesOptions{
@ -153,6 +164,11 @@ func (l *Label) BelongsToOrg() bool {
return l.OrgID > 0 return l.OrgID > 0
} }
// IsArchived returns true if label is an archived
func (l *Label) IsArchived() bool {
return l.ArchivedUnix > 0
}
// BelongsToRepo returns true if label is a repository label // BelongsToRepo returns true if label is a repository label
func (l *Label) BelongsToRepo() bool { func (l *Label) BelongsToRepo() bool {
return l.RepoID > 0 return l.RepoID > 0
@ -211,7 +227,7 @@ func UpdateLabel(l *Label) error {
} }
l.Color = color l.Color = color
return updateLabelCols(db.DefaultContext, l, "name", "description", "color", "exclusive") return updateLabelCols(db.DefaultContext, l, "name", "description", "color", "exclusive", "archived_unix")
} }
// DeleteLabel delete a label // DeleteLabel delete a label

@ -11,6 +11,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/timeutil"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -259,11 +260,12 @@ func TestUpdateLabel(t *testing.T) {
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}) label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
// make sure update wont overwrite it // make sure update wont overwrite it
update := &issues_model.Label{ update := &issues_model.Label{
ID: label.ID, ID: label.ID,
Color: "#ffff00", Color: "#ffff00",
Name: "newLabelName", Name: "newLabelName",
Description: label.Description, Description: label.Description,
Exclusive: false, Exclusive: false,
ArchivedUnix: timeutil.TimeStamp(0),
} }
label.Color = update.Color label.Color = update.Color
label.Name = update.Name label.Name = update.Name
@ -273,6 +275,7 @@ func TestUpdateLabel(t *testing.T) {
assert.EqualValues(t, label.Color, newLabel.Color) assert.EqualValues(t, label.Color, newLabel.Color)
assert.EqualValues(t, label.Name, newLabel.Name) assert.EqualValues(t, label.Name, newLabel.Name)
assert.EqualValues(t, label.Description, newLabel.Description) assert.EqualValues(t, label.Description, newLabel.Description)
assert.EqualValues(t, newLabel.ArchivedUnix, 0)
unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{}) unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{})
} }

@ -522,6 +522,8 @@ var migrations = []Migration{
NewMigration("Drop deleted branch table", v1_21.DropDeletedBranchTable), NewMigration("Drop deleted branch table", v1_21.DropDeletedBranchTable),
// v270 -> v271 // v270 -> v271
NewMigration("Fix PackageProperty typo", v1_21.FixPackagePropertyTypo), NewMigration("Fix PackageProperty typo", v1_21.FixPackagePropertyTypo),
// v271 -> v272
NewMigration("Allow archiving labels", v1_21.AddArchivedUnixColumInLabelTable),
} }
// GetCurrentDBVersion returns the current db version // GetCurrentDBVersion returns the current db version

@ -0,0 +1,16 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_21 //nolint
import (
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/xorm"
)
func AddArchivedUnixColumInLabelTable(x *xorm.Engine) error {
type Label struct {
ArchivedUnix timeutil.TimeStamp `xorm:"DEFAULT NULL"`
}
return x.Sync(new(Label))
}

@ -11,6 +11,8 @@ type Label struct {
Name string `json:"name"` Name string `json:"name"`
// example: false // example: false
Exclusive bool `json:"exclusive"` Exclusive bool `json:"exclusive"`
// example: false
IsArchived bool `json:"is_archived"`
// example: 00aabb // example: 00aabb
Color string `json:"color"` Color string `json:"color"`
Description string `json:"description"` Description string `json:"description"`
@ -27,6 +29,8 @@ type CreateLabelOption struct {
// example: #00aabb // example: #00aabb
Color string `json:"color" binding:"Required"` Color string `json:"color" binding:"Required"`
Description string `json:"description"` Description string `json:"description"`
// example: false
IsArchived bool `json:"is_archived"`
} }
// EditLabelOption options for editing a label // EditLabelOption options for editing a label
@ -37,6 +41,8 @@ type EditLabelOption struct {
// example: #00aabb // example: #00aabb
Color *string `json:"color"` Color *string `json:"color"`
Description *string `json:"description"` Description *string `json:"description"`
// example: false
IsArchived *bool `json:"is_archived"`
} }
// IssueLabelsOption a collection of labels // IssueLabelsOption a collection of labels

@ -1491,6 +1491,8 @@ issues.label_title = Name
issues.label_description = Description issues.label_description = Description
issues.label_color = Color issues.label_color = Color
issues.label_exclusive = Exclusive issues.label_exclusive = Exclusive
issues.label_archive = Archive Label
issues.label_archive_tooltip= Archived labels are excluded from the label search when applying labels to an issue. Existing labels on issues remain unaffected, allowing you to retire obsolete labels without losing information.
issues.label_exclusive_desc = Name the label <code>scope/item</code> to make it mutually exclusive with other <code>scope/</code> labels. issues.label_exclusive_desc = Name the label <code>scope/item</code> to make it mutually exclusive with other <code>scope/</code> labels.
issues.label_exclusive_warning = Any conflicting scoped labels will be removed when editing the labels of an issue or pull request. issues.label_exclusive_warning = Any conflicting scoped labels will be removed when editing the labels of an issue or pull request.
issues.label_count = %d labels issues.label_count = %d labels

@ -209,6 +209,7 @@ func EditLabel(ctx *context.APIContext) {
if form.Description != nil { if form.Description != nil {
l.Description = *form.Description l.Description = *form.Description
} }
l.SetArchived(form.IsArchived != nil && *form.IsArchived)
if err := issues_model.UpdateLabel(l); err != nil { if err := issues_model.UpdateLabel(l); err != nil {
ctx.Error(http.StatusInternalServerError, "UpdateLabel", err) ctx.Error(http.StatusInternalServerError, "UpdateLabel", err)
return return

@ -151,7 +151,6 @@ func CreateLabel(ctx *context.APIContext) {
return return
} }
form.Color = color form.Color = color
l := &issues_model.Label{ l := &issues_model.Label{
Name: form.Name, Name: form.Name,
Exclusive: form.Exclusive, Exclusive: form.Exclusive,
@ -159,6 +158,7 @@ func CreateLabel(ctx *context.APIContext) {
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
Description: form.Description, Description: form.Description,
} }
l.SetArchived(form.IsArchived)
if err := issues_model.NewLabel(ctx, l); err != nil { if err := issues_model.NewLabel(ctx, l); err != nil {
ctx.Error(http.StatusInternalServerError, "NewLabel", err) ctx.Error(http.StatusInternalServerError, "NewLabel", err)
return return
@ -231,6 +231,7 @@ func EditLabel(ctx *context.APIContext) {
if form.Description != nil { if form.Description != nil {
l.Description = *form.Description l.Description = *form.Description
} }
l.SetArchived(form.IsArchived != nil && *form.IsArchived)
if err := issues_model.UpdateLabel(l); err != nil { if err := issues_model.UpdateLabel(l); err != nil {
ctx.Error(http.StatusInternalServerError, "UpdateLabel", err) ctx.Error(http.StatusInternalServerError, "UpdateLabel", err)
return return

@ -75,6 +75,7 @@ func UpdateLabel(ctx *context.Context) {
l.Exclusive = form.Exclusive l.Exclusive = form.Exclusive
l.Description = form.Description l.Description = form.Description
l.Color = form.Color l.Color = form.Color
l.SetArchived(form.IsArchived)
if err := issues_model.UpdateLabel(l); err != nil { if err := issues_model.UpdateLabel(l); err != nil {
ctx.ServerError("UpdateLabel", err) ctx.ServerError("UpdateLabel", err)
return return

@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/modules/label" "code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
issue_service "code.gitea.io/gitea/services/issue" issue_service "code.gitea.io/gitea/services/issue"
@ -111,11 +112,12 @@ func NewLabel(ctx *context.Context) {
} }
l := &issues_model.Label{ l := &issues_model.Label{
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
Name: form.Title, Name: form.Title,
Exclusive: form.Exclusive, Exclusive: form.Exclusive,
Description: form.Description, Description: form.Description,
Color: form.Color, Color: form.Color,
ArchivedUnix: timeutil.TimeStamp(0),
} }
if err := issues_model.NewLabel(ctx, l); err != nil { if err := issues_model.NewLabel(ctx, l); err != nil {
ctx.ServerError("NewLabel", err) ctx.ServerError("NewLabel", err)
@ -137,11 +139,12 @@ func UpdateLabel(ctx *context.Context) {
} }
return return
} }
l.Name = form.Title l.Name = form.Title
l.Exclusive = form.Exclusive l.Exclusive = form.Exclusive
l.Description = form.Description l.Description = form.Description
l.Color = form.Color l.Color = form.Color
l.SetArchived(form.IsArchived)
if err := issues_model.UpdateLabel(l); err != nil { if err := issues_model.UpdateLabel(l); err != nil {
ctx.ServerError("UpdateLabel", err) ctx.ServerError("UpdateLabel", err)
return return

@ -97,9 +97,10 @@ func TestUpdateLabel(t *testing.T) {
test.LoadUser(t, ctx, 2) test.LoadUser(t, ctx, 2)
test.LoadRepo(t, ctx, 1) test.LoadRepo(t, ctx, 1)
web.SetForm(ctx, &forms.CreateLabelForm{ web.SetForm(ctx, &forms.CreateLabelForm{
ID: 2, ID: 2,
Title: "newnameforlabel", Title: "newnameforlabel",
Color: "#abcdef", Color: "#abcdef",
IsArchived: true,
}) })
UpdateLabel(ctx) UpdateLabel(ctx)
assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())

@ -208,6 +208,7 @@ func ToLabel(label *issues_model.Label, repo *repo_model.Repository, org *user_m
Exclusive: label.Exclusive, Exclusive: label.Exclusive,
Color: strings.TrimLeft(label.Color, "#"), Color: strings.TrimLeft(label.Color, "#"),
Description: label.Description, Description: label.Description,
IsArchived: label.IsArchived(),
} }
// calculate URL // calculate URL

@ -569,6 +569,7 @@ type CreateLabelForm struct {
ID int64 ID int64
Title string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"` Title string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"`
Exclusive bool `form:"exclusive"` Exclusive bool `form:"exclusive"`
IsArchived bool `form:"is_archived"`
Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"` Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"`
Color string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"` Color string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"`
} }

@ -33,6 +33,16 @@
<div class="desc gt-ml-2 gt-mt-3 gt-hidden label-exclusive-warning"> <div class="desc gt-ml-2 gt-mt-3 gt-hidden label-exclusive-warning">
{{svg "octicon-alert"}} {{.locale.Tr "repo.issues.label_exclusive_warning" | Safe}} {{svg "octicon-alert"}} {{.locale.Tr "repo.issues.label_exclusive_warning" | Safe}}
</div> </div>
<br>
</div>
<div class="field label-is-archived-input-field">
<div class="ui checkbox">
<input class="label-is-archived-input" name="is_archived" type="checkbox">
<label>{{.locale.Tr "repo.issues.label_archive"}}</label>
</div>
<i class="gt-ml-2" data-tooltip-content={{.locale.Tr "repo.issues.label_archive_tooltip"}}>
{{svg "octicon-info"}}
</i>
</div> </div>
<div class="field"> <div class="field">
<label for="description">{{.locale.Tr "repo.issues.label_description"}}</label> <label for="description">{{.locale.Tr "repo.issues.label_description"}}</label>

@ -44,10 +44,10 @@
</div> </div>
<div class="label-operation"> <div class="label-operation">
{{if and (not $.PageIsOrgSettingsLabels) (not $.Repository.IsArchived) (or $.CanWriteIssues $.CanWritePulls)}} {{if and (not $.PageIsOrgSettingsLabels) (not $.Repository.IsArchived) (or $.CanWriteIssues $.CanWritePulls)}}
<a class="edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" {{if .Exclusive}}data-exclusive{{end}} data-num-issues="{{.NumIssues}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a> <a class="edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" {{if .Exclusive}}data-exclusive{{end}} {{if gt .ArchivedUnix 0}}data-is-archived{{end}} data-num-issues="{{.NumIssues}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a>
<a class="delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a> <a class="delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a>
{{else if $.PageIsOrgSettingsLabels}} {{else if $.PageIsOrgSettingsLabels}}
<a class="edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" {{if .Exclusive}}data-exclusive{{end}} data-num-issues="{{.NumIssues}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a> <a class="edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" {{if .Exclusive}}data-exclusive{{end}} {{if gt .ArchivedUnix 0}}data-is-archived{{end}} data-num-issues="{{.NumIssues}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a>
<a class="delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a> <a class="delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a>
{{end}} {{end}}
</div> </div>

@ -17057,6 +17057,11 @@
"x-go-name": "Exclusive", "x-go-name": "Exclusive",
"example": false "example": false
}, },
"is_archived": {
"type": "boolean",
"x-go-name": "IsArchived",
"example": false
},
"name": { "name": {
"type": "string", "type": "string",
"x-go-name": "Name" "x-go-name": "Name"
@ -18001,6 +18006,11 @@
"x-go-name": "Exclusive", "x-go-name": "Exclusive",
"example": false "example": false
}, },
"is_archived": {
"type": "boolean",
"x-go-name": "IsArchived",
"example": false
},
"name": { "name": {
"type": "string", "type": "string",
"x-go-name": "Name" "x-go-name": "Name"
@ -19479,6 +19489,11 @@
"format": "int64", "format": "int64",
"x-go-name": "ID" "x-go-name": "ID"
}, },
"is_archived": {
"type": "boolean",
"x-go-name": "IsArchived",
"example": false
},
"name": { "name": {
"type": "string", "type": "string",
"x-go-name": "Name" "x-go-name": "Name"

@ -36,7 +36,7 @@ export function initCompLabelEdit(selector) {
$('.new-label.modal').modal({ $('.new-label.modal').modal({
onApprove() { onApprove() {
$('.new-label.form').trigger('submit'); $('.new-label.form').trigger('submit');
} },
}).modal('show'); }).modal('show');
return false; return false;
}); });
@ -49,6 +49,9 @@ export function initCompLabelEdit(selector) {
const nameInput = $('.edit-label .label-name-input'); const nameInput = $('.edit-label .label-name-input');
nameInput.val($(this).data('title')); nameInput.val($(this).data('title'));
const isArchivedCheckbox = $('.edit-label .label-is-archived-input');
isArchivedCheckbox.prop('checked', this.hasAttribute('data-is-archived'));
const exclusiveCheckbox = $('.edit-label .label-exclusive-input'); const exclusiveCheckbox = $('.edit-label .label-exclusive-input');
exclusiveCheckbox.prop('checked', this.hasAttribute('data-exclusive')); exclusiveCheckbox.prop('checked', this.hasAttribute('data-exclusive'));
// Warn when label was previously not exclusive and used in issues // Warn when label was previously not exclusive and used in issues
@ -64,7 +67,7 @@ export function initCompLabelEdit(selector) {
$('.edit-label.modal').modal({ $('.edit-label.modal').modal({
onApprove() { onApprove() {
$('.edit-label.form').trigger('submit'); $('.edit-label.form').trigger('submit');
} },
}).modal('show'); }).modal('show');
return false; return false;
}); });

Loading…
Cancel
Save