mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-18 14:53:27 +02:00
Merge branch 'main' into feature/workflow-graph
This commit is contained in:
commit
0f9d18a712
@ -490,12 +490,25 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o
|
||||
opts.CommitAfterUnix = time.Now().Add(-time.Hour * 2).Unix()
|
||||
}
|
||||
|
||||
baseBranch, err := GetBranch(ctx, opts.BaseRepo.ID, opts.BaseRepo.DefaultBranch)
|
||||
var ignoredCommitIDs []string
|
||||
baseDefaultBranch, err := GetBranch(ctx, opts.BaseRepo.ID, opts.BaseRepo.DefaultBranch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
log.Warn("GetBranch:DefaultBranch: %v", err)
|
||||
} else {
|
||||
ignoredCommitIDs = append(ignoredCommitIDs, baseDefaultBranch.CommitID)
|
||||
}
|
||||
|
||||
// find all related branches, these branches may already created PRs, we will check later
|
||||
baseDefaultTargetBranchName := opts.BaseRepo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig().DefaultTargetBranch
|
||||
if baseDefaultTargetBranchName != "" && baseDefaultTargetBranchName != opts.BaseRepo.DefaultBranch {
|
||||
baseDefaultTargetBranch, err := GetBranch(ctx, opts.BaseRepo.ID, baseDefaultTargetBranchName)
|
||||
if err != nil {
|
||||
log.Warn("GetBranch:DefaultTargetBranch: %v", err)
|
||||
} else {
|
||||
ignoredCommitIDs = append(ignoredCommitIDs, baseDefaultTargetBranch.CommitID)
|
||||
}
|
||||
}
|
||||
|
||||
// find all related branches, these branches may already have PRs, we will check later
|
||||
var branches []*Branch
|
||||
if err := db.GetEngine(ctx).
|
||||
Where(builder.And(
|
||||
@ -506,7 +519,7 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o
|
||||
builder.Gte{"commit_time": opts.CommitAfterUnix},
|
||||
builder.In("repo_id", repoIDs),
|
||||
// newly created branch have no changes, so skip them
|
||||
builder.Neq{"commit_id": baseBranch.CommitID},
|
||||
builder.NotIn("commit_id", ignoredCommitIDs),
|
||||
)).
|
||||
OrderBy(db.SearchOrderByRecentUpdated.String()).
|
||||
Find(&branches); err != nil {
|
||||
@ -514,10 +527,8 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o
|
||||
}
|
||||
|
||||
newBranches := make([]*RecentlyPushedNewBranch, 0, len(branches))
|
||||
if opts.MaxCount == 0 {
|
||||
// by default we display 2 recently pushed new branch
|
||||
opts.MaxCount = 2
|
||||
}
|
||||
opts.MaxCount = util.IfZero(opts.MaxCount, 2) // by default, we display 2 recently pushed new branch
|
||||
baseTargetBranchName := opts.BaseRepo.GetPullRequestTargetBranch(ctx)
|
||||
for _, branch := range branches {
|
||||
// whether the branch is protected
|
||||
protected, err := IsBranchProtected(ctx, branch.RepoID, branch.Name)
|
||||
@ -555,7 +566,7 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o
|
||||
BranchDisplayName: branchDisplayName,
|
||||
BranchName: branch.Name,
|
||||
BranchLink: fmt.Sprintf("%s/src/branch/%s", branch.Repo.Link(), util.PathEscapeSegments(branch.Name)),
|
||||
BranchCompareURL: branch.Repo.ComposeBranchCompareURL(opts.BaseRepo, branch.Name),
|
||||
BranchCompareURL: branch.Repo.ComposeBranchCompareURL(opts.BaseRepo, baseTargetBranchName, branch.Name),
|
||||
CommitTime: branch.CommitTime,
|
||||
})
|
||||
}
|
||||
|
||||
@ -682,7 +682,7 @@ func (issue *Issue) GetParticipantIDsByIssue(ctx context.Context) ([]int64, erro
|
||||
}
|
||||
|
||||
// BlockedByDependencies finds all Dependencies an issue is blocked by
|
||||
func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptions) (issueDeps []*DependencyInfo, err error) {
|
||||
func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptions) (issueDeps []*DependencyInfo, total int64, err error) {
|
||||
sess := db.GetEngine(ctx).
|
||||
Table("issue").
|
||||
Join("INNER", "repository", "repository.id = issue.repo_id").
|
||||
@ -693,13 +693,13 @@ func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptio
|
||||
if opts.Page > 0 {
|
||||
sess = db.SetSessionPagination(sess, &opts)
|
||||
}
|
||||
err = sess.Find(&issueDeps)
|
||||
total, err = sess.FindAndCount(&issueDeps)
|
||||
|
||||
for _, depInfo := range issueDeps {
|
||||
depInfo.Issue.Repo = &depInfo.Repository
|
||||
}
|
||||
|
||||
return issueDeps, err
|
||||
return issueDeps, total, err
|
||||
}
|
||||
|
||||
// BlockingDependencies returns all blocking dependencies, aka all other issues a given issue blocks
|
||||
|
||||
16
models/repo/pull_request_default.go
Normal file
16
models/repo/pull_request_default.go
Normal file
@ -0,0 +1,16 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
func (repo *Repository) GetPullRequestTargetBranch(ctx context.Context) string {
|
||||
unitPRConfig := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
|
||||
return util.IfZero(unitPRConfig.DefaultTargetBranch, repo.DefaultBranch)
|
||||
}
|
||||
32
models/repo/pull_request_default_test.go
Normal file
32
models/repo/pull_request_default_test.go
Normal file
@ -0,0 +1,32 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDefaultTargetBranchSelection(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
ctx := t.Context()
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 1})
|
||||
|
||||
assert.Equal(t, repo.DefaultBranch, repo.GetPullRequestTargetBranch(ctx))
|
||||
|
||||
repo.Units = nil
|
||||
prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests)
|
||||
assert.NoError(t, err)
|
||||
prConfig := prUnit.PullRequestsConfig()
|
||||
prConfig.DefaultTargetBranch = "branch2"
|
||||
prUnit.Config = prConfig
|
||||
assert.NoError(t, UpdateRepoUnit(ctx, prUnit))
|
||||
repo.Units = nil
|
||||
assert.Equal(t, "branch2", repo.GetPullRequestTargetBranch(ctx))
|
||||
}
|
||||
@ -613,16 +613,13 @@ func (repo *Repository) ComposeCompareURL(oldCommitID, newCommitID string) strin
|
||||
return fmt.Sprintf("%s/%s/compare/%s...%s", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), util.PathEscapeSegments(oldCommitID), util.PathEscapeSegments(newCommitID))
|
||||
}
|
||||
|
||||
func (repo *Repository) ComposeBranchCompareURL(baseRepo *Repository, branchName string) string {
|
||||
if baseRepo == nil {
|
||||
baseRepo = repo
|
||||
}
|
||||
func (repo *Repository) ComposeBranchCompareURL(baseRepo *Repository, baseBranch, branchName string) string {
|
||||
var cmpBranchEscaped string
|
||||
if repo.ID != baseRepo.ID {
|
||||
cmpBranchEscaped = fmt.Sprintf("%s/%s:", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name))
|
||||
}
|
||||
cmpBranchEscaped = fmt.Sprintf("%s%s", cmpBranchEscaped, util.PathEscapeSegments(branchName))
|
||||
return fmt.Sprintf("%s/compare/%s...%s", baseRepo.Link(), util.PathEscapeSegments(baseRepo.DefaultBranch), cmpBranchEscaped)
|
||||
return fmt.Sprintf("%s/compare/%s...%s", baseRepo.Link(), util.PathEscapeSegments(baseBranch), cmpBranchEscaped)
|
||||
}
|
||||
|
||||
// IsOwnedBy returns true when user owns this repository
|
||||
|
||||
@ -131,6 +131,7 @@ type PullRequestsConfig struct {
|
||||
DefaultDeleteBranchAfterMerge bool
|
||||
DefaultMergeStyle MergeStyle
|
||||
DefaultAllowMaintainerEdit bool
|
||||
DefaultTargetBranch string
|
||||
}
|
||||
|
||||
// FromDB fills up a PullRequestsConfig from serialized format.
|
||||
|
||||
@ -9,19 +9,32 @@ import (
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// GetSystemOrDefaultWebhooks returns webhooks by given argument or all if argument is missing.
|
||||
func GetSystemOrDefaultWebhooks(ctx context.Context, isSystemWebhook optional.Option[bool]) ([]*Webhook, error) {
|
||||
webhooks := make([]*Webhook, 0, 5)
|
||||
if !isSystemWebhook.Has() {
|
||||
return webhooks, db.GetEngine(ctx).Where("repo_id=? AND owner_id=?", 0, 0).
|
||||
Find(&webhooks)
|
||||
}
|
||||
// ListSystemWebhookOptions options for listing system or default webhooks
|
||||
type ListSystemWebhookOptions struct {
|
||||
db.ListOptions
|
||||
IsActive optional.Option[bool]
|
||||
IsSystem optional.Option[bool]
|
||||
}
|
||||
|
||||
return webhooks, db.GetEngine(ctx).
|
||||
Where("repo_id=? AND owner_id=? AND is_system_webhook=?", 0, 0, isSystemWebhook.Value()).
|
||||
Find(&webhooks)
|
||||
func (opts ListSystemWebhookOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
cond = cond.And(builder.Eq{"webhook.repo_id": 0}, builder.Eq{"webhook.owner_id": 0})
|
||||
if opts.IsActive.Has() {
|
||||
cond = cond.And(builder.Eq{"webhook.is_active": opts.IsActive.Value()})
|
||||
}
|
||||
if opts.IsSystem.Has() {
|
||||
cond = cond.And(builder.Eq{"is_system_webhook": opts.IsSystem.Value()})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
// GetGlobalWebhooks returns global (default and/or system) webhooks
|
||||
func GetGlobalWebhooks(ctx context.Context, opts *ListSystemWebhookOptions) ([]*Webhook, int64, error) {
|
||||
return db.FindAndCount[Webhook](ctx, opts)
|
||||
}
|
||||
|
||||
// GetDefaultWebhooks returns all admin-default webhooks.
|
||||
|
||||
@ -12,23 +12,24 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetSystemOrDefaultWebhooks(t *testing.T) {
|
||||
func TestListSystemWebhookOptions(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
hooks, err := GetSystemOrDefaultWebhooks(t.Context(), optional.None[bool]())
|
||||
opts := ListSystemWebhookOptions{IsSystem: optional.None[bool]()}
|
||||
hooks, _, err := GetGlobalWebhooks(t.Context(), &opts)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, hooks, 2) {
|
||||
assert.Equal(t, int64(5), hooks[0].ID)
|
||||
assert.Equal(t, int64(6), hooks[1].ID)
|
||||
}
|
||||
|
||||
hooks, err = GetSystemOrDefaultWebhooks(t.Context(), optional.Some(true))
|
||||
opts.IsSystem = optional.Some(true)
|
||||
hooks, _, err = GetGlobalWebhooks(t.Context(), &opts)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, hooks, 1) {
|
||||
assert.Equal(t, int64(5), hooks[0].ID)
|
||||
}
|
||||
|
||||
hooks, err = GetSystemOrDefaultWebhooks(t.Context(), optional.Some(false))
|
||||
opts.IsSystem = optional.Some(false)
|
||||
hooks, _, err = GetGlobalWebhooks(t.Context(), &opts)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, hooks, 1) {
|
||||
assert.Equal(t, int64(6), hooks[0].ID)
|
||||
|
||||
@ -58,26 +58,27 @@ type Repository struct {
|
||||
Fork bool `json:"fork"`
|
||||
Template bool `json:"template"`
|
||||
// the original repository if this repository is a fork, otherwise null
|
||||
Parent *Repository `json:"parent,omitempty"`
|
||||
Mirror bool `json:"mirror"`
|
||||
Size int `json:"size"`
|
||||
Language string `json:"language"`
|
||||
LanguagesURL string `json:"languages_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
URL string `json:"url"`
|
||||
Link string `json:"link"`
|
||||
SSHURL string `json:"ssh_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
OriginalURL string `json:"original_url"`
|
||||
Website string `json:"website"`
|
||||
Stars int `json:"stars_count"`
|
||||
Forks int `json:"forks_count"`
|
||||
Watchers int `json:"watchers_count"`
|
||||
OpenIssues int `json:"open_issues_count"`
|
||||
OpenPulls int `json:"open_pr_counter"`
|
||||
Releases int `json:"release_counter"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
Archived bool `json:"archived"`
|
||||
Parent *Repository `json:"parent,omitempty"`
|
||||
Mirror bool `json:"mirror"`
|
||||
Size int `json:"size"`
|
||||
Language string `json:"language"`
|
||||
LanguagesURL string `json:"languages_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
URL string `json:"url"`
|
||||
Link string `json:"link"`
|
||||
SSHURL string `json:"ssh_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
OriginalURL string `json:"original_url"`
|
||||
Website string `json:"website"`
|
||||
Stars int `json:"stars_count"`
|
||||
Forks int `json:"forks_count"`
|
||||
Watchers int `json:"watchers_count"`
|
||||
OpenIssues int `json:"open_issues_count"`
|
||||
OpenPulls int `json:"open_pr_counter"`
|
||||
Releases int `json:"release_counter"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
DefaultTargetBranch string `json:"default_target_branch,omitempty"`
|
||||
Archived bool `json:"archived"`
|
||||
// swagger:strfmt date-time
|
||||
Created time.Time `json:"created_at"`
|
||||
// swagger:strfmt date-time
|
||||
|
||||
@ -2124,6 +2124,8 @@
|
||||
"repo.settings.pulls.ignore_whitespace": "Ignore Whitespace for Conflicts",
|
||||
"repo.settings.pulls.enable_autodetect_manual_merge": "Enable autodetect manual merge (Note: In some special cases, misjudgments can occur)",
|
||||
"repo.settings.pulls.allow_rebase_update": "Enable updating pull request branch by rebase",
|
||||
"repo.settings.pulls.default_target_branch": "Default target branch for new pull requests",
|
||||
"repo.settings.pulls.default_target_branch_default": "Default branch (%s)",
|
||||
"repo.settings.pulls.default_delete_branch_after_merge": "Delete pull request branch after merge by default",
|
||||
"repo.settings.pulls.default_allow_edits_from_maintainers": "Allow edits from maintainers by default",
|
||||
"repo.settings.releases_desc": "Enable Repository Releases",
|
||||
@ -2436,9 +2438,10 @@
|
||||
"repo.settings.block_outdated_branch_desc": "Merging will not be possible when head branch is behind base branch.",
|
||||
"repo.settings.block_admin_merge_override": "Administrators must follow branch protection rules",
|
||||
"repo.settings.block_admin_merge_override_desc": "Administrators must follow branch protection rules and cannot circumvent it.",
|
||||
"repo.settings.default_branch_desc": "Select a default repository branch for pull requests and code commits:",
|
||||
"repo.settings.default_branch_desc": "Select a default branch for code commits.",
|
||||
"repo.settings.default_target_branch_desc": "Pull requests can use different default target branch if it is set in the Pull Requests section of Repository Advance Settings.",
|
||||
"repo.settings.merge_style_desc": "Merge Styles",
|
||||
"repo.settings.default_merge_style_desc": "Default Merge Style",
|
||||
"repo.settings.default_merge_style_desc": "Default merge style",
|
||||
"repo.settings.choose_branch": "Choose a branch…",
|
||||
"repo.settings.no_protected_branch": "There are no protected branches.",
|
||||
"repo.settings.edit_protected_branch": "Edit",
|
||||
@ -2650,7 +2653,7 @@
|
||||
"repo.branch.restore_success": "Branch \"%s\" has been restored.",
|
||||
"repo.branch.restore_failed": "Failed to restore branch \"%s\".",
|
||||
"repo.branch.protected_deletion_failed": "Branch \"%s\" is protected. It cannot be deleted.",
|
||||
"repo.branch.default_deletion_failed": "Branch \"%s\" is the default branch. It cannot be deleted.",
|
||||
"repo.branch.default_deletion_failed": "Branch \"%s\" is the default or pull request target branch. It cannot be deleted.",
|
||||
"repo.branch.default_branch_not_exist": "Default branch \"%s\" does not exist.",
|
||||
"repo.branch.restore": "Restore Branch \"%s\"",
|
||||
"repo.branch.download": "Download Branch \"%s\"",
|
||||
|
||||
@ -1778,6 +1778,8 @@
|
||||
"repo.pulls.title_desc": "ag iarraidh %[1]d gealltanas a chumasc ó <code>%[2]s</code> go <code id=\"branch_target\">%[3]s</code>",
|
||||
"repo.pulls.merged_title_desc": "cumasc %[1]d tiomantas ó <code>%[2]s</code> go <code>%[3]s</code> %[4]s",
|
||||
"repo.pulls.change_target_branch_at": "athraigh an spriocbhrainse ó <b>%s</b> go <b>%s</b> %s",
|
||||
"repo.pulls.marked_as_work_in_progress_at": "marcáladh an iarratas tarraingthe mar obair ar siúl %s",
|
||||
"repo.pulls.marked_as_ready_for_review_at": "marcáladh an iarratas tarraingthe mar réidh le haghaidh athbhreithnithe %s",
|
||||
"repo.pulls.tab_conversation": "Comhrá",
|
||||
"repo.pulls.tab_commits": "Tiomáintí",
|
||||
"repo.pulls.tab_files": "Comhaid Athraithe",
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"@github/relative-time-element": "5.0.0",
|
||||
"@github/text-expander-element": "2.9.4",
|
||||
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@primer/octicons": "19.21.2",
|
||||
"@resvg/resvg-wasm": "2.6.2",
|
||||
"@silverwind/vue3-calendar-heatmap": "2.1.1",
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@ -53,6 +53,9 @@ importers:
|
||||
'@mcaptcha/vanilla-glue':
|
||||
specifier: 0.1.0-alpha-3
|
||||
version: 0.1.0-alpha-3
|
||||
'@mermaid-js/layout-elk':
|
||||
specifier: 0.2.0
|
||||
version: 0.2.0(mermaid@11.12.2)
|
||||
'@primer/octicons':
|
||||
specifier: 19.21.2
|
||||
version: 19.21.2
|
||||
@ -805,6 +808,11 @@ packages:
|
||||
'@mcaptcha/vanilla-glue@0.1.0-alpha-3':
|
||||
resolution: {integrity: sha512-GT6TJBgmViGXcXiT5VOr+h/6iOnThSlZuCoOWncubyTZU9R3cgU5vWPkF7G6Ob6ee2CBe3yqBxxk24CFVGTVXw==}
|
||||
|
||||
'@mermaid-js/layout-elk@0.2.0':
|
||||
resolution: {integrity: sha512-vjjYGnCCjYlIA/rR7M//eFi0rHM6dsMyN1JQKfckpt30DTC/esrw36hcrvA2FNPHaqh3Q/SyBWzddyaky8EtUQ==}
|
||||
peerDependencies:
|
||||
mermaid: ^11.0.2
|
||||
|
||||
'@mermaid-js/parser@0.6.3':
|
||||
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
|
||||
|
||||
@ -2257,6 +2265,9 @@ packages:
|
||||
electron-to-chromium@1.5.283:
|
||||
resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==}
|
||||
|
||||
elkjs@0.9.3:
|
||||
resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==}
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
@ -4744,6 +4755,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@mcaptcha/core-glue': 0.1.0-alpha-5
|
||||
|
||||
'@mermaid-js/layout-elk@0.2.0(mermaid@11.12.2)':
|
||||
dependencies:
|
||||
d3: 7.9.0
|
||||
elkjs: 0.9.3
|
||||
mermaid: 11.12.2
|
||||
|
||||
'@mermaid-js/parser@0.6.3':
|
||||
dependencies:
|
||||
langium: 3.3.1
|
||||
@ -6172,6 +6189,8 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.283: {}
|
||||
|
||||
elkjs@0.9.3: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
@ -57,8 +57,13 @@ func ListHooks(ctx *context.APIContext) {
|
||||
case "all":
|
||||
isSystemWebhook = optional.None[bool]()
|
||||
}
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
opts := &webhook.ListSystemWebhookOptions{
|
||||
ListOptions: listOptions,
|
||||
IsSystem: isSystemWebhook,
|
||||
}
|
||||
|
||||
sysHooks, err := webhook.GetSystemOrDefaultWebhooks(ctx, isSystemWebhook)
|
||||
sysHooks, total, err := webhook.GetGlobalWebhooks(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@ -72,6 +77,8 @@ func ListHooks(ctx *context.APIContext) {
|
||||
}
|
||||
hooks[i] = h
|
||||
}
|
||||
ctx.SetLinkHeader(int(total), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, hooks)
|
||||
}
|
||||
|
||||
|
||||
@ -125,8 +125,8 @@ func ListRepoNotifications(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(totalCount), opts.PageSize)
|
||||
ctx.SetTotalCountHeader(totalCount)
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToNotifications(ctx, nl))
|
||||
}
|
||||
|
||||
|
||||
@ -86,6 +86,7 @@ func ListNotifications(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(totalCount), opts.PageSize)
|
||||
ctx.SetTotalCountHeader(totalCount)
|
||||
ctx.JSON(http.StatusOK, convert.ToNotifications(ctx, nl))
|
||||
}
|
||||
|
||||
@ -67,6 +67,7 @@ func (Action) ListActionsSecrets(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), opts.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiSecrets)
|
||||
}
|
||||
@ -240,9 +241,10 @@ func (Action) ListVariables(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
@ -259,7 +261,7 @@ func (Action) ListVariables(ctx *context.APIContext) {
|
||||
Description: v.Description,
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, variables)
|
||||
}
|
||||
|
||||
@ -20,11 +20,12 @@ import (
|
||||
|
||||
// listMembers list an organization's members
|
||||
func listMembers(ctx *context.APIContext, isMember bool) {
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
opts := &organization.FindOrgMembersOpts{
|
||||
Doer: ctx.Doer,
|
||||
IsDoerMember: isMember,
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
|
||||
count, err := organization.CountOrgMembers(ctx, opts)
|
||||
@ -44,6 +45,7 @@ func listMembers(ctx *context.APIContext, isMember bool) {
|
||||
apiMembers[i] = convert.ToUser(ctx, member, ctx.Doer)
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiMembers)
|
||||
}
|
||||
|
||||
@ -54,8 +54,9 @@ func ListTeams(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
})
|
||||
if err != nil {
|
||||
@ -69,6 +70,7 @@ func ListTeams(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiTeams)
|
||||
}
|
||||
@ -93,8 +95,9 @@ func ListUserTeams(ctx *context.APIContext) {
|
||||
// "200":
|
||||
// "$ref": "#/responses/TeamList"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
UserID: ctx.Doer.ID,
|
||||
})
|
||||
if err != nil {
|
||||
@ -108,6 +111,7 @@ func ListUserTeams(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiTeams)
|
||||
}
|
||||
@ -392,8 +396,9 @@ func GetTeamMembers(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
TeamID: ctx.Org.Team.ID,
|
||||
})
|
||||
if err != nil {
|
||||
@ -406,6 +411,7 @@ func GetTeamMembers(ctx *context.APIContext) {
|
||||
members[i] = convert.ToUser(ctx, member, ctx.Doer)
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(ctx.Org.Team.NumMembers, listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(int64(ctx.Org.Team.NumMembers))
|
||||
ctx.JSON(http.StatusOK, members)
|
||||
}
|
||||
@ -559,8 +565,9 @@ func GetTeamRepos(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
team := ctx.Org.Team
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
teamRepos, err := repo_model.GetTeamRepositories(ctx, &repo_model.SearchTeamRepoOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
TeamID: team.ID,
|
||||
})
|
||||
if err != nil {
|
||||
@ -576,6 +583,7 @@ func GetTeamRepos(ctx *context.APIContext) {
|
||||
}
|
||||
repos[i] = convert.ToRepo(ctx, repo, permission)
|
||||
}
|
||||
ctx.SetLinkHeader(team.NumRepos, listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(int64(team.NumRepos))
|
||||
ctx.JSON(http.StatusOK, repos)
|
||||
}
|
||||
@ -874,7 +882,7 @@ func ListTeamActivityFeeds(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer))
|
||||
}
|
||||
|
||||
@ -69,10 +69,11 @@ func (Action) ListActionsSecrets(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
repo := ctx.Repo.Repository
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
opts := &secret_model.FindSecretsOptions{
|
||||
RepoID: repo.ID,
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
|
||||
secrets, count, err := db.FindAndCount[secret_model.Secret](ctx, opts)
|
||||
@ -89,7 +90,7 @@ func (Action) ListActionsSecrets(ctx *context.APIContext) {
|
||||
Created: v.CreatedUnix.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiSecrets)
|
||||
}
|
||||
@ -482,9 +483,11 @@ func (Action) ListVariables(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
@ -502,6 +505,7 @@ func (Action) ListVariables(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, variables)
|
||||
}
|
||||
@ -807,9 +811,10 @@ func ListActionTasks(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/conflict"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
tasks, total, err := db.FindAndCount[actions_model.ActionTask](ctx, &actions_model.FindTaskOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
})
|
||||
if err != nil {
|
||||
@ -830,6 +835,8 @@ func ListActionTasks(ctx *context.APIContext) {
|
||||
res.Entries[i] = convertedTask
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(total), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(total) // Duplicates api response field but it's better to set it for consistency
|
||||
ctx.JSON(http.StatusOK, &res)
|
||||
}
|
||||
|
||||
|
||||
@ -155,7 +155,7 @@ func DeleteBranch(ctx *context.APIContext) {
|
||||
case git.IsErrBranchNotExist(err):
|
||||
ctx.APIErrorNotFound(err)
|
||||
case errors.Is(err, repo_service.ErrBranchIsDefault):
|
||||
ctx.APIError(http.StatusForbidden, errors.New("can not delete default branch"))
|
||||
ctx.APIError(http.StatusForbidden, errors.New("can not delete default or pull request target branch"))
|
||||
case errors.Is(err, git_model.ErrBranchIsProtected):
|
||||
ctx.APIError(http.StatusForbidden, errors.New("branch protected"))
|
||||
default:
|
||||
|
||||
@ -7,13 +7,13 @@ package repo
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
)
|
||||
@ -77,23 +77,14 @@ func GetIssueDependencies(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
page := max(ctx.FormInt("page"), 1)
|
||||
limit := ctx.FormInt("limit")
|
||||
if limit == 0 {
|
||||
limit = setting.API.DefaultPagingNum
|
||||
} else if limit > setting.API.MaxResponseItems {
|
||||
limit = setting.API.MaxResponseItems
|
||||
}
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
canWrite := ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)
|
||||
|
||||
blockerIssues := make([]*issues_model.Issue, 0, limit)
|
||||
blockerIssues := make([]*issues_model.Issue, 0, listOptions.PageSize)
|
||||
|
||||
// 2. Get the issues this issue depends on, i.e. the `<#b>`: `<issue> <- <#b>`
|
||||
blockersInfo, err := issue.BlockedByDependencies(ctx, db.ListOptions{
|
||||
Page: page,
|
||||
PageSize: limit,
|
||||
})
|
||||
blockersInfo, total, err := issue.BlockedByDependencies(ctx, listOptions)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@ -149,7 +140,8 @@ func GetIssueDependencies(ctx *context.APIContext) {
|
||||
}
|
||||
blockerIssues = append(blockerIssues, &blocker.Issue)
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(total), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, blockerIssues))
|
||||
}
|
||||
|
||||
|
||||
@ -1138,7 +1138,7 @@ func parseCompareInfo(ctx *context.APIContext, compareParam string) (result *git
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(util.IfZero(compareReq.BaseOriRef, baseRepo.DefaultBranch))
|
||||
baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(util.IfZero(compareReq.BaseOriRef, baseRepo.GetPullRequestTargetBranch(ctx)))
|
||||
headRef := headGitRepo.UnstableGuessRefByShortName(util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch))
|
||||
|
||||
log.Trace("Repo path: %q, base ref: %q->%q, head ref: %q->%q", ctx.Repo.Repository.RelativePath(), compareReq.BaseOriRef, baseRef, compareReq.HeadOriRef, headRef)
|
||||
|
||||
@ -257,8 +257,8 @@ func GetCombinedCommitStatusByRef(ctx *context.APIContext) {
|
||||
}
|
||||
|
||||
repo := ctx.Repo.Repository
|
||||
|
||||
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, refCommit.Commit.ID.String(), utils.GetListOptions(ctx))
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, refCommit.Commit.ID.String(), listOptions)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(fmt.Errorf("GetLatestCommitStatus[%s, %s]: %w", repo.FullName(), refCommit.CommitID, err))
|
||||
return
|
||||
@ -269,6 +269,7 @@ func GetCombinedCommitStatusByRef(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(fmt.Errorf("CountLatestCommitStatus[%s, %s]: %w", repo.FullName(), refCommit.CommitID, err))
|
||||
return
|
||||
}
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
|
||||
combiStatus := convert.ToCombinedStatus(ctx, refCommit.Commit.ID.String(), statuses,
|
||||
|
||||
@ -333,6 +333,7 @@ func ListWikiPages(ctx *context.APIContext) {
|
||||
pages = append(pages, wiki_service.ToWikiPageMetaData(wikiName, c, ctx.Repo.Repository))
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(len(entries), limit)
|
||||
ctx.SetTotalCountHeader(int64(len(entries)))
|
||||
ctx.JSON(http.StatusOK, pages)
|
||||
}
|
||||
@ -445,6 +446,7 @@ func ListPageRevisions(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME: SetLinkHeader missing
|
||||
ctx.SetTotalCountHeader(commitsCount)
|
||||
ctx.JSON(http.StatusOK, convert.ToWikiCommitList(commitsHistory, commitsCount))
|
||||
}
|
||||
|
||||
@ -32,11 +32,12 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
|
||||
if ownerID != 0 && repoID != 0 {
|
||||
setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
|
||||
}
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
opts := actions_model.FindRunJobOptions{
|
||||
OwnerID: ownerID,
|
||||
RepoID: repoID,
|
||||
RunID: runID,
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
for _, status := range ctx.FormStrings("status") {
|
||||
values, err := convertToInternal(status)
|
||||
@ -78,7 +79,8 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
|
||||
}
|
||||
res.Entries[i] = convertedWorkflowJob
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(total), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, &res)
|
||||
}
|
||||
|
||||
@ -120,10 +122,11 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
|
||||
if ownerID != 0 && repoID != 0 {
|
||||
setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
|
||||
}
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
opts := actions_model.FindRunOptions{
|
||||
OwnerID: ownerID,
|
||||
RepoID: repoID,
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
|
||||
if event := ctx.FormString("event"); event != "" {
|
||||
@ -182,6 +185,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
|
||||
}
|
||||
res.Entries[i] = convertedRun
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(total), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, &res)
|
||||
}
|
||||
|
||||
@ -16,8 +16,9 @@ import (
|
||||
)
|
||||
|
||||
func ListBlocks(ctx *context.APIContext, blocker *user_model.User) {
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
blocks, total, err := user_model.FindBlockings(ctx, &user_model.FindBlockingOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
BlockerID: blocker.ID,
|
||||
})
|
||||
if err != nil {
|
||||
@ -35,6 +36,7 @@ func ListBlocks(ctx *context.APIContext, blocker *user_model.User) {
|
||||
users = append(users, convert.ToUser(ctx, b.Blockee, blocker))
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(total), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, &users)
|
||||
}
|
||||
|
||||
@ -333,10 +333,10 @@ func ListVariables(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{
|
||||
OwnerID: ctx.Doer.ID,
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
@ -354,6 +354,7 @@ func ListVariables(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, variables)
|
||||
}
|
||||
|
||||
@ -24,12 +24,14 @@ func responseAPIUsers(ctx *context.APIContext, users []*user_model.User) {
|
||||
}
|
||||
|
||||
func listUserFollowers(ctx *context.APIContext, u *user_model.User) {
|
||||
users, count, err := user_model.GetUserFollowers(ctx, u, ctx.Doer, utils.GetListOptions(ctx))
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
users, count, err := user_model.GetUserFollowers(ctx, u, ctx.Doer, listOptions)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
responseAPIUsers(ctx, users)
|
||||
}
|
||||
@ -88,12 +90,14 @@ func ListFollowers(ctx *context.APIContext) {
|
||||
}
|
||||
|
||||
func listUserFollowing(ctx *context.APIContext, u *user_model.User) {
|
||||
users, count, err := user_model.GetUserFollowing(ctx, u, ctx.Doer, utils.GetListOptions(ctx))
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
users, count, err := user_model.GetUserFollowing(ctx, u, ctx.Doer, listOptions)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
responseAPIUsers(ctx, users)
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
@ -53,11 +54,11 @@ func composePublicKeysAPILink() string {
|
||||
func listPublicKeys(ctx *context.APIContext, user *user_model.User) {
|
||||
var keys []*asymkey_model.PublicKey
|
||||
var err error
|
||||
var count int
|
||||
var count int64
|
||||
|
||||
fingerprint := ctx.FormString("fingerprint")
|
||||
username := ctx.PathParam("username")
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
if fingerprint != "" {
|
||||
var userID int64 // Unrestricted
|
||||
// Querying not just listing
|
||||
@ -65,20 +66,18 @@ func listPublicKeys(ctx *context.APIContext, user *user_model.User) {
|
||||
// Restrict to provided uid
|
||||
userID = user.ID
|
||||
}
|
||||
keys, err = db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
|
||||
keys, count, err = db.FindAndCount[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
|
||||
ListOptions: listOptions,
|
||||
OwnerID: userID,
|
||||
Fingerprint: fingerprint,
|
||||
})
|
||||
count = len(keys)
|
||||
} else {
|
||||
var total int64
|
||||
// Use ListPublicKeys
|
||||
keys, total, err = db.FindAndCount[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
keys, count, err = db.FindAndCount[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
|
||||
ListOptions: listOptions,
|
||||
OwnerID: user.ID,
|
||||
NotKeytype: asymkey_model.KeyTypePrincipal,
|
||||
})
|
||||
count = int(total)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@ -95,7 +94,8 @@ func listPublicKeys(ctx *context.APIContext, user *user_model.User) {
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(int64(count))
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, &apiKeys)
|
||||
}
|
||||
|
||||
|
||||
@ -76,6 +76,7 @@ func GetStarredRepos(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(ctx.ContextUser.NumStars, utils.GetListOptions(ctx).PageSize)
|
||||
ctx.SetTotalCountHeader(int64(ctx.ContextUser.NumStars))
|
||||
ctx.JSON(http.StatusOK, &repos)
|
||||
}
|
||||
@ -107,6 +108,7 @@ func GetMyStarredRepos(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(ctx.Doer.NumStars, utils.GetListOptions(ctx).PageSize)
|
||||
ctx.SetTotalCountHeader(int64(ctx.Doer.NumStars))
|
||||
ctx.JSON(http.StatusOK, &repos)
|
||||
}
|
||||
|
||||
@ -71,6 +71,7 @@ func GetWatchedRepos(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(total), utils.GetListOptions(ctx).PageSize)
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, &repos)
|
||||
}
|
||||
@ -99,7 +100,7 @@ func GetMyWatchedRepos(ctx *context.APIContext) {
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(total), utils.GetListOptions(ctx).PageSize)
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, &repos)
|
||||
}
|
||||
|
||||
@ -23,8 +23,9 @@ import (
|
||||
|
||||
// ListOwnerHooks lists the webhooks of the provided owner
|
||||
func ListOwnerHooks(ctx *context.APIContext, owner *user_model.User) {
|
||||
listOptions := GetListOptions(ctx)
|
||||
opts := &webhook.ListWebhookOptions{
|
||||
ListOptions: GetListOptions(ctx),
|
||||
ListOptions: listOptions,
|
||||
OwnerID: owner.ID,
|
||||
}
|
||||
|
||||
@ -42,7 +43,7 @@ func ListOwnerHooks(ctx *context.APIContext, owner *user_model.User) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiHooks)
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
// GetListOptions returns list options using the page and limit parameters
|
||||
func GetListOptions(ctx *context.APIContext) db.ListOptions {
|
||||
return db.ListOptions{
|
||||
Page: ctx.FormInt("page"),
|
||||
Page: max(ctx.FormInt("page"), 1),
|
||||
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@ func Branches(ctx *context.Context) {
|
||||
ctx.Data["AllowsPulls"] = ctx.Repo.Repository.AllowsPulls(ctx)
|
||||
ctx.Data["IsWriter"] = ctx.Repo.CanWrite(unit.TypeCode)
|
||||
ctx.Data["IsMirror"] = ctx.Repo.Repository.IsMirror
|
||||
// TODO: Can be replaced by ctx.Repo.PullRequestCtx.CanCreateNewPull()
|
||||
ctx.Data["CanPull"] = ctx.Repo.CanWrite(unit.TypeCode) ||
|
||||
(ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID))
|
||||
ctx.Data["PageIsViewCode"] = true
|
||||
|
||||
@ -247,7 +247,7 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
|
||||
}
|
||||
|
||||
// 4 get base and head refs
|
||||
baseRefName := util.IfZero(compareReq.BaseOriRef, baseRepo.DefaultBranch)
|
||||
baseRefName := util.IfZero(compareReq.BaseOriRef, baseRepo.GetPullRequestTargetBranch(ctx))
|
||||
headRefName := util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch)
|
||||
|
||||
baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(baseRefName)
|
||||
@ -276,10 +276,10 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
|
||||
ctx.Data["BaseBranch"] = baseRef.ShortName() // for legacy templates
|
||||
ctx.Data["HeadUser"] = headOwner
|
||||
ctx.Data["HeadBranch"] = headRef.ShortName() // for legacy templates
|
||||
ctx.Repo.PullRequest.SameRepo = isSameRepo
|
||||
|
||||
ctx.Data["IsPull"] = true
|
||||
|
||||
context.InitRepoPullRequestCtx(ctx, baseRepo, headRepo)
|
||||
|
||||
// The current base and head repositories and branches may not
|
||||
// actually be the intended branches that the user wants to
|
||||
// create a pull-request from - but also determining the head
|
||||
|
||||
@ -109,11 +109,6 @@ func MustAllowPulls(ctx *context.Context) {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// User can send pull request if owns a forked repository.
|
||||
if ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) {
|
||||
ctx.Repo.PullRequest.Allowed = true
|
||||
}
|
||||
}
|
||||
|
||||
func retrieveProjectsInternal(ctx *context.Context, repo *repo_model.Repository) (open, closed []*project_model.Project) {
|
||||
|
||||
@ -469,7 +469,7 @@ func prepareIssueViewSidebarDependency(ctx *context.Context, issue *issues_model
|
||||
ctx.Data["AllowCrossRepositoryDependencies"] = setting.Service.AllowCrossRepositoryDependencies
|
||||
|
||||
// Get Dependencies
|
||||
blockedBy, err := issue.BlockedByDependencies(ctx, db.ListOptions{})
|
||||
blockedBy, _, err := issue.BlockedByDependencies(ctx, db.ListOptions{})
|
||||
if err != nil {
|
||||
ctx.ServerError("BlockedByDependencies", err)
|
||||
return
|
||||
|
||||
@ -28,6 +28,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/validation"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
repo_router "code.gitea.io/gitea/routers/web/repo"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
@ -88,6 +89,11 @@ func SettingsCtxData(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
ctx.Data["PushMirrors"] = pushMirrors
|
||||
|
||||
repo_router.PrepareBranchList(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Settings show a repository's settings page
|
||||
@ -622,6 +628,7 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
|
||||
DefaultDeleteBranchAfterMerge: form.DefaultDeleteBranchAfterMerge,
|
||||
DefaultMergeStyle: repo_model.MergeStyle(form.PullsDefaultMergeStyle),
|
||||
DefaultAllowMaintainerEdit: form.DefaultAllowMaintainerEdit,
|
||||
DefaultTargetBranch: strings.TrimSpace(form.DefaultTargetBranch),
|
||||
}))
|
||||
} else if !unit_model.TypePullRequests.UnitGlobalDisabled() {
|
||||
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePullRequests)
|
||||
|
||||
@ -221,7 +221,7 @@ func APIContexter() func(http.Handler) http.Handler {
|
||||
ctx := &APIContext{
|
||||
Base: base,
|
||||
Cache: cache.GetCache(),
|
||||
Repo: &Repository{PullRequest: &PullRequest{}},
|
||||
Repo: &Repository{},
|
||||
Org: &APIOrganization{},
|
||||
}
|
||||
|
||||
|
||||
@ -129,7 +129,7 @@ func NewWebContext(base *Base, render Render, session session.Store) *Context {
|
||||
|
||||
Cache: cache.GetCache(),
|
||||
Link: setting.AppSubURL + strings.TrimSuffix(base.Req.URL.EscapedPath(), "/"),
|
||||
Repo: &Repository{PullRequest: &PullRequest{}},
|
||||
Repo: &Repository{},
|
||||
Org: &Organization{},
|
||||
}
|
||||
ctx.TemplateContext = NewTemplateContextForWeb(ctx)
|
||||
|
||||
@ -37,11 +37,46 @@ import (
|
||||
"github.com/editorconfig/editorconfig-core-go/v2"
|
||||
)
|
||||
|
||||
// PullRequest contains information to make a pull request
|
||||
type PullRequest struct {
|
||||
BaseRepo *repo_model.Repository
|
||||
Allowed bool // it only used by the web tmpl: "PullRequestCtx.Allowed"
|
||||
SameRepo bool // it only used by the web tmpl: "PullRequestCtx.SameRepo"
|
||||
// PullRequestContext contains context information for making a new pull request
|
||||
type PullRequestContext struct {
|
||||
ctx *Context
|
||||
|
||||
baseRepo, headRepo *repo_model.Repository
|
||||
|
||||
canCreateNewPull *bool
|
||||
defaultTargetBranch *string
|
||||
}
|
||||
|
||||
func (prc *PullRequestContext) SameRepo() bool {
|
||||
return prc.baseRepo != nil && prc.headRepo != nil && prc.baseRepo.ID == prc.headRepo.ID
|
||||
}
|
||||
|
||||
func (prc *PullRequestContext) CanCreateNewPull() bool {
|
||||
if prc.canCreateNewPull != nil {
|
||||
return *prc.canCreateNewPull
|
||||
}
|
||||
ctx := prc.ctx
|
||||
// People who have push access or have forked repository can propose a new pull request.
|
||||
can := prc.baseRepo.CanContentChange() &&
|
||||
(ctx.Repo.CanWrite(unit_model.TypeCode) || (ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)))
|
||||
prc.canCreateNewPull = &can
|
||||
return can
|
||||
}
|
||||
|
||||
func (prc *PullRequestContext) MakeDefaultCompareLink(headBranch string) string {
|
||||
return prc.baseRepo.Link() + "/compare/" +
|
||||
util.PathEscapeSegments(prc.DefaultTargetBranch()) + "..." +
|
||||
util.Iif(prc.SameRepo(), "", util.PathEscapeSegments(prc.headRepo.OwnerName)+":") +
|
||||
util.PathEscapeSegments(headBranch)
|
||||
}
|
||||
|
||||
func (prc *PullRequestContext) DefaultTargetBranch() string {
|
||||
if prc.defaultTargetBranch != nil {
|
||||
return *prc.defaultTargetBranch
|
||||
}
|
||||
branchName := prc.baseRepo.GetPullRequestTargetBranch(prc.ctx)
|
||||
prc.defaultTargetBranch = &branchName
|
||||
return branchName
|
||||
}
|
||||
|
||||
// Repository contains information to operate a repository
|
||||
@ -64,7 +99,7 @@ type Repository struct {
|
||||
CommitID string
|
||||
CommitsCount int64
|
||||
|
||||
PullRequest *PullRequest
|
||||
PullRequestCtx *PullRequestContext
|
||||
}
|
||||
|
||||
// CanWriteToBranch checks if the branch is writable by the user
|
||||
@ -418,6 +453,12 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) {
|
||||
ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty
|
||||
}
|
||||
|
||||
func InitRepoPullRequestCtx(ctx *Context, base, head *repo_model.Repository) {
|
||||
ctx.Repo.PullRequestCtx = &PullRequestContext{ctx: ctx}
|
||||
ctx.Repo.PullRequestCtx.baseRepo, ctx.Repo.PullRequestCtx.headRepo = base, head
|
||||
ctx.Data["PullRequestCtx"] = ctx.Repo.PullRequestCtx
|
||||
}
|
||||
|
||||
// RepoAssignment returns a middleware to handle repository assignment
|
||||
func RepoAssignment(ctx *Context) {
|
||||
if ctx.Data["Repository"] != nil {
|
||||
@ -666,28 +707,16 @@ func RepoAssignment(ctx *Context) {
|
||||
|
||||
ctx.Data["BranchesCount"] = branchesTotal
|
||||
|
||||
// People who have push access or have forked repository can propose a new pull request.
|
||||
canPush := ctx.Repo.CanWrite(unit_model.TypeCode) ||
|
||||
(ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID))
|
||||
canCompare := false
|
||||
|
||||
// Pull request is allowed if this is a fork repository
|
||||
// and base repository accepts pull requests.
|
||||
// Pull request is allowed if this is a fork repository, and base repository accepts pull requests.
|
||||
if repo.BaseRepo != nil && repo.BaseRepo.AllowsPulls(ctx) {
|
||||
canCompare = true
|
||||
// TODO: this (and below) "BaseRepo" var is not clear and should be removed in the future
|
||||
ctx.Data["BaseRepo"] = repo.BaseRepo
|
||||
ctx.Repo.PullRequest.BaseRepo = repo.BaseRepo
|
||||
ctx.Repo.PullRequest.Allowed = canPush
|
||||
InitRepoPullRequestCtx(ctx, repo.BaseRepo, repo)
|
||||
} else if repo.AllowsPulls(ctx) {
|
||||
// Or, this is repository accepts pull requests between branches.
|
||||
canCompare = true
|
||||
ctx.Data["BaseRepo"] = repo
|
||||
ctx.Repo.PullRequest.BaseRepo = repo
|
||||
ctx.Repo.PullRequest.Allowed = canPush
|
||||
ctx.Repo.PullRequest.SameRepo = true
|
||||
InitRepoPullRequestCtx(ctx, repo, repo)
|
||||
}
|
||||
ctx.Data["CanCompareOrPull"] = canCompare
|
||||
ctx.Data["PullRequestCtx"] = ctx.Repo.PullRequest
|
||||
|
||||
if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer {
|
||||
repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository)
|
||||
|
||||
@ -103,6 +103,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
|
||||
defaultDeleteBranchAfterMerge := false
|
||||
defaultMergeStyle := repo_model.MergeStyleMerge
|
||||
defaultAllowMaintainerEdit := false
|
||||
defaultTargetBranch := ""
|
||||
if unit, err := repo.GetUnit(ctx, unit_model.TypePullRequests); err == nil {
|
||||
config := unit.PullRequestsConfig()
|
||||
hasPullRequests = true
|
||||
@ -118,6 +119,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
|
||||
defaultDeleteBranchAfterMerge = config.DefaultDeleteBranchAfterMerge
|
||||
defaultMergeStyle = config.GetDefaultMergeStyle()
|
||||
defaultAllowMaintainerEdit = config.DefaultAllowMaintainerEdit
|
||||
defaultTargetBranch = config.DefaultTargetBranch
|
||||
}
|
||||
hasProjects := false
|
||||
projectsMode := repo_model.ProjectsModeAll
|
||||
@ -235,6 +237,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
|
||||
DefaultDeleteBranchAfterMerge: defaultDeleteBranchAfterMerge,
|
||||
DefaultMergeStyle: string(defaultMergeStyle),
|
||||
DefaultAllowMaintainerEdit: defaultAllowMaintainerEdit,
|
||||
DefaultTargetBranch: defaultTargetBranch,
|
||||
AvatarURL: repo.AvatarLink(ctx),
|
||||
Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate,
|
||||
MirrorInterval: mirrorInterval,
|
||||
|
||||
@ -143,6 +143,7 @@ type RepoSettingForm struct {
|
||||
PullsAllowRebaseUpdate bool
|
||||
DefaultDeleteBranchAfterMerge bool
|
||||
DefaultAllowMaintainerEdit bool
|
||||
DefaultTargetBranch string
|
||||
EnableTimetracker bool
|
||||
AllowOnlyContributorsToTrackTime bool
|
||||
EnableIssueDependencies bool
|
||||
|
||||
@ -547,10 +547,11 @@ func UpdateBranch(ctx context.Context, repo *repo_model.Repository, gitRepo *git
|
||||
return gitrepo.Push(ctx, repo, repo, pushOpts)
|
||||
}
|
||||
|
||||
var ErrBranchIsDefault = util.ErrorWrap(util.ErrPermissionDenied, "branch is default")
|
||||
var ErrBranchIsDefault = util.ErrorWrap(util.ErrPermissionDenied, "branch is default or pull request target")
|
||||
|
||||
func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchName string, doer *user_model.User) error {
|
||||
if branchName == repo.DefaultBranch {
|
||||
unitPRConfig := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
|
||||
if branchName == repo.DefaultBranch || branchName == unitPRConfig.DefaultTargetBranch {
|
||||
return ErrBranchIsDefault
|
||||
}
|
||||
|
||||
|
||||
@ -133,14 +133,14 @@
|
||||
<span class="ui orange large label" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.included_desc"}}">
|
||||
{{svg "octicon-git-pull-request"}} {{ctx.Locale.Tr "repo.branch.included"}}
|
||||
</span>
|
||||
{{else if and (not .DBBranch.IsDeleted) $.AllowsPulls (gt .CommitsAhead 0)}}
|
||||
<a href="{{$.RepoLink}}/compare/{{PathEscapeSegments $.DefaultBranchBranch.DBBranch.Name}}...{{if ne $.Repository.Owner.Name $.Owner.Name}}{{PathEscape $.Owner.Name}}:{{end}}{{PathEscapeSegments .DBBranch.Name}}?expand=1">
|
||||
{{else if and (not .DBBranch.IsDeleted) $.AllowsPulls (gt .CommitsAhead 0)}}
|
||||
<a href="{{$.PullRequestCtx.MakeDefaultCompareLink .DBBranch.Name}}?expand=1">
|
||||
<button id="new-pull-request" class="ui compact basic button tw-mr-0">{{if $.CanPull}}{{ctx.Locale.Tr "repo.pulls.compare_changes"}}{{else}}{{ctx.Locale.Tr "action.compare_branch"}}{{end}}</button>
|
||||
</a>
|
||||
{{end}}
|
||||
{{else if and .LatestPullRequest.HasMerged .MergeMovedOn}}
|
||||
{{if and (not .DBBranch.IsDeleted) $.AllowsPulls (gt .CommitsAhead 0)}}
|
||||
<a href="{{$.RepoLink}}/compare/{{PathEscapeSegments $.DefaultBranchBranch.DBBranch.Name}}...{{if ne $.Repository.Owner.Name $.Owner.Name}}{{PathEscape $.Owner.Name}}:{{end}}{{PathEscapeSegments .DBBranch.Name}}?expand=1">
|
||||
<a href="{{$.PullRequestCtx.MakeDefaultCompareLink .DBBranch.Name}}?expand=1">
|
||||
<button id="new-pull-request" class="ui compact basic button tw-mr-0">{{if $.CanPull}}{{ctx.Locale.Tr "repo.pulls.compare_changes"}}{{else}}{{ctx.Locale.Tr "action.compare_branch"}}{{end}}</button>
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
@ -24,11 +24,12 @@
|
||||
{{if .PageIsIssueList}}
|
||||
<a class="ui small primary button issue-list-new" href="{{.RepoLink}}/issues/new{{if .NewIssueChooseTemplate}}/choose{{end}}">{{ctx.Locale.Tr "repo.issues.new"}}</a>
|
||||
{{else}}
|
||||
<a class="ui small primary button new-pr-button issue-list-new{{if not .PullRequestCtx.Allowed}} disabled{{end}}" href="{{if .PullRequestCtx.Allowed}}{{.Repository.Link}}/compare/{{.Repository.DefaultBranch | PathEscapeSegments}}...{{if ne .Repository.Owner.Name .PullRequestCtx.BaseRepo.Owner.Name}}{{PathEscape .Repository.Owner.Name}}:{{end}}{{.Repository.DefaultBranch | PathEscapeSegments}}{{end}}">{{ctx.Locale.Tr "repo.pulls.new"}}</a>
|
||||
<a class="ui small primary button new-pr-button issue-list-new {{if not .PullRequestCtx.CanCreateNewPull}}disabled{{end}}" href="{{.PullRequestCtx.MakeDefaultCompareLink .Repository.DefaultBranch}}">{{ctx.Locale.Tr "repo.pulls.new"}}</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{/* archived, view compare page only */}}
|
||||
{{if not .PageIsIssueList}}
|
||||
<a class="ui small primary small button issue-list-new{{if not .PullRequestCtx.Allowed}} disabled{{end}}" href="{{if .PullRequestCtx.Allowed}}{{.PullRequestCtx.BaseRepo.Link}}/compare/{{.PullRequestCtx.BaseRepo.DefaultBranch | PathEscapeSegments}}...{{if ne .Repository.Owner.Name .PullRequestCtx.BaseRepo.Owner.Name}}{{PathEscape .Repository.Owner.Name}}:{{end}}{{.Repository.DefaultBranch | PathEscapeSegments}}{{end}}">{{ctx.Locale.Tr "action.compare_commits_general"}}</a>
|
||||
<a class="ui small primary small button issue-list-new" href="{{.PullRequestCtx.MakeDefaultCompareLink .Repository.DefaultBranch}}">{{ctx.Locale.Tr "action.compare_commits_general"}}</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@ -9,22 +9,23 @@
|
||||
{{ctx.Locale.Tr "repo.default_branch"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p>
|
||||
{{ctx.Locale.Tr "repo.settings.default_branch_desc"}}
|
||||
</p>
|
||||
<form class="tw-flex" action="{{.Link}}" method="post">
|
||||
<p>{{ctx.Locale.Tr "repo.settings.default_branch_desc"}}</p>
|
||||
<form class="ui form" action="{{.Link}}" method="post">
|
||||
<input type="hidden" name="action" value="default_branch">
|
||||
<div class="ui dropdown selection search tw-flex-1 tw-mr-2 tw-max-w-96">
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<input type="hidden" name="branch" value="{{.Repository.DefaultBranch}}">
|
||||
<div class="default text">{{.Repository.DefaultBranch}}</div>
|
||||
<div class="menu">
|
||||
{{range .Branches}}
|
||||
<div class="item" data-value="{{.}}">{{.}}</div>
|
||||
{{end}}
|
||||
<div class="flex-text-block">
|
||||
<div class="ui dropdown selection search tw-flex-1 tw-mr-2 tw-max-w-96">
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<input type="hidden" name="branch" value="{{.Repository.DefaultBranch}}">
|
||||
<div class="default text">{{.Repository.DefaultBranch}}</div>
|
||||
<div class="menu">
|
||||
{{range .Branches}}
|
||||
<div class="item" data-value="{{.}}">{{.}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<button class="ui primary button"{{if .Repository.IsEmpty}} disabled{{end}}>{{ctx.Locale.Tr "repo.settings.branches.update_default_branch"}}</button>
|
||||
</div>
|
||||
<button class="ui primary button"{{if .Repository.IsEmpty}} disabled{{end}}>{{ctx.Locale.Tr "repo.settings.branches.update_default_branch"}}</button>
|
||||
<div class="help tw-mt-4 tw-p-0">{{ctx.Locale.Tr "repo.settings.default_target_branch_desc"}}</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@ -287,6 +287,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* FIXME: need to split the "Advance Settings" by units, there are too many options here */}}
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "repo.settings.advanced_settings"}}
|
||||
</h4>
|
||||
@ -594,6 +595,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_target_branch"}}</label>
|
||||
<div class="ui search selection dropdown">
|
||||
<input type="hidden" name="default_target_branch" value="{{$prUnit.PullRequestsConfig.DefaultTargetBranch}}">
|
||||
<div class="default text"></div>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="menu">
|
||||
<div class="item" data-value="">{{ctx.Locale.Tr "repo.settings.pulls.default_target_branch_default" $.Repository.DefaultBranch}}</div>
|
||||
{{range $branchName := $.Branches}}
|
||||
<div class="item" data-value="{{$branchName}}">{{$branchName}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="default_allow_maintainer_edit" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.DefaultAllowMaintainerEdit)}}checked{{end}}>
|
||||
|
||||
@ -20,15 +20,10 @@
|
||||
"ShowViewAllRefsEntry" true
|
||||
}}
|
||||
|
||||
{{if and .CanCompareOrPull .RefFullName.IsBranch (not .Repository.IsArchived)}}
|
||||
{{$cmpBranch := ""}}
|
||||
{{if ne .Repository.ID .BaseRepo.ID}}
|
||||
{{$cmpBranch = printf "%s/%s:" (.Repository.OwnerName|PathEscape) (.Repository.Name|PathEscape)}}
|
||||
{{end}}
|
||||
{{$cmpBranch = print $cmpBranch (.BranchName|PathEscapeSegments)}}
|
||||
{{$compareLink := printf "%s/compare/%s...%s" .BaseRepo.Link (.BaseRepo.DefaultBranch|PathEscapeSegments) $cmpBranch}}
|
||||
{{if and .PullRequestCtx.CanCreateNewPull .RefFullName.IsBranch}}
|
||||
{{$compareLink := .PullRequestCtx.MakeDefaultCompareLink .BranchName}}
|
||||
<a id="new-pull-request" role="button" class="ui compact basic button" href="{{QueryBuild $compareLink "expand" 1}}"
|
||||
data-tooltip-content="{{if .PullRequestCtx.Allowed}}{{ctx.Locale.Tr "repo.pulls.compare_changes"}}{{else}}{{ctx.Locale.Tr "action.compare_branch"}}{{end}}">
|
||||
data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.compare_changes"}}">
|
||||
{{svg "octicon-git-pull-request"}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
4
templates/swagger/v1_json.tmpl
generated
4
templates/swagger/v1_json.tmpl
generated
@ -28158,6 +28158,10 @@
|
||||
"type": "string",
|
||||
"x-go-name": "DefaultMergeStyle"
|
||||
},
|
||||
"default_target_branch": {
|
||||
"type": "string",
|
||||
"x-go-name": "DefaultTargetBranch"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"x-go-name": "Description"
|
||||
|
||||
@ -307,6 +307,7 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
async reposFilterKeyControl(e: KeyboardEvent) {
|
||||
if (e.isComposing) return;
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click();
|
||||
|
||||
@ -1,22 +1,26 @@
|
||||
import {shouldHideLine, type LogLine} from './RepoActionView.vue';
|
||||
import {createLogLineMessage, parseLogLineCommand} from './RepoActionView.vue';
|
||||
|
||||
test('shouldHideLine', () => {
|
||||
expect(([
|
||||
{index: 1, message: 'Starting build process', timestamp: 1000},
|
||||
{index: 2, message: '::add-matcher::/home/runner/go/pkg/mod/example.com/tool/matcher.json', timestamp: 1001},
|
||||
{index: 3, message: 'Running tests...', timestamp: 1002},
|
||||
{index: 4, message: '##[add-matcher]/opt/hostedtoolcache/go/1.25.7/x64/matchers.json', timestamp: 1003},
|
||||
{index: 5, message: 'Test suite started', timestamp: 1004},
|
||||
{index: 7, message: 'All tests passed', timestamp: 1006},
|
||||
{index: 8, message: '::remove-matcher owner=go::', timestamp: 1007},
|
||||
{index: 9, message: 'Build complete', timestamp: 1008},
|
||||
] as Array<LogLine>).filter((line) => !shouldHideLine(line)).map((line) => line.message)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"Starting build process",
|
||||
"Running tests...",
|
||||
"Test suite started",
|
||||
"All tests passed",
|
||||
"Build complete",
|
||||
]
|
||||
`);
|
||||
test('LogLineMessage', () => {
|
||||
const cases = {
|
||||
'normal message': '<span class="log-msg">normal message</span>',
|
||||
'##[group] foo': '<span class="log-msg log-cmd-group"> foo</span>',
|
||||
'::group::foo': '<span class="log-msg log-cmd-group">foo</span>',
|
||||
'##[endgroup]': '<span class="log-msg log-cmd-endgroup"></span>',
|
||||
'::endgroup::': '<span class="log-msg log-cmd-endgroup"></span>',
|
||||
|
||||
// parser shouldn't do any trim, keep origin output as-is
|
||||
'##[error] foo': '<span class="log-msg log-cmd-error"> foo</span>',
|
||||
'[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
|
||||
|
||||
// hidden is special, it is actually skipped before creating
|
||||
'##[add-matcher]foo': '<span class="log-msg log-cmd-hidden">foo</span>',
|
||||
'::add-matcher::foo': '<span class="log-msg log-cmd-hidden">foo</span>',
|
||||
'::remove-matcher foo::': '<span class="log-msg log-cmd-hidden"> foo::</span>', // not correctly parsed, but we don't need it
|
||||
};
|
||||
for (const [input, html] of Object.entries(cases)) {
|
||||
const line = {index: 0, timestamp: 0, message: input};
|
||||
const cmd = parseLogLineCommand(line);
|
||||
const el = createLogLineMessage(line, cmd);
|
||||
expect(el.outerHTML).toBe(html);
|
||||
}
|
||||
});
|
||||
|
||||
@ -14,7 +14,12 @@ import {localUserSettings} from '../modules/user-settings.ts';
|
||||
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
|
||||
type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
|
||||
|
||||
type StepContainerElement = HTMLElement & {_stepLogsActiveContainer?: HTMLElement}
|
||||
type StepContainerElement = HTMLElement & {
|
||||
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
|
||||
// then the following batches of logs should still use the same group (active logs container).
|
||||
// maybe it can be refactored to decouple from the HTML element in the future.
|
||||
_stepLogsActiveContainer?: HTMLElement;
|
||||
}
|
||||
|
||||
export type LogLine = {
|
||||
index: number;
|
||||
@ -22,19 +27,35 @@ export type LogLine = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
// `##[group]` is from Azure Pipelines, just supported by the way. https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands
|
||||
const LogLinePrefixesGroup = ['::group::', '##[group]'];
|
||||
const LogLinePrefixesEndGroup = ['::endgroup::', '##[endgroup]'];
|
||||
// https://github.com/actions/toolkit/blob/master/docs/commands.md
|
||||
// https://github.com/actions/runner/blob/main/docs/adrs/0276-problem-matchers.md#registration
|
||||
// Although there should be no `##[add-matcher]` syntax, there are still such outputs when using act-runner
|
||||
const LogLinePrefixesHidden = ['::add-matcher::', '##[add-matcher]', '::remove-matcher'];
|
||||
|
||||
type LogLineCommandName = 'group' | 'endgroup' | 'command' | 'error' | 'hidden';
|
||||
type LogLineCommand = {
|
||||
name: 'group' | 'endgroup',
|
||||
name: LogLineCommandName,
|
||||
prefix: string,
|
||||
}
|
||||
|
||||
// How GitHub Actions logs work:
|
||||
// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
|
||||
// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
|
||||
// * The reported logs are the processed logs.
|
||||
// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
|
||||
const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
|
||||
'::group::': 'group',
|
||||
'##[group]': 'group',
|
||||
'::endgroup::': 'endgroup',
|
||||
'##[endgroup]': 'endgroup',
|
||||
|
||||
'##[error]': 'error',
|
||||
'[command]': 'command',
|
||||
|
||||
// https://github.com/actions/toolkit/blob/master/docs/commands.md
|
||||
// https://github.com/actions/runner/blob/main/docs/adrs/0276-problem-matchers.md#registration
|
||||
'::add-matcher::': 'hidden',
|
||||
'##[add-matcher]': 'hidden',
|
||||
'::remove-matcher': 'hidden', // it has arguments
|
||||
};
|
||||
|
||||
|
||||
type Job = {
|
||||
id: number;
|
||||
job_id: string;
|
||||
@ -57,27 +78,27 @@ type JobStepState = {
|
||||
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
|
||||
}
|
||||
|
||||
function parseLineCommand(line: LogLine): LogLineCommand | null {
|
||||
for (const prefix of LogLinePrefixesGroup) {
|
||||
export function parseLogLineCommand(line: LogLine): LogLineCommand | null {
|
||||
// TODO: in the future it can be refactored to be a general parser that can parse arguments, drop the "prefix match"
|
||||
for (const prefix in LogLinePrefixCommandMap) {
|
||||
if (line.message.startsWith(prefix)) {
|
||||
return {name: 'group', prefix};
|
||||
}
|
||||
}
|
||||
for (const prefix of LogLinePrefixesEndGroup) {
|
||||
if (line.message.startsWith(prefix)) {
|
||||
return {name: 'endgroup', prefix};
|
||||
return {name: LogLinePrefixCommandMap[prefix], prefix};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldHideLine(line: LogLine): boolean {
|
||||
for (const prefix of LogLinePrefixesHidden) {
|
||||
if (line.message.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
|
||||
const logMsgAttrs = {class: 'log-msg'};
|
||||
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd?.name}`; // make it easier to add styles to some commands like "error"
|
||||
|
||||
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
|
||||
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
|
||||
const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
|
||||
|
||||
const logMsg = createElementFromAttrs('span', logMsgAttrs);
|
||||
logMsg.innerHTML = renderAnsi(msgContent);
|
||||
return logMsg;
|
||||
}
|
||||
|
||||
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
|
||||
@ -255,11 +276,7 @@ export default defineComponent({
|
||||
beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
|
||||
const el = (this.$refs.logs as any)[stepIndex] as StepContainerElement;
|
||||
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
|
||||
this.createLogLine(stepIndex, startTime, {
|
||||
index: line.index,
|
||||
timestamp: line.timestamp,
|
||||
message: line.message.substring(cmd.prefix.length),
|
||||
}),
|
||||
this.createLogLine(stepIndex, startTime, line, cmd),
|
||||
);
|
||||
const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
|
||||
const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
|
||||
@ -273,11 +290,7 @@ export default defineComponent({
|
||||
endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
|
||||
const el = (this.$refs.logs as any)[stepIndex];
|
||||
el._stepLogsActiveContainer = null;
|
||||
el.append(this.createLogLine(stepIndex, startTime, {
|
||||
index: line.index,
|
||||
timestamp: line.timestamp,
|
||||
message: line.message.substring(cmd.prefix.length),
|
||||
}));
|
||||
el.append(this.createLogLine(stepIndex, startTime, line, cmd));
|
||||
},
|
||||
|
||||
// show/hide the step logs for a step
|
||||
@ -298,7 +311,7 @@ export default defineComponent({
|
||||
POST(`${this.run.link}/approve`);
|
||||
},
|
||||
|
||||
createLogLine(stepIndex: number, startTime: number, line: LogLine) {
|
||||
createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand | null) {
|
||||
const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
|
||||
String(line.index),
|
||||
);
|
||||
@ -307,9 +320,7 @@ export default defineComponent({
|
||||
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
|
||||
);
|
||||
|
||||
const logMsg = createElementFromAttrs('span', {class: 'log-msg'});
|
||||
logMsg.innerHTML = renderAnsi(line.message);
|
||||
|
||||
const logMsg = createLogLineMessage(line, cmd);
|
||||
const seconds = Math.floor(line.timestamp - startTime);
|
||||
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
|
||||
`${seconds}s`, // for "Show seconds"
|
||||
@ -334,17 +345,20 @@ export default defineComponent({
|
||||
|
||||
appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
|
||||
for (const line of logLines) {
|
||||
if (shouldHideLine(line)) continue;
|
||||
const el = this.getActiveLogsContainer(stepIndex);
|
||||
const cmd = parseLineCommand(line);
|
||||
if (cmd?.name === 'group') {
|
||||
this.beginLogGroup(stepIndex, startTime, line, cmd);
|
||||
continue;
|
||||
} else if (cmd?.name === 'endgroup') {
|
||||
this.endLogGroup(stepIndex, startTime, line, cmd);
|
||||
continue;
|
||||
const cmd = parseLogLineCommand(line);
|
||||
switch (cmd?.name) {
|
||||
case 'hidden':
|
||||
continue;
|
||||
case 'group':
|
||||
this.beginLogGroup(stepIndex, startTime, line, cmd);
|
||||
continue;
|
||||
case 'endgroup':
|
||||
this.endLogGroup(stepIndex, startTime, line, cmd);
|
||||
continue;
|
||||
}
|
||||
el.append(this.createLogLine(stepIndex, startTime, line));
|
||||
// the active logs container may change during the loop, for example: entering and leaving a group
|
||||
const el = this.getActiveLogsContainer(stepIndex);
|
||||
el.append(this.createLogLine(stepIndex, startTime, line, cmd));
|
||||
}
|
||||
},
|
||||
|
||||
@ -1047,6 +1061,14 @@ export default defineComponent({
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.job-step-logs .job-log-line .log-cmd-command {
|
||||
color: var(--color-ansi-blue);
|
||||
}
|
||||
|
||||
.job-step-logs .job-log-line .log-cmd-error {
|
||||
color: var(--color-ansi-red);
|
||||
}
|
||||
|
||||
/* selectors here are intentionally exact to only match fullscreen */
|
||||
|
||||
.full.height > .action-view-right {
|
||||
|
||||
@ -159,6 +159,7 @@ export default defineComponent({
|
||||
return el?.length ? el[0] : null;
|
||||
},
|
||||
keydown(e: KeyboardEvent) {
|
||||
if (e.isComposing) return;
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
@ -42,6 +42,8 @@ const handleSearchInput = () => {
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.isComposing) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
clearSearch();
|
||||
|
||||
@ -14,6 +14,7 @@ export function initGlobalFormDirtyLeaveConfirm() {
|
||||
|
||||
export function initGlobalEnterQuickSubmit() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return;
|
||||
if (e.key !== 'Enter') return;
|
||||
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
|
||||
if (hasCtrlOrMeta && (e.target as HTMLElement).matches('textarea')) {
|
||||
|
||||
@ -223,6 +223,7 @@ function isTextExpanderShown(textarea: HTMLElement): boolean {
|
||||
|
||||
export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return;
|
||||
if (isTextExpanderShown(textarea)) return;
|
||||
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// use Tab/Shift-Tab to indent/unindent the selected lines
|
||||
|
||||
@ -82,6 +82,7 @@ function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
|
||||
});
|
||||
// alt(or option) + enter to exclude selected label
|
||||
elDropdown.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.isComposing) return;
|
||||
if (e.altKey && e.key === 'Enter') {
|
||||
const selectedItem = elDropdown.querySelector('.label-filter-query-item.selected');
|
||||
if (selectedItem) excludeLabel(e, selectedItem);
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
|
||||
export function makeCodeCopyButton(): HTMLButtonElement {
|
||||
export function makeCodeCopyButton(attrs: Record<string, string> = {}): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.classList.add('code-copy', 'ui', 'button');
|
||||
button.innerHTML = svg('octicon-copy');
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
button.setAttribute(key, value);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
|
||||
59
web_src/js/markup/mermaid.test.ts
Normal file
59
web_src/js/markup/mermaid.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import {sourceNeedsElk} from './mermaid.ts';
|
||||
import {dedent} from '../utils/testhelper.ts';
|
||||
|
||||
test('MermaidConfigLayoutCheck', () => {
|
||||
expect(sourceNeedsElk(dedent(`
|
||||
flowchart TB
|
||||
elk --> B
|
||||
`))).toEqual(false);
|
||||
|
||||
expect(sourceNeedsElk(dedent(`
|
||||
---
|
||||
config:
|
||||
layout : elk
|
||||
---
|
||||
flowchart TB
|
||||
A --> B
|
||||
`))).toEqual(true);
|
||||
|
||||
expect(sourceNeedsElk(dedent(`
|
||||
---
|
||||
config:
|
||||
layout: elk.layered
|
||||
---
|
||||
flowchart TB
|
||||
A --> B
|
||||
`))).toEqual(true);
|
||||
|
||||
expect(sourceNeedsElk(`
|
||||
%%{ init : { "flowchart": { "defaultRenderer": "elk" } } }%%
|
||||
flowchart TB
|
||||
A --> B
|
||||
`)).toEqual(true);
|
||||
|
||||
expect(sourceNeedsElk(dedent(`
|
||||
---
|
||||
config:
|
||||
layout: 123
|
||||
---
|
||||
%%{ init : { "class": { "defaultRenderer": "elk.any" } } }%%
|
||||
flowchart TB
|
||||
A --> B
|
||||
`))).toEqual(true);
|
||||
|
||||
expect(sourceNeedsElk(`
|
||||
%%{init:{
|
||||
"layout" : "elk.layered"
|
||||
}}%%
|
||||
flowchart TB
|
||||
A --> B
|
||||
`)).toEqual(true);
|
||||
|
||||
expect(sourceNeedsElk(`
|
||||
%%{ initialize: {
|
||||
'layout' : 'elk.layered'
|
||||
}}%%
|
||||
flowchart TB
|
||||
A --> B
|
||||
`)).toEqual(true);
|
||||
});
|
||||
@ -1,8 +1,10 @@
|
||||
import {isDarkTheme} from '../utils.ts';
|
||||
import {isDarkTheme, parseDom} from '../utils.ts';
|
||||
import {makeCodeCopyButton} from './codecopy.ts';
|
||||
import {displayError} from './common.ts';
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
import {createElementFromAttrs, queryElems} from '../utils/dom.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
import {load as loadYaml} from 'js-yaml';
|
||||
import type {MermaidConfig} from 'mermaid';
|
||||
|
||||
const {mermaidMaxSourceCharacters} = window.config;
|
||||
|
||||
@ -10,78 +12,143 @@ const iframeCss = `:root {color-scheme: normal}
|
||||
body {margin: 0; padding: 0; overflow: hidden}
|
||||
#mermaid {display: block; margin: 0 auto}`;
|
||||
|
||||
function isSourceTooLarge(source: string) {
|
||||
return mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters;
|
||||
}
|
||||
|
||||
function parseYamlInitConfig(source: string): MermaidConfig | null {
|
||||
// ref: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/diagram-api/regexes.ts
|
||||
const yamlFrontMatterRegex = /^---\s*[\n\r](.*?)[\n\r]---\s*[\n\r]+/s;
|
||||
const frontmatter = (yamlFrontMatterRegex.exec(source) || [])[1];
|
||||
if (!frontmatter) return null;
|
||||
try {
|
||||
return (loadYaml(frontmatter) as {config: MermaidConfig})?.config;
|
||||
} catch {
|
||||
console.error('invalid or unsupported mermaid init YAML config', frontmatter);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJsonInitConfig(source: string): MermaidConfig | null {
|
||||
// https://mermaid.js.org/config/directives.html#declaring-directives
|
||||
// Do as dirty as mermaid does: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/utils.ts
|
||||
// It can even accept invalid JSON string like:
|
||||
// %%{initialize: { 'logLevel': 'fatal', "theme":'dark', 'startOnLoad': true } }%%
|
||||
const jsonInitConfigRegex = /%%\{\s*(init|initialize)\s*:\s*(.*?)\}%%/s;
|
||||
const jsonInitText = (jsonInitConfigRegex.exec(source) || [])[2];
|
||||
if (!jsonInitText) return null;
|
||||
try {
|
||||
const processed = jsonInitText.trim().replace(/'/g, '"');
|
||||
return JSON.parse(processed);
|
||||
} catch {
|
||||
console.error('invalid or unsupported mermaid init JSON config', jsonInitText);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function configValueIsElk(layoutOrRenderer: string | undefined) {
|
||||
if (typeof layoutOrRenderer !== 'string') return false;
|
||||
return layoutOrRenderer === 'elk' || layoutOrRenderer.startsWith('elk.');
|
||||
}
|
||||
|
||||
function configContainsElk(config: MermaidConfig | null) {
|
||||
if (!config) return false;
|
||||
// Check the layout from the following properties:
|
||||
// * config.layout
|
||||
// * config.{any-diagram-config}.defaultRenderer
|
||||
// Although only a few diagram types like "flowchart" support "defaultRenderer",
|
||||
// as long as there is no side effect, here do a general check for all properties of "config", for ease of maintenance
|
||||
return configValueIsElk(config.layout) || Object.values(config).some((diagCfg) => configValueIsElk(diagCfg?.defaultRenderer));
|
||||
}
|
||||
|
||||
export function sourceNeedsElk(source: string) {
|
||||
if (isSourceTooLarge(source)) return false;
|
||||
const configYaml = parseYamlInitConfig(source), configJson = parseJsonInitConfig(source);
|
||||
return configContainsElk(configYaml) || configContainsElk(configJson);
|
||||
}
|
||||
|
||||
async function loadMermaid(needElkRender: boolean) {
|
||||
const mermaidPromise = import(/* webpackChunkName: "mermaid" */'mermaid');
|
||||
const elkPromise = needElkRender ? import(/* webpackChunkName: "mermaid-layout-elk" */'@mermaid-js/layout-elk') : null;
|
||||
const results = await Promise.all([mermaidPromise, elkPromise]);
|
||||
return {
|
||||
mermaid: results[0].default,
|
||||
elkLayouts: results[1]?.default,
|
||||
};
|
||||
}
|
||||
|
||||
let elkLayoutsRegistered = false;
|
||||
|
||||
export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void> {
|
||||
// .markup code.language-mermaid
|
||||
queryElems(elMarkup, 'code.language-mermaid', async (el) => {
|
||||
const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid');
|
||||
const mermaidBlocks: Array<{source: string, parentContainer: HTMLElement}> = [];
|
||||
const attrMermaidRendered = 'data-markup-mermaid-rendered';
|
||||
let needElkRender = false;
|
||||
for (const elCodeBlock of queryElems(elMarkup, 'code.language-mermaid')) {
|
||||
const parentContainer = elCodeBlock.closest('pre')!; // it must exist, if no, there must be a bug
|
||||
if (parentContainer.hasAttribute(attrMermaidRendered)) continue;
|
||||
parentContainer.setAttribute(attrMermaidRendered, 'true');
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDarkTheme() ? 'dark' : 'neutral',
|
||||
securityLevel: 'strict',
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
const source = elCodeBlock.textContent ?? '';
|
||||
needElkRender = needElkRender || sourceNeedsElk(source);
|
||||
mermaidBlocks.push({source, parentContainer});
|
||||
}
|
||||
if (!mermaidBlocks.length) return;
|
||||
|
||||
const pre = el.closest('pre');
|
||||
if (!pre || pre.hasAttribute('data-render-done')) return;
|
||||
const {mermaid, elkLayouts} = await loadMermaid(needElkRender);
|
||||
if (elkLayouts && !elkLayoutsRegistered) {
|
||||
mermaid.registerLayoutLoaders(elkLayouts);
|
||||
elkLayoutsRegistered = true;
|
||||
}
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDarkTheme() ? 'dark' : 'neutral', // TODO: maybe it should use "darkMode" to adopt more user-specified theme instead of just "dark" or "neutral"
|
||||
securityLevel: 'strict',
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
|
||||
const source = el.textContent;
|
||||
if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) {
|
||||
displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`));
|
||||
return;
|
||||
// mermaid is a globally shared instance, its document also says "Multiple calls to this function will be enqueued to run serially."
|
||||
// so here we just simply render the mermaid blocks one by one, no need to do "Promise.all" concurrently
|
||||
for (const block of mermaidBlocks) {
|
||||
const {source, parentContainer} = block;
|
||||
if (isSourceTooLarge(source)) {
|
||||
displayError(parentContainer, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await mermaid.parse(source);
|
||||
} catch (err) {
|
||||
displayError(pre, err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// can't use bindFunctions here because we can't cross the iframe boundary. This
|
||||
// means js-based interactions won't work but they aren't intended to work either
|
||||
const {svg} = await mermaid.render('mermaid', source);
|
||||
// render the mermaid diagram to svg text, and parse it to a DOM node
|
||||
const {svg: svgText, bindFunctions} = await mermaid.render('mermaid', source, parentContainer);
|
||||
const svgDoc = parseDom(svgText, 'image/svg+xml');
|
||||
const svgNode = (svgDoc.documentElement as unknown) as SVGSVGElement;
|
||||
|
||||
// create an iframe to sandbox the svg with styles, and set correct height by reading svg's viewBox height
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.classList.add('markup-content-iframe', 'tw-invisible');
|
||||
iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg)}</body></html>`;
|
||||
iframe.classList.add('markup-content-iframe', 'is-loading');
|
||||
iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body></body></html>`;
|
||||
|
||||
const mermaidBlock = document.createElement('div');
|
||||
mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden');
|
||||
mermaidBlock.append(iframe);
|
||||
|
||||
const btn = makeCodeCopyButton();
|
||||
btn.setAttribute('data-clipboard-text', source);
|
||||
mermaidBlock.append(btn);
|
||||
|
||||
const updateIframeHeight = () => {
|
||||
const body = iframe.contentWindow?.document?.body;
|
||||
if (body) {
|
||||
iframe.style.height = `${body.clientHeight}px`;
|
||||
}
|
||||
};
|
||||
// although the "viewBox" is optional, mermaid's output should always have a correct viewBox with width and height
|
||||
const iframeHeightFromViewBox = Math.ceil(svgNode.viewBox?.baseVal?.height ?? 0);
|
||||
if (iframeHeightFromViewBox) iframe.style.height = `${iframeHeightFromViewBox}px`;
|
||||
|
||||
// the iframe will be fully reloaded if its DOM context is changed (e.g.: moved in the DOM tree).
|
||||
// to avoid unnecessary reloading, we should insert the iframe to its final position only once.
|
||||
iframe.addEventListener('load', () => {
|
||||
pre.replaceWith(mermaidBlock);
|
||||
mermaidBlock.classList.remove('tw-hidden');
|
||||
updateIframeHeight();
|
||||
setTimeout(() => { // avoid flash of iframe background
|
||||
mermaidBlock.classList.remove('is-loading');
|
||||
iframe.classList.remove('tw-invisible');
|
||||
}, 0);
|
||||
// same origin, so we can operate "iframe body" and all elements directly
|
||||
const iframeBody = iframe.contentDocument!.body;
|
||||
iframeBody.append(svgNode);
|
||||
bindFunctions?.(iframeBody); // follow "mermaid.render" doc, attach event handlers to the svg's container
|
||||
|
||||
// update height when element's visibility state changes, for example when the diagram is inside
|
||||
// a <details> + <summary> block and the <details> block becomes visible upon user interaction, it
|
||||
// would initially set a incorrect height and the correct height is set during this callback.
|
||||
(new IntersectionObserver(() => {
|
||||
updateIframeHeight();
|
||||
}, {root: document.documentElement})).observe(iframe);
|
||||
// according to mermaid, the viewBox height should always exist, here just a fallback for unknown cases.
|
||||
// and keep in mind: clientHeight can be 0 if the element is hidden (display: none).
|
||||
if (!iframeHeightFromViewBox && iframeBody.clientHeight) iframe.style.height = `${iframeBody.clientHeight}px`;
|
||||
iframe.classList.remove('is-loading');
|
||||
});
|
||||
|
||||
document.body.append(mermaidBlock);
|
||||
const container = createElementFromAttrs('div', {class: 'mermaid-block'}, iframe, makeCodeCopyButton({'data-clipboard-text': source}));
|
||||
parentContainer.replaceWith(container);
|
||||
} catch (err) {
|
||||
displayError(pre, err);
|
||||
displayError(parentContainer, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -225,6 +225,7 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
|
||||
};
|
||||
|
||||
dropdown.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.isComposing) return;
|
||||
// here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler
|
||||
if (e.key === 'Enter') {
|
||||
const elItem = menu.querySelector<HTMLElement>(':scope > .item.selected, .menu > .item.selected');
|
||||
|
||||
@ -4,3 +4,19 @@
|
||||
export function isInFrontendUnitTest() {
|
||||
return import.meta.env.TEST === 'true';
|
||||
}
|
||||
|
||||
/** strip common indentation from a string and trim it */
|
||||
export function dedent(str: string) {
|
||||
const match = str.match(/^[ \t]*(?=\S)/gm);
|
||||
if (!match) return str;
|
||||
|
||||
let minIndent = Number.POSITIVE_INFINITY;
|
||||
for (const indent of match) {
|
||||
minIndent = Math.min(minIndent, indent.length);
|
||||
}
|
||||
if (minIndent === 0 || minIndent === Number.POSITIVE_INFINITY) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return str.replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '').trim();
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
||||
const div = document.createElement('div');
|
||||
div.tabIndex = -1; // for initial focus, programmatic focus only
|
||||
div.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return;
|
||||
if (e.key === 'Tab') {
|
||||
const items = this.tippyContent.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
||||
if (e.shiftKey) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user