Skip to content

Commit 67c5388

Browse files
authored
Bits from the GitHub Query sandbox app (#249)
* Bits from the GitHub Query sandbox app * Add .sln, and fix warnings from other projects * Line endings * Fix unicode issue
1 parent f1731e4 commit 67c5388

9 files changed

+246
-60
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Octokit" Version="7.1.0" />
12+
<PackageReference Include="Spectre.Console" Version="0.47.1-preview.0.11" />
13+
</ItemGroup>
14+
15+
</Project>

GitHub.QuerySandbox/GlobalUsings.cs

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
global using System.Text;
2+
3+
global using GitHub.QuerySandbox.Spinners;
4+
5+
global using Octokit;
6+
global using Octokit.Internal;
7+
8+
global using Spectre.Console;
9+
global using SpectreEmoji = Spectre.Console.Emoji;

GitHub.QuerySandbox/Program.cs

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
Console.InputEncoding = Console.OutputEncoding = Encoding.UTF8;
2+
3+
// The GitHub repository to query, for example; dotnet/docs.
4+
string owner = "dotnet";
5+
string name = "docs";
6+
7+
// Try getting the GitHub Action repo.
8+
string? repository = Environment.GetEnvironmentVariable("GITHUB_REPOSITORY");
9+
if (repository is { Length: > 0 } && repository.Contains('/'))
10+
{
11+
var repo = repository.Split('/', StringSplitOptions.RemoveEmptyEntries);
12+
if (repo.Length is 2)
13+
{
14+
owner = repo[0];
15+
name = repo[1];
16+
}
17+
}
18+
19+
// Start reporting status.
20+
await AnsiConsole.Status()
21+
.Spinner(Spinner.Known.Smiley)
22+
.StartAsync($"{SpectreEmoji.Known.NerdFace} Querying the [bold grey]GitHub API[/] for answers...", async (ctx) =>
23+
{
24+
AnsiConsole.MarkupLine(
25+
$"{SpectreEmoji.Known.Gear} Configured to query the [link=https://github.com/{owner}/{name}][bold aqua]{owner}/{name}[/][/] repo...");
26+
27+
// Create a GitHub client to use given the token in environment variables.
28+
var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
29+
var credentials = new InMemoryCredentialStore(new Credentials(token));
30+
var client = new GitHubClient(
31+
new ProductHeaderValue("GitHub.QuerySandbox"), credentials);
32+
33+
AnsiConsole.MarkupLine(
34+
$"{SpectreEmoji.Known.ExclamationQuestionMark} Requesting all [italic dim]pull requests[/] from the [bold aqua]{owner}/{name}[/] for the past year...");
35+
ctx.Spinner = new TimeTravelSpinner();
36+
37+
// Get all issues for a repository, for the past year.
38+
var issues = await client.Issue.GetAllForRepository(
39+
owner, name, new RepositoryIssueRequest
40+
{
41+
Since = DateTimeOffset.Now.AddYears(-1),
42+
State = ItemStateFilter.All
43+
});
44+
45+
static string LabelNameSelector(Label label)
46+
{
47+
return label.Name;
48+
}
49+
50+
AnsiConsole.MarkupLine(
51+
$"{SpectreEmoji.Known.Label} Requesting all [italic dim]labels[/] from the [bold aqua]{owner}/{name}[/] repo...");
52+
ctx.Spinner = Spinner.Known.Monkey;
53+
54+
// Get all the labels from the returned issues, as a distinct set.
55+
var issueLabels =
56+
issues.SelectMany(i => i.Labels).DistinctBy(LabelNameSelector);
57+
58+
// Get all the labels from the repository.
59+
var allLabels =
60+
await client.Issue.Labels.GetAllForRepository(owner, name);
61+
62+
// List all of the labels that haven't been used in the past year.
63+
var unusedLabels = allLabels.ExceptBy(
64+
issueLabels.Select(LabelNameSelector), LabelNameSelector);
65+
66+
static void ConfigureTableColumn(TableColumn column)
67+
{
68+
column.Alignment(Justify.Left)
69+
.Padding(new Padding(2));
70+
}
71+
72+
var table = new Table()
73+
.Border(TableBorder.Heavy)
74+
.BorderColor(Color.Fuchsia)
75+
.Expand()
76+
.AddColumn("[bold invert]Label[/]", ConfigureTableColumn)
77+
.AddColumn("[bold invert]Id[/]", ConfigureTableColumn);
78+
79+
var total = 0;
80+
foreach (var label in unusedLabels.OrderBy(LabelNameSelector))
81+
{
82+
table.AddRow(label.Name, label.Id.ToString());
83+
++ total;
84+
}
85+
86+
AnsiConsole.MarkupLine(
87+
$"{SpectreEmoji.Known.Bullseye} Found {total} unused labels in the [bold aqua]{owner}/{name}[/] repo...");
88+
89+
table.Title = new TableTitle(
90+
$"{total} Unused GitHub Labels (Within The Last Year)", Color.LightGreen);
91+
92+
AnsiConsole.WriteLine();
93+
AnsiConsole.Write(table);
94+
95+
var chart = new BarChart()
96+
.Label("[green bold underline]Label Totals[/]")
97+
.CenterLabel()
98+
.AddItem("Used Labels", allLabels.Count - total, Color.Green)
99+
.AddItem("Unused Labels", total, Color.DarkOrange);
100+
101+
AnsiConsole.WriteLine();
102+
AnsiConsole.Write(chart);
103+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace GitHub.QuerySandbox.Spinners;
2+
3+
internal sealed class TimeTravelSpinner : Spinner
4+
{
5+
public override TimeSpan Interval => TimeSpan.FromMilliseconds(100);
6+
public override bool IsUnicode => true;
7+
public override IReadOnlyList<string> Frames => new List<string>
8+
{
9+
"🕚 ",
10+
"🕙 ",
11+
"🕘 ",
12+
"🕗 ",
13+
"🕖 ",
14+
"🕕 ",
15+
"🕔 ",
16+
"🕓 ",
17+
"🕒 ",
18+
"🕑 ",
19+
"🕐 ",
20+
"🕛 ",
21+
};
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
2+
Microsoft Visual Studio Solution File, Format Version 12.00
3+
# Visual Studio Version 17
4+
VisualStudioVersion = 17.7.33920.267
5+
MinimumVisualStudioVersion = 10.0.40219.1
6+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.QuerySandbox", "GitHub.QuerySandbox.csproj", "{54D4AC77-C0F9-4FA5-A419-39D1B2D96652}"
7+
EndProject
8+
Global
9+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
10+
Debug|Any CPU = Debug|Any CPU
11+
Release|Any CPU = Release|Any CPU
12+
EndGlobalSection
13+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
14+
{54D4AC77-C0F9-4FA5-A419-39D1B2D96652}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
15+
{54D4AC77-C0F9-4FA5-A419-39D1B2D96652}.Debug|Any CPU.Build.0 = Debug|Any CPU
16+
{54D4AC77-C0F9-4FA5-A419-39D1B2D96652}.Release|Any CPU.ActiveCfg = Release|Any CPU
17+
{54D4AC77-C0F9-4FA5-A419-39D1B2D96652}.Release|Any CPU.Build.0 = Release|Any CPU
18+
EndGlobalSection
19+
GlobalSection(SolutionProperties) = preSolution
20+
HideSolutionNode = FALSE
21+
EndGlobalSection
22+
GlobalSection(ExtensibilityGlobals) = postSolution
23+
SolutionGuid = {D79C8B19-C1D4-4069-9943-BFF249270EC1}
24+
EndGlobalSection
25+
EndGlobal

actions/sequester/Quest2GitHub/Models/IssueExtensions.cs

+12-13
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,20 @@ month descending
2828
return sizes.FirstOrDefault();
2929
}
3030

31-
public static int? QuestStoryPoint(this StoryPointSize storyPointSize)
32-
{
33-
if (storyPointSize.Size.Length < 6)
34-
return null;
35-
36-
return storyPointSize.Size.Substring(0, 6) switch
31+
public static int? QuestStoryPoint(this StoryPointSize storyPointSize) =>
32+
storyPointSize.Size.Length switch
3733
{
38-
"🦔 Tiny" => 1,
39-
"🐇 Smal" => 3,
40-
"🐂 Medi" => 5,
41-
"🦑 Larg" => 8,
42-
"🐋 X-La" => 13,
43-
_ => null,
34+
< 6 => null,
35+
_ => storyPointSize.Size[..6] switch
36+
{
37+
"🦔 Tiny" => 1,
38+
"🐇 Smal" => 3,
39+
"🐂 Medi" => 5,
40+
"🦑 Larg" => 8,
41+
"🐋 X-La" => 13,
42+
_ => null,
43+
}
4444
};
45-
}
4645

4746
public static QuestIteration? ProjectIteration(this StoryPointSize storyPoints, IEnumerable<QuestIteration> iterations)
4847
{

actions/sequester/Quest2GitHub/Models/QuestWorkItem.cs

+18-12
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public class QuestWorkItem
6363
/// This is retrieved from /Microsoft.VSTS.Scheduling.StoryPoints
6464
/// </remarks>
6565
public required int? StoryPoints { get; init; }
66-
66+
6767
/// <summary>
6868
/// Create a work item object from the ID
6969
/// </summary>
@@ -91,8 +91,8 @@ public static async Task<QuestWorkItem> QueryWorkItem(QuestClient client, int wo
9191
/// Finally, create the work item object from the returned
9292
/// Json element.
9393
/// </remarks>
94-
public static async Task<QuestWorkItem> CreateWorkItemAsync(GithubIssue issue,
95-
QuestClient questClient,
94+
public static async Task<QuestWorkItem> CreateWorkItemAsync(GithubIssue issue,
95+
QuestClient questClient,
9696
OspoClient ospoClient,
9797
string path,
9898
string? requestLabelNodeId,
@@ -200,7 +200,8 @@ public static async Task<QuestWorkItem> CreateWorkItemAsync(GithubIssue issue,
200200
{
201201
result = await questClient.CreateWorkItem(patchDocument);
202202
newItem = WorkItemFromJson(result);
203-
} catch (InvalidOperationException)
203+
}
204+
catch (InvalidOperationException)
204205
{
205206
Console.WriteLine(result.ToString());
206207
// This will happen when the assignee IS a Microsoft FTE,
@@ -273,20 +274,25 @@ public static string BuildDescriptionFromIssue(GithubIssue issue, string? reques
273274
var newItem = QuestWorkItem.WorkItemFromJson(jsonDocument);
274275
linkedGitHubRepo = true;
275276
return newItem;
276-
} catch (InvalidOperationException ex)
277+
}
278+
catch (InvalidOperationException ex)
277279
{
278-
Console.WriteLine("Can't add closing PR. The GitHub repo is likely not configured as linked in Quest.");
280+
Console.WriteLine($"""
281+
Can't add closing PR. The GitHub repo is likely not configured as linked in Quest.
282+
Exception: {ex}
283+
""");
284+
279285
linkedGitHubRepo = false;
280286
return null;
281287
}
282288
}
283289

284-
/// <summary>
285-
/// Construct a work item from the JSON document.
286-
/// </summary>
287-
/// <param name="root">The root element.</param>
288-
/// <returns>The Quest work item.</returns>
289-
public static QuestWorkItem WorkItemFromJson(JsonElement root)
290+
/// <summary>
291+
/// Construct a work item from the JSON document.
292+
/// </summary>
293+
/// <param name="root">The root element.</param>
294+
/// <returns>The Quest work item.</returns>
295+
public static QuestWorkItem WorkItemFromJson(JsonElement root)
290296
{
291297
var id = root.GetProperty("id").GetInt32();
292298
var fields = root.GetProperty("fields");

actions/sequester/Quest2GitHub/Options/EnvironmentVariableReader.cs

+10-6
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,21 @@ static string CoalesceEnvVar((string preferredKey, string fallbackKey) keys, boo
2727
{
2828
var (preferredKey, fallbackKey) = keys;
2929

30+
// Attempt the preferred key first.
3031
var value = Environment.GetEnvironmentVariable(preferredKey);
3132
if (string.IsNullOrWhiteSpace(value))
3233
{
34+
// If the preferred key is not set, try the fallback key.
3335
value = Environment.GetEnvironmentVariable(fallbackKey);
34-
if (string.IsNullOrWhiteSpace(value) && required)
35-
{
36-
throw new Exception(
37-
$"Missing env var, checked for both: {preferredKey} and {fallbackKey}.");
38-
}
3936
}
4037

41-
return value;
38+
// If neither key is set, throw an exception if required.
39+
if (string.IsNullOrWhiteSpace(value) && required)
40+
{
41+
throw new Exception(
42+
$"Missing env var, checked for both: {preferredKey} and {fallbackKey}.");
43+
}
44+
45+
return value!;
4246
}
4347
}

0 commit comments

Comments
 (0)