Skip to content

How to Automate Git Branch Creation with Ticket Numbers in Python

The Problem

I was tired of this workflow:

  1. Open browser, navigate to Azure DevOps
  2. Create a new ticket, fill in title, type, project
  3. Copy the ticket number (e.g., “12345”)
  4. Return to terminal
  5. Type: git checkout -b 12345-my-feature-description
  6. Make a typo in the ticket number
  7. Delete the branch, start over

Every day, I was doing this 5-10 times. Each context switch between browser and terminal cost me 30-60 seconds. For a team creating 20 branches per week, that’s over 20 minutes of wasted time.

And that’s not counting the mental context switch cost.

Why This Matters

Our company has a strict branch naming convention: the first 5 characters must be the associated task number. This is enforced by CI/CD pipelines and code review requirements.

Branch naming requirement
12345-fix-login-bug # Correct
12346-add-user-feature # Correct
fix-login-bug # WRONG - no ticket number
12347_fix_login_bug # WRONG - underscores instead of hyphens

When branches don’t follow this convention, PRs get rejected. Developers waste time renaming branches. CI pipelines fail. It’s a mess.

What I Built

I created a Python script that does everything in one command:

Terminal window
$ new-pbi -t bug "fix login timeout"
Creating bug ticket: fix login timeout
Created ticket: 12345
Creating branch...
Created branch: 12345-fix-login-timeout
Switched to a new branch '12345-fix-login-timeout'

The script:

  1. Creates a ticket in Azure DevOps via REST API
  2. Gets the ticket ID from the response
  3. Formats the branch name (slugifies the title)
  4. Creates and switches to the new branch

First Attempt: The Basic Script

I started with a minimal working version:

new-pbi-v1.py
#!/usr/bin/env python3
import os
import re
import subprocess
import requests
def create_ticket(title):
"""Create ticket in Azure DevOps and return ticket ID."""
org_url = os.environ['AZURE_DEVOPS_ORG_URL']
token = os.environ['AZURE_DEVOPS_TOKEN']
project = os.environ['AZURE_DEVOPS_PROJECT']
url = f"{org_url}/{project}/_apis/wit/workitems/$Bug?api-version=7.0"
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json-patch+json'
}
body = [{'op': 'add', 'path': '/fields/System.Title', 'value': title}]
response = requests.post(url, headers=headers, json=body)
return str(response.json()['id'])
def create_branch(ticket_id, title):
slug = re.sub(r'[^\w-]', '-', title.lower())
branch_name = f"{ticket_id}-{slug}"
subprocess.run(['git', 'checkout', '-b', branch_name], check=True)
return branch_name
if __name__ == '__main__':
import sys
title = sys.argv[1]
ticket_id = create_ticket(title)
branch = create_branch(ticket_id, title)
print(f"Created: {branch}")

I ran it:

Terminal window
$ export AZURE_DEVOPS_ORG_URL="https://dev.azure.com/myorg"
$ export AZURE_DEVOPS_TOKEN="my-personal-access-token"
$ export AZURE_DEVOPS_PROJECT="MyProject"
$ python new-pbi-v1.py "fix login timeout"
Created: 12345-fix-login-timeout

It worked! But I quickly ran into problems.

Problem 1: Hardcoded Ticket Type

The first version always created a “Bug” ticket. I needed support for features, tasks, and bugs.

Fixed: Adding ticket type support
import argparse
def main():
parser = argparse.ArgumentParser(description='Create ticket and branch')
parser.add_argument('-t', '--type', default='task',
choices=['bug', 'feature', 'task'],
help='Ticket type')
parser.add_argument('title', help='Ticket title')
args = parser.parse_args()
# Map to Azure DevOps work item types
type_map = {'bug': 'Bug', 'feature': 'Feature', 'task': 'Task'}
work_item_type = type_map[args.type.lower()]
# ... rest of the code

Now I could use:

Terminal window
$ new-pbi -t bug "fix login timeout"
$ new-pbi -t feature "add user dashboard"
$ new-pbi -t task "update documentation"

Problem 2: Special Characters Breaking Git

I tried creating a branch for a ticket titled “fix user’s login issue”:

Terminal output
$ new-pbi "fix user's login issue"
fatal: '12345-fix-user's-login-issue' is not a valid branch name

The apostrophe broke the branch name. Git has specific rules about what characters are allowed.

I needed proper slugification:

slugify.py
import re
def slugify(title):
"""Convert title to git-safe branch name component."""
# Replace any non-alphanumeric character (except hyphens) with hyphen
slug = re.sub(r'[^\w-]', '-', title.lower())
# Collapse multiple consecutive hyphens
slug = re.sub(r'-+', '-', slug)
# Strip leading/trailing hyphens
slug = slug.strip('-')
return slug
# Examples:
# "fix user's login issue" -> "fix-user-s-login-issue"
# "Add @mentions feature!!!" -> "add-mentions-feature"
# "API: return JSON response" -> "api-return-json-response"

Problem 3: Not in a Git Repository

I ran the script outside a git repo and got an unhelpful error:

Terminal output
$ cd /tmp
$ new-pbi "test ticket"
fatal: not a git repository (or any of the parent directories): .git

I added a check before attempting to create the branch:

git-check.py
import subprocess
import sys
def ensure_git_repo():
"""Verify we're in a git repository."""
result = subprocess.run(
['git', 'rev-parse', '--is-inside-work-tree'],
capture_output=True,
text=True
)
if result.returncode != 0:
print("Error: Not in a git repository")
sys.exit(1)
def create_branch(ticket_id, title):
ensure_git_repo()
# ... rest of branch creation code

Problem 4: API Failures Not Handled

Sometimes the Azure DevOps API would fail (network issues, rate limiting, invalid token). The script would crash with an exception.

I added proper error handling:

error-handling.py
def create_ticket(title, ticket_type, project):
"""Create ticket with error handling."""
org_url = os.environ.get('AZURE_DEVOPS_ORG_URL')
token = os.environ.get('AZURE_DEVOPS_TOKEN')
if not org_url or not token:
raise RuntimeError(
"Set AZURE_DEVOPS_ORG_URL and AZURE_DEVOPS_TOKEN environment variables"
)
# ... build request ...
response = requests.post(url, headers=headers, json=body)
if response.status_code != 200:
error_msg = response.text
raise RuntimeError(f"Failed to create ticket: {error_msg}")
return str(response.json()['id'])

The Complete Script

After all the iterations, here’s the complete Azure DevOps version:

new-pbi.py
#!/usr/bin/env python3
"""
new-pbi: Create Azure DevOps ticket and git branch in one command.
Usage:
new-pbi -t bug "fix login timeout"
new-pbi -t feature "add user dashboard"
new-pbi -t task "update documentation"
Requires: pip install requests
"""
import argparse
import os
import re
import subprocess
import sys
import requests
def create_azure_ticket(title: str, ticket_type: str, project: str) -> str:
"""Create ticket in Azure DevOps and return ticket ID."""
org_url = os.environ.get('AZURE_DEVOPS_ORG_URL')
token = os.environ.get('AZURE_DEVOPS_TOKEN')
if not org_url or not token:
raise RuntimeError("Set AZURE_DEVOPS_ORG_URL and AZURE_DEVOPS_TOKEN")
type_map = {'bug': 'Bug', 'feature': 'Feature', 'task': 'Task'}
work_item_type = type_map.get(ticket_type.lower(), 'Task')
url = f"{org_url}/{project}/_apis/wit/workitems/${work_item_type}?api-version=7.0"
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json-patch+json'
}
body = [{'op': 'add', 'path': '/fields/System.Title', 'value': title}]
response = requests.post(url, headers=headers, json=body)
if response.status_code not in (200, 201):
raise RuntimeError(f"API error: {response.status_code} - {response.text}")
return str(response.json()['id'])
def slugify(title: str) -> str:
"""Convert title to git-safe branch name component."""
slug = re.sub(r'[^\w-]', '-', title.lower())
slug = re.sub(r'-+', '-', slug).strip('-')
return slug
def ensure_git_repo():
"""Verify we're in a git repository."""
result = subprocess.run(
['git', 'rev-parse', '--is-inside-work-tree'],
capture_output=True,
text=True
)
if result.returncode != 0:
raise RuntimeError("Not in a git repository")
def create_branch(ticket_id: str, title: str) -> str:
"""Create git branch with sanitized name."""
ensure_git_repo()
slug = slugify(title)
branch_name = f"{ticket_id}-{slug}"
result = subprocess.run(
['git', 'checkout', '-b', branch_name],
capture_output=True,
text=True
)
if result.returncode != 0:
raise RuntimeError(f"Git error: {result.stderr}")
return branch_name
def main():
parser = argparse.ArgumentParser(description='Create ticket and branch')
parser.add_argument('-t', '--type', default='task',
choices=['bug', 'feature', 'task'],
help='Ticket type')
parser.add_argument('-p', '--project',
default=os.environ.get('AZURE_DEVOPS_PROJECT'),
help='Azure DevOps project')
parser.add_argument('title', help='Ticket title')
args = parser.parse_args()
if not args.project:
print("Error: Set AZURE_DEVOPS_PROJECT or use -p")
sys.exit(1)
try:
print(f"Creating {args.type} ticket: {args.title}")
ticket_id = create_azure_ticket(args.title, args.type, args.project)
print(f"Created ticket: {ticket_id}")
print("Creating branch...")
branch = create_branch(ticket_id, args.title)
print(f"Created branch: {branch}")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == '__main__':
main()

Jira Variant

If your team uses Jira instead of Azure DevOps, the concept is the same but the API differs:

new-jira.py
#!/usr/bin/env python3
"""
new-jira: Create Jira ticket and git branch in one command.
Usage:
new-jira -p PROJ -t bug "fix login timeout"
new-jira -p PROJ -t story "user authentication"
"""
import argparse
import os
import re
import subprocess
import sys
import requests
def create_jira_ticket(summary: str, ticket_type: str, project_key: str) -> str:
"""Create Jira ticket and return key (e.g., 'PROJ-123')."""
jira_url = os.environ.get('JIRA_URL')
email = os.environ.get('JIRA_EMAIL')
api_token = os.environ.get('JIRA_API_TOKEN')
if not all([jira_url, email, api_token]):
raise RuntimeError("Set JIRA_URL, JIRA_EMAIL, and JIRA_API_TOKEN")
url = f"{jira_url}/rest/api/3/issue"
auth = (email, api_token)
headers = {'Accept': 'application/json'}
type_map = {'bug': 'Bug', 'feature': 'Story', 'task': 'Task', 'story': 'Story'}
body = {
'fields': {
'project': {'key': project_key},
'summary': summary,
'issuetype': {'name': type_map.get(ticket_type.lower(), 'Task')}
}
}
response = requests.post(url, headers=headers, auth=auth, json=body)
response.raise_for_status()
return response.json()['key']
def slugify(title: str) -> str:
slug = re.sub(r'[^\w-]', '-', title.lower())
slug = re.sub(r'-+', '-', slug).strip('-')
if len(slug) > 40:
slug = slug[:40].rsplit('-', 1)[0]
return slug
def create_branch(ticket_key: str, summary: str) -> str:
slug = slugify(summary)
branch_name = f"{ticket_key.lower()}-{slug}"
subprocess.run(['git', 'checkout', '-b', branch_name], check=True)
return branch_name
def main():
parser = argparse.ArgumentParser()
parser.add_argument('-t', '--type', default='task')
parser.add_argument('-p', '--project', required=True, help='Jira project key')
parser.add_argument('summary')
args = parser.parse_args()
try:
key = create_jira_ticket(args.summary, args.type, args.project)
print(f"Created: {key}")
branch = create_branch(key, args.summary)
print(f"Branch: {branch}")
except Exception as e:
print(f"Failed: {e}")
sys.exit(1)
if __name__ == '__main__':
main()

Shell Alias Integration

I added aliases to my shell configuration for quick access:

~/.zshrc or ~/.bashrc
# Azure DevOps workflow
alias new-pbi='python3 ~/scripts/new-pbi.py'
alias new-bug='new-pbi -t bug'
alias new-feature='new-pbi -t feature'
# Jira workflow
alias jira-ticket='python3 ~/scripts/new-jira.py'
# Usage:
# $ new-bug "fix timeout on login page"
# $ new-feature "add user profile page"
# $ jira-ticket -p PROJ -t story "implement oauth"

Architecture Overview

The script architecture is simple:

Script architecture
+------------------+ +-------------------+ +------------------+
| CLI Command | --> | Python Script | --> | Git Branch |
| new-pbi -t bug | | | | 12345-fix-bug |
+------------------+ +---------+---------+ +------------------+
|
v
+-------------------+
| Azure/Jira API |
| Create Ticket |
+-------------------+

How It Works

The flow is:

Execution flow
1. Parse CLI arguments (type, title, project)
|
v
2. Validate environment (credentials, git repo)
|
v
3. Call API to create ticket
|
v
4. Extract ticket ID from response
|
v
5. Slugify the title
|
v
6. Run: git checkout -b {id}-{slug}
|
v
7. Print success message

Common Mistakes to Avoid

Mistake 1: Storing Credentials in Code

Never do this
# WRONG - Never hardcode tokens
token = "abcd1234efgh5678ixyz"
Correct approach
# RIGHT - Use environment variables
import os
token = os.environ.get('AZURE_DEVOPS_TOKEN')
if not token:
raise RuntimeError("Set AZURE_DEVOPS_TOKEN environment variable")

Mistake 2: Not Sanitizing Branch Names

Wrong approach
branch = f"{ticket_id}-{title}"
# "12345-fix user's bug!" -> Invalid branch name
Correct approach
import re
slug = re.sub(r'[^\w-]', '-', title.lower())
slug = re.sub(r'-+', '-', slug).strip('-')
branch = f"{ticket_id}-{slug}"
# "12345-fix-user-s-bug" -> Valid branch name

Mistake 3: Assuming API Always Succeeds

Wrong approach
ticket_id = response.json()['id']
# Crashes with KeyError if API returns error
Correct approach
if response.status_code not in (200, 201):
raise RuntimeError(f"API error: {response.text}")
ticket_id = response.json()['id']

Mistake 4: Long Branch Names

Jira keys plus long summaries create unwieldy branch names:

Wrong approach
branch = f"{key}-{summary}"
# "proj-12345-this-is-a-very-long-summary-that-goes-on-forever..."
Correct approach
slug = slugify(summary)
if len(slug) > 40:
slug = slug[:40].rsplit('-', 1)[0]
branch = f"{key}-{slug}"
# "proj-12345-this-is-a-very-long-summary-that"

Summary

This script replaced a 5-step manual process with a single command:

  1. Single command replaces browser navigation + form filling + copy/paste + branch creation
  2. Zero typos in ticket numbers
  3. Consistent branch naming across the team
  4. Automatic ticket creation with proper metadata

The key components are:

  • argparse for CLI argument parsing
  • requests for REST API calls
  • subprocess for git commands
  • re for branch name sanitization

Share this script with your team to standardize branch naming and eliminate the context-switching tax.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments