* Fix counts on issues dashboard * setupSess -> setupSession * Unit test * Load repo owners for issuestags/v1.21.12.1
| @@ -1057,6 +1057,7 @@ type IssuesOptions struct { | |||
| MilestoneID int64 | |||
| RepoIDs []int64 | |||
| Page int | |||
| PageSize int | |||
| IsClosed util.OptionalBool | |||
| IsPull util.OptionalBool | |||
| Labels string | |||
| @@ -1085,21 +1086,16 @@ func sortIssuesSession(sess *xorm.Session, sortType string) { | |||
| } | |||
| } | |||
| // Issues returns a list of issues by given conditions. | |||
| func Issues(opts *IssuesOptions) ([]*Issue, error) { | |||
| var sess *xorm.Session | |||
| if opts.Page >= 0 { | |||
| func (opts *IssuesOptions) setupSession(sess *xorm.Session) error { | |||
| if opts.Page >= 0 && opts.PageSize > 0 { | |||
| var start int | |||
| if opts.Page == 0 { | |||
| start = 0 | |||
| } else { | |||
| start = (opts.Page - 1) * setting.UI.IssuePagingNum | |||
| start = (opts.Page - 1) * opts.PageSize | |||
| } | |||
| sess = x.Limit(setting.UI.IssuePagingNum, start) | |||
| } else { | |||
| sess = x.NewSession() | |||
| sess.Limit(opts.PageSize, start) | |||
| } | |||
| defer sess.Close() | |||
| if len(opts.IssueIDs) > 0 { | |||
| sess.In("issue.id", opts.IssueIDs) | |||
| @@ -1144,12 +1140,10 @@ func Issues(opts *IssuesOptions) ([]*Issue, error) { | |||
| sess.And("issue.is_pull=?", false) | |||
| } | |||
| sortIssuesSession(sess, opts.SortType) | |||
| if len(opts.Labels) > 0 && opts.Labels != "0" { | |||
| labelIDs, err := base.StringsToInt64s(strings.Split(opts.Labels, ",")) | |||
| if err != nil { | |||
| return nil, err | |||
| return err | |||
| } | |||
| if len(labelIDs) > 0 { | |||
| sess. | |||
| @@ -1157,6 +1151,45 @@ func Issues(opts *IssuesOptions) ([]*Issue, error) { | |||
| In("issue_label.label_id", labelIDs) | |||
| } | |||
| } | |||
| return nil | |||
| } | |||
| // CountIssuesByRepo map from repoID to number of issues matching the options | |||
| func CountIssuesByRepo(opts *IssuesOptions) (map[int64]int64, error) { | |||
| sess := x.NewSession() | |||
| defer sess.Close() | |||
| if err := opts.setupSession(sess); err != nil { | |||
| return nil, err | |||
| } | |||
| countsSlice := make([]*struct { | |||
| RepoID int64 | |||
| Count int64 | |||
| }, 0, 10) | |||
| if err := sess.GroupBy("issue.repo_id"). | |||
| Select("issue.repo_id AS repo_id, COUNT(*) AS count"). | |||
| Table("issue"). | |||
| 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 | |||
| } | |||
| // Issues returns a list of issues by given conditions. | |||
| func Issues(opts *IssuesOptions) ([]*Issue, error) { | |||
| sess := x.NewSession() | |||
| defer sess.Close() | |||
| if err := opts.setupSession(sess); err != nil { | |||
| return nil, err | |||
| } | |||
| sortIssuesSession(sess, opts.SortType) | |||
| issues := make([]*Issue, 0, setting.UI.IssuePagingNum) | |||
| if err := sess.Find(&issues); err != nil { | |||
| @@ -133,7 +133,6 @@ func populateIssueIndexer() error { | |||
| RepoID: repo.ID, | |||
| IsClosed: util.OptionalBoolNone, | |||
| IsPull: util.OptionalBoolNone, | |||
| Page: -1, // do not page | |||
| }) | |||
| if err != nil { | |||
| return fmt.Errorf("Issues: %v", err) | |||
| @@ -8,11 +8,8 @@ import ( | |||
| "code.gitea.io/gitea/modules/setting" | |||
| "github.com/go-xorm/core" | |||
| "github.com/go-xorm/xorm" | |||
| _ "github.com/mattn/go-sqlite3" // for the test engine | |||
| "github.com/stretchr/testify/assert" | |||
| "gopkg.in/testfixtures.v2" | |||
| ) | |||
| // TestFixturesAreConsistent assert that test fixtures are consistent | |||
| @@ -21,23 +18,8 @@ func TestFixturesAreConsistent(t *testing.T) { | |||
| CheckConsistencyForAll(t) | |||
| } | |||
| // CreateTestEngine create an xorm engine for testing | |||
| func CreateTestEngine() error { | |||
| var err error | |||
| x, err = xorm.NewEngine("sqlite3", "file::memory:?cache=shared") | |||
| if err != nil { | |||
| return err | |||
| } | |||
| x.SetMapper(core.GonicMapper{}) | |||
| if err = x.StoreEngine("InnoDB").Sync2(tables...); err != nil { | |||
| return err | |||
| } | |||
| return InitFixtures(&testfixtures.SQLite{}, "fixtures/") | |||
| } | |||
| func TestMain(m *testing.M) { | |||
| if err := CreateTestEngine(); err != nil { | |||
| if err := CreateTestEngine("fixtures/"); err != nil { | |||
| fmt.Printf("Error creating test engine: %v\n", err) | |||
| os.Exit(1) | |||
| } | |||
| @@ -15,6 +15,11 @@ import ( | |||
| // RepositoryList contains a list of repositories | |||
| type RepositoryList []*Repository | |||
| // RepositoryListOfMap make list from values of map | |||
| func RepositoryListOfMap(repoMap map[int64]*Repository) RepositoryList { | |||
| return RepositoryList(valuesRepository(repoMap)) | |||
| } | |||
| func (repos RepositoryList) loadAttributes(e Engine) error { | |||
| if len(repos) == 0 { | |||
| return nil | |||
| @@ -7,13 +7,31 @@ package models | |||
| import ( | |||
| "testing" | |||
| "github.com/go-xorm/core" | |||
| "github.com/go-xorm/xorm" | |||
| "github.com/stretchr/testify/assert" | |||
| "gopkg.in/testfixtures.v2" | |||
| ) | |||
| // NonexistentID an ID that will never exist | |||
| const NonexistentID = 9223372036854775807 | |||
| // CreateTestEngine create in-memory sqlite database for unit tests | |||
| // Any package that calls this must import github.com/mattn/go-sqlite3 | |||
| func CreateTestEngine(fixturesDir string) error { | |||
| var err error | |||
| x, err = xorm.NewEngine("sqlite3", "file::memory:?cache=shared") | |||
| if err != nil { | |||
| return err | |||
| } | |||
| x.SetMapper(core.GonicMapper{}) | |||
| if err = x.StoreEngine("InnoDB").Sync2(tables...); err != nil { | |||
| return err | |||
| } | |||
| return InitFixtures(&testfixtures.SQLite{}, fixturesDir) | |||
| } | |||
| // PrepareTestDatabase load test fixtures into test database | |||
| func PrepareTestDatabase() error { | |||
| return LoadFixtures() | |||
| @@ -0,0 +1,150 @@ | |||
| // Copyright 2017 The Gitea Authors. All rights reserved. | |||
| // Use of this source code is governed by a MIT-style | |||
| // license that can be found in the LICENSE file. | |||
| package test | |||
| import ( | |||
| "net/http" | |||
| "net/url" | |||
| "testing" | |||
| "code.gitea.io/gitea/modules/context" | |||
| "github.com/stretchr/testify/assert" | |||
| macaron "gopkg.in/macaron.v1" | |||
| ) | |||
| // MockContext mock context for unit tests | |||
| func MockContext(t *testing.T) *context.Context { | |||
| var macaronContext *macaron.Context | |||
| mac := macaron.New() | |||
| mac.Get("*/", func(ctx *macaron.Context) { | |||
| macaronContext = ctx | |||
| }) | |||
| req, err := http.NewRequest("GET", "star", nil) | |||
| assert.NoError(t, err) | |||
| req.Form = url.Values{} | |||
| mac.ServeHTTP(&mockResponseWriter{}, req) | |||
| assert.NotNil(t, macaronContext) | |||
| assert.EqualValues(t, req, macaronContext.Req.Request) | |||
| macaronContext.Locale = &mockLocale{} | |||
| macaronContext.Resp = &mockResponseWriter{} | |||
| macaronContext.Render = &mockRender{ResponseWriter: macaronContext.Resp} | |||
| return &context.Context{ | |||
| Context: macaronContext, | |||
| } | |||
| } | |||
| type mockLocale struct{} | |||
| func (l mockLocale) Language() string { | |||
| return "en" | |||
| } | |||
| func (l mockLocale) Tr(s string, _ ...interface{}) string { | |||
| return "test translation" | |||
| } | |||
| type mockResponseWriter struct { | |||
| status int | |||
| size int | |||
| } | |||
| func (rw *mockResponseWriter) Header() http.Header { | |||
| return map[string][]string{} | |||
| } | |||
| func (rw *mockResponseWriter) Write(b []byte) (int, error) { | |||
| rw.size += len(b) | |||
| return len(b), nil | |||
| } | |||
| func (rw *mockResponseWriter) WriteHeader(status int) { | |||
| rw.status = status | |||
| } | |||
| func (rw *mockResponseWriter) Flush() { | |||
| } | |||
| func (rw *mockResponseWriter) Status() int { | |||
| return rw.status | |||
| } | |||
| func (rw *mockResponseWriter) Written() bool { | |||
| return rw.status > 0 | |||
| } | |||
| func (rw *mockResponseWriter) Size() int { | |||
| return rw.size | |||
| } | |||
| func (rw *mockResponseWriter) Before(b macaron.BeforeFunc) { | |||
| b(rw) | |||
| } | |||
| type mockRender struct { | |||
| http.ResponseWriter | |||
| } | |||
| func (tr *mockRender) SetResponseWriter(rw http.ResponseWriter) { | |||
| tr.ResponseWriter = rw | |||
| } | |||
| func (tr *mockRender) JSON(int, interface{}) { | |||
| } | |||
| func (tr *mockRender) JSONString(interface{}) (string, error) { | |||
| return "", nil | |||
| } | |||
| func (tr *mockRender) RawData(status int, _ []byte) { | |||
| tr.Status(status) | |||
| } | |||
| func (tr *mockRender) PlainText(status int, _ []byte) { | |||
| tr.Status(status) | |||
| } | |||
| func (tr *mockRender) HTML(status int, _ string, _ interface{}, _ ...macaron.HTMLOptions) { | |||
| tr.Status(status) | |||
| } | |||
| func (tr *mockRender) HTMLSet(status int, _ string, _ string, _ interface{}, _ ...macaron.HTMLOptions) { | |||
| tr.Status(status) | |||
| } | |||
| func (tr *mockRender) HTMLSetString(string, string, interface{}, ...macaron.HTMLOptions) (string, error) { | |||
| return "", nil | |||
| } | |||
| func (tr *mockRender) HTMLString(string, interface{}, ...macaron.HTMLOptions) (string, error) { | |||
| return "", nil | |||
| } | |||
| func (tr *mockRender) HTMLSetBytes(string, string, interface{}, ...macaron.HTMLOptions) ([]byte, error) { | |||
| return nil, nil | |||
| } | |||
| func (tr *mockRender) HTMLBytes(string, interface{}, ...macaron.HTMLOptions) ([]byte, error) { | |||
| return nil, nil | |||
| } | |||
| func (tr *mockRender) XML(status int, _ interface{}) { | |||
| tr.Status(status) | |||
| } | |||
| func (tr *mockRender) Error(status int, _ ...string) { | |||
| tr.Status(status) | |||
| } | |||
| func (tr *mockRender) Status(status int) { | |||
| tr.ResponseWriter.WriteHeader(status) | |||
| } | |||
| func (tr *mockRender) SetTemplatePath(string, string) { | |||
| } | |||
| func (tr *mockRender) HasTemplateSet(string) bool { | |||
| return true | |||
| } | |||
| @@ -31,6 +31,7 @@ func ListIssues(ctx *context.APIContext) { | |||
| issues, err := models.Issues(&models.IssuesOptions{ | |||
| RepoID: ctx.Repo.Repository.ID, | |||
| Page: ctx.QueryInt("page"), | |||
| PageSize: setting.UI.IssuePagingNum, | |||
| IsClosed: isClosed, | |||
| }) | |||
| if err != nil { | |||
| @@ -193,6 +193,7 @@ func Issues(ctx *context.Context) { | |||
| MentionedID: mentionedID, | |||
| MilestoneID: milestoneID, | |||
| Page: pager.Current(), | |||
| PageSize: setting.UI.IssuePagingNum, | |||
| IsClosed: util.OptionalBoolOf(isShowClosed), | |||
| IsPull: util.OptionalBoolOf(isPullList), | |||
| Labels: selectLabels, | |||
| @@ -270,94 +270,77 @@ func Issues(ctx *context.Context) { | |||
| userRepoIDs = []int64{-1} | |||
| } | |||
| var issues []*models.Issue | |||
| opts := &models.IssuesOptions{ | |||
| RepoID: repoID, | |||
| IsClosed: util.OptionalBoolOf(isShowClosed), | |||
| IsPull: util.OptionalBoolOf(isPullList), | |||
| SortType: sortType, | |||
| } | |||
| switch filterMode { | |||
| case models.FilterModeAll: | |||
| // Get all issues from repositories from this user. | |||
| issues, err = models.Issues(&models.IssuesOptions{ | |||
| RepoIDs: userRepoIDs, | |||
| RepoID: repoID, | |||
| Page: page, | |||
| IsClosed: util.OptionalBoolOf(isShowClosed), | |||
| IsPull: util.OptionalBoolOf(isPullList), | |||
| SortType: sortType, | |||
| }) | |||
| opts.RepoIDs = userRepoIDs | |||
| case models.FilterModeAssign: | |||
| // Get all issues assigned to this user. | |||
| issues, err = models.Issues(&models.IssuesOptions{ | |||
| RepoID: repoID, | |||
| AssigneeID: ctxUser.ID, | |||
| Page: page, | |||
| IsClosed: util.OptionalBoolOf(isShowClosed), | |||
| IsPull: util.OptionalBoolOf(isPullList), | |||
| SortType: sortType, | |||
| }) | |||
| opts.AssigneeID = ctxUser.ID | |||
| case models.FilterModeCreate: | |||
| // Get all issues created by this user. | |||
| issues, err = models.Issues(&models.IssuesOptions{ | |||
| RepoID: repoID, | |||
| PosterID: ctxUser.ID, | |||
| Page: page, | |||
| IsClosed: util.OptionalBoolOf(isShowClosed), | |||
| IsPull: util.OptionalBoolOf(isPullList), | |||
| SortType: sortType, | |||
| }) | |||
| opts.PosterID = ctxUser.ID | |||
| case models.FilterModeMention: | |||
| // Get all issues created by this user. | |||
| issues, err = models.Issues(&models.IssuesOptions{ | |||
| RepoID: repoID, | |||
| MentionedID: ctxUser.ID, | |||
| Page: page, | |||
| IsClosed: util.OptionalBoolOf(isShowClosed), | |||
| IsPull: util.OptionalBoolOf(isPullList), | |||
| SortType: sortType, | |||
| }) | |||
| opts.MentionedID = ctxUser.ID | |||
| } | |||
| counts, err := models.CountIssuesByRepo(opts) | |||
| if err != nil { | |||
| ctx.Handle(500, "Issues", err) | |||
| ctx.Handle(500, "CountIssuesByRepo", err) | |||
| return | |||
| } | |||
| showRepos, err := models.IssueList(issues).LoadRepositories() | |||
| opts.Page = page | |||
| opts.PageSize = setting.UI.IssuePagingNum | |||
| issues, err := models.Issues(opts) | |||
| if err != nil { | |||
| ctx.Handle(500, "LoadRepositories", fmt.Errorf("%v", err)) | |||
| ctx.Handle(500, "Issues", err) | |||
| return | |||
| } | |||
| if repoID > 0 { | |||
| var theRepo *models.Repository | |||
| for _, repo := range showRepos { | |||
| if repo.ID == repoID { | |||
| theRepo = repo | |||
| break | |||
| } | |||
| showReposMap := make(map[int64]*models.Repository, len(counts)) | |||
| for repoID := range counts { | |||
| repo, err := models.GetRepositoryByID(repoID) | |||
| if err != nil { | |||
| ctx.Handle(500, "GetRepositoryByID", err) | |||
| return | |||
| } | |||
| showReposMap[repoID] = repo | |||
| } | |||
| if theRepo == nil { | |||
| theRepo, err = models.GetRepositoryByID(repoID) | |||
| if repoID > 0 { | |||
| if _, ok := showReposMap[repoID]; !ok { | |||
| repo, err := models.GetRepositoryByID(repoID) | |||
| if err != nil { | |||
| ctx.Handle(500, "GetRepositoryByID", fmt.Errorf("[#%d]%v", repoID, err)) | |||
| ctx.Handle(500, "GetRepositoryByID", fmt.Errorf("[%d]%v", repoID, err)) | |||
| return | |||
| } | |||
| showRepos = append(showRepos, theRepo) | |||
| showReposMap[repoID] = repo | |||
| } | |||
| repo := showReposMap[repoID] | |||
| // Check if user has access to given repository. | |||
| if !theRepo.IsOwnedBy(ctxUser.ID) && !theRepo.HasAccess(ctxUser) { | |||
| ctx.Handle(404, "Issues", fmt.Errorf("#%d", repoID)) | |||
| if !repo.IsOwnedBy(ctxUser.ID) && !repo.HasAccess(ctxUser) { | |||
| ctx.Status(404) | |||
| return | |||
| } | |||
| } | |||
| err = models.RepositoryList(showRepos).LoadAttributes() | |||
| if err != nil { | |||
| showRepos := models.RepositoryListOfMap(showReposMap) | |||
| if err = showRepos.LoadAttributes(); err != nil { | |||
| ctx.Handle(500, "LoadAttributes", fmt.Errorf("%v", err)) | |||
| return | |||
| } | |||
| for _, issue := range issues { | |||
| issue.Repo = showReposMap[issue.RepoID] | |||
| } | |||
| issueStats := models.GetUserIssueStats(repoID, ctxUser.ID, userRepoIDs, filterMode, isPullList) | |||
| var total int | |||
| @@ -369,6 +352,7 @@ func Issues(ctx *context.Context) { | |||
| ctx.Data["Issues"] = issues | |||
| ctx.Data["Repos"] = showRepos | |||
| ctx.Data["Counts"] = counts | |||
| ctx.Data["Page"] = paginater.New(total, setting.UI.IssuePagingNum, page, 5) | |||
| ctx.Data["IssueStats"] = issueStats | |||
| ctx.Data["ViewType"] = viewType | |||
| @@ -0,0 +1,33 @@ | |||
| // Copyright 2017 The Gitea Authors. All rights reserved. | |||
| // Use of this source code is governed by a MIT-style | |||
| // license that can be found in the LICENSE file. | |||
| package user | |||
| import ( | |||
| "net/http" | |||
| "testing" | |||
| "code.gitea.io/gitea/models" | |||
| "code.gitea.io/gitea/modules/test" | |||
| "code.gitea.io/gitea/modules/setting" | |||
| "github.com/stretchr/testify/assert" | |||
| ) | |||
| func TestIssues(t *testing.T) { | |||
| setting.UI.IssuePagingNum = 1 | |||
| assert.NoError(t, models.LoadFixtures()) | |||
| ctx := test.MockContext(t) | |||
| ctx.User = models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) | |||
| ctx.SetParams(":type", "issues") | |||
| ctx.Req.Form.Set("state", "closed") | |||
| Issues(ctx) | |||
| assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) | |||
| assert.EqualValues(t, map[int64]int64{1: 1, 2: 1}, ctx.Data["Counts"]) | |||
| assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) | |||
| assert.Len(t, ctx.Data["Issues"], 1) | |||
| assert.Len(t, ctx.Data["Repos"], 2) | |||
| } | |||
| @@ -0,0 +1,33 @@ | |||
| // Copyright 2017 The Gitea Authors. All rights reserved. | |||
| // Use of this source code is governed by a MIT-style | |||
| // license that can be found in the LICENSE file. | |||
| package user | |||
| import ( | |||
| "fmt" | |||
| "os" | |||
| "path/filepath" | |||
| "testing" | |||
| "code.gitea.io/gitea/models" | |||
| "code.gitea.io/gitea/modules/setting" | |||
| _ "github.com/mattn/go-sqlite3" // for the test engine | |||
| ) | |||
| func TestMain(m *testing.M) { | |||
| if err := models.CreateTestEngine("../../models/fixtures/"); err != nil { | |||
| fmt.Printf("Error creating test engine: %v\n", err) | |||
| os.Exit(1) | |||
| } | |||
| setting.AppURL = "https://try.gitea.io/" | |||
| setting.RunUser = "runuser" | |||
| setting.SSH.Port = 3000 | |||
| setting.SSH.Domain = "try.gitea.io" | |||
| setting.RepoRootPath = filepath.Join(os.TempDir(), "repos") | |||
| setting.AppDataPath = filepath.Join(os.TempDir(), "appdata") | |||
| os.Exit(m.Run()) | |||
| } | |||
| @@ -23,7 +23,7 @@ | |||
| {{range .Repos}} | |||
| <a class="{{if eq $.RepoID .ID}}ui basic blue button{{end}} repo name item" href="{{$.Link}}?type={{$.ViewType}}{{if not (eq $.RepoID .ID)}}&repo={{.ID}}{{end}}&sort={{$.SortType}}&state={{$.State}}"> | |||
| <span class="text truncate">{{.FullName}}</span> | |||
| <div class="floating ui {{if $.IsShowClosed}}red{{else}}green{{end}} label">{{if $.IsShowClosed}}{{if $.PageIsPulls}}{{.NumClosedPulls}}{{else}}{{.NumClosedIssues}}{{end}}{{else}}{{if $.PageIsPulls}}{{.NumOpenPulls}}{{else}}{{.NumOpenIssues}}{{end}}{{end}}</div> | |||
| <div class="floating ui {{if $.IsShowClosed}}red{{else}}green{{end}} label">{{index $.Counts .ID}}</div> | |||
| </a> | |||
| {{end}} | |||
| </div> | |||