How to Automate Git Branch Creation with Ticket Numbers in Python
The Problem
I was tired of this workflow:
- Open browser, navigate to Azure DevOps
- Create a new ticket, fill in title, type, project
- Copy the ticket number (e.g., “12345”)
- Return to terminal
- Type:
git checkout -b 12345-my-feature-description - Make a typo in the ticket number
- 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.
12345-fix-login-bug # Correct12346-add-user-feature # Correctfix-login-bug # WRONG - no ticket number12347_fix_login_bug # WRONG - underscores instead of hyphensWhen 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:
$ new-pbi -t bug "fix login timeout"Creating bug ticket: fix login timeoutCreated ticket: 12345Creating branch...Created branch: 12345-fix-login-timeoutSwitched to a new branch '12345-fix-login-timeout'The script:
- Creates a ticket in Azure DevOps via REST API
- Gets the ticket ID from the response
- Formats the branch name (slugifies the title)
- Creates and switches to the new branch
First Attempt: The Basic Script
I started with a minimal working version:
#!/usr/bin/env python3import osimport reimport subprocessimport 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:
$ 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-timeoutIt 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.
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 codeNow I could use:
$ 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”:
$ new-pbi "fix user's login issue"fatal: '12345-fix-user's-login-issue' is not a valid branch nameThe apostrophe broke the branch name. Git has specific rules about what characters are allowed.
I needed proper slugification:
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:
$ cd /tmp$ new-pbi "test ticket"fatal: not a git repository (or any of the parent directories): .gitI added a check before attempting to create the branch:
import subprocessimport 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 codeProblem 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:
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:
#!/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 argparseimport osimport reimport subprocessimport 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:
#!/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 argparseimport osimport reimport subprocessimport 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:
# Azure DevOps workflowalias new-pbi='python3 ~/scripts/new-pbi.py'alias new-bug='new-pbi -t bug'alias new-feature='new-pbi -t feature'
# Jira workflowalias 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:
+------------------+ +-------------------+ +------------------+| 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:
1. Parse CLI arguments (type, title, project) | v2. Validate environment (credentials, git repo) | v3. Call API to create ticket | v4. Extract ticket ID from response | v5. Slugify the title | v6. Run: git checkout -b {id}-{slug} | v7. Print success messageCommon Mistakes to Avoid
Mistake 1: Storing Credentials in Code
# WRONG - Never hardcode tokenstoken = "abcd1234efgh5678ixyz"# RIGHT - Use environment variablesimport ostoken = os.environ.get('AZURE_DEVOPS_TOKEN')
if not token: raise RuntimeError("Set AZURE_DEVOPS_TOKEN environment variable")Mistake 2: Not Sanitizing Branch Names
branch = f"{ticket_id}-{title}"# "12345-fix user's bug!" -> Invalid branch nameimport reslug = re.sub(r'[^\w-]', '-', title.lower())slug = re.sub(r'-+', '-', slug).strip('-')branch = f"{ticket_id}-{slug}"# "12345-fix-user-s-bug" -> Valid branch nameMistake 3: Assuming API Always Succeeds
ticket_id = response.json()['id']# Crashes with KeyError if API returns errorif 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:
branch = f"{key}-{summary}"# "proj-12345-this-is-a-very-long-summary-that-goes-on-forever..."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:
- Single command replaces browser navigation + form filling + copy/paste + branch creation
- Zero typos in ticket numbers
- Consistent branch naming across the team
- Automatic ticket creation with proper metadata
The key components are:
argparsefor CLI argument parsingrequestsfor REST API callssubprocessfor git commandsrefor 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:
- 👨💻 Azure DevOps REST API Reference
- 👨💻 Jira REST API Documentation
- 👨💻 Python subprocess module
- 👨💻 Python requests library
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments