Revolutionizing Requirement Gathering: Azure DevOps Meets Azure OpenAI using Semantic kernel

Requirement gathering eats up a surprising amount of time on most delivery teams. A stakeholder describes what they want in a call or an email, and someone has to turn that into a proper feature with a title, a description, user needs, and functional and non-functional requirements before it can go into the backlog. This article walks through a sample from the Azure DevOps team that automates a good chunk of that translation work using Semantic Kernel and Azure OpenAI, then pushes the result straight into Azure DevOps as a work item.

The sample combines two Semantic Kernel building blocks. A semantic function calls an Azure OpenAI model to expand a short feature title into a full description with requirements. A native function takes that output and creates the actual work item in Azure DevOps through the REST API. On top of these two, the sample uses Semantic Kernel’s Sequential Planner so a single natural language request can generate several features in one go, without you having to script the exact call sequence yourself.

Setting up the environment

Before opening any code, install a few VS Code extensions: Jupyter and Python (both published by Microsoft) to run the notebook, Pylance for type checking, and the Semantic Kernel Tools extension for prompt authoring support. On the Python side you need pip available and the semantic-kernel and azure-devops packages, which the notebook installs in its first cell.

The full sample is available on Vivek Garudi’s GitHub repository (github.com/vivekgarudi/Semantic-Kernal-Azure). Download or clone it and open the PlugIn-for-creating-Azure-DevOps-features-from-Requirment-text folder in VS Code before continuing, since the notebook and plugin files referenced below all live there.

Understanding the plugin folder structure

Semantic Kernel organizes reusable capabilities as plugins, and each plugin folder groups related functions together. The screenshot below shows how this sample lays things out: a plugins folder containing an AzureDevOps plugin, which in turn holds a FeatureDescription folder with the semantic function files, plus a native_function.py file, a notebook to drive everything, and a .env.example file for configuration.

Project folder structure for the AzureDevOps plugin
Project folder structure for the AzureDevOps plugin

Defining the semantic function for feature descriptions

A semantic function in Semantic Kernel is really just a prompt template plus a small configuration file. The config.json file below tells the kernel how to run the prompt: which model settings to use and what parameters the function expects.

{
  "schema": 1,
  "description": "get standard feature title and description",
  "type": "completion",
  "completion": {
    "max_tokens": 500,
    "temperature": 0.0,
    "top_p": 0.0,
    "presence_penalty": 0.0,
    "frequency_penalty": 0.0
   },
     "input": {
          "parameters": [
               {
               "name": "input",
               "description": "The feature name.",
               "defaultValue": ""
               }
          ]
     }
}

The temperature and top_p are both set to zero, which pushes the model toward the most deterministic completion it can give for a given input. That is a reasonable choice for this use case since you want consistent structure across features, though with chat-based models zero temperature reduces variance rather than eliminating it completely, so do not assume byte-identical output on every run. The single input parameter maps to {{$input}} in the prompt file below.

The prompt itself lives in a separate skprompt.txt file, which keeps the instructions readable and easy to tweak without touching the configuration.

Create feature title and description for {{$input}}  in below format
Feature Title:"[Prodive a short title for the feature]"
Description: "[Provide a more detailed description of the feature's purpose, the problem it addresses, and its significance to the product or project.]
 
User Needs-
[Outline the specific user needs or pain points that this feature aims to address.]
 
Functional Requirements:-
- [Requirement 1]
- [Requirement 2]
- [Requirement 3]
- ...
 
Non-Functional Requirements:-
- [Requirement 1]
- [Requirement 2]
- [Requirement 3]
- ...
 
Feature Scope: [Indicates the minimum capabilities that feature should address. Agreed upon between Engineering Leads and Product Mangers]

This is a single-shot prompt, meaning it relies purely on instructions with no worked example. That works fine for a demo, but in a real backlog you will get more consistent formatting if you add one or two examples pulled from your own existing features. It is also worth noting that free-text output like this is fragile to parse downstream, which becomes relevant in the native function below.

Wiring up the kernel and running the semantic function

With the plugin files in place, rename .env.example to .env and fill in your Azure OpenAI deployment name, endpoint, and key. Then open the Create-Azure-Devops-feature-from-requirement-text notebook and start with the first cell, which installs the required packages.

!python -m pip install semantic-kernel==0.3.10.dev0 !python -m pip install azure-devops

Note that this pins a pre-release build of semantic-kernel from before the library’s 1.0 API stabilized. If you install a current version of Semantic Kernel instead, expect several of the calls below, including import_semantic_skill_from_directory and kernel.import_skill, to be renamed since the library later renamed skills to plugins and reworked large parts of the Python API. Pin the version shown here if you want this sample to run as written.

The next cell creates two kernel instances and configures the Azure OpenAI chat completion service.

import os
from dotenv import dotenv_values
import semantic_kernel as sk
from semantic_kernel import ContextVariables, Kernel # Context to store variables and Kernel to interact with the kernel
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion, OpenAIChatCompletion # AI services
from semantic_kernel.planning.sequential_planner import SequentialPlanner # Planner
 
kernel = sk.Kernel() # Create a kernel instance
kernel1 = sk.Kernel() #create another kernel instance for not having semantic function in the same kernel
 
useAzureOpenAI = True
 
# Configure AI service used by the kernel
if useAzureOpenAI:
    deployment, api_key, endpoint = sk.azure_openai_settings_from_dot_env()
    kernel.add_chat_service("chat_completion", AzureChatCompletion(deployment, endpoint, api_key))
    kernel1.add_chat_service("chat_completion", AzureChatCompletion(deployment, endpoint, api_key))
else:
    api_key, org_id = sk.openai_settings_from_dot_env()
    kernel.add_chat_service("chat-gpt", OpenAIChatCompletion("gpt-3.5-turbo", api_key, org_id))

The sample uses two separate kernel objects, kernel for the native function later on and kernel1 for loading the semantic function, mainly to keep the two concerns apart in the notebook. In your own code a single kernel instance is usually enough since Semantic Kernel lets you register both semantic and native functions on the same kernel without conflict.

Next, the notebook loads the semantic function from the plugins folder.

# note: using skills from the samples folder
plugins_directory = "./plugins"
 
# Import the semantic functions
DevFunctions=kernel1.import_semantic_skill_from_directory(plugins_directory, "AzureDevOps")
FDesFunction = DevFunctions["FeatureDescription"]

This scans the plugins directory for the AzureDevOps folder, finds the FeatureDescription semantic function inside it, and gives you a callable reference to it. From here, calling FDesFunction with a title string runs the prompt against Azure OpenAI.

resultFD = FDesFunction("Azure Resource Group Configuration Export and Infrastructure as Code (IAC) Generation")
print(resultFD)

Running this cell sends the title to Azure OpenAI and prints back a formatted feature description matching the structure defined in skprompt.txt: a title line, a description, user needs, and both functional and non-functional requirements. This confirms the semantic function works on its own before you wire it into Azure DevOps.

Creating a native function to create Azure DevOps work items

Native functions in Semantic Kernel are regular Python code that the kernel can call the same way it calls semantic functions. This one takes a feature title, calls the semantic function above to get a full description, then uses the Azure DevOps Python SDK to create the work item.

One thing to flag before the code: the original blog post’s listing is missing the @ symbol in front of the sk_function and sk_function_context_parameter decorators, which would fail to run as published. The version below restores it so the decorators actually attach to the method.

from semantic_kernel.skill_definition import (
    sk_function,
    sk_function_context_parameter,
)
 
from semantic_kernel.orchestration.sk_context import SKContext
from azure.devops.v7_1.py_pi_api import JsonPatchOperation
 
from azure.devops.connection import Connection
from msrest.authentication import BasicAuthentication
import base64
from semantic_kernel import ContextVariables, Kernel
import re
 
class feature:
    def __init__(self, kernel: Kernel):
        self._kernel = kernel
 
    @sk_function(
        description="create a Azure DevOps feature with description",
        name="create",
    )
    @sk_function_context_parameter(
        name="title",
        description="the title of the feature",
    )
    @sk_function_context_parameter(
        name="description",
        description="Description of the feature",
    )
    async def create_feature(self, context: SKContext) -> str:
        feature_title = context["title"]
        get_feature = self._kernel.skills.get_function("AzureDevOps", "FeatureDescription")
        fdetails = get_feature(feature_title)
        # Define a regular expression pattern to match the feature title
        pattern = r"Feature Title:\s+(.+)"
        # Search for the pattern in the input string
        match = re.search(pattern, str(fdetails))
        # Check if a match was found
        if match:
            feature_title = match.group(1)
        # Strip the title line and keep the rest as the description
        lines = str(fdetails).split('\n')
        lines = [line for index, line in enumerate(lines) if index not in [0]]
        description = '\n'.join(lines)
        jsonPatchList = []
        targetOrganizationName = "XXX"
        targetProjectName = "test"
        targetOrganizationPAT = "XXXXXX"
        teamName = "test Team"
        areaName = teamName
        iterationName = "Sprint 1"
        targetOrganizationUri = 'https://dev.azure.com/' + targetOrganizationName
        credentials = BasicAuthentication('', targetOrganizationPAT)
        connection = Connection(base_url=targetOrganizationUri, creds=credentials)
        userToken = "" + ":" + targetOrganizationPAT
        base64UserToken = base64.b64encode(userToken.encode()).decode()
        headers = {'Authorization': 'Basic' + base64UserToken}
        core_client = connection.clients.get_core_client()
        targetProjectId = core_client.get_project(targetProjectName).id
        workItemObjects = [
                {'op': 'add', 'path': '/fields/System.WorkItemType', 'value': "Feature"},
                {'op': 'add', 'path': '/fields/System.Title', 'value': feature_title},
                {'op': 'add', 'path': '/fields/System.State', 'value': "New"},
                {'op': 'add', 'path': '/fields/System.Description', 'value': description},
                {'op': 'add', 'path': '/fields/Microsoft.VSTS.Common.AcceptanceCriteria', 'value': "acceptance criteria"},
                {'op': 'add', 'path': '/fields/System.IterationPath', 'value': targetProjectName + "\\" + iterationName}
            ]
        jsonPatchList = JsonPatchOperation(workItemObjects)
        work_client = connection.clients.get_work_item_tracking_client()
        try:
            WorkItemCreation = work_client.create_work_item(jsonPatchList.from_, targetProjectName, "Feature")
        except Exception as e:
            return feature_title + " Feature created unsuccessfully"
        return feature_title + " Feature created successfully"

A few things stand out here that matter if you take this beyond a demo. The organization name and personal access token are hardcoded strings, which is fine for a quick test but should never ship this way. Pull these from environment variables or Azure Key Vault instead. The function also parses the semantic function’s free-text output with a regex looking for the line Feature Title:, which is brittle: if the model varies its wording even slightly, the regex misses and the title falls back to whatever was passed in originally. A more reliable approach is to ask the model to return JSON directly and parse that instead of matching text patterns. The iteration path is also hardcoded to Sprint 1, so a real implementation would need to look up the current sprint or accept it as a parameter.

Calling the native function from the notebook

Back in the notebook, importing the native function looks similar to importing the semantic one, except this time it comes from a Python module rather than a folder of config and prompt files.

from plugins.AzureDevops.native_function import feature
math_plugin = kernel.import_skill(feature(kernel1), skill_name="AzureDevOps")
variables = ContextVariables()

With the native function registered on the kernel, you can call it directly by passing a title and description through context variables.

variables["title"] = "creating a nice pipelines"
variables["description"] = "test"
result = await kernel.run_async(
                math_plugin["create"], input_vars=variables
            )
print(result)

Running this cell triggers the full chain: the native function calls the semantic function internally to expand the title into a description, builds the JSON patch document, and calls the Azure DevOps REST API to create the work item. On success it prints a message like creating a nice pipelines Feature created successfully. If the Azure DevOps call fails, for example because the PAT lacks permission or the project name is wrong, it prints the unsuccessful message instead, which is a bit too quiet for anything beyond a demo since it swallows the actual exception.

Generating multiple features with the Sequential Planner

The Sequential Planner is where this sample gets more interesting. Instead of calling the native function directly, you give the planner a goal in plain English, and it works out which registered functions to call and in what order based on the descriptions attached to each function.

from plugins.AzureDevops.native_function import feature
planner = SequentialPlanner(kernel)
# Import the native functions
AzDevplugin = kernel.import_skill(feature(kernel1), skill_name="AzureDevOps")
ask = "create two Azure DevOps features for one with title creating user and one with creating work items with standard feature title and description"
plan = await planner.create_plan_async(goal=ask)
for step in plan._steps:
        print(step.description, ":", step._state.__dict__)

The planner reads the ask, matches it against the description strings you gave the create function earlier, and produces a plan with one step per feature to be created. Printing plan._steps shows you what it decided to do before you actually run anything, which is worth doing in any workflow that hands control to a planner, since it lets you sanity check the plan before it takes real actions.

Once you are happy with the plan, invoking it executes each step in sequence.

print("Plan results:")
result = await plan.invoke_async(ask)
for step in plan._steps:
        print(step.description, ":", step._state.__dict__)

This creates two separate features in Azure DevOps, one titled around user creation and one around work items, each going through the same semantic-function-then-native-function chain described earlier. The output for each step mirrors what you saw when calling the native function directly.

Practical considerations before you build on this

This sample is a good proof of concept, but a few gaps need attention before it becomes something a team actually relies on. There is no validation step between the LLM generating a description and Azure DevOps creating the work item, so an inconsistent or malformed response goes straight into your backlog. Adding a review or approval gate, even a simple one where a human confirms the generated description before it gets created, avoids polluting your backlog with poorly formed features.

There is also no deduplication or linking against existing backlog items, so running this against a transcript with overlapping asks will happily create duplicate features. Extending the native function to search Azure DevOps for similar existing work items before creating a new one, using the same REST APIs already in play here, is a natural next step and lines up with where teams usually take this kind of automation once the basic flow works.

Finally, keep in mind that every feature generated means at least one Azure OpenAI call, so if you plan to run this against a large backlog or a long meeting transcript, factor in throttling limits and cost, and consider batching titles rather than calling the semantic function one at a time in a loop.

Wrapping up

What makes this sample worth studying is not the code itself, which is fairly simple, but the pattern: pairing a semantic function for language generation with a native function for the actual system integration, then letting a planner sequence multiple calls from a single natural language ask. That pattern extends well beyond Azure DevOps feature creation to any workflow where you need an LLM to interpret intent and a deterministic function to act on it safely.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading