Prerequisites
Before you start, make sure you have the basics in place so the rest of the walkthrough stays smooth.
- Visual Studio 2022 or later.
- .NET 8 SDK or the SDK version your team already uses.
- JIRA API token and access to the project/release scope.
- A release key, sprint name, or JQL that identifies the issues you want.
- An output folder where the Markdown file can be written.
- Network access to your JIRA instance.
- A test release so you can verify the first run safely.
Packages I used
If you start from a normal Visual Studio console app, you do not need a long package list. Keep it lean and add only what helps the tool run cleanly.
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Configuration.Json
dotnet add package Microsoft.Extensions.Http
Those packages gave me configuration, dependency injection, and HttpClient support. I stopped there because the standard HTTP stack was enough for this tool.
Step 1: Decide what the tool should produce
Before writing code, decide the output shape. For this tool, the output is one Markdown file with release-note sections grouped by type. That is the build phase.
- Connect to JIRA.
- Pull issues for a release, sprint, or fix version.
- Turn the issue list into a readable release note draft.
- Export the result as Markdown.
- Run and debug it from Visual Studio.
- Keep the logic simple enough that the team can maintain it.
Step 2: Create the project in Visual Studio
Start with a console app. That keeps the first version simple and makes it easy to run, debug, and change. This is where I test the first end-to-end flow.
- Create a new .NET console app in Visual Studio.
- Add an
appsettings.jsonfile for JIRA settings. - Wire the app with dependency injection if you want the code to stay clean.
- Run the tool from the Visual Studio debugger before you think about packaging it.
Step 3: Keep the folder structure simple
I kept the client, mapper, and renderer separate. That way one part can change without touching the others. This is the package stage in practice.
ReleaseNoteTool/
Program.cs
appsettings.json
Models/
Services/
Renderers/
Output/
That keeps the JIRA client, the mapping logic, and the output rendering separate. I did not let all of those concerns sit in one file.
Step 4: Put the JIRA settings in config
Keep the connection details and JQL in configuration so you can change the release scope without editing code. That is what makes the tool easy to deliver across releases.
{
"Jira": {
"BaseUrl": "https://your-company.atlassian.net",
"Email": "your.email@company.com",
"ApiToken": "replace-me",
"ProjectKey": "APP",
"Jql": "project = APP AND fixVersion = \"1.0.0\" ORDER BY priority DESC"
},
"Release": {
"OutputPath": "Output/release-notes.md"
}
}
- Use a project key or fix version to control the release scope.
- Keep the JQL in config so you can change it without touching code.
- If your JIRA setup differs, adjust the query shape, not the whole design.
Step 5: Follow the data flow
The sequence is simple: Visual Studio runs the app, the app fetches JIRA issues, the mapper normalizes them, and the renderer writes Markdown.
Step 6: Build the JIRA client
This service should do one thing: ask JIRA for issues and give you the raw response in a shape your app can use.
public sealed record JiraIssue(string Key, string Summary, string IssueType, string Status);
public sealed class JiraClient
{
private readonly HttpClient _http;
public JiraClient(HttpClient http) => _http = http;
public async Task<List<JiraIssue>> SearchAsync(string jql)
{
var response = await _http.GetAsync($"/rest/api/3/search?jql={Uri.EscapeDataString(jql)}");
response.EnsureSuccessStatusCode();
// Parse the response and map the fields you need.
return new List<JiraIssue>();
}
}
- Use
HttpClientonce and inject it. - Keep authentication outside the business logic.
- Map only the fields you need for the note.
Step 7: Normalize the issues
Raw JIRA issues were not yet release notes. I converted them into a smaller model first so the rest of the pipeline stays predictable.
public sealed record ReleaseNoteItem(string Title, string Category, string Notes);
public static class ReleaseNoteMapper
{
public static ReleaseNoteItem Map(JiraIssue issue)
{
var category = issue.IssueType switch
{
"Bug" => "Fix",
"Story" => "Feature",
"Task" => "Change",
_ => "Other"
};
return new ReleaseNoteItem(issue.Summary, category, issue.Status);
}
}
I kept the mapping boring on purpose. The release note generator worked best when it transformed JIRA issues into a predictable shape before it started formatting anything.
Step 8: Render the Markdown
Once the items are normalized, format them into Markdown so the output is easy to read, edit, and check into a release branch.
public static class ReleaseNoteRenderer
{
public static string Render(IEnumerable<ReleaseNoteItem> items)
{
var lines = new List<string>
{
"# Release Notes",
""
};
foreach (var group in items.GroupBy(x => x.Category))
{
lines.Add($"## {group.Key}");
lines.Add("");
foreach (var item in group)
lines.Add($"- {item.Title} ({item.Notes})");
lines.Add("");
}
return string.Join(Environment.NewLine, lines);
}
}
- Markdown is easy to review in Git and easy to export later.
- It also keeps the first version simple enough to debug in Visual Studio.
Step 9: Wire it together in Program.cs
This is the part where the tool finally becomes useful: fetch, map, render, and save the file.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Fetch issues from JIRA, normalize them, render Markdown, then save the file.
app.Run(async () =>
{
var issues = await jiraClient.SearchAsync(config.Jira.Jql);
var items = issues.Select(ReleaseNoteMapper.Map).ToList();
var markdown = ReleaseNoteRenderer.Render(items);
await File.WriteAllTextAsync(config.Release.OutputPath, markdown);
});
app.Run();
Step 10: Run it in Visual Studio
I debugged it in Visual Studio first so I could inspect the fetched issues, the normalized items, and the final file path before handing it to anyone else.
- Open the console project.
- Set a breakpoint after the JIRA fetch.
- Inspect the issue list and the normalized items.
- Confirm the Markdown file is written to the expected output folder.
- Check the file into a release branch or drop it into your release notes process.
What this solves in practice
Instead of copying work item text into a release note by hand, I end up with a repeatable tool that pulls from JIRA, formats the note the same way every time, and gives me a file I can edit instead of a blank page.
Complete sample, split into a small project
I kept this as a console app with three simple folders. That kept the entry point clean and made the rest of the code easy to reuse.
ReleaseNoteGenerator/
Program.cs
appsettings.json
Models/
JiraOptions.cs
ReleaseOptions.cs
JiraIssue.cs
ReleaseNoteItem.cs
Services/
JiraClient.cs
ReleaseNoteMapper.cs
Rendering/
ReleaseNoteRenderer.cs
appsettings.json
I kept only the values that changed by environment here.
{
"Jira": {
"BaseUrl": "https://your-domain.atlassian.net",
"Email": "you@company.com",
"ApiToken": "paste-token-here",
"ProjectKey": "ABC",
"Jql": "project = ABC AND updated >= -7d ORDER BY updated DESC"
},
"Release": {
"OutputPath": "Output/release-notes.md"
}
}
Program.cs
This file only orchestrates the flow. It should not know how JIRA is parsed or how Markdown is rendered.
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
var jiraOptions = builder.Configuration.GetSection("Jira").Get<JiraOptions>()!;
var releaseOptions = builder.Configuration.GetSection("Release").Get<ReleaseOptions>()!;
using var http = new HttpClient { BaseAddress = new Uri(jiraOptions.BaseUrl) };
if (!string.IsNullOrWhiteSpace(jiraOptions.Email) && !string.IsNullOrWhiteSpace(jiraOptions.ApiToken))
{
var raw = $"{jiraOptions.Email}:{jiraOptions.ApiToken}";
var token = Convert.ToBase64String(Encoding.ASCII.GetBytes(raw));
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", token);
}
var jiraClient = new JiraClient(http);
var mapper = new ReleaseNoteMapper();
var renderer = new ReleaseNoteRenderer();
var issues = await jiraClient.SearchAsync(jiraOptions.Jql);
var items = issues.Select(mapper.Map).ToList();
var markdown = renderer.Render(items);
Directory.CreateDirectory(Path.GetDirectoryName(releaseOptions.OutputPath)!);
await File.WriteAllTextAsync(releaseOptions.OutputPath, markdown);
Console.WriteLine($"Release notes written to {releaseOptions.OutputPath}");
Models/JiraOptions.cs
This holds the JIRA settings for the app.
public sealed class JiraOptions
{
public string BaseUrl { get; set; } = "";
public string Email { get; set; } = "";
public string ApiToken { get; set; } = "";
public string ProjectKey { get; set; } = "";
public string Jql { get; set; } = "";
}
Models/ReleaseOptions.cs
This keeps the output path in one place.
public sealed class ReleaseOptions
{
public string OutputPath { get; set; } = "Output/release-notes.md";
}
Models/JiraIssue.cs
This is the raw issue shape I want back from JIRA.
public sealed record JiraIssue(
string Key,
string Summary,
string IssueType,
string Status);
Models/ReleaseNoteItem.cs
This is the smaller shape that the rest of the tool uses after mapping.
public sealed record ReleaseNoteItem(
string Title,
string Category,
string Notes);
Services/JiraClient.cs
This class talked to JIRA and returned the issues I needed. I kept the HTTP and parsing here so the rest of the app stayed simple.
using System.Text.Json;
public sealed class JiraClient
{
private readonly HttpClient _http;
public JiraClient(HttpClient http) => _http = http;
public async Task<List<JiraIssue>> SearchAsync(string jql)
{
var url = $"/rest/api/3/search?jql={Uri.EscapeDataString(jql)}&fields=summary,issuetype,status&maxResults=100";
var response = await _http.GetAsync(url);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync();
using var doc = await JsonDocument.ParseAsync(stream);
var result = new List<JiraIssue>();
foreach (var issue in doc.RootElement.GetProperty("issues").EnumerateArray())
{
var fields = issue.GetProperty("fields");
result.Add(new JiraIssue(
issue.GetProperty("key").GetString() ?? "",
fields.GetProperty("summary").GetString() ?? "",
fields.GetProperty("issuetype").GetProperty("name").GetString() ?? "Other",
fields.GetProperty("status").GetProperty("name").GetString() ?? "Unknown"));
}
return result;
}
}
Services/ReleaseNoteMapper.cs
This is the transformation step. It keeps the logic for grouping and naming in one place.
public sealed class ReleaseNoteMapper
{
public ReleaseNoteItem Map(JiraIssue issue)
{
var category = issue.IssueType switch
{
"Bug" => "Fix",
"Story" => "Feature",
"Task" => "Change",
_ => "Other"
};
return new ReleaseNoteItem(issue.Summary, category, issue.Status);
}
}
Rendering/ReleaseNoteRenderer.cs
This file only formats the final Markdown. I kept the renderer boring and predictable.
public sealed class ReleaseNoteRenderer
{
public string Render(IEnumerable<ReleaseNoteItem> items)
{
var lines = new List<string> { "# Release Notes", "" };
foreach (var group in items.GroupBy(x => x.Category))
{
lines.Add($"## {group.Key}");
lines.Add("");
foreach (var item in group)
lines.Add($"- {item.Title} ({item.Notes})");
lines.Add("");
}
return string.Join(Environment.NewLine, lines);
}
}