Files
awesome-copilot/cookbook/copilot-sdk/dotnet/pr-visualization.md
T
Jon GallowayandCopilot a19b3101cc Update .NET Copilot SDK cookbook for current SDK releases (#3548)
* Update .NET Copilot SDK cookbook: fix On<T> breaking change, add in-process runtime, tool search, and message provenance recipes

- Fix breaking change: Session.On is now generic (On<T> where T : SessionEvent).
  Updated all recipes that used the old non-generic session.On(evt => ...) pattern:
  error-handling, pr-visualization, managing-local-files, accessibility-report,
  ralph-loop (both .md and .cs where applicable).
- Add new 'In-Process Runtime' recipe (in-process-runtime.md + recipe/in-process-runtime.cs)
  demonstrating RuntimeConnection.ForInProcess(), live-tested.
- Document tool search / deferred loading (SessionConfig.ToolSearch / ToolSearchConfig)
  in pr-visualization.md, live-tested.
- Document typed message provenance (MessageOptions.Source / MessageSource) in
  error-handling.md, live-tested.
- Fix dotnet/README.md recipe index: it was missing Ralph Loop and Accessibility
  Report entries; also added the new In-Process Runtime entry.
- Add In-Process Runtime entry to the top-level cookbook/copilot-sdk/README.md
  .NET section.
- Update dotnet/recipe/README.md runnable-examples table with the new recipe.

All existing .NET cookbook recipes were rebuilt and rechecked against the current
GitHub Copilot SDK (Copilot CLI 1.0.85); no other breaking changes were found.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update cookbook model names to current supported models

- Replace stale/nonexistent model IDs (gpt-5, claude-sonnet-4.5,
  claude-opus-4.6, gpt-5.1-codex-mini) with current values from the
  supported AI models list
- Prefer Model = "auto" wherever a specific model isn't load-bearing
  to the recipe's teaching point
- ralph-loop: pin gpt-5.3-codex (current codex-family GA model), since
  a dedicated coding model is a deliberate, documented choice for the
  autonomous loop
- multiple-sessions: reshape the model narrative so it demonstrates a
  real explicit-override use case (A/B testing via claude-sonnet-5)
  instead of two sessions sharing the same hardcoded model

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Use SendAndWaitAsync in in-process runtime recipe

Replace the event-based TaskCompletionSource pattern with SendAndWaitAsync so the example waits for the final assistant response, propagates session errors, and uses the SDK timeout behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-22 00:15:06 +00:00

293 lines
9.0 KiB
Markdown

# Generating PR Age Charts
Build an interactive CLI tool that visualizes pull request age distribution for a GitHub repository using Copilot's built-in capabilities.
> **Runnable example:** [recipe/pr-visualization.cs](recipe/pr-visualization.cs)
>
> ```bash
> # Auto-detect from current git repo
> dotnet run recipe/pr-visualization.cs
>
> # Specify a repo explicitly
> dotnet run recipe/pr-visualization.cs -- --repo github/copilot-sdk
> ```
## Example scenario
You want to understand how long PRs have been open in a repository. This tool detects the current Git repo or accepts a repo as input, then lets Copilot fetch PR data via the GitHub MCP Server and generate a chart image.
## Prerequisites
```bash
dotnet add package GitHub.Copilot.SDK
```
## Usage
```bash
# Auto-detect from current git repo
dotnet run
# Specify a repo explicitly
dotnet run -- --repo github/copilot-sdk
```
## Full example: pr-visualization.cs
```csharp
using System.Diagnostics;
using GitHub.Copilot;
// ============================================================================
// Git & GitHub Detection
// ============================================================================
bool IsGitRepo()
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "git",
Arguments = "rev-parse --git-dir",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
})?.WaitForExit();
return true;
}
catch
{
return false;
}
}
string? GetGitHubRemote()
{
try
{
var proc = Process.Start(new ProcessStartInfo
{
FileName = "git",
Arguments = "remote get-url origin",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
});
var remoteUrl = proc?.StandardOutput.ReadToEnd().Trim();
proc?.WaitForExit();
if (string.IsNullOrEmpty(remoteUrl)) return null;
// Handle SSH: git@github.com:owner/repo.git
var sshMatch = System.Text.RegularExpressions.Regex.Match(
remoteUrl, @"git@github\.com:(.+/.+?)(?:\.git)?$");
if (sshMatch.Success) return sshMatch.Groups[1].Value;
// Handle HTTPS: https://github.com/owner/repo.git
var httpsMatch = System.Text.RegularExpressions.Regex.Match(
remoteUrl, @"https://github\.com/(.+/.+?)(?:\.git)?$");
if (httpsMatch.Success) return httpsMatch.Groups[1].Value;
return null;
}
catch
{
return null;
}
}
string? ParseRepoArg(string[] args)
{
var repoIndex = Array.IndexOf(args, "--repo");
if (repoIndex != -1 && repoIndex + 1 < args.Length)
{
return args[repoIndex + 1];
}
return null;
}
string PromptForRepo()
{
Console.Write("Enter GitHub repo (owner/repo): ");
return Console.ReadLine()?.Trim() ?? "";
}
// ============================================================================
// Main Application
// ============================================================================
Console.WriteLine("🔍 PR Age Chart Generator\n");
// Determine the repository
var repo = ParseRepoArg(args);
if (!string.IsNullOrEmpty(repo))
{
Console.WriteLine($"📦 Using specified repo: {repo}");
}
else if (IsGitRepo())
{
var detected = GetGitHubRemote();
if (detected != null)
{
repo = detected;
Console.WriteLine($"📦 Detected GitHub repo: {repo}");
}
else
{
Console.WriteLine("⚠️ Git repo found but no GitHub remote detected.");
repo = PromptForRepo();
}
}
else
{
Console.WriteLine("📁 Not in a git repository.");
repo = PromptForRepo();
}
if (string.IsNullOrEmpty(repo) || !repo.Contains('/'))
{
Console.WriteLine("❌ Invalid repo format. Expected: owner/repo");
return;
}
var parts = repo.Split('/');
var owner = parts[0];
var repoName = parts[1];
// Create Copilot client - no custom tools needed!
await using var client = new CopilotClient(new CopilotClientOptions { LogLevel = CopilotLogLevel.Error });
await client.StartAsync();
var session = await client.CreateSessionAsync(new SessionConfig
{
Model = "auto",
OnPermissionRequest = PermissionHandler.ApproveAll,
SystemMessage = new SystemMessageConfig
{
Content = $"""
<context>
You are analyzing pull requests for the GitHub repository: {owner}/{repoName}
The current working directory is: {Environment.CurrentDirectory}
</context>
<instructions>
- Use the GitHub MCP Server tools to fetch PR data
- Use your file and code execution tools to generate charts
- Save any generated images to the current working directory
- Be concise in your responses
</instructions>
"""
}
});
// Set up event handling
session.On<SessionEvent>(evt =>
{
switch (evt)
{
case AssistantMessageEvent msg:
Console.WriteLine($"\n🤖 {msg.Data.Content}\n");
break;
case ToolExecutionStartEvent toolStart:
Console.WriteLine($" ⚙️ {toolStart.Data.ToolName}");
break;
}
});
// Initial prompt - let Copilot figure out the details
Console.WriteLine("\n📊 Starting analysis...\n");
await session.SendAsync(new MessageOptions
{
Prompt = $"""
Fetch the open pull requests for {owner}/{repoName} from the last week.
Calculate the age of each PR in days.
Then generate a bar chart image showing the distribution of PR ages
(group them into sensible buckets like <1 day, 1-3 days, etc.).
Save the chart as "pr-age-chart.png" in the current directory.
Finally, summarize the PR health - average age, oldest PR, and how many might be considered stale.
"""
});
// Interactive loop
Console.WriteLine("\n💡 Ask follow-up questions or type \"exit\" to quit.\n");
Console.WriteLine("Examples:");
Console.WriteLine(" - \"Expand to the last month\"");
Console.WriteLine(" - \"Show me the 5 oldest PRs\"");
Console.WriteLine(" - \"Generate a pie chart instead\"");
Console.WriteLine(" - \"Group by author instead of age\"");
Console.WriteLine();
while (true)
{
Console.Write("You: ");
var input = Console.ReadLine()?.Trim();
if (string.IsNullOrEmpty(input)) continue;
if (input.ToLower() is "exit" or "quit")
{
Console.WriteLine("👋 Goodbye!");
break;
}
await session.SendAsync(new MessageOptions { Prompt = input });
}
```
## How it works
1. **Repository detection**: Checks `--repo` flag → git remote → prompts user
2. **No custom tools**: Relies entirely on Copilot CLI's built-in capabilities:
- **GitHub MCP Server** - Fetches PR data from GitHub
- **File tools** - Saves generated chart images
- **Code execution** - Generates charts using Python/matplotlib or other methods
3. **Interactive session**: After initial analysis, user can ask for adjustments
## Why this approach?
| Aspect | Custom Tools | Built-in Copilot |
| --------------- | ----------------- | --------------------------------- |
| Code complexity | High | **Minimal** |
| Maintenance | You maintain | **Copilot maintains** |
| Flexibility | Fixed logic | **AI decides best approach** |
| Chart types | What you coded | **Any type Copilot can generate** |
| Data grouping | Hardcoded buckets | **Intelligent grouping** |
## Deferring tools with tool search
The GitHub MCP Server alone exposes dozens of tools, and this recipe also has the file and code
execution tools available — well past the point where stuffing every tool description into the
model's context on every turn helps more than it costs. `SessionConfig.ToolSearch` controls when
the SDK defers less-frequently-needed tools behind a search step instead of listing them all
up front:
```csharp
var session = await client.CreateSessionAsync(new SessionConfig
{
Model = "auto",
OnPermissionRequest = PermissionHandler.ApproveAll,
ToolSearch = new ToolSearchConfig
{
Enabled = true,
DeferThreshold = 20
},
SystemMessage = new SystemMessageConfig { Content = "..." }
});
```
- `Enabled` turns tool search on or off explicitly; leave it `null` to use the runtime default.
- `DeferThreshold` is the tool count above which MCP/external tools are deferred behind a
`tool_search_tool` call instead of being listed directly (the runtime default is 30 when
unset). Lowering it — as above — is useful once a recipe pulls in a large MCP server like the
GitHub MCP Server alongside file and code-execution tools, since it keeps the per-turn tool
list small while still letting Copilot search for the exact tool it needs.
- To customize how the search itself behaves, register your own tool named `"tool_search_tool"`
with `OverridesBuiltInTool = true` to replace the built-in implementation.
This is a per-session setting, not a global one, so you can tune it independently for
tool-heavy recipes like this one versus lighter sessions elsewhere in your app.