Timotheus Pokorra 2025-12-10 19:28:50 +07:00 committed by GitHub
commit a984ec45d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 289 additions and 9 deletions

File diff suppressed because one or more lines are too long

@ -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
@ -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
@ -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
@ -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

@ -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=
@ -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=
@ -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=

@ -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

@ -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() {

@ -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() {

@ -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
}

@ -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>

@ -4,6 +4,7 @@
package integration
import (
"bytes"
"fmt"
"html/template"
"net/http"
@ -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 {
@ -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)()

@ -84,3 +84,28 @@
margin-right: 8px;
text-align: left;
}
/* used by the misc-actions popup */
.misc-actions-panel-field,
.misc-actions-list {
margin: 10px;
}
.misc-actions-tab .item {
padding: 5px 10px;
background: none;
color: var(--color-text-light-2);
}
.misc-actions-tab .item.active {
color: var(--color-text-dark);
border-bottom: 3px solid currentcolor;
}
.misc-actions-tab + .divider {
margin: -1px 0 0;
}
.misc-actions-panel-list .item {
margin: 5px 0;
}

@ -8,6 +8,7 @@ import {DELETE, POST} from '../modules/fetch.ts';
import {parseDom} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import type {SortableEvent} from 'sortablejs';
import {createTippy} from '../modules/tippy.ts';
function initRepoIssueListCheckboxes() {
const issueSelectAll = document.querySelector<HTMLInputElement>('.issue-checkbox-all');
@ -223,11 +224,28 @@ async function initIssuePinSort() {
});
}
function initMiscActionsButton(btn: HTMLButtonElement) {
const elPanel = btn.nextElementSibling!;
createTippy(btn, {
content: elPanel,
trigger: 'click',
placement: 'bottom-end',
interactive: true,
hideOnClick: true,
arrow: false,
});
}
async function initRepoIssueMiscActions() {
queryElems(document, '.js-btn-misc-actions', initMiscActionsButton);
}
export function initRepoIssueList() {
if (document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) {
initRepoIssueListCheckboxes();
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
initIssuePinSort();
initRepoIssueMiscActions();
} else if (document.querySelector('.page-content.dashboard.issues')) {
// user or org home: issue list, pull request list
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));