Skip to content

Commit

Permalink
Fix bugs in Get-TfsWorkItem, Get-TfsArea, Get-TfsIteration, Invoke-Tf…
Browse files Browse the repository at this point in the history
…sRestApi, New-TfsTeam and Set-TfsTeam. (#214)

* Update VS Code settings

* fix: Improve handling of response when content-type is missing

* chore: Improve readability

* fix: Deal with team projects with reserved regex chars

* fix: Issue "Get-TfsWorkItem : Value cannot be null. Parameter name: values" when specifying -Fields '*' #211

* fix: Handling of node paths in Get-TfsWorkItem

* Update release notes

* Update release notes

* Update release notes

* chore: Update CodeQL action to v3

* Add SECURITY.md

* chore: Update SECURITY.md

* fix: Adjust syntax for building in PS Core

* chore: Update tests

* fix: Wildcard handling

* chore: Update security report link

* chore: Upgrade WiX to v5

* fix: Correct variable name

* fix: Typo

* chore: Simplify WiX code

* chore: Update release notes
  • Loading branch information
igoravl authored May 15, 2024
1 parent 276cfc9 commit a8ad84b
Show file tree
Hide file tree
Showing 16 changed files with 220 additions and 275 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
with:
fetch-depth: 0
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: csharp
- name: Build module
Expand All @@ -35,7 +35,7 @@ jobs:
run: |
./Build.ps1 -Targets Package -Config ${{ env.Config }} -Verbose:$${{ env.Debug }} -SkipReleaseNotes:$${{ env.SkipReleaseNotes }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3
- name: Publish Nuget
uses: actions/upload-artifact@v3
with:
Expand Down
3 changes: 1 addition & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@
"Import-Module ${workspaceFolder}/out/module/TfsCmdlets.psd1; Enter-TfsShell"
],
"cwd": "${workspaceFolder}",
"console": "externalTerminal",
"preLaunchTask": "Build"
"console": "externalTerminal"
}
]
}
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,6 @@
"titleBar.inactiveBackground": "#4b008299",
"titleBar.inactiveForeground": "#e7e7e799"
},
"peacock.color": "Indigo"
"peacock.color": "Indigo",
"dotnet.preferCSharpExtension": true
}
27 changes: 13 additions & 14 deletions CSharp/TfsCmdlets.Common/Services/Impl/NodeUtilImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ public string NormalizeNodePath(string path, string projectName = "", string sco
bool includeTrailingSeparator = false, bool includeTeamProject = false, char separator = '\\')
{
if (path == null) throw new ArgumentNullException("path");
//if (projectName == null) throw new ArgumentNullException("projectName");
if (includeTeamProject && string.IsNullOrEmpty(projectName)) throw new ArgumentNullException("projectName");
if (includeScope && string.IsNullOrEmpty(scope)) throw new ArgumentNullException("scope");
if (excludePath && !includeScope && !includeTeamProject) throw new ArgumentException("excludePath is only valid when either includeScope or includeTeamProject are true");
Expand All @@ -29,28 +28,28 @@ public string NormalizeNodePath(string path, string projectName = "", string sco

if (!excludePath)
{
if (path.Equals(projectName) || path.StartsWith($@"{projectName}{separator}"))
if (path.Equals(projectName, StringComparison.OrdinalIgnoreCase) || path.StartsWith($@"{projectName}{separator}", StringComparison.OrdinalIgnoreCase))
{
if (Regex.IsMatch(path, $@"^{projectName}\{separator}{scope}\{separator}"))
{
path = path.Substring($"{projectName}{separator}{scope}{separator}".Length);
}
if (Regex.IsMatch(path, $@"^{projectName}\{separator}"))
{
path = path.Substring($"{projectName}{separator}".Length);
}
else if (path.Equals(projectName, StringComparison.OrdinalIgnoreCase))
if (path.Equals(projectName, StringComparison.OrdinalIgnoreCase))
{
path = "";
}
else{
var escapedProject = Regex.Escape(projectName);
var escapedScope = Regex.Escape(scope);
var escapedSep = Regex.Escape(separator.ToString());
var pattern = $@"^{escapedProject}{escapedSep}({escapedScope}{separator}?)?";

path = Regex.Replace(path, pattern, "");
}
}
else if (path.Equals(scope) || path.StartsWith($"{scope}{separator}"))
else if (path.Equals(scope, StringComparison.OrdinalIgnoreCase) || path.StartsWith($"{scope}{separator}", StringComparison.OrdinalIgnoreCase))
{
if (Regex.IsMatch(path, $@"^{scope}\{separator}"))
if(path.Length > scope.Length)
{
path = path.Substring(path.IndexOf(separator) + 1);
}
else if (path.Equals(scope, StringComparison.OrdinalIgnoreCase))
else
{
path = "";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ protected override IEnumerable Run()
yield break;
}

var responseType = result.Content.Headers.ContentType.MediaType;
var responseType = result.Content.Headers.ContentType?.MediaType;

switch (responseType)
{
Expand Down
4 changes: 3 additions & 1 deletion CSharp/TfsCmdlets/Controllers/Team/SetTeamController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ protected override IEnumerable Run()
if (usesAreaPath)
{
Logger.Log("Treating Team Field Value as Area Path");
DefaultAreaPath = NodeUtil.NormalizeNodePath(DefaultAreaPath, Project.Name, "Areas", includeTeamProject: true);
DefaultAreaPath = NodeUtil.NormalizeNodePath(DefaultAreaPath, Project.Name, "Areas",
includeTeamProject: true,
includeLeadingSeparator: true);

var a = new { Node = DefaultAreaPath, StructureGroup = TreeStructureGroup.Areas };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,22 @@ protected override IEnumerable Run()
}
case string s when !string.IsNullOrEmpty(s) && s.IsWildcard():
{
path = NodeUtil.NormalizeNodePath(s, tp.Name, structureGroup.ToString().TrimEnd('s'), true, false, true, false, true);
path = NodeUtil.NormalizeNodePath(s, tp.Name, structureGroup.ToString().TrimEnd('s'),
includeScope: true,
excludePath: false,
includeLeadingSeparator: true,
includeTrailingSeparator: false,
includeTeamProject: true);
break;
}
case string s when !string.IsNullOrEmpty(s):
{
path = NodeUtil.NormalizeNodePath(s, tp.Name, structureGroup.ToString().TrimEnd('s'), false, false, true, false, false);
path = NodeUtil.NormalizeNodePath(s, tp.Name, structureGroup.ToString().TrimEnd('s'),
includeScope: false,
excludePath: false,
includeLeadingSeparator: true,
includeTrailingSeparator: false,
includeTeamProject: false);
break;
}
default:
Expand Down
64 changes: 39 additions & 25 deletions CSharp/TfsCmdlets/Controllers/WorkItem/GetWorkItemController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ partial class GetWorkItemController
[Import]
private IProcessUtil ProcessUtil { get; set; }

[Import]
private INodeUtil NodeUtil { get; set; }

private const int MAX_WORKITEMS = 200;

protected override IEnumerable Run()
Expand All @@ -31,10 +34,11 @@ protected override IEnumerable Run()
throw new ArgumentException($"'{Parameters.Get<object>("Project")}' is not a valid project, which is required to execute a saved query. Either supply a valid -Project argument or use Connect-TfsTeamProject prior to invoking this cmdlet.");
}

if (!Deleted && Fields.Length > 0 && Fields[0] != "*")
if (!Deleted && Fields.Length > 0)
{
expand = IncludeLinks ? WorkItemExpand.All : (ShowWindow ? WorkItemExpand.Links : WorkItemExpand.None);
fields = FixWellKnownFields(Fields);
expand = IncludeLinks ? WorkItemExpand.All : (
ShowWindow ? WorkItemExpand.Links : Fields[0] == "*" ? WorkItemExpand.Fields : WorkItemExpand.None);
fields = FixWellKnownFields(Fields).ToList();
}

var ids = new List<int>();
Expand Down Expand Up @@ -204,19 +208,19 @@ private IEnumerable<WebApiWorkItem> GetWorkItemsById(IEnumerable<int> ids, DateT
yield break;
}

if(idList.Count <= MAX_WORKITEMS)
if (idList.Count <= MAX_WORKITEMS)
{
var wis = client.GetWorkItemsAsync(idList, fields, asOf, expand, WorkItemErrorPolicy.Fail)
.GetResult("Error getting work items");

foreach(var wi in wis) yield return wi;
foreach (var wi in wis) yield return wi;

yield break;
}

Logger.LogWarn($"Your query resulted in {idList.Count} work items, therefore items must be fetched one at a time. This may take a while. For best performance, write queries that return less than 200 items.");

foreach(var id in idList)
foreach (var id in idList)
{
yield return GetWorkItemById(id, expand, fields, client);
}
Expand Down Expand Up @@ -312,33 +316,23 @@ private IEnumerable<string> FixWellKnownFields(IEnumerable<string> fields)

private string BuildSimpleQuery(IEnumerable<string> fields)
{
var sb = new StringBuilder();

sb.Append($"SELECT {string.Join(", ", fields)} FROM WorkItems Where");

var hasCriteria = false;
var criteria = new List<string>();
StringBuilder sb;

foreach (var kvp in SimpleQueryFields)
{
if (!Parameters.HasParameter(kvp.Key)) continue;

sb = new StringBuilder();
var paramValue = Parameters.Get<object>(kvp.Key);

if (hasCriteria)
{
sb.Append(" AND ");
}
else
{
sb.Append(" ");
hasCriteria = true;
}

switch (kvp.Value.Item1)
{
case "Text":
{
var values = ((paramValue as IEnumerable<string>) ?? (IEnumerable<string>)new[] { (string)paramValue }).ToList();
if (values.Count == 0) continue;

sb.Append("(");
for (int i = 0; i < values.Count; i++)
{
Expand All @@ -352,6 +346,8 @@ private string BuildSimpleQuery(IEnumerable<string> fields)
case "LongText":
{
var values = ((paramValue as IEnumerable<string>) ?? (IEnumerable<string>)new[] { (string)paramValue }).ToList();
if (values.Count == 0) continue;

sb.Append("(");
for (int i = 0; i < values.Count; i++)
{
Expand All @@ -365,6 +361,8 @@ private string BuildSimpleQuery(IEnumerable<string> fields)
case "Number":
{
var values = ((paramValue as IEnumerable<int>) ?? (IEnumerable<int>)new[] { (int)paramValue }).ToList();
if (values.Count == 0) continue;

var op = Ever ? "ever" : "=";
sb.Append("(");
sb.Append(string.Join(" OR ", values.Select(v => $"([{kvp.Value.Item2}] {op} {v})")));
Expand All @@ -374,6 +372,8 @@ private string BuildSimpleQuery(IEnumerable<string> fields)
case "Date":
{
var values = ((paramValue as IEnumerable<DateTime>) ?? (IEnumerable<DateTime>)new[] { (DateTime)paramValue }).ToList();
if (values.Count == 0) continue;

var op = Ever ? "ever" : "=";
var format = $"yyyy-MM-dd HH:mm:ss{(TimePrecision ? "HH:mm:ss" : "")}";
sb.Append("(");
Expand All @@ -383,15 +383,22 @@ private string BuildSimpleQuery(IEnumerable<string> fields)
}
case "Tree":
{
var values = ((paramValue as IEnumerable<string>) ?? (IEnumerable<string>)new[] { (string)paramValue }).ToList();
var values = ((paramValue as IEnumerable<string>) ?? (IEnumerable<string>)new[] { (string)paramValue })
.Where(v => !string.IsNullOrEmpty(v) && !v.Equals("\\")).ToList();
if (values.Count == 0) continue;

var projectName = Project.Name;

sb.Append("(");
sb.Append(string.Join(" OR ", values.Select(v => $"([{kvp.Value.Item2}] UNDER '{v}')")));
sb.Append(string.Join(" OR ", values.Select(v => $"([{kvp.Value.Item2}] UNDER '{NodeUtil.NormalizeNodePath(v, projectName, includeTeamProject: true, includeLeadingSeparator: false, includeTrailingSeparator: false)}')")));
sb.Append(")");
break;
}
case "Boolean":
{
var values = ((paramValue as IEnumerable<bool>) ?? (IEnumerable<bool>)new[] { (bool)paramValue }).ToList();
if (values.Count == 0) continue;

var op = Ever ? "ever" : "=";
sb.Append("(");
sb.Append(string.Join(" OR ", values.Select(v => $"([{kvp.Value.Item2}] {op} {v})")));
Expand All @@ -407,13 +414,20 @@ private string BuildSimpleQuery(IEnumerable<string> fields)
break;
}
}

if (sb.Length > 0)
{
criteria.Add(sb.ToString());
}
}

if (!hasCriteria)
if (criteria.Count == 0)
{
throw new ArgumentException("No filter arguments have been specified. Unable to perform a simple query.");
}

sb = new StringBuilder($"SELECT {string.Join(", ", fields)} FROM WorkItems WHERE {string.Join(" AND ", criteria)}");

if (Has_AsOf)
{
var asOf = AsOf.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ssZ");
Expand Down
15 changes: 15 additions & 0 deletions Docs/ReleaseNotes/2.6.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# TfsCmdlets Release Notes

## Version 2.6.1 (_15/May/2024_)

Ouch! It's been a while since the last release! Sometimes life gets in the way, but I'm back!

This release fixes bugs in `Get-TfsWorkItem`, `Get-TfsArea`, `Get-TfsIteration`, `Invoke-TfsRestApi`, `New-TfsTeam` and `Set-TfsTeam`.

## Fixes

* Fixes [#211](https://github.com/igoravl/TfsCmdlets/issues/211), where `Get-TfsWorkItem` would throw an error when the `-Fields` parameter was "*".
* Fixes a bug in `Invoke-TfsRestApi` where Azure DevOps APIs whose responses were missing the `content-type` header would throw an error.
* Fixes a bug in `Get-TfsArea` and `Get-TfsIteration` where team projects containing Regex-reserved characters (such as parentheses) would throw an error. This bug would indirectly affect `New-TfsTeam` and `Set-TfsTeam` due to their reliance on the same underlying class to handle area and iteration paths when creating/updating teams.
* Fixes a bug in `Get-TfsWorkItem` where the `-AreaPath` and `-IterationPath` parameters would not work when the specified path either started with a backslash or did not contain the team project name.
* Adds the installed module version to the _Azure DevOps Shell_ startup command to prevent loading an older version of the module when the PSModulePath variable contains an older version of the module listed earlier in the search path.
4 changes: 2 additions & 2 deletions PS/_Tests/RestApi.Invoke-TfsRestApi.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ Describe (($MyInvocation.MyCommand.Name -split '\.')[-3]) {
| ForEach-Object { $_.ExtensionName } `
| Sort-Object `
| Select-Object -First 3 `
| Should -Be @('Aex Code Mapper', 'Aex platform', 'Aex user management')
| Should -Be @('AdvancedSecurity', 'Aex Code Mapper', 'Aex platform')
}

It 'Should call multiple alternates hosts in sequence' {
Invoke-TfsRestApi '/_apis/extensionmanagement/installedextensions' -UseHost 'extmgmt.dev.azure.com' -ApiVersion '6.1' `
| ForEach-Object { $_.ExtensionName } `
| Sort-Object `
| Select-Object -First 3 `
| Should -Be @('Aex Code Mapper', 'Aex platform', 'Aex user management')
| Should -Be @('AdvancedSecurity', 'Aex Code Mapper', 'Aex platform')

Invoke-TfsRestApi 'GET https://vsrm.dev.azure.com/{organization}/{project}/_apis/release/definitions?api-version=6.1' -Project $tfsProject `
| ForEach-Object { $_.name } `
Expand Down
38 changes: 12 additions & 26 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -1,41 +1,27 @@
# TfsCmdlets Release Notes

## Version 2.6.0 (_30/Sep/2022_)
## Version 2.6.1 (_15/May/2024_)

This release fixes a bug in `Get-TfsWorkItemQuery` and `Get-TfsWorkItemQueryFolder`, and adds two new cmdlets.
Ouch! It's been a while since the last release! Sometimes life gets in the way, but I'm back!

## New cmdlets

* `Undo-TfsWorkItemQueryRemoval` and `Undo-TfsWorkItemQueryFolderRemoval` allow you to undo the deletion of a query or query folder. This is useful when you accidentally delete a query or query folder and want to restore it.

To restore a deleted query:

```powershell
# You can either pipe the deleted query from Get-TfsWorkItemQuery to Undo-TfsWorkItemQueryRemoval...
Get-TfsWorkItemQuery 'My Deleted Query' -Scope Personal -Deleted | Undo-TfsWorkItemQueryRemoval
# ... or you can specify the query directly when calling Undo-TfsWorkItemQueryRemoval
Undo-TfsWorkItemQueryRemoval 'My Deleted Query' -Scope Personal
```

The same applies to query folders - with the distinction that folder can be restored recursively by specifying the `-Recursive` switch. When `-Recursive` is omitted, only the folder itself is restored, without any of its contents. You can then restore its contents by issuing further calls to `Undo-TfsWorkItemQueryRemoval` and/or `Undo-TfsWorkItemQueryFolderRemoval`.

```powershell
# You can either pipe the deleted folder from Get-TfsWorkItemQueryFolder to Undo-TfsWorkItemQueryFolderRemoval...
Get-TfsWorkItemQueryFolder 'My Deleted Folder' -Scope Personal -Deleted | Undo-TfsWorkItemQueryRemoval -Recursive
# ... or you can specify the folder directly when calling Undo-TfsWorkItemQueryFolderRemoval
Undo-TfsWorkItemQueryFolderRemoval 'My Deleted Folder' -Scope Personal -Recursive
```
This release fixes bugs in `Get-TfsWorkItem`, `Get-TfsArea`, `Get-TfsIteration`, `Invoke-TfsRestApi`, `New-TfsTeam` and `Set-TfsTeam`.

## Fixes

* Fixes a bug in `Get-TfsWorkItemQuery` and `Get-TfsWorkItemQueryFolder` where the `-Deleted` switch was not respected and deleted items would not be returned.
* Fixes [#211](https://github.com/igoravl/TfsCmdlets/issues/211), where `Get-TfsWorkItem` would throw an error when the `-Fields` parameter was "*".
* Fixes a bug in `Invoke-TfsRestApi` where Azure DevOps APIs whose responses were missing the `content-type` header would throw an error.
* Fixes a bug in `Get-TfsArea` and `Get-TfsIteration` where team projects containing Regex-reserved characters (such as parentheses) would throw an error. This bug would indirectly affect `New-TfsTeam` and `Set-TfsTeam` due to their reliance on the same underlying class to handle area and iteration paths when creating/updating teams.
* Fixes a bug in `Get-TfsWorkItem` where the `-AreaPath` and `-IterationPath` parameters would not work when the specified path either started with a backslash or did not contain the team project name.
* Adds the installed module version to the _Azure DevOps Shell_ startup command to prevent loading an older version of the module when the PSModulePath variable contains an older version of the module listed earlier in the search path.

-----------------------

## Previous Versions

### Version 2.6.0 (_30/Sep/2022_)

See release notes [here](Docs/ReleaseNotes/2.6.0.md).

### Version 2.5.1 (_22/Aug/2022_)

See release notes [here](Docs/ReleaseNotes/2.5.1.md).
Expand Down
Loading

0 comments on commit a8ad84b

Please sign in to comment.