From 1d8705679dff8e447083bd2755b2d9af6e4aa9e0 Mon Sep 17 00:00:00 2001 From: Timotheus Pokorra Date: Mon, 18 Aug 2025 23:24:39 +0200 Subject: [PATCH] add button to export issues to Excel --- go.mod | 6 ++++ go.sum | 13 +++++++ options/locale/locale_en-US.ini | 1 + routers/web/repo/issue_list.go | 39 ++++++++++++++++----- routers/web/web.go | 1 + services/export/excel.go | 62 +++++++++++++++++++++++++++++++++ templates/repo/issue/list.tmpl | 1 + 7 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 services/export/excel.go 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" .}}