Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add nuget package status indicator proof-of-concept #8539

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information.

using System.Text.RegularExpressions;
using System.Threading.Tasks.Dataflow;
using System.Timers;
using Microsoft.VisualStudio.Collections;
using Microsoft.VisualStudio.ProjectSystem.Tree.Dependencies.Models;

namespace Microsoft.VisualStudio.ProjectSystem.Tree.Dependencies;

[Export(typeof(IDependencyNugetUpdateBlock))]
internal class DependencyNugetUpdateBlock: ProjectValueDataSourceBase<Dictionary<string, DiagnosticLevel>>, IDependencyNugetUpdateBlock
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
internal class DependencyNugetUpdateBlock: ProjectValueDataSourceBase<Dictionary<string, DiagnosticLevel>>, IDependencyNugetUpdateBlock
internal class DependencyNuGetUpdateBlock : ProjectValueDataSourceBase<Dictionary<string, DiagnosticLevel>>, IDependencyNugetUpdateBlock

{
private int _sourceVersion;

private IBroadcastBlock<IProjectVersionedValue<Dictionary<string, DiagnosticLevel>>> _broadcastBlock = null!;

private IReceivableSourceBlock<IProjectVersionedValue<Dictionary<string, DiagnosticLevel>>> _publicBlock = null!;

private Dictionary<string, DiagnosticLevel>? _lastPublishedValue;

public override NamedIdentity DataSourceKey { get; } = new(nameof(DependencyNugetUpdateBlock));

public override IComparable DataSourceVersion => _sourceVersion;

[ImportingConstructor]
public DependencyNugetUpdateBlock(UnconfiguredProject unconfiguredProject)
: base(unconfiguredProject.Services, synchronousDisposal: false, registerDataSource: false)
{
}

public override IReceivableSourceBlock<IProjectVersionedValue<Dictionary<string, DiagnosticLevel>>> SourceBlock
{
get
{
EnsureInitialized();
return _publicBlock;
}
}

protected override void Initialize()
{
#pragma warning disable RS0030
base.Initialize();
#pragma warning restore RS0030

_broadcastBlock = DataflowBlockSlim.CreateBroadcastBlock<IProjectVersionedValue<Dictionary<string, DiagnosticLevel>>>(nameFormat: $"{nameof(DependencyNugetUpdateBlock)} {1}");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_broadcastBlock = DataflowBlockSlim.CreateBroadcastBlock<IProjectVersionedValue<Dictionary<string, DiagnosticLevel>>>(nameFormat: $"{nameof(DependencyNugetUpdateBlock)} {1}");
_broadcastBlock = DataflowBlockSlim.CreateBroadcastBlock<IProjectVersionedValue<Dictionary<string, DiagnosticLevel>>>(nameFormat: $"{nameof(DependencyNugetUpdateBlock)} {{1}}");


_publicBlock = _broadcastBlock.SafePublicize();

PostNewValue(GetNewValue()); // TODO currently blocks receiving dependency model to make initial request

var timer = new System.Timers.Timer();
timer.Elapsed += OnRefreshDependencyStatus;
timer.Interval = TimeSpan.FromMinutes(15).TotalMilliseconds;
timer.Start();
}

private void OnRefreshDependencyStatus(object sender, ElapsedEventArgs elapsedEventArgs)
{
PostNewValue(GetNewValue());
}

private string RunCommandSynchronouslyAndReceiveOutput(string command)
{
var process = new System.Diagnostics.Process();
var startInfo = new System.Diagnostics.ProcessStartInfo
{
WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden,
FileName = "cmd.exe",
Arguments = $"/C {command}",
RedirectStandardOutput = true,
UseShellExecute = false
};

process.StartInfo = startInfo;
process.Start();
string output = process.StandardOutput.ReadToEnd();
process.WaitForExit();

return output;
}

private Dictionary<string, DiagnosticLevel> GetNewValue()
{
Dictionary<string, DiagnosticLevel> packageDiagnosticLevels = new();

string dotnetListVulnerableCommandOutput = RunCommandSynchronouslyAndReceiveOutput("dotnet list package --vulnerable");
string dotnetListOutdatedCommandOutput = RunCommandSynchronouslyAndReceiveOutput("dotnet list package --outdated");
string dotnetListDeprecatedCommandOutput = RunCommandSynchronouslyAndReceiveOutput("dotnet list package --deprecated");

foreach (Match match in Regex.Matches(dotnetListVulnerableCommandOutput, "> ([^\\s]+)\\s+"))
{
AddPackageIfLevelHasPriority(match.Groups[1].Value, DiagnosticLevel.Vulnerability);
}

foreach (Match match in Regex.Matches(dotnetListOutdatedCommandOutput, "> ([^\\s]+)\\s+"))
{
AddPackageIfLevelHasPriority(match.Groups[1].Value, DiagnosticLevel.UpgradeAvailable);
}

foreach (Match match in Regex.Matches(dotnetListDeprecatedCommandOutput, "> ([^\\s]+)\\s+"))
{
AddPackageIfLevelHasPriority(match.Groups[1].Value, DiagnosticLevel.Deprecation);
}

void AddPackageIfLevelHasPriority(string package, DiagnosticLevel level)
{
if (!packageDiagnosticLevels.TryGetValue(package, out DiagnosticLevel existingValue) || existingValue < level)
{
packageDiagnosticLevels[package] = level;
}
}

return packageDiagnosticLevels;
}

private void PostNewValue(Dictionary<string, DiagnosticLevel> newValue)
{
// Add thread safety as needed. Make sure to never regress the data source version published
if (!DictionaryEqualityComparer<string, DiagnosticLevel>.Instance.Equals(newValue, _lastPublishedValue)) // only publish if you have to
{
_lastPublishedValue = newValue;
_broadcastBlock.Post(
new ProjectVersionedValue<Dictionary<string, DiagnosticLevel>>(
newValue,
ImmutableDictionary.Create<NamedIdentity, IComparable>().Add(
DataSourceKey,
_sourceVersion++)));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,16 @@ public IDependencyViewModel CreateTargetViewModel(TargetFramework targetFramewor

public ImageMoniker GetDependenciesRootIcon(DiagnosticLevel maximumDiagnosticLevel)
{
// TODO update upgradeavailable/deprecation/vulnerability icons
return maximumDiagnosticLevel switch
{
DiagnosticLevel.None => KnownMonikers.ReferenceGroup,
_ => KnownMonikers.ReferenceGroupWarning
DiagnosticLevel.UpgradeAvailable => KnownMonikers.OfficeWord2013,
DiagnosticLevel.Warning => KnownMonikers.ReferenceGroupWarning,
DiagnosticLevel.Deprecation => KnownMonikers.OfficeSharePoint2013,
DiagnosticLevel.Error => KnownMonikers.ReferenceGroupError,
DiagnosticLevel.Vulnerability => KnownMonikers.OfficeExcel2013,
_ => throw new ArgumentOutOfRangeException()
};
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ internal enum DiagnosticLevel
// These states are in precedence order, where later states override earlier ones.

None = 0,
Warning = 1,
Error = 2,
UpgradeAvailable = 1,
Warning = 2,
Deprecation = 3,
Error = 4,
Vulnerability = 5
}

/// <summary>
Expand Down Expand Up @@ -63,8 +66,11 @@ protected DependencyModel(
{
diagnosticLevel = levelString switch
{
"Warning" => DiagnosticLevel.Warning,
"Error" => DiagnosticLevel.Error,
nameof(DiagnosticLevel.Warning) => DiagnosticLevel.Warning,
nameof(DiagnosticLevel.Error) => DiagnosticLevel.Error,
nameof(DiagnosticLevel.UpgradeAvailable) => DiagnosticLevel.UpgradeAvailable,
nameof(DiagnosticLevel.Deprecation) => DiagnosticLevel.Deprecation,
nameof(DiagnosticLevel.Vulnerability) => DiagnosticLevel.Vulnerability,
Comment on lines +69 to +73
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about replacing the string literals with nameof. The strings are defined in MSBuild data. The C# enum is not explicitly tied to that. In some cases we have XAML rules with generated classes that provide constants for these values, but that's not the case here.

Consider in future that someone renames an enum member. It's not obvious that such a change would break our binding to the MSBuild item metadata contract.

_ => DiagnosticLevel.None
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information.

using Microsoft.VisualStudio.Buffers.PooledObjects;
using Microsoft.VisualStudio.Imaging;
using Microsoft.VisualStudio.Imaging.Interop;
using Microsoft.VisualStudio.ProjectSystem.Tree.Dependencies.Models;
using Microsoft.VisualStudio.ProjectSystem.VS.Tree.Dependencies;
Expand Down Expand Up @@ -122,8 +123,70 @@ private Dependency(

public string? FilePath { get; }

public ImageMoniker Icon => DiagnosticLevel == DiagnosticLevel.None ? Implicit ? IconSet.ImplicitIcon : IconSet.Icon : IconSet.UnresolvedIcon;
public ImageMoniker ExpandedIcon => DiagnosticLevel == DiagnosticLevel.None ? Implicit ? IconSet.ImplicitExpandedIcon : IconSet.ExpandedIcon : IconSet.UnresolvedExpandedIcon;
public ImageMoniker Icon
{
get
{
if (DiagnosticLevel == DiagnosticLevel.None)
{
if (Implicit)
{
return IconSet.ImplicitIcon;
}

return IconSet.Icon;
}

switch (DiagnosticLevel)
{
case DiagnosticLevel.UpgradeAvailable:
return KnownMonikers.OfficeWord2013;
case DiagnosticLevel.Warning:
return IconSet.UnresolvedIcon;
case DiagnosticLevel.Deprecation:
return KnownMonikers.OfficeSharePoint2013;
case DiagnosticLevel.Error:
return IconSet.UnresolvedIcon;
case DiagnosticLevel.Vulnerability:
return KnownMonikers.OfficeExcel2013;
default:
throw new ArgumentOutOfRangeException();
}
Comment on lines +140 to +154
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear (I know this is a draft) but we should consider adding unresolved/deprecation/vulnerability icon members to IconSet. The Dependency class applies to all kinds of dependencies, not just packages. If another dependency type used one of these new diagnostic levels, we'd end up showing a NuGet package icon for it if we hard coded them here.

This comment is based on the assumption that we end up with package reference icons with new overlays for the three new states.

For non-package dependencies we would likely re-use the warning/error icons for those diagnostic levels, as we don't expect to display them and therefore wouldn't ask the design team to produce such icons for us.

Brief history detour: These icons used to separate the base icon from the overlay. However in 17.0 the design team wanted finer control over how the overlay appeared, and so baked a flattened form of each combination into its own icon.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@drewnoakes so what exactly would be requested to be created of the design team? Just the overlays with no nuget package icon?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For each new overlay, you'd need a version of it displayed on top of the NuGet icon. That is, baked into a single image for each overlay.

}
}

public ImageMoniker ExpandedIcon
{
get
{
if (DiagnosticLevel == DiagnosticLevel.None)
{
if (Implicit)
{
return IconSet.ImplicitExpandedIcon;
}

return IconSet.ExpandedIcon;
}

// TODO update upgradeavailable/deprecation/vulnerability icons
switch (DiagnosticLevel)
{
case DiagnosticLevel.UpgradeAvailable:
return KnownMonikers.OfficeWord2013;
case DiagnosticLevel.Warning:
return IconSet.UnresolvedIcon;
case DiagnosticLevel.Deprecation:
return KnownMonikers.OfficeSharePoint2013;
case DiagnosticLevel.Error:
return IconSet.UnresolvedExpandedIcon;
case DiagnosticLevel.Vulnerability:
return KnownMonikers.OfficeExcel2013;
default:
throw new ArgumentOutOfRangeException();
}
}
}

#endregion

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information.

using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks.Dataflow;
using Microsoft.VisualStudio.Buffers.PooledObjects;
using Microsoft.VisualStudio.Imaging;
using Microsoft.VisualStudio.ProjectSystem.Properties;
Expand Down Expand Up @@ -30,12 +31,14 @@ internal sealed class PackageRuleHandler : DependenciesRuleHandlerBase
DependencyTreeFlags.PackageDependencyGroup);

private readonly ITargetFrameworkProvider _targetFrameworkProvider;
private readonly IDependencyNugetUpdateBlock _dependencyNugetUpdateBlock;

[ImportingConstructor]
public PackageRuleHandler(ITargetFrameworkProvider targetFrameworkProvider)
public PackageRuleHandler(ITargetFrameworkProvider targetFrameworkProvider, IDependencyNugetUpdateBlock dependencyNugetUpdateBlock)
: base(PackageReference.SchemaName, ResolvedPackageReference.SchemaName)
{
_targetFrameworkProvider = targetFrameworkProvider;
_dependencyNugetUpdateBlock = dependencyNugetUpdateBlock;
}

public override string ProviderType => ProviderTypeString;
Expand Down Expand Up @@ -193,6 +196,17 @@ private bool TryCreatePackageDependencyModel(
return false;
}

DiagnosticLevel diagnosticLevel = properties.TryGetValue(ProjectItemMetadata.DiagnosticLevel, out string levelString)
? Enum.TryParse(levelString, out DiagnosticLevel level) ? level : DiagnosticLevel.None
: DiagnosticLevel.None;
Dictionary<string, DiagnosticLevel> packageDiagnosticLevels = _dependencyNugetUpdateBlock.SourceBlock.Receive().Value;

if (packageDiagnosticLevels.TryGetValue(originalItemSpec, out DiagnosticLevel foundLevel) && foundLevel > diagnosticLevel)
{
properties = properties.SetItem(ProjectItemMetadata.DiagnosticLevel, foundLevel.ToString());
}


bool isImplicit = IsImplicit(projectFullPath, evaluationProperties);

// When we only have evaluation data, mark the dependency as resolved if we currently have a corresponding resolved item
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information.

using Microsoft.VisualStudio.Composition;
using Microsoft.VisualStudio.ProjectSystem.Tree.Dependencies.Models;

namespace Microsoft.VisualStudio.ProjectSystem.Tree;

[ProjectSystemContract(ProjectSystemContractScope.UnconfiguredProject, ProjectSystemContractProvider.Private, Cardinality = ImportCardinality.ExactlyOne)]
internal interface IDependencyNugetUpdateBlock : IProjectValueDataSource<Dictionary<string, DiagnosticLevel>>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
internal interface IDependencyNugetUpdateBlock : IProjectValueDataSource<Dictionary<string, DiagnosticLevel>>
internal interface IDependencyNuGetUpdateBlock : IProjectValueDataSource<Dictionary<string, DiagnosticLevel>>

{
}