Skip to content

Commit

Permalink
Add API branch protection endpoint (#9311)
Browse files Browse the repository at this point in the history
* add API branch protection endpoint

* lint

* Change to use team names instead of ids.

* Status codes.

* fix

* Fix

* Add new branch protection options (BlockOnRejectedReviews, DismissStaleApprovals, RequireSignedCommits)

* Do xorm query directly

* fix xorm GetUserNamesByIDs

* Add some tests

* Improved GetTeamNamesByID

* http status created for CreateBranchProtection

* Correct status code in integration test

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: zeripath <art27@cantab.net>
  • Loading branch information
3 people authored Feb 12, 2020
1 parent 908f895 commit 9ff4e1d
Show file tree
Hide file tree
Showing 10 changed files with 1,352 additions and 28 deletions.
68 changes: 68 additions & 0 deletions integrations/api_branch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,54 @@ func testAPIGetBranch(t *testing.T, branchName string, exists bool) {
assert.EqualValues(t, branchName, branch.Name)
}

func testAPIGetBranchProtection(t *testing.T, branchName string, expectedHTTPStatus int) {
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session)
req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/branch_protections/%s?token=%s", branchName, token)
resp := session.MakeRequest(t, req, expectedHTTPStatus)

if resp.Code == 200 {
var branchProtection api.BranchProtection
DecodeJSON(t, resp, &branchProtection)
assert.EqualValues(t, branchName, branchProtection.BranchName)
}
}

func testAPICreateBranchProtection(t *testing.T, branchName string, expectedHTTPStatus int) {
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session)
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/branch_protections?token="+token, &api.BranchProtection{
BranchName: branchName,
})
resp := session.MakeRequest(t, req, expectedHTTPStatus)

if resp.Code == 201 {
var branchProtection api.BranchProtection
DecodeJSON(t, resp, &branchProtection)
assert.EqualValues(t, branchName, branchProtection.BranchName)
}
}

func testAPIEditBranchProtection(t *testing.T, branchName string, body *api.BranchProtection, expectedHTTPStatus int) {
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session)
req := NewRequestWithJSON(t, "PATCH", "/api/v1/repos/user2/repo1/branch_protections/"+branchName+"?token="+token, body)
resp := session.MakeRequest(t, req, expectedHTTPStatus)

if resp.Code == 200 {
var branchProtection api.BranchProtection
DecodeJSON(t, resp, &branchProtection)
assert.EqualValues(t, branchName, branchProtection.BranchName)
}
}

func testAPIDeleteBranchProtection(t *testing.T, branchName string, expectedHTTPStatus int) {
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session)
req := NewRequestf(t, "DELETE", "/api/v1/repos/user2/repo1/branch_protections/%s?token=%s", branchName, token)
session.MakeRequest(t, req, expectedHTTPStatus)
}

func TestAPIGetBranch(t *testing.T) {
for _, test := range []struct {
BranchName string
Expand All @@ -43,3 +91,23 @@ func TestAPIGetBranch(t *testing.T) {
testAPIGetBranch(t, test.BranchName, test.Exists)
}
}

func TestAPIBranchProtection(t *testing.T) {
defer prepareTestEnv(t)()

// Branch protection only on branch that exist
testAPICreateBranchProtection(t, "master/doesnotexist", http.StatusNotFound)
// Get branch protection on branch that exist but not branch protection
testAPIGetBranchProtection(t, "master", http.StatusNotFound)

testAPICreateBranchProtection(t, "master", http.StatusCreated)
// Can only create once
testAPICreateBranchProtection(t, "master", http.StatusForbidden)

testAPIGetBranchProtection(t, "master", http.StatusOK)
testAPIEditBranchProtection(t, "master", &api.BranchProtection{
EnablePush: true,
}, http.StatusOK)

testAPIDeleteBranchProtection(t, "master", http.StatusNoContent)
}
33 changes: 33 additions & 0 deletions models/org_team.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,23 @@ func GetTeam(orgID int64, name string) (*Team, error) {
return getTeam(x, orgID, name)
}

// GetTeamIDsByNames returns a slice of team ids corresponds to names.
func GetTeamIDsByNames(orgID int64, names []string, ignoreNonExistent bool) ([]int64, error) {
ids := make([]int64, 0, len(names))
for _, name := range names {
u, err := GetTeam(orgID, name)
if err != nil {
if ignoreNonExistent {
continue
} else {
return nil, err
}
}
ids = append(ids, u.ID)
}
return ids, nil
}

// getOwnerTeam returns team by given team name and organization.
func getOwnerTeam(e Engine, orgID int64) (*Team, error) {
return getTeam(e, orgID, ownerTeamName)
Expand All @@ -574,6 +591,22 @@ func GetTeamByID(teamID int64) (*Team, error) {
return getTeamByID(x, teamID)
}

// GetTeamNamesByID returns team's lower name from a list of team ids.
func GetTeamNamesByID(teamIDs []int64) ([]string, error) {
if len(teamIDs) == 0 {
return []string{}, nil
}

var teamNames []string
err := x.Table("team").
Select("lower_name").
In("id", teamIDs).
Asc("name").
Find(&teamNames)

return teamNames, err
}

// UpdateTeam updates information of team.
func UpdateTeam(t *Team, authChanged bool, includeAllChanged bool) (err error) {
if len(t.Name) == 0 {
Expand Down
11 changes: 11 additions & 0 deletions models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,17 @@ func GetMaileableUsersByIDs(ids []int64) ([]*User, error) {
Find(&ous)
}

// GetUserNamesByIDs returns usernames for all resolved users from a list of Ids.
func GetUserNamesByIDs(ids []int64) ([]string, error) {
unames := make([]string, 0, len(ids))
err := x.In("id", ids).
Table("user").
Asc("name").
Cols("name").
Find(&unames)
return unames, err
}

// GetUsersByIDs returns all resolved users from a list of Ids.
func GetUsersByIDs(ids []int64) ([]*User, error) {
ous := make([]*User, 0, len(ids))
Expand Down
92 changes: 75 additions & 17 deletions modules/convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,28 +30,86 @@ func ToEmail(email *models.EmailAddress) *api.Email {
}

// ToBranch convert a git.Commit and git.Branch to an api.Branch
func ToBranch(repo *models.Repository, b *git.Branch, c *git.Commit, bp *models.ProtectedBranch, user *models.User) *api.Branch {
func ToBranch(repo *models.Repository, b *git.Branch, c *git.Commit, bp *models.ProtectedBranch, user *models.User, isRepoAdmin bool) *api.Branch {
if bp == nil {
return &api.Branch{
Name: b.Name,
Commit: ToCommit(repo, c),
Protected: false,
RequiredApprovals: 0,
EnableStatusCheck: false,
StatusCheckContexts: []string{},
UserCanPush: true,
UserCanMerge: true,
Name: b.Name,
Commit: ToCommit(repo, c),
Protected: false,
RequiredApprovals: 0,
EnableStatusCheck: false,
StatusCheckContexts: []string{},
UserCanPush: true,
UserCanMerge: true,
EffectiveBranchProtectionName: "",
}
}
branchProtectionName := ""
if isRepoAdmin {
branchProtectionName = bp.BranchName
}

return &api.Branch{
Name: b.Name,
Commit: ToCommit(repo, c),
Protected: true,
RequiredApprovals: bp.RequiredApprovals,
EnableStatusCheck: bp.EnableStatusCheck,
StatusCheckContexts: bp.StatusCheckContexts,
UserCanPush: bp.CanUserPush(user.ID),
UserCanMerge: bp.IsUserMergeWhitelisted(user.ID),
Name: b.Name,
Commit: ToCommit(repo, c),
Protected: true,
RequiredApprovals: bp.RequiredApprovals,
EnableStatusCheck: bp.EnableStatusCheck,
StatusCheckContexts: bp.StatusCheckContexts,
UserCanPush: bp.CanUserPush(user.ID),
UserCanMerge: bp.IsUserMergeWhitelisted(user.ID),
EffectiveBranchProtectionName: branchProtectionName,
}
}

// ToBranchProtection convert a ProtectedBranch to api.BranchProtection
func ToBranchProtection(bp *models.ProtectedBranch) *api.BranchProtection {
pushWhitelistUsernames, err := models.GetUserNamesByIDs(bp.WhitelistUserIDs)
if err != nil {
log.Error("GetUserNamesByIDs (WhitelistUserIDs): %v", err)
}
mergeWhitelistUsernames, err := models.GetUserNamesByIDs(bp.MergeWhitelistUserIDs)
if err != nil {
log.Error("GetUserNamesByIDs (MergeWhitelistUserIDs): %v", err)
}
approvalsWhitelistUsernames, err := models.GetUserNamesByIDs(bp.ApprovalsWhitelistUserIDs)
if err != nil {
log.Error("GetUserNamesByIDs (ApprovalsWhitelistUserIDs): %v", err)
}
pushWhitelistTeams, err := models.GetTeamNamesByID(bp.WhitelistTeamIDs)
if err != nil {
log.Error("GetTeamNamesByID (WhitelistTeamIDs): %v", err)
}
mergeWhitelistTeams, err := models.GetTeamNamesByID(bp.MergeWhitelistTeamIDs)
if err != nil {
log.Error("GetTeamNamesByID (MergeWhitelistTeamIDs): %v", err)
}
approvalsWhitelistTeams, err := models.GetTeamNamesByID(bp.ApprovalsWhitelistTeamIDs)
if err != nil {
log.Error("GetTeamNamesByID (ApprovalsWhitelistTeamIDs): %v", err)
}

return &api.BranchProtection{
BranchName: bp.BranchName,
EnablePush: bp.CanPush,
EnablePushWhitelist: bp.EnableWhitelist,
PushWhitelistUsernames: pushWhitelistUsernames,
PushWhitelistTeams: pushWhitelistTeams,
PushWhitelistDeployKeys: bp.WhitelistDeployKeys,
EnableMergeWhitelist: bp.EnableMergeWhitelist,
MergeWhitelistUsernames: mergeWhitelistUsernames,
MergeWhitelistTeams: mergeWhitelistTeams,
EnableStatusCheck: bp.EnableStatusCheck,
StatusCheckContexts: bp.StatusCheckContexts,
RequiredApprovals: bp.RequiredApprovals,
EnableApprovalsWhitelist: bp.EnableApprovalsWhitelist,
ApprovalsWhitelistUsernames: approvalsWhitelistUsernames,
ApprovalsWhitelistTeams: approvalsWhitelistTeams,
BlockOnRejectedReviews: bp.BlockOnRejectedReviews,
DismissStaleApprovals: bp.DismissStaleApprovals,
RequireSignedCommits: bp.RequireSignedCommits,
Created: bp.CreatedUnix.AsTime(),
Updated: bp.UpdatedUnix.AsTime(),
}
}

Expand Down
90 changes: 82 additions & 8 deletions modules/structs/repo_branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,88 @@

package structs

import (
"time"
)

// Branch represents a repository branch
type Branch struct {
Name string `json:"name"`
Commit *PayloadCommit `json:"commit"`
Protected bool `json:"protected"`
RequiredApprovals int64 `json:"required_approvals"`
EnableStatusCheck bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"`
UserCanPush bool `json:"user_can_push"`
UserCanMerge bool `json:"user_can_merge"`
Name string `json:"name"`
Commit *PayloadCommit `json:"commit"`
Protected bool `json:"protected"`
RequiredApprovals int64 `json:"required_approvals"`
EnableStatusCheck bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"`
UserCanPush bool `json:"user_can_push"`
UserCanMerge bool `json:"user_can_merge"`
EffectiveBranchProtectionName string `json:"effective_branch_protection_name"`
}

// BranchProtection represents a branch protection for a repository
type BranchProtection struct {
BranchName string `json:"branch_name"`
EnablePush bool `json:"enable_push"`
EnablePushWhitelist bool `json:"enable_push_whitelist"`
PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
PushWhitelistTeams []string `json:"push_whitelist_teams"`
PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys"`
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
EnableStatusCheck bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"`
RequiredApprovals int64 `json:"required_approvals"`
EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"`
ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"`
ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
RequireSignedCommits bool `json:"require_signed_commits"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
// swagger:strfmt date-time
Updated time.Time `json:"updated_at"`
}

// CreateBranchProtectionOption options for creating a branch protection
type CreateBranchProtectionOption struct {
BranchName string `json:"branch_name"`
EnablePush bool `json:"enable_push"`
EnablePushWhitelist bool `json:"enable_push_whitelist"`
PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
PushWhitelistTeams []string `json:"push_whitelist_teams"`
PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys"`
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
EnableStatusCheck bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"`
RequiredApprovals int64 `json:"required_approvals"`
EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"`
ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"`
ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
RequireSignedCommits bool `json:"require_signed_commits"`
}

// EditBranchProtectionOption options for editing a branch protection
type EditBranchProtectionOption struct {
EnablePush *bool `json:"enable_push"`
EnablePushWhitelist *bool `json:"enable_push_whitelist"`
PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
PushWhitelistTeams []string `json:"push_whitelist_teams"`
PushWhitelistDeployKeys *bool `json:"push_whitelist_deploy_keys"`
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
EnableStatusCheck *bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"`
RequiredApprovals *int64 `json:"required_approvals"`
EnableApprovalsWhitelist *bool `json:"enable_approvals_whitelist"`
ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"`
ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
BlockOnRejectedReviews *bool `json:"block_on_rejected_reviews"`
DismissStaleApprovals *bool `json:"dismiss_stale_approvals"`
RequireSignedCommits *bool `json:"require_signed_commits"`
}
9 changes: 9 additions & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,15 @@ func RegisterRoutes(m *macaron.Macaron) {
m.Get("", repo.ListBranches)
m.Get("/*", context.RepoRefByType(context.RepoRefBranch), repo.GetBranch)
}, reqRepoReader(models.UnitTypeCode))
m.Group("/branch_protections", func() {
m.Get("", repo.ListBranchProtections)
m.Post("", bind(api.CreateBranchProtectionOption{}), repo.CreateBranchProtection)
m.Group("/:name", func() {
m.Get("", repo.GetBranchProtection)
m.Patch("", bind(api.EditBranchProtectionOption{}), repo.EditBranchProtection)
m.Delete("", repo.DeleteBranchProtection)
})
}, reqToken(), reqAdmin())
m.Group("/tags", func() {
m.Get("", repo.ListTags)
}, reqRepoReader(models.UnitTypeCode), context.ReferencesGitRepo(true))
Expand Down
Loading

0 comments on commit 9ff4e1d

Please sign in to comment.