mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Add /milestones endpoint (#8733)
Create a /milestones endpoint which basically serves as a dashboard view for milestones, very similar to the /issues or /pulls page. Closes #8232
This commit is contained in:
		| @@ -511,6 +511,8 @@ DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME = true | |||||||
| NO_REPLY_ADDRESS = noreply.%(DOMAIN)s | NO_REPLY_ADDRESS = noreply.%(DOMAIN)s | ||||||
| ; Show Registration button | ; Show Registration button | ||||||
| SHOW_REGISTRATION_BUTTON = true | SHOW_REGISTRATION_BUTTON = true | ||||||
|  | ; Show milestones dashboard page - a view of all the user's milestones | ||||||
|  | SHOW_MILESTONES_DASHBOARD_PAGE = true | ||||||
| ; Default value for AutoWatchNewRepos | ; Default value for AutoWatchNewRepos | ||||||
| ; When adding a repo to a team or creating a new repo all team members will watch the | ; When adding a repo to a team or creating a new repo all team members will watch the | ||||||
| ; repo automatically if enabled | ; repo automatically if enabled | ||||||
|   | |||||||
| @@ -310,6 +310,7 @@ relation to port exhaustion. | |||||||
| - `EMAIL_DOMAIN_WHITELIST`: **\<empty\>**: If non-empty, list of domain names that can only be used to register | - `EMAIL_DOMAIN_WHITELIST`: **\<empty\>**: If non-empty, list of domain names that can only be used to register | ||||||
|   on this instance. |   on this instance. | ||||||
| - `SHOW_REGISTRATION_BUTTON`: **! DISABLE\_REGISTRATION**: Show Registration Button | - `SHOW_REGISTRATION_BUTTON`: **! DISABLE\_REGISTRATION**: Show Registration Button | ||||||
|  | - `SHOW_MILESTONES_DASHBOARD_PAGE`: **true** Enable this to show the milestones dashboard page - a view of all the user's milestones | ||||||
| - `AUTO_WATCH_NEW_REPOS`: **true**: Enable this to let all organisation users watch new repos when they are created | - `AUTO_WATCH_NEW_REPOS`: **true**: Enable this to let all organisation users watch new repos when they are created | ||||||
| - `AUTO_WATCH_ON_CHANGES`: **false**: Enable this to make users watch a repository after their first commit to it | - `AUTO_WATCH_ON_CHANGES`: **false**: Enable this to make users watch a repository after their first commit to it | ||||||
| - `DEFAULT_ORG_VISIBILITY`: **public**: Set default visibility mode for organisations, either "public", "limited" or "private". | - `DEFAULT_ORG_VISIBILITY`: **public**: Set default visibility mode for organisations, either "public", "limited" or "private". | ||||||
|   | |||||||
| @@ -86,6 +86,12 @@ func testLinksAsUser(userName string, t *testing.T) { | |||||||
| 		"/pulls?type=your_repositories&repos=[0]&sort=&state=closed", | 		"/pulls?type=your_repositories&repos=[0]&sort=&state=closed", | ||||||
| 		"/pulls?type=assigned&repos=[0]&sort=&state=closed", | 		"/pulls?type=assigned&repos=[0]&sort=&state=closed", | ||||||
| 		"/pulls?type=created_by&repos=[0]&sort=&state=closed", | 		"/pulls?type=created_by&repos=[0]&sort=&state=closed", | ||||||
|  | 		"/milestones", | ||||||
|  | 		"/milestones?sort=mostcomplete&state=closed", | ||||||
|  | 		"/milestones?type=your_repositories&sort=mostcomplete&state=closed", | ||||||
|  | 		"/milestones?sort=&repos=[1]&state=closed", | ||||||
|  | 		"/milestones?sort=&repos=[1]&state=open", | ||||||
|  | 		"/milestones?repos=[0]&sort=mostissues&state=open", | ||||||
| 		"/notifications", | 		"/notifications", | ||||||
| 		"/repo/create", | 		"/repo/create", | ||||||
| 		"/repo/migrate", | 		"/repo/migrate", | ||||||
|   | |||||||
| @@ -17,8 +17,9 @@ import ( | |||||||
|  |  | ||||||
| // Milestone represents a milestone of repository. | // Milestone represents a milestone of repository. | ||||||
| type Milestone struct { | type Milestone struct { | ||||||
| 	ID              int64 `xorm:"pk autoincr"` | 	ID              int64       `xorm:"pk autoincr"` | ||||||
| 	RepoID          int64 `xorm:"INDEX"` | 	RepoID          int64       `xorm:"INDEX"` | ||||||
|  | 	Repo            *Repository `xorm:"-"` | ||||||
| 	Name            string | 	Name            string | ||||||
| 	Content         string `xorm:"TEXT"` | 	Content         string `xorm:"TEXT"` | ||||||
| 	RenderedContent string `xorm:"-"` | 	RenderedContent string `xorm:"-"` | ||||||
| @@ -177,11 +178,38 @@ func (milestones MilestoneList) loadTotalTrackedTimes(e Engine) error { | |||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func (m *Milestone) loadTotalTrackedTime(e Engine) error { | ||||||
|  | 	type totalTimesByMilestone struct { | ||||||
|  | 		MilestoneID int64 | ||||||
|  | 		Time        int64 | ||||||
|  | 	} | ||||||
|  | 	totalTime := &totalTimesByMilestone{MilestoneID: m.ID} | ||||||
|  | 	has, err := e.Table("issue"). | ||||||
|  | 		Join("INNER", "milestone", "issue.milestone_id = milestone.id"). | ||||||
|  | 		Join("LEFT", "tracked_time", "tracked_time.issue_id = issue.id"). | ||||||
|  | 		Select("milestone_id, sum(time) as time"). | ||||||
|  | 		Where("milestone_id = ?", m.ID). | ||||||
|  | 		GroupBy("milestone_id"). | ||||||
|  | 		Get(totalTime) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} else if !has { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 	m.TotalTrackedTime = totalTime.Time | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  |  | ||||||
| // LoadTotalTrackedTimes loads for every milestone in the list the TotalTrackedTime by a batch request | // LoadTotalTrackedTimes loads for every milestone in the list the TotalTrackedTime by a batch request | ||||||
| func (milestones MilestoneList) LoadTotalTrackedTimes() error { | func (milestones MilestoneList) LoadTotalTrackedTimes() error { | ||||||
| 	return milestones.loadTotalTrackedTimes(x) | 	return milestones.loadTotalTrackedTimes(x) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // LoadTotalTrackedTime loads the tracked time for the milestone | ||||||
|  | func (m *Milestone) LoadTotalTrackedTime() error { | ||||||
|  | 	return m.loadTotalTrackedTime(x) | ||||||
|  | } | ||||||
|  |  | ||||||
| func (milestones MilestoneList) getMilestoneIDs() []int64 { | func (milestones MilestoneList) getMilestoneIDs() []int64 { | ||||||
| 	var ids = make([]int64, 0, len(milestones)) | 	var ids = make([]int64, 0, len(milestones)) | ||||||
| 	for _, ms := range milestones { | 	for _, ms := range milestones { | ||||||
| @@ -465,3 +493,78 @@ func DeleteMilestoneByRepoID(repoID, id int64) error { | |||||||
| 	} | 	} | ||||||
| 	return sess.Commit() | 	return sess.Commit() | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // CountMilestonesByRepoIDs map from repoIDs to number of milestones matching the options` | ||||||
|  | func CountMilestonesByRepoIDs(repoIDs []int64, isClosed bool) (map[int64]int64, error) { | ||||||
|  | 	sess := x.Where("is_closed = ?", isClosed) | ||||||
|  | 	sess.In("repo_id", repoIDs) | ||||||
|  |  | ||||||
|  | 	countsSlice := make([]*struct { | ||||||
|  | 		RepoID int64 | ||||||
|  | 		Count  int64 | ||||||
|  | 	}, 0, 10) | ||||||
|  | 	if err := sess.GroupBy("repo_id"). | ||||||
|  | 		Select("repo_id AS repo_id, COUNT(*) AS count"). | ||||||
|  | 		Table("milestone"). | ||||||
|  | 		Find(&countsSlice); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	countMap := make(map[int64]int64, len(countsSlice)) | ||||||
|  | 	for _, c := range countsSlice { | ||||||
|  | 		countMap[c.RepoID] = c.Count | ||||||
|  | 	} | ||||||
|  | 	return countMap, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetMilestonesByRepoIDs returns a list of milestones of given repositories and status. | ||||||
|  | func GetMilestonesByRepoIDs(repoIDs []int64, page int, isClosed bool, sortType string) (MilestoneList, error) { | ||||||
|  | 	miles := make([]*Milestone, 0, setting.UI.IssuePagingNum) | ||||||
|  | 	sess := x.Where("is_closed = ?", isClosed) | ||||||
|  | 	sess.In("repo_id", repoIDs) | ||||||
|  | 	if page > 0 { | ||||||
|  | 		sess = sess.Limit(setting.UI.IssuePagingNum, (page-1)*setting.UI.IssuePagingNum) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	switch sortType { | ||||||
|  | 	case "furthestduedate": | ||||||
|  | 		sess.Desc("deadline_unix") | ||||||
|  | 	case "leastcomplete": | ||||||
|  | 		sess.Asc("completeness") | ||||||
|  | 	case "mostcomplete": | ||||||
|  | 		sess.Desc("completeness") | ||||||
|  | 	case "leastissues": | ||||||
|  | 		sess.Asc("num_issues") | ||||||
|  | 	case "mostissues": | ||||||
|  | 		sess.Desc("num_issues") | ||||||
|  | 	default: | ||||||
|  | 		sess.Asc("deadline_unix") | ||||||
|  | 	} | ||||||
|  | 	return miles, sess.Find(&miles) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // MilestonesStats represents milestone statistic information. | ||||||
|  | type MilestonesStats struct { | ||||||
|  | 	OpenCount, ClosedCount int64 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetMilestonesStats returns milestone statistic information for dashboard by given conditions. | ||||||
|  | func GetMilestonesStats(userRepoIDs []int64) (*MilestonesStats, error) { | ||||||
|  | 	var err error | ||||||
|  | 	stats := &MilestonesStats{} | ||||||
|  |  | ||||||
|  | 	stats.OpenCount, err = x.Where("is_closed = ?", false). | ||||||
|  | 		And(builder.In("repo_id", userRepoIDs)). | ||||||
|  | 		Count(new(Milestone)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	stats.ClosedCount, err = x.Where("is_closed = ?", true). | ||||||
|  | 		And(builder.In("repo_id", userRepoIDs)). | ||||||
|  | 		Count(new(Milestone)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return stats, nil | ||||||
|  | } | ||||||
|   | |||||||
| @@ -289,3 +289,88 @@ func TestMilestoneList_LoadTotalTrackedTimes(t *testing.T) { | |||||||
|  |  | ||||||
| 	assert.Equal(t, miles[0].TotalTrackedTime, int64(3662)) | 	assert.Equal(t, miles[0].TotalTrackedTime, int64(3662)) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func TestCountMilestonesByRepoIDs(t *testing.T) { | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 	milestonesCount := func(repoID int64) (int, int) { | ||||||
|  | 		repo := AssertExistsAndLoadBean(t, &Repository{ID: repoID}).(*Repository) | ||||||
|  | 		return repo.NumOpenMilestones, repo.NumClosedMilestones | ||||||
|  | 	} | ||||||
|  | 	repo1OpenCount, repo1ClosedCount := milestonesCount(1) | ||||||
|  | 	repo2OpenCount, repo2ClosedCount := milestonesCount(2) | ||||||
|  |  | ||||||
|  | 	openCounts, err := CountMilestonesByRepoIDs([]int64{1, 2}, false) | ||||||
|  | 	assert.NoError(t, err) | ||||||
|  | 	assert.EqualValues(t, repo1OpenCount, openCounts[1]) | ||||||
|  | 	assert.EqualValues(t, repo2OpenCount, openCounts[2]) | ||||||
|  |  | ||||||
|  | 	closedCounts, err := CountMilestonesByRepoIDs([]int64{1, 2}, true) | ||||||
|  | 	assert.NoError(t, err) | ||||||
|  | 	assert.EqualValues(t, repo1ClosedCount, closedCounts[1]) | ||||||
|  | 	assert.EqualValues(t, repo2ClosedCount, closedCounts[2]) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestGetMilestonesByRepoIDs(t *testing.T) { | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 	repo1 := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository) | ||||||
|  | 	repo2 := AssertExistsAndLoadBean(t, &Repository{ID: 2}).(*Repository) | ||||||
|  | 	test := func(sortType string, sortCond func(*Milestone) int) { | ||||||
|  | 		for _, page := range []int{0, 1} { | ||||||
|  | 			openMilestones, err := GetMilestonesByRepoIDs([]int64{repo1.ID, repo2.ID}, page, false, sortType) | ||||||
|  | 			assert.NoError(t, err) | ||||||
|  | 			assert.Len(t, openMilestones, repo1.NumOpenMilestones+repo2.NumOpenMilestones) | ||||||
|  | 			values := make([]int, len(openMilestones)) | ||||||
|  | 			for i, milestone := range openMilestones { | ||||||
|  | 				values[i] = sortCond(milestone) | ||||||
|  | 			} | ||||||
|  | 			assert.True(t, sort.IntsAreSorted(values)) | ||||||
|  |  | ||||||
|  | 			closedMilestones, err := GetMilestonesByRepoIDs([]int64{repo1.ID, repo2.ID}, page, true, sortType) | ||||||
|  | 			assert.NoError(t, err) | ||||||
|  | 			assert.Len(t, closedMilestones, repo1.NumClosedMilestones+repo2.NumClosedMilestones) | ||||||
|  | 			values = make([]int, len(closedMilestones)) | ||||||
|  | 			for i, milestone := range closedMilestones { | ||||||
|  | 				values[i] = sortCond(milestone) | ||||||
|  | 			} | ||||||
|  | 			assert.True(t, sort.IntsAreSorted(values)) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	test("furthestduedate", func(milestone *Milestone) int { | ||||||
|  | 		return -int(milestone.DeadlineUnix) | ||||||
|  | 	}) | ||||||
|  | 	test("leastcomplete", func(milestone *Milestone) int { | ||||||
|  | 		return milestone.Completeness | ||||||
|  | 	}) | ||||||
|  | 	test("mostcomplete", func(milestone *Milestone) int { | ||||||
|  | 		return -milestone.Completeness | ||||||
|  | 	}) | ||||||
|  | 	test("leastissues", func(milestone *Milestone) int { | ||||||
|  | 		return milestone.NumIssues | ||||||
|  | 	}) | ||||||
|  | 	test("mostissues", func(milestone *Milestone) int { | ||||||
|  | 		return -milestone.NumIssues | ||||||
|  | 	}) | ||||||
|  | 	test("soonestduedate", func(milestone *Milestone) int { | ||||||
|  | 		return int(milestone.DeadlineUnix) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestLoadTotalTrackedTime(t *testing.T) { | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 	milestone := AssertExistsAndLoadBean(t, &Milestone{ID: 1}).(*Milestone) | ||||||
|  |  | ||||||
|  | 	assert.NoError(t, milestone.LoadTotalTrackedTime()) | ||||||
|  |  | ||||||
|  | 	assert.Equal(t, milestone.TotalTrackedTime, int64(3662)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestGetMilestonesStats(t *testing.T) { | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 	repo1 := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository) | ||||||
|  | 	repo2 := AssertExistsAndLoadBean(t, &Repository{ID: 2}).(*Repository) | ||||||
|  |  | ||||||
|  | 	milestoneStats, err := GetMilestonesStats([]int64{repo1.ID, repo2.ID}) | ||||||
|  | 	assert.NoError(t, err) | ||||||
|  | 	assert.EqualValues(t, repo1.NumOpenMilestones+repo2.NumOpenMilestones, milestoneStats.OpenCount) | ||||||
|  | 	assert.EqualValues(t, repo1.NumClosedMilestones+repo2.NumClosedMilestones, milestoneStats.ClosedCount) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -334,6 +334,7 @@ func Contexter() macaron.Handler { | |||||||
| 		ctx.Data["IsLandingPageOrganizations"] = setting.LandingPageURL == setting.LandingPageOrganizations | 		ctx.Data["IsLandingPageOrganizations"] = setting.LandingPageURL == setting.LandingPageOrganizations | ||||||
|  |  | ||||||
| 		ctx.Data["ShowRegistrationButton"] = setting.Service.ShowRegistrationButton | 		ctx.Data["ShowRegistrationButton"] = setting.Service.ShowRegistrationButton | ||||||
|  | 		ctx.Data["ShowMilestonesDashboardPage"] = setting.Service.ShowMilestonesDashboardPage | ||||||
| 		ctx.Data["ShowFooterBranding"] = setting.ShowFooterBranding | 		ctx.Data["ShowFooterBranding"] = setting.ShowFooterBranding | ||||||
| 		ctx.Data["ShowFooterVersion"] = setting.ShowFooterVersion | 		ctx.Data["ShowFooterVersion"] = setting.ShowFooterVersion | ||||||
|  |  | ||||||
|   | |||||||
| @@ -21,6 +21,7 @@ var Service struct { | |||||||
| 	DisableRegistration                     bool | 	DisableRegistration                     bool | ||||||
| 	AllowOnlyExternalRegistration           bool | 	AllowOnlyExternalRegistration           bool | ||||||
| 	ShowRegistrationButton                  bool | 	ShowRegistrationButton                  bool | ||||||
|  | 	ShowMilestonesDashboardPage             bool | ||||||
| 	RequireSignInView                       bool | 	RequireSignInView                       bool | ||||||
| 	EnableNotifyMail                        bool | 	EnableNotifyMail                        bool | ||||||
| 	EnableBasicAuth                         bool | 	EnableBasicAuth                         bool | ||||||
| @@ -62,6 +63,7 @@ func newService() { | |||||||
| 	Service.AllowOnlyExternalRegistration = sec.Key("ALLOW_ONLY_EXTERNAL_REGISTRATION").MustBool() | 	Service.AllowOnlyExternalRegistration = sec.Key("ALLOW_ONLY_EXTERNAL_REGISTRATION").MustBool() | ||||||
| 	Service.EmailDomainWhitelist = sec.Key("EMAIL_DOMAIN_WHITELIST").Strings(",") | 	Service.EmailDomainWhitelist = sec.Key("EMAIL_DOMAIN_WHITELIST").Strings(",") | ||||||
| 	Service.ShowRegistrationButton = sec.Key("SHOW_REGISTRATION_BUTTON").MustBool(!(Service.DisableRegistration || Service.AllowOnlyExternalRegistration)) | 	Service.ShowRegistrationButton = sec.Key("SHOW_REGISTRATION_BUTTON").MustBool(!(Service.DisableRegistration || Service.AllowOnlyExternalRegistration)) | ||||||
|  | 	Service.ShowMilestonesDashboardPage = sec.Key("SHOW_MILESTONES_DASHBOARD_PAGE").MustBool(true) | ||||||
| 	Service.RequireSignInView = sec.Key("REQUIRE_SIGNIN_VIEW").MustBool() | 	Service.RequireSignInView = sec.Key("REQUIRE_SIGNIN_VIEW").MustBool() | ||||||
| 	Service.EnableBasicAuth = sec.Key("ENABLE_BASIC_AUTHENTICATION").MustBool(true) | 	Service.EnableBasicAuth = sec.Key("ENABLE_BASIC_AUTHENTICATION").MustBool(true) | ||||||
| 	Service.EnableReverseProxyAuth = sec.Key("ENABLE_REVERSE_PROXY_AUTHENTICATION").MustBool() | 	Service.EnableReverseProxyAuth = sec.Key("ENABLE_REVERSE_PROXY_AUTHENTICATION").MustBool() | ||||||
|   | |||||||
| @@ -66,6 +66,7 @@ forks = Forks | |||||||
| activities = Activities | activities = Activities | ||||||
| pull_requests = Pull Requests | pull_requests = Pull Requests | ||||||
| issues = Issues | issues = Issues | ||||||
|  | milestones = Milestones | ||||||
|  |  | ||||||
| cancel = Cancel | cancel = Cancel | ||||||
| add = Add | add = Add | ||||||
|   | |||||||
| @@ -254,6 +254,13 @@ func RegisterRoutes(m *macaron.Macaron) { | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	reqMilestonesDashboardPageEnabled := func(ctx *context.Context) { | ||||||
|  | 		if !setting.Service.ShowMilestonesDashboardPage { | ||||||
|  | 			ctx.Error(403) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	m.Use(user.GetNotificationCount) | 	m.Use(user.GetNotificationCount) | ||||||
|  |  | ||||||
| 	// FIXME: not all routes need go through same middlewares. | 	// FIXME: not all routes need go through same middlewares. | ||||||
| @@ -276,6 +283,7 @@ func RegisterRoutes(m *macaron.Macaron) { | |||||||
| 	m.Combo("/install", routers.InstallInit).Get(routers.Install). | 	m.Combo("/install", routers.InstallInit).Get(routers.Install). | ||||||
| 		Post(bindIgnErr(auth.InstallForm{}), routers.InstallPost) | 		Post(bindIgnErr(auth.InstallForm{}), routers.InstallPost) | ||||||
| 	m.Get("/^:type(issues|pulls)$", reqSignIn, user.Issues) | 	m.Get("/^:type(issues|pulls)$", reqSignIn, user.Issues) | ||||||
|  | 	m.Get("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones) | ||||||
|  |  | ||||||
| 	// ***** START: User ***** | 	// ***** START: User ***** | ||||||
| 	m.Group("/user", func() { | 	m.Group("/user", func() { | ||||||
| @@ -556,6 +564,7 @@ func RegisterRoutes(m *macaron.Macaron) { | |||||||
| 		m.Group("/:org", func() { | 		m.Group("/:org", func() { | ||||||
| 			m.Get("/dashboard", user.Dashboard) | 			m.Get("/dashboard", user.Dashboard) | ||||||
| 			m.Get("/^:type(issues|pulls)$", user.Issues) | 			m.Get("/^:type(issues|pulls)$", user.Issues) | ||||||
|  | 			m.Get("/milestones", reqMilestonesDashboardPageEnabled, user.Milestones) | ||||||
| 			m.Get("/members", org.Members) | 			m.Get("/members", org.Members) | ||||||
| 			m.Get("/members/action/:action", org.MembersAction) | 			m.Get("/members/action/:action", org.MembersAction) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -18,17 +18,20 @@ import ( | |||||||
| 	"code.gitea.io/gitea/modules/base" | 	"code.gitea.io/gitea/modules/base" | ||||||
| 	"code.gitea.io/gitea/modules/context" | 	"code.gitea.io/gitea/modules/context" | ||||||
| 	"code.gitea.io/gitea/modules/log" | 	"code.gitea.io/gitea/modules/log" | ||||||
|  | 	"code.gitea.io/gitea/modules/markup/markdown" | ||||||
| 	"code.gitea.io/gitea/modules/setting" | 	"code.gitea.io/gitea/modules/setting" | ||||||
| 	"code.gitea.io/gitea/modules/util" | 	"code.gitea.io/gitea/modules/util" | ||||||
|  |  | ||||||
| 	"github.com/keybase/go-crypto/openpgp" | 	"github.com/keybase/go-crypto/openpgp" | ||||||
| 	"github.com/keybase/go-crypto/openpgp/armor" | 	"github.com/keybase/go-crypto/openpgp/armor" | ||||||
|  | 	"github.com/unknwon/com" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| const ( | const ( | ||||||
| 	tplDashboard base.TplName = "user/dashboard/dashboard" | 	tplDashboard  base.TplName = "user/dashboard/dashboard" | ||||||
| 	tplIssues    base.TplName = "user/dashboard/issues" | 	tplIssues     base.TplName = "user/dashboard/issues" | ||||||
| 	tplProfile   base.TplName = "user/profile" | 	tplMilestones base.TplName = "user/dashboard/milestones" | ||||||
|  | 	tplProfile    base.TplName = "user/profile" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // getDashboardContextUser finds out dashboard is viewing as which context user. | // getDashboardContextUser finds out dashboard is viewing as which context user. | ||||||
| @@ -150,6 +153,190 @@ func Dashboard(ctx *context.Context) { | |||||||
| 	ctx.HTML(200, tplDashboard) | 	ctx.HTML(200, tplDashboard) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // Milestones render the user milestones page | ||||||
|  | func Milestones(ctx *context.Context) { | ||||||
|  | 	ctx.Data["Title"] = ctx.Tr("milestones") | ||||||
|  | 	ctx.Data["PageIsMilestonesDashboard"] = true | ||||||
|  |  | ||||||
|  | 	ctxUser := getDashboardContextUser(ctx) | ||||||
|  | 	if ctx.Written() { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	sortType := ctx.Query("sort") | ||||||
|  | 	page := ctx.QueryInt("page") | ||||||
|  | 	if page <= 1 { | ||||||
|  | 		page = 1 | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	reposQuery := ctx.Query("repos") | ||||||
|  | 	isShowClosed := ctx.Query("state") == "closed" | ||||||
|  |  | ||||||
|  | 	// Get repositories. | ||||||
|  | 	var err error | ||||||
|  | 	var userRepoIDs []int64 | ||||||
|  | 	if ctxUser.IsOrganization() { | ||||||
|  | 		env, err := ctxUser.AccessibleReposEnv(ctx.User.ID) | ||||||
|  | 		if err != nil { | ||||||
|  | 			ctx.ServerError("AccessibleReposEnv", err) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		userRepoIDs, err = env.RepoIDs(1, ctxUser.NumRepos) | ||||||
|  | 		if err != nil { | ||||||
|  | 			ctx.ServerError("env.RepoIDs", err) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} else { | ||||||
|  | 		unitType := models.UnitTypeIssues | ||||||
|  | 		userRepoIDs, err = ctxUser.GetAccessRepoIDs(unitType) | ||||||
|  | 		if err != nil { | ||||||
|  | 			ctx.ServerError("ctxUser.GetAccessRepoIDs", err) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	if len(userRepoIDs) == 0 { | ||||||
|  | 		userRepoIDs = []int64{-1} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var repoIDs []int64 | ||||||
|  | 	if issueReposQueryPattern.MatchString(reposQuery) { | ||||||
|  | 		// remove "[" and "]" from string | ||||||
|  | 		reposQuery = reposQuery[1 : len(reposQuery)-1] | ||||||
|  | 		//for each ID (delimiter ",") add to int to repoIDs | ||||||
|  | 		reposSet := false | ||||||
|  | 		for _, rID := range strings.Split(reposQuery, ",") { | ||||||
|  | 			// Ensure nonempty string entries | ||||||
|  | 			if rID != "" && rID != "0" { | ||||||
|  | 				reposSet = true | ||||||
|  | 				rIDint64, err := strconv.ParseInt(rID, 10, 64) | ||||||
|  | 				if err == nil && com.IsSliceContainsInt64(userRepoIDs, rIDint64) { | ||||||
|  | 					repoIDs = append(repoIDs, rIDint64) | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		if reposSet && len(repoIDs) == 0 { | ||||||
|  | 			// force an empty result | ||||||
|  | 			repoIDs = []int64{-1} | ||||||
|  | 		} | ||||||
|  | 	} else { | ||||||
|  | 		log.Error("issueReposQueryPattern not match with query") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if len(repoIDs) == 0 { | ||||||
|  | 		repoIDs = userRepoIDs | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	counts, err := models.CountMilestonesByRepoIDs(userRepoIDs, isShowClosed) | ||||||
|  | 	if err != nil { | ||||||
|  | 		ctx.ServerError("CountMilestonesByRepoIDs", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	milestones, err := models.GetMilestonesByRepoIDs(repoIDs, page, isShowClosed, sortType) | ||||||
|  | 	if err != nil { | ||||||
|  | 		ctx.ServerError("GetMilestonesByRepoIDs", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	showReposMap := make(map[int64]*models.Repository, len(counts)) | ||||||
|  | 	for rID := range counts { | ||||||
|  | 		if rID == -1 { | ||||||
|  | 			break | ||||||
|  | 		} | ||||||
|  | 		repo, err := models.GetRepositoryByID(rID) | ||||||
|  | 		if err != nil { | ||||||
|  | 			if models.IsErrRepoNotExist(err) { | ||||||
|  | 				ctx.NotFound("GetRepositoryByID", err) | ||||||
|  | 				return | ||||||
|  | 			} else if err != nil { | ||||||
|  | 				ctx.ServerError("GetRepositoryByID", fmt.Errorf("[%d]%v", rID, err)) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		showReposMap[rID] = repo | ||||||
|  |  | ||||||
|  | 		// Check if user has access to given repository. | ||||||
|  | 		perm, err := models.GetUserRepoPermission(repo, ctxUser) | ||||||
|  | 		if err != nil { | ||||||
|  | 			ctx.ServerError("GetUserRepoPermission", fmt.Errorf("[%d]%v", rID, err)) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if !perm.CanRead(models.UnitTypeIssues) { | ||||||
|  | 			if log.IsTrace() { | ||||||
|  | 				log.Trace("Permission Denied: User %-v cannot read %-v of repo %-v\n"+ | ||||||
|  | 					"User in repo has Permissions: %-+v", | ||||||
|  | 					ctxUser, | ||||||
|  | 					models.UnitTypeIssues, | ||||||
|  | 					repo, | ||||||
|  | 					perm) | ||||||
|  | 			} | ||||||
|  | 			ctx.Status(404) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	showRepos := models.RepositoryListOfMap(showReposMap) | ||||||
|  | 	sort.Sort(showRepos) | ||||||
|  | 	if err = showRepos.LoadAttributes(); err != nil { | ||||||
|  | 		ctx.ServerError("LoadAttributes", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	for _, m := range milestones { | ||||||
|  | 		m.Repo = showReposMap[m.RepoID] | ||||||
|  | 		m.RenderedContent = string(markdown.Render([]byte(m.Content), m.Repo.Link(), m.Repo.ComposeMetas())) | ||||||
|  | 		if m.Repo.IsTimetrackerEnabled() { | ||||||
|  | 			err := m.LoadTotalTrackedTime() | ||||||
|  | 			if err != nil { | ||||||
|  | 				ctx.ServerError("LoadTotalTrackedTime", err) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	milestoneStats, err := models.GetMilestonesStats(repoIDs) | ||||||
|  | 	if err != nil { | ||||||
|  | 		ctx.ServerError("GetMilestoneStats", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	totalMilestoneStats, err := models.GetMilestonesStats(userRepoIDs) | ||||||
|  | 	if err != nil { | ||||||
|  | 		ctx.ServerError("GetMilestoneStats", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var pagerCount int | ||||||
|  | 	if isShowClosed { | ||||||
|  | 		ctx.Data["State"] = "closed" | ||||||
|  | 		ctx.Data["Total"] = totalMilestoneStats.ClosedCount | ||||||
|  | 		pagerCount = int(milestoneStats.ClosedCount) | ||||||
|  | 	} else { | ||||||
|  | 		ctx.Data["State"] = "open" | ||||||
|  | 		ctx.Data["Total"] = totalMilestoneStats.OpenCount | ||||||
|  | 		pagerCount = int(milestoneStats.OpenCount) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	ctx.Data["Milestones"] = milestones | ||||||
|  | 	ctx.Data["Repos"] = showRepos | ||||||
|  | 	ctx.Data["Counts"] = counts | ||||||
|  | 	ctx.Data["MilestoneStats"] = milestoneStats | ||||||
|  | 	ctx.Data["SortType"] = sortType | ||||||
|  | 	if len(repoIDs) != len(userRepoIDs) { | ||||||
|  | 		ctx.Data["RepoIDs"] = repoIDs | ||||||
|  | 	} | ||||||
|  | 	ctx.Data["IsShowClosed"] = isShowClosed | ||||||
|  |  | ||||||
|  | 	pager := context.NewPagination(pagerCount, setting.UI.IssuePagingNum, page, 5) | ||||||
|  | 	pager.AddParam(ctx, "repos", "RepoIDs") | ||||||
|  | 	pager.AddParam(ctx, "sort", "SortType") | ||||||
|  | 	pager.AddParam(ctx, "state", "State") | ||||||
|  | 	ctx.Data["Page"] = pager | ||||||
|  |  | ||||||
|  | 	ctx.HTML(200, tplMilestones) | ||||||
|  | } | ||||||
|  |  | ||||||
| // Regexp for repos query | // Regexp for repos query | ||||||
| var issueReposQueryPattern = regexp.MustCompile(`^\[\d+(,\d+)*,?\]$`) | var issueReposQueryPattern = regexp.MustCompile(`^\[\d+(,\d+)*,?\]$`) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -31,3 +31,42 @@ func TestIssues(t *testing.T) { | |||||||
| 	assert.Len(t, ctx.Data["Issues"], 1) | 	assert.Len(t, ctx.Data["Issues"], 1) | ||||||
| 	assert.Len(t, ctx.Data["Repos"], 1) | 	assert.Len(t, ctx.Data["Repos"], 1) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func TestMilestones(t *testing.T) { | ||||||
|  | 	setting.UI.IssuePagingNum = 1 | ||||||
|  | 	assert.NoError(t, models.LoadFixtures()) | ||||||
|  |  | ||||||
|  | 	ctx := test.MockContext(t, "milestones") | ||||||
|  | 	test.LoadUser(t, ctx, 2) | ||||||
|  | 	ctx.SetParams("sort", "issues") | ||||||
|  | 	ctx.Req.Form.Set("state", "closed") | ||||||
|  | 	ctx.Req.Form.Set("sort", "furthestduedate") | ||||||
|  | 	Milestones(ctx) | ||||||
|  | 	assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) | ||||||
|  | 	assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"]) | ||||||
|  | 	assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) | ||||||
|  | 	assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"]) | ||||||
|  | 	assert.EqualValues(t, 1, ctx.Data["Total"]) | ||||||
|  | 	assert.Len(t, ctx.Data["Milestones"], 1) | ||||||
|  | 	assert.Len(t, ctx.Data["Repos"], 1) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestMilestonesForSpecificRepo(t *testing.T) { | ||||||
|  | 	setting.UI.IssuePagingNum = 1 | ||||||
|  | 	assert.NoError(t, models.LoadFixtures()) | ||||||
|  |  | ||||||
|  | 	ctx := test.MockContext(t, "milestones") | ||||||
|  | 	test.LoadUser(t, ctx, 2) | ||||||
|  | 	ctx.SetParams("sort", "issues") | ||||||
|  | 	ctx.SetParams("repo", "1") | ||||||
|  | 	ctx.Req.Form.Set("state", "closed") | ||||||
|  | 	ctx.Req.Form.Set("sort", "furthestduedate") | ||||||
|  | 	Milestones(ctx) | ||||||
|  | 	assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) | ||||||
|  | 	assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"]) | ||||||
|  | 	assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) | ||||||
|  | 	assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"]) | ||||||
|  | 	assert.EqualValues(t, 1, ctx.Data["Total"]) | ||||||
|  | 	assert.Len(t, ctx.Data["Milestones"], 1) | ||||||
|  | 	assert.Len(t, ctx.Data["Repos"], 1) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ | |||||||
| 		<a class="item {{if .PageIsDashboard}}active{{end}}" href="{{AppSubUrl}}/">{{.i18n.Tr "dashboard"}}</a> | 		<a class="item {{if .PageIsDashboard}}active{{end}}" href="{{AppSubUrl}}/">{{.i18n.Tr "dashboard"}}</a> | ||||||
| 		<a class="item {{if .PageIsIssues}}active{{end}}" href="{{AppSubUrl}}/issues">{{.i18n.Tr "issues"}}</a> | 		<a class="item {{if .PageIsIssues}}active{{end}}" href="{{AppSubUrl}}/issues">{{.i18n.Tr "issues"}}</a> | ||||||
| 		<a class="item {{if .PageIsPulls}}active{{end}}" href="{{AppSubUrl}}/pulls">{{.i18n.Tr "pull_requests"}}</a> | 		<a class="item {{if .PageIsPulls}}active{{end}}" href="{{AppSubUrl}}/pulls">{{.i18n.Tr "pull_requests"}}</a> | ||||||
|  | 		{{if .ShowMilestonesDashboardPage}}<a class="item {{if .PageIsMilestonesDashboard}}active{{end}}" href="{{AppSubUrl}}/milestones">{{.i18n.Tr "milestones"}}</a>{{end}} | ||||||
| 		<a class="item {{if .PageIsExplore}}active{{end}}" href="{{AppSubUrl}}/explore/repos">{{.i18n.Tr "explore"}}</a> | 		<a class="item {{if .PageIsExplore}}active{{end}}" href="{{AppSubUrl}}/explore/repos">{{.i18n.Tr "explore"}}</a> | ||||||
| 	{{else if .IsLandingPageHome}} | 	{{else if .IsLandingPageHome}} | ||||||
| 		<a class="item {{if .PageIsHome}}active{{end}}" href="{{AppSubUrl}}/">{{.i18n.Tr "home"}}</a> | 		<a class="item {{if .PageIsHome}}active{{end}}" href="{{AppSubUrl}}/">{{.i18n.Tr "home"}}</a> | ||||||
|   | |||||||
							
								
								
									
										119
									
								
								templates/user/dashboard/milestones.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								templates/user/dashboard/milestones.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,119 @@ | |||||||
|  | {{template "base/head" .}} | ||||||
|  | <div class="dashboard issues repository milestones"> | ||||||
|  | 	{{template "user/dashboard/navbar" .}} | ||||||
|  | 	<div class="ui container"> | ||||||
|  | 		<div class="ui stackable grid"> | ||||||
|  | 			<div class="four wide column"> | ||||||
|  | 				<div class="ui secondary vertical filter menu"> | ||||||
|  | 					<a class="item" href="{{.Link}}?type=your_repositories&sort={{$.SortType}}&state={{.State}}"> | ||||||
|  | 						{{.i18n.Tr "home.issues.in_your_repos"}} | ||||||
|  | 						<strong class="ui right">{{.Total}}</strong> | ||||||
|  | 					</a> | ||||||
|  | 					<div class="ui divider"></div> | ||||||
|  | 					{{range .Repos}} | ||||||
|  | 						{{with $Repo := .}} | ||||||
|  | 							<a class="{{range $.RepoIDs}}{{if eq . $Repo.ID}}ui basic blue button{{end}}{{end}} repo name item" href="{{$.Link}}?repos=[ | ||||||
|  | 								{{with $include := true}} | ||||||
|  | 									{{range $.RepoIDs}} | ||||||
|  | 										{{if eq . $Repo.ID}} | ||||||
|  | 											{{$include = false}} | ||||||
|  | 										{{else}} | ||||||
|  | 											{{.}}%2C | ||||||
|  | 										{{end}} | ||||||
|  | 									{{end}} | ||||||
|  | 									{{if eq $include true}} | ||||||
|  | 										{{$Repo.ID}}%2C | ||||||
|  | 									{{end}} | ||||||
|  | 								{{end}} | ||||||
|  | 								]&sort={{$.SortType}}&state={{$.State}}" title="{{.FullName}}"> | ||||||
|  | 								<span class="text truncate">{{$Repo.FullName}}</span> | ||||||
|  | 								<div class="floating ui {{if $.IsShowClosed}}red{{else}}green{{end}} label">{{index $.Counts $Repo.ID}}</div> | ||||||
|  | 							</a> | ||||||
|  | 						{{end}} | ||||||
|  | 					{{end}} | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  | 			<div class="twelve wide column content"> | ||||||
|  | 				<div class="ui tiny basic status buttons"> | ||||||
|  | 					<a class="ui {{if not .IsShowClosed}}green active{{end}} basic button" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open"> | ||||||
|  | 						<i class="octicon octicon-issue-opened"></i> | ||||||
|  | 						{{.i18n.Tr "repo.milestones.open_tab" .MilestoneStats.OpenCount}} | ||||||
|  | 					</a> | ||||||
|  | 					<a class="ui {{if .IsShowClosed}}red active{{end}} basic button" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed"> | ||||||
|  | 						<i class="octicon octicon-issue-closed"></i> | ||||||
|  | 						{{.i18n.Tr "repo.milestones.close_tab" .MilestoneStats.ClosedCount}} | ||||||
|  | 					</a> | ||||||
|  | 				</div> | ||||||
|  | 				<div class="ui right floated secondary filter menu"> | ||||||
|  | 					<!-- Sort --> | ||||||
|  | 					<div class="ui dropdown type jump item"> | ||||||
|  | 						<span class="text"> | ||||||
|  | 							{{.i18n.Tr "repo.issues.filter_sort"}} | ||||||
|  | 							<i class="dropdown icon"></i> | ||||||
|  | 						</span> | ||||||
|  | 						<div class="menu"> | ||||||
|  | 							<a class="{{if or (eq .SortType "closestduedate") (not .SortType)}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=closestduedate&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.closest_due_date"}}</a> | ||||||
|  |               <a class="{{if eq .SortType "furthestduedate"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=furthestduedate&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.furthest_due_date"}}</a> | ||||||
|  |               <a class="{{if eq .SortType "leastcomplete"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastcomplete&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.least_complete"}}</a> | ||||||
|  |               <a class="{{if eq .SortType "mostcomplete"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostcomplete&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.most_complete"}}</a> | ||||||
|  |               <a class="{{if eq .SortType "mostissues"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostissues&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.most_issues"}}</a> | ||||||
|  |               <a class="{{if eq .SortType "leastissues"}}active{{end}} item" href="{{$.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastissues&state={{$.State}}">{{.i18n.Tr "repo.milestones.filter_sort.least_issues"}}</a> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  |                 <div class="milestone list"> | ||||||
|  |                     {{range .Milestones}} | ||||||
|  |                         <li class="item"> | ||||||
|  |                             <div class="ui label">{{if not $.RepoIDs}}{{.Repo.FullName}}{{end}}</div> | ||||||
|  |                             <i class="octicon octicon-milestone"></i> <a href="{{.Repo.Link }}/milestone/{{.ID}}">{{.Name}}</a> | ||||||
|  |                             <div class="ui right green progress" data-percent="{{.Completeness}}"> | ||||||
|  |                                 <div class="bar" {{if not .Completeness}}style="background-color: transparent"{{end}}> | ||||||
|  |                                     <div class="progress"></div> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                             <div class="meta"> | ||||||
|  |                                 {{ $closedDate:= TimeSinceUnix .ClosedDateUnix $.Lang }} | ||||||
|  |                                 {{if .IsClosed}} | ||||||
|  |                                     <span class="octicon octicon-clock"></span> {{$.i18n.Tr "repo.milestones.closed" $closedDate|Str2html}} | ||||||
|  |                                 {{else}} | ||||||
|  |                                     <span class="octicon octicon-calendar"></span> | ||||||
|  |                                     {{if .DeadlineString}} | ||||||
|  |                                         <span {{if .IsOverdue}}class="overdue"{{end}}>{{.DeadlineString}}</span> | ||||||
|  |                                     {{else}} | ||||||
|  |                                         {{$.i18n.Tr "repo.milestones.no_due_date"}} | ||||||
|  |                                     {{end}} | ||||||
|  |                                 {{end}} | ||||||
|  |                                 <span class="issue-stats"> | ||||||
|  |                                     <i class="octicon octicon-issue-opened"></i> {{$.i18n.Tr "repo.milestones.open_tab" .NumOpenIssues}} | ||||||
|  |                                     <i class="octicon octicon-issue-closed"></i> {{$.i18n.Tr "repo.milestones.close_tab" .NumClosedIssues}} | ||||||
|  |                                     {{if .TotalTrackedTime}}<i class="octicon octicon-clock"></i> {{.TotalTrackedTime|Sec2Time}}{{end}} | ||||||
|  |                                 </span> | ||||||
|  |                             </div> | ||||||
|  |                             {{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}} | ||||||
|  |                                 <div class="ui right operate"> | ||||||
|  |                                     <a href="{{$.Link}}/{{.ID}}/edit" data-id={{.ID}} data-title={{.Name}}><i class="octicon octicon-pencil"></i> {{$.i18n.Tr "repo.issues.label_edit"}}</a> | ||||||
|  |                                     {{if .IsClosed}} | ||||||
|  |                                         <a href="{{$.Link}}/{{.ID}}/open" data-id={{.ID}} data-title={{.Name}}><i class="octicon octicon-check"></i> {{$.i18n.Tr "repo.milestones.open"}}</a> | ||||||
|  |                                     {{else}} | ||||||
|  |                                         <a href="{{$.Link}}/{{.ID}}/close" data-id={{.ID}} data-title={{.Name}}><i class="octicon octicon-x"></i> {{$.i18n.Tr "repo.milestones.close"}}</a> | ||||||
|  |                                     {{end}} | ||||||
|  |                                     <a class="delete-button" href="#" data-url="{{$.RepoLink}}/milestones/delete" data-id="{{.ID}}"><i class="octicon octicon-trashcan"></i> {{$.i18n.Tr "repo.issues.label_delete"}}</a> | ||||||
|  |                                 </div> | ||||||
|  |                             {{end}} | ||||||
|  |                             {{if .Content}} | ||||||
|  |                                 <div class="content"> | ||||||
|  |                                     {{.RenderedContent|Str2html}} | ||||||
|  |                                 </div> | ||||||
|  |                             {{end}} | ||||||
|  |                         </li> | ||||||
|  |                     {{end}} | ||||||
|  |  | ||||||
|  |                     {{template "base/paginate" .}} | ||||||
|  |                 </div> | ||||||
|  |  | ||||||
|  | 			</div> | ||||||
|  | 		</div> | ||||||
|  | 	</div> | ||||||
|  | </div> | ||||||
|  | {{template "base/footer" .}} | ||||||
| @@ -12,12 +12,12 @@ | |||||||
| 						{{.i18n.Tr "home.switch_dashboard_context"}} | 						{{.i18n.Tr "home.switch_dashboard_context"}} | ||||||
| 					</div> | 					</div> | ||||||
| 					<div class="scrolling menu items"> | 					<div class="scrolling menu items"> | ||||||
| 						<a class="{{if eq .ContextUser.ID .SignedUser.ID}}active selected{{end}} item" href="{{AppSubUrl}}/{{if .PageIsIssues}}issues{{else if .PageIsPulls}}pulls{{end}}"> | 						<a class="{{if eq .ContextUser.ID .SignedUser.ID}}active selected{{end}} item" href="{{AppSubUrl}}/{{if .PageIsIssues}}issues{{else if .PageIsPulls}}pulls{{else if .PageIsMilestonesDashboard}}milestones{{end}}"> | ||||||
| 							<img class="ui avatar image" src="{{.SignedUser.RelAvatarLink}}"> | 							<img class="ui avatar image" src="{{.SignedUser.RelAvatarLink}}"> | ||||||
| 							{{.SignedUser.Name}} | 							{{.SignedUser.Name}} | ||||||
| 						</a> | 						</a> | ||||||
| 						{{range .Orgs}} | 						{{range .Orgs}} | ||||||
| 							<a class="{{if eq $.ContextUser.ID .ID}}active selected{{end}} item" title="{{.Name}}" href="{{AppSubUrl}}/org/{{.Name}}/{{if $.PageIsIssues}}issues{{else if $.PageIsPulls}}pulls{{else}}dashboard{{end}}"> | 							<a class="{{if eq $.ContextUser.ID .ID}}active selected{{end}} item" title="{{.Name}}" href="{{AppSubUrl}}/org/{{.Name}}/{{if $.PageIsIssues}}issues{{else if $.PageIsPulls}}pulls{{else if $.PageIsMilestonesDashboard}}milestones{{else}}dashboard{{end}}"> | ||||||
| 								<img class="ui avatar image" src="{{.RelAvatarLink}}"> | 								<img class="ui avatar image" src="{{.RelAvatarLink}}"> | ||||||
| 								{{.ShortName 20}} | 								{{.ShortName 20}} | ||||||
| 							</a> | 							</a> | ||||||
| @@ -43,6 +43,11 @@ | |||||||
| 				<a class="{{if .PageIsPulls}}active{{end}} item" href="{{AppSubUrl}}/org/{{.ContextUser.Name}}/pulls"> | 				<a class="{{if .PageIsPulls}}active{{end}} item" href="{{AppSubUrl}}/org/{{.ContextUser.Name}}/pulls"> | ||||||
| 					<i class="octicon octicon-git-pull-request"></i> {{.i18n.Tr "pull_requests"}} | 					<i class="octicon octicon-git-pull-request"></i> {{.i18n.Tr "pull_requests"}} | ||||||
| 				</a> | 				</a> | ||||||
|  | 				{{if .ShowMilestonesDashboardPage}} | ||||||
|  | 					<a class="{{if .PageIsMilestonesDashboard}}active{{end}} item" href="{{AppSubUrl}}/org/{{.ContextUser.Name}}/milestones"> | ||||||
|  | 						<i class="octicon octicon-milestone"></i> {{.i18n.Tr "milestones"}} | ||||||
|  | 					</a> | ||||||
|  | 				{{end}} | ||||||
| 				<div class="item"> | 				<div class="item"> | ||||||
| 					<a class="ui blue basic button" href="{{.ContextUser.HomeLink}}" title='{{.i18n.Tr "home.view_home" .ContextUser.Name}}'> | 					<a class="ui blue basic button" href="{{.ContextUser.HomeLink}}" title='{{.i18n.Tr "home.view_home" .ContextUser.Name}}'> | ||||||
| 						{{.i18n.Tr "home.view_home" (.ContextUser.ShortName 10)}} | 						{{.i18n.Tr "home.view_home" (.ContextUser.ShortName 10)}} | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user