Skip to content

Commit 6c8cbdd

Browse files
igoravlCopilotCopilot
authored
Add Azure Login support and consolidate release notes (#254)
* Add Azure Login support with automatic token renewal and update help text * Delete outdated release notes files and consolidate changelog into a single file following Keep a Changelog format. * Update CSharp/TfsCmdlets/Services/AzureCredential.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Tighten version match and add failure check in release notes extraction Agent-Logs-Url: https://github.com/igoravl/TfsCmdlets/sessions/c5d69aaa-749e-42a7-a9df-e92fea57b5fd Co-authored-by: igoravl <725797+igoravl@users.noreply.github.com> * Refactor PowerShell command paths in VSCode tasks to use relative workspace folder +semver:minor * Update Microsoft.PowerShell.SDK package version to 7.0.10 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: igoravl <725797+igoravl@users.noreply.github.com>
1 parent 25c9841 commit 6c8cbdd

58 files changed

Lines changed: 604 additions & 1005 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/main.yml

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ jobs:
7777
uses: actions/upload-artifact@v4
7878
with:
7979
name: releasenotes
80-
path: "docs/ReleaseNotes/**"
80+
path: "RELEASENOTES.md"
8181
outputs:
8282
BUILD_NAME: ${{ steps.build_module.outputs.BUILD_NAME }}
8383

@@ -96,9 +96,27 @@ jobs:
9696
id: extract_release_notes
9797
shell: pwsh
9898
run: |
99-
$fileName = (Get-ChildItem [0-9]*.md -Recurse | Sort-Object Name | Select -ExpandProperty FullName -Last 1)
100-
Write-Output $fileName
101-
$releaseNotes = (Get-Content $fileName -Encoding UTF8 | Select-Object -Skip 4)
99+
$lines = Get-Content releasenotes/RELEASENOTES.md -Encoding UTF8
100+
$version = $env:BUILD_NAME -replace '\+.*$' # strip build metadata
101+
$collecting = $false
102+
$notes = @()
103+
foreach ($line in $lines) {
104+
if ($line -match "^## \[$([regex]::Escape($version))\]( - \d{4}-\d{2}-\d{2})?$") {
105+
$collecting = $true
106+
continue
107+
}
108+
if ($collecting -and $line -match '^## ') {
109+
break
110+
}
111+
if ($collecting) {
112+
$notes += $line
113+
}
114+
}
115+
$releaseNotes = ($notes -join "`n").Trim()
116+
if (-not $releaseNotes) {
117+
Write-Error "No release notes found for version '$version' in RELEASENOTES.md"
118+
exit 1
119+
}
102120
Write-Output $releaseNotes
103121
Write-Output 'RELEASE_NOTES<<EOF' >> $env:GITHUB_OUTPUT
104122
Write-Output $releaseNotes >> $env:GITHUB_OUTPUT

.vscode/tasks.json

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
{
22
"version": "2.0.0",
3-
"command": "powershell",
43
"tasks": [
54
{
65
"label": "Build",
7-
"command": "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
6+
"command": "pwsh",
87
"group": {
98
"kind": "build",
109
"isDefault": true
@@ -22,7 +21,7 @@
2221
"Unrestricted",
2322
"-NoProfile",
2423
"-File",
25-
"${workspaceRoot}/build.ps1",
24+
"${workspaceFolder}/Build.ps1",
2625
"-Configuration",
2726
"Debug",
2827
"-Targets",
@@ -35,7 +34,7 @@
3534
},
3635
{
3736
"label": "Rebuild",
38-
"command": "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
37+
"command": "pwsh",
3938
"presentation": {
4039
"echo": true,
4140
"reveal": "always",
@@ -49,7 +48,7 @@
4948
"Unrestricted",
5049
"-NoProfile",
5150
"-File",
52-
"${workspaceRoot}/build.ps1",
51+
"${workspaceFolder}/Build.ps1",
5352
"-Configuration",
5453
"Debug",
5554
"-Targets",
@@ -62,7 +61,7 @@
6261
},
6362
{
6463
"label": "Clean",
65-
"command": "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
64+
"command": "pwsh",
6665
"presentation": {
6766
"echo": true,
6867
"reveal": "always",
@@ -76,7 +75,7 @@
7675
"Unrestricted",
7776
"-NoProfile",
7877
"-File",
79-
"${workspaceRoot}/build.ps1",
78+
"${workspaceFolder}/Build.ps1",
8079
"-Configuration",
8180
"Debug",
8281
"-Targets",
@@ -86,7 +85,7 @@
8685
},
8786
{
8887
"label": "Package",
89-
"command": "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
88+
"command": "pwsh",
9089
"group": "none",
9190
"presentation": {
9291
"echo": true,
@@ -101,7 +100,7 @@
101100
"Unrestricted",
102101
"-NoProfile",
103102
"-File",
104-
"${workspaceRoot}/build.ps1",
103+
"${workspaceFolder}/Build.ps1",
105104
"-Configuration",
106105
"Release",
107106
"-SkipTests",
@@ -114,7 +113,7 @@
114113
},
115114
{
116115
"label": "Test",
117-
"command": "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
116+
"command": "pwsh",
118117
"group": "test",
119118
"presentation": {
120119
"echo": true,
@@ -129,7 +128,7 @@
129128
"Unrestricted",
130129
"-NoProfile",
131130
"-File",
132-
"${workspaceRoot}/build.ps1",
131+
"${workspaceFolder}/Build.ps1",
133132
"-Configuration",
134133
"Debug",
135134
"-Targets",
@@ -138,7 +137,7 @@
138137
},
139138
{
140139
"label": "Docs",
141-
"command": "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
140+
"command": "pwsh",
142141
"presentation": {
143142
"echo": true,
144143
"reveal": "always",
@@ -152,7 +151,7 @@
152151
"Unrestricted",
153152
"-NoProfile",
154153
"-File",
155-
"${workspaceRoot}/build.ps1",
154+
"${workspaceFolder}/Build.ps1",
156155
"-Configuration",
157156
"Debug",
158157
"-Targets",

CSharp/TfsCmdlets.SourceGenerators/Generators/Cmdlets/CmdletInfo.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ private static IEnumerable<GeneratedProperty> GenerateCredentialProperties(Cmdle
150150
("Alias", "\"Pat\"")}, "HELP_PARAM_PERSONAL_ACCESS_TOKEN");
151151

152152
yield return GenerateParameter("Interactive", "SwitchParameter", "ParameterSetName = \"Prompt for credential\"", "HELP_PARAM_INTERACTIVE");
153+
154+
yield return GenerateParameter("AzureLogin", "SwitchParameter", "ParameterSetName = \"Azure Login\"", "HELP_PARAM_AZURE_LOGIN");
153155
}
154156

155157
private static IEnumerable<GeneratedProperty> GenerateCustomControllerProperty(CmdletInfo settings)
@@ -208,7 +210,7 @@ private static bool IsPipelineProperty(CmdletScope currentScope, CmdletInfo cmdl
208210
private static readonly string[] _scopeNames = new[]{
209211
"ConfigurationServer", "Organization", "TeamProjectCollection", "TeamProject", "Team" };
210212
private static readonly string[] _credentialParameterSetNames = new[]{
211-
"Cached credentials", "User name and password", "Credential object", "Personal Access Token", "Prompt for credential" };
213+
"Cached credentials", "User name and password", "Credential object", "Personal Access Token", "Prompt for credential", "Azure Login" };
212214

213215

214216
private static readonly List<(Predicate<CmdletInfo>, Func<CmdletInfo, IEnumerable<GeneratedProperty>>, string)> _generators =

CSharp/TfsCmdlets.Tests.UnitTests/TfsCmdlets.Tests.UnitTests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1919
<PrivateAssets>all</PrivateAssets>
2020
</PackageReference>
21-
<PackageReference Include="Microsoft.PowerShell.SDK" Version="7.0.0" />
21+
<PackageReference Include="Microsoft.PowerShell.SDK" Version="7.0.10" />
2222
<ProjectReference Include="..\TfsCmdlets.SourceGenerators\TfsCmdlets.SourceGenerators.csproj" />
2323
<ProjectReference Include="..\TfsCmdlets\TfsCmdlets.csproj" />
2424
</ItemGroup>

CSharp/TfsCmdlets/Cmdlets/Credential/NewCredential.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ partial class GetCredentialController
2727
[Import]
2828
private IInteractiveAuthentication InteractiveAuthentication { get; }
2929

30+
[Import]
31+
private ICurrentConnections CurrentConnections { get; }
32+
3033
protected override IEnumerable Run()
3134
{
3235
var connectionMode = ConnectionMode.CachedCredentials;
@@ -39,6 +42,8 @@ protected override IEnumerable Run()
3942
connectionMode = ConnectionMode.AccessToken;
4043
else if (Interactive)
4144
connectionMode = ConnectionMode.Interactive;
45+
else if (Has_AzureLogin && AzureLogin)
46+
connectionMode = ConnectionMode.AzureLogin;
4247

4348
NetworkCredential netCred = null;
4449

@@ -124,6 +129,17 @@ protected override IEnumerable Run()
124129
throw new Exception("Interactive authentication is not supported for TFS / Azure DevOps Server in PowerShell Core. Please use either a username/password credential or a Personal Access Token.");
125130
}
126131

132+
case ConnectionMode.AzureLogin:
133+
{
134+
Logger.Log("Using Azure Login credentials (DefaultAzureCredential)");
135+
136+
var azureCredential = new AzureCredential();
137+
CurrentConnections.AzureCredential = azureCredential;
138+
139+
yield return azureCredential.CreateVssCredentials();
140+
yield break;
141+
}
142+
127143
default:
128144
{
129145
throw new Exception($"Invalid parameter set '{connectionMode}'");
@@ -150,7 +166,8 @@ private enum ConnectionMode
150166
CredentialObject,
151167
UserNamePassword,
152168
AccessToken,
153-
Interactive
169+
Interactive,
170+
AzureLogin
154171
}
155172
}
156173
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
using Azure.Core;
2+
using Azure.Identity;
3+
using Microsoft.VisualStudio.Services.Common;
4+
using Microsoft.VisualStudio.Services.OAuth;
5+
6+
namespace TfsCmdlets.Services
7+
{
8+
/// <summary>
9+
/// Helper class that wraps Azure.Identity's DefaultAzureCredential to obtain and
10+
/// automatically renew Azure DevOps access tokens.
11+
/// </summary>
12+
/// <remarks>
13+
/// Tokens issued by Azure AD for Azure DevOps have a short lifetime (~1 hour).
14+
/// This class transparently refreshes the token before it expires by checking expiry
15+
/// when <see cref="GetValidToken"/> is called. The underlying <see cref="DefaultAzureCredential"/>
16+
/// chains multiple credential sources (Environment, Managed Identity, Azure CLI,
17+
/// Visual Studio, etc.) so the caller only needs to be authenticated in one of them.
18+
/// </remarks>
19+
public sealed class AzureCredential
20+
{
21+
// Azure DevOps resource ID used as the token audience
22+
private const string AzureDevOpsScope = "499b84ac-1321-427f-aa17-267ca6975798/.default";
23+
24+
// Refresh the token when it is within this margin of expiry
25+
private static readonly TimeSpan RefreshMargin = TimeSpan.FromMinutes(5);
26+
27+
private readonly TokenCredential _tokenCredential;
28+
private AccessToken _cachedToken;
29+
private readonly object _lock = new object();
30+
31+
/// <summary>
32+
/// Initializes a new instance using the default Azure credential chain.
33+
/// </summary>
34+
public AzureCredential()
35+
: this(new DefaultAzureCredential(
36+
new DefaultAzureCredentialOptions
37+
{
38+
// Disable interactive browser auth to avoid hangs in unattended (CI/CD) scenarios.
39+
ExcludeInteractiveBrowserCredential = true
40+
}))
41+
{
42+
}
43+
44+
/// <summary>
45+
/// Initializes a new instance using the specified Azure <see cref="TokenCredential"/>.
46+
/// </summary>
47+
public AzureCredential(TokenCredential tokenCredential)
48+
{
49+
_tokenCredential = tokenCredential ?? throw new ArgumentNullException(nameof(tokenCredential));
50+
51+
// Eagerly acquire the first token to fail fast on auth errors
52+
_cachedToken = _tokenCredential.GetToken(
53+
new TokenRequestContext(new[] { AzureDevOpsScope }),
54+
default);
55+
}
56+
57+
/// <summary>
58+
/// Returns true if the cached token is expired or near expiry.
59+
/// </summary>
60+
public bool IsTokenExpired
61+
{
62+
get
63+
{
64+
lock (_lock)
65+
{
66+
return _cachedToken.ExpiresOn <= DateTimeOffset.UtcNow.Add(RefreshMargin);
67+
}
68+
}
69+
}
70+
71+
/// <summary>
72+
/// Gets a current, valid access token, refreshing from Azure if the cached token
73+
/// is expired or near expiry.
74+
/// </summary>
75+
public string GetValidToken()
76+
{
77+
lock (_lock)
78+
{
79+
if (_cachedToken.ExpiresOn <= DateTimeOffset.UtcNow.Add(RefreshMargin))
80+
{
81+
_cachedToken = _tokenCredential.GetToken(
82+
new TokenRequestContext(new[] { AzureDevOpsScope }),
83+
default);
84+
}
85+
86+
return _cachedToken.Token;
87+
}
88+
}
89+
90+
/// <summary>
91+
/// Creates a new <see cref="VssCredentials"/> instance using a valid (potentially refreshed) token.
92+
/// Each call returns a new instance with a fresh token.
93+
/// </summary>
94+
public VssCredentials CreateVssCredentials()
95+
{
96+
return new VssCredentials(new VssOAuthAccessTokenCredential(GetValidToken()));
97+
}
98+
}
99+
}

CSharp/TfsCmdlets/Services/ICurrentConnections.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ public interface ICurrentConnections
1212

1313
Models.Team Team { get; set; }
1414

15+
/// <summary>
16+
/// Stores the AzureCredential used for Azure Login authentication,
17+
/// enabling automatic token renewal for long-lived sessions.
18+
/// </summary>
19+
AzureCredential AzureCredential { get; set; }
20+
1521
T Get<T>(string name);
1622

1723
object Get(string name);

CSharp/TfsCmdlets/Services/Impl/CurrentConnectionsImpl.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ public class CurrentConnectionsImpl: ICurrentConnections
1414

1515
public Models.Team Team {get;set;}
1616

17+
public AzureCredential AzureCredential {get;set;}
18+
1719
public T Get<T>(string name)
1820
{
1921
return (T) Get(name);
@@ -37,6 +39,7 @@ public void Reset()
3739
Collection = null;
3840
Project = null;
3941
Team = null;
42+
AzureCredential = null;
4043
}
4144

4245
public void Set(Connection server)

CSharp/TfsCmdlets/Services/Impl/DataManagerImpl.cs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,40 @@ private Connection CreateConnection(ClientScope scope, object overridingParamete
251251
case null:
252252
{
253253
Logger.Log($"Get currently connected {scope}");
254-
result = ((Connection)CurrentConnections.Get(scope.ToString()))?.InnerObject;
254+
255+
var cachedConnection = (Connection)CurrentConnections.Get(scope.ToString());
256+
257+
// If using Azure Login and the token is expired, rebuild the connection
258+
// with a fresh token from DefaultAzureCredential
259+
if (cachedConnection != null && CurrentConnections.AzureCredential is { } azCred && azCred.IsTokenExpired)
260+
{
261+
Logger.Log("Azure Login token expired, refreshing...");
262+
263+
var freshCreds = azCred.CreateVssCredentials();
264+
#if NETCOREAPP3_1_OR_GREATER
265+
var refreshedConn = new Microsoft.VisualStudio.Services.WebApi.VssConnection(
266+
cachedConnection.Uri, freshCreds);
267+
#else
268+
AdoConnection refreshedConn = scope == ClientScope.Collection
269+
? (AdoConnection)TfsTeamProjectCollectionFactory.GetTeamProjectCollection(cachedConnection.Uri, freshCreds)
270+
: (AdoConnection)new TfsConfigurationServer(cachedConnection.Uri, freshCreds);
271+
#endif
272+
var newConnection = new Connection(refreshedConn);
273+
newConnection.Connect();
274+
275+
// Update the cached connection
276+
if (scope == ClientScope.Collection)
277+
CurrentConnections.Collection = newConnection;
278+
else
279+
CurrentConnections.Server = newConnection;
280+
281+
Logger.Log("Azure Login token refreshed successfully.");
282+
result = newConnection.InnerObject;
283+
}
284+
else
285+
{
286+
result = cachedConnection?.InnerObject;
287+
}
255288

256289
break;
257290
}

0 commit comments

Comments
 (0)