diff --git a/models/fixtures/issue.yml b/models/fixtures/issue.yml index ca5b1c6cd1df5..9764efe6c8278 100644 --- a/models/fixtures/issue.yml +++ b/models/fixtures/issue.yml @@ -14,6 +14,7 @@ created_unix: 946684800 updated_unix: 978307200 is_locked: false + weight: 10 - id: 2 @@ -31,6 +32,7 @@ created_unix: 946684810 updated_unix: 978307190 is_locked: false + weight: 12 - id: 3 @@ -48,6 +50,7 @@ created_unix: 946684820 updated_unix: 978307180 is_locked: false + weight: 5 - id: 4 @@ -65,6 +68,7 @@ created_unix: 946684830 updated_unix: 978307200 is_locked: false + weight: 10 - id: 5 @@ -82,6 +86,7 @@ created_unix: 946684840 updated_unix: 978307200 is_locked: false + weight: 20 - id: 6 @@ -99,6 +104,7 @@ created_unix: 946684850 updated_unix: 978307200 is_locked: false + weight: 0 - id: 7 @@ -116,6 +122,7 @@ created_unix: 946684830 updated_unix: 978307200 is_locked: false + weight: 10 - id: 8 @@ -133,6 +140,7 @@ created_unix: 946684820 updated_unix: 978307180 is_locked: false + weight: 0 - id: 9 @@ -150,6 +158,7 @@ created_unix: 946684820 updated_unix: 978307180 is_locked: false + weight: 10 - id: 10 @@ -168,6 +177,7 @@ created_unix: 946684830 updated_unix: 999307200 is_locked: false + weight: 4 - id: 11 @@ -185,6 +195,7 @@ created_unix: 1579194806 updated_unix: 1579194806 is_locked: false + weight: 0 - id: 12 @@ -202,6 +213,7 @@ created_unix: 1602935696 updated_unix: 1602935696 is_locked: false + weight: 22 - id: 13 @@ -219,6 +231,7 @@ created_unix: 1602935696 updated_unix: 1602935696 is_locked: false + weight: 13 - id: 14 @@ -236,6 +249,7 @@ created_unix: 1602935696 updated_unix: 1602935696 is_locked: false + weight: 8 - id: 15 @@ -253,6 +267,7 @@ created_unix: 1602935696 updated_unix: 1602935696 is_locked: false + weight: 52 - id: 16 @@ -270,6 +285,7 @@ created_unix: 1602935696 updated_unix: 1602935696 is_locked: false + weight: 10 - id: 17 @@ -287,6 +303,7 @@ created_unix: 1602935696 updated_unix: 1602935696 is_locked: false + weight: 0 - id: 18 @@ -304,6 +321,7 @@ created_unix: 946684830 updated_unix: 978307200 is_locked: false + weight: 20 - id: 19 @@ -321,6 +339,7 @@ created_unix: 946684830 updated_unix: 978307200 is_locked: false + weight: 10 - id: 20 @@ -338,6 +357,7 @@ created_unix: 978307210 updated_unix: 978307210 is_locked: false + weight: 24 - id: 21 @@ -355,6 +375,7 @@ created_unix: 1707270422 updated_unix: 1707270422 is_locked: false + weight: 40 - id: 22 @@ -372,3 +393,4 @@ created_unix: 1707270422 updated_unix: 1707270422 is_locked: false + weight: 2 diff --git a/models/issues/comment.go b/models/issues/comment.go index 48b8e335d48ef..6fb4cd3f9c185 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -114,6 +114,10 @@ const ( CommentTypePin // 36 pin Issue CommentTypeUnpin // 37 unpin Issue + + CommentTypeAddedWeight // 38 Added a weight + CommentTypeModifiedWeight // 39 Modified the weight + CommentTypeRemovedWeight // 40 Removed a weight ) var commentStrings = []string{ @@ -960,6 +964,40 @@ func createDeadlineComment(ctx context.Context, doer *user_model.User, issue *Is return comment, nil } +func CreateWeightComment(ctx context.Context, doer *user_model.User, issue *Issue, newWeight int) (*Comment, error) { + var content string + var commentType CommentType + + // weight = 0 means deleting + if newWeight == 0 { + commentType = CommentTypeRemovedWeight + content = fmt.Sprintf("%d", issue.Weight) + } else if issue.Weight == 0 || issue.Weight == newWeight { + commentType = CommentTypeAddedWeight + content = fmt.Sprintf("%d", newWeight) + } else { // Otherwise modified + commentType = CommentTypeModifiedWeight + content = fmt.Sprintf("%d|%d", newWeight, issue.Weight) + } + + if err := issue.LoadRepo(ctx); err != nil { + return nil, err + } + + opts := &CreateCommentOptions{ + Type: commentType, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + Content: content, + } + comment, err := CreateComment(ctx, opts) + if err != nil { + return nil, err + } + return comment, nil +} + // Creates issue dependency comment func createIssueDependencyComment(ctx context.Context, doer *user_model.User, issue, dependentIssue *Issue, add bool) (err error) { cType := CommentTypeAddDependency diff --git a/models/issues/issue.go b/models/issues/issue.go index 40462ed09dfd6..f5d2105a74221 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -127,6 +127,7 @@ type Issue struct { NumComments int Ref string PinOrder int `xorm:"DEFAULT 0"` + Weight int DeadlineUnix timeutil.TimeStamp `xorm:"INDEX"` diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 1bbc0eee564fd..111c93bcea225 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -375,6 +375,26 @@ func TestLoadTotalTrackedTime(t *testing.T) { assert.Equal(t, int64(3682), milestone.TotalTrackedTime) } +func TestMilestoneList_LoadTotalWeight(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + miles := issues_model.MilestoneList{ + unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1}), + } + + assert.NoError(t, miles.LoadTotalWeight(db.DefaultContext)) + + assert.Equal(t, 12, miles[0].TotalWeight) +} + +func TestLoadTotalWeight(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + milestone := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1}) + + assert.NoError(t, milestone.LoadTotalWeight(db.DefaultContext)) + + assert.Equal(t, 12, milestone.TotalWeight) +} + func TestCountIssues(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) count, err := issues_model.CountIssues(db.DefaultContext, &issues_model.IssuesOptions{}) diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 31d76be5e0aea..3ed6ea0cac36a 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -459,6 +459,31 @@ func UpdateIssueDeadline(ctx context.Context, issue *Issue, deadlineUnix timeuti return committer.Commit() } +// UpdateIssueDeadline updates an issue deadline and adds comments. Setting a deadline to 0 means deleting it. +func UpdateIssueWeight(ctx context.Context, issue *Issue, weight int, doer *user_model.User) (err error) { + // if the weight hasn't changed do nothing + if issue.Weight == weight { + return nil + } + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + // Update the weight + if err = UpdateIssueCols(ctx, &Issue{ID: issue.ID, Weight: weight}, "weight"); err != nil { + return err + } + + // Make the comment + if _, err = CreateWeightComment(ctx, doer, issue, weight); err != nil { + return fmt.Errorf("createWeightComment: %w", err) + } + + return committer.Commit() +} + // FindAndUpdateIssueMentions finds users mentioned in the given content string, and saves them in the database. func FindAndUpdateIssueMentions(ctx context.Context, issue *Issue, doer *user_model.User, content string) (mentions []*user_model.User, err error) { rawMentions := references.FindAllMentionsMarkdown(content) diff --git a/models/issues/milestone.go b/models/issues/milestone.go index db0312adf0057..3ec1a0b04d37a 100644 --- a/models/issues/milestone.go +++ b/models/issues/milestone.go @@ -64,7 +64,9 @@ type Milestone struct { ClosedDateUnix timeutil.TimeStamp DeadlineString string `xorm:"-"` - TotalTrackedTime int64 `xorm:"-"` + TotalTrackedTime int64 `xorm:"-"` + TotalWeight int `xorm:"-"` + WeightedCompleteness int `xorm:"-"` } func init() { @@ -355,6 +357,33 @@ func (m *Milestone) LoadTotalTrackedTime(ctx context.Context) error { return nil } +// LoadTotalWeight loads the total weight for the milestone +func (m *Milestone) LoadTotalWeight(ctx context.Context) error { + type totalWeightByMilestone struct { + MilestoneID int64 + Weight int + WeightClosed int + } + + totalWeight := &totalWeightByMilestone{MilestoneID: m.ID} + has, err := db.GetEngine(ctx).Table("issue"). + Join("INNER", "milestone", "issue.milestone_id = milestone.id"). + Select("milestone_id, sum(weight) as weight, sum(CASE WHEN is_closed THEN 0 ELSE weight END) as weight_closed"). + Where("milestone_id = ?", m.ID). + GroupBy("milestone_id"). + Get(totalWeight) + if err != nil { + return err + } else if !has { + return nil + } + + m.TotalWeight = totalWeight.Weight + m.WeightedCompleteness = totalWeight.WeightClosed * 100 / totalWeight.Weight + + return nil +} + // InsertMilestones creates milestones of repository. func InsertMilestones(ctx context.Context, ms ...*Milestone) (err error) { if len(ms) == 0 { diff --git a/models/issues/milestone_list.go b/models/issues/milestone_list.go index 955ab2356df5e..bfc3d53cfc46b 100644 --- a/models/issues/milestone_list.go +++ b/models/issues/milestone_list.go @@ -130,6 +130,49 @@ func (milestones MilestoneList) LoadTotalTrackedTimes(ctx context.Context) error return nil } +// LoadTotalWeight loads for every milestone in the list the TotalWeight by a batch request +func (milestones MilestoneList) LoadTotalWeight(ctx context.Context) error { + type totalWeightByMilestone struct { + MilestoneID int64 + Weight int + WeightClosed int + } + if len(milestones) == 0 { + return nil + } + + weight := make(map[int64]int, len(milestones)) + closedWeight := make(map[int64]int, len(milestones)) + + rows, err := db.GetEngine(ctx).Table("issue"). + Join("INNER", "milestone", "issue.milestone_id = milestone.id"). + Select("milestone_id, sum(weight) as weight, sum(CASE WHEN is_closed THEN 0 ELSE weight END) as weight_closed"). + In("milestone_id", milestones.getMilestoneIDs()). + GroupBy("milestone_id"). + Rows(new(totalWeightByMilestone)) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var totalWeight totalWeightByMilestone + err = rows.Scan(&totalWeight) + if err != nil { + return err + } + weight[totalWeight.MilestoneID] = totalWeight.Weight + closedWeight[totalWeight.MilestoneID] = totalWeight.WeightClosed + } + + for _, milestone := range milestones { + milestone.TotalWeight = weight[milestone.ID] + milestone.WeightedCompleteness = closedWeight[milestone.ID] * 100 / milestone.TotalWeight + } + + return nil +} + // CountMilestonesByRepoCondAndKw map from repo conditions and the keyword of milestones' name to number of milestones matching the options` func CountMilestonesMap(ctx context.Context, opts FindMilestoneOptions) (map[int64]int64, error) { sess := db.GetEngine(ctx).Where(opts.ToConds()) diff --git a/models/issues/weight.go b/models/issues/weight.go new file mode 100644 index 0000000000000..ca5d7dfd55bbd --- /dev/null +++ b/models/issues/weight.go @@ -0,0 +1,60 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "context" + + "code.gitea.io/gitea/models/db" +) + +// GetIssueTotalWeight returns the total weight for issues by given conditions. +func GetIssueTotalWeight(ctx context.Context, opts *IssuesOptions) (int, int, error) { + if len(opts.IssueIDs) <= MaxQueryParameters { + return getIssueTotalWeightChunk(ctx, opts, opts.IssueIDs) + } + + // If too long a list of IDs is provided, + // we get the statistics in smaller chunks and get accumulates + var weightSum int + var closedWeightSum int + for i := 0; i < len(opts.IssueIDs); { + chunk := i + MaxQueryParameters + if chunk > len(opts.IssueIDs) { + chunk = len(opts.IssueIDs) + } + weight, closedWeight, err := getIssueTotalWeightChunk(ctx, opts, opts.IssueIDs[i:chunk]) + if err != nil { + return 0, 0, err + } + weightSum += weight + closedWeightSum += closedWeight + i = chunk + } + + return weightSum, closedWeightSum, nil +} + +func getIssueTotalWeightChunk(ctx context.Context, opts *IssuesOptions, issueIDs []int64) (int, int, error) { + type totalWeight struct { + Weight int + WeightClosed int + } + + tw := &totalWeight{} + + session := db.GetEngine(ctx).Table("issue"). + Select("sum(weight) as weight, sum(CASE WHEN is_closed THEN 0 ELSE weight END) as weight_closed") + + has, err := applyIssuesOptions(session, opts, issueIDs). + Get(tw) + + if err != nil { + return 0, 0, err + } else if !has { + return 0, 0, nil + } + + return tw.Weight, tw.WeightClosed, nil +} diff --git a/models/issues/weight_test.go b/models/issues/weight_test.go new file mode 100644 index 0000000000000..c1e1d02c83c5a --- /dev/null +++ b/models/issues/weight_test.go @@ -0,0 +1,33 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestGetIssueTotalWeight(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + tw, twc, err := issues_model.GetIssueTotalWeight(db.DefaultContext, &issues_model.IssuesOptions{MilestoneIDs: []int64{1}}) + assert.NoError(t, err) + assert.EqualValues(t, 12, tw) + assert.EqualValues(t, 12, twc) + + tw, twc, err = issues_model.GetIssueTotalWeight(db.DefaultContext, &issues_model.IssuesOptions{MilestoneIDs: []int64{3}}) + assert.NoError(t, err) + assert.EqualValues(t, 5, tw) + assert.EqualValues(t, 5, twc) + + tw, twc, err = issues_model.GetIssueTotalWeight(db.DefaultContext, &issues_model.IssuesOptions{RepoIDs: []int64{2}}) + assert.NoError(t, err) + assert.EqualValues(t, 20, tw) + assert.EqualValues(t, 10, twc) +} diff --git a/models/migrations/v1_23/v305.go b/models/migrations/v1_23/v305.go new file mode 100644 index 0000000000000..76ba32d23bc1d --- /dev/null +++ b/models/migrations/v1_23/v305.go @@ -0,0 +1,13 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_23 //nolint + +import "xorm.io/xorm" + +func AddIssueWeight(x *xorm.Engine) error { + type Issue struct { + Weight int + } + return x.Sync(new(Issue)) +} diff --git a/models/project/column.go b/models/project/column.go index 222f44859928e..f6d6614004796 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -57,20 +57,6 @@ func (Column) TableName() string { return "project_board" // TODO: the legacy table name should be project_column } -// NumIssues return counter of all issues assigned to the column -func (c *Column) NumIssues(ctx context.Context) int { - total, err := db.GetEngine(ctx).Table("project_issue"). - Where("project_id=?", c.ProjectID). - And("project_board_id=?", c.ID). - GroupBy("issue_id"). - Cols("issue_id"). - Count() - if err != nil { - return 0 - } - return int(total) -} - func (c *Column) GetIssues(ctx context.Context) ([]*ProjectIssue, error) { issues := make([]*ProjectIssue, 0, 5) if err := db.GetEngine(ctx).Where("project_id=?", c.ProjectID). diff --git a/models/repo/issue.go b/models/repo/issue.go index 0dd4fd5ed480e..b131e1d209005 100644 --- a/models/repo/issue.go +++ b/models/repo/issue.go @@ -38,6 +38,24 @@ func (repo *Repository) IsTimetrackerEnabled(ctx context.Context) bool { return u.IssuesConfig().EnableTimetracker } +func (repo *Repository) CanEnableWeight() bool { + return setting.Service.EnableIssueWeight +} + +// IsTimetrackerEnabled returns whether or not the timetracker is enabled. It returns the default value from config if an error occurs. +func (repo *Repository) IsWeightEnabled(ctx context.Context) bool { + if !setting.Service.EnableIssueWeight { + return false + } + + var u *RepoUnit + var err error + if u, err = repo.GetUnit(ctx, unit.TypeIssues); err != nil { + return setting.Service.DefaultEnableIssueWeight + } + return u.IssuesConfig().EnableWeight +} + // AllowOnlyContributorsToTrackTime returns value of IssuesConfig or the default value func (repo *Repository) AllowOnlyContributorsToTrackTime(ctx context.Context) bool { var u *RepoUnit diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index cb52c2c9e2058..022cedbdc4cce 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -105,6 +105,7 @@ type IssuesConfig struct { EnableTimetracker bool AllowOnlyContributorsToTrackTime bool EnableDependencies bool + EnableWeight bool } // FromDB fills up a IssuesConfig from serialized format. diff --git a/modules/setting/service.go b/modules/setting/service.go index 3ea1501236dfd..681e7d39d9b62 100644 --- a/modules/setting/service.go +++ b/modules/setting/service.go @@ -70,6 +70,8 @@ var Service = struct { DefaultUserIsRestricted bool EnableTimetracking bool DefaultEnableTimetracking bool + EnableIssueWeight bool + DefaultEnableIssueWeight bool DefaultEnableDependencies bool AllowCrossRepositoryDependencies bool DefaultAllowOnlyContributorsToTrackTime bool @@ -184,6 +186,11 @@ func loadServiceFrom(rootCfg ConfigProvider) { if Service.EnableTimetracking { Service.DefaultEnableTimetracking = sec.Key("DEFAULT_ENABLE_TIMETRACKING").MustBool(true) } + Service.EnableIssueWeight = sec.Key("ENABLE_ISSUE_WEIGHT").MustBool(false) + if Service.EnableIssueWeight { + Service.DefaultEnableIssueWeight = sec.Key("DEFAULT_ENABLE_ISSUE_WEIGHT").MustBool(true) + } + Service.DefaultEnableDependencies = sec.Key("DEFAULT_ENABLE_DEPENDENCIES").MustBool(true) Service.AllowCrossRepositoryDependencies = sec.Key("ALLOW_CROSS_REPOSITORY_DEPENDENCIES").MustBool(true) Service.DefaultAllowOnlyContributorsToTrackTime = sec.Key("DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME").MustBool(true) diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 3682191be5751..c6a201f40f61f 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -80,6 +80,7 @@ type Issue struct { Repo *RepositoryMeta `json:"repository"` PinOrder int `json:"pin_order"` + Weight int `json:"weight"` } // CreateIssueOption options to create one issue @@ -98,6 +99,7 @@ type CreateIssueOption struct { // list of label ids Labels []int64 `json:"labels"` Closed bool `json:"closed"` + Weight int `json:"weight"` } // EditIssueOption options for editing an issue @@ -113,6 +115,7 @@ type EditIssueOption struct { // swagger:strfmt date-time Deadline *time.Time `json:"due_date"` RemoveDeadline *bool `json:"unset_due_date"` + Weight *int `json:"weight"` } // EditDeadlineOption options for creating a deadline @@ -122,6 +125,11 @@ type EditDeadlineOption struct { Deadline *time.Time `json:"due_date"` } +type EditWeightOption struct { + // required:true + Weight int `json:"weight"` +} + // IssueDeadline represents an issue deadline // swagger:model type IssueDeadline struct { @@ -129,6 +137,11 @@ type IssueDeadline struct { Deadline *time.Time `json:"due_date"` } +// IssueWeight represents an issue weight +type IssueWeight struct { + Weight int `json:"weight"` +} + // IssueFormFieldType defines issue form field type, can be "markdown", "textarea", "input", "dropdown" or "checkboxes" type IssueFormFieldType string diff --git a/modules/structs/repo.go b/modules/structs/repo.go index fd27df384da2f..12495bf084a9b 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -24,6 +24,8 @@ type InternalTracker struct { AllowOnlyContributorsToTrackTime bool `json:"allow_only_contributors_to_track_time"` // Enable dependencies for issues and pull requests (Built-in issue tracker) EnableIssueDependencies bool `json:"enable_issue_dependencies"` + // Enable issue weight (Built-in issue tracker) + EnableWeight bool `json:"enable_issue_weight"` } // ExternalTracker represents settings for external tracker diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 957e73b171d32..d5ac90925c5ae 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -18,6 +18,7 @@ language = Language notifications = Notifications active_stopwatch = Active Time Tracker tracked_time_summary = Summary of tracked time based on filters of issue list +weight_summary = Total weight based on filters of issue list create_new = Create… user_profile_and_more = Profile and Settings… signed_in_as = Signed in as @@ -1698,6 +1699,14 @@ issues.due_date_modified = "modified the due date from %[2]s to %[1]s %[3]s" issues.due_date_remove = "removed the due date %s %s" issues.due_date_overdue = "Overdue" issues.due_date_invalid = "The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'." +issues.weight_added = "added weight of %s %s" +issues.weight_modified = " changed weight to %s from %s %s" +issues.weight_removed = "removed weight of %s %s" + +issues.weight.title = Weight +issues.weight.no_weight = No Weight +issues.weight.remove_weight = remove weight + issues.dependency.title = Dependencies issues.dependency.issue_no_dependencies = No dependencies set. issues.dependency.pr_no_dependencies = No dependencies set. @@ -2152,6 +2161,7 @@ settings.tracker_issue_style.regexp_pattern_desc = The first captured group will settings.tracker_url_format_desc = Use the placeholders {user}, {repo} and {index} for the username, repository name and issue index. settings.enable_timetracker = Enable Time Tracking settings.allow_only_contributors_to_track_time = Let Only Contributors Track Time +settings.enable_weight = Enable Weight settings.pulls_desc = Enable Repository Pull Requests settings.pulls.ignore_whitespace = Ignore Whitespace for Conflicts settings.pulls.enable_autodetect_manual_merge = Enable autodetect manual merge (Note: In some special cases, misjudgments can occur) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index c1218440e5958..8616bd20bbb74 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -707,7 +707,7 @@ func CreateIssue(ctx *context.APIContext) { form.Labels = make([]int64, 0) } - if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, 0); err != nil { + if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, 0, form.Weight); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) } else if errors.Is(err, user_model.ErrBlockedUser) { @@ -829,6 +829,13 @@ func EditIssue(ctx *context.APIContext) { } } + if form.Weight != nil { + if err := issue_service.ChangeIssueWeight(ctx, issue, ctx.Doer, *form.Weight); err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeWeight", err) + return + } + } + // Update or remove the deadline, only if set and allowed if (form.Deadline != nil || form.RemoveDeadline != nil) && canWrite { var deadlineUnix timeutil.TimeStamp diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 1bcec8fcf7e72..f04e2fb211c1e 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -799,6 +799,7 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error { EnableTimetracker: opts.InternalTracker.EnableTimeTracker, AllowOnlyContributorsToTrackTime: opts.InternalTracker.AllowOnlyContributorsToTrackTime, EnableDependencies: opts.InternalTracker.EnableIssueDependencies, + EnableWeight: opts.InternalTracker.EnableWeight, } } else if unit, err := repo.GetUnit(ctx, unit_model.TypeIssues); err != nil { // Unit type doesn't exist so we make a new config file with default values @@ -806,6 +807,7 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error { EnableTimetracker: true, AllowOnlyContributorsToTrackTime: true, EnableDependencies: true, + EnableWeight: false, } } else { config = unit.IssuesConfig() diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 596abb4b9ca5b..6144e31b2c2cb 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -265,6 +265,17 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt ctx.Data["TotalTrackedTime"] = totalTrackedTime } + if repo.IsWeightEnabled(ctx) { + totalWeight, totalWeightClosed, err := issues_model.GetIssueTotalWeight(ctx, statsOpts) + if err != nil { + ctx.ServerError("GetIssueTotalWeight", err) + return + } + + ctx.Data["TotalWeight"] = totalWeight + ctx.Data["WeightedCompleteness"] = totalWeightClosed * 100 / totalWeight + } + archived := ctx.FormBool("archived") page := ctx.FormInt("page") @@ -1277,9 +1288,10 @@ func NewIssuePost(ctx *context.Context) { MilestoneID: milestoneID, Content: content, Ref: form.Ref, + Weight: form.Weight, } - if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectID); err != nil { + if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectID, form.Weight); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) } else if errors.Is(err, user_model.ErrBlockedUser) { @@ -2360,6 +2372,33 @@ func UpdateIssueDeadline(ctx *context.Context) { ctx.JSON(http.StatusCreated, api.IssueDeadline{Deadline: &deadline}) } +// UpdateIssueWeight updates an issue weight +func UpdateIssueWeight(ctx *context.Context) { + form := web.GetForm(ctx).(*api.EditWeightOption) + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error()) + } + return + } + + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + ctx.Error(http.StatusForbidden, "", "Not repo writer") + return + } + + if err := issues_model.UpdateIssueWeight(ctx, issue, form.Weight, ctx.Doer); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateIssueWeight", err.Error()) + return + } + + ctx.Redirect(issue.Link()) +} + // UpdateIssueMilestone change issue's milestone func UpdateIssueMilestone(ctx *context.Context) { issues := getActionIssues(ctx) diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go index e4ee025875ef6..68f1aad8de297 100644 --- a/routers/web/repo/milestone.go +++ b/routers/web/repo/milestone.go @@ -79,6 +79,14 @@ func Milestones(ctx *context.Context) { return } } + + if ctx.Repo.Repository.IsWeightEnabled(ctx) { + if err := issues_model.MilestoneList(miles).LoadTotalWeight(ctx); err != nil { + ctx.ServerError("LoadTotalWeight", err) + return + } + } + for _, m := range miles { m.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ Links: markup.Links{ diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 664ea7eb76d79..af299bd7e05cc 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -441,6 +441,7 @@ func ViewProject(ctx *context.Context) { ctx.Data["IsProjectsPage"] = true ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["IsWeightEnabled"] = ctx.Repo.Repository.IsWeightEnabled(ctx) ctx.Data["Project"] = project ctx.Data["IssuesMap"] = issuesMap ctx.Data["Columns"] = columns diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 485bd927fa932..01cdc9afa4bcd 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -523,6 +523,7 @@ func SettingsPost(ctx *context.Context) { EnableTimetracker: form.EnableTimetracker, AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime, EnableDependencies: form.EnableIssueDependencies, + EnableWeight: form.EnableIssueWeight, }, }) deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) diff --git a/routers/web/web.go b/routers/web/web.go index f1e941a84efcb..797e307bd42a8 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1204,6 +1204,7 @@ func registerRoutes(m *web.Router) { m.Post("/title", repo.UpdateIssueTitle) m.Post("/content", repo.UpdateIssueContent) m.Post("/deadline", web.Bind(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline) + m.Post("/weight", web.Bind(structs.EditWeightOption{}), repo.UpdateIssueWeight) m.Post("/watch", repo.IssueWatch) m.Post("/ref", repo.UpdateIssueRef) m.Post("/pin", reqRepoAdmin, repo.IssuePinOrUnpin) diff --git a/services/convert/issue.go b/services/convert/issue.go index f514dc431351e..5bd90b186ea14 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -55,6 +55,7 @@ func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Iss Created: issue.CreatedUnix.AsTime(), Updated: issue.UpdatedUnix.AsTime(), PinOrder: issue.PinOrder, + Weight: issue.Weight, } if issue.Repo != nil { diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 988e479a48138..debb15e113c5b 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -167,6 +167,7 @@ type RepoSettingForm struct { EnableTimetracker bool AllowOnlyContributorsToTrackTime bool EnableIssueDependencies bool + EnableIssueWeight bool IsArchived bool // Signing Settings @@ -450,6 +451,7 @@ type CreateIssueForm struct { MilestoneID int64 ProjectID int64 AssigneeID int64 + Weight int Content string Files []string AllowMaintainerEdit bool diff --git a/services/issue/issue.go b/services/issue/issue.go index 72ea66c8d98c5..b3d4e9c14ee0c 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -23,7 +23,7 @@ import ( ) // NewIssue creates new issue with labels for repository. -func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64, projectID int64) error { +func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64, projectID int64, weight int) error { if err := issue.LoadPoster(ctx); err != nil { return err } @@ -46,6 +46,12 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo return err } } + + if weight != 0 { + if _, err := issues_model.CreateWeightComment(ctx, issue.Poster, issue, weight); err != nil { + return err + } + } return nil }); err != nil { return err @@ -119,6 +125,15 @@ func ChangeIssueRef(ctx context.Context, issue *issues_model.Issue, doer *user_m return nil } +// ChangeIssueWeight changes the weight of this issue, as the given user. +func ChangeIssueWeight(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, weight int) error { + if issue.Weight == weight { + return nil + } + + return issues_model.UpdateIssueWeight(ctx, issue, weight, doer) +} + // UpdateAssignees is a helper function to add or delete one or multiple issue assignee(s) // Deleting is done the GitHub way (quote from their api documentation): // https://developer.github.com/v3/issues/#edit-an-issue diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index f5c1bb76703c4..c429dee88645a 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -145,9 +145,15 @@
- {{.NumIssues ctx}} + {{len (index $.IssuesMap .ID)}}
{{.Title}}
+ {{if $.IsWeightEnabled}} +
+ {{svg "octicon-briefcase"}} + +
+ {{end}} {{if $canWriteProject}} {{end}} + {{if .Repo.IsWeightEnabled ctx}} +
+ {{svg "octicon-briefcase" 16 "tw-mr-1 tw-align-middle"}} + {{.Weight}} +
+ {{end}}
{{if or .Labels .Assignees}} diff --git a/templates/repo/issue/milestone_issues.tmpl b/templates/repo/issue/milestone_issues.tmpl index 5bae6fc6d585e..8bf052a4585a5 100644 --- a/templates/repo/issue/milestone_issues.tmpl +++ b/templates/repo/issue/milestone_issues.tmpl @@ -27,7 +27,11 @@
{{end}}
- + {{$completeness := .Milestone.Completeness}} + {{if .Repository.IsWeightEnabled ctx}} + {{$completeness = .WeightedCompleteness}} + {{end}} +
{{$closedDate:= TimeSinceUnix .Milestone.ClosedDateUnix ctx.Locale}} @@ -46,13 +50,19 @@ {{end}} {{end}}
-
{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}
+
{{ctx.Locale.Tr "repo.milestones.completeness" $completeness}}
{{if .TotalTrackedTime}}
{{svg "octicon-clock"}} {{.TotalTrackedTime | Sec2Time}}
{{end}} + {{if .TotalWeight}} +
+ {{svg "octicon-briefcase"}} + {{.TotalWeight}} +
+ {{end}}
diff --git a/templates/repo/issue/milestones.tmpl b/templates/repo/issue/milestones.tmpl index bce7ad871719a..d5d7103a23000 100644 --- a/templates/repo/issue/milestones.tmpl +++ b/templates/repo/issue/milestones.tmpl @@ -18,14 +18,18 @@
{{range .Milestones}}
  • + {{$completeness := .Completeness}} + {{if $.Repository.IsWeightEnabled ctx}} + {{$completeness = .WeightedCompleteness}} + {{end}}

    {{svg "octicon-milestone" 16}} {{.Name}}

    - {{.Completeness}}% - + {{$completeness}}% +
    @@ -44,6 +48,12 @@ {{.TotalTrackedTime|Sec2Time}}
    {{end}} + {{if .TotalWeight}} +
    + {{svg "octicon-briefcase"}} + {{.TotalWeight}} +
    + {{end}} {{if .UpdatedUnix}}
    {{svg "octicon-clock"}} diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl index e56d1b9ecc7bd..37c0907e6cee8 100644 --- a/templates/repo/issue/new_form.tmpl +++ b/templates/repo/issue/new_form.tmpl @@ -185,6 +185,15 @@
  • {{end}} +
    +
    + + Weight + +
    +
    + +
    diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index 57abbeb8f7960..543d7d52c4e0d 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -2,6 +2,7 @@ {{range .Issue.Comments}} {{if call $.ShouldShowCommentType .Type}} {{$createdStr:= TimeSinceUnix .CreatedUnix ctx.Locale}} + {{$isWeightEnabled:= $.Repository.IsWeightEnabled ctx}}