Merge remote-tracking branch 'refs/remotes/origin/agit/update' into agit/update

This commit is contained in:
hiifong 2025-01-11 15:49:22 +08:00
commit 013d437373
No known key found for this signature in database
GPG Key ID: 0814559E2F97194D
48 changed files with 1055 additions and 293 deletions

View File

@ -13,5 +13,5 @@ contact_links:
url: https://docs.gitea.com/help/faq
about: Please check if your question isn't mentioned here.
- name: Crowdin Translations
url: https://crowdin.com/project/gitea
url: https://translate.gitea.com
about: Translations are managed here.

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ _test
# IntelliJ
.idea
.run
# IntelliJ Gateway
.uuid

View File

@ -182,7 +182,7 @@ Here's how to run the test suite:
## Translation
All translation work happens on [Crowdin](https://crowdin.com/project/gitea).
All translation work happens on [Crowdin](https://translate.gitea.com).
The only translation that is maintained in this repository is [the English translation](https://github.com/go-gitea/gitea/blob/main/options/locale/locale_en-US.ini).
It is synced regularly with Crowdin. \
Other locales on main branch **should not** be updated manually as they will be overwritten with each sync. \

View File

@ -9,7 +9,7 @@
[![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
[![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT")
[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod&color=green)](https://gitpod.io/#https://github.com/go-gitea/gitea)
[![](https://badges.crowdin.net/gitea/localized.svg)](https://crowdin.com/project/gitea "Crowdin")
[![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin")
[View this document in Chinese](./README_ZH.md)
@ -31,6 +31,14 @@ For accessing free Gitea service (with a limited number of repositories), you ca
To quickly deploy your own dedicated Gitea instance on Gitea Cloud, you can start a free trial at [cloud.gitea.com](https://cloud.gitea.com).
## Documentation
You can find comprehensive documentation on our official [documentation website](https://docs.gitea.com/).
It includes installation, administration, usage, development, contributing guides, and more to help you get started and explore all features effectively.
If you have any suggestions or would like to contribute to it, you can visit the [documentation repository](https://gitea.com/gitea/docs)
## Building
From the root of the source tree, run:
@ -52,6 +60,8 @@ More info: https://docs.gitea.com/installation/install-from-source
## Using
After building, a binary file named `gitea` will be generated in the root of the source tree by default. To run it, use:
./gitea web
> [!NOTE]
@ -68,22 +78,25 @@ Expected workflow is: Fork -> Patch -> Push -> Pull Request
## Translating
Translations are done through Crowdin. If you want to translate to a new language ask one of the managers in the Crowdin project to add a new language there.
[![Crowdin](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com)
Translations are done through [Crowdin](https://translate.gitea.com). If you want to translate to a new language ask one of the managers in the Crowdin project to add a new language there.
You can also just create an issue for adding a language or ask on discord on the #translation channel. If you need context or find some translation issues, you can leave a comment on the string or ask on Discord. For general translation questions there is a section in the docs. Currently a bit empty but we hope to fill it as questions pop up.
https://docs.gitea.com/contributing/localization
Get more information from [documentation](https://docs.gitea.com/contributing/localization).
[![Crowdin](https://badges.crowdin.net/gitea/localized.svg)](https://crowdin.com/project/gitea)
## Official and Third-Party Projects
## Further information
We provide an official [go-sdk](https://gitea.com/gitea/go-sdk), a CLI tool called [tea](https://gitea.com/gitea/tea) and an [action runner](https://gitea.com/gitea/act_runner) for Gitea Action.
For more information and instructions about how to install Gitea, please look at our [documentation](https://docs.gitea.com/).
If you have questions that are not covered by the documentation, you can get in contact with us on our [Discord server](https://discord.gg/Gitea) or create a post in the [discourse forum](https://forum.gitea.com/).
We maintain a list of Gitea-related projects at [gitea/awesome-gitea](https://gitea.com/gitea/awesome-gitea), where you can discover more third-party projects, including SDKs, plugins, themes, and more.
We maintain a list of Gitea-related projects at [gitea/awesome-gitea](https://gitea.com/gitea/awesome-gitea).
## Communication
The official Gitea CLI is developed at [gitea/tea](https://gitea.com/gitea/tea).
[![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea")
If you have questions that are not covered by the [documentation](https://docs.gitea.com/), you can get in contact with us on our [Discord server](https://discord.gg/Gitea) or create a post in the [discourse forum](https://forum.gitea.com/).
## Authors
@ -122,18 +135,25 @@ Gitea is pronounced [/ɡɪti:/](https://youtu.be/EM71-2uDAoY) as in "gi-tea"
We're [working on it](https://github.com/go-gitea/gitea/issues/1029).
**Where can I find the security patches?**
In the [release log](https://github.com/go-gitea/gitea/releases) or the [change log](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md), search for the keyword `SECURITY` to find the security patches.
## License
This project is licensed under the MIT License.
See the [LICENSE](https://github.com/go-gitea/gitea/blob/main/LICENSE) file
for the full license text.
## Screenshots
## Further information
Looking for an overview of the interface? Check it out!
<details>
<summary>Looking for an overview of the interface? Check it out!</summary>
|![Dashboard](https://dl.gitea.com/screenshots/home_timeline.png)|![User Profile](https://dl.gitea.com/screenshots/user_profile.png)|![Global Issues](https://dl.gitea.com/screenshots/global_issues.png)|
|:---:|:---:|:---:|
|![Branches](https://dl.gitea.com/screenshots/branches.png)|![Web Editor](https://dl.gitea.com/screenshots/web_editor.png)|![Activity](https://dl.gitea.com/screenshots/activity.png)|
|![New Migration](https://dl.gitea.com/screenshots/migration.png)|![Migrating](https://dl.gitea.com/screenshots/migration.gif)|![Pull Request View](https://image.ibb.co/e02dSb/6.png)|
|![Pull Request Dark](https://dl.gitea.com/screenshots/pull_requests_dark.png)|![Diff Review Dark](https://dl.gitea.com/screenshots/review_dark.png)|![Diff Dark](https://dl.gitea.com/screenshots/diff_dark.png)|
</details>

View File

@ -9,13 +9,13 @@
[![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
[![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT")
[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod&color=green)](https://gitpod.io/#https://github.com/go-gitea/gitea)
[![](https://badges.crowdin.net/gitea/localized.svg)](https://crowdin.com/project/gitea "Crowdin")
[![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin")
[View this document in English](./README.md)
## 目标
Gitea 的首要目标是创建一个极易安装,运行非常快速,安装和使用体验良好的自建 Git 服务。我们采用 Go 作为后端语言,这使我们只要生成一个可执行程序即可。并且他还支持跨平台,支持 Linux, macOS 和 Windows 以及各种架构,除了 x86amd64还包括 ARM 和 PowerPC。
Gitea 的首要目标是创建一个极易安装,运行非常快速,安装和使用体验良好的自建 Git 服务。我们采用 Go 作为后端语言,这使我们只要生成一个可执行程序即可。并且他还支持跨平台,支持 Linux、macOS 和 Windows 以及各种架构,除了 x86 和 amd64还包括 ARM 和 PowerPC。
如果你想试用在线演示和报告问题,请访问 [demo.gitea.com](https://demo.gitea.com/)。
@ -23,39 +23,80 @@ Gitea 的首要目标是创建一个极易安装,运行非常快速,安装
如果你想在 Gitea Cloud 上快速部署你自己独享的 Gitea 实例,请访问 [cloud.gitea.com](https://cloud.gitea.com) 开始免费试用。
## 提示
1. **开始贡献代码之前请确保你已经看过了 [贡献者向导(英文)](CONTRIBUTING.md)**.
2. 所有的安全问题,请私下发送邮件给 **security@gitea.io**。谢谢!
3. 如果你要使用API请参见 [API 文档](https://godoc.org/code.gitea.io/sdk/gitea).
## 文档
关于如何安装请访问我们的 [文档站](https://docs.gitea.com/zh-cn/category/installation),如果没有找到对应的文档,你也可以通过 [Discord - 英文](https://discord.gg/gitea) 和 QQ群 328432459 来和我们交流。
## 贡献流程
## 编译
Fork -> Patch -> Push -> Pull Request
在源代码的根目录下执行:
TAGS="bindata" make build
或者如果需要SQLite支持
TAGS="bindata sqlite sqlite_unlock_notify" make build
编译过程会分成2个子任务
- `make backend`,需要 [Go Stable](https://go.dev/dl/),最低版本需求可查看 [go.mod](/go.mod)。
- `make frontend`,需要 [Node.js LTS](https://nodejs.org/en/download/) 或更高版本。
你需要连接网络来下载 go 和 npm modules。当从 tar 格式的源文件编译时,其中包含了预编译的前端文件,因此 `make frontend` 将不会被执行。这允许编译时不需要 Node.js。
更多信息: https://docs.gitea.com/installation/install-from-source
## 使用
编译之后,默认会在根目录下生成一个名为 `gitea` 的文件。你可以这样执行它:
./gitea web
> [!注意]
> 如果你要使用API请参见 [API 文档](https://godoc.org/code.gitea.io/sdk/gitea)。
## 贡献
贡献流程Fork -> Patch -> Push -> Pull Request
> [!注意]
>
> 1. **开始贡献代码之前请确保你已经看过了 [贡献者向导(英文)](CONTRIBUTING.md)**
> 2. 所有的安全问题,请私下发送邮件给 **security@gitea.io**。 谢谢!
## 翻译
多语言翻译是基于Crowdin进行的.
[![Crowdin](https://badges.crowdin.net/gitea/localized.svg)](https://crowdin.com/project/gitea)
[![Crowdin](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com)
多语言翻译是基于Crowdin进行的。
从 [文档](https://docs.gitea.com/contributing/localization) 中获取更多信息。
## 官方和第三方项目
Gitea 提供官方的 [go-sdk](https://gitea.com/gitea/go-sdk),以及名为 [tea](https://gitea.com/gitea/tea) 的 CLI 工具 和 用于 Gitea Action 的 [action runner](https://gitea.com/gitea/act_runner)。
[gitea/awesome-gitea](https://gitea.com/gitea/awesome-gitea) 是一个 Gitea 相关项目的列表,你可以在这里找到更多的第三方项目,包括 SDK、插件、主题等等。
## 作者
* [Maintainers](https://github.com/orgs/go-gitea/people)
* [Contributors](https://github.com/go-gitea/gitea/graphs/contributors)
* [Translators](options/locale/TRANSLATORS)
- [Maintainers](https://github.com/orgs/go-gitea/people)
- [Contributors](https://github.com/go-gitea/gitea/graphs/contributors)
- [Translators](options/locale/TRANSLATORS)
## 授权许可
本项目采用 MIT 开源授权许可证,完整的授权说明已放置在 [LICENSE](https://github.com/go-gitea/gitea/blob/main/LICENSE) 文件中。
## 截图
## 更多信息
<details>
<summary>截图</summary>
|![Dashboard](https://dl.gitea.com/screenshots/home_timeline.png)|![User Profile](https://dl.gitea.com/screenshots/user_profile.png)|![Global Issues](https://dl.gitea.com/screenshots/global_issues.png)|
|:---:|:---:|:---:|
|![Branches](https://dl.gitea.com/screenshots/branches.png)|![Web Editor](https://dl.gitea.com/screenshots/web_editor.png)|![Activity](https://dl.gitea.com/screenshots/activity.png)|
|![New Migration](https://dl.gitea.com/screenshots/migration.png)|![Migrating](https://dl.gitea.com/screenshots/migration.gif)|![Pull Request View](https://image.ibb.co/e02dSb/6.png)|
|![Pull Request Dark](https://dl.gitea.com/screenshots/pull_requests_dark.png)|![Diff Review Dark](https://dl.gitea.com/screenshots/review_dark.png)|![Diff Dark](https://dl.gitea.com/screenshots/diff_dark.png)|
</details>

View File

@ -237,7 +237,7 @@ func runServ(c *cli.Context) error {
// for AGit Flow
if cmd == "ssh_info" {
data := private.GetSSHInfo(ctx)
fmt.Print(data)
fmt.Println(data)
return nil
}
}

View File

@ -54,8 +54,10 @@ func runACME(listenAddr string, m http.Handler) error {
altTLSALPNPort = p
}
magic := &certmagic.Default
magic.Storage = &certmagic.FileStorage{Path: setting.AcmeLiveDirectory}
// FIXME: this path is not right, it uses "AppWorkPath" incorrectly, and writes the data into "AppWorkPath/https"
// Ideally it should migrate to AppDataPath write to "AppDataPath/https"
certmagic.Default.Storage = &certmagic.FileStorage{Path: setting.AcmeLiveDirectory}
magic := certmagic.NewDefault()
// Try to use private CA root if provided, otherwise defaults to system's trust
var certPool *x509.CertPool
if setting.AcmeCARoot != "" {

View File

@ -37,5 +37,5 @@ done
if [ $# -gt 0 ]; then
exec "$@"
else
exec /bin/s6-svscan /etc/s6
exec /usr/bin/s6-svscan /etc/s6
fi

View File

@ -26,14 +26,14 @@ func (comments CommentList) LoadPosters(ctx context.Context) error {
return c.PosterID, c.Poster == nil && c.PosterID > 0
})
posterMaps, err := getPostersByIDs(ctx, posterIDs)
posterMaps, err := user_model.GetUsersMapByIDs(ctx, posterIDs)
if err != nil {
return err
}
for _, comment := range comments {
if comment.Poster == nil {
comment.Poster = getPoster(comment.PosterID, posterMaps)
comment.Poster = user_model.GetPossibleUserFromMap(comment.PosterID, posterMaps)
}
}
return nil
@ -41,7 +41,7 @@ func (comments CommentList) LoadPosters(ctx context.Context) error {
func (comments CommentList) getLabelIDs() []int64 {
return container.FilterSlice(comments, func(comment *Comment) (int64, bool) {
return comment.LabelID, comment.LabelID > 0
return comment.LabelID, comment.LabelID > 0 && comment.Label == nil
})
}
@ -51,6 +51,9 @@ func (comments CommentList) loadLabels(ctx context.Context) error {
}
labelIDs := comments.getLabelIDs()
if len(labelIDs) == 0 {
return nil
}
commentLabels := make(map[int64]*Label, len(labelIDs))
left := len(labelIDs)
for left > 0 {
@ -118,8 +121,8 @@ func (comments CommentList) loadMilestones(ctx context.Context) error {
milestoneIDs = milestoneIDs[limit:]
}
for _, issue := range comments {
issue.Milestone = milestoneMaps[issue.MilestoneID]
for _, comment := range comments {
comment.Milestone = milestoneMaps[comment.MilestoneID]
}
return nil
}
@ -175,6 +178,9 @@ func (comments CommentList) loadAssignees(ctx context.Context) error {
}
assigneeIDs := comments.getAssigneeIDs()
if len(assigneeIDs) == 0 {
return nil
}
assignees := make(map[int64]*user_model.User, len(assigneeIDs))
left := len(assigneeIDs)
for left > 0 {
@ -301,6 +307,9 @@ func (comments CommentList) loadDependentIssues(ctx context.Context) error {
e := db.GetEngine(ctx)
issueIDs := comments.getDependentIssueIDs()
if len(issueIDs) == 0 {
return nil
}
issues := make(map[int64]*Issue, len(issueIDs))
left := len(issueIDs)
for left > 0 {
@ -427,6 +436,9 @@ func (comments CommentList) loadReviews(ctx context.Context) error {
}
reviewIDs := comments.getReviewIDs()
if len(reviewIDs) == 0 {
return nil
}
reviews := make(map[int64]*Review, len(reviewIDs))
if err := db.GetEngine(ctx).In("id", reviewIDs).Find(&reviews); err != nil {
return err

View File

@ -238,6 +238,9 @@ func (issue *Issue) loadCommentsByType(ctx context.Context, tp CommentType) (err
IssueID: issue.ID,
Type: tp,
})
for _, comment := range issue.Comments {
comment.Issue = issue
}
return err
}

View File

@ -81,53 +81,19 @@ func (issues IssueList) LoadPosters(ctx context.Context) error {
return issue.PosterID, issue.Poster == nil && issue.PosterID > 0
})
posterMaps, err := getPostersByIDs(ctx, posterIDs)
posterMaps, err := user_model.GetUsersMapByIDs(ctx, posterIDs)
if err != nil {
return err
}
for _, issue := range issues {
if issue.Poster == nil {
issue.Poster = getPoster(issue.PosterID, posterMaps)
issue.Poster = user_model.GetPossibleUserFromMap(issue.PosterID, posterMaps)
}
}
return nil
}
func getPostersByIDs(ctx context.Context, posterIDs []int64) (map[int64]*user_model.User, error) {
posterMaps := make(map[int64]*user_model.User, len(posterIDs))
left := len(posterIDs)
for left > 0 {
limit := db.DefaultMaxInSize
if left < limit {
limit = left
}
err := db.GetEngine(ctx).
In("id", posterIDs[:limit]).
Find(&posterMaps)
if err != nil {
return nil, err
}
left -= limit
posterIDs = posterIDs[limit:]
}
return posterMaps, nil
}
func getPoster(posterID int64, posterMaps map[int64]*user_model.User) *user_model.User {
if posterID == user_model.ActionsUserID {
return user_model.NewActionsUser()
}
if posterID <= 0 {
return nil
}
poster, ok := posterMaps[posterID]
if !ok {
return user_model.NewGhostUser()
}
return poster
}
func (issues IssueList) getIssueIDs() []int64 {
ids := make([]int64, 0, len(issues))
for _, issue := range issues {

View File

@ -166,6 +166,23 @@ func (prs PullRequestList) getRepositoryIDs() []int64 {
return repoIDs.Values()
}
func (prs PullRequestList) SetBaseRepo(baseRepo *repo_model.Repository) {
for _, pr := range prs {
if pr.BaseRepo == nil {
pr.BaseRepo = baseRepo
}
}
}
func (prs PullRequestList) SetHeadRepo(headRepo *repo_model.Repository) {
for _, pr := range prs {
if pr.HeadRepo == nil {
pr.HeadRepo = headRepo
pr.isHeadRepoLoaded = true
}
}
}
func (prs PullRequestList) LoadRepositories(ctx context.Context) error {
repoIDs := prs.getRepositoryIDs()
reposMap := make(map[int64]*repo_model.Repository, len(repoIDs))

View File

@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/models/migrations/v1_21"
"code.gitea.io/gitea/models/migrations/v1_22"
"code.gitea.io/gitea/models/migrations/v1_23"
"code.gitea.io/gitea/models/migrations/v1_24"
"code.gitea.io/gitea/models/migrations/v1_6"
"code.gitea.io/gitea/models/migrations/v1_7"
"code.gitea.io/gitea/models/migrations/v1_8"
@ -369,6 +370,9 @@ func prepareMigrationTasks() []*migration {
newMigration(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices),
newMigration(310, "Add Priority to ProtectedBranch", v1_23.AddPriorityToProtectedBranch),
newMigration(311, "Add TimeEstimate to Issue table", v1_23.AddTimeEstimateColumnToIssueTable),
// Gitea 1.23.0-rc0 ends at migration ID number 311 (database version 312)
newMigration(312, "Add DeleteBranchAfterMerge to AutoMerge", v1_24.AddDeleteBranchAfterMergeForAutoMerge),
}
return preparedMigrations
}

View File

@ -0,0 +1,21 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_24 //nolint
import (
"xorm.io/xorm"
)
type pullAutoMerge struct {
DeleteBranchAfterMerge bool
}
// TableName return database table name for xorm
func (pullAutoMerge) TableName() string {
return "pull_auto_merge"
}
func AddDeleteBranchAfterMergeForAutoMerge(x *xorm.Engine) error {
return x.Sync(new(pullAutoMerge))
}

View File

@ -15,13 +15,14 @@ import (
// AutoMerge represents a pull request scheduled for merging when checks succeed
type AutoMerge struct {
ID int64 `xorm:"pk autoincr"`
PullID int64 `xorm:"UNIQUE"`
DoerID int64 `xorm:"INDEX NOT NULL"`
Doer *user_model.User `xorm:"-"`
MergeStyle repo_model.MergeStyle `xorm:"varchar(30)"`
Message string `xorm:"LONGTEXT"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
ID int64 `xorm:"pk autoincr"`
PullID int64 `xorm:"UNIQUE"`
DoerID int64 `xorm:"INDEX NOT NULL"`
Doer *user_model.User `xorm:"-"`
MergeStyle repo_model.MergeStyle `xorm:"varchar(30)"`
Message string `xorm:"LONGTEXT"`
DeleteBranchAfterMerge bool
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
// TableName return database table name for xorm
@ -49,7 +50,7 @@ func IsErrAlreadyScheduledToAutoMerge(err error) bool {
}
// ScheduleAutoMerge schedules a pull request to be merged when all checks succeed
func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pullID int64, style repo_model.MergeStyle, message string) error {
func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pullID int64, style repo_model.MergeStyle, message string, deleteBranchAfterMerge bool) error {
// Check if we already have a merge scheduled for that pull request
if exists, _, err := GetScheduledMergeByPullID(ctx, pullID); err != nil {
return err
@ -58,10 +59,11 @@ func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pullID int64,
}
_, err := db.GetEngine(ctx).Insert(&AutoMerge{
DoerID: doer.ID,
PullID: pullID,
MergeStyle: style,
Message: message,
DoerID: doer.ID,
PullID: pullID,
MergeStyle: style,
Message: message,
DeleteBranchAfterMerge: deleteBranchAfterMerge,
})
return err
}

47
models/user/user_list.go Normal file
View File

@ -0,0 +1,47 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"code.gitea.io/gitea/models/db"
)
func GetUsersMapByIDs(ctx context.Context, userIDs []int64) (map[int64]*User, error) {
userMaps := make(map[int64]*User, len(userIDs))
left := len(userIDs)
for left > 0 {
limit := db.DefaultMaxInSize
if left < limit {
limit = left
}
err := db.GetEngine(ctx).
In("id", userIDs[:limit]).
Find(&userMaps)
if err != nil {
return nil, err
}
left -= limit
userIDs = userIDs[limit:]
}
return userMaps, nil
}
func GetPossibleUserFromMap(userID int64, usererMaps map[int64]*User) *User {
switch userID {
case GhostUserID:
return NewGhostUser()
case ActionsUserID:
return NewActionsUser()
case 0:
return nil
default:
user, ok := usererMaps[userID]
if !ok {
return NewGhostUser()
}
return user
}
}

View File

@ -12,6 +12,7 @@ import (
"strconv"
"time"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
@ -43,16 +44,19 @@ func GetSSHInfo(ctx context.Context) string {
resp, err := req.Response()
if err != nil {
log.Error("GetSSHInfo Error: %v", err)
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Error("response status code is not OK, code: %d", resp.StatusCode)
return ""
}
content, err := io.ReadAll(resp.Body)
if err != nil {
log.Error("read body error: %v", err)
return ""
}

View File

@ -133,3 +133,11 @@ type EditBranchProtectionOption struct {
type UpdateBranchProtectionPriories struct {
IDs []int64 `json:"ids"`
}
type MergeUpstreamRequest struct {
Branch string `json:"branch"`
}
type MergeUpstreamResponse struct {
MergeStyle string `json:"merge_type"`
}

View File

@ -145,6 +145,7 @@ confirm_delete_selected=Potvrdit odstranění všech vybraných položek?
name=Název
value=Hodnota
readme=Readme
filter=Filtr
filter.clear=Vymazat filtr
@ -243,6 +244,7 @@ license_desc=Vše je na <a target="_blank" rel="noopener noreferrer" href="%[1]s
[install]
install=Instalace
installing_desc=Probíhá instalace, čekejte prosím...
title=Výchozí konfigurace
docker_helper=Pokud spouštíte Gitea v Dockeru, přečtěte si <a target="_blank" rel="noopener noreferrer" href="%s">dokumentaci</a>, než budete měnit jakákoliv nastavení.
require_db_desc=Gitea requires MySQL, PostgreSQL, MSSQL, SQLite3 or TiDB (MySQL protocol).
@ -459,6 +461,7 @@ authorize_application=Autorizovat aplikaci
authorize_redirect_notice=Budete přesměrováni na %s, pokud autorizujete tuto aplikaci.
authorize_application_created_by=Tuto aplikaci vytvořil %s.
authorize_application_description=Pokud povolíte přístup, bude moci přistupovat a zapisovat do všech vašich informací o účtu včetně soukromých repozitářů a organizací.
authorize_application_with_scopes=S rozsahy působnosti: %s
authorize_title=Autorizovat „%s“ pro přístup k vašemu účtu?
authorization_failed=Autorizace selhala
authorization_failed_desc=Autorizace selhala, protože jsme detekovali neplatný požadavek. Kontaktujte prosím správce aplikace, kterou jste se pokoušeli autorizovat.
@ -765,6 +768,7 @@ uploaded_avatar_not_a_image=Nahraný soubor není obrázek.
uploaded_avatar_is_too_big=Nahraný soubor (%d KiB) přesahuje maximální velikost (%d KiB).
update_avatar_success=Vaše avatar byl aktualizován.
update_user_avatar_success=Uživatelův avatar byl aktualizován.
cropper_prompt=Před uložením můžete obrázek upravit. Upravený obrázek bude uložen jako PNG.
change_password=Aktualizovat heslo
old_password=Stávající heslo
@ -1012,6 +1016,9 @@ new_repo_helper=Repozitář obsahuje všechny projektové soubory, včetně hist
owner=Vlastník
owner_helper=Některé organizace se nemusejí v seznamu zobrazit kvůli maximálnímu dosaženému počtu repozitářů.
repo_name=Název repozitáře
repo_name_profile_public_hint=.profile je speciální repozitář, který můžete použít k přidání souboru README.md do svého veřejného profilu organizace, který je viditelný pro každého. Ujistěte se, že je veřejný, a pro začátek jej inicializujte pomocí README v adresáři profilu.
repo_name_profile_private_hint=.profile-private je speciální repozitář, který můžete použít k přidání souboru README.md do profilu člena organizace, který je viditelný pouze pro členy organizace. Ujistěte se, že je soukromý, a pro začátek jej inicializujte pomocí README v adresáři profilu.
repo_name_helper=Dobrá jména repozitářů používají krátká, zapamatovatelná a unikátní klíčová slova. Repozitář s názvem „.profile“ nebo „.profile-private“ lze použít k přidání README.md pro uživatelský/organizační profil.
repo_size=Velikost repozitáře
template=Šablona
template_select=Vyberte šablonu.
@ -1030,6 +1037,8 @@ fork_to_different_account=Rozštěpit na jiný účet
fork_visibility_helper=Viditelnost rozštěpeného repozitáře nemůže být změněna.
fork_branch=Větev, která má být klonována pro fork
all_branches=Všechny větve
view_all_branches=Zobrazit všechny větve
view_all_tags=Zobrazit všechny značky
fork_no_valid_owners=Tento repozitář nemůže být rozštěpen, protože neexistují žádní platní vlastníci.
fork.blocked_user=Nelze rozštěpit repozitář, protože jste blokováni majitelem repozitáře.
use_template=Použít tuto šablonu
@ -1041,6 +1050,8 @@ generate_repo=Generovat repozitář
generate_from=Generovat z
repo_desc=Popis
repo_desc_helper=Zadejte krátký popis (volitelné)
repo_no_desc=Nebyl uveden žádný popis
repo_lang=Jazyky
repo_gitignore_helper=Vyberte šablony .gitignore.
repo_gitignore_helper_desc=Vyberte soubory, které nechcete sledovat ze seznamu šablon pro běžné jazyky. Typické artefakty generované nástroji pro sestavení každého jazyka jsou ve výchozím stavu součástí .gitignore.
issue_labels=Štítky úkolů
@ -1102,6 +1113,7 @@ delete_preexisting_success=Smazány nepřijaté soubory v %s
blame_prior=Zobrazit blame před touto změnou
blame.ignore_revs=Ignorování revizí v <a href="%s">.git-blame-ignorerevs</a>. Klikněte zde <a href="%s">pro obejití</a> a zobrazení normálního pohledu blame.
blame.ignore_revs.failed=Nepodařilo se ignorovat revize v <a href="%s">.git-blame-ignore-revs</a>.
user_search_tooltip=Zobrazí maximálně 30 uživatelů
tree_path_not_found_commit=Cesta %[1]s v commitu %[2]s neexistuje
tree_path_not_found_branch=Cesta %[1]s ve větvi %[2]s neexistuje
@ -1223,6 +1235,7 @@ create_new_repo_command=Vytvořit nový repozitář na příkazové řádce
push_exist_repo=Nahrání existujícího repozitáře z příkazové řádky
empty_message=Tento repozitář nemá žádný obsah.
broken_message=Data gitu, která jsou základem tohoto repozitáře, nelze číst. Kontaktujte správce této instance nebo smažte tento repositář.
no_branch=Tento repozitář nemá žádné větve.
code=Zdrojový kód
code.desc=Přístup ke zdrojovým kódům, souborům, commitům a větvím.
@ -1520,6 +1533,8 @@ issues.filter_assignee=Zpracovatel
issues.filter_assginee_no_select=Všichni zpracovatelé
issues.filter_assginee_no_assignee=Bez zpracovatele
issues.filter_poster=Autor
issues.filter_user_placeholder=Hledat uživatele
issues.filter_user_no_select=Všichni uživatelé
issues.filter_type=Typ
issues.filter_type.all_issues=Všechny úkoly
issues.filter_type.assigned_to_you=Přiřazené vám
@ -1664,12 +1679,25 @@ issues.delete.title=Smazat tento úkol?
issues.delete.text=Opravdu chcete tento úkol smazat? (Tím se trvale odstraní veškerý obsah. Pokud jej hodláte archivovat, zvažte raději jeho uzavření.)
issues.tracker=Sledování času
issues.timetracker_timer_start=Spustit časovač
issues.timetracker_timer_stop=Zastavit časovač
issues.timetracker_timer_discard=Zahodit časovač
issues.timetracker_timer_manually_add=Přidat čas
issues.time_estimate_set=Nastavit odhadovaný čas
issues.time_estimate_display=Odhad: %s
issues.change_time_estimate_at=změnil/a odhad času na <b>%s</b> %s
issues.remove_time_estimate_at=odstranil/a odhad času %s
issues.time_estimate_invalid=Formát odhadu času je neplatný
issues.start_tracking_history=započal/a práci %s
issues.tracker_auto_close=Časovač se automaticky zastaví po zavření tohoto úkolu
issues.tracking_already_started=`Již jste spustili sledování času na <a href="%s">jiném úkolu</a>!`
issues.stop_tracking_history=pracoval/a <b>%s</b> %s
issues.cancel_tracking_history=`zrušil/a sledování času %s`
issues.del_time=Odstranit tento časový záznam
issues.add_time_history=přidal/a strávený čas <b>%s</b> %s
issues.del_time_history=`odstranil/a strávený čas %s`
issues.add_time_manually=Přidat čas ručně
issues.add_time_hours=Hodiny
issues.add_time_minutes=Minuty
issues.add_time_sum_to_small=Čas nebyl zadán.
@ -1922,6 +1950,9 @@ pulls.delete.title=Odstranit tento pull request?
pulls.delete.text=Opravdu chcete tento pull request smazat? (Tím se trvale odstraní veškerý obsah. Pokud jej hodláte archivovat, zvažte raději jeho uzavření.)
pulls.recently_pushed_new_branches=Nahráli jste větev <strong>%[1]s</strong> %[2]s
pulls.upstream_diverging_prompt_behind_1=Tato větev je %[1]d commit pozadu za %[2]s
pulls.upstream_diverging_prompt_behind_n=Tato větev je %[1]d commitů pozadu za %[2]s
pulls.upstream_diverging_prompt_base_newer=Hlavní větev %s má nové změny
pull.deleted_branch=(odstraněno):%s
pull.agit_documentation=Prohlédněte si dokumentaci o AGit
@ -2595,6 +2626,9 @@ diff.image.overlay=Překrytí
diff.has_escaped=Tento řádek má skryté znaky Unicode
diff.show_file_tree=Zobrazit souborový strom
diff.hide_file_tree=Skrýt souborový strom
diff.submodule_added=Submodul %[1]s přidán v %[2]s
diff.submodule_deleted=Submodul %[1]s odebrán z %[2]s
diff.submodule_updated=Submodul %[1]s aktualizován: %[2]s
releases.desc=Sledování verzí projektu a souborů ke stažení.
release.releases=Vydání
@ -2604,6 +2638,7 @@ release.new_release=Nové vydání
release.draft=Koncept
release.prerelease=Předběžná verze
release.stable=Stabilní
release.latest=Nejnovější
release.compare=Porovnat
release.edit=upravit
release.ahead.commits=<strong>%d</strong> revizí
@ -2832,6 +2867,9 @@ teams.invite.title=Byli jste pozváni do týmu <strong>%s</strong> v organizaci
teams.invite.by=Pozvání od %s
teams.invite.description=Pro připojení k týmu klikněte na tlačítko níže.
view_as_role=Zobrazit jako: %s
view_as_public_hint=Prohlížíte README jako veřejný uživatel.
view_as_member_hint=Prohlížíte README jako člen této organizace.
[admin]
maintenance=Údržba
@ -3502,6 +3540,7 @@ versions=Verze
versions.view_all=Zobrazit všechny
dependency.id=ID
dependency.version=Verze
search_in_external_registry=Hledat v %s
alpine.registry=Nastavte tento registr přidáním URL do <code>/etc/apk/repositories</code>:
alpine.registry.key=Stáhněte si veřejný RSA klíč registru do složky <code>/etc/apk/keys/</code> pro ověření podpisu indexu:
alpine.registry.info=Vyberte $branch a $repository ze seznamu níže.
@ -3510,6 +3549,8 @@ alpine.repository=Informace o repozitáři
alpine.repository.branches=Větve
alpine.repository.repositories=Repozitáře
alpine.repository.architectures=Architektury
arch.registry=Přidejte server se souvisejícím repozitářem a architekturou do <code>/etc/pacman.conf</code>:
arch.install=Synchronizovat balíček s pacman:
arch.repository=Informace o repozitáři
arch.repository.repositories=Repozitáře
arch.repository.architectures=Architektury
@ -3692,6 +3733,7 @@ runners.status.active=Aktivní
runners.status.offline=Offline
runners.version=Verze
runners.reset_registration_token=Resetovat registrační token
runners.reset_registration_token_confirm=Chcete zneplatnit stávající token a vygenerovat nový?
runners.reset_registration_token_success=Registrační token runneru byl úspěšně obnoven
runs.all_workflows=Všechny pracovní postupy
@ -3724,6 +3766,7 @@ workflow.not_found=Pracovní postup „%s“ nebyl nalezen.
workflow.run_success=Pracovní postup „%s“ proběhl úspěšně.
workflow.from_ref=Použít pracovní postup od
workflow.has_workflow_dispatch=Tento pracovní postup má spouštěč události workflow_dispatch.
workflow.has_no_workflow_dispatch=Pracovní postup „%s“ memá workflow_dispatch spouštěč.
need_approval_desc=Potřebujete schválení pro spuštění pracovních postupů pro rozštěpený pull request.
@ -3743,6 +3786,8 @@ variables.creation.success=Proměnná „%s“ byla přidána.
variables.update.failed=Úprava proměnné se nezdařila.
variables.update.success=Proměnná byla upravena.
logs.always_auto_scroll=Vždy automaticky posouvat logy
logs.always_expand_running=Vždy rozšířit běžící logy
[projects]
deleted.display_name=Odstraněný projekt

View File

@ -2627,6 +2627,9 @@ diff.image.overlay=Sobrepor
diff.has_escaped=Esta linha tem caracteres unicode escondidos
diff.show_file_tree=Mostrar árvore de ficheiros
diff.hide_file_tree=Esconder árvore de ficheiros
diff.submodule_added=Submódulo %[1]s adicionado em %[2]s
diff.submodule_deleted=Submódulo %[1]s eliminado de %[2]s
diff.submodule_updated=Submódulo %[1]s modificado: %[2]s
releases.desc=Acompanhe as versões e as descargas do repositório.
release.releases=Lançamentos

View File

@ -1190,6 +1190,7 @@ func Routes() *web.Router {
m.Get("/archive/*", reqRepoReader(unit.TypeCode), repo.GetArchive)
m.Combo("/forks").Get(repo.ListForks).
Post(reqToken(), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork)
m.Post("/merge-upstream", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeCode), bind(api.MergeUpstreamRequest{}), repo.MergeUpstream)
m.Group("/branches", func() {
m.Get("", repo.ListBranches)
m.Get("/*", repo.GetBranch)

View File

@ -18,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/optional"
repo_module "code.gitea.io/gitea/modules/repository"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/context"
@ -150,7 +151,7 @@ func DeleteBranch(ctx *context.APIContext) {
}
}
if err := repo_service.DeleteBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName); err != nil {
if err := repo_service.DeleteBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName, nil); err != nil {
switch {
case git.IsErrBranchNotExist(err):
ctx.NotFound(err)
@ -1186,3 +1187,47 @@ func UpdateBranchProtectionPriories(ctx *context.APIContext) {
ctx.Status(http.StatusNoContent)
}
func MergeUpstream(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/merge-upstream repository repoMergeUpstream
// ---
// summary: Merge a branch from upstream
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/MergeUpstreamRequest"
// responses:
// "200":
// "$ref": "#/responses/MergeUpstreamResponse"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
form := web.GetForm(ctx).(*api.MergeUpstreamRequest)
mergeStyle, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, form.Branch)
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
ctx.Error(http.StatusBadRequest, "MergeUpstream", err)
return
} else if errors.Is(err, util.ErrNotExist) {
ctx.Error(http.StatusNotFound, "MergeUpstream", err)
return
}
ctx.Error(http.StatusInternalServerError, "MergeUpstream", err)
return
}
ctx.JSON(http.StatusOK, &api.MergeUpstreamResponse{MergeStyle: mergeStyle})
}

View File

@ -971,7 +971,7 @@ func MergePullRequest(ctx *context.APIContext) {
}
if form.MergeWhenChecksSucceed {
scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message)
scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message, form.DeleteBranchAfterMerge)
if err != nil {
if pull_model.IsErrAlreadyScheduledToAutoMerge(err) {
ctx.Error(http.StatusConflict, "ScheduleAutoMerge", err)
@ -1043,11 +1043,8 @@ func MergePullRequest(ctx *context.APIContext) {
}
defer headRepo.Close()
}
if err := pull_service.RetargetChildrenOnMerge(ctx, ctx.Doer, pr); err != nil {
ctx.Error(http.StatusInternalServerError, "RetargetChildrenOnMerge", err)
return
}
if err := repo_service.DeleteBranch(ctx, ctx.Doer, pr.HeadRepo, headRepo, pr.HeadBranch); err != nil {
if err := repo_service.DeleteBranch(ctx, ctx.Doer, pr.HeadRepo, headRepo, pr.HeadBranch, pr); err != nil {
switch {
case git.IsErrBranchNotExist(err):
ctx.NotFound(err)
@ -1060,10 +1057,6 @@ func MergePullRequest(ctx *context.APIContext) {
}
return
}
if err := issues_model.AddDeletePRBranchComment(ctx, ctx.Doer, pr.BaseRepo, pr.Issue.ID, pr.HeadBranch); err != nil {
// Do not fail here as branch has already been deleted
log.Error("DeleteBranch: %v", err)
}
}
}

View File

@ -448,3 +448,15 @@ type swaggerCompare struct {
// in:body
Body api.Compare `json:"body"`
}
// swagger:response MergeUpstreamRequest
type swaggerMergeUpstreamRequest struct {
// in:body
Body api.MergeUpstreamRequest `json:"body"`
}
// swagger:response MergeUpstreamResponse
type swaggerMergeUpstreamResponse struct {
// in:body
Body api.MergeUpstreamResponse `json:"body"`
}

View File

@ -27,7 +27,7 @@ func TestHandlePullRequestMerging(t *testing.T) {
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
err = pull_model.ScheduleAutoMerge(db.DefaultContext, user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr")
err = pull_model.ScheduleAutoMerge(db.DefaultContext, user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr", false)
assert.NoError(t, err)
autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID})

View File

@ -97,7 +97,7 @@ func DeleteBranchPost(ctx *context.Context) {
defer redirect(ctx)
branchName := ctx.FormString("name")
if err := repo_service.DeleteBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName); err != nil {
if err := repo_service.DeleteBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName, nil); err != nil {
switch {
case git.IsErrBranchNotExist(err):
log.Debug("DeleteBranch: Can't delete non existing branch '%s'", branchName)

View File

@ -40,16 +40,30 @@ import (
)
// roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and issue
func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *user_model.User, issue *issues_model.Issue, hasOriginalAuthor bool) (issues_model.RoleDescriptor, error) {
func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *user_model.User, permsCache map[int64]access_model.Permission, issue *issues_model.Issue, hasOriginalAuthor bool) (issues_model.RoleDescriptor, error) {
roleDescriptor := issues_model.RoleDescriptor{}
if hasOriginalAuthor {
return roleDescriptor, nil
}
perm, err := access_model.GetUserRepoPermission(ctx, repo, poster)
if err != nil {
return roleDescriptor, err
var perm access_model.Permission
var err error
if permsCache != nil {
var ok bool
perm, ok = permsCache[poster.ID]
if !ok {
perm, err = access_model.GetUserRepoPermission(ctx, repo, poster)
if err != nil {
return roleDescriptor, err
}
}
permsCache[poster.ID] = perm
} else {
perm, err = access_model.GetUserRepoPermission(ctx, repo, poster)
if err != nil {
return roleDescriptor, err
}
}
// If the poster is the actual poster of the issue, enable Poster role.
@ -576,6 +590,8 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue
return
}
permCache := make(map[int64]access_model.Permission)
for _, comment = range issue.Comments {
comment.Issue = issue
@ -593,7 +609,7 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue
continue
}
comment.ShowRole, err = roleDescriptor(ctx, issue.Repo, comment.Poster, issue, comment.HasOriginalAuthor())
comment.ShowRole, err = roleDescriptor(ctx, issue.Repo, comment.Poster, permCache, issue, comment.HasOriginalAuthor())
if err != nil {
ctx.ServerError("roleDescriptor", err)
return
@ -691,7 +707,7 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue
continue
}
c.ShowRole, err = roleDescriptor(ctx, issue.Repo, c.Poster, issue, c.HasOriginalAuthor())
c.ShowRole, err = roleDescriptor(ctx, issue.Repo, c.Poster, permCache, issue, c.HasOriginalAuthor())
if err != nil {
ctx.ServerError("roleDescriptor", err)
return
@ -940,7 +956,7 @@ func prepareIssueViewContent(ctx *context.Context, issue *issues_model.Issue) {
ctx.ServerError("RenderString", err)
return
}
if issue.ShowRole, err = roleDescriptor(ctx, issue.Repo, issue.Poster, issue, issue.HasOriginalAuthor()); err != nil {
if issue.ShowRole, err = roleDescriptor(ctx, issue.Repo, issue.Poster, nil, issue, issue.HasOriginalAuthor()); err != nil {
ctx.ServerError("roleDescriptor", err)
return
}

View File

@ -1097,7 +1097,7 @@ func MergePullRequest(ctx *context.Context) {
// delete all scheduled auto merges
_ = pull_model.DeleteScheduledAutoMerge(ctx, pr.ID)
// schedule auto merge
scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message)
scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message, form.DeleteBranchAfterMerge)
if err != nil {
ctx.ServerError("ScheduleAutoMerge", err)
return
@ -1504,12 +1504,7 @@ func CleanUpPullRequest(ctx *context.Context) {
func deleteBranch(ctx *context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository) {
fullBranchName := pr.HeadRepo.FullName() + ":" + pr.HeadBranch
if err := pull_service.RetargetChildrenOnMerge(ctx, ctx.Doer, pr); err != nil {
ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName))
return
}
if err := repo_service.DeleteBranch(ctx, ctx.Doer, pr.HeadRepo, gitRepo, pr.HeadBranch); err != nil {
if err := repo_service.DeleteBranch(ctx, ctx.Doer, pr.HeadRepo, gitRepo, pr.HeadBranch, pr); err != nil {
switch {
case git.IsErrBranchNotExist(err):
ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName))
@ -1524,11 +1519,6 @@ func deleteBranch(ctx *context.Context, pr *issues_model.PullRequest, gitRepo *g
return
}
if err := issues_model.AddDeletePRBranchComment(ctx, ctx.Doer, pr.BaseRepo, pr.IssueID, pr.HeadBranch); err != nil {
// Do not fail here as branch has already been deleted
log.Error("DeleteBranch: %v", err)
}
ctx.Flash.Success(ctx.Tr("repo.branch.deletion_success", fullBranchName))
}

View File

@ -24,6 +24,7 @@ import (
"code.gitea.io/gitea/modules/queue"
notify_service "code.gitea.io/gitea/services/notify"
pull_service "code.gitea.io/gitea/services/pull"
repo_service "code.gitea.io/gitea/services/repository"
)
// prAutoMergeQueue represents a queue to handle update pull request tests
@ -63,9 +64,9 @@ func addToQueue(pr *issues_model.PullRequest, sha string) {
}
// ScheduleAutoMerge if schedule is false and no error, pull can be merged directly
func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pull *issues_model.PullRequest, style repo_model.MergeStyle, message string) (scheduled bool, err error) {
func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pull *issues_model.PullRequest, style repo_model.MergeStyle, message string, deleteBranchAfterMerge bool) (scheduled bool, err error) {
err = db.WithTx(ctx, func(ctx context.Context) error {
if err := pull_model.ScheduleAutoMerge(ctx, doer, pull.ID, style, message); err != nil {
if err := pull_model.ScheduleAutoMerge(ctx, doer, pull.ID, style, message, deleteBranchAfterMerge); err != nil {
return err
}
scheduled = true
@ -303,4 +304,10 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
// on the pull request page. But this should not be finished in a bug fix PR which will be backport to release branch.
return
}
if pr.Flow == issues_model.PullRequestFlowGithub && scheduledPRM.DeleteBranchAfterMerge {
if err := repo_service.DeleteBranch(ctx, doer, pr.HeadRepo, headGitRepo, pr.HeadBranch, pr); err != nil {
log.Error("DeletePullRequestHeadBranch: %v", err)
}
}
}

View File

@ -293,8 +293,7 @@ func RepoRefForAPI(next http.Handler) http.Handler {
return
}
// NOTICE: the "ref" here for internal usage only (e.g. woodpecker)
refName, _ := getRefNameLegacy(ctx.Base, ctx.Repo, ctx.FormTrim("ref"))
refName, _ := getRefNameLegacy(ctx.Base, ctx.Repo, ctx.PathParam("*"), ctx.FormTrim("ref"))
var err error
if ctx.Repo.GitRepo.IsBranchExist(refName) {

View File

@ -765,35 +765,30 @@ func getRefNameFromPath(repo *Repository, path string, isExist func(string) bool
return ""
}
func getRefNameLegacy(ctx *Base, repo *Repository, optionalExtraRef ...string) (string, RepoRefType) {
extraRef := util.OptionalArg(optionalExtraRef)
reqPath := ctx.PathParam("*")
reqPath = path.Join(extraRef, reqPath)
if refName := getRefName(ctx, repo, RepoRefBranch); refName != "" {
func getRefNameLegacy(ctx *Base, repo *Repository, reqPath, extraRef string) (string, RepoRefType) {
reqRefPath := path.Join(extraRef, reqPath)
reqRefPathParts := strings.Split(reqRefPath, "/")
if refName := getRefName(ctx, repo, reqRefPath, RepoRefBranch); refName != "" {
return refName, RepoRefBranch
}
if refName := getRefName(ctx, repo, RepoRefTag); refName != "" {
if refName := getRefName(ctx, repo, reqRefPath, RepoRefTag); refName != "" {
return refName, RepoRefTag
}
// For legacy support only full commit sha
parts := strings.Split(reqPath, "/")
if git.IsStringLikelyCommitID(git.ObjectFormatFromName(repo.Repository.ObjectFormatName), parts[0]) {
if git.IsStringLikelyCommitID(git.ObjectFormatFromName(repo.Repository.ObjectFormatName), reqRefPathParts[0]) {
// FIXME: this logic is different from other types. Ideally, it should also try to GetCommit to check if it exists
repo.TreePath = strings.Join(parts[1:], "/")
return parts[0], RepoRefCommit
repo.TreePath = strings.Join(reqRefPathParts[1:], "/")
return reqRefPathParts[0], RepoRefCommit
}
if refName := getRefName(ctx, repo, RepoRefBlob); len(refName) > 0 {
if refName := getRefName(ctx, repo, reqPath, RepoRefBlob); refName != "" {
return refName, RepoRefBlob
}
// FIXME: the old code falls back to default branch if "ref" doesn't exist, there could be an edge case:
// "README?ref=no-such" would read the README file from the default branch, but the user might expect a 404
repo.TreePath = reqPath
return repo.Repository.DefaultBranch, RepoRefBranch
}
func getRefName(ctx *Base, repo *Repository, pathType RepoRefType) string {
path := ctx.PathParam("*")
func getRefName(ctx *Base, repo *Repository, path string, pathType RepoRefType) string {
switch pathType {
case RepoRefBranch:
ref := getRefNameFromPath(repo, path, repo.GitRepo.IsBranchExist)
@ -889,7 +884,8 @@ func RepoRefByType(detectRefType RepoRefType, opts ...RepoRefByTypeOptions) func
}
// Get default branch.
if len(ctx.PathParam("*")) == 0 {
reqPath := ctx.PathParam("*")
if reqPath == "" {
refName = ctx.Repo.Repository.DefaultBranch
if !ctx.Repo.GitRepo.IsBranchExist(refName) {
brs, _, err := ctx.Repo.GitRepo.GetBranches(0, 1)
@ -914,12 +910,12 @@ func RepoRefByType(detectRefType RepoRefType, opts ...RepoRefByTypeOptions) func
return
}
ctx.Repo.IsViewBranch = true
} else {
} else { // there is a path in request
guessLegacyPath := refType == RepoRefUnknown
if guessLegacyPath {
refName, refType = getRefNameLegacy(ctx.Base, ctx.Repo)
refName, refType = getRefNameLegacy(ctx.Base, ctx.Repo, reqPath, "")
} else {
refName = getRefName(ctx.Base, ctx.Repo, refType)
refName = getRefName(ctx.Base, ctx.Repo, reqPath, refType)
}
ctx.Repo.RefName = refName
isRenamedBranch, has := ctx.Data["IsRenamedBranch"].(bool)

View File

@ -6,6 +6,7 @@ package pull
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
@ -636,33 +637,9 @@ func UpdateRef(ctx context.Context, pr *issues_model.PullRequest) (err error) {
return err
}
type errlist []error
func (errs errlist) Error() string {
if len(errs) > 0 {
var buf strings.Builder
for i, err := range errs {
if i > 0 {
buf.WriteString(", ")
}
buf.WriteString(err.Error())
}
return buf.String()
}
return ""
}
// RetargetChildrenOnMerge retarget children pull requests on merge if possible
func RetargetChildrenOnMerge(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) error {
if setting.Repository.PullRequest.RetargetChildrenOnMerge && pr.BaseRepoID == pr.HeadRepoID {
return RetargetBranchPulls(ctx, doer, pr.HeadRepoID, pr.HeadBranch, pr.BaseBranch)
}
return nil
}
// RetargetBranchPulls change target branch for all pull requests whose base branch is the branch
// retargetBranchPulls change target branch for all pull requests whose base branch is the branch
// Both branch and targetBranch must be in the same repo (for security reasons)
func RetargetBranchPulls(ctx context.Context, doer *user_model.User, repoID int64, branch, targetBranch string) error {
func retargetBranchPulls(ctx context.Context, doer *user_model.User, repoID int64, branch, targetBranch string) error {
prs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, repoID, branch)
if err != nil {
return err
@ -672,7 +649,7 @@ func RetargetBranchPulls(ctx context.Context, doer *user_model.User, repoID int6
return err
}
var errs errlist
var errs []error
for _, pr := range prs {
if err = pr.Issue.LoadRepo(ctx); err != nil {
errs = append(errs, err)
@ -682,40 +659,75 @@ func RetargetBranchPulls(ctx context.Context, doer *user_model.User, repoID int6
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errs
}
return nil
return errors.Join(errs...)
}
// CloseBranchPulls close all the pull requests who's head branch is the branch
func CloseBranchPulls(ctx context.Context, doer *user_model.User, repoID int64, branch string) error {
prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, repoID, branch)
// AdjustPullsCausedByBranchDeleted close all the pull requests who's head branch is the branch
// Or Close all the plls who's base branch is the branch if setting.Repository.PullRequest.RetargetChildrenOnMerge is false.
// If it's true, Retarget all these pulls to the default branch.
func AdjustPullsCausedByBranchDeleted(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, branch string) error {
// branch as head branch
prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, repo.ID, branch)
if err != nil {
return err
}
prs2, err := issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, repoID, branch)
if err != nil {
return err
}
prs = append(prs, prs2...)
if err := issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil {
return err
}
issues_model.PullRequestList(prs).SetHeadRepo(repo)
if err := issues_model.PullRequestList(prs).LoadRepositories(ctx); err != nil {
return err
}
var errs errlist
var errs []error
for _, pr := range prs {
if err = issue_service.CloseIssue(ctx, pr.Issue, doer, ""); err != nil && !issues_model.IsErrIssueIsClosed(err) && !issues_model.IsErrDependenciesLeft(err) {
errs = append(errs, err)
}
if err == nil {
if err := issues_model.AddDeletePRBranchComment(ctx, doer, pr.BaseRepo, pr.Issue.ID, pr.HeadBranch); err != nil {
log.Error("AddDeletePRBranchComment: %v", err)
errs = append(errs, err)
}
}
}
if len(errs) > 0 {
return errs
if setting.Repository.PullRequest.RetargetChildrenOnMerge {
if err := retargetBranchPulls(ctx, doer, repo.ID, branch, repo.DefaultBranch); err != nil {
log.Error("retargetBranchPulls failed: %v", err)
errs = append(errs, err)
}
return errors.Join(errs...)
}
return nil
// branch as base branch
prs, err = issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, repo.ID, branch)
if err != nil {
return err
}
if err := issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil {
return err
}
issues_model.PullRequestList(prs).SetBaseRepo(repo)
if err := issues_model.PullRequestList(prs).LoadRepositories(ctx); err != nil {
return err
}
errs = nil
for _, pr := range prs {
if err = issues_model.AddDeletePRBranchComment(ctx, doer, pr.BaseRepo, pr.Issue.ID, pr.BaseBranch); err != nil {
log.Error("AddDeletePRBranchComment: %v", err)
errs = append(errs, err)
}
if err == nil {
if err = issue_service.CloseIssue(ctx, pr.Issue, doer, ""); err != nil && !issues_model.IsErrIssueIsClosed(err) && !issues_model.IsErrDependenciesLeft(err) {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
// CloseRepoBranchesPulls close all pull requests which head branches are in the given repository, but only whose base repo is not in the given repository
@ -725,7 +737,7 @@ func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *re
return err
}
var errs errlist
var errs []error
for _, branch := range branches {
prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, repo.ID, branch.Name)
if err != nil {
@ -748,10 +760,7 @@ func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *re
}
}
if len(errs) > 0 {
return errs
}
return nil
return errors.Join(errs...)
}
var commitMessageTrailersPattern = regexp.MustCompile(`(?:^|\n\n)(?:[\w-]+[ \t]*:[^\n]+\n*(?:[ \t]+[^\n]+\n*)*)+$`)

View File

@ -489,7 +489,7 @@ func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchNam
}
// DeleteBranch delete branch
func DeleteBranch(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, branchName string) error {
func DeleteBranch(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, branchName string, pr *issues_model.PullRequest) error {
err := repo.MustNotBeArchived()
if err != nil {
return err
@ -519,6 +519,12 @@ func DeleteBranch(ctx context.Context, doer *user_model.User, repo *repo_model.R
}
}
if pr != nil {
if err := issues_model.AddDeletePRBranchComment(ctx, doer, pr.BaseRepo, pr.Issue.ID, pr.HeadBranch); err != nil {
return fmt.Errorf("DeleteBranch: %v", err)
}
}
return gitRepo.DeleteBranch(branchName, git.DeleteBranchOptions{
Force: true,
})

View File

@ -12,17 +12,20 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/pull"
)
type UpstreamDivergingInfo struct {
BaseIsNewer bool
CommitsBehind int
CommitsAhead int
BaseHasNewCommits bool
CommitsBehind int
CommitsAhead int
}
// MergeUpstream merges the base repository's default branch into the fork repository's current branch.
func MergeUpstream(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, branch string) (mergeStyle string, err error) {
if err = repo.MustNotBeArchived(); err != nil {
return "", err
@ -32,7 +35,7 @@ func MergeUpstream(ctx context.Context, doer *user_model.User, repo *repo_model.
}
err = git.Push(ctx, repo.BaseRepo.RepoPath(), git.PushOptions{
Remote: repo.RepoPath(),
Branch: fmt.Sprintf("%s:%s", branch, branch),
Branch: fmt.Sprintf("%s:%s", repo.BaseRepo.DefaultBranch, branch),
Env: repo_module.PushingEnvironment(doer, repo),
})
if err == nil {
@ -64,7 +67,7 @@ func MergeUpstream(ctx context.Context, doer *user_model.User, repo *repo_model.
BaseRepoID: repo.BaseRepo.ID,
BaseRepo: repo.BaseRepo,
HeadBranch: branch, // maybe HeadCommitID is not needed
BaseBranch: branch,
BaseBranch: repo.BaseRepo.DefaultBranch,
}
fakeIssue.PullRequest = fakePR
err = pull.Update(ctx, fakePR, doer, "merge upstream", false)
@ -74,7 +77,8 @@ func MergeUpstream(ctx context.Context, doer *user_model.User, repo *repo_model.
return "merge", nil
}
func GetUpstreamDivergingInfo(ctx context.Context, repo *repo_model.Repository, branch string) (*UpstreamDivergingInfo, error) {
// GetUpstreamDivergingInfo returns the information about the divergence between the fork repository's branch and the base repository's default branch.
func GetUpstreamDivergingInfo(ctx reqctx.RequestContext, repo *repo_model.Repository, branch string) (*UpstreamDivergingInfo, error) {
if !repo.IsFork {
return nil, util.NewInvalidArgumentErrorf("repo is not a fork")
}
@ -92,7 +96,7 @@ func GetUpstreamDivergingInfo(ctx context.Context, repo *repo_model.Repository,
return nil, err
}
baseBranch, err := git_model.GetBranch(ctx, repo.BaseRepo.ID, branch)
baseBranch, err := git_model.GetBranch(ctx, repo.BaseRepo.ID, repo.BaseRepo.DefaultBranch)
if err != nil {
return nil, err
}
@ -102,14 +106,39 @@ func GetUpstreamDivergingInfo(ctx context.Context, repo *repo_model.Repository,
return info, nil
}
// TODO: if the fork repo has new commits, this call will fail:
// if the fork repo has new commits, this call will fail because they are not in the base repo
// exit status 128 - fatal: Invalid symmetric difference expression aaaaaaaaaaaa...bbbbbbbbbbbb
// so at the moment, we are not able to handle this case, should be improved in the future
// so at the moment, we first check the update time, then check whether the fork branch has base's head
diff, err := git.GetDivergingCommits(ctx, repo.BaseRepo.RepoPath(), baseBranch.CommitID, forkBranch.CommitID)
if err != nil {
info.BaseIsNewer = baseBranch.UpdatedUnix > forkBranch.UpdatedUnix
info.BaseHasNewCommits = baseBranch.UpdatedUnix > forkBranch.UpdatedUnix
if info.BaseHasNewCommits {
return info, nil
}
// if the base's update time is before the fork, check whether the base's head is in the fork
baseGitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo.BaseRepo)
if err != nil {
return nil, err
}
headGitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo)
if err != nil {
return nil, err
}
baseCommitID, err := baseGitRepo.ConvertToGitID(baseBranch.CommitID)
if err != nil {
return nil, err
}
headCommit, err := headGitRepo.GetCommit(forkBranch.CommitID)
if err != nil {
return nil, err
}
hasPreviousCommit, _ := headCommit.HasPreviousCommit(baseCommitID)
info.BaseHasNewCommits = !hasPreviousCommit
return info, nil
}
info.CommitsBehind, info.CommitsAhead = diff.Behind, diff.Ahead
return info, nil
}

View File

@ -275,7 +275,8 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error {
}
} else {
notify_service.DeleteRef(ctx, pusher, repo, opts.RefFullName)
if err = pull_service.CloseBranchPulls(ctx, pusher, repo.ID, branch); err != nil {
if err := pull_service.AdjustPullsCausedByBranchDeleted(ctx, pusher, repo, branch); err != nil {
// close all related pulls
log.Error("close related pull request failed: %v", err)
}

View File

@ -1,8 +1,8 @@
{{/* Attributes:
* ContainerClasses
* Repository
* CurrentRefType: eg. "branch", "tag"
* CurrentRefShortName: eg. "master", "v1.0"
* CurrentRefType: eg. "branch", "tag", "commit"
* CurrentRefShortName: eg. "master", "v1.0", "abcdef0123"
* CurrentTreePath
* RefLinkTemplate: redirect to the link when a branch/tag is selected
* RefFormActionTemplate: change the parent form's action when a branch/tag is selected

View File

@ -1,8 +1,8 @@
{{if and .UpstreamDivergingInfo (or .UpstreamDivergingInfo.BaseIsNewer .UpstreamDivergingInfo.CommitsBehind)}}
{{if and .UpstreamDivergingInfo (or .UpstreamDivergingInfo.BaseHasNewCommits .UpstreamDivergingInfo.CommitsBehind)}}
<div class="ui message flex-text-block">
<div class="tw-flex-1">
{{$upstreamLink := printf "%s/src/branch/%s" .Repository.BaseRepo.Link (.BranchName|PathEscapeSegments)}}
{{$upstreamHtml := HTMLFormat `<a href="%s">%s:%s</a>` $upstreamLink .Repository.BaseRepo.FullName .BranchName}}
{{$upstreamLink := printf "%s/src/branch/%s" .Repository.BaseRepo.Link (.Repository.BaseRepo.DefaultBranch|PathEscapeSegments)}}
{{$upstreamHtml := HTMLFormat `<a href="%s">%s:%s</a>` $upstreamLink .Repository.BaseRepo.FullName .Repository.BaseRepo.DefaultBranch}}
{{if .UpstreamDivergingInfo.CommitsBehind}}
{{ctx.Locale.TrN .UpstreamDivergingInfo.CommitsBehind "repo.pulls.upstream_diverging_prompt_behind_1" "repo.pulls.upstream_diverging_prompt_behind_n" .UpstreamDivergingInfo.CommitsBehind $upstreamHtml}}
{{else}}

View File

@ -5,14 +5,19 @@
{{template "repo/sub_menu" .}}
<div class="repo-button-row">
<div class="repo-button-row-left">
{{$branchDropdownCurrentRefType := "branch"}}
{{$branchDropdownCurrentRefShortName := .BranchName}}
{{if .IsViewTag}}
{{$branchDropdownCurrentRefType = "tag"}}
{{$branchDropdownCurrentRefShortName = .TagName}}
{{end}}
{{template "repo/branch_dropdown" dict
{{- /* for /owner/repo/commits/branch/the-name */ -}}
{{- $branchDropdownCurrentRefType := "branch" -}}
{{- $branchDropdownCurrentRefShortName := .BranchName -}}
{{- if .IsViewTag -}}
{{- /* for /owner/repo/commits/tag/the-name */ -}}
{{- $branchDropdownCurrentRefType = "tag" -}}
{{- $branchDropdownCurrentRefShortName = .TagName -}}
{{- else if .IsViewCommit -}}
{{- /* for /owner/repo/commits/commit/000000 */ -}}
{{- $branchDropdownCurrentRefType = "commit" -}}
{{- $branchDropdownCurrentRefShortName = ShortSha .CommitID -}}
{{- end -}}
{{- template "repo/branch_dropdown" dict
"Repository" .Repository
"ShowTabBranches" true
"ShowTabTags" true
@ -21,8 +26,7 @@
"CurrentTreePath" .TreePath
"RefLinkTemplate" "{RepoLink}/commits/{RefType}/{RefShortName}/{TreePath}"
"AllowCreateNewRef" .CanCreateBranch
}}
-}}
<a href="{{.RepoLink}}/graph" class="ui basic small compact button">
{{svg "octicon-git-branch"}}
{{ctx.Locale.Tr "repo.commit_graph"}}

View File

@ -24,13 +24,19 @@
{{template "repo/sub_menu" .}}
<div class="repo-button-row">
<div class="repo-button-row-left">
{{$branchDropdownCurrentRefType := "branch"}}
{{$branchDropdownCurrentRefShortName := .BranchName}}
{{if .IsViewTag}}
{{$branchDropdownCurrentRefType = "tag"}}
{{$branchDropdownCurrentRefShortName = .TagName}}
{{end}}
{{template "repo/branch_dropdown" dict
{{- /* for repo home (default branch) and /owner/repo/src/branch/the-name */ -}}
{{- $branchDropdownCurrentRefType := "branch" -}}
{{- $branchDropdownCurrentRefShortName := .BranchName -}}
{{- if .IsViewTag -}}
{{- /* for /owner/repo/src/tag/the-name */ -}}
{{- $branchDropdownCurrentRefType = "tag" -}}
{{- $branchDropdownCurrentRefShortName = .TagName -}}
{{- else if .IsViewCommit -}}
{{- /* for /owner/repo/src/commit/000000 */ -}}
{{- $branchDropdownCurrentRefType = "commit" -}}
{{- $branchDropdownCurrentRefShortName = ShortSha .CommitID -}}
{{- end -}}
{{- template "repo/branch_dropdown" dict
"Repository" .Repository
"ShowTabBranches" true
"ShowTabTags" true
@ -40,7 +46,7 @@
"RefLinkTemplate" "{RepoLink}/src/{RefType}/{RefShortName}/{TreePath}"
"AllowCreateNewRef" .CanCreateBranch
"ShowViewAllRefsEntry" true
}}
-}}
{{if and .CanCompareOrPull .IsViewBranch (not .Repository.IsArchived)}}
{{$cmpBranch := ""}}
{{if ne .Repository.ID .BaseRepo.ID}}

View File

@ -16,9 +16,9 @@
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignees"}}">
</div>
<div class="item clear-selection">{{ctx.Locale.Tr "repo.issues.new.clear_assignees"}}</div>
<div class="scrolling menu">
<div class="scrolling menu flex-items-block">
{{range $data.CandidateAssignees}}
<a class="item muted" href="#" data-value="{{.ID}}">
<a class="item" href="#" data-value="{{.ID}}">
<span class="item-check-mark">{{svg "octicon-check"}}</span>
{{ctx.AvatarUtils.Avatar . 20}} {{template "repo/search_name" .}}
</a>
@ -26,10 +26,10 @@
</div>
</div>
</div>
<div class="ui list tw-flex tw-flex-row tw-gap-2">
<div class="ui list muted-links flex-items-block tw-flex tw-flex-col tw-gap-2">
<span class="item empty-list {{if $issueAssignees}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_assignees"}}</span>
{{range $issueAssignees}}
<a class="item muted" href="{{$pageMeta.RepoLink}}/{{if $pageMeta.IsPullRequest}}pulls{{else}}issues{{end}}?assignee={{.ID}}">
<a class="item" href="{{$pageMeta.RepoLink}}/{{if $pageMeta.IsPullRequest}}pulls{{else}}issues{{end}}?assignee={{.ID}}">
{{ctx.AvatarUtils.Avatar . 20}} {{.GetDisplayName}}
</a>
{{end}}

View File

@ -10867,6 +10867,52 @@
}
}
},
"/repos/{owner}/{repo}/merge-upstream": {
"post": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Merge a branch from upstream",
"operationId": "repoMergeUpstream",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/MergeUpstreamRequest"
}
}
],
"responses": {
"200": {
"$ref": "#/responses/MergeUpstreamResponse"
},
"400": {
"$ref": "#/responses/error"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/milestones": {
"get": {
"produces": [
@ -22827,6 +22873,26 @@
"x-go-name": "MergePullRequestForm",
"x-go-package": "code.gitea.io/gitea/services/forms"
},
"MergeUpstreamRequest": {
"type": "object",
"properties": {
"branch": {
"type": "string",
"x-go-name": "Branch"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"MergeUpstreamResponse": {
"type": "object",
"properties": {
"merge_type": {
"type": "string",
"x-go-name": "MergeStyle"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"MigrateRepoOptions": {
"description": "MigrateRepoOptions options for migrating repository's\nthis is used to interact with api v1",
"type": "object",
@ -26008,6 +26074,18 @@
"type": "string"
}
},
"MergeUpstreamRequest": {
"description": "",
"schema": {
"$ref": "#/definitions/MergeUpstreamRequest"
}
},
"MergeUpstreamResponse": {
"description": "",
"schema": {
"$ref": "#/definitions/MergeUpstreamResponse"
}
},
"Milestone": {
"description": "Milestone",
"schema": {

View File

@ -405,7 +405,7 @@ func TestCreateDeleteRefEvent(t *testing.T) {
assert.NotNil(t, run)
// delete the branch
err = repo_service.DeleteBranch(db.DefaultContext, user2, repo, gitRepo, "test-create-branch")
err = repo_service.DeleteBranch(db.DefaultContext, user2, repo, gitRepo, "test-create-branch", nil)
assert.NoError(t, err)
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
Title: "add workflow",

View File

@ -18,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
repo_service "code.gitea.io/gitea/services/repository"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
@ -168,28 +169,37 @@ func testAPIGetContents(t *testing.T, u *url.URL) {
}
func TestAPIGetContentsRefFormats(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
file := "README.md"
sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
content := "# repo1\n\nDescription for repo1"
defer tests.PrepareTestEnv(t)()
noRef := setting.AppURL + "api/v1/repos/user2/repo1/raw/" + file
refInPath := setting.AppURL + "api/v1/repos/user2/repo1/raw/" + sha + "/" + file
refInQuery := setting.AppURL + "api/v1/repos/user2/repo1/raw/" + file + "?ref=" + sha
file := "README.md"
sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
content := "# repo1\n\nDescription for repo1"
resp := MakeRequest(t, NewRequest(t, http.MethodGet, noRef), http.StatusOK)
raw, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.EqualValues(t, content, string(raw))
resp := MakeRequest(t, NewRequest(t, http.MethodGet, "/api/v1/repos/user2/repo1/raw/"+file), http.StatusOK)
raw, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.EqualValues(t, content, string(raw))
resp = MakeRequest(t, NewRequest(t, http.MethodGet, refInPath), http.StatusOK)
raw, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.EqualValues(t, content, string(raw))
resp = MakeRequest(t, NewRequest(t, http.MethodGet, "/api/v1/repos/user2/repo1/raw/"+sha+"/"+file), http.StatusOK)
raw, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.EqualValues(t, content, string(raw))
resp = MakeRequest(t, NewRequest(t, http.MethodGet, refInQuery), http.StatusOK)
raw, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.EqualValues(t, content, string(raw))
})
resp = MakeRequest(t, NewRequest(t, http.MethodGet, "/api/v1/repos/user2/repo1/raw/"+file+"?ref="+sha), http.StatusOK)
raw, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.EqualValues(t, content, string(raw))
resp = MakeRequest(t, NewRequest(t, http.MethodGet, "/api/v1/repos/user2/repo1/raw/"+file+"?ref=master"), http.StatusOK)
raw, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.EqualValues(t, content, string(raw))
_ = MakeRequest(t, NewRequest(t, http.MethodGet, "/api/v1/repos/user2/repo1/raw/docs/README.md?ref=main"), http.StatusNotFound)
_ = MakeRequest(t, NewRequest(t, http.MethodGet, "/api/v1/repos/user2/repo1/raw/README.md?ref=main"), http.StatusOK)
_ = MakeRequest(t, NewRequest(t, http.MethodGet, "/api/v1/repos/user2/repo1/raw/docs/README.md?ref=sub-home-md-img-check"), http.StatusOK)
_ = MakeRequest(t, NewRequest(t, http.MethodGet, "/api/v1/repos/user2/repo1/raw/README.md?ref=sub-home-md-img-check"), http.StatusNotFound)
// FIXME: this is an incorrect behavior, non-existing branch falls back to default branch
_ = MakeRequest(t, NewRequest(t, http.MethodGet, "/api/v1/repos/user2/repo1/raw/README.md?ref=no-such"), http.StatusOK)
}

View File

@ -585,6 +585,8 @@ func TestPullDontRetargetChildOnWrongRepo(t *testing.T) {
elemChildPR := strings.Split(test.RedirectURL(respChildPR), "/")
assert.EqualValues(t, "pulls", elemChildPR[3])
defer test.MockVariableValue(&setting.Repository.PullRequest.RetargetChildrenOnMerge, false)()
testPullMerge(t, session, elemBasePR[1], elemBasePR[2], elemBasePR[4], repo_model.MergeStyleMerge, true)
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: "repo1"})
@ -736,12 +738,12 @@ func TestPullAutoMergeAfterCommitStatusSucceed(t *testing.T) {
session.MakeRequest(t, req, http.StatusSeeOther)
// first time insert automerge record, return true
scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test")
scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test", false)
assert.NoError(t, err)
assert.True(t, scheduled)
// second time insert automerge record, return false because it does exist
scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test")
scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test", false)
assert.Error(t, err)
assert.False(t, scheduled)
@ -820,12 +822,12 @@ func TestPullAutoMergeAfterCommitStatusSucceedAndApproval(t *testing.T) {
session.MakeRequest(t, req, http.StatusSeeOther)
// first time insert automerge record, return true
scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test")
scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test", false)
assert.NoError(t, err)
assert.True(t, scheduled)
// second time insert automerge record, return false because it does exist
scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test")
scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test", false)
assert.Error(t, err)
assert.False(t, scheduled)
@ -949,12 +951,12 @@ func TestPullAutoMergeAfterCommitStatusSucceedAndApprovalForAgitFlow(t *testing.
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
// first time insert automerge record, return true
scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test")
scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test", false)
assert.NoError(t, err)
assert.True(t, scheduled)
// second time insert automerge record, return false because it does exist
scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test")
scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test", false)
assert.Error(t, err)
assert.False(t, scheduled)

View File

@ -0,0 +1,122 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoMergeUpstream(t *testing.T) {
onGiteaRun(t, func(*testing.T, *url.URL) {
forkUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
baseUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: baseRepo.OwnerID})
checkFileContent := func(branch, exp string) {
req := NewRequest(t, "GET", fmt.Sprintf("/%s/test-repo-fork/raw/branch/%s/new-file.txt", forkUser.Name, branch))
resp := MakeRequest(t, req, http.StatusOK)
require.Equal(t, exp, resp.Body.String())
}
session := loginUser(t, forkUser.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
// create a fork
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseUser.Name, baseRepo.Name), &api.CreateForkOption{
Name: util.ToPointer("test-repo-fork"),
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusAccepted)
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: forkUser.ID, Name: "test-repo-fork"})
// create fork-branch
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/test-repo-fork/branches/_new/branch/master", forkUser.Name), map[string]string{
"_csrf": GetUserCSRFToken(t, session),
"new_branch_name": "fork-branch",
})
session.MakeRequest(t, req, http.StatusSeeOther)
queryMergeUpstreamButtonLink := func(htmlDoc *HTMLDoc) string {
return htmlDoc.Find(`button[data-url*="merge-upstream"]`).AttrOr("data-url", "")
}
t.Run("HeadBeforeBase", func(t *testing.T) {
// add a file in base repo
require.NoError(t, createOrReplaceFileInBranch(baseUser, baseRepo, "new-file.txt", "master", "test-content-1"))
// the repo shows a prompt to "sync fork"
var mergeUpstreamLink string
require.Eventually(t, func() bool {
resp := session.MakeRequest(t, NewRequestf(t, "GET", "/%s/test-repo-fork/src/branch/fork-branch", forkUser.Name), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
mergeUpstreamLink = queryMergeUpstreamButtonLink(htmlDoc)
if mergeUpstreamLink == "" {
return false
}
respMsg, _ := htmlDoc.Find(".ui.message:not(.positive)").Html()
return strings.Contains(respMsg, `This branch is 1 commit behind <a href="/user2/repo1/src/branch/master">user2/repo1:master</a>`)
}, 5*time.Second, 100*time.Millisecond)
// click the "sync fork" button
req = NewRequestWithValues(t, "POST", mergeUpstreamLink, map[string]string{"_csrf": GetUserCSRFToken(t, session)})
session.MakeRequest(t, req, http.StatusOK)
checkFileContent("fork-branch", "test-content-1")
})
t.Run("BaseChangeAfterHeadChange", func(t *testing.T) {
// update the files: base first, head later, and check the prompt
require.NoError(t, createOrReplaceFileInBranch(baseUser, baseRepo, "new-file.txt", "master", "test-content-2"))
require.NoError(t, createOrReplaceFileInBranch(forkUser, forkRepo, "new-file-other.txt", "fork-branch", "test-content-other"))
// make sure the base branch's update time is before the fork, to make it test the complete logic
baseBranch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: baseRepo.ID, Name: "master"})
forkBranch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: forkRepo.ID, Name: "fork-branch"})
_, err := db.GetEngine(db.DefaultContext).ID(forkBranch.ID).Update(&git_model.Branch{UpdatedUnix: baseBranch.UpdatedUnix + 1})
require.NoError(t, err)
// the repo shows a prompt to "sync fork"
require.Eventually(t, func() bool {
resp := session.MakeRequest(t, NewRequestf(t, "GET", "/%s/test-repo-fork/src/branch/fork-branch", forkUser.Name), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
respMsg, _ := htmlDoc.Find(".ui.message:not(.positive)").Html()
return strings.Contains(respMsg, `The base branch <a href="/user2/repo1/src/branch/master">user2/repo1:master</a> has new changes`)
}, 5*time.Second, 100*time.Millisecond)
// and do the merge-upstream by API
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/test-repo-fork/merge-upstream", forkUser.Name), &api.MergeUpstreamRequest{
Branch: "fork-branch",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
checkFileContent("fork-branch", "test-content-2")
var mergeResp api.MergeUpstreamResponse
DecodeJSON(t, resp, &mergeResp)
assert.Equal(t, "merge", mergeResp.MergeStyle)
// after merge, there should be no "sync fork" button anymore
require.Eventually(t, func() bool {
resp := session.MakeRequest(t, NewRequestf(t, "GET", "/%s/test-repo-fork/src/branch/fork-branch", forkUser.Name), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
return queryMergeUpstreamButtonLink(htmlDoc) == ""
}, 5*time.Second, 100*time.Millisecond)
})
})
}

View File

@ -129,7 +129,7 @@ function clearMergeMessage() {
{{ mergeForm.textCancel }}
</button>
<div class="ui checkbox tw-ml-1" v-if="mergeForm.isPullBranchDeletable && !autoMergeWhenSucceed">
<div class="ui checkbox tw-ml-1" v-if="mergeForm.isPullBranchDeletable">
<input name="delete_branch_after_merge" type="checkbox" v-model="deleteBranchAfterMerge" id="delete-branch-after-merge">
<label for="delete-branch-after-merge">{{ mergeForm.textDeleteBranch }}</label>
</div>

View File

@ -1,4 +1,166 @@
import {initTextareaMarkdown} from './EditorMarkdown.ts';
import {initTextareaMarkdown, markdownHandleIndention, textareaSplitLines} from './EditorMarkdown.ts';
test('textareaSplitLines', () => {
let ret = textareaSplitLines('a\nbc\nd', 0);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 0});
ret = textareaSplitLines('a\nbc\nd', 1);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 1});
ret = textareaSplitLines('a\nbc\nd', 2);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 0});
ret = textareaSplitLines('a\nbc\nd', 3);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 1});
ret = textareaSplitLines('a\nbc\nd', 4);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 2});
ret = textareaSplitLines('a\nbc\nd', 5);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 0});
ret = textareaSplitLines('a\nbc\nd', 6);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 1});
});
test('markdownHandleIndention', () => {
const testInput = (input: string, expected?: string) => {
const inputPos = input.indexOf('|');
input = input.replace('|', '');
const ret = markdownHandleIndention({value: input, selStart: inputPos, selEnd: inputPos});
if (expected === null) {
expect(ret).toEqual({handled: false});
} else {
const expectedPos = expected.indexOf('|');
expected = expected.replace('|', '');
expect(ret).toEqual({
handled: true,
valueSelection: {value: expected, selStart: expectedPos, selEnd: expectedPos},
});
}
};
testInput(`
a|b
`, `
a
|b
`);
testInput(`
1. a
2. |
`, `
1. a
|
`);
testInput(`
|1. a
`, null); // let browser handle it
testInput(`
1. a
1. b|c
`, `
1. a
2. b
3. |c
`);
testInput(`
2. a
2. b|
1. x
1. y
`, `
1. a
2. b
3. |
1. x
1. y
`);
testInput(`
2. a
2. b
1. x|
1. y
`, `
2. a
2. b
1. x
2. |
3. y
`);
testInput(`
1. a
2. b|
3. c
`, `
1. a
2. b
3. |
4. c
`);
testInput(`
1. a
1. b
2. b
3. b
4. b
1. c|
`, `
1. a
1. b
2. b
3. b
4. b
2. c
3. |
`);
testInput(`
1. a
2. a
3. a
4. a
5. a
6. a
7. a
8. a
9. b|c
`, `
1. a
2. a
3. a
4. a
5. a
6. a
7. a
8. a
9. b
10. |c
`);
// this is a special case, it's difficult to re-format the parent level at the moment, so leave it to the future
testInput(`
1. a
2. b|
3. c
`, `
1. a
1. b
2. |
3. c
`);
});
test('EditorMarkdown', () => {
const textarea = document.createElement('textarea');
@ -32,10 +194,10 @@ test('EditorMarkdown', () => {
testInput({value: '1. \n2. ', pos: 3}, {value: '\n2. ', pos: 0});
testInput('- x', '- x\n- ');
testInput('1. foo', '1. foo\n1. ');
testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n1. \n2. b\n3. c', pos: 8});
testInput('1. foo', '1. foo\n2. ');
testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n2. \n3. b\n4. c', pos: 8});
testInput('- [ ]', '- [ ]\n- ');
testInput('- [ ] foo', '- [ ] foo\n- [ ] ');
testInput('* [x] foo', '* [x] foo\n* [ ] ');
testInput('1. [x] foo', '1. [x] foo\n1. [ ] ');
testInput('1. [x] foo', '1. [x] foo\n2. [ ] ');
});

View File

@ -14,7 +14,13 @@ export function textareaInsertText(textarea, value) {
triggerEditorContentChanged(textarea);
}
function handleIndentSelection(textarea, e) {
type TextareaValueSelection = {
value: string;
selStart: number;
selEnd: number;
}
function handleIndentSelection(textarea: HTMLTextAreaElement, e) {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
if (selEnd === selStart) return; // do not process when no selection
@ -56,53 +62,125 @@ function handleIndentSelection(textarea, e) {
triggerEditorContentChanged(textarea);
}
function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
if (selEnd !== selStart) return; // do not process when there is a selection
type MarkdownHandleIndentionResult = {
handled: boolean;
valueSelection?: TextareaValueSelection;
}
const value = textarea.value;
type TextLinesBuffer = {
lines: string[];
lengthBeforePosLine: number;
posLineIndex: number;
inlinePos: number
}
// find the current line
// * if selStart is 0, lastIndexOf(..., -1) is the same as lastIndexOf(..., 0)
// * if lastIndexOf reruns -1, lineStart is 0 and it is still correct.
const lineStart = value.lastIndexOf('\n', selStart - 1) + 1;
let lineEnd = value.indexOf('\n', selStart);
lineEnd = lineEnd < 0 ? value.length : lineEnd;
let line = value.slice(lineStart, lineEnd);
if (!line) return; // if the line is empty, do nothing, let the browser handle it
export function textareaSplitLines(value: string, pos: number): TextLinesBuffer {
const lines = value.split('\n');
let lengthBeforePosLine = 0, inlinePos = 0, posLineIndex = 0;
for (; posLineIndex < lines.length; posLineIndex++) {
const lineLength = lines[posLineIndex].length + 1;
if (lengthBeforePosLine + lineLength > pos) {
inlinePos = pos - lengthBeforePosLine;
break;
}
lengthBeforePosLine += lineLength;
}
return {lines, lengthBeforePosLine, posLineIndex, inlinePos};
}
function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: string) {
const reDeeperIndention = new RegExp(`^${indention}\\s+`);
const reSameLevel = new RegExp(`^${indention}([0-9]+)\\.`);
let firstLineIdx: number;
for (firstLineIdx = linesBuf.posLineIndex - 1; firstLineIdx >= 0; firstLineIdx--) {
const line = linesBuf.lines[firstLineIdx];
if (!reDeeperIndention.test(line) && !reSameLevel.test(line)) break;
}
firstLineIdx++;
let num = 1;
for (let i = firstLineIdx; i < linesBuf.lines.length; i++) {
const oldLine = linesBuf.lines[i];
const sameLevel = reSameLevel.test(oldLine);
if (!sameLevel && !reDeeperIndention.test(oldLine)) break;
if (sameLevel) {
const newLine = `${indention}${num}.${oldLine.replace(reSameLevel, '')}`;
linesBuf.lines[i] = newLine;
num++;
if (linesBuf.posLineIndex === i) {
// need to correct the cursor inline position if the line length changes
linesBuf.inlinePos += newLine.length - oldLine.length;
linesBuf.inlinePos = Math.max(0, linesBuf.inlinePos);
linesBuf.inlinePos = Math.min(newLine.length, linesBuf.inlinePos);
}
}
}
recalculateLengthBeforeLine(linesBuf);
}
function recalculateLengthBeforeLine(linesBuf: TextLinesBuffer) {
linesBuf.lengthBeforePosLine = 0;
for (let i = 0; i < linesBuf.posLineIndex; i++) {
linesBuf.lengthBeforePosLine += linesBuf.lines[i].length + 1;
}
}
export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHandleIndentionResult {
const unhandled: MarkdownHandleIndentionResult = {handled: false};
if (tvs.selEnd !== tvs.selStart) return unhandled; // do not process when there is a selection
const linesBuf = textareaSplitLines(tvs.value, tvs.selStart);
const line = linesBuf.lines[linesBuf.posLineIndex] ?? '';
if (!line) return unhandled; // if the line is empty, do nothing, let the browser handle it
// parse the indention
const indention = /^\s*/.exec(line)[0];
line = line.slice(indention.length);
let lineContent = line;
const indention = /^\s*/.exec(lineContent)[0];
lineContent = lineContent.slice(indention.length);
if (linesBuf.inlinePos <= indention.length) return unhandled; // if cursor is at the indention, do nothing, let the browser handle it
// parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists
// there must be a space after the prefix because none of "1.foo" / "-foo" is a list item
const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(line);
const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(lineContent);
let prefix = '';
if (prefixMatch) {
prefix = prefixMatch[0];
if (lineStart + prefix.length > selStart) prefix = ''; // do not add new line if cursor is at prefix
if (prefix.length > linesBuf.inlinePos) prefix = ''; // do not add new line if cursor is at prefix
}
line = line.slice(prefix.length);
if (!indention && !prefix) return; // if no indention and no prefix, do nothing, let the browser handle it
lineContent = lineContent.slice(prefix.length);
if (!indention && !prefix) return unhandled; // if no indention and no prefix, do nothing, let the browser handle it
e.preventDefault();
if (!line) {
if (!lineContent) {
// clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list
textarea.value = value.slice(0, lineStart) + value.slice(lineEnd);
textarea.setSelectionRange(selStart - prefix.length, selStart - prefix.length);
linesBuf.lines[linesBuf.posLineIndex] = '';
linesBuf.inlinePos = 0;
} else {
// start a new line with the same indention and prefix
// start a new line with the same indention
let newPrefix = prefix;
// a simple approach, otherwise it needs to parse the lines after the current line
if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`;
newPrefix = newPrefix.replace('[x]', '[ ]');
const newLine = `\n${indention}${newPrefix}`;
textarea.value = value.slice(0, selStart) + newLine + value.slice(selEnd);
textarea.setSelectionRange(selStart + newLine.length, selStart + newLine.length);
const inlinePos = linesBuf.inlinePos;
linesBuf.lines[linesBuf.posLineIndex] = line.substring(0, inlinePos);
const newLineLeft = `${indention}${newPrefix}`;
const newLine = `${newLineLeft}${line.substring(inlinePos)}`;
linesBuf.lines.splice(linesBuf.posLineIndex + 1, 0, newLine);
linesBuf.posLineIndex++;
linesBuf.inlinePos = newLineLeft.length;
recalculateLengthBeforeLine(linesBuf);
}
markdownReformatListNumbers(linesBuf, indention);
const newPos = linesBuf.lengthBeforePosLine + linesBuf.inlinePos;
return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
}
function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
if (!ret.handled) return;
e.preventDefault();
textarea.value = ret.valueSelection.value;
textarea.setSelectionRange(ret.valueSelection.selStart, ret.valueSelection.selEnd);
triggerEditorContentChanged(textarea);
}