Skip to content

Commit 15aa81e

Browse files
committed
Merge remote-tracking branch 'giteaofficial/main'
* giteaofficial/main: fix: generate IDs for HTML headings without id attribute (go-gitea#36233) Add 'allow_maintainer_edit' API option for creating a pull request (go-gitea#36283) fix: prevent panic when GitLab release has more links than sources (go-gitea#36295)
2 parents d228c9a + f9d3983 commit 15aa81e

14 files changed

Lines changed: 199 additions & 13 deletions

File tree

models/renderhelper/repo_file.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,6 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository,
7070
"repo": helper.opts.DeprecatedRepoName,
7171
})
7272
}
73-
rctx = rctx.WithHelper(helper)
73+
rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true)
7474
return rctx
7575
}

models/renderhelper/repo_wiki.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository,
7171
"markupAllowShortIssuePattern": "true",
7272
})
7373
}
74-
rctx = rctx.WithHelper(helper)
74+
rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true)
7575
helper.ctx = rctx
7676
return rctx
7777
}

modules/markup/html.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
314314
return node.NextSibling
315315
}
316316

317-
processNodeAttrID(node)
317+
processNodeAttrID(ctx, node)
318318
processFootnoteNode(ctx, node) // FIXME: the footnote processing should be done in the "footnote.go" renderer directly
319319

320320
if isEmojiNode(node) {

modules/markup/html_node.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ package markup
66
import (
77
"strings"
88

9+
"code.gitea.io/gitea/modules/markup/common"
10+
911
"golang.org/x/net/html"
1012
)
1113

@@ -23,16 +25,57 @@ func isAnchorHrefFootnote(s string) bool {
2325
return strings.HasPrefix(s, "#fnref:user-content-") || strings.HasPrefix(s, "#fn:user-content-")
2426
}
2527

26-
func processNodeAttrID(node *html.Node) {
28+
// isHeadingTag returns true if the node is a heading tag (h1-h6)
29+
func isHeadingTag(node *html.Node) bool {
30+
return node.Type == html.ElementNode &&
31+
len(node.Data) == 2 &&
32+
node.Data[0] == 'h' &&
33+
node.Data[1] >= '1' && node.Data[1] <= '6'
34+
}
35+
36+
// getNodeText extracts the text content from a node and its children
37+
func getNodeText(node *html.Node) string {
38+
var text strings.Builder
39+
var extractText func(*html.Node)
40+
extractText = func(n *html.Node) {
41+
if n.Type == html.TextNode {
42+
text.WriteString(n.Data)
43+
}
44+
for c := n.FirstChild; c != nil; c = c.NextSibling {
45+
extractText(c)
46+
}
47+
}
48+
extractText(node)
49+
return text.String()
50+
}
51+
52+
func processNodeAttrID(ctx *RenderContext, node *html.Node) {
2753
// Add user-content- to IDs and "#" links if they don't already have them,
2854
// and convert the link href to a relative link to the host root
55+
hasID := false
2956
for idx, attr := range node.Attr {
3057
if attr.Key == "id" {
58+
hasID = true
3159
if !isAnchorIDUserContent(attr.Val) {
3260
node.Attr[idx].Val = "user-content-" + attr.Val
3361
}
3462
}
3563
}
64+
65+
// For heading tags (h1-h6) without an id attribute, generate one from the text content.
66+
// This ensures HTML headings like <h1>Title</h1> get proper permalink anchors
67+
// matching the behavior of Markdown headings.
68+
// Only enabled for repository files and wiki pages via EnableHeadingIDGeneration option.
69+
if !hasID && isHeadingTag(node) && ctx.RenderOptions.EnableHeadingIDGeneration {
70+
text := getNodeText(node)
71+
if text != "" {
72+
// Use the same CleanValue function used by Markdown heading ID generation
73+
cleanedID := string(common.CleanValue([]byte(text)))
74+
if cleanedID != "" {
75+
node.Attr = append(node.Attr, html.Attribute{Key: "id", Val: "user-content-" + cleanedID})
76+
}
77+
}
78+
}
3679
}
3780

3881
func processFootnoteNode(ctx *RenderContext, node *html.Node) {

modules/markup/html_node_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package markup
5+
6+
import (
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestProcessNodeAttrID_HTMLHeadingWithoutID(t *testing.T) {
14+
// Test that HTML headings without id get an auto-generated id from their text content
15+
// when EnableHeadingIDGeneration is true (for repo files and wiki pages)
16+
testCases := []struct {
17+
name string
18+
input string
19+
expected string
20+
}{
21+
{
22+
name: "h1 without id",
23+
input: `<h1>Heading without ID</h1>`,
24+
expected: `<h1 id="user-content-heading-without-id">Heading without ID</h1>`,
25+
},
26+
{
27+
name: "h2 without id",
28+
input: `<h2>Another Heading</h2>`,
29+
expected: `<h2 id="user-content-another-heading">Another Heading</h2>`,
30+
},
31+
{
32+
name: "h3 without id",
33+
input: `<h3>Third Level</h3>`,
34+
expected: `<h3 id="user-content-third-level">Third Level</h3>`,
35+
},
36+
{
37+
name: "h1 with existing id should keep it",
38+
input: `<h1 id="my-custom-id">Heading with ID</h1>`,
39+
expected: `<h1 id="user-content-my-custom-id">Heading with ID</h1>`,
40+
},
41+
{
42+
name: "h1 with user-content prefix should not double prefix",
43+
input: `<h1 id="user-content-already-prefixed">Already Prefixed</h1>`,
44+
expected: `<h1 id="user-content-already-prefixed">Already Prefixed</h1>`,
45+
},
46+
{
47+
name: "heading with special characters",
48+
input: `<h1>What is Wine Staging?</h1>`,
49+
expected: `<h1 id="user-content-what-is-wine-staging">What is Wine Staging?</h1>`,
50+
},
51+
{
52+
name: "heading with nested elements",
53+
input: `<h2><strong>Bold</strong> and <em>Italic</em></h2>`,
54+
expected: `<h2 id="user-content-bold-and-italic"><strong>Bold</strong> and <em>Italic</em></h2>`,
55+
},
56+
}
57+
58+
for _, tc := range testCases {
59+
t.Run(tc.name, func(t *testing.T) {
60+
var result strings.Builder
61+
ctx := NewTestRenderContext().WithEnableHeadingIDGeneration(true)
62+
err := PostProcessDefault(ctx, strings.NewReader(tc.input), &result)
63+
assert.NoError(t, err)
64+
assert.Equal(t, tc.expected, strings.TrimSpace(result.String()))
65+
})
66+
}
67+
}
68+
69+
func TestProcessNodeAttrID_SkipHeadingIDForComments(t *testing.T) {
70+
// Test that HTML headings in comment-like contexts (issue comments)
71+
// do NOT get auto-generated IDs to avoid duplicate IDs on pages with multiple documents.
72+
// This is controlled by EnableHeadingIDGeneration which defaults to false.
73+
testCases := []struct {
74+
name string
75+
input string
76+
expected string
77+
}{
78+
{
79+
name: "h1 without id in comment context",
80+
input: `<h1>Heading without ID</h1>`,
81+
expected: `<h1>Heading without ID</h1>`,
82+
},
83+
{
84+
name: "h2 without id in comment context",
85+
input: `<h2>Another Heading</h2>`,
86+
expected: `<h2>Another Heading</h2>`,
87+
},
88+
{
89+
name: "h1 with existing id should still be prefixed",
90+
input: `<h1 id="my-custom-id">Heading with ID</h1>`,
91+
expected: `<h1 id="user-content-my-custom-id">Heading with ID</h1>`,
92+
},
93+
}
94+
95+
for _, tc := range testCases {
96+
t.Run(tc.name, func(t *testing.T) {
97+
var result strings.Builder
98+
// Default context without EnableHeadingIDGeneration (simulates comment rendering)
99+
err := PostProcessDefault(NewTestRenderContext(), strings.NewReader(tc.input), &result)
100+
assert.NoError(t, err)
101+
assert.Equal(t, tc.expected, strings.TrimSpace(result.String()))
102+
})
103+
}
104+
}

modules/markup/render.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ type RenderOptions struct {
5454

5555
// used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
5656
InStandalonePage bool
57+
58+
// EnableHeadingIDGeneration controls whether to auto-generate IDs for HTML headings without id attribute.
59+
// This should be enabled for repository files and wiki pages, but disabled for comments to avoid duplicate IDs.
60+
EnableHeadingIDGeneration bool
5761
}
5862

5963
// RenderContext represents a render context
@@ -112,6 +116,11 @@ func (ctx *RenderContext) WithInStandalonePage(v bool) *RenderContext {
112116
return ctx
113117
}
114118

119+
func (ctx *RenderContext) WithEnableHeadingIDGeneration(v bool) *RenderContext {
120+
ctx.RenderOptions.EnableHeadingIDGeneration = v
121+
return ctx
122+
}
123+
115124
func (ctx *RenderContext) WithUseAbsoluteLink(v bool) *RenderContext {
116125
ctx.RenderOptions.UseAbsoluteLink = v
117126
return ctx

modules/migration/release.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ import (
1010

1111
// ReleaseAsset represents a release asset
1212
type ReleaseAsset struct {
13-
ID int64
14-
Name string
15-
ContentType *string `yaml:"content_type"`
13+
ID int64
14+
Name string
15+
16+
// There was a field "ContentType (content_type)" because Some forges can provide that for assets,
17+
// but we don't need it when migrating, so the field is omitted here.
18+
1619
Size *int
1720
DownloadCount *int `yaml:"download_count"`
1821
Created time.Time

modules/structs/pull.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ type CreatePullRequestOption struct {
140140
Reviewers []string `json:"reviewers"`
141141
// The list of team reviewer names
142142
TeamReviewers []string `json:"team_reviewers"`
143+
// Whether maintainers can edit the pull request
144+
AllowMaintainerEdit *bool `json:"allow_maintainer_edit"`
143145
}
144146

145147
// EditPullRequestOption options when modify pull request

routers/api/v1/repo/pull.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"code.gitea.io/gitea/modules/gitrepo"
2626
"code.gitea.io/gitea/modules/graceful"
2727
"code.gitea.io/gitea/modules/log"
28+
"code.gitea.io/gitea/modules/optional"
2829
"code.gitea.io/gitea/modules/setting"
2930
api "code.gitea.io/gitea/modules/structs"
3031
"code.gitea.io/gitea/modules/timeutil"
@@ -496,6 +497,11 @@ func CreatePullRequest(ctx *context.APIContext) {
496497
deadlineUnix = timeutil.TimeStamp(form.Deadline.Unix())
497498
}
498499

500+
unitPullRequest, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypePullRequests)
501+
if err != nil {
502+
ctx.APIErrorInternal(err)
503+
}
504+
499505
prIssue := &issues_model.Issue{
500506
RepoID: repo.ID,
501507
Title: form.Title,
@@ -517,6 +523,8 @@ func CreatePullRequest(ctx *context.APIContext) {
517523
Type: issues_model.PullRequestGitea,
518524
}
519525

526+
pr.AllowMaintainerEdit = optional.FromPtr(form.AllowMaintainerEdit).ValueOrDefault(unitPullRequest.PullRequestsConfig().DefaultAllowMaintainerEdit)
527+
520528
// Get all assignee IDs
521529
assigneeIDs, err := issues_model.MakeIDsFromAPIAssigneesToAdd(ctx, form.Assignee, form.Assignees)
522530
if err != nil {

services/migrations/github.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,6 @@ func (g *GithubDownloaderV3) convertGithubRelease(ctx context.Context, rel *gith
329329
r.Assets = append(r.Assets, &base.ReleaseAsset{
330330
ID: asset.GetID(),
331331
Name: asset.GetName(),
332-
ContentType: asset.ContentType,
333332
Size: asset.Size,
334333
DownloadCount: asset.DownloadCount,
335334
Created: asset.CreatedAt.Time,

0 commit comments

Comments
 (0)