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
76 changes: 76 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Copilot instructions for msgraph-cloud-support

## What this tool does

`CheckCloudSupport` is a .NET console tool that determines which Microsoft national
clouds (Global, US Government L4/L5, China) each Microsoft Graph API supports, then
injects a `[!INCLUDE [national-cloud-support]...]` line into the API's Markdown
reference doc. It does this by cross-referencing two external inputs:

1. **OpenAPI descriptions** (`Prod.yml` = Global, `Fairfax.yml` = US Gov,
`Mooncake.yml` = China) from the `msgraph-metadata` repo.
2. **API reference Markdown docs** from the `microsoft-graph-docs` (or
`m365copilot-docs-pr`) repo.

Neither of those repos lives here — paths are passed as CLI arguments. See
`.vscode/launch.json` for concrete example invocations.

## Build / test / run

- **Target framework:** `net10.0` (both projects). CI (`.github/workflows/dotnet.yml`) uses the `10.x` SDK.
- **Build:** `dotnet build`
- **Test (full suite):** `dotnet test`
- **Run a single test:** `dotnet test --filter "FullyQualifiedName~ApiDocumentTests.CreateFromMarkdownFile_LoadsGraphApiFileCorrectly"` (or `--filter "DisplayName~<name>"`).
- **Run the tool:** `dotnet run --project src -- --open-api <folder> --api-docs <folder> [--overrides <file>] [--excludes <file>]`.
Use the `copilot` subcommand for the M365 Copilot docs, which expects the OpenAPI
folder to contain `v1.0/` and `beta/` subfolders and requires `--include-directory`.

## Architecture / data flow

The whole pipeline is orchestrated in `src/Program.cs` (top-level statements,
System.CommandLine). There are two commands: the root command (single API version)
and `copilot` (processes v1.0 and beta separately, emitting zone-pivoted INCLUDEs when
the two versions differ).

Flow: `DocSet.CreateFromDirectory` recursively loads every `*.md`, building an
`ApiDocument` per file (files whose `doc_type` is not `apiPageType` are skipped). Each
`ApiDocument` parses its "HTTP request" section with Markdig into `ApiOperation`s.
Separately, each cloud's OpenAPI YAML is attached to an `OpenApiUrlTreeNode`. For every
operation, `OpenApiUrlTreeNodeExtensions.GetNodeByPath` locates the matching tree node
and `GetCloudSupportStatus` checks which clouds expose that method, producing a
`CloudSupportStatus`. Finally `ApiDocument.AddOrUpdateIncludeLine` (or
`AddOrUpdatePivotedIncludeLine`) rewrites the Markdown file.

Key types:
- `CloudSupportStatus` — a `[Flags]` enum. Individual clouds (`Global`, `China`,
`USGovL4`, `USGovL5`) are OR-combined; named combinations (`AllClouds`,
`GlobalAndChina`, etc.) map 1:1 to specific INCLUDE files (`all-clouds.md`,
`global-china.md`, ...). When adding a new cloud combination you must add both an enum
member and a matching `switch` arm in `ApiDocument.GetIncludeLine`.
- `OpenAPIOverrides` — static, initialized once from two optional JSON files.
`overrides.json` remaps an API path (docs path -> OpenAPI path) when the two don't
match; `cloud-exclusions.json` force-excludes a specific API+method+cloud, or an
entire file+cloud. JSON schemas are in `schema/`. `src/overrides.json` and
`src/cloud-exclusions.json` are sample/seed files.

## Conventions

- **Doc/OpenAPI path mismatches are handled by string normalization, not by editing
data.** `ApiOperation.CreateFromStringLine` runs the raw path through a chain of
`StringExtensions` methods (`MakePathRelativeToVersion`, `NormalizeIdSegments`,
`FixUserDrivePath`, `FixWellKnownMailFoldersId`, etc.). Add new quirks as another
chained extension + `[GeneratedRegex]` method rather than special-casing callers.
- **Path segment matching is case-insensitive and namespace-aware.** Use the
`IsEqualIgnoringCase` extension (it also has special handling for OData function
parameter formatting) rather than raw `==`/`string.Compare`.
- **StyleCop is enforced** via `Stylecop.Analyzers` with `stylecop.json`. Every source
file starts with the `// Copyright (c) Microsoft Corporation.` / `// Licensed under
the MIT license.` header, `using` directives go **outside** the namespace, and all
public members need XML doc comments (`GenerateDocumentationFile` is on). `SA1101`
(prefix local calls with `this.`) is silenced in `.editorconfig`.
- **Indentation:** 4 spaces for C#, 2 spaces otherwise (`.editorconfig`).
- **Logging:** never `Console.WriteLine` for diagnostics — use
`OutputLogger.Logger?.Log...` with structured message templates.
- **Tests** are xUnit; the src project exposes internals via `InternalsVisibleTo`.
Path-related tests branch on OS (`RuntimeInformation.IsOSPlatform`) because Windows
and Unix produce different relative paths, so keep both `TheoryData` sets in sync.
54 changes: 54 additions & 0 deletions src/Docs/CloudSupportStatusEvaluator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

namespace CheckCloudSupport.Docs;

/// <summary>
/// Contains the decision logic for combining and interpreting
/// <see cref="CloudSupportStatus"/> values across an API document's operations.
/// </summary>
public static class CloudSupportStatusEvaluator
{
/// <summary>
/// Merges an operation's cloud support status into an accumulated status.
/// </summary>
/// <param name="accumulated">The status accumulated so far.</param>
/// <param name="operationStatus">The status of the current operation.</param>
/// <param name="mismatch">
/// Set to <see langword="true"/> when both statuses are known but disagree,
/// in which case they are combined with a bitwise OR. This allows callers to
/// surface a warning about the disagreement.
/// </param>
/// <returns>The merged <see cref="CloudSupportStatus"/>.</returns>
public static CloudSupportStatus Merge(
CloudSupportStatus accumulated,
CloudSupportStatus operationStatus,
out bool mismatch)
{
mismatch = operationStatus != CloudSupportStatus.Unknown &&
accumulated != CloudSupportStatus.Unknown &&
operationStatus != accumulated;

if (mismatch)
{
return accumulated | operationStatus;
}

return operationStatus != CloudSupportStatus.Unknown ? operationStatus : accumulated;
}

/// <summary>
/// Determines whether an API document requires version-pivoted include lines.
/// This is the case when the v1 and beta statuses differ and the v1 status is known.
/// </summary>
/// <param name="v1Status">The cloud support status for the v1 pivot.</param>
/// <param name="betaStatus">The cloud support status for the beta pivot.</param>
/// <returns>
/// <see langword="true"/> if separate v1 and beta include lines are required;
/// otherwise, <see langword="false"/>.
/// </returns>
public static bool RequiresVersionPivot(CloudSupportStatus v1Status, CloudSupportStatus betaStatus)
{
return v1Status != betaStatus && v1Status != CloudSupportStatus.Unknown;
}
}
44 changes: 16 additions & 28 deletions src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,22 +150,18 @@

var supportStatus = operationNode.GetCloudSupportStatus(operation.Method, apiDoc.FilePath);
OutputLogger.Logger?.LogInformation("{path} support status: {status}", operation.Path, supportStatus);
if (supportStatus != CloudSupportStatus.Unknown &&
apiDoc.CloudSupportStatus != CloudSupportStatus.Unknown &&
supportStatus != apiDoc.CloudSupportStatus)

var mergedStatus = CloudSupportStatusEvaluator.Merge(apiDoc.CloudSupportStatus, supportStatus, out var statusMismatch);
if (statusMismatch)
{
OutputLogger.Logger?.LogWarning(
"Mismatched support status in API doc {path}: {newStatus}, {oldStatus}",
apiDoc.FilePath,
supportStatus,
apiDoc.CloudSupportStatus);

apiDoc.CloudSupportStatus |= supportStatus;
}
else
{
apiDoc.CloudSupportStatus = supportStatus != CloudSupportStatus.Unknown ? supportStatus : apiDoc.CloudSupportStatus;
}

apiDoc.CloudSupportStatus = mergedStatus;
}

try
Expand Down Expand Up @@ -329,23 +325,19 @@

var supportStatus = operationNode.GetCloudSupportStatus(operation.Method, apiDoc.FilePath);
OutputLogger.Logger?.LogInformation("{version} {path} support status: {status}", "v1", operation.Path, supportStatus);
if (supportStatus != CloudSupportStatus.Unknown &&
v1CloudSupportStatus != CloudSupportStatus.Unknown &&
supportStatus != v1CloudSupportStatus)

var mergedStatus = CloudSupportStatusEvaluator.Merge(v1CloudSupportStatus, supportStatus, out var statusMismatch);
if (statusMismatch)
{
OutputLogger.Logger?.LogWarning(
"Mismatched {version} support status in API doc {path}: {newStatus}, {oldStatus}",
"v1",
apiDoc.FilePath,
supportStatus,
v1CloudSupportStatus);

v1CloudSupportStatus |= supportStatus;
}
else
{
v1CloudSupportStatus = supportStatus != CloudSupportStatus.Unknown ? supportStatus : v1CloudSupportStatus;
}

v1CloudSupportStatus = mergedStatus;
}

var betaCloudSupportStatus = CloudSupportStatus.Unknown;
Expand All @@ -366,31 +358,27 @@

var supportStatus = operationNode.GetCloudSupportStatus(operation.Method, apiDoc.FilePath);
OutputLogger.Logger?.LogInformation("{version} {path} support status: {status}", "beta", operation.Path, supportStatus);
if (supportStatus != CloudSupportStatus.Unknown &&
betaCloudSupportStatus != CloudSupportStatus.Unknown &&
supportStatus != betaCloudSupportStatus)

var mergedStatus = CloudSupportStatusEvaluator.Merge(betaCloudSupportStatus, supportStatus, out var statusMismatch);
if (statusMismatch)
{
OutputLogger.Logger?.LogWarning(
"Mismatched {version} support status in API doc {path}: {newStatus}, {oldStatus}",
"beta",
apiDoc.FilePath,
supportStatus,
betaCloudSupportStatus);

betaCloudSupportStatus |= supportStatus;
}
else
{
betaCloudSupportStatus = supportStatus != CloudSupportStatus.Unknown ? supportStatus : betaCloudSupportStatus;
}

betaCloudSupportStatus = mergedStatus;
}

if (v1Operations.Count > 0 && v1CloudSupportStatus == CloudSupportStatus.Unknown)
{
OutputLogger.Logger?.LogWarning("Could not determine v1 support status for {doc} - assuming beta status", apiDoc.FilePath);
}

if (v1CloudSupportStatus == betaCloudSupportStatus || v1CloudSupportStatus == CloudSupportStatus.Unknown)
if (!CloudSupportStatusEvaluator.RequiresVersionPivot(v1CloudSupportStatus, betaCloudSupportStatus))
{
apiDoc.CloudSupportStatus = betaCloudSupportStatus;

Expand Down
116 changes: 116 additions & 0 deletions test/ApiDocumentIncludeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using CheckCloudSupport.Docs;

namespace CheckCloudSupportTests;

public class ApiDocumentIncludeTests
{
private const string SourceDoc = "../../../test-data/graph-api.md";

private static string CopyToTemp()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"cloud-support-{Guid.NewGuid():N}.md");
File.Copy(SourceDoc, tempPath, overwrite: true);
return tempPath;
}

[Theory]
[InlineData(CloudSupportStatus.AllClouds, "all-clouds.md")]
[InlineData(CloudSupportStatus.GlobalAndChina, "global-china.md")]
[InlineData(CloudSupportStatus.GlobalAndChinaAndUsGovL4, "global-china-us-l4.md")]
[InlineData(CloudSupportStatus.GlobalAndChinaAndUsGovL5, "global-china-us-l5.md")]
[InlineData(CloudSupportStatus.GlobalAndUSGov, "global-us.md")]
[InlineData(CloudSupportStatus.GlobalAndUsGovL4, "global-us-l4.md")]
[InlineData(CloudSupportStatus.GlobalAndUsGovL5, "global-us-l5.md")]
[InlineData(CloudSupportStatus.Global, "global-only.md")]
public async Task AddOrUpdateIncludeLine_InsertsIncludeForStatus(CloudSupportStatus status, string expectedFile)
{
var tempPath = CopyToTemp();
try
{
var apiDocument = await ApiDocument.CreateFromMarkdownFile(tempPath);
apiDocument.CloudSupportStatus = status;

await apiDocument.AddOrUpdateIncludeLine(removeOldIncludes: false);

var lines = await File.ReadAllLinesAsync(tempPath);
var includeLines = lines.Where(l => l.Contains("[!INCLUDE [national-cloud-support]")).ToList();
Assert.Single(includeLines);
Assert.Contains($"includes/{expectedFile})]", includeLines[0]);
}
finally
{
File.Delete(tempPath);
}
}

[Fact]
public async Task AddOrUpdateIncludeLine_ThrowsForUnmappedStatus()
{
var tempPath = CopyToTemp();
try
{
var apiDocument = await ApiDocument.CreateFromMarkdownFile(tempPath);
apiDocument.CloudSupportStatus = CloudSupportStatus.China;

await Assert.ThrowsAsync<ArgumentException>(
() => apiDocument.AddOrUpdateIncludeLine(removeOldIncludes: false));
}
finally
{
File.Delete(tempPath);
}
}

[Fact]
public async Task AddOrUpdateIncludeLine_UpdatesExistingLineInPlace()
{
var tempPath = CopyToTemp();
try
{
var apiDocument = await ApiDocument.CreateFromMarkdownFile(tempPath);

apiDocument.CloudSupportStatus = CloudSupportStatus.Global;
await apiDocument.AddOrUpdateIncludeLine(removeOldIncludes: false);

apiDocument.CloudSupportStatus = CloudSupportStatus.AllClouds;
await apiDocument.AddOrUpdateIncludeLine(removeOldIncludes: false);

var lines = await File.ReadAllLinesAsync(tempPath);
var includeLines = lines.Where(l => l.Contains("[!INCLUDE [national-cloud-support]")).ToList();
Assert.Single(includeLines);
Assert.Contains("includes/all-clouds.md)]", includeLines[0]);
}
finally
{
File.Delete(tempPath);
}
}

[Fact]
public async Task AddOrUpdatePivotedIncludeLine_WritesZonePivotsAndBothIncludes()
{
var tempPath = CopyToTemp();
var includeDirectory = Path.Combine(Path.GetDirectoryName(tempPath)!, "includes");
try
{
var apiDocument = await ApiDocument.CreateFromMarkdownFile(tempPath);

await apiDocument.AddOrUpdatePivotedIncludeLine(
CloudSupportStatus.Global,
CloudSupportStatus.AllClouds,
includeDirectory);

var lines = await File.ReadAllLinesAsync(tempPath);

Assert.Contains(lines, l => l.StartsWith(":::zone pivot=\"graph-v1\""));
Assert.Contains(lines, l => l.StartsWith(":::zone pivot=\"graph-preview\""));
Assert.Contains(lines, l => l.Contains("[!INCLUDE [version-support-differs]"));
Assert.Contains(lines, l => l.Contains("global-only.md)]"));
Assert.Contains(lines, l => l.Contains("all-clouds.md)]"));
}
finally
{
File.Delete(tempPath);
}
}
}
47 changes: 47 additions & 0 deletions test/ApiOperationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using CheckCloudSupport.Docs;
using Markdig.Helpers;

namespace CheckCloudSupportTests;

public class ApiOperationTests
{
private static ApiOperation? CreateFrom(string text)
{
var slice = new StringSlice(text);
var line = new StringLine(ref slice);
return ApiOperation.CreateFromStringLine(line);
}

[Fact]
public void CreateFromStringLine_ParsesMethodAndNormalizedPath()
{
var operation = CreateFrom("GET https://graph.microsoft.com/v1.0/users/{user-id}/messages/{message-id}");

Assert.NotNull(operation);
Assert.Equal(HttpMethod.Get, operation!.Method);
Assert.Equal("/users/{id}/messages/{id}", operation.Path);
Assert.Equal(ApiVersion.V1, operation.Version);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
public void CreateFromStringLine_ReturnsNullForEmptyLine(string text)
{
Assert.Null(CreateFrom(text));
}

[Fact]
public void CreateFromStringLine_ThrowsWhenNoPath()
{
var ex = Assert.Throws<ArgumentException>(() => CreateFrom("GET"));
Assert.Contains("Invalid line text", ex.Message);
}

[Fact]
public void CreateFromStringLine_ThrowsForInvalidHttpMethod()
{
var ex = Assert.Throws<ArgumentException>(() => CreateFrom("N@T-A-METHOD /me/messages"));
Assert.Contains("Invalid HTTP operation", ex.Message);
}
}
Loading