The Invisible DevOps Team: GitHub Org on Autopilot

Frédéric De Smet
March 11th, 2026 · 3 min read

As featured in Azure Spring Clean 2026

The problem with small DevOps teams

Running a GitHub organisation with dozens of repositories sounds manageable… until your DevOps team is two people. Every new project starts the same way: someone sends a message, you manually create a repo, configure permissions, set up topics. Repeat.

The work is not hard. It is repetitive, error-prone, and scales poorly. Worse, it creates a human bottleneck: nothing moves until someone on the DevOps team has time.

We needed a system where:

  • Anyone can request infrastructure through a self-service form
  • The request produces production-ready code changes automatically
  • A DevOps engineer only needs to review and approve

No manual YAML editing. No Teams messages asking “can you create a repo for me?“. Just a form, automation, and a pull request.

The solution: issue forms as a service catalog

The architecture has four layers, each with a single responsibility:

LayerToolResponsibility
InputGitHub Issue FormStructured data capture
ProcessingPython scriptParse issue, update config files
OrchestrationGitHub ActionsBranch, commit, PR creation
InfrastructureTerraformActual resource provisioning

High level architecture diagram

We will walk through each layer using a simple example: requesting a new GitHub repository through a form.

Layer 1: The issue form

GitHub issue templates support YAML-based forms that render as structured input fields. This eliminates freeform text parsing entirely.

1name: New Repository
2description: Request creation of a new GitHub repository
3title: "New Repo"
4labels: ["new-repo"]
5body:
6 - type: markdown
7 attributes:
8 value: |
9 ## What gets created?
10 A new private GitHub repository with the specified name and topics.
11
12 - type: input
13 id: repo_name
14 attributes:
15 label: Name of the new repo
16 description: |
17 Use lowercase with hyphens, avoid spaces.
18 placeholder: "my-new-service"
19 validations:
20 required: true
21
22 - type: input
23 id: topics
24 attributes:
25 label: Repository Topics
26 description: "Comma-separated tags for categorization"
27 placeholder: "python, api, data-processing"
28 validations:
29 required: false

The labels field is critical: it is how the automation identifies which handler to invoke. Each template type gets a unique label.

Issue form rendered in GitHub

The markdown block at the top acts as inline documentation. Users see exactly what will be created before they submit.

Issue form direct link tip

Layer 2: The Python issue handler

A single Python script serves as the processing engine. It receives the raw issue JSON, detects the template type from labels, and runs the matching handler.

Template detection

1TEMPLATES = {
2 'new-repo': {'handler': handle_new_repo, 'branch_prefix': 'new-repo'},
3 'new-user': {'handler': handle_new_user, 'branch_prefix': 'new-user'},
4 # add more templates here
5}
6
7def detect_template(labels: list[str]) -> Optional[str]:
8 for label in labels:
9 if label in TEMPLATES:
10 return label
11 return None

Adding a new template type means adding one entry to TEMPLATES and implementing its handler function. Nothing else changes.

Field parsing

GitHub issue forms render as markdown with ### Label headings. The parser extracts values using regex:

1def parse_field(issue_body: str, label: str) -> Optional[str]:
2 pattern = rf'###\s*{re.escape(label)}\s*\n\s*(.+)'
3 match = re.search(pattern, issue_body, re.IGNORECASE)
4 if match:
5 value = match.group(1).strip()
6 return value if value and value != '_No response_' else None
7 return None

The repo handler

The handler parses the issue, then writes structured data into a JSON config file that Terraform consumes:

1def handle_new_repo(issue_body: str, issue_number: int) -> dict:
2 repo_name = parse_field(issue_body, 'Name of the new repo')
3 topics = parse_list(issue_body, 'Repository Topics')
4
5 if not repo_name:
6 raise ValueError("Repository name is required")
7
8 data = load_json('infra/github_mgmt/input.json')
9
10 if repo_name in data.get('repos', {}):
11 raise ValueError(f"Repository '{repo_name}' already exists")
12
13 data['repos'][repo_name] = {
14 'repo_name': repo_name,
15 'visibility': 'private',
16 'topics': topics,
17 }
18
19 save_json('infra/github_mgmt/input.json', data)
20
21 return {
22 'files': 'infra/github_mgmt/input.json',
23 'commit_msg': f"Add repo {repo_name}",
24 'pr_title': f"Add repo {repo_name} from issue #{issue_number}",
25 'pr_body': f"Adds repository **{repo_name}**. Closes #{issue_number}"
26 }

The handler updates one JSON file and returns metadata (branch name, commit message, and PR details). It owns no git logic.

Layer 3: The GitHub Actions workflow

The workflow ties everything together. It triggers on issue events, runs the Python handler, and creates a PR from the result:

1name: issue-handler
2on:
3 issues:
4 types: [opened, reopened, edited]
5
6permissions:
7 contents: write
8 pull-requests: write
9 issues: read
10
11jobs:
12 process-issue:
13 runs-on: ubuntu-latest
14 steps:
15 - uses: actions/checkout@v4
16 - uses: actions/setup-python@v5
17 with:
18 python-version: "3.11"
19
20 - name: Process issue
21 id: process
22 run: |
23 issue_json='${{ toJson(github.event.issue) }}'
24 result=$(python .github/scripts/issue_handler.py "$issue_json")
25 echo "result=$result" >> $GITHUB_OUTPUT
26 echo "branch_prefix=$(echo "$result" | jq -r '.branch_prefix')" >> $GITHUB_ENV
27 echo "files_to_add=$(echo "$result" | jq -r '.files_to_add')" >> $GITHUB_ENV
28 echo "commit_msg=$(echo "$result" | jq -r '.commit_msg')" >> $GITHUB_ENV
29 echo "pr_title=$(echo "$result" | jq -r '.pr_title')" >> $GITHUB_ENV
30
31 - name: Create branch, commit, and open PR
32 if: env.skip != 'true'
33 env:
34 GITHUB_TOKEN: ${{ github.token }}
35 run: |
36 git checkout -b "$branch_prefix/issue-${{ github.event.issue.number }}"
37 git add "$files_to_add"
38 git commit -m "$commit_msg"
39 git push --set-upstream origin "$branch_prefix/issue-${{ github.event.issue.number }}"
40 gh pr create --title "$pr_title" --body-file <(echo "$RESULT_JSON" | jq -r '.pr_body')

The workflow handles the git plumbing: branch naming, commit messages, PR creation with labels and assignees. The Python script stays focused on business logic.

Pull request created by automation

Workflow run details

Layer 4: Terraform, from JSON to infrastructure

The input.json file modified by the Python handler is the single source of truth for Terraform. The root module loads it and iterates over each entry:

1locals {
2 config = jsondecode(file("${path.module}/input.json"))
3 repos = local.config.repos
4}
5
6module "github_repo" {
7 source = "./modules/github_repo"
8 for_each = local.repos
9
10 repo_name = each.value.repo_name
11 visibility = each.value.visibility
12 topics = each.value.topics
13}

The repo module creates the actual GitHub resource:

1resource "github_repository" "repo" {
2 name = var.repo_name
3 visibility = var.visibility
4 topics = var.topics
5 auto_init = true
6 delete_branch_on_merge = true
7}

When the PR merges and Terraform applies, the repository gets created. In our real setup, the same pattern provisions additional resources: Entra ID security groups, GitHub teams with Entra sync, and project boards, all driven from the same JSON file.

Adding new template types

The system is designed to be extended. Adding a new request type requires:

  1. A new .yml issue template with a unique label
  2. A new handler function in the Python script
  3. A new entry in the TEMPLATES dictionary

No workflow changes. No Terraform changes. The existing modules handle the new resources.

Future improvements

Agentic AI as the intake layer. The current system requires users to fill in a structured form. The next step is letting users send a plain-text request, whether through email or a Microsoft Teams message, and having an AI agent parse the intent, fill the form fields, and submit the issue automatically. The form becomes a machine-to-machine interface rather than a human one.

GitHub Copilot Coding Agent for edge cases. Templates work well for predictable requests. For one-off infrastructure changes that do not fit a template, the Copilot coding agent can be pointed at an issue, explore the existing Terraform codebase, and propose changes following established patterns. An AGENTS.md file in the repository provides the agent with conventions and constraints, improving output quality.

Wrapping up

The pattern shown here is not specific to GitHub repositories. The general approach (structured forms driving declarative config files consumed by infrastructure-as-code) works anywhere you have repeatable infrastructure requests.

The key design decisions:

  • Single source of truth: one JSON file consumed by Terraform
  • Separation of concerns: forms capture intent, Python transforms it, Actions orchestrates, Terraform provisions
  • Human-in-the-loop: automation proposes, humans approve

The result is a GitHub organisation that runs like it has a full DevOps team, without one constantly in the loop.

More articles from blog by Frédéric De Smet