diff --git a/go.mod b/go.mod
index 03fc2ae4bfe5c..869d0f74138e5 100644
--- a/go.mod
+++ b/go.mod
@@ -245,12 +245,15 @@ require (
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.1 // 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
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
+ github.com/tiendc/go-deepcopy v1.6.0 // indirect
github.com/unknwon/com v1.0.1 // indirect
github.com/valyala/fastjson v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
@@ -258,6 +261,9 @@ 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/excelize/v2 v2.9.1 // indirect
+ github.com/xuri/nfp v0.0.1 // indirect
github.com/zeebo/assert v1.3.0 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
diff --git a/go.sum b/go.sum
index b912466eb095c..a8fa3efe02c5e 100644
--- a/go.sum
+++ b/go.sum
@@ -614,6 +614,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=
@@ -678,6 +683,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.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo=
+github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tstranex/u2f v1.0.0 h1:HhJkSzDDlVSVIVt7pDJwCHQj67k7A5EeBgPmeD+pVsQ=
github.com/tstranex/u2f v1.0.0/go.mod h1:eahSLaqAS0zsIEv80+vXT7WanXs7MQQDg3j3wGBSayo=
@@ -711,6 +718,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.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
+github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
+github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
+github.com/xuri/nfp v0.0.1/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=
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index d7e73a0cfbb08..2f1e8bc064226 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -3539,6 +3539,7 @@ review_dismissed_reason = Reason:
create_branch = created branch %[3]s in %[4]s
starred_repo = starred %[2]s
watched_repo = started watching %[2]s
+export_to_excel = Export to Excel
[tool]
now = now
diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go
index fd34422cfcc65..5fef0f7d03d08 100644
--- a/routers/web/repo/issue_list.go
+++ b/routers/web/repo/issue_list.go
@@ -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"
)
@@ -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]
@@ -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 {
@@ -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{
@@ -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() {
diff --git a/routers/web/web.go b/routers/web/web.go
index 09be0c39045e0..33279f14ea12d 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1237,6 +1237,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() {
diff --git a/services/export/excel.go b/services/export/excel.go
new file mode 100644
index 0000000000000..4e4f1ab5b977f
--- /dev/null
+++ b/services/export/excel.go
@@ -0,0 +1,62 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package export
+
+import (
+ "fmt"
+ "github.com/xuri/excelize/v2"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/services/context"
+)
+
+func IssuesToExcel(ctx *context.Context, issues issues_model.IssueList) *excelize.File {
+ f := excelize.NewFile()
+ sheet := f.GetSheetName(f.GetActiveSheetIndex())
+
+ headers := []string{"ID", "Title", "Status", "Assignee(s)", "Label(s)", "Created At"}
+ for col, h := range headers {
+ cell, _ := excelize.CoordinatesToCellName(col+1, 1)
+ f.SetCellValue(sheet, cell, h)
+ }
+
+ 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
+ }
+ }
+ }
+
+ f.SetCellValue(sheet, fmt.Sprintf("A%d", i+2), issue.Index)
+ f.SetCellValue(sheet, fmt.Sprintf("B%d", i+2), issue.Title)
+ f.SetCellValue(sheet, fmt.Sprintf("C%d", i+2), issue.State())
+ f.SetCellValue(sheet, fmt.Sprintf("D%d", i+2), assignees)
+ f.SetCellValue(sheet, fmt.Sprintf("E%d", i+2), labels)
+ f.SetCellValue(sheet, fmt.Sprintf("F%d", i+2), issue.CreatedUnix.AsTime()) // .Format("2006-01-02"))
+ }
+ return f
+}
\ No newline at end of file
diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl
index 1fe220e1b8b80..243d0372ccaea 100644
--- a/templates/repo/issue/list.tmpl
+++ b/templates/repo/issue/list.tmpl
@@ -31,6 +31,7 @@
{{ctx.Locale.Tr "action.compare_commits_general"}}
{{end}}
{{end}}
+ {{ctx.Locale.Tr "action.export_to_excel"}}
{{template "repo/issue/filters" .}}