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
18 changes: 18 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Models/WikiDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Viamus.Azure.Devops.Mcp.Server.Models;

/// <summary>
/// Data transfer object representing an Azure DevOps Wiki.
/// </summary>
public sealed record WikiDto
{
public string? Id { get; init; }
public string? Name { get; init; }
public string? Type { get; init; }
public string? Url { get; init; }
public string? RemoteUrl { get; init; }
public string? ProjectId { get; init; }
public string? ProjectName { get; init; }
public string? RepositoryId { get; init; }
public string? MappedPath { get; init; }
public List<string>? Versions { get; init; }
}
29 changes: 29 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Models/WikiPageDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace Viamus.Azure.Devops.Mcp.Server.Models;

/// <summary>
/// Data transfer object representing an Azure DevOps Wiki page.
/// </summary>
public sealed record WikiPageDto
{
public int? Id { get; init; }
public string? Path { get; init; }
public string? Content { get; init; }
public int? Order { get; init; }
public string? GitItemPath { get; init; }
public string? RemoteUrl { get; init; }
public bool IsParentPage { get; init; }
public List<WikiPageSummaryDto>? SubPages { get; init; }
}

/// <summary>
/// Lightweight summary of a Wiki page for listing operations.
/// </summary>
public sealed record WikiPageSummaryDto
{
public int? Id { get; init; }
public string? Path { get; init; }
public int? Order { get; init; }
public string? GitItemPath { get; init; }
public string? RemoteUrl { get; init; }
public bool IsParentPage { get; init; }
}
172 changes: 172 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Options;
using Microsoft.TeamFoundation.Build.WebApi;
using Microsoft.TeamFoundation.SourceControl.WebApi;
using Microsoft.TeamFoundation.Wiki.WebApi;
using Microsoft.TeamFoundation.WorkItemTracking.WebApi;
using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
using Microsoft.VisualStudio.Services.Common;
Expand All @@ -23,6 +24,7 @@ public sealed class AzureDevOpsService : IAzureDevOpsService, IDisposable
private readonly WorkItemTrackingHttpClient _witClient;
private readonly GitHttpClient _gitClient;
private readonly BuildHttpClient _buildClient;
private readonly WikiHttpClient _wikiClient;
private bool _disposed;

private static readonly string[] DefaultFields =
Expand Down Expand Up @@ -71,6 +73,7 @@ public AzureDevOpsService(IOptions<AzureDevOpsOptions> options, ILogger<AzureDev
_witClient = _connection.GetClient<WorkItemTrackingHttpClient>();
_gitClient = _connection.GetClient<GitHttpClient>();
_buildClient = _connection.GetClient<BuildHttpClient>();
_wikiClient = _connection.GetClient<WikiHttpClient>();

_logger.LogInformation("Azure DevOps service initialized for organization: {OrganizationUrl}", _options.OrganizationUrl);
}
Expand Down Expand Up @@ -1628,13 +1631,182 @@ private static BuildTimelineRecordDto MapToBuildTimelineRecordDto(TimelineRecord

#endregion

#region Wiki Operations

public async Task<IReadOnlyList<WikiDto>> GetWikisAsync(string? project = null, CancellationToken cancellationToken = default)
{
var projectName = project ?? _options.DefaultProject;

_logger.LogInformation("Getting wikis for project: {Project}", projectName ?? "(all)");

try
{
var wikis = await _wikiClient.GetAllWikisAsync(project: projectName, cancellationToken: cancellationToken);

_logger.LogInformation("Found {Count} wikis", wikis.Count);

return wikis.Select(MapToWikiDto).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting wikis for project: {Project}", projectName);
throw;
}
}

public async Task<WikiDto?> GetWikiAsync(string wikiIdentifier, string? project = null, CancellationToken cancellationToken = default)
{
var projectName = project ?? _options.DefaultProject;

_logger.LogInformation("Getting wiki: {WikiIdentifier} in project: {Project}", wikiIdentifier, projectName ?? "(default)");

try
{
var wiki = await _wikiClient.GetWikiAsync(project: projectName, wikiIdentifier: wikiIdentifier, cancellationToken: cancellationToken);
return MapToWikiDto(wiki);
}
catch (Exception ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Wiki '{WikiIdentifier}' not found", wikiIdentifier);
return null;
}
}

public async Task<WikiPageDto?> GetWikiPageAsync(
string wikiIdentifier,
string path,
bool includeContent = true,
string? version = null,
string? project = null,
CancellationToken cancellationToken = default)
{
var projectName = project ?? _options.DefaultProject;

_logger.LogInformation("Getting wiki page: {Path} from wiki: {WikiIdentifier}", path, wikiIdentifier);

try
{
var versionDescriptor = !string.IsNullOrWhiteSpace(version)
? new GitVersionDescriptor { Version = version, VersionType = GitVersionType.Branch }
: null;

var page = await _wikiClient.GetPageAsync(
project: projectName,
wikiIdentifier: wikiIdentifier,
path: path,
recursionLevel: VersionControlRecursionType.None,
versionDescriptor: versionDescriptor,
includeContent: includeContent,
cancellationToken: cancellationToken);

return MapToWikiPageDto(page.Page, page.Page.Content);
}
catch (Exception ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Wiki page '{Path}' not found in wiki '{WikiIdentifier}'", path, wikiIdentifier);
return null;
}
}

public async Task<WikiPageDto?> GetWikiPageTreeAsync(
string wikiIdentifier,
string path = "/",
string recursionLevel = "OneLevel",
string? project = null,
CancellationToken cancellationToken = default)
{
var projectName = project ?? _options.DefaultProject;

_logger.LogInformation("Getting wiki page tree: {Path} from wiki: {WikiIdentifier} with recursion: {Recursion}", path, wikiIdentifier, recursionLevel);

try
{
var recursion = recursionLevel.Equals("Full", StringComparison.OrdinalIgnoreCase)
? VersionControlRecursionType.Full
: VersionControlRecursionType.OneLevel;

var page = await _wikiClient.GetPageAsync(
project: projectName,
wikiIdentifier: wikiIdentifier,
path: path,
recursionLevel: recursion,
includeContent: false,
cancellationToken: cancellationToken);

return MapToWikiPageDtoWithSubPages(page.Page);
}
catch (Exception ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Wiki page '{Path}' not found in wiki '{WikiIdentifier}'", path, wikiIdentifier);
return null;
}
}

private static WikiDto MapToWikiDto(WikiV2 wiki)
{
return new WikiDto
{
Id = wiki.Id.ToString(),
Name = wiki.Name,
Type = wiki.Type.ToString(),
Url = wiki.Url,
RemoteUrl = wiki.RemoteUrl,
ProjectId = wiki.ProjectId.ToString(),
RepositoryId = wiki.RepositoryId.ToString(),
MappedPath = wiki.MappedPath,
Versions = wiki.Versions?.Select(v => v.Version).ToList()
};
}

private static WikiPageDto MapToWikiPageDto(WikiPage page, string? content = null)
{
return new WikiPageDto
{
Id = page.Id,
Path = page.Path,
Content = content ?? page.Content,
Order = page.Order,
GitItemPath = page.GitItemPath,
RemoteUrl = page.RemoteUrl,
IsParentPage = page.IsParentPage
};
}

private static WikiPageDto MapToWikiPageDtoWithSubPages(WikiPage page)
{
return new WikiPageDto
{
Id = page.Id,
Path = page.Path,
Order = page.Order,
GitItemPath = page.GitItemPath,
RemoteUrl = page.RemoteUrl,
IsParentPage = page.IsParentPage,
SubPages = page.SubPages?.Select(sp => new WikiPageSummaryDto
{
Id = sp.Id,
Path = sp.Path,
Order = sp.Order,
GitItemPath = sp.GitItemPath,
RemoteUrl = sp.RemoteUrl,
IsParentPage = sp.IsParentPage
}).ToList()
};
}

#endregion

public void Dispose()
{
if (_disposed) return;

_witClient.Dispose();
_gitClient.Dispose();
_buildClient.Dispose();
_wikiClient.Dispose();
_connection.Dispose();
_disposed = true;
}
Expand Down
55 changes: 55 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -418,4 +418,59 @@ Task<IReadOnlyList<BuildTimelineRecordDto>> GetBuildTimelineAsync(
CancellationToken cancellationToken = default);

#endregion

#region Wiki Operations

/// <summary>
/// Gets all wikis in a project.
/// </summary>
/// <param name="project">The project name (optional if default project is configured).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of wikis.</returns>
Task<IReadOnlyList<WikiDto>> GetWikisAsync(string? project = null, CancellationToken cancellationToken = default);

/// <summary>
/// Gets a specific wiki by its identifier (name or ID).
/// </summary>
/// <param name="wikiIdentifier">The wiki name or ID.</param>
/// <param name="project">The project name (optional if default project is configured).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The wiki details.</returns>
Task<WikiDto?> GetWikiAsync(string wikiIdentifier, string? project = null, CancellationToken cancellationToken = default);

/// <summary>
/// Gets a wiki page by path, including its content.
/// </summary>
/// <param name="wikiIdentifier">The wiki name or ID.</param>
/// <param name="path">The page path (e.g., "/Home", "/Getting-Started/Installation").</param>
/// <param name="includeContent">Whether to include the page content (Markdown).</param>
/// <param name="version">Optional version/branch of the wiki.</param>
/// <param name="project">The project name (optional if default project is configured).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The wiki page details.</returns>
Task<WikiPageDto?> GetWikiPageAsync(
string wikiIdentifier,
string path,
bool includeContent = true,
string? version = null,
string? project = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Gets the sub-pages of a wiki page (page tree).
/// </summary>
/// <param name="wikiIdentifier">The wiki name or ID.</param>
/// <param name="path">The parent page path (default is root "/").</param>
/// <param name="recursionLevel">How deep to recurse: "OneLevel" or "Full".</param>
/// <param name="project">The project name (optional if default project is configured).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The wiki page with its sub-pages.</returns>
Task<WikiPageDto?> GetWikiPageTreeAsync(
string wikiIdentifier,
string path = "/",
string recursionLevel = "OneLevel",
string? project = null,
CancellationToken cancellationToken = default);

#endregion
}
Loading
Loading