Search code examples
gitazure-devopsazure-repos

Azure DevOps Repos Limit or Block PR by Number of Commits


I am working in Azure DevOps and have junior developers creating PRs with massive code changes and over 100 commits. I have looked at the default policies in Azure DevOps Repos, but did not see anything that limits the number of commits in a PR.

Is there any way to limit the number of commits in a single PR, so it promotes positive development behavior?

Thanks in advance for the help.


Addendum:

I am not looking to make any universal judgements on coding practices. I am simply looking for advice to determine if there is a way to limit number of commits or commit sizes per PR.

I believe there can be legitimate situations to have many large commits per PR. One example of this would be a rigid, strictly regulated systems that do not allow simpler components without error.

From my leadership perspective with my teams' context, it does not constitute one of these valid reasons.


Solution

  • There is no documented limit for the max count of commits on a PR, and also no built-in option to set a custom limit for this.

    As a workaround, you can try like as below:

    1. In Azure DevOps project, go to Project Settings > Teams to create a team (e.g., Developers).

      • Add all the developers in the project as members of this team.
      • Go to Project Settings > Repositories > "Security", set and ensure the permission "Remove others' locks" is "Deny" for this team.

      enter image description here

    2. Go to Project Settings > Repositories > "Security", set and ensure the following permissions are "Allow" for the identities "Project Collection Build Service ({Organization Name})" and "{Project Name} Build Service ({Organization Name})":

      • Contribute
      • Contribute to pull requests
      • Edit policies
      • Remove others' locks

      enter image description here

    3. Go to Project Settings > Service connections to create a Generic service connection.

      • Server URL: https://dev.azure.com/{organization}/{project}, replace {organization} and {project} with the actual names of your Azure DevOps organization and project.
      • Service connection name: A custom name of the service connection.

      enter image description here

    4. In the Azure DevOps project, create a YAML pipeline (e.g., LockPR) with the configurations like as below.

    • The pipeline main YAML. Replace {organization} with the actual name of your Azure DevOps organization.

      # pipeline-lock-PR.yml
      
       trigger: none
      
       jobs:
       - job: lock
         pool: server
         variables:
           repoName: $[variables['Build.Repository.Name']]
           branchName: $[replace(variables['System.PullRequest.SourceBranch'], 'refs/', '')]
         steps:
         - task: InvokeRESTAPI@1
           displayName: 'Lock source branch of PR'
           inputs:
             connectionType: 'connectedServiceName'
             serviceConnection: 'ForGitAPI'
             method: 'PATCH'
             headers: |
               {
                 "Content-Type":"application/json",
                 "Authorization": "Bearer $(system.AccessToken)"
               }
             body: |
               {
                 "isLocked": true
               }
             urlSuffix: '/_apis/git/repositories/$(repoName)/refs?filter=$(branchName)&api-version=7.0'
             waitForCompletion: 'false'
      
       - job: check
         dependsOn: lock
         steps:
         - task: PowerShell@2
           displayName: 'Check PR Commits Count'
           inputs:
             filePath: 'scripts/Check-PR-Commits-Count.ps1'
             arguments: '-o "{organization}" -p "$(System.TeamProject)" -r "$(Build.Repository.Name)" -pr $(System.PullRequest.PullRequestId)'
             pwsh: true
           env:
             SYSTEM_ACCESSTOKEN: $(System.AccessToken)
      
    • The content of the PowerShell script "scripts/Check-PR-Commits-Count.ps1".

      In this example script, I set the default value of $maxCommitCount be to 5 for testing. You can change it to be a different value in the script, or pass a different value to overwrite it using the parameter 'maxCount' or 'max' when calling the script.

    # Check-PR-Commits-Count.ps1
    
    param (
        [Alias("org", "o")]
        [string] $organization,
    
        [Alias("proj", "p")]
        [string] $project,
    
        [Alias("repo", "r")]
        [string] $repository,
    
        [Alias("prId", "pr")]
        [int] $pullRequestId,
    
        [Alias("maxCount", "max")]
        [int] $maxCommitCount = 5,
    
        [Alias("sGenre", "sg")]
        [string] $statusGenre = "PR-Status",
    
        [Alias("sName", "sn")]
        [string] $statusName = "Lock-SourceBranch"
    )
    
    $headers = @{
        Authorization = "Bearer $env:SYSTEM_ACCESSTOKEN"
        "Content-Type" = "application/json"
    }
    
    # Get the count of commits on PR.
    $uri_list_commits = "https://dev.azure.com/${organization}/${project}/_apis/git/repositories/${repository}/pullRequests/${pullRequestId}/commits?api-version=7.0" 
    $commit_count = (Invoke-RestMethod -Method GET -Uri $uri_list_commits -Headers $headers).count
    
    # Check whether to unlock the source branch of PR.
    $statusDescription = "Allowed max commits count: $maxCommitCount; Current commits count: $commit_count;"
    
    if ($commit_count -lt $maxCommitCount) {
        # Unlock the source branch of PR.
        $branchName = $env:SYSTEM_PULLREQUEST_SOURCEBRANCH -replace "refs/", ""
        $uri_unlock_branch = "https://dev.azure.com/${organization}/${project}/_apis/git/repositories/${repository}/refs?filter=${branchName}&api-version=7.0"
        $body_unlock_branch = @{isLocked = $false} | ConvertTo-Json -Depth 5
        Invoke-RestMethod -Method PATCH -Uri $uri_unlock_branch -Headers $headers -Body $body_unlock_branch
    
        $statusDescription += " Unlock source branch, allow subsequent new commits."    
    }
    else {
        $statusDescription += " Lock source branch, disallow subsequent new commits."
    }
    
    # Get the ID of latest iteration for PR.
    $uri_list_iterations = "https://dev.azure.com/${organization}/${project}/_apis/git/repositories/${repository}/pullRequests/${pullRequestId}/iterations?api-version=7.0"
    $iterationId = (Invoke-RestMethod -Method GET -Uri $uri_list_iterations -Headers $headers).count
    
    # Create a status for the latest iteration of PR.
    $uri_create_status = "https://dev.azure.com/${organization}/${project}/_apis/git/repositories/${repository}/pullRequests/${pullRequestId}/statuses?api-version=7.0"
    
    $body_create_status = @{
        iterationId = $iterationId
        state = "succeeded"
        description = $statusDescription
        context = @{
            genre = $statusGenre
            name = $statusName
        }
    } | ConvertTo-Json -Depth 5
    
    Invoke-RestMethod -Method POST -Uri $uri_create_status -Headers $headers -Body $body_create_status
    

    enter image description here

    1. Go to Project Settings > Repositories > "Policies" to create the Cross-Repository Branch Policies within the project.

      • Protect the default branch of each repository: If select this option, the policies will be applied to the default branch of all repositories within the project.
      • Protect current and future branches matching a specified pattern: If select this option, you can set a pattern. The policies will be applied to the branches that the names can match the pattern.
      • Add the YAML pipeline (LockPR) as a required Build Validation on the Branch Policies.

      enter image description here


    With above configuration, if the target branch of PR has the Branch Policies applied, when opening a new PR or have new commits to an active PR, the pipeline (LockPR) automatically gets triggered by the PR:

    • If the count of commits on the PR is equal or more than the value of $maxCommitCount, the source branch of the PR gets locked, and the developers cannot push new commits. They need to wait for the the current PR is completed, and the source branch gets unlocked by admin. Then they can create a new PR for the subsequent commits.

      When the branch is locked, the developers still can clone the repository to their local machines and make changes on the local repository. But they cannot push the changes from the local repository to the locked branch in remote repository.

      enter image description here

    • If the count of commits on the PR is less than the value of $maxCommitCount, the source branch of the PR gets unlocked, and the developers can continue pushing new commits.

      enter image description here