Skip to content

Commit 7781e8c

Browse files
JulienTantlafriks
authored andcommitted
Disable merging a WIP Pull request (#4529)
* prevent pull request to be merged when PR is a WIP * add tests * add helper to prepend WIP: in PR title * move default wip prefixes into settings * use configurable WIP prefixes in javascript and default to first one in templates * add documentation * add unit test on pull model Signed-off-by: Julien Tant <[email protected]>
1 parent 52c2cb1 commit 7781e8c

File tree

16 files changed

+218
-5
lines changed

16 files changed

+218
-5
lines changed

custom/conf/app.ini.sample

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ FILE_MAX_SIZE = 3
6060
; Max number of files per upload. Defaults to 5
6161
MAX_FILES = 5
6262

63+
[repository.pull-request]
64+
; List of prefixes used in Pull Request title to mark them as Work In Progress
65+
WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
66+
6367
[ui]
6468
; Number of repositories that are displayed on one explore page
6569
EXPLORE_PAGING_NUM = 20

docs/content/doc/advanced/config-cheat-sheet.en-us.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
6262
HTTP protocol.
6363
- `USE_COMPAT_SSH_URI`: **false**: Force ssh:// clone url instead of scp-style uri when
6464
default SSH port is used.
65+
66+
### Repository - Pull Request (`repository.pull-request`)
67+
- `WORK_IN_PROGRESS_PREFIXES`: **WIP:,\[WIP\]**: List of prefixes used in Pull Request
68+
title to mark them as Work In Progress
6569

6670
## UI (`ui`)
6771

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
date: "2018-06-01T19:00:00+02:00"
3+
title: "Usage: Pull Request"
4+
slug: "pull-request"
5+
weight: 13
6+
toc: true
7+
draft: false
8+
menu:
9+
sidebar:
10+
parent: "usage"
11+
name: "Pull Request"
12+
weight: 13
13+
identifier: "pull-request"
14+
---
15+
16+
# Pull Request
17+
18+
## "Work In Progress" pull requests
19+
20+
Marking a pull request as being a work in progress will prevent that pull request from being accidentally merged. To mark a pull request as being a work in progress, you must prefix its title by `WIP:` or `[WIP]` (case insensitive). Those values are configurable in your `app.ini` file :
21+
22+
```
23+
[repository.pull-request]
24+
WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
25+
```
26+
27+
The first value of the list will be used in helpers.
28+
29+
## Pull Request Templates
30+
31+
You can find more information about pull request templates in the dedicated page : [Issue and Pull Request templates](../issue-pull-request-templates)

integrations/api_pull_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
package integrations
66

77
import (
8+
"fmt"
89
"net/http"
910
"testing"
1011

1112
"code.gitea.io/gitea/models"
13+
"code.gitea.io/gitea/modules/auth"
14+
"code.gitea.io/gitea/modules/setting"
1215
api "code.gitea.io/sdk/gitea"
1316

1417
"github.com/stretchr/testify/assert"
@@ -28,3 +31,26 @@ func TestAPIViewPulls(t *testing.T) {
2831
expectedLen := models.GetCount(t, &models.Issue{RepoID: repo.ID}, models.Cond("is_pull = ?", true))
2932
assert.Len(t, pulls, expectedLen)
3033
}
34+
35+
// TestAPIMergePullWIP ensures that we can't merge a WIP pull request
36+
func TestAPIMergePullWIP(t *testing.T) {
37+
prepareTestEnv(t)
38+
repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository)
39+
owner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User)
40+
pr := models.AssertExistsAndLoadBean(t, &models.PullRequest{Status: models.PullRequestStatusMergeable}, models.Cond("has_merged = ?", false)).(*models.PullRequest)
41+
pr.LoadIssue()
42+
pr.Issue.ChangeTitle(owner, setting.Repository.PullRequest.WorkInProgressPrefixes[0]+" "+pr.Issue.Title)
43+
44+
// force reload
45+
pr.LoadAttributes()
46+
47+
assert.Contains(t, pr.Issue.Title, setting.Repository.PullRequest.WorkInProgressPrefixes[0])
48+
49+
session := loginUser(t, owner.Name)
50+
req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner.Name, repo.Name, pr.Index), &auth.MergePullRequestForm{
51+
MergeMessageField: pr.Issue.Title,
52+
Do: string(models.MergeStyleMerge),
53+
})
54+
55+
session.MakeRequest(t, req, http.StatusMethodNotAllowed)
56+
}

integrations/pull_merge_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"code.gitea.io/gitea/models"
1515
"code.gitea.io/gitea/modules/test"
1616

17+
"github.com/Unknwon/i18n"
1718
"github.com/stretchr/testify/assert"
1819
)
1920

@@ -123,3 +124,23 @@ func TestPullCleanUpAfterMerge(t *testing.T) {
123124

124125
assert.EqualValues(t, "Branch 'user1/feature/test' has been deleted.", resultMsg)
125126
}
127+
128+
func TestCantMergeWorkInProgress(t *testing.T) {
129+
prepareTestEnv(t)
130+
session := loginUser(t, "user1")
131+
testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
132+
testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
133+
134+
resp := testPullCreate(t, session, "user1", "repo1", "master", "[wip] This is a pull title")
135+
136+
req := NewRequest(t, "GET", resp.Header().Get("Location"))
137+
resp = session.MakeRequest(t, req, http.StatusOK)
138+
htmlDoc := NewHTMLParser(t, resp.Body)
139+
text := strings.TrimSpace(htmlDoc.doc.Find(".merge.segment > .text.grey").Text())
140+
assert.NotEmpty(t, text, "Can't find WIP text")
141+
142+
// remove <strong /> from lang
143+
expected := i18n.Tr("en", "repo.pulls.cannot_merge_work_in_progress", "[wip]")
144+
replacer := strings.NewReplacer("<strong>", "", "</strong>", "")
145+
assert.Equal(t, replacer.Replace(expected), text, "Unable to find WIP text")
146+
}

models/pull.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ func (pr *PullRequest) APIFormat() *api.PullRequest {
214214
}
215215

216216
if pr.Status != PullRequestStatusChecking {
217-
mergeable := pr.Status != PullRequestStatusConflict
217+
mergeable := pr.Status != PullRequestStatusConflict && !pr.IsWorkInProgress()
218218
apiPullRequest.Mergeable = mergeable
219219
}
220220
if pr.HasMerged {
@@ -1247,6 +1247,37 @@ func (pr *PullRequest) checkAndUpdateStatus() {
12471247
}
12481248
}
12491249

1250+
// IsWorkInProgress determine if the Pull Request is a Work In Progress by its title
1251+
func (pr *PullRequest) IsWorkInProgress() bool {
1252+
if err := pr.LoadIssue(); err != nil {
1253+
log.Error(4, "LoadIssue: %v", err)
1254+
return false
1255+
}
1256+
1257+
for _, prefix := range setting.Repository.PullRequest.WorkInProgressPrefixes {
1258+
if strings.HasPrefix(strings.ToUpper(pr.Issue.Title), prefix) {
1259+
return true
1260+
}
1261+
}
1262+
return false
1263+
}
1264+
1265+
// GetWorkInProgressPrefix returns the prefix used to mark the pull request as a work in progress.
1266+
// It returns an empty string when none were found
1267+
func (pr *PullRequest) GetWorkInProgressPrefix() string {
1268+
if err := pr.LoadIssue(); err != nil {
1269+
log.Error(4, "LoadIssue: %v", err)
1270+
return ""
1271+
}
1272+
1273+
for _, prefix := range setting.Repository.PullRequest.WorkInProgressPrefixes {
1274+
if strings.HasPrefix(strings.ToUpper(pr.Issue.Title), prefix) {
1275+
return pr.Issue.Title[0:len(prefix)]
1276+
}
1277+
}
1278+
return ""
1279+
}
1280+
12501281
// TestPullRequests checks and tests untested patches of pull requests.
12511282
// TODO: test more pull requests at same time.
12521283
func TestPullRequests() {

models/pull_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,34 @@ func TestChangeUsernameInPullRequests(t *testing.T) {
237237
}
238238
CheckConsistencyFor(t, &PullRequest{})
239239
}
240+
241+
func TestPullRequest_IsWorkInProgress(t *testing.T) {
242+
assert.NoError(t, PrepareTestDatabase())
243+
244+
pr := AssertExistsAndLoadBean(t, &PullRequest{ID: 2}).(*PullRequest)
245+
pr.LoadIssue()
246+
247+
assert.False(t, pr.IsWorkInProgress())
248+
249+
pr.Issue.Title = "WIP: " + pr.Issue.Title
250+
assert.True(t, pr.IsWorkInProgress())
251+
252+
pr.Issue.Title = "[wip]: " + pr.Issue.Title
253+
assert.True(t, pr.IsWorkInProgress())
254+
}
255+
256+
func TestPullRequest_GetWorkInProgressPrefixWorkInProgress(t *testing.T) {
257+
assert.NoError(t, PrepareTestDatabase())
258+
259+
pr := AssertExistsAndLoadBean(t, &PullRequest{ID: 2}).(*PullRequest)
260+
pr.LoadIssue()
261+
262+
assert.Empty(t, pr.GetWorkInProgressPrefix())
263+
264+
original := pr.Issue.Title
265+
pr.Issue.Title = "WIP: " + original
266+
assert.Equal(t, "WIP:", pr.GetWorkInProgressPrefix())
267+
268+
pr.Issue.Title = "[wip] " + original
269+
assert.Equal(t, "[wip]", pr.GetWorkInProgressPrefix())
270+
}

modules/setting/defaults.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
)
66

77
var (
8-
defaultLangs = strings.Split("en-US,zh-CN,zh-HK,zh-TW,de-DE,fr-FR,nl-NL,lv-LV,ru-RU,uk-UA,ja-JP,es-ES,pt-BR,pl-PL,bg-BG,it-IT,fi-FI,tr-TR,cs-CZ,sr-SP,sv-SE,ko-KR", ",")
9-
defaultLangNames = strings.Split("English,简体中文,繁體中文(香港),繁體中文(台灣),Deutsch,français,Nederlands,latviešu,русский,Українська,日本語,español,português do Brasil,polski,български,italiano,suomi,Türkçe,čeština,српски,svenska,한국어", ",")
8+
defaultLangs = strings.Split("en-US,zh-CN,zh-HK,zh-TW,de-DE,fr-FR,nl-NL,lv-LV,ru-RU,uk-UA,ja-JP,es-ES,pt-BR,pl-PL,bg-BG,it-IT,fi-FI,tr-TR,cs-CZ,sr-SP,sv-SE,ko-KR", ",")
9+
defaultLangNames = strings.Split("English,简体中文,繁體中文(香港),繁體中文(台灣),Deutsch,français,Nederlands,latviešu,русский,Українська,日本語,español,português do Brasil,polski,български,italiano,suomi,Türkçe,čeština,српски,svenska,한국어", ",")
10+
defaultPullRequestWorkInProgressPrefixes = strings.Split("WIP:,[WIP]", ",")
1011
)

modules/setting/setting.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ var (
224224
LocalCopyPath string
225225
LocalWikiPath string
226226
} `ini:"-"`
227+
228+
// Pull request settings
229+
PullRequest struct {
230+
WorkInProgressPrefixes []string
231+
} `ini:"repository.pull-request"`
227232
}{
228233
AnsiCharset: "",
229234
ForcePrivate: false,
@@ -267,6 +272,13 @@ var (
267272
LocalCopyPath: "tmp/local-repo",
268273
LocalWikiPath: "tmp/local-wiki",
269274
},
275+
276+
// Pull request settings
277+
PullRequest: struct {
278+
WorkInProgressPrefixes []string
279+
}{
280+
WorkInProgressPrefixes: defaultPullRequestWorkInProgressPrefixes,
281+
},
270282
}
271283
RepoRootPath string
272284
ScriptType = "bash"
@@ -1031,6 +1043,8 @@ func NewContext() {
10311043
log.Fatal(4, "Failed to map Repository.Upload settings: %v", err)
10321044
} else if err = Cfg.Section("repository.local").MapTo(&Repository.Local); err != nil {
10331045
log.Fatal(4, "Failed to map Repository.Local settings: %v", err)
1046+
} else if err = Cfg.Section("repository.pull-request").MapTo(&Repository.PullRequest); err != nil {
1047+
log.Fatal(4, "Failed to map Repository.PullRequest settings: %v", err)
10341048
}
10351049

10361050
if !filepath.IsAbs(Repository.Upload.TempPath) {

options/locale/locale_en-US.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,13 +842,16 @@ pulls.tab_files = Files Changed
842842
pulls.reopen_to_merge = Please reopen this pull request to perform a merge.
843843
pulls.merged = Merged
844844
pulls.has_merged = The pull request has been merged.
845+
pulls.title_wip_desc = `<a href="#">Start the title with <strong>%s</strong></a> to prevent the pull request from being merged accidentally.`
846+
pulls.cannot_merge_work_in_progress = This pull request is marked as a work in progress. Remove the <strong>%s</strong> prefix from the title when it's ready
845847
pulls.data_broken = This pull request is broken due to missing fork information.
846848
pulls.is_checking = "Merge conflict checking is in progress. Try again in few moments."
847849
pulls.can_auto_merge_desc = This pull request can be merged automatically.
848850
pulls.cannot_auto_merge_desc = This pull request cannot be merged automatically due to conflicts.
849851
pulls.cannot_auto_merge_helper = Merge manually to resolve the conflicts.
850852
pulls.no_merge_desc = This pull request cannot be merged because all repository merge options are disabled.
851853
pulls.no_merge_helper = Enable merge options in the repository settings or merge the pull request manually.
854+
pulls.no_merge_wip = This pull request can not be merged because it is marked as being a work in progress.
852855
pulls.merge_pull_request = Merge Pull Request
853856
pulls.rebase_merge_pull_request = Rebase and Merge
854857
pulls.squash_merge_pull_request = Squash and Merge

public/js/index.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1655,6 +1655,23 @@ function u2fRegisterRequest() {
16551655
});
16561656
}
16571657

1658+
function initWipTitle() {
1659+
$(".title_wip_desc > a").click(function (e) {
1660+
e.preventDefault();
1661+
1662+
var $issueTitle = $("#issue_title");
1663+
var value = $issueTitle.val().trim().toUpperCase();
1664+
1665+
for (var i in wipPrefixes) {
1666+
if (value.startsWith(wipPrefixes[i].toUpperCase())) {
1667+
return;
1668+
}
1669+
}
1670+
1671+
$issueTitle.val(wipPrefixes[0] + " " + $issueTitle.val());
1672+
});
1673+
}
1674+
16581675
$(document).ready(function () {
16591676
csrf = $('meta[name=_csrf]').attr("content");
16601677
suburl = $('meta[name=_suburl]').attr("content");
@@ -1869,6 +1886,7 @@ $(document).ready(function () {
18691886
initU2FAuth();
18701887
initU2FRegister();
18711888
initIssueList();
1889+
initWipTitle();
18721890
initPullRequestReview();
18731891

18741892
// Repo clone url.

routers/api/v1/repo/pull.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ func MergePullRequest(ctx *context.APIContext, form auth.MergePullRequestForm) {
510510
return
511511
}
512512

513-
if !pr.CanAutoMerge() || pr.HasMerged {
513+
if !pr.CanAutoMerge() || pr.HasMerged || pr.IsWorkInProgress() {
514514
ctx.Status(405)
515515
return
516516
}

routers/repo/issue.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ func NewIssue(ctx *context.Context) {
356356
ctx.Data["RequireHighlightJS"] = true
357357
ctx.Data["RequireSimpleMDE"] = true
358358
ctx.Data["RequireTribute"] = true
359+
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
359360
setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates)
360361
renderAttachmentSettings(ctx)
361362

@@ -449,6 +450,7 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) {
449450
ctx.Data["RequireHighlightJS"] = true
450451
ctx.Data["RequireSimpleMDE"] = true
451452
ctx.Data["ReadOnly"] = false
453+
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
452454
renderAttachmentSettings(ctx)
453455

454456
var (

routers/repo/pull.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,12 @@ func PrepareViewPullInfo(ctx *context.Context, issue *models.Issue) *git.PullReq
323323
ctx.ServerError("GetPullRequestInfo", err)
324324
return nil
325325
}
326+
327+
if pull.IsWorkInProgress() {
328+
ctx.Data["IsPullWorkInProgress"] = true
329+
ctx.Data["WorkInProgressPrefix"] = pull.GetWorkInProgressPrefix()
330+
}
331+
326332
ctx.Data["NumCommits"] = prInfo.Commits.Len()
327333
ctx.Data["NumFiles"] = prInfo.NumFiles
328334
return prInfo
@@ -516,6 +522,12 @@ func MergePullRequest(ctx *context.Context, form auth.MergePullRequestForm) {
516522
return
517523
}
518524

525+
if pr.IsWorkInProgress() {
526+
ctx.Flash.Error(ctx.Tr("repo.pulls.no_merge_wip"))
527+
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(pr.Index))
528+
return
529+
}
530+
519531
if ctx.HasError() {
520532
ctx.Flash.Error(ctx.Data["ErrorMsg"].(string))
521533
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(pr.Index))
@@ -747,6 +759,7 @@ func CompareAndPullRequest(ctx *context.Context) {
747759
ctx.Data["IsDiffCompare"] = true
748760
ctx.Data["RequireHighlightJS"] = true
749761
ctx.Data["RequireTribute"] = true
762+
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
750763
setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates)
751764
renderAttachmentSettings(ctx)
752765

@@ -790,6 +803,7 @@ func CompareAndPullRequestPost(ctx *context.Context, form auth.CreateIssueForm)
790803
ctx.Data["PageIsComparePull"] = true
791804
ctx.Data["IsDiffCompare"] = true
792805
ctx.Data["RequireHighlightJS"] = true
806+
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
793807
renderAttachmentSettings(ctx)
794808

795809
var (

templates/repo/issue/new_form.tmpl

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
</a>
1414
<div class="ui segment content">
1515
<div class="field">
16-
<input name="title" placeholder="{{.i18n.Tr "repo.milestones.title"}}" value="{{.title}}" tabindex="3" autofocus required>
16+
<input name="title" id="issue_title" placeholder="{{.i18n.Tr "repo.milestones.title"}}" value="{{.title}}" tabindex="3" autofocus required>
17+
{{if .PageIsComparePull}}
18+
<span class="title_wip_desc">{{.i18n.Tr "repo.pulls.title_wip_desc" (index .PullRequestWorkInProgressPrefixes 0) | Safe}}</span>
19+
{{end}}
1720
</div>
1821
{{template "repo/issue/comment_tab" .}}
1922
<div class="text right">
@@ -150,3 +153,7 @@
150153
</div>
151154
</div>
152155
</form>
156+
{{if .PageIsComparePull}}
157+
<script>window.wipPrefixes = {{.PullRequestWorkInProgressPrefixes}}</script>
158+
{{end}}
159+

0 commit comments

Comments
 (0)