Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/skills/doc-writer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ MergeButler evaluate --config <path> --pr <url> --platform <GitHub|AzureDevOps>
| `--config` | `-c` | Yes | Path to the YAML configuration file |
| `--pr` | | Yes | URL of the pull request to evaluate |
| `--platform` | `-p` | Yes | `GitHub` or `AzureDevOps` |
| `--token` | `-t` | No | Auth token. Defaults to `GITHUB_TOKEN` or `AZURE_DEVOPS_TOKEN` env var |
| `--token` | `-t` | No | Auth token. Defaults to `MERGEBUTLER__GITHUB_TOKEN` or `MERGEBUTLER__AZURE_DEVOPS_TOKEN` env var |
| `--dry-run` | `-n` | No | Evaluate without submitting an approval |

### `config show`
Expand Down Expand Up @@ -240,8 +240,8 @@ Use GitHub Copilot to evaluate the PR diff against a natural-language prompt.

| Platform | Library | Token env var |
|----------|---------|---------------|
| GitHub | [Octokit](https://github.com/octokit/octokit.net) | `GITHUB_TOKEN` |
| Azure DevOps | REST API | `AZURE_DEVOPS_TOKEN` |
| GitHub | [Octokit](https://github.com/octokit/octokit.net) | `MERGEBUTLER__GITHUB_TOKEN` |
| Azure DevOps | REST API | `MERGEBUTLER__AZURE_DEVOPS_TOKEN` |

## Documentation Maintenance

Expand Down
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
This would typically list all NuGet packages used within this solution.
-->
<ItemGroup>
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.6.2" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="17.14.1" />
<PackageVersion Include="GitHub.Copilot.SDK" Version="0.2.2" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.4.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
Expand All @@ -43,4 +43,4 @@
</PackageVersion>
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
</Project>
</Project>
61 changes: 55 additions & 6 deletions MergeButler.Tests/Commands/PlatformServiceFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ namespace MergeButler.Tests.Commands;

public class PlatformServiceFactoryTests
{
[Theory]
[InlineData(Platform.GitHub, PlatformServiceFactory.GitHubTokenEnvironmentVariable)]
[InlineData(Platform.AzureDevOps, PlatformServiceFactory.AzureDevOpsTokenEnvironmentVariable)]
public void GetTokenEnvironmentVariableName_ReturnsPrefixedName(Platform platform, string expected)
{
string result = PlatformServiceFactory.GetTokenEnvironmentVariableName(platform);

Assert.Equal(expected, result);
}

[Theory]
[InlineData("GitHub", Platform.GitHub)]
[InlineData("github", Platform.GitHub)]
Expand Down Expand Up @@ -35,13 +45,52 @@ public void ResolveToken_ProvidedToken_ReturnsProvided()
Assert.Equal("my-token", result);
}

[Fact]
public void ResolveToken_NullToken_FallsBackToEnvVar()
[Theory]
[InlineData(Platform.GitHub, PlatformServiceFactory.GitHubTokenEnvironmentVariable, "GITHUB_TOKEN")]
[InlineData(Platform.AzureDevOps, PlatformServiceFactory.AzureDevOpsTokenEnvironmentVariable, "AZURE_DEVOPS_TOKEN")]
public void ResolveToken_NullToken_FallsBackToPrefixedEnvVar(Platform platform, string prefixedEnvVar, string legacyEnvVar)
{
string? savedPrefixedToken = Environment.GetEnvironmentVariable(prefixedEnvVar);
string? savedLegacyToken = Environment.GetEnvironmentVariable(legacyEnvVar);

try
{
Environment.SetEnvironmentVariable(prefixedEnvVar, "prefixed-token");
Environment.SetEnvironmentVariable(legacyEnvVar, "legacy-token");

string? result = PlatformServiceFactory.ResolveToken(platform, null);

Assert.Equal("prefixed-token", result);
}
finally
{
Environment.SetEnvironmentVariable(prefixedEnvVar, savedPrefixedToken);
Environment.SetEnvironmentVariable(legacyEnvVar, savedLegacyToken);
}
}

[Theory]
[InlineData(Platform.GitHub, PlatformServiceFactory.GitHubTokenEnvironmentVariable, "GITHUB_TOKEN")]
[InlineData(Platform.AzureDevOps, PlatformServiceFactory.AzureDevOpsTokenEnvironmentVariable, "AZURE_DEVOPS_TOKEN")]
public void ResolveToken_NullToken_DoesNotUseLegacyEnvVar(Platform platform, string prefixedEnvVar, string legacyEnvVar)
{
// If env var is not set, returns null
string? result = PlatformServiceFactory.ResolveToken(Platform.GitHub, null);
// Can't assert specific value since it depends on env, but shouldn't throw
Assert.True(result is null || result.Length > 0);
string? savedPrefixedToken = Environment.GetEnvironmentVariable(prefixedEnvVar);
string? savedLegacyToken = Environment.GetEnvironmentVariable(legacyEnvVar);

try
{
Environment.SetEnvironmentVariable(prefixedEnvVar, null);
Environment.SetEnvironmentVariable(legacyEnvVar, "legacy-token");

string? result = PlatformServiceFactory.ResolveToken(platform, null);

Assert.Null(result);
}
finally
{
Environment.SetEnvironmentVariable(prefixedEnvVar, savedPrefixedToken);
Environment.SetEnvironmentVariable(legacyEnvVar, savedLegacyToken);
}
}

[Fact]
Expand Down
17 changes: 13 additions & 4 deletions MergeButler.Tests/Mcp/PullRequestToolsTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using MergeButler.Commands;
using MergeButler.Mcp;

namespace MergeButler.Tests.Mcp;
Expand All @@ -8,9 +9,11 @@ public class PullRequestToolsTests
public async Task GradePullRequest_NoToken_ReturnsError()
{
// Ensure env var is not set for this test
string? savedToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
string? savedToken = Environment.GetEnvironmentVariable(PlatformServiceFactory.GitHubTokenEnvironmentVariable);
string? savedLegacyToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
try
{
Environment.SetEnvironmentVariable(PlatformServiceFactory.GitHubTokenEnvironmentVariable, null);
Environment.SetEnvironmentVariable("GITHUB_TOKEN", null);

string result = await PullRequestTools.GradePullRequest(
Expand All @@ -21,10 +24,12 @@ public async Task GradePullRequest_NoToken_ReturnsError()

Assert.Contains("ERROR", result);
Assert.Contains("token", result, StringComparison.OrdinalIgnoreCase);
Assert.Contains(PlatformServiceFactory.GitHubTokenEnvironmentVariable, result);
}
finally
{
Environment.SetEnvironmentVariable("GITHUB_TOKEN", savedToken);
Environment.SetEnvironmentVariable(PlatformServiceFactory.GitHubTokenEnvironmentVariable, savedToken);
Environment.SetEnvironmentVariable("GITHUB_TOKEN", savedLegacyToken);
}
}

Expand All @@ -42,9 +47,11 @@ await Assert.ThrowsAsync<ArgumentException>(() =>
[Fact]
public async Task ApprovePullRequest_NoToken_ReturnsError()
{
string? savedToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
string? savedToken = Environment.GetEnvironmentVariable(PlatformServiceFactory.GitHubTokenEnvironmentVariable);
string? savedLegacyToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
try
{
Environment.SetEnvironmentVariable(PlatformServiceFactory.GitHubTokenEnvironmentVariable, null);
Environment.SetEnvironmentVariable("GITHUB_TOKEN", null);

string result = await PullRequestTools.ApprovePullRequest(
Expand All @@ -55,10 +62,12 @@ public async Task ApprovePullRequest_NoToken_ReturnsError()

Assert.Contains("ERROR", result);
Assert.Contains("token", result, StringComparison.OrdinalIgnoreCase);
Assert.Contains(PlatformServiceFactory.GitHubTokenEnvironmentVariable, result);
}
finally
{
Environment.SetEnvironmentVariable("GITHUB_TOKEN", savedToken);
Environment.SetEnvironmentVariable(PlatformServiceFactory.GitHubTokenEnvironmentVariable, savedToken);
Environment.SetEnvironmentVariable("GITHUB_TOKEN", savedLegacyToken);
}
}

Expand Down
6 changes: 4 additions & 2 deletions MergeButler/Commands/EvaluateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ public static Command Create()

Option<string?> tokenOption = new("--token", ["-t"])
{
Description = "Authentication token for the platform API. Defaults to GITHUB_TOKEN or AZURE_DEVOPS_TOKEN environment variable."
Description = "Authentication token for the platform API. Defaults to " +
PlatformServiceFactory.TokenEnvironmentVariableNames + " environment variable."
};

Option<bool> dryRunOption = new("--dry-run", ["-n"])
Expand Down Expand Up @@ -110,7 +111,8 @@ internal static async Task ExecuteAsync(

if (string.IsNullOrWhiteSpace(token))
{
output.WriteLine("Error: No authentication token provided. Use --token or set the appropriate environment variable.");
output.WriteLine(
$"Error: No authentication token provided. Use --token or set the {PlatformServiceFactory.GetTokenEnvironmentVariableName(platform)} environment variable.");
return;
}

Expand Down
17 changes: 13 additions & 4 deletions MergeButler/Commands/PlatformServiceFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,21 @@ namespace MergeButler.Commands;
/// </summary>
public static class PlatformServiceFactory
{
public const string EnvironmentVariablePrefix = "MERGEBUTLER__";
public const string GitHubTokenEnvironmentVariable = EnvironmentVariablePrefix + "GITHUB_TOKEN";
public const string AzureDevOpsTokenEnvironmentVariable = EnvironmentVariablePrefix + "AZURE_DEVOPS_TOKEN";
public const string TokenEnvironmentVariableNames =
GitHubTokenEnvironmentVariable + " or " + AzureDevOpsTokenEnvironmentVariable;

public static string? ResolveToken(Platform platform, string? providedToken) =>
providedToken ?? platform switch
providedToken ?? Environment.GetEnvironmentVariable(GetTokenEnvironmentVariableName(platform));

public static string GetTokenEnvironmentVariableName(Platform platform) =>
platform switch
{
Platform.GitHub => Environment.GetEnvironmentVariable("GITHUB_TOKEN"),
Platform.AzureDevOps => Environment.GetEnvironmentVariable("AZURE_DEVOPS_TOKEN"),
_ => null
Platform.GitHub => GitHubTokenEnvironmentVariable,
Platform.AzureDevOps => AzureDevOpsTokenEnvironmentVariable,
_ => throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unsupported platform.")
};

public static IPullRequestService CreateService(Platform platform, string token)
Expand Down
12 changes: 6 additions & 6 deletions MergeButler/Mcp/PullRequestTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ public class PullRequestTools
{
[McpServerTool(Name = "grade_pull_request"),
Description("Evaluate a pull request against MergeButler rules and return the grading result. " +
"This does NOT approve the PR — use approve_pull_request for that.")]
"This does NOT approve the PR — use approve_pull_request for that.")]
public static async Task<string> GradePullRequest(
[Description("Full URL of the pull request (e.g. https://github.com/owner/repo/pull/42)")] string prUrl,
[Description("Platform hosting the PR: GitHub, AzureDevOps, or azdo. If not specified, inferred from git remotes.")] string? platform = null,
[Description("Path to MergeButler YAML config file. When omitted, the effective config is built from the default user and repo locations.")] string? configPath = null,
[Description("Auth token for the platform API. If not provided, falls back to GITHUB_TOKEN or AZURE_DEVOPS_TOKEN environment variable.")] string? token = null,
[Description("Auth token for the platform API. If not provided, falls back to " + PlatformServiceFactory.TokenEnvironmentVariableNames + " environment variable.")] string? token = null,
CancellationToken cancellationToken = default)
{
Platform platformEnum;
Expand All @@ -39,7 +39,7 @@ public static async Task<string> GradePullRequest(
token = PlatformServiceFactory.ResolveToken(platformEnum, token);
if (string.IsNullOrWhiteSpace(token))
{
string envVar = platformEnum == Platform.GitHub ? "GITHUB_TOKEN" : "AZURE_DEVOPS_TOKEN";
string envVar = PlatformServiceFactory.GetTokenEnvironmentVariableName(platformEnum);
return $"ERROR: No authentication token provided. Please provide a token parameter or set the {envVar} environment variable.";
}

Expand Down Expand Up @@ -144,11 +144,11 @@ public static async Task<string> GradePullRequest(

[McpServerTool(Name = "approve_pull_request"),
Description("Submit an approval on a pull request. " +
"Use grade_pull_request first to evaluate the PR before approving.")]
"Use grade_pull_request first to evaluate the PR before approving.")]
public static async Task<string> ApprovePullRequest(
[Description("Full URL of the pull request (e.g. https://github.com/owner/repo/pull/42)")] string prUrl,
[Description("Platform hosting the PR: GitHub, AzureDevOps, or azdo. If not specified, inferred from git remotes.")] string? platform = null,
[Description("Auth token for the platform API. If not provided, falls back to GITHUB_TOKEN or AZURE_DEVOPS_TOKEN environment variable.")] string? token = null,
[Description("Auth token for the platform API. If not provided, falls back to " + PlatformServiceFactory.TokenEnvironmentVariableNames + " environment variable.")] string? token = null,
CancellationToken cancellationToken = default)
{
Platform platformEnum;
Expand All @@ -169,7 +169,7 @@ public static async Task<string> ApprovePullRequest(
token = PlatformServiceFactory.ResolveToken(platformEnum, token);
if (string.IsNullOrWhiteSpace(token))
{
string envVar = platformEnum == Platform.GitHub ? "GITHUB_TOKEN" : "AZURE_DEVOPS_TOKEN";
string envVar = PlatformServiceFactory.GetTokenEnvironmentVariableName(platformEnum);
return $"ERROR: No authentication token provided. Please provide a token parameter or set the {envVar} environment variable.";
}

Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ MergeButler evaluate --config .mergebutler.yml --pr <PR_URL> --platform <GitHub|
| `--config` | `-c` | Yes | Path to the YAML configuration file |
| `--pr` | | Yes | URL of the pull request to evaluate |
| `--platform` | `-p` | Yes | `GitHub` or `AzureDevOps` |
| `--token` | `-t` | No | Auth token. Defaults to `GITHUB_TOKEN` or `AZURE_DEVOPS_TOKEN` env var |
| `--token` | `-t` | No | Auth token. Defaults to `MERGEBUTLER__GITHUB_TOKEN` or `MERGEBUTLER__AZURE_DEVOPS_TOKEN` env var |
| `--dry-run` | `-n` | No | Evaluate without submitting an approval |

#### Examples
Expand All @@ -32,8 +32,8 @@ MergeButler evaluate --config .mergebutler.yml --pr <PR_URL> --platform <GitHub|
# GitHub PR
MergeButler evaluate -c .mergebutler.yml --pr https://github.com/owner/repo/pull/42 -p GitHub

# Azure DevOps PR
MergeButler evaluate -c .mergebutler.yml --pr https://dev.azure.com/org/project/_git/repo/pullrequest/1 -p AzureDevOps -t $AZURE_DEVOPS_TOKEN
# Azure DevOps PR using the default env var
MERGEBUTLER__AZURE_DEVOPS_TOKEN=your-token MergeButler evaluate -c .mergebutler.yml --pr https://dev.azure.com/org/project/_git/repo/pullrequest/1 -p AzureDevOps

# Dry run — see if a PR would be approved without actually approving it
MergeButler evaluate -c .mergebutler.yml --pr https://github.com/owner/repo/pull/42 -p GitHub --dry-run
Expand Down Expand Up @@ -196,8 +196,8 @@ rules:

| Platform | Library | Token env var |
|----------|---------|---------------|
| GitHub | [Octokit](https://github.com/octokit/octokit.net) | `GITHUB_TOKEN` |
| Azure DevOps | REST API | `AZURE_DEVOPS_TOKEN` |
| GitHub | [Octokit](https://github.com/octokit/octokit.net) | `MERGEBUTLER__GITHUB_TOKEN` |
| Azure DevOps | REST API | `MERGEBUTLER__AZURE_DEVOPS_TOKEN` |

## MCP Server (Local Development)

Expand Down Expand Up @@ -232,7 +232,7 @@ Add to your `.vscode/mcp.json`:
}
```

PR tools accept `prUrl`, `platform`, and an optional `token` parameter. If no token is provided, the tools check environment variables (`GITHUB_TOKEN` / `AZURE_DEVOPS_TOKEN`) and return a descriptive error if none is found.
PR tools accept `prUrl`, `platform`, and an optional `token` parameter. If no token is provided, the tools check environment variables (`MERGEBUTLER__GITHUB_TOKEN` / `MERGEBUTLER__AZURE_DEVOPS_TOKEN`) and return a descriptive error if none is found.

### `setup`

Expand Down Expand Up @@ -263,4 +263,4 @@ MergeButler setup

# Non-interactive — perform all steps automatically
MergeButler setup --yes
```
```
Loading