Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions assets/go-licenses.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ require (
github.com/urfave/cli/v3 v3.4.1
github.com/wneessen/go-mail v0.7.2
github.com/xeipuuv/gojsonschema v1.2.0
github.com/xuri/excelize/v2 v2.10.0
github.com/yohcop/openid-go v1.0.1
github.com/yuin/goldmark v1.7.13
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
Expand Down Expand Up @@ -256,6 +257,8 @@ require (
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/rhysd/actionlint v1.7.7 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
Expand All @@ -264,6 +267,7 @@ require (
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/tiendc/go-deepcopy v1.7.1 // indirect
github.com/tinylib/msgp v1.4.0 // indirect
github.com/unknwon/com v1.0.1 // indirect
github.com/valyala/fastjson v1.6.4 // indirect
Expand All @@ -272,6 +276,8 @@ require (
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/zeebo/assert v1.3.0 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
Expand Down
13 changes: 13 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,11 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6O
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rhysd/actionlint v1.7.7 h1:0KgkoNTrYY7vmOCs9BW2AHxLvvpoY9nEUzgBHiPUr0k=
github.com/rhysd/actionlint v1.7.7/go.mod h1:AE6I6vJEkNaIfWqC2GNE5spIJNhxf8NCtLEKU4NnUXg=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
Expand Down Expand Up @@ -750,6 +755,8 @@ github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08Yu
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
Expand Down Expand Up @@ -784,6 +791,12 @@ github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQ
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yohcop/openid-go v1.0.1 h1:DPRd3iPO5F6O5zX2e62XpVAbPT6wV51cuucH0z9g3js=
Expand Down
1 change: 1 addition & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3565,6 +3565,7 @@ review_dismissed_reason = Reason:
create_branch = created branch <a href="%[2]s">%[3]s</a> in <a href="%[1]s">%[4]s</a>
starred_repo = starred <a href="%[1]s">%[2]s</a>
watched_repo = started watching <a href="%[1]s">%[2]s</a>
export_to_excel = Export to Excel

[tool]
now = now
Expand Down
39 changes: 30 additions & 9 deletions routers/web/repo/issue_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
"code.gitea.io/gitea/services/export"
issue_service "code.gitea.io/gitea/services/issue"
pull_service "code.gitea.io/gitea/services/pull"
)
Expand Down Expand Up @@ -258,14 +259,13 @@ func getUserIDForFilter(ctx *context.Context, queryName string) int64 {
return user.ID
}

// SearchRepoIssuesJSON lists the issues of a repository
// This function was copied from API (decouple the web and API routes),
// it is only used by frontend to search some dependency or related issues
func SearchRepoIssuesJSON(ctx *context.Context) {
func SearchRepoIssues(ctx *context.Context) (issues_model.IssueList, int64) {
before, since, err := context.GetQueryBeforeSince(ctx.Base)
if err != nil {
ctx.HTTPError(http.StatusUnprocessableEntity, err.Error())
return
return nil, 0
}

var isClosed optional.Option[bool]
Expand Down Expand Up @@ -295,7 +295,7 @@ func SearchRepoIssuesJSON(ctx *context.Context) {
}
if !issues_model.IsErrMilestoneNotExist(err) {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
return
return nil, 0
}
id, err := strconv.ParseInt(part[i], 10, 64)
if err != nil {
Expand Down Expand Up @@ -329,15 +329,15 @@ func SearchRepoIssuesJSON(ctx *context.Context) {
// FIXME: we should be more efficient here
createdByID := getUserIDForFilter(ctx, "created_by")
if ctx.Written() {
return
return nil, 0
}
assignedByID := getUserIDForFilter(ctx, "assigned_by")
if ctx.Written() {
return
return nil, 0
}
mentionedByID := getUserIDForFilter(ctx, "mentioned_by")
if ctx.Written() {
return
return nil, 0
}

searchOpt := &issue_indexer.SearchOptions{
Expand Down Expand Up @@ -380,18 +380,39 @@ func SearchRepoIssuesJSON(ctx *context.Context) {
ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "SearchIssues", err.Error())
return
return nil, 0
}
issues, err := issues_model.GetIssuesByIDs(ctx, ids, true)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "FindIssuesByIDs", err.Error())
return
return nil, 0
}

return issues, total
}

// SearchRepoIssuesJSON lists the issues of a repository
func SearchRepoIssuesJSON(ctx *context.Context) {
issues, total := SearchRepoIssues(ctx)

ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, ctx.Doer, issues))
}

func ExportIssues(ctx *context.Context) {
issues, total := SearchRepoIssues(ctx)

if total == 0 {
return
}

f := export.IssuesToExcel(ctx, issues)

ctx.Resp.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
ctx.Resp.Header().Set("Content-Disposition", `attachment; filename="issues.xlsx"`)
_ = f.Write(ctx.Resp)
}

func BatchDeleteIssues(ctx *context.Context) {
issues := getActionIssues(ctx)
if ctx.Written() {
Expand Down
1 change: 1 addition & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,7 @@ func registerWebRoutes(m *web.Router) {
m.Get("/choose", repo.NewIssueChooseTemplate)
})
m.Get("/search", repo.SearchRepoIssuesJSON)
m.Get("/export", reqRepoAdmin, repo.ExportIssues)
}, reqUnitIssuesReader)

addIssuesPullsUpdateRoutes := func() {
Expand Down
94 changes: 94 additions & 0 deletions services/export/excel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package export

import (
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/services/context"

"github.com/xuri/excelize/v2"
)

func IssuesToExcel(ctx *context.Context, issues issues_model.IssueList) *excelize.File {
f := excelize.NewFile()
sw, err := f.NewStreamWriter("Sheet1")
if err != nil {
log.Error("cannot open stream writer for Sheet1: %v", err)
return f
}
// print headers
cell, err := excelize.CoordinatesToCellName(1, 1)
if err != nil {
log.Error("cannot get first cell: %v", err)
return f
}
err = sw.SetRow(cell, []any{
excelize.Cell{Value: "ID"},
excelize.Cell{Value: "Title"},
excelize.Cell{Value: "Status"},
excelize.Cell{Value: "Assignee(s)"},
excelize.Cell{Value: "Label(s)"},
excelize.Cell{Value: "Created At"},
})
if err != nil {
log.Error("cannot SetRow for header: %v", err)
return f
}

// built-in format ID 22 ("m/d/yy h:mm")
datetimeStyleID, err := f.NewStyle(&excelize.Style{NumFmt: 22})
if err != nil {
log.Error("cannot set new style NumFmt: %v", err)
return f
}

for i, issue := range issues {
assignees := ""
if err := issue.LoadAssignees(ctx); err == nil {
if len(issue.Assignees) > 0 {
for _, assignee := range issue.Assignees {
if assignees != "" {
assignees += ", "
}
if assignee.FullName != "" {
assignees += assignee.FullName
} else {
assignees += assignee.Name
}
}
}
}

labels := ""
if err := issue.LoadLabels(ctx); err == nil {
if len(issue.Labels) > 0 {
for _, label := range issue.Labels {
if labels != "" {
labels += ", "
}
labels += label.Name
}
}
}

cell, _ := excelize.CoordinatesToCellName(1, i+2)
err = sw.SetRow(cell, []any{
excelize.Cell{Value: issue.Index},
excelize.Cell{Value: issue.Title},
excelize.Cell{Value: issue.State()},
excelize.Cell{Value: assignees},
excelize.Cell{Value: labels},
excelize.Cell{StyleID: datetimeStyleID, Value: issue.CreatedUnix.AsTime()},
})
if err != nil {
log.Error("cannot SetRow: %v", err)
return f
}
}

sw.Flush()

return f
}
13 changes: 13 additions & 0 deletions templates/repo/issue/filters.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@
{{else}}
{{template "repo/issue/filter_list" .}}
{{end}}

<button class="ui compact secondary button js-btn-misc-actions">
</button>

<div class="misc-actions-popup tippy-target">
<div class="flex-items-block misc-actions-panel-field">
<a class="item muted" href="{{$.RepoLink}}/issues/export?{{$.Page.GetParams}}">
{{svg "gitea-double-chevron-left" 16 "tw-mr-2"}}
{{ctx.Locale.Tr "action.export_to_excel"}}</a>
</div>
</div>

</div>
</div>
</div>
58 changes: 58 additions & 0 deletions tests/integration/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package integration

import (
"bytes"
"fmt"
"html/template"
"net/http"
Expand All @@ -29,6 +30,7 @@ import (

"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"github.com/xuri/excelize/v2"
)

func getIssuesSelection(t testing.TB, htmlDoc *HTMLDoc) *goquery.Selection {
Expand Down Expand Up @@ -371,6 +373,62 @@ func TestIssueReaction(t *testing.T) {
session.MakeRequest(t, req, http.StatusOK)
}

func TestIssueListExport(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
_ = testNewIssue(t, session, "user2", "repo1", "Title1", "Description1")
_ = testNewIssue(t, session, "user2", "repo1", "Title2", "Description2")
_ = testNewIssue(t, session, "user2", "repo1", "Title3", "Description3")

// trying to export all open issues of the given repository
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/issues/export?%s", "user2", "repo1", "type=all&state=open"))
resp := session.MakeRequest(t, req, http.StatusOK)

// Content-Type should be an Excel file (XLSX)
ct := strings.Split(resp.Header().Get("Content-Type"), ";")[0]
assert.Equal(t, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ct)

// Content-Disposition should indicate attachment with .xlsx
cd := resp.Header().Get("Content-Disposition")
assert.Contains(t, cd, "attachment")
assert.Contains(t, cd, ".xlsx")

// open bytes as XLSX with excelize
data := resp.Body.Bytes()
f, err := excelize.OpenReader(bytes.NewReader(data))
assert.NoError(t, err)
defer func() { _ = f.Close() }()

// get first sheet and rows
sheets := f.GetSheetList()
assert.NotEmpty(t, sheets)
rows, err := f.GetRows(sheets[0])
assert.NoError(t, err)
// there should be at least a header row + our three issues
assert.GreaterOrEqual(t, len(rows), 4)

// ensure header has some cells and that our created titles appear somewhere
foundTitle1, foundTitle2, foundTitle3 := false, false, false
if len(rows) > 0 {
for _, row := range rows {
for _, cell := range row {
if strings.Contains(cell, "Title1") {
foundTitle1 = true
}
if strings.Contains(cell, "Title2") {
foundTitle2 = true
}
if strings.Contains(cell, "Title3") {
foundTitle3 = true
}
}
}
}
assert.True(t, foundTitle1, "Exported XLSX should contain Title1")
assert.True(t, foundTitle2, "Exported XLSX should contain Title2")
assert.True(t, foundTitle3, "Exported XLSX should contain Title3")
}

func TestIssueCrossReference(t *testing.T) {
defer tests.PrepareTestEnv(t)()

Expand Down
Loading