Azure DevOps - Auto Approve PR with PAT

I hear a lot of people ask, how can I problematically approve a PR when using Azure DevOps? See the example script below. You will need a valid PAT, Azure DevOps Org URL, Azure DevOps Project Name/ID.

You will have to make small adjustment to the code to work, so please see comments inline.

import argparse
import json
import requests


# Go ahead and add all the users you want to approve with a PAT
# This is the easiest way, otherwise we would have to run a lot of
# api requests to figure out the user identity based on the current
# PAT used
TEAM = [
    'user1@example.com',
    'user2@example.com',
]


def submit_to_api(pat=None, url=None, data=None, method="get", return_type='json', json=None):
    result = None
    if method == 'get':
        query_parameters = data

        headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}

        if query_parameters:
            result = requests.get(url,
                                  params=query_parameters, headers=headers, auth=('', pat))
        else:
            result = requests.get(url, headers=headers, auth=('', pat))
    elif method == 'delete':
        json_dictionary = data
        headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}

        result = requests.delete(url, data=json_dictionary,
                              headers=headers, auth=('', pat))
    elif method == 'put':
        json_dictionary = data
        headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}

        result = requests.put(url, data=json_dictionary,
                              headers=headers, auth=('', pat))
    elif method == 'patch':
        headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}

        if data:
            json_dictionary = data
            result = requests.patch(url, data=json_dictionary,
                        headers=headers, auth=('', pat))
        elif json:
            json_dictionary = json
            result = requests.patch(url, json=json_dictionary,
                        headers=headers, auth=('', pat))
    else:
        json_dictionary = data
        headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}

        result = requests.post(url, data=json_dictionary,
                               headers=headers, auth=('', pat))

    json = None

    if int(result.status_code) >= 400:
        msg = 'Submit Response = ' + \
            str(result.status_code) + "\n" + result.text
        raise Exception(msg)

    if return_type == 'json':
        try:
            json = result.json()
        except Exception:
            raise Exception("Can't parse Json Respone\n" + json.text)
    else:
        try:
            json = result.text
        except Exception:
            raise Exception("Can't get text\n" + json)

    return json


def get_user_local_id(user, pat, module=None):
    # my original code was hard coded to our org, you can just refactor to pass this in or
    # update org to your visual studio org name
    org = 'my_vs_project_name'
    identity_url = f'https://{org}.visualstudio.com/_apis/IdentityPicker/Identities?api-version=5.0-preview.1'
    identity_obj = """
        {
            "query":"{0}",
            "identityTypes":["user","group"],
            "operationScopes":["ims","source"],
            "options":{"MinResults":5,"MaxResults":40},
            "properties":[
                "DisplayName",
                "IsMru",
                "ScopeName",
                "SamAccountName",
                "Active",
                "SubjectDescriptor",
                "Department",
                "JobTitle",
                "Mail",
                "MailNickname",
                "PhysicalDeliveryOfficeName",
                "SignInAddress",
                "Surname",
                "Guest",
                "TelephoneNumber",
                "Manager",
                "Description"
            ]
        }
        """.replace("{0}", user)

    local_id = None

    json = submit_to_api(pat=pat, url=identity_url, data=identity_obj, method="post")
    if json:
        local_id = json['results'][0]['identities'][0]['localId']

    return local_id


def update_approval(pat, org, project, repo_id, pr_id, reviewer_id, module=None):
    url = f'{org}/{project}/_apis/git/repositories/{repo_id}/pullRequests/{pr_id}/reviewers/{reviewer_id}?api-version=6.0-preview'

    reviewer_obj = '{ "vote": "10"}'

    update_json = None

    json = submit_to_api(pat=pat, url=url,
                         data=reviewer_obj, method="put")
    if json:
        update_json = json

    return update_json


def get_pr_title(pat, org, project, repo_id, pr_id, module=None):
    url = f'{org}/{project}/_apis/git/repositories/{repo_id}/pullrequests/{pr_id}?api-version=6.0-preview.1'

    update_json = None
    description = None

    json = submit_to_api(pat=pat, url=url,
                         data=None, method="get")
    if json:
        update_json = json
        description = update_json['title']

    return description


def update_auto_complete(pat, org, project, repo_id, pr_id, reviewer_id, module=None):
    url = f'{org}/{project}/_apis/git/repositories/{repo_id}/pullrequests/{pr_id}?api-version=6.0-preview'

    title = get_pr_title(pat, org, project, repo_id, pr_id)
    update_json = None

    if title:
    	# You might want to change reviewer_obj particularly mergeCommitMessage
        reviewer_obj = """
        {
            "completionOptions":
            {
                "autoCompleteIgnoreConfigIds":[],
                "bypassPolicy":false,
                "bypassReason":"",
                "deleteSourceBranch":true,
                "mergeCommitMessage":"Auto-Merged PR {pr}: {title}",
                "mergeStrategy":2,
                "transitionWorkItems":false
            },
            "autoCompleteSetBy":
            {
                "id": "{id}"
            }
        }
        """.replace("{id}", reviewer_id).replace("{pr}", pr_id).replace("{title}", title)


        json = submit_to_api(pat=pat, url=url,
                             data=reviewer_obj, method="patch")
        if json:
            update_json = json

    return update_json


def approve_pull_request(pat, pr_ids, org, project, repo_id, module=None):
    results = []
    for pr in pr_ids:

        for name in TEAM:
            if module:
                module.warn(str(name))
            reviewer_id = get_user_local_id(name, pat, module=module)

            if module:
                module.warn(str(reviewer_id))

            result = None

            try:
                result = update_approval(pat, org, project, repo_id, pr, reviewer_id, module=module)
            except Exception:
                pass

            if result:
                result2 = update_auto_complete(pat, org, project, repo_id, pr, reviewer_id, module=module)
                results.append(result)

    return results


def main():
    parser = argparse.ArgumentParser(
        description='Auto approve Pull Request(s)')
    parser.add_argument('--pat', type=str, nargs='?', required=True,
                        help='vsts Pat')
    parser.add_argument('--pr_id', type=str, nargs='+', required=True,
                        help='Pull Request id or list of Pull Request ids')
    parser.add_argument('--org', type=str, default='https://dev.azure.com/org_id',
                        help='Azure DevOps Org URL')
    parser.add_argument('--repo_id', type=str, default='repo_id',
                        help='Azure DevOps Repo Id')
    parser.add_argument('--project', type=str, default='project_id',
                        help='Azure DevOps Project Name/ID')

    args = parser.parse_args()
    approve_pull_request(args.pat, args.pr_id, args.org, args.project, args.repo_id)


if __name__ == '__main__':
    main()
Assuming you saved the file to auto_approve_pull_request.py you can run the code like below. You can then pass in the parameters as desired.

python3 ./auto_approve_pull_request.py
## Output 
# usage: auto_approve_pull_request.py [-h] --pat [PAT] --pr_id PR_ID [PR_ID ...] [--org ORG] [--repo_id REPO_ID] [--project PROJECT]
# auto_approve_pull_request.py: error: the following arguments are required: --pat, --pr_id

# you can use this to get help
python3 ./auto_approve_pull_request.py --help

# Output
#usage: auto_approve_pull_request.py [-h] --pat [PAT] --pr_id PR_ID [PR_ID ...] [--org ORG] [--repo_id REPO_ID] [--project PROJECT]
#
#Auto approve Pull Request(s)
#
#optional arguments:
#  -h, --help            show this help message and exit
#  --pat [PAT]           vsts Pat
#  --pr_id PR_ID [PR_ID ...]
#                        Pull Request id or list of Pull Request ids
#  --org ORG             Azure DevOps Org URL
#  --repo_id REPO_ID     Repo Id
#  --project PROJECT     Azure DevOps Project Name/ID

# You will likely call the script like this:
python3 ./auto_approve_pull_request.py --pat 'some_guid' --pr_id 1234 --org 'https://dev.azure.com/my_org' --repo_id 'My_repo_id' --project 'My_Project_Name'
I recommend saving the PAT to keyvault and using a lookup to pass to your script. If you find this useful, please comment, share and let me know how you decide to integrate.

Comments

Popular posts from this blog

Ubuntu 18.04 - Install Android Emulator

Ansible Module - VMWare Update Guest PCI Device